feat: refine archived habit management
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m28s

This commit is contained in:
2026-09-10 14:39:06 +08:00
parent bf61e69776
commit 3e80f2ecbb
8 changed files with 515 additions and 34 deletions
+59 -16
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
import { moveItemWithinScope } from './lib/task-utils'
import { changedHabitFields, dateKey, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
@@ -19,7 +19,10 @@ const emit = defineEmits<{
const habits = ref<Habit[]>([])
const archivedHabits = ref<Habit[]>([])
const showArchivedHabits = ref(false)
const archivedHabitsLoaded = ref(false)
const archiveState = ref<ArchivePanelState>('idle')
const archiveError = ref('')
const archiveFlags = computed(() => archivePanelFlags(archiveState.value, archivedHabits.value.length))
const habitArchiveToggle = ref<HTMLButtonElement | null>(null)
const sessions = ref<Session[]>([])
const audit = ref<any[]>([])
const busy = ref(false)
@@ -387,7 +390,10 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
}
function closeHabitDetail() {
selectedHabit.value = null
void nextTick(() => habitDetailOpener?.focus())
void nextTick(() => {
if (habitDetailOpener?.isConnected) habitDetailOpener.focus()
else habitArchiveToggle.value?.focus()
})
}
defineExpose({ openHabitComposer })
async function archiveHabit(h: Habit) {
@@ -400,22 +406,53 @@ async function archiveHabit(h: Habit) {
emit('notice', '习惯已归档')
})
}
async function restoreHabit(h: Habit) {
if (!h.archived_at) return
error.value = ''
try {
const result = await performHabitRestore({
restore: () => request(`/habits/${h.id}/restore`, { method: 'POST' }),
commitRestore: () => {
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
selectedHabit.value = null
invalidateHabitGridCache()
},
refreshGrid: () => loadHabits(),
})
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '恢复失败'
}
}
async function deleteHabit(h: Habit) {
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
await safe(async () => {
error.value = ''
try {
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
selectedHabit.value = null
await loadArchivedHabits()
emit('notice', '习惯已永久删除')
})
void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '永久删除失败'
}
}
async function loadArchivedHabits() {
archivedHabits.value = await request('/habits?archived=true') as Habit[]
archivedHabitsLoaded.value = true
if (props.view !== 'habits' || archiveState.value === 'loading') return
archiveState.value = 'loading'
archiveError.value = ''
try {
archivedHabits.value = await request('/habits?archived=true') as Habit[]
archiveState.value = 'success'
} catch (reason) {
archiveState.value = 'error'
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败'
}
}
async function toggleArchivedHabits() {
showArchivedHabits.value = !showArchivedHabits.value
if (showArchivedHabits.value && !archivedHabitsLoaded.value) await safe(loadArchivedHabits)
if (showArchivedHabits.value && archiveState.value !== 'success') await loadArchivedHabits()
}
function refreshHabitDay() {
const next = dateKey(new Date())
@@ -434,8 +471,10 @@ async function loadHabits() {
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
habits.value = data.habits ?? []
writeHabitGridCache(week, habits.value)
return true
} catch (e) {
error.value = e instanceof Error ? e.message : '请求失败'
return false
} finally {
if (!cached) busy.value = false
}
@@ -578,19 +617,23 @@ onBeforeUnmount(() => {
</div>
</article>
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏' : '还没有习惯从一件容易坚持的小事开始' }}</div>
<button class="archived-toggle habit-archive-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}</button>
<div v-if="showArchivedHabits" class="archived-habits">
<button v-for="h in archivedHabits" :key="h.id" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span>{{ h.name }}</span><small>查看详情</small></button>
<p v-if="archivedHabitsLoaded && !archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯</p>
</div>
</div>
<section v-if="view === 'habits'" class="habit-archive-section">
<button ref="habitArchiveToggle" class="archived-toggle habit-archive-toggle" type="button" :aria-expanded="showArchivedHabits" aria-controls="archived-habits-panel" @click="toggleArchivedHabits"><ArchiveRestore/><span>{{ archiveState === 'success' ? `已归档(${archivedHabits.length}` : '已归档' }}</span><ChevronRight :class="{ expanded: showArchivedHabits }" aria-hidden="true"/></button>
<div v-if="showArchivedHabits" id="archived-habits-panel" class="archived-habits" aria-live="polite">
<p v-if="archiveState === 'loading'" class="habit-archive-status">正在加载归档习惯</p>
<div v-else-if="archiveState === 'error'" class="habit-archive-status inline-error" role="alert"><span>{{ archiveError }}</span><button type="button" class="soft-button" @click="loadArchivedHabits">重试</button></div>
<p v-else-if="archiveFlags.empty" class="empty-panel">暂无已归档习惯</p>
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
</div>
</section>
<Transition name="countdown-detail">
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
<div class="app-sheet__body"><div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></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__danger"><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"><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>
</article>
</div>
</Transition>
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, 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', () => {
@@ -196,6 +196,49 @@ describe('MVP view utilities', () => {
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
})
it('keeps a successful restore committed when the grid refresh fails', async () => {
const events: string[] = []
const result = await performHabitRestore({
restore: async () => { events.push('restored') },
commitRestore: () => { events.push('archive-removed') },
refreshGrid: async () => false,
})
expect(events).toEqual(['restored', 'archive-removed'])
expect(result).toEqual({ restored: true, refreshed: false })
})
it('invalidates a restored habit grid cache before reconciliation', () => {
writeHabitGridCache('2026-09-07', [{ id: 'restored-habit' }])
invalidateHabitGridCache()
expect(readHabitGridCache('2026-09-07')).toBeNull()
})
it('does not commit a failed restore or attempt a grid refresh', async () => {
const events: string[] = []
await expect(performHabitRestore({
restore: async () => { throw new Error('恢复失败') },
commitRestore: () => { events.push('archive-removed') },
refreshGrid: async () => { events.push('grid-refreshed') },
})).rejects.toThrow('恢复失败')
expect(events).toEqual([])
})
it('formats archive timestamps locally and rejects every malformed calendar value', () => {
expect(formatArchivedAt('2026-09-10T12:08:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/^归档于 2026\/9\/10 20:08$/)
for (const value of [null, '', 'not-a-date', '2026-02-30T12:00:00Z', '2026-13-01T00:00:00Z', '2026-09-10T25:00:00Z']) {
expect(formatArchivedAt(value, { timeZone: 'UTC' })).toBe('归档时间未知')
}
})
it('makes archive loading, error, and empty states mutually exclusive', () => {
expect(archivePanelFlags('loading', 0)).toEqual({ loading: true, error: false, empty: false, list: false })
expect(archivePanelFlags('error', 0)).toEqual({ loading: false, error: true, empty: false, list: false })
expect(archivePanelFlags('success', 0)).toEqual({ loading: false, error: false, empty: true, list: false })
expect(archivePanelFlags('success', 2)).toEqual({ loading: false, error: false, empty: false, list: true })
expect(archivePanelFlags('idle', 0)).toEqual({ loading: false, error: false, empty: false, list: false })
})
it('localizes known audit actions and entities and hides unknown actions', () => {
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
+35
View File
@@ -269,6 +269,25 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
habitGridCache = { week, habits }
}
export function invalidateHabitGridCache() {
habitGridCache = null
}
export async function performHabitRestore(actions: {
restore: () => Promise<unknown>
commitRestore: () => void
refreshGrid: () => Promise<boolean | unknown>
}) {
await actions.restore()
actions.commitRestore()
try {
const refreshed = await actions.refreshGrid()
return { restored: true as const, refreshed: refreshed !== false }
} catch {
return { restored: true as const, refreshed: false }
}
}
export function readCountdownCache<T>() {
if (!countdownCache) return null
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
@@ -369,6 +388,22 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
return { writable: true, reason: '' }
}
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
export function archivePanelFlags(state: ArchivePanelState, count: number) {
return {
loading: state === 'loading',
error: state === 'error',
empty: state === 'success' && count === 0,
list: state === 'success' && count > 0,
}
}
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
const formatted = formatLocalShortDateTime(value, options)
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
}
export function formatUserAgent(userAgent?: string | null): string {
const ua = userAgent?.trim() ?? ''
let device = '未知设备'
+9 -7
View File
@@ -219,18 +219,20 @@ describe('request generation protection', () => {
describe('habit request boundaries', () => {
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function deleteHabit'))
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function restoreHabit'))
const loadBlock = habits.slice(habits.indexOf('async function loadArchivedHabits'), habits.indexOf('async function toggleArchivedHabits'))
expect(mounted).not.toContain('loadArchivedHabits()')
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
expect(habits).toContain('if (showArchivedHabits.value && !archivedHabitsLoaded.value)')
expect(habits).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}")
expect(loadBlock).toContain("if (props.view !== 'habits'")
expect(habits).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length}` : '已归档'")
})
it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => {
it('does not render an empty archive until loading succeeds and retries after failure', () => {
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
expect(toggleBlock).toContain('showArchivedHabits.value && !archivedHabitsLoaded.value')
expect(habits).toContain('v-if="archivedHabitsLoaded && !archivedHabits.length && !busy"')
expect(habits).not.toContain('v-if="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。')
expect(toggleBlock).toContain("showArchivedHabits.value && archiveState.value !== 'success'")
expect(habits).toContain('v-else-if="archiveFlags.empty"')
expect(habits).toContain("v-else-if=\"archiveState === 'error'\"")
expect(habits).toContain('@click="loadArchivedHabits">重试</button>')
})
})
+2 -1
View File
@@ -65,7 +65,8 @@ main{background:var(--surface-base)}
.soft-button,.danger-button,.file-button,.secondary,.restore,.archived-toggle,.archived-countdowns button{background:var(--surface-raised);border-color:var(--border-cream);box-shadow:var(--highlight-inner)}
.primary,.primary-small,.unified-fab{background:var(--accent);color:#fff}
.danger-button{color:var(--danger);border-color:#dba99d;background:#fff8f5}.inline-error,.purge-list-error{color:var(--danger);background:#fff0ec}.task-check[aria-pressed="true"] .task-check-mark,.habit-row.done .habit-check .task-check-mark{background:var(--success);border-color:var(--success)}
@media(max-width:930px){.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none;justify-content:flex-start}.archived-habits{width:100%;display:grid;gap:0}.archived-habits>button{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);border-radius:0;font:inherit}.archived-habits>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habits>button small{color:var(--muted);white-space:nowrap}}
.habit-archive-section{display:grid;gap:0}.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;text-align:left}.habit-archive-toggle>span{min-width:0}.habit-archive-toggle>svg:last-child{transition:transform .16s ease}.habit-archive-toggle>svg.expanded{transform:rotate(90deg)}.archived-habits{width:100%;display:grid;gap:0;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-card);overflow:hidden}.archived-habit-row{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);font:inherit}.archived-habit-row:last-child{border-bottom:0}.archived-habit-row>span{min-width:0;display:grid;gap:3px}.archived-habit-row b,.archived-habit-row small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habit-row small{color:var(--muted);font-size:11px}.archived-habit-row>svg{flex:none}.habit-archive-status{min-height:44px;margin:0;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px}.app-sheet__danger{display:flex;gap:8px}.app-sheet__danger>button{min-height:44px}.habit-restore-button{flex:1}.habit-delete-button{flex:1}
@media(max-width:930px){.habit-archive-section{width:100%;gap:0}.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none}.archived-habits{width:100%;display:grid;gap:0}.archived-habit-row{width:100%;min-height:44px;appearance:none;grid-template-columns:minmax(0,1fr) auto;background:var(--surface-raised);border-bottom:1px solid var(--border-cream)}.habit-detail-sheet.app-sheet--detail{width:100%;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}}
input,select,textarea,.search{background:var(--surface-raised);border-color:var(--border-cream);border-radius:var(--radius-control);box-shadow:var(--highlight-inner)}
.search input,.detail-title textarea{background:transparent;box-shadow:none}
.sidebar-popover button,.sidebar-action-sheet .app-sheet__body button,.app-sheet--actions .app-sheet__body>button,.more-sheet>button{background:var(--surface-raised)}
+47 -7
View File
@@ -267,16 +267,56 @@ describe('mobile list row language', () => {
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
})
it('provides a minimal archived-habit viewing path', () => {
it('provides an archived-habit viewing path', () => {
expect(mvpPanel).toContain("request('/habits?archived=true')")
expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}")
expect(mvpPanel).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length}` : '已归档'")
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-toggle\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\)/)
expect(mvpPanel).toContain('archiveFlags.list ? archivedHabits : []')
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-section\{[^}]*width:100%/)
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
expect(css).toMatch(/\.archived-habits>button\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*grid-template-columns:minmax\(0,1fr\) auto;[^}]*background:var\(--surface-raised\);[^}]*border-bottom:1px solid var\(--border-cream\)/)
expect(css).toMatch(/\.archived-habits>button span\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
expect(css).toMatch(/\.archived-habits>button small\{[^}]*white-space:nowrap/)
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
expect(css).toMatch(/\.archived-habit-row b,\.archived-habit-row small\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
})
it('renders a separate accessible archive section with explicit states and lifecycle actions', () => {
const activeList = mvpPanel.slice(mvpPanel.indexOf('<div v-if="view === \'habits\'" class="habit-list">'), mvpPanel.indexOf('<section v-if="view === \'habits\'" class="habit-archive-section"'))
const todayList = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
expect(activeList).not.toContain('habit-archive-toggle')
expect(todayList).not.toContain('habit-archive-section')
expect(mvpPanel).toContain('class="habit-archive-section"')
expect(mvpPanel).toContain('aria-controls="archived-habits-panel"')
expect(mvpPanel).toContain(':aria-expanded="showArchivedHabits"')
expect(mvpPanel).toContain('id="archived-habits-panel"')
expect(mvpPanel).toContain('archiveState === \'loading\'')
expect(mvpPanel).toContain('archiveState === \'error\'')
expect(mvpPanel).toContain('@click="loadArchivedHabits"')
expect(mvpPanel).toContain('formatArchivedAt(h.archived_at)')
expect(mvpPanel).toContain('<ChevronRight aria-hidden="true"/>')
expect(mvpPanel).toContain('@click="restoreHabit(selectedHabit)"')
expect(mvpPanel).toContain("method: 'POST'")
expect(mvpPanel).toContain('archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)')
expect(mvpPanel).toContain('恢复习惯')
})
it('preserves archived detail after restore or purge failures and falls focus back to disclosure after removal', () => {
const restoreBlock = mvpPanel.slice(mvpPanel.indexOf('async function restoreHabit'), mvpPanel.indexOf('async function deleteHabit'))
const deleteBlock = mvpPanel.slice(mvpPanel.indexOf('async function deleteHabit'), mvpPanel.indexOf('async function loadArchivedHabits'))
expect(restoreBlock).toContain('catch (reason)')
expect(deleteBlock).toContain('catch (reason)')
expect(restoreBlock).toContain('performHabitRestore({')
expect(restoreBlock).toContain('invalidateHabitGridCache()')
expect(restoreBlock).toContain("result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试'")
expect(restoreBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(restoreBlock.indexOf("method: 'POST'"))
expect(deleteBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(deleteBlock.indexOf("method: 'DELETE'"))
expect(mvpPanel).toContain('habitArchiveToggle.value?.focus()')
expect(mvpPanel).toContain('ref="habitArchiveToggle"')
})
it('styles archive rows as 44px full-width desktop rows and a mobile bottom sheet detail', () => {
expect(css).toMatch(/\.habit-archive-toggle\{[^}]*min-height:44px/)
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
expect(css).toContain('@media(max-width:930px){.habit-archive-section')
expect(css).toContain('.habit-detail-sheet.app-sheet--detail{')
})
it('reuses the habit composer for edits and patches only changed fields', () => {