diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6d454b4..d4bd4dd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ import { Settings, Trash2, X, Repeat2, } from 'lucide-vue-next' import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' -import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' +import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' @@ -460,13 +460,14 @@ async function loadTodayTaskSummary() { todayTaskTotal.value = overdue + open + completed } catch { /* 概览统计失败不阻断今天页 */ } } -async function loadOverdueTasks() { +async function loadOverdueTasks(request = beginLatestRequest('tasks')) { const params = new URLSearchParams() params.set('due_to', isoAtLocalDayOffset(0)) params.set('completed', 'false') - overdueTasks.value = await loadTaskPages(`/tasks?${params}`) + const loaded = await loadTaskPages(`/tasks?${params}`) + if (isLatestRequest('tasks', request)) overdueTasks.value = loaded } -async function loadTasksPage() { +async function loadTasksPage(request = beginLatestRequest('tasks')) { const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) }) if (query.value) params.set('q', query.value) else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value) @@ -474,6 +475,7 @@ async function loadTasksPage() { if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) } if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false') const data = await api(`/tasks?${params}`) + if (!isLatestRequest('tasks', request)) return tasks.value = data.items ?? [] totalTasks.value = data.total ?? tasks.value.length hiddenCompletedTaskCount.value = 0 @@ -483,6 +485,7 @@ async function loadTasksPage() { completedParams.set('page_size', '1') completedParams.set('completed', 'true') const completedData = await api(`/tasks?${completedParams}`) + if (!isLatestRequest('tasks', request)) return hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0) } } @@ -499,39 +502,67 @@ async function loadNavigation(force = false) { } async function preloadCountdowns() { try { - const active = await api('/countdowns') - writeCountdownCache(active ?? [], []) - const archived = await api('/countdowns?archived=true') - writeCountdownCache(active ?? [], archived ?? []) + await loadCountdownCache(async () => { + const [active, archived] = await Promise.all([api('/countdowns'), api('/countdowns?archived=true')]) + return { items: active ?? [], archived: archived ?? [] } + }) } catch { /* 倒数日页会在打开时重试 */ } } async function loadAll() { - loading.value = true; error.value = '' + const request = beginLatestRequest('tasks') + loading.value = true + error.value = '' try { if (!navigationLoaded.value) await loadNavigation() - await loadTasksPage() + if (!isLatestRequest('tasks', request)) return if (activeView.value === 'today') { - await loadOverdueTasks() - void loadTodayTaskSummary() - } else overdueTasks.value = [] - if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() } - } catch (reason) { fail(reason) } finally { loading.value = false } + await startPrimaryWithBackground( + [() => loadTasksPage(request), () => loadOverdueTasks(request)], + loadTodayTaskSummary, + ) + } else { + await loadTasksPage(request) + if (isLatestRequest('tasks', request)) overdueTasks.value = [] + } + if (isLatestRequest('tasks', request) && page.value > totalPages.value) { + page.value = totalPages.value + await loadTasksPage(request) + } + } catch (reason) { + if (isLatestRequest('tasks', request)) fail(reason) + } finally { + if (isLatestRequest('tasks', request)) loading.value = false + } } async function refreshAll() { navigationLoaded.value = false await loadAll() } +type TrashPage = { items?: Task[]; total?: number } async function loadTrashPage() { - const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`) - trash.value = data.items ?? [] - totalTasks.value = data.total ?? trash.value.length + return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise } async function loadTrash() { - loading.value = true; error.value = '' - try { await loadTrashPage() } catch (reason) { fail(reason) } finally { loading.value = false } + loading.value = true + error.value = '' + await runLatestRequest('trash', loadTrashPage, { + success: (data) => { + trash.value = data.items ?? [] + totalTasks.value = data.total ?? trash.value.length + }, + error: fail, + finally: () => { loading.value = false }, + }) } async function switchView(view: View, listId?: string) { + taskMutationNavigation.value += 1 activeView.value = view + if (view !== 'trash') beginLatestRequest('trash') + if (!isTaskView(view)) { + beginLatestRequest('tasks') + loading.value = false + error.value = '' + } if (listId) activeList.value = listId writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) page.value = 1 @@ -547,8 +578,7 @@ async function switchView(view: View, listId?: string) { async function loadTodayView() { await loadAll() } -async function patchTask(task: Task, patch: Partial) { - const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) +function applyTaskUpdate(task: Task, updated: Task) { const index = tasks.value.findIndex((item) => item.id === task.id) if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated } const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id) @@ -557,20 +587,29 @@ async function patchTask(task: Task, patch: Partial) { else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated } } if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated } - return updated as Task } +async function patchTask(task: Task, patch: Partial) { + const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task + applyTaskUpdate(task, updated) + return updated +} +const taskMutationNavigation = ref(0) +const taskMutationReconciler = createMutationReconciler( + () => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value }), + (left, right) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted, + loadAll, +) async function toggle(task: Task) { const completing = !task.completed - try { - await patchTask(task, { completed: completing }) - if (completing) markTaskJustCompleted(task.id) - await loadTasksPage() - if (activeView.value === 'today') { - await loadOverdueTasks() - void loadTodayTaskSummary() - } - toast(task.completed ? '已重新打开' : '完成啦') - } catch (reason) { fail(reason) } + await taskMutationReconciler.run( + () => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise, + () => toast(completing ? '完成啦' : '已重新打开'), + fail, + (updated) => { + applyTaskUpdate(task, updated) + if (completing) markTaskJustCompleted(task.id) + }, + ) } function isInteractiveTarget(target: EventTarget | null) { return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label')) diff --git a/frontend/src/CountdownPanel.test.ts b/frontend/src/CountdownPanel.test.ts index 8f761b5..bce876f 100644 --- a/frontend/src/CountdownPanel.test.ts +++ b/frontend/src/CountdownPanel.test.ts @@ -44,12 +44,15 @@ describe('countdown modal accessibility', () => { expect(source).toContain(':disabled="form.ignore_year"') }) - it('shows cached countdowns immediately and refreshes active items first', () => { + it('shows cached countdowns immediately and shares one fresh/in-flight refresh', () => { expect(source).toContain('readCountdownCache()') - expect(source).toContain("const active = await request('/countdowns')") - expect(source).toContain("const archivedItems = await request('/countdowns?archived=true')") - expect(source).toContain('writeCountdownCache(items.value, archived.value)') - expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])") + expect(source).toContain('loadCountdownCache(fetchCountdowns, { force })') + expect(source).toContain("request('/countdowns') as Promise") + expect(source).toContain("request('/countdowns?archived=true') as Promise") + expect(source).toContain('const generation = getCountdownCacheGeneration()') + expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return') + expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) error.value=') + expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) busy.value=false') }) it('prevents duplicate submits and sends the edit precondition', () => { diff --git a/frontend/src/CountdownPanel.vue b/frontend/src/CountdownPanel.vue index c39ed7f..d55e985 100644 --- a/frontend/src/CountdownPanel.vue +++ b/frontend/src/CountdownPanel.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next' import { csrfHeader } from './lib/csrf' -import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, readCountdownCache, writeCountdownCache } from './lib/mvp-utils' +import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils' type Countdown = { id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' @@ -100,23 +100,28 @@ async function request(path:string, options:RequestInit={}) { return response.status === 204 ? null : response.json() } async function safe(work:()=>Promise) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } } -async function load() { +async function fetchCountdowns() { + const [active, archivedItems] = await Promise.all([ + request('/countdowns') as Promise, + request('/countdowns?archived=true') as Promise, + ]) + return { items: active, archived: archivedItems } +} +async function load(force = false) { + const generation = getCountdownCacheGeneration() const cached = readCountdownCache() if (cached) { items.value=cached.items; archived.value=cached.archived } if (!cached) busy.value=true error.value='' try { - const active = await request('/countdowns') as Countdown[] - items.value=active - writeCountdownCache(items.value, archived.value) - busy.value=false - const archivedItems = await request('/countdowns?archived=true') as Countdown[] - archived.value=archivedItems - writeCountdownCache(items.value, archived.value) + const data = await loadCountdownCache(fetchCountdowns, { force }) + if (!isCountdownCacheGenerationCurrent(generation)) return + items.value=data.items + archived.value=data.archived } catch(reason) { - error.value=reason instanceof Error ? reason.message : '请求失败' + if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败' } finally { - busy.value=false + if (isCountdownCacheGenerationCurrent(generation)) busy.value=false } } function edit(item:Countdown) { @@ -141,13 +146,13 @@ async function save() { if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day } const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns' await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) }) - closeDialog(); await load(); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加') + invalidateCountdownCache(); closeDialog(); await load(true); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加') }) } -async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});detailItem.value=null;await load();emit('notice','已置顶')})} -async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});detailItem.value=null;await load();emit('notice','已归档')})} -async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});await load();emit('notice','已恢复')})} -async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});await load();emit('notice','已永久删除')})} +async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已置顶')})} +async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})} +async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})} +async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);emit('notice','已永久删除')})} function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`} function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`} function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]} @@ -167,7 +172,7 @@ function trapDetailFocus(event: KeyboardEvent) { function openFromEmpty(){openCountdownComposer()} function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()} defineExpose({ openCountdownComposer }) -onMounted(load) +onMounted(() => { void load() }) onBeforeUnmount(() => { previousFocus = null }) diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index a3061ef..8c3a5db 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -20,6 +20,7 @@ const emit = defineEmits<{ const habits = ref([]) const archivedHabits = ref([]) const showArchivedHabits = ref(false) +const archivedHabitsLoaded = ref(false) const sessions = ref([]) const audit = ref([]) const busy = ref(false) @@ -396,7 +397,7 @@ async function archiveHabit(h: Habit) { await request(`/habits/${h.id}`, { method: 'DELETE' }) selectedHabit.value = null await loadHabits() - await loadArchivedHabits() + if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits() emit('notice', '习惯已归档') }) } @@ -411,10 +412,11 @@ async function deleteHabit(h: Habit) { } async function loadArchivedHabits() { archivedHabits.value = await request('/habits?archived=true') as Habit[] + archivedHabitsLoaded.value = true } async function toggleArchivedHabits() { showArchivedHabits.value = !showArchivedHabits.value - if (showArchivedHabits.value) await safe(loadArchivedHabits) + if (showArchivedHabits.value && !archivedHabitsLoaded.value) await safe(loadArchivedHabits) } function refreshHabitDay() { const next = dateKey(new Date()) @@ -505,7 +507,6 @@ onMounted(() => { if (props.view === 'habits' || props.view === 'today-habits') { refreshHabitDay() void loadHabits() - void loadArchivedHabits() dayRolloverTimer = setInterval(refreshHabitDay, 60_000) } else { void loadSettings() @@ -579,10 +580,10 @@ onBeforeUnmount(() => {
{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯,从一件容易坚持的小事开始。' }}
- +
-

暂无已归档习惯。

+

暂无已归档习惯。

diff --git a/frontend/src/lib/mvp-utils.ts b/frontend/src/lib/mvp-utils.ts index 80cce8e..d54bdce 100644 --- a/frontend/src/lib/mvp-utils.ts +++ b/frontend/src/lib/mvp-utils.ts @@ -153,7 +153,113 @@ export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) { } let habitGridCache: { week: string; habits: unknown[] } | null = null -let countdownCache: { items: unknown[]; archived: unknown[] } | null = null +let countdownCache: { items: unknown[]; archived: unknown[]; writtenAt: number } | null = null +let countdownInFlight: Promise<{ items: unknown[]; archived: unknown[] }> | null = null +let countdownCacheGeneration = 0 +const requestGenerations = new Map() + +export function beginLatestRequest(key: string) { + const generation = (requestGenerations.get(key) ?? 0) + 1 + requestGenerations.set(key, generation) + return generation +} + +export function isLatestRequest(key: string, generation: number) { + return requestGenerations.get(key) === generation +} + +export type RequestContext = { key: string; generation: number } + +export function captureRequestContext(key: string): RequestContext { + return { key, generation: requestGenerations.get(key) ?? 0 } +} + +export function commitIfRequestContextCurrent(context: RequestContext, commit: () => void) { + if (!isLatestRequest(context.key, context.generation)) return false + commit() + return true +} + +export async function runLatestRequest( + key: string, + request: () => Promise, + callbacks: { + success: (value: T) => void + error: (reason: unknown) => void + finally: () => void + }, +) { + const generation = beginLatestRequest(key) + try { + const value = await request() + if (isLatestRequest(key, generation)) callbacks.success(value) + } catch (reason) { + if (isLatestRequest(key, generation)) callbacks.error(reason) + } finally { + if (isLatestRequest(key, generation)) callbacks.finally() + } +} + +export type MutationReconciler = { + run( + mutation: () => Promise, + onSuccess: (value: T) => void, + onError?: (reason: unknown) => void, + onCurrentSuccess?: (value: T) => void, + ): Promise +} + +export function createMutationReconciler( + currentContext: () => TContext, + sameContext: (left: TContext, right: TContext) => boolean, + refresh: () => Promise, +): MutationReconciler { + let dirty = 0 + let reconciled = 0 + let refreshLoop: Promise | null = null + + const reconcile = (context: TContext) => { + if (!sameContext(context, currentContext())) return Promise.resolve() + dirty += 1 + if (!refreshLoop) { + refreshLoop = (async () => { + while (reconciled < dirty && sameContext(context, currentContext())) { + const target = dirty + await refresh() + if (!sameContext(context, currentContext())) break + reconciled = target + } + })().finally(() => { refreshLoop = null }) + } + return refreshLoop + } + + return { + async run(mutation, onSuccess, onError, onCurrentSuccess) { + const context = currentContext() + let value: Awaited> + try { + value = await mutation() + } catch (reason) { + onError?.(reason) + return + } + onSuccess(value) + if (sameContext(context, currentContext())) onCurrentSuccess?.(value) + await reconcile(context) + if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context) + }, + } +} + +export function startPrimaryWithBackground( + primary: Array<() => Promise>, + background: () => Promise, +) { + const pending = primary.map((start) => start()) + void background().catch(() => undefined) + return Promise.all(pending) +} export function readHabitGridCache(week: string): T[] | null { return habitGridCache?.week === week ? habitGridCache.habits as T[] : null @@ -164,11 +270,47 @@ export function writeHabitGridCache(week: string, habits: T[]) { } export function readCountdownCache() { - return countdownCache as { items: T[]; archived: T[] } | null + if (!countdownCache) return null + return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] } } -export function writeCountdownCache(items: T[], archived: T[]) { - countdownCache = { items, archived } +export function writeCountdownCache(items: T[], archived: T[], now = Date.now()) { + countdownCache = { items, archived, writtenAt: now } +} + +export function invalidateCountdownCache() { + countdownCacheGeneration += 1 + countdownCache = null + countdownInFlight = null +} + +export function getCountdownCacheGeneration() { + return countdownCacheGeneration +} + +export function isCountdownCacheGenerationCurrent(generation: number) { + return generation === countdownCacheGeneration +} + +export function loadCountdownCache( + fetcher: () => Promise<{ items: T[]; archived: T[] }>, + options: { now?: number; maxAge?: number; force?: boolean } = {}, +) { + const now = options.now ?? Date.now() + const maxAge = options.maxAge ?? 30_000 + if (!options.force && countdownCache && now - countdownCache.writtenAt < maxAge) { + return Promise.resolve(readCountdownCache()!) + } + if (countdownInFlight) return countdownInFlight as Promise<{ items: T[]; archived: T[] }> + const generation = countdownCacheGeneration + const pending = fetcher().then((data) => { + if (generation === countdownCacheGeneration) writeCountdownCache(data.items, data.archived, now) + return data + }).finally(() => { + if (countdownInFlight === pending) countdownInFlight = null + }) + countdownInFlight = pending as Promise<{ items: unknown[]; archived: unknown[] }> + return pending } export type HabitFormValues = { diff --git a/frontend/src/lib/performance.test.ts b/frontend/src/lib/performance.test.ts new file mode 100644 index 0000000..8eb3cf6 --- /dev/null +++ b/frontend/src/lib/performance.test.ts @@ -0,0 +1,282 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + beginLatestRequest, + captureRequestContext, + commitIfRequestContextCurrent, + getCountdownCacheGeneration, + invalidateCountdownCache, + isCountdownCacheGenerationCurrent, + isLatestRequest, + loadCountdownCache, + readCountdownCache, + runLatestRequest, + startPrimaryWithBackground, + createMutationReconciler, +} from './mvp-utils' + +const app = readFileSync('src/App.vue', 'utf8') +const habits = readFileSync('src/MvpPanel.vue', 'utf8') + +describe('request generation protection', () => { + it('allows only the latest task request to commit data, loading, and errors', () => { + const first = beginLatestRequest('tasks') + const second = beginLatestRequest('tasks') + expect(isLatestRequest('tasks', first)).toBe(false) + expect(isLatestRequest('tasks', second)).toBe(true) + }) + + it('does not let a completed mutation supersede newer navigation state', async () => { + const task = beginLatestRequest('tasks') + const mutation = captureRequestContext('tasks') + const events: string[] = [] + let finishMutation!: () => void + const pendingMutation = new Promise((resolve) => { finishMutation = resolve }).then(() => { + commitIfRequestContextCurrent(mutation, () => events.push('mutation:refresh')) + }) + + const navigation = beginLatestRequest('tasks') + finishMutation() + await pendingMutation + + expect(isLatestRequest('tasks', task)).toBe(false) + expect(isLatestRequest('tasks', navigation)).toBe(true) + expect(events).toEqual([]) + }) + + it('keeps both success notifications and performs a final refresh for out-of-order mutations', async () => { + const events: string[] = [] + const context = { view: 'today', list: 'inbox' } + let resolveFirst!: () => void + let resolveSecond!: () => void + let releaseRefresh!: () => void + const reconciler = createMutationReconciler( + () => context, + (left, right) => left.view === right.view && left.list === right.list, + () => new Promise((resolve) => { events.push('refresh'); releaseRefresh = resolve }), + ) + + const first = reconciler.run( + () => new Promise((resolve) => { resolveFirst = resolve }), + () => events.push('success:first'), + ) + const second = reconciler.run( + () => new Promise((resolve) => { resolveSecond = resolve }), + () => events.push('success:second'), + ) + + resolveSecond() + await Promise.resolve() + expect(events).toEqual(['success:second', 'refresh']) + resolveFirst() + await Promise.resolve() + expect(events).toEqual(['success:second', 'refresh', 'success:first']) + releaseRefresh() + await Promise.resolve() + expect(events).toEqual(['success:second', 'refresh', 'success:first', 'refresh']) + releaseRefresh() + await Promise.all([first, second]) + }) + + it('refreshes again when the first refresh finishes before the second mutation commits', async () => { + const events: string[] = [] + const context = { view: 'today' } + let resolveFirstMutation!: () => void + let resolveSecondMutation!: () => void + let resolveFirstRefresh!: () => void + const reconciler = createMutationReconciler( + () => context, + (left, right) => left.view === right.view, + () => { + events.push('refresh:start') + if (events.filter((event) => event === 'refresh:start').length === 1) { + return new Promise((resolve) => { resolveFirstRefresh = resolve }) + } + events.push('refresh:second-done') + return Promise.resolve() + }, + ) + + const first = reconciler.run( + () => new Promise((resolve) => { resolveFirstMutation = resolve }), + () => events.push('success:first'), + ) + const second = reconciler.run( + () => new Promise((resolve) => { resolveSecondMutation = resolve }), + () => events.push('success:second'), + ) + resolveFirstMutation() + await Promise.resolve() + expect(events).toEqual(['success:first', 'refresh:start']) + resolveFirstRefresh() + await first + resolveSecondMutation() + await second + expect(events).toEqual(['success:first', 'refresh:start', 'success:second', 'refresh:start', 'refresh:second-done']) + }) + + it('keeps success feedback but skips reconciliation after navigation changes', async () => { + const events: string[] = [] + let context = { view: 'today' } + let resolveMutation!: () => void + const reconciler = createMutationReconciler( + () => context, + (left, right) => left.view === right.view, + async () => { events.push('refresh') }, + ) + const mutation = reconciler.run( + () => new Promise((resolve) => { resolveMutation = resolve }), + () => events.push('success'), + ) + context = { view: 'tasks' } + resolveMutation() + await mutation + expect(events).toEqual(['success']) + }) + + it('prevents stale Trash success, error, and finally callbacks from committing', async () => { + const events: string[] = [] + let finishOld!: (value: string) => void + const oldRequest = runLatestRequest('trash', + () => new Promise((resolve) => { finishOld = resolve }), + { + success: (value) => events.push(`old:${value}`), + error: () => events.push('old:error'), + finally: () => events.push('old:finally'), + }, + ) + const currentRequest = runLatestRequest('trash', + async () => 'new', + { + success: (value) => events.push(`new:${value}`), + error: () => events.push('new:error'), + finally: () => events.push('new:finally'), + }, + ) + await currentRequest + finishOld('stale') + await oldRequest + expect(events).toEqual(['new:new', 'new:finally']) + }) + + it('prevents a stale Trash rejection from surfacing after a new view starts', async () => { + const events: string[] = [] + let rejectOld!: (reason: Error) => void + const oldRequest = runLatestRequest('trash', + () => new Promise((_resolve, reject) => { rejectOld = reject }), + { + success: () => events.push('old:success'), + error: () => events.push('old:error'), + finally: () => events.push('old:finally'), + }, + ) + beginLatestRequest('trash') + rejectOld(new Error('stale trash failure')) + await oldRequest + expect(events).toEqual([]) + }) + + it('wires Trash loading through its own generation and invalidates it on exit', () => { + const trashBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView')) + const switchBlock = app.slice(app.indexOf('async function switchView'), app.indexOf('async function loadTodayView')) + expect(trashBlock).toContain("await runLatestRequest('trash', loadTrashPage") + expect(trashBlock).toContain('success: (data) => {') + expect(trashBlock).toContain('error: fail') + expect(trashBlock).toContain('finally: () => { loading.value = false }') + expect(switchBlock).toContain("if (view !== 'trash') beginLatestRequest('trash')") + }) + + it('starts Today requests in the same turn without awaiting summary', async () => { + const events: string[] = [] + let finishTasks!: () => void + let finishOverdue!: () => void + let finishSummary!: () => void + const primary = startPrimaryWithBackground( + [ + () => new Promise((resolve) => { events.push('tasks:start'); finishTasks = resolve }), + () => new Promise((resolve) => { events.push('overdue:start'); finishOverdue = resolve }), + ], + () => new Promise((resolve) => { events.push('summary:start'); finishSummary = resolve }), + ).then(() => events.push('primary:done')) + + expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start']) + finishTasks() + finishOverdue() + await primary + expect(events).toEqual(['tasks:start', 'overdue:start', 'summary:start', 'primary:done']) + finishSummary() + }) + + it('keeps Today primary completion independent from summary failure', async () => { + const primary = startPrimaryWithBackground( + [() => Promise.resolve(), () => Promise.resolve()], + () => Promise.reject(new Error('summary unavailable')), + ) + await expect(primary).resolves.toEqual([undefined, undefined]) + }) +}) + +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')) + 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})` : '已归档' }}") + }) + + it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => { + 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">暂无已归档习惯。') + }) +}) + +describe('shared countdown cache', () => { + it('deduplicates prefetch and mount while a request is in flight', async () => { + invalidateCountdownCache() + let resolve!: (value: { items: unknown[]; archived: unknown[] }) => void + const fetcher = vi.fn(() => new Promise<{ items: unknown[]; archived: unknown[] }>((done) => { resolve = done })) + const first = loadCountdownCache(fetcher) + const second = loadCountdownCache(fetcher) + expect(fetcher).toHaveBeenCalledTimes(1) + resolve({ items: [{ id: 'one' }], archived: [] }) + await expect(first).resolves.toEqual({ items: [{ id: 'one' }], archived: [] }) + await expect(second).resolves.toEqual({ items: [{ id: 'one' }], archived: [] }) + }) + + it('does not let an invalidated in-flight result overwrite a newer forced load', async () => { + invalidateCountdownCache() + const oldGeneration = getCountdownCacheGeneration() + let resolveOld!: (value: { items: unknown[]; archived: unknown[] }) => void + const oldLoad = loadCountdownCache(() => new Promise<{ items: unknown[]; archived: unknown[] }>((resolve) => { resolveOld = resolve })) + + invalidateCountdownCache() + const newGeneration = getCountdownCacheGeneration() + const fresh = { items: [{ id: 'new' }], archived: [{ id: 'new-archived' }] } + await expect(loadCountdownCache(async () => fresh, { force: true })).resolves.toEqual(fresh) + resolveOld({ items: [{ id: 'old' }], archived: [{ id: 'old-archived' }] }) + const stale = await oldLoad + + const componentState = { items: fresh.items, archived: fresh.archived } + if (isCountdownCacheGenerationCurrent(oldGeneration)) Object.assign(componentState, stale) + expect(isCountdownCacheGenerationCurrent(oldGeneration)).toBe(false) + expect(isCountdownCacheGenerationCurrent(newGeneration)).toBe(true) + expect(componentState).toEqual(fresh) + expect(readCountdownCache()).toEqual(fresh) + }) + + it('reuses fresh data and invalidates it after a write', async () => { + invalidateCountdownCache() + const fetcher = vi.fn(async () => ({ items: [{ id: 'one' }], archived: [] })) + await loadCountdownCache(fetcher, { now: 1000 }) + await loadCountdownCache(fetcher, { now: 1500 }) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(readCountdownCache()).toEqual({ items: [{ id: 'one' }], archived: [] }) + invalidateCountdownCache() + await loadCountdownCache(fetcher, { now: 1600 }) + expect(fetcher).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 22f422e..c052277 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -227,7 +227,7 @@ describe('mobile list row language', () => { it('provides a minimal archived-habit viewing path', () => { expect(mvpPanel).toContain("request('/habits?archived=true')") - expect(mvpPanel).toContain('已归档({{ archivedHabits.length }})') + expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}") expect(mvpPanel).toContain('v-for="h in archivedHabits"') }) @@ -319,14 +319,13 @@ describe('task and habit row decoration', () => { expect(css).toContain('.task-check:focus-visible{') }) - it('refreshes the current task data after toggling completion', () => { + it('reconciles concurrent completion mutations without suppressing their feedback', () => { const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget')) expect(toggleBlock).toContain('const completing = !task.completed') - expect(toggleBlock).toContain('await patchTask(task, { completed: completing })') - expect(toggleBlock).toContain('await loadTasksPage()') - expect(toggleBlock).toContain("if (activeView.value === 'today')") - expect(toggleBlock).toContain('await loadOverdueTasks()') - expect(toggleBlock).toContain('void loadTodayTaskSummary()') + expect(toggleBlock).toContain('await taskMutationReconciler.run(') + expect(toggleBlock).toContain("() => toast(completing ? '完成啦' : '已重新打开')") + expect(toggleBlock).toContain('applyTaskUpdate(task, updated)') + expect(toggleBlock).not.toContain("beginLatestRequest('tasks')") }) it('shows the same visible round check control for boolean and numeric habits in both views', () => { @@ -634,11 +633,10 @@ describe('unified floating add interaction', () => { expect(app).toContain('重复次数') }) - it('preloads countdowns after authentication', () => { + it('preloads countdowns through the shared cache after authentication', () => { expect(app).toContain('void preloadCountdowns()') - expect(app).toContain("const active = await api('/countdowns')") - expect(app).toContain("const archived = await api('/countdowns?archived=true')") - expect(app).toContain('writeCountdownCache(active ?? [], archived ?? [])') + expect(app).toContain('await loadCountdownCache(async () =>') + expect(app).toContain("Promise.all([api('/countdowns'), api('/countdowns?archived=true')])") }) it('reuses one draggable FAB component for task, habit, and countdown views', () => {