diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8a1b672..0c81d1d 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 { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' -import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils' +import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, writeCountdownCache } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' @@ -216,6 +216,7 @@ async function bootstrap() { activeList.value = data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '' expandedFolders.value = new Set(folders.value.map((folder) => folder.id)) await loadArchivedLists() + void preloadCountdowns() await loadAll() } } catch { @@ -237,6 +238,7 @@ async function submitAuth() { navigationLoaded.value = true activeList.value = data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '' expandedFolders.value = new Set(folders.value.map((folder) => folder.id)) + void preloadCountdowns() await loadAll() } catch (reason) { fail(reason) } } @@ -279,6 +281,14 @@ async function loadNavigation(force = false) { expandedFolders.value = new Set(folders.value.map((folder) => folder.id)) await loadArchivedLists() } +async function preloadCountdowns() { + try { + const active = await api('/countdowns') + writeCountdownCache(active ?? [], []) + const archived = await api('/countdowns?archived=true') + writeCountdownCache(active ?? [], archived ?? []) + } catch { /* 倒数日页会在打开时重试 */ } +} async function loadAll() { loading.value = true; error.value = '' try { diff --git a/frontend/src/CountdownPanel.test.ts b/frontend/src/CountdownPanel.test.ts index dfa12de..20cb09f 100644 --- a/frontend/src/CountdownPanel.test.ts +++ b/frontend/src/CountdownPanel.test.ts @@ -30,6 +30,14 @@ describe('countdown modal accessibility', () => { expect(source).toContain(':disabled="form.ignore_year"') }) + it('shows cached countdowns immediately and refreshes active items first', () => { + 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')])") + }) + it('keeps the empty state directly actionable', () => { expect(source).toContain('添加第一个重要日子') expect(source).toContain('@click="openFromEmpty"') diff --git a/frontend/src/CountdownPanel.vue b/frontend/src/CountdownPanel.vue index 6250284..da123a1 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 } from './lib/mvp-utils' +import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, readCountdownCache, writeCountdownCache } from './lib/mvp-utils' type Countdown = { id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' @@ -99,7 +99,25 @@ 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() { await safe(async()=>{ const [a,b]=await Promise.all([request('/countdowns'),request('/countdowns?archived=true')]); items.value=a; archived.value=b }) } +async function load() { + 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) + } catch(reason) { + error.value=reason instanceof Error ? reason.message : '请求失败' + } finally { + busy.value=false + } +} function edit(item:Countdown) { detailItem.value=null editingId.value=item.id diff --git a/frontend/src/lib/mvp-utils.test.ts b/frontend/src/lib/mvp-utils.test.ts index eba6a5a..c1da7d8 100644 --- a/frontend/src/lib/mvp-utils.test.ts +++ b/frontend/src/lib/mvp-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readHabitGridCache, shouldToggleRowSwipe, writeHabitGridCache } from './mvp-utils' +import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, shouldToggleRowSwipe, writeCountdownCache, writeHabitGridCache } from './mvp-utils' describe('MVP view utilities', () => { it('formats a local date as YYYY-MM-DD', () => { @@ -77,6 +77,13 @@ describe('MVP view utilities', () => { expect(readHabitGridCache('2026-09-14')).toBeNull() }) + it('reuses countdown data while the page refreshes in the background', () => { + const active = [{ id: 'countdown-1', title: '旅行' }] + const archived = [{ id: 'countdown-2', title: '旧日期' }] + writeCountdownCache(active, archived) + expect(readCountdownCache()).toEqual({ items: active, archived }) + }) + it('renders countdown labels from date-only day values', () => { expect(countdownDayText(8)).toBe('还有 8 天') expect(countdownDayText(0)).toBe('就是今天') diff --git a/frontend/src/lib/mvp-utils.ts b/frontend/src/lib/mvp-utils.ts index f3a3a98..ea80e83 100644 --- a/frontend/src/lib/mvp-utils.ts +++ b/frontend/src/lib/mvp-utils.ts @@ -115,6 +115,7 @@ 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 export function readHabitGridCache(week: string): T[] | null { return habitGridCache?.week === week ? habitGridCache.habits as T[] : null @@ -124,6 +125,14 @@ export function writeHabitGridCache(week: string, habits: T[]) { habitGridCache = { week, habits } } +export function readCountdownCache() { + return countdownCache as { items: T[]; archived: T[] } | null +} + +export function writeCountdownCache(items: T[], archived: T[]) { + countdownCache = { items, archived } +} + export function formatApiErrorDetail(detail: unknown): string { if (typeof detail === 'string') return detail if (Array.isArray(detail)) { diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 1bd220a..1f49000 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -156,6 +156,13 @@ describe('mobile touch targets', () => { }) describe('unified floating add interaction', () => { + it('preloads countdowns 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 ?? [])') + }) + it('reuses one draggable FAB component for task, habit, and countdown views', () => { expect(app).toContain("import FloatingAddButton from './components/FloatingAddButton.vue'") expect(app).toContain(' { }) it('loads archived lists during bootstrap so archived rows are visible after refresh', () => { - expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n await loadAll()') + expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n await loadAll()') }) it('styles archived rows through the new list-row-main structure', () => {