From 0baedbc99efbde12be7910ffd0042c0ecff8cd4c Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 18 Sep 2026 21:10:50 +0800 Subject: [PATCH] feat: redesign habit detail paper flow --- frontend/e2e/habit-detail-paper-flow.spec.ts | 155 +++++++++++++++++++ frontend/src/MvpPanel.vue | 46 +++++- frontend/src/lib/mvp-utils.test.ts | 20 ++- frontend/src/lib/mvp-utils.ts | 57 +++++++ frontend/src/style.css | 5 + frontend/src/style.test.ts | 49 +++++- 6 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 frontend/e2e/habit-detail-paper-flow.spec.ts diff --git a/frontend/e2e/habit-detail-paper-flow.spec.ts b/frontend/e2e/habit-detail-paper-flow.spec.ts new file mode 100644 index 0000000..846931e --- /dev/null +++ b/frontend/e2e/habit-detail-paper-flow.spec.ts @@ -0,0 +1,155 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test } from './fixtures' + +async function csrf(request: APIRequestContext) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' +} + +async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters[1]) { + return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) +} + +function shanghaiDay(offset = 0) { + const instant = new Date(Date.now() + offset * 86_400_000) + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(instant) + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)!.value + return `${value('year')}-${value('month')}-${value('day')}` +} + +async function createHabit(request: APIRequestContext, baseURL: string, name: string, kind: 'boolean' | 'numeric', target = 1, extra: Record = {}) { + const response = await mutate(request, baseURL, '/api/v1/habits', { + method: 'POST', data: { name, kind, target, max_value: kind === 'numeric' ? target * 2 : 1, schedule_type: 'daily', ...extra }, + }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() as Promise<{ id: string }> +} + +async function openHabits(page: Page) { + await page.goto('/') + if ((page.viewportSize()?.width ?? 0) <= 930) { + await page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name: '习惯', exact: true }).click() + } else { + await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click() + } +} + +async function expectNoHorizontalOverflow(page: Page) { + const widths = await page.evaluate(() => ({ client: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth })) + expect(widths.scroll).toBeLessThanOrEqual(widths.client) +} + +test('habit detail paper flow is responsive, ordered, scrollable, and preserves overlay contracts', async ({ page, request, baseURL }, testInfo) => { + const nonce = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + const numericName = `纸页数字-${nonce}` + const booleanName = `纸页布尔-${nonce}` + const archivedName = `纸页归档-${nonce}` + const numeric = await createHabit(request, baseURL!, numericName, 'numeric', 100) + const boolean = await createHabit(request, baseURL!, booleanName, 'boolean') + const archived = await createHabit(request, baseURL!, archivedName, 'numeric', 7, { start_date: shanghaiDay(-30) }) + for (const [habit, value] of [[numeric, 68], [boolean, 1]] as const) { + const response = await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${shanghaiDay()}`, { method: 'PUT', data: { value } }) + expect(response.ok(), await response.text()).toBeTruthy() + } + for (let offset = 0; offset > -24; offset -= 1) { + const response = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}/logs/${shanghaiDay(offset)}`, { method: 'PUT', data: { value: Math.abs(offset % 8) } }) + expect(response.ok(), await response.text()).toBeTruthy() + } + const archiveResponse = await mutate(request, baseURL!, `/api/v1/habits/${archived.id}`, { method: 'DELETE' }) + expect(archiveResponse.ok(), await archiveResponse.text()).toBeTruthy() + + await openHabits(page) + await expectNoHorizontalOverflow(page) + + const numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` }) + await numericOpener.click() + let dialog = page.getByRole('dialog', { name: numericName }) + await expect(dialog.locator('.habit-detail-hero')).toContainText('68 / 100') + await expect(dialog.locator('.habit-detail-progress-row')).toContainText('68%') + await expect(dialog.locator('.habit-detail-archive-note')).toHaveCount(0) + if ((page.viewportSize()?.width ?? 0) <= 930) { + const originalViewport = page.viewportSize()! + await page.setViewportSize({ width: 720, height: 900 }) + const archiveButton = dialog.getByRole('button', { name: '归档习惯' }) + const archiveBox = await archiveButton.boundingBox() + expect(archiveBox).not.toBeNull() + expect(archiveBox!.width).toBeGreaterThanOrEqual(44) + expect(archiveBox!.height).toBeGreaterThanOrEqual(44) + await page.setViewportSize(originalViewport) + } + const activeOrder = await dialog.evaluate(element => { + const selectors = ['.habit-detail-header', '.habit-detail-hero', '.habit-detail-progress-row', '.habit-detail-meta', '.habit-history', '.habit-detail-active-actions'] + const nodes = selectors.map(selector => element.querySelector(selector)!) + return nodes.slice(0, -1).every((node, index) => Boolean(node.compareDocumentPosition(nodes[index + 1]) & Node.DOCUMENT_POSITION_FOLLOWING)) + }) + expect(activeOrder).toBe(true) + await dialog.getByRole('button', { name: '关闭习惯详情' }).click() + await expect(numericOpener).toBeFocused() + + const booleanOpener = page.locator('.habit-row').filter({ hasText: booleanName }).getByRole('button', { name: `查看习惯详情:${booleanName}` }) + await booleanOpener.click() + dialog = page.getByRole('dialog', { name: booleanName }) + await expect(dialog.locator('.habit-detail-hero')).toContainText('已完成') + await dialog.getByRole('button', { name: '关闭习惯详情' }).click() + + const archiveToggle = page.locator('.habit-archive-toggle') + await archiveToggle.click() + const archivedOpener = page.getByRole('button', { name: new RegExp(archivedName) }) + await archivedOpener.click() + dialog = page.getByRole('dialog', { name: archivedName }) + await expect(dialog).toHaveAttribute('aria-labelledby', 'habit-detail-title') + await expect(dialog.locator('.habit-detail-archive-note')).toHaveText('此习惯已归档,历史记录仍完整保留。') + await expect(dialog.locator('.habit-detail-hero')).toHaveCount(0) + await expect(dialog.locator('.habit-detail-progress-row')).toHaveCount(0) + await expect(dialog).not.toContainText('今日进度') + const order = await dialog.evaluate(element => { + const note = element.querySelector('.habit-detail-archive-note')! + const header = element.querySelector('.habit-detail-header')! + return Boolean(note.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING) + }) + expect(order).toBe(true) + + const body = dialog.locator('.habit-detail-body') + const footer = dialog.locator('.habit-detail-archived-actions') + await expect(footer).toBeInViewport() + const before = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()]) + const scroll = await body.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop })) + expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight) + await body.evaluate(element => { element.scrollTop = element.scrollHeight }) + await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + const after = await Promise.all([dialog.locator('.habit-detail-header').boundingBox(), footer.boundingBox()]) + expect(after[0]?.y).toBeCloseTo(before[0]!.y, 0) + expect(after[1]?.y).toBeCloseTo(before[1]!.y, 0) + await expectNoHorizontalOverflow(page) + + if ((page.viewportSize()?.width ?? 0) <= 930) { + for (const button of [dialog.getByRole('button', { name: '关闭习惯详情' }), dialog.getByRole('button', { name: '永久删除' }), dialog.getByRole('button', { name: '恢复习惯' })]) { + const box = await button.boundingBox() + expect(box).not.toBeNull() + expect(box!.width).toBeGreaterThanOrEqual(44) + expect(box!.height).toBeGreaterThanOrEqual(44) + } + } + + let releaseRestore!: () => void + const restoreGate = new Promise(resolve => { releaseRestore = resolve }) + await page.route(`**/api/v1/habits/${archived.id}/restore`, async route => { await restoreGate; await route.continue() }) + await dialog.getByRole('button', { name: '恢复习惯' }).click() + const mask = page.locator('.app-sheet-mask').filter({ has: dialog }) + await expect(mask).toHaveAttribute('aria-busy', 'true') + const closeButton = dialog.getByRole('button', { name: '关闭习惯详情' }) + const deleteButton = dialog.getByRole('button', { name: '永久删除' }) + const restoreButton = dialog.getByRole('button', { name: '恢复习惯' }) + await expect(closeButton).toBeDisabled() + await expect(deleteButton).toBeDisabled() + await expect(restoreButton).toBeDisabled() + await closeButton.click({ force: true }) + await expect(dialog).toBeVisible() + await mask.click({ position: { x: 2, y: 2 }, force: true }) + await expect(dialog).toBeVisible() + await page.keyboard.press('Escape') + await expect(dialog).toBeVisible() + releaseRestore() + await expect(dialog).toBeHidden() + await expect(archiveToggle).toBeFocused() +}) diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index 10c4ae3..99d2308 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next' import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api' import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils' -import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils' +import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state' @@ -53,6 +53,12 @@ const habitComposerOpen = ref(false) const editingHabit = ref(null) const originalHabitForm = ref(null) const selectedHabit = ref(null) +const selectedHabitDetailProgress = computed(() => { + const habit = selectedHabit.value + return habit + ? formatHabitDetailProgress({ ...habit, value: logFor(habit, todayKey.value)?.value }) + : null +}) const habitHistory = ref([]) const habitHistoryLoading = ref(false) const habitHistoryLoadingMore = ref(false) @@ -499,6 +505,7 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) { void loadHabitHistory(true) } function closeHabitDetail() { + if (busy.value) return habitHistoryRequest += 1 selectedHabit.value = null habitHistory.value = [] @@ -524,7 +531,8 @@ async function archiveHabit(h: Habit) { }) } async function restoreHabit(h: Habit) { - if (!h.archived_at) return + if (!h.archived_at || busy.value) return + busy.value = true error.value = '' try { const result = await performHabitRestore({ @@ -540,10 +548,15 @@ async function restoreHabit(h: Habit) { void nextTick(() => habitArchiveToggle.value?.focus()) } catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' + } finally { + busy.value = false } } async function deleteHabit(h: Habit) { - if (!h.archived_at || !(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return + if (!h.archived_at || busy.value) return + if (!(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return + if (busy.value) return + busy.value = true error.value = '' try { await request(`/habits/${h.id}/permanent`, { method: 'DELETE' }) @@ -553,6 +566,8 @@ async function deleteHabit(h: Habit) { void nextTick(() => habitArchiveToggle.value?.focus()) } catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' + } finally { + busy.value = false } } async function loadArchivedHabits() { @@ -814,9 +829,24 @@ onBeforeUnmount(() => { diff --git a/frontend/src/lib/mvp-utils.test.ts b/frontend/src/lib/mvp-utils.test.ts index 20d2d70..6585ebd 100644 --- a/frontend/src/lib/mvp-utils.test.ts +++ b/frontend/src/lib/mvp-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, fabBottomReserved, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils' +import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, fabBottomReserved, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils' describe('MVP view utilities', () => { it('formats a local date as YYYY-MM-DD', () => { @@ -90,6 +90,24 @@ describe('MVP view utilities', () => { expect(formatHabitHistoryNumber(1.25)).toBe('1.25') }) + it('formats paper-flow habit details from real habit fields without invented data', () => { + expect(formatHabitRecordMode({ kind: 'boolean' })).toBe('完成 / 未完成') + expect(formatHabitRecordMode({ kind: 'numeric', target: 100, unit: '个' })).toBe('按数量记录 · 目标 100 个') + expect(formatHabitSchedule({ schedule_type: 'daily' })).toBe('每天') + expect(formatHabitSchedule({ schedule_type: 'weekly', weekdays: [0, 2, 6] })).toBe('每周一、周三、周日') + expect(formatHabitSchedule({ schedule_type: 'monthly', month_days: [1, 15, 31] })).toBe('每月 1、15、31 日') + expect(formatHabitSchedule({ schedule_type: 'interval', interval_days: 3 })).toBe('每 3 天') + expect(formatHabitSchedule({ schedule_type: 'weekly', weekdays: null })).toBe('每周(日期未设置)') + expect(formatHabitDetailDate('2026-08-12')).toBe('2026年8月12日') + expect(formatHabitDetailDate(undefined)).toBe('未提供') + expect(formatHabitDetailProgress({ kind: 'numeric', target: 100, value: 68 })).toEqual({ primary: '68 / 100', note: '今天还差 32', semantic: '68%' }) + expect(formatHabitDetailProgress({ kind: 'numeric', target: 3, value: 4 })).toEqual({ primary: '4 / 3', note: '今日目标已达成', semantic: '已达标' }) + expect(formatHabitDetailProgress({ kind: 'boolean', value: false })).toEqual({ primary: '未完成', note: '今天尚未完成', semantic: '未完成' }) + expect(formatHabitDetailProgress({ kind: 'boolean', value: true })).toEqual({ primary: '已完成', note: '今日目标已达成', semantic: '已完成' }) + expect(formatHabitDetailProgress({ kind: 'numeric', target: 100, archived_at: '2026-09-01T00:00:00Z' })).toBeNull() + expect(formatHabitDetailProgress({ kind: 'boolean', archived_at: '2026-09-01T00:00:00Z' })).toBeNull() + }) + it('only completes numeric habits when progress reaches the target', () => { expect(isHabitComplete('numeric', 3, 5)).toBe(false) expect(isHabitComplete('numeric', 5, 5)).toBe(true) diff --git a/frontend/src/lib/mvp-utils.ts b/frontend/src/lib/mvp-utils.ts index c4a922d..46182b9 100644 --- a/frontend/src/lib/mvp-utils.ts +++ b/frontend/src/lib/mvp-utils.ts @@ -107,6 +107,63 @@ export function formatHabitHistoryNumber(value: number) { return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))) } +type HabitDetailFields = { + kind?: string + target?: number + unit?: string + archived_at?: string | null + schedule_type?: 'daily' | 'weekly' | 'monthly' | 'interval' + weekdays?: number[] | null + month_days?: number[] | null + interval_days?: number | null +} + +export function formatHabitRecordMode(habit: HabitDetailFields) { + if (habit.kind !== 'numeric') return '完成 / 未完成' + const unit = habit.unit ? ` ${habit.unit}` : '' + return `按数量记录 · 目标 ${formatHabitHistoryNumber(habit.target ?? 1)}${unit}` +} + +export function formatHabitSchedule(habit: HabitDetailFields) { + if (habit.schedule_type === 'weekly') { + const weekdays = habit.weekdays ?? [] + if (!weekdays.length) return '每周(日期未设置)' + const labels = ['一', '二', '三', '四', '五', '六', '日'] + return `每${weekdays.map((day) => `周${labels[day] ?? day}`).join('、')}` + } + if (habit.schedule_type === 'monthly') { + const monthDays = habit.month_days ?? [] + return monthDays.length ? `每月 ${monthDays.join('、')} 日` : '每月(日期未设置)' + } + if (habit.schedule_type === 'interval') { + return habit.interval_days ? `每 ${habit.interval_days} 天` : '按间隔(天数未设置)' + } + return '每天' +} + +export function formatHabitDetailDate(day?: string) { + if (!day) return '未提供' + const [year, month, date] = day.split('-').map(Number) + if (!year || !month || !date) return day + return `${year}年${month}月${date}日` +} + +export function formatHabitDetailProgress(habit: Pick & { value?: number | boolean }) { + if (habit.archived_at) return null + const target = habit.target ?? 1 + const value = Number(habit.value ?? 0) + if (habit.kind !== 'numeric') { + const complete = Boolean(habit.value) + return { primary: complete ? '已完成' : '未完成', note: complete ? '今日目标已达成' : '今天尚未完成', semantic: complete ? '已完成' : '未完成' } + } + const complete = value >= target + return { + primary: `${formatHabitHistoryNumber(value)} / ${formatHabitHistoryNumber(target)}`, + note: complete ? '今日目标已达成' : `今天还差 ${formatHabitHistoryNumber(Math.max(0, target - value))}${habit.unit ? ` ${habit.unit}` : ''}`, + semantic: complete ? '已达标' : `${Math.round(target > 0 ? value / target * 100 : 0)}%`, + } +} + type HabitSchedule = { kind?: string cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }> diff --git a/frontend/src/style.css b/frontend/src/style.css index d0711c0..3f55c4f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -227,3 +227,8 @@ main:has(>.mvp-view .settings-sections) .settings-data>.inline-error{margin:14px .task-detail-notes,.task-detail-subtasks{padding-bottom:18px;border-bottom:1px solid #e4dbcf}.task-detail-notes{gap:8px}.task-detail-subtasks{display:grid;gap:4px}.task-detail-subtasks .field-label{min-height:44px}.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}.subtask-detail{padding:0;border-bottom:1px solid #eee6db}.subtask-detail>span{min-width:0;flex:1;overflow-wrap:anywhere;word-break:break-word}.subtask-remove{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;padding:0;border:0;background:transparent;color:var(--muted);border-radius:9px}.subtask-remove:hover:not(:disabled){color:var(--danger);background:#fff3ef}.after-completion-fields,.repeat-custom-fields{padding:10px 0;border-top:1px solid #eee6db;border-bottom:1px solid #eee6db}.more-settings{border-bottom:1px solid #e4dbcf;padding-bottom:12px}.more-settings>summary{min-height:44px;display:flex;align-items:center;font-size:13px;font-weight:700;cursor:pointer}.more-settings-body{display:grid;gap:12px;padding-top:7px}.more-settings-body select{width:100%;min-height:44px;border-radius:10px} .detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;flex:0 0 auto;padding:12px 18px;display:flex;align-items:center;justify-content:space-between;gap:10px;border-top:1px solid #e4dbcf;background:#fffdf8}.detail-actions>button{min-height:44px}.detail-trash{padding:0;color:var(--danger)}.detail-save{min-width:88px} @media(max-width:930px){.detail{position:relative;z-index:auto;left:auto;right:auto;top:auto;bottom:auto;width:100%;max-height:min(88dvh,760px);overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:20px 20px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:none;transition:none;padding-bottom:0}.detail-head{position:relative;top:auto;z-index:2;height:58px;flex:0 0 58px;padding:0 18px}.detail-head .icon{width:44px;height:44px;background:transparent;color:var(--text-secondary);border-radius:10px}.detail-form{flex:1 1 auto;min-height:0;padding:18px;gap:18px;overflow-y:auto;overflow-x:hidden}.task-detail-date-time{grid-template-columns:minmax(0,1fr)}.task-detail-date-control .task-compose-date-clear,.task-detail-time-control .task-compose-time-remove{min-width:44px;min-height:44px;width:44px;height:44px}.detail-actions{width:100%;padding:12px 18px calc(12px + env(safe-area-inset-bottom))}.detail-actions>button{min-height:44px}} + +/* Approved habit detail 01: paper flow. */ +.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);padding:0;gap:0;display:flex;flex-direction:column;background:#fffdf8;overflow:hidden}.habit-detail-archive-note{order:-1;flex:0 0 auto;margin:0;padding:10px 20px;border:0;border-bottom:1px solid #e8b7aa;background:#fff5f2;color:var(--danger);font-size:12px;line-height:1.5}.habit-detail-header{height:64px;flex:0 0 64px;padding:0 20px}.habit-detail-header h3{font-size:18px}.habit-detail-body{flex:1 1 auto;gap:0;padding:18px 20px;overflow-y:auto;overflow-x:hidden}.habit-detail-hero{padding:4px 0 18px;border-bottom:1px solid #e4dbcf}.habit-detail-hero strong{display:block;color:var(--text-primary);font-size:28px;line-height:1.15;letter-spacing:-.04em;font-variant-numeric:tabular-nums}.habit-detail-hero small{display:block;margin-top:6px;color:var(--muted);font-size:12px}.habit-detail-progress-row{min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px;border-bottom:1px solid #e4dbcf}.habit-detail-progress-row span{font-size:12px;font-weight:700}.habit-detail-progress-row small{display:block;margin-top:3px;color:var(--muted);font-size:11px}.habit-detail-progress-row strong{color:var(--success);font-size:20px}.habit-detail-meta{margin:0 0 14px}.habit-detail-meta-row{min-height:52px;display:grid;grid-template-columns:86px minmax(0,1fr);align-items:center;gap:12px;border-bottom:1px solid #e4dbcf;font-size:13px}.habit-detail-meta-row dt{color:var(--muted);font-size:11px;font-weight:700}.habit-detail-meta-row dd{min-width:0;margin:0;text-align:right;overflow-wrap:anywhere}.habit-history__header{min-height:42px;border-bottom:1px solid #e4dbcf}.habit-history__list{border-top:0}.habit-detail-active-actions{min-height:68px;justify-content:space-between;padding:12px 20px calc(12px + env(safe-area-inset-bottom))}.habit-detail-archive-button{min-height:40px;display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--danger);font-size:13px;font-weight:700}.habit-detail-archive-button svg{width:16px;height:16px}.habit-detail-edit-button{min-width:100px}.habit-detail-archived-actions{min-height:68px;padding:12px 20px calc(12px + env(safe-area-inset-bottom));background:#fffdf8;border-top:1px solid #e4dbcf}.habit-detail-archived-actions .habit-delete-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;background:transparent;color:var(--danger)} +@media(max-width:930px){.habit-detail-sheet.app-sheet--detail{width:100%;max-width:100%}.habit-detail-sheet :is(.habit-detail-archive-button,.habit-detail-edit-button,.habit-restore-button,.habit-delete-button,.habit-history__more){min-height:44px}} +@media(max-width:390px){.habit-detail-body{padding:16px}.habit-detail-header{padding:0 16px}.habit-detail-active-actions,.habit-detail-archived-actions{padding-left:16px;padding-right:16px}.habit-detail-meta-row{grid-template-columns:78px minmax(0,1fr)}} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index f9ec533..fff3918 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -617,7 +617,7 @@ describe('mobile sheet contract', () => { }) it('keeps the danger zone reserved for archived habit deletion', () => { - expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"') + expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"') expect(mvpPanel).toContain('deleteHabit(selectedHabit)') expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;') }) @@ -643,6 +643,8 @@ describe('mobile list row language', () => { expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"') expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"') expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"') + expect(mvpPanel).toContain("if (!(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return") + expect(mvpPanel).toContain('if (busy.value) return\n busy.value = true') expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })") expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)") expect(mvpPanel).toContain('aria-labelledby="habit-history-title"') @@ -1230,6 +1232,51 @@ describe('settings data tools', () => { }) }) +describe('habit detail paper-flow redesign', () => { + it('keeps the approved information order and every existing detail state/action', () => { + const sheet = mvpPanel.slice(mvpPanel.indexOf('', mvpPanel.indexOf('')) + const ordered = [ + 'habit-detail-archive-note', + 'habit-detail-title', + 'habit-detail-hero', + 'habit-detail-progress-row', + '记录方式', + '计划', + '开始日期', + 'habit-history-title', + ].map((token) => detail.indexOf(token)) + expect(ordered.every((position) => position >= 0)).toBe(true) + expect(ordered).toEqual([...ordered].sort((a, b) => a - b)) + expect(detail).toContain('selectedHabitDetailProgress.primary') + expect(detail).toContain('selectedHabitDetailProgress.note') + expect(detail).toContain('formatHabitRecordMode(selectedHabit)') + expect(detail).toContain('formatHabitSchedule(selectedHabit)') + expect(detail).toContain('formatHabitDetailDate(selectedHabit.start_date)') + expect(detail).not.toContain('最后记录于') + expect(detail).toContain('habitHistoryLoading') + expect(detail).toContain('habitHistoryError') + expect(detail).toContain('loadHabitHistory(true)') + expect(detail).toContain('loadHabitHistory(false)') + expect(detail).toContain('@click="editHabit(selectedHabit)"') + expect(detail).toContain('@click="archiveHabit(selectedHabit)"') + expect(detail).toContain('@click="restoreHabit(selectedHabit)"') + expect(detail).toContain('@click="deleteHabit(selectedHabit)"') + }) + + it('matches the paper-flow surface and action hierarchy at desktop and touch widths', () => { + expect(css).toContain('/* Approved habit detail 01: paper flow. */') + expect(css).toMatch(/\.habit-detail-hero\{[^}]*padding:[^;}]*;[^}]*border-bottom:1px solid #e4dbcf/) + expect(css).toMatch(/\.habit-detail-hero strong\{[^}]*font-size:28px/) + expect(css).toMatch(/\.habit-detail-progress-row\{[^}]*min-height:66px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/) + expect(css).toMatch(/\.habit-detail-meta-row\{[^}]*min-height:52px;[^}]*grid-template-columns:86px minmax\(0,1fr\)/) + expect(css).toMatch(/\.habit-detail-active-actions\{[^}]*justify-content:space-between/) + expect(css).toMatch(/\.habit-detail-archive-button\{[^}]*background:transparent;[^}]*color:var\(--danger\)/) + expect(css).toMatch(/@media\(max-width:930px\)\{[^}]*\.habit-detail-sheet\.app-sheet--detail\{[^}]*width:100%/) + expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.habit-detail-sheet :is\([^}]*\)\{[^}]*min-height:44px/) + }) +}) + describe('task detail layout', () => { it('keeps long subtask text inside the detail panel', () => { expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);')