feat: redesign habit detail paper flow
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m46s

This commit is contained in:
2026-09-18 21:10:50 +08:00
parent 432a133a42
commit 0baedbc99e
6 changed files with 322 additions and 10 deletions
@@ -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<APIRequestContext['fetch']>[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<string, unknown> = {}) {
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<void>(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()
})
+38 -8
View File
@@ -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 { 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 { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils' 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state' import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
@@ -53,6 +53,12 @@ const habitComposerOpen = ref(false)
const editingHabit = ref<Habit | null>(null) const editingHabit = ref<Habit | null>(null)
const originalHabitForm = ref<HabitFormValues | null>(null) const originalHabitForm = ref<HabitFormValues | null>(null)
const selectedHabit = ref<Habit | null>(null) const selectedHabit = ref<Habit | null>(null)
const selectedHabitDetailProgress = computed(() => {
const habit = selectedHabit.value
return habit
? formatHabitDetailProgress({ ...habit, value: logFor(habit, todayKey.value)?.value })
: null
})
const habitHistory = ref<HabitLog[]>([]) const habitHistory = ref<HabitLog[]>([])
const habitHistoryLoading = ref(false) const habitHistoryLoading = ref(false)
const habitHistoryLoadingMore = ref(false) const habitHistoryLoadingMore = ref(false)
@@ -499,6 +505,7 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
void loadHabitHistory(true) void loadHabitHistory(true)
} }
function closeHabitDetail() { function closeHabitDetail() {
if (busy.value) return
habitHistoryRequest += 1 habitHistoryRequest += 1
selectedHabit.value = null selectedHabit.value = null
habitHistory.value = [] habitHistory.value = []
@@ -524,7 +531,8 @@ async function archiveHabit(h: Habit) {
}) })
} }
async function restoreHabit(h: Habit) { async function restoreHabit(h: Habit) {
if (!h.archived_at) return if (!h.archived_at || busy.value) return
busy.value = true
error.value = '' error.value = ''
try { try {
const result = await performHabitRestore({ const result = await performHabitRestore({
@@ -540,10 +548,15 @@ async function restoreHabit(h: Habit) {
void nextTick(() => habitArchiveToggle.value?.focus()) void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '恢复失败' error.value = reason instanceof Error ? reason.message : '恢复失败'
} finally {
busy.value = false
} }
} }
async function deleteHabit(h: Habit) { 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 = '' error.value = ''
try { try {
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' }) await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
@@ -553,6 +566,8 @@ async function deleteHabit(h: Habit) {
void nextTick(() => habitArchiveToggle.value?.focus()) void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '永久删除失败' error.value = reason instanceof Error ? reason.message : '永久删除失败'
} finally {
busy.value = false
} }
} }
async function loadArchivedHabits() { async function loadArchivedHabits() {
@@ -814,9 +829,24 @@ onBeforeUnmount(() => {
</section> </section>
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail"> <AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
<template v-if="selectedHabit"> <template v-if="selectedHabit">
<header class="app-sheet__header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header> <p v-if="selectedHabit.archived_at" class="habit-detail-archive-note" role="status">此习惯已归档历史记录仍完整保留</p>
<div class="app-sheet__body"> <header class="app-sheet__header habit-detail-header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" :disabled="busy" @click="closeHabitDetail"><X/></button></header>
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div> <div class="app-sheet__body habit-detail-body">
<template v-if="selectedHabitDetailProgress">
<section class="habit-detail-hero" aria-labelledby="habit-detail-today-label">
<strong>{{ selectedHabitDetailProgress.primary }}</strong>
<small>{{ selectedHabitDetailProgress.note }}</small>
</section>
<div class="habit-detail-progress-row">
<div><span id="habit-detail-today-label">今日进度</span><small>按今日记录计算</small></div>
<strong>{{ selectedHabitDetailProgress.semantic }}</strong>
</div>
</template>
<dl class="habit-detail-meta">
<div class="habit-detail-meta-row"><dt>记录方式</dt><dd>{{ formatHabitRecordMode(selectedHabit) }}</dd></div>
<div class="habit-detail-meta-row"><dt>计划</dt><dd>{{ formatHabitSchedule(selectedHabit) }}</dd></div>
<div class="habit-detail-meta-row"><dt>开始日期</dt><dd>{{ formatHabitDetailDate(selectedHabit.start_date) }}</dd></div>
</dl>
<section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading"> <section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading">
<header class="habit-history__header"><h4 id="habit-history-title">历史记录</h4><small v-if="habitHistory.length">{{ habitHistory.length }} </small></header> <header class="habit-history__header"><h4 id="habit-history-title">历史记录</h4><small v-if="habitHistory.length">{{ habitHistory.length }} </small></header>
<div v-if="habitHistoryLoading" class="habit-history__loading" role="status"><span v-for="index in 3" :key="index"></span><b class="sr-only">正在加载历史记录</b></div> <div v-if="habitHistoryLoading" class="habit-history__loading" role="status"><span v-for="index in 3" :key="index"></span><b class="sr-only">正在加载历史记录</b></div>
@@ -834,8 +864,8 @@ onBeforeUnmount(() => {
<small v-else-if="habitHistory.length && !habitHistoryLoading" class="habit-history__end">没有更早记录了</small> <small v-else-if="habitHistory.length && !habitHistoryLoading" class="habit-history__end">没有更早记录了</small>
</section> </section>
</div> </div>
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer> <footer v-if="!selectedHabit.archived_at" class="app-sheet__footer habit-detail-active-actions"><button type="button" class="habit-detail-archive-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button><button type="button" class="soft-button habit-detail-edit-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button></footer>
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer> <footer v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"><button type="button" class="danger-text habit-delete-button" :disabled="busy" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button><button type="button" class="soft-button habit-restore-button" :disabled="busy" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button></footer>
</template> </template>
</AppSheet> </AppSheet>
</template> </template>
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -90,6 +90,24 @@ describe('MVP view utilities', () => {
expect(formatHabitHistoryNumber(1.25)).toBe('1.25') 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', () => { it('only completes numeric habits when progress reaches the target', () => {
expect(isHabitComplete('numeric', 3, 5)).toBe(false) expect(isHabitComplete('numeric', 3, 5)).toBe(false)
expect(isHabitComplete('numeric', 5, 5)).toBe(true) expect(isHabitComplete('numeric', 5, 5)).toBe(true)
+57
View File
@@ -107,6 +107,63 @@ export function formatHabitHistoryNumber(value: number) {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))) 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<HabitDetailFields, 'kind' | 'target' | 'unit' | 'archived_at'> & { 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 = { type HabitSchedule = {
kind?: string kind?: string
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }> cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
+5
View File
@@ -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} .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} .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}} @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)}}
+48 -1
View File
@@ -617,7 +617,7 @@ describe('mobile sheet contract', () => {
}) })
it('keeps the danger zone reserved for archived habit deletion', () => { 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(mvpPanel).toContain('deleteHabit(selectedHabit)')
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;') 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('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"') expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"') 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/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)") expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)")
expect(mvpPanel).toContain('aria-labelledby="habit-history-title"') 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('<AppSheet :open="Boolean(selectedHabit)"'), mvpPanel.indexOf('</AppSheet>', mvpPanel.indexOf('<AppSheet :open="Boolean(selectedHabit)"')))
const detail = sheet.slice(sheet.indexOf('<template v-if="selectedHabit">'))
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', () => { describe('task detail layout', () => {
it('keeps long subtask text inside the detail panel', () => { it('keeps long subtask text inside the detail panel', () => {
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);') expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);')