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
+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>')
})
})