perf: streamline view data loading
This commit is contained in:
+71
-32
@@ -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<TrashPage>
|
||||
}
|
||||
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<Task>) {
|
||||
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<Task>) {
|
||||
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<Task>) {
|
||||
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 })
|
||||
await taskMutationReconciler.run(
|
||||
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
|
||||
() => toast(completing ? '完成啦' : '已重新打开'),
|
||||
fail,
|
||||
(updated) => {
|
||||
applyTaskUpdate(task, updated)
|
||||
if (completing) markTaskJustCompleted(task.id)
|
||||
await loadTasksPage()
|
||||
if (activeView.value === 'today') {
|
||||
await loadOverdueTasks()
|
||||
void loadTodayTaskSummary()
|
||||
}
|
||||
toast(task.completed ? '已重新打开' : '完成啦')
|
||||
} catch (reason) { fail(reason) }
|
||||
},
|
||||
)
|
||||
}
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||
|
||||
@@ -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<Countdown>()')
|
||||
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<Countdown[]>")
|
||||
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
||||
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', () => {
|
||||
|
||||
@@ -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<void>) { 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<Countdown[]>,
|
||||
request('/countdowns?archived=true') as Promise<Countdown[]>,
|
||||
])
|
||||
return { items: active, archived: archivedItems }
|
||||
}
|
||||
async function load(force = false) {
|
||||
const generation = getCountdownCacheGeneration()
|
||||
const cached = readCountdownCache<Countdown>()
|
||||
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 })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const emit = defineEmits<{
|
||||
const habits = ref<Habit[]>([])
|
||||
const archivedHabits = ref<Habit[]>([])
|
||||
const showArchivedHabits = ref(false)
|
||||
const archivedHabitsLoaded = ref(false)
|
||||
const sessions = ref<Session[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
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(() => {
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯,从一件容易坚持的小事开始。' }}</div>
|
||||
<button class="archived-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>已归档({{ archivedHabits.length }})</button>
|
||||
<button class="archived-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="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。</p>
|
||||
<p v-if="archivedHabitsLoaded && !archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="countdown-detail">
|
||||
|
||||
@@ -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<string, number>()
|
||||
|
||||
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<T>(
|
||||
key: string,
|
||||
request: () => Promise<T>,
|
||||
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<TContext> = {
|
||||
run<T>(
|
||||
mutation: () => Promise<T>,
|
||||
onSuccess: (value: T) => void,
|
||||
onError?: (reason: unknown) => void,
|
||||
onCurrentSuccess?: (value: T) => void,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
export function createMutationReconciler<TContext>(
|
||||
currentContext: () => TContext,
|
||||
sameContext: (left: TContext, right: TContext) => boolean,
|
||||
refresh: () => Promise<unknown>,
|
||||
): MutationReconciler<TContext> {
|
||||
let dirty = 0
|
||||
let reconciled = 0
|
||||
let refreshLoop: Promise<void> | 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<ReturnType<typeof mutation>>
|
||||
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<T>(
|
||||
primary: Array<() => Promise<T>>,
|
||||
background: () => Promise<unknown>,
|
||||
) {
|
||||
const pending = primary.map((start) => start())
|
||||
void background().catch(() => undefined)
|
||||
return Promise.all(pending)
|
||||
}
|
||||
|
||||
export function readHabitGridCache<T>(week: string): T[] | null {
|
||||
return habitGridCache?.week === week ? habitGridCache.habits as T[] : null
|
||||
@@ -164,11 +270,47 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
|
||||
}
|
||||
|
||||
export function readCountdownCache<T>() {
|
||||
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<T>(items: T[], archived: T[]) {
|
||||
countdownCache = { items, archived }
|
||||
export function writeCountdownCache<T>(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<T>(
|
||||
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<T>()!)
|
||||
}
|
||||
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 = {
|
||||
|
||||
@@ -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<void>((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<void>((resolve) => { events.push('refresh'); releaseRefresh = resolve }),
|
||||
)
|
||||
|
||||
const first = reconciler.run(
|
||||
() => new Promise<void>((resolve) => { resolveFirst = resolve }),
|
||||
() => events.push('success:first'),
|
||||
)
|
||||
const second = reconciler.run(
|
||||
() => new Promise<void>((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<void>((resolve) => { resolveFirstRefresh = resolve })
|
||||
}
|
||||
events.push('refresh:second-done')
|
||||
return Promise.resolve()
|
||||
},
|
||||
)
|
||||
|
||||
const first = reconciler.run(
|
||||
() => new Promise<void>((resolve) => { resolveFirstMutation = resolve }),
|
||||
() => events.push('success:first'),
|
||||
)
|
||||
const second = reconciler.run(
|
||||
() => new Promise<void>((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<void>((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<string>((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<void>((_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<void>((resolve) => { events.push('tasks:start'); finishTasks = resolve }),
|
||||
() => new Promise<void>((resolve) => { events.push('overdue:start'); finishOverdue = resolve }),
|
||||
],
|
||||
() => new Promise<void>((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<unknown>(() => 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)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user