fix: harden async task and habit mutations
ci / gitleaks (push) Successful in 1m50s
ci / docker (push) Successful in 9m26s

This commit is contained in:
2026-09-19 10:00:23 +08:00
parent 0baedbc99e
commit cec0a4418c
8 changed files with 1593 additions and 164 deletions
+214 -78
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
@@ -60,6 +60,7 @@ const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_
const activeList = ref(restoredNavigation.listId)
const activeView = ref<View>(restoredNavigation.view)
const selectedTask = ref<Task | null>(null)
const taskSelectionGeneration = ref(0)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
@@ -137,6 +138,8 @@ const taskReorderTarget = ref('')
const taskReorderBlocked = ref(false)
const taskReorderMode = ref(false)
const taskComposeOpen = ref(false)
const taskComposeGeneration = ref(0)
const creatingTask = ref(false)
const composeTitle = ref('')
const composeTitleError = ref('')
const composeListId = ref('')
@@ -161,8 +164,10 @@ const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false)
const savingSelectedTask = ref(false)
const removingTaskId = ref<string | null>(null)
const addingSubtask = ref(false)
const removingSubtaskId = ref<string | null>(null)
const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingSubtaskId.value !== null)
const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingTaskId.value !== null || addingSubtask.value || removingSubtaskId.value !== null)
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
@@ -186,6 +191,7 @@ let suppressTaskClickId = ''
const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` }))
function openTaskCompose() {
taskComposeGeneration.value += 1
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
composeTitle.value = ''
composeTitleError.value = ''
@@ -222,7 +228,12 @@ function addComposeTime() {
try { picker?.showPicker?.() } catch { picker?.focus() }
})
}
function closeTaskCompose() { composeCalendarOpen.value = false; taskComposeOpen.value = false }
function closeTaskCompose() {
if (creatingTask.value) return
taskComposeGeneration.value += 1
composeCalendarOpen.value = false
taskComposeOpen.value = false
}
function activateFloatingAdd(origin: { x: number; y: number }) {
composeOrigin.value = origin
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
@@ -244,6 +255,8 @@ async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatCon
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
const selectionGeneration = taskSelectionGeneration.value
const selectionIsCurrent = () => token === recurrenceLoadToken && taskSelectionGeneration.value === selectionGeneration && selectedTask.value?.id === task.id
recurrenceLoading.value = true
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
@@ -251,19 +264,20 @@ async function loadTaskRecurrence(task: Task) {
selectedRepeatError.value = ''
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
if (!selectionIsCurrent()) return
selectedTaskRecurrence.value = recurrence
const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) {
if (token === recurrenceLoadToken) fail(reason)
if (selectionIsCurrent()) fail(reason)
} finally {
if (token === recurrenceLoadToken) recurrenceLoading.value = false
if (selectionIsCurrent()) recurrenceLoading.value = false
}
}
async function submitTaskCompose() {
if (creatingTask.value) return
const normalized = normalizeRequiredName(composeTitle.value)
if (normalized.error) {
composeTitleError.value = normalized.error
@@ -273,27 +287,40 @@ async function submitTaskCompose() {
composeTitle.value = taskTitle
composeTitleError.value = ''
if (!composeListId.value) return
const composeGeneration = taskComposeGeneration.value
const targetListId = composeListId.value
creatingTask.value = true
try {
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
await taskMutationReconciler.run(
() => api('/tasks', { method: 'POST', body: JSON.stringify({
title: taskTitle,
list_id: composeListId.value,
list_id: targetListId,
due_at: fromDateTimeLocal(dueValue),
due_has_time: composeHasTime.value,
priority: composePriority.value,
description: composeDescription.value,
...recurrencePayload,
}) })
if (isTaskView(activeView.value)) {
tasks.value.push(task)
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
}
if (activeView.value === 'today') void loadTodayTaskSummary()
taskComposeOpen.value = false
}) }) as Promise<Task>,
() => {
if (taskComposeGeneration.value === composeGeneration) taskComposeOpen.value = false
toast('任务已添加')
} catch (reason) { composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'; fail(reason) }
},
(reason) => {
if (taskComposeGeneration.value !== composeGeneration) return
composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'
fail(reason)
},
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
)
} catch (reason) {
if (taskComposeGeneration.value === composeGeneration) {
composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'
fail(reason)
}
} finally { creatingTask.value = false }
}
function toggleSidebar() {
const compact = window.matchMedia('(max-width: 930px)').matches
@@ -670,6 +697,7 @@ async function loadTrash() {
async function switchView(view: View, listId?: string) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
taskMutationNavigation.value += 1
taskCompletionExitCoordinator.clearAll()
taskReorderMode.value = false
cancelTaskReorder()
if (view !== 'today') {
@@ -690,7 +718,7 @@ async function switchView(view: View, listId?: string) {
if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
selectedTask.value = null; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
if (view !== 'memos') memoDetailOpen.value = false
if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView()
@@ -721,32 +749,104 @@ function applyTaskUpdate(task: Task, updated: Task, updateSelected = true) {
}
}
async function patchTask(task: Task, patch: Partial<Task>, updateSelected = true) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task
const result: { value?: Task } = {}
let mutationError: unknown
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(taskVersionedPatchPayload(task, patch)) }) as Promise<Task>,
(updated) => {
result.value = updated
applyTaskUpdate(task, updated, updateSelected)
return updated
},
(reason) => { mutationError = reason },
)
if (mutationError) throw mutationError
return result.value ?? false
}
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,
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; query: string; showCompleted: boolean }
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value })
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted
async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) {
await reconcileCurrentTaskView({
capture: currentTaskViewContext,
isTaskBacked: context => isTaskView(context.view),
isTrash: context => context.view === 'trash',
sameContext: sameTaskViewContext,
affectsTrash: options.affectsTrash,
affectsTaskView: options.affectsTaskView,
loadTrash: async (ownership) => {
if (ownership.current()) await loadTrash()
},
loadTaskView: async (context, ownership) => {
const request = beginLatestRequest('tasks')
loading.value = true
error.value = ''
try {
if (context.view === 'today') {
await startPrimaryWithBackground(
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
loadTodayTaskSummary,
)
async function toggle(task: Task) {
const completing = !task.completed
const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value })
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
() => toast(completing ? '完成啦' : '已重新打开'),
fail,
async (updated) => {
if (animateExit) setTaskCompletionExiting(task.id, true)
applyTaskUpdate(task, updated)
if (completing) markTaskJustCompleted(task.id)
if (animateExit) {
await waitForCompletionExit()
setTaskCompletionExiting(task.id, false)
} else {
await loadTasksPage(request)
if (ownership.current() && isLatestRequest('tasks', request)) overdueTasks.value = []
}
if (activeView.value === 'today' && showCompleted.value) await loadAll()
if (ownership.current() && isLatestRequest('tasks', request) && page.value > totalPages.value) {
page.value = totalPages.value
await loadTasksPage(request)
}
} catch (reason) {
if (ownership.current() && isLatestRequest('tasks', request)) fail(reason)
} finally {
if (ownership.current() && isLatestRequest('tasks', request)) loading.value = false
}
},
})
}
const taskMutationReconciler = createMutationReconciler(
currentTaskViewContext,
sameTaskViewContext,
reconcileCurrentViewAfterTaskMutation,
)
const taskToggleCoordinator = createTaskToggleCoordinator<Task>()
const taskCompletionExitCoordinator = createTaskCompletionExitCoordinator(setTaskCompletionExiting)
async function toggle(task: Task) {
taskCompletionExitCoordinator.supersede(task.id)
const preserveSelectedDraft = selectedTask.value?.id === task.id
const navigation = taskMutationNavigation.value
const view = activeView.value
const listId = activeList.value
const selectionGeneration = taskSelectionGeneration.value
const isCurrentToggle = () => taskMutationNavigation.value === navigation && activeView.value === view && activeList.value === listId
await taskToggleCoordinator.toggle(
task.id,
task,
(payload) => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(payload) }) as Promise<Task>,
{
current: isCurrentToggle,
beforeReconcile: async (updated, ownership) => {
if (!ownership.current()) return
const completing = updated.completed
const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value })
const exitOwnership = taskCompletionExitCoordinator.begin(task.id, animateExit)
applyTaskUpdate(task, updated, !preserveSelectedDraft)
if (preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
selectedTask.value = mergeTaskToggleResponse(selectedTask.value, updated)
}
if (completing) markTaskJustCompleted(task.id)
await taskCompletionExitCoordinator.wait(task.id, exitOwnership, waitForCompletionExit)
},
reconcile: async (confirmed, ownership) => {
await reconcileCurrentViewAfterTaskMutation()
if (ownership.current() && preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
selectedTask.value = mergeTaskToggleResponse(selectedTask.value, confirmed)
}
},
success: async (updated, ownership) => {
const completing = updated.completed
if (ownership.current()) toast(completing ? '完成啦' : '已重新打开')
},
error: (_confirmed, reason, ownership) => { if (ownership.current()) fail(reason) },
},
)
}
@@ -817,14 +917,15 @@ async function finishTaskReorder(task: Task, event: PointerEvent) {
tasks.value = next
ids = next.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id)
}
try {
await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) })
toast('顺序已保存')
} catch (reason) {
await taskMutationReconciler.run(
() => api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }),
() => toast('顺序已保存'),
(reason) => {
tasks.value = previous
selectedTask.value = previousSelected
fail(reason)
}
},
)
}
function cancelTaskReorder() {
taskReorder.value = null
@@ -936,6 +1037,7 @@ async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: stri
const due = buildTaskDueDraft({ date: selectedDueDate.value, hasTime: selectedDueHasTime.value, time: selectedDueTime.value })
try {
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), ...due, list_id: task.list_id } as Partial<Task>, false)
if (!updated) return false
const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken
if (selectedTask.value?.id === task.id && selectionMatches) {
selectedTask.value = { ...selectedTask.value, ...updated }
@@ -982,47 +1084,70 @@ async function saveSelectedTaskChanges() {
}
}
async function removeTask(task: Task) {
if (taskDetailBusy.value) return
if (taskDetailBusy.value || removingTaskId.value === task.id) return
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
if (taskDetailBusy.value || selectedTask.value?.id !== task.id) return
const selectionGeneration = taskSelectionGeneration.value
removingTaskId.value = task.id
try {
await api(`/tasks/${task.id}`, { method: 'DELETE' })
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
selectedTask.value = null
mobileDetail.value = false
if (activeView.value === 'today') void loadTodayTaskSummary()
toast('已移到回收站')
} catch (reason) { fail(reason) }
}
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string) {
const result = await performTrashMutation(
mutation,
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'DELETE' }),
() => {
trash.value = trash.value.filter((item) => item.id !== task.id)
totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)
if (page.value > totalPages.value) page.value = totalPages.value
},
loadTrash,
)
if (!result.mutated) {
fail(result.error)
return
if (selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
selectedTask.value = null
taskSelectionGeneration.value += 1
mobileDetail.value = false
}
if (!result.refreshed) toast(`${successMessage},但列表刷新失败,请重试刷新`)
else toast(successMessage)
toast('已移到回收站')
},
fail,
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
{ affectsTrash: true },
)
} finally { if (removingTaskId.value === task.id) removingTaskId.value = null }
}
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string, affectsTaskView: boolean) {
await taskMutationReconciler.run(
mutation,
() => toast(successMessage),
fail,
undefined,
{ affectsTrash: true, affectsTaskView },
)
}
async function restoreTask(task: Task) {
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
}
async function purgeTask(task: Task) {
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
}
async function addSubtask() {
if (!selectedTask.value) return
if (!selectedTask.value || addingSubtask.value) return
const parent = selectedTask.value
const parentId = parent.id
const parentListId = parent.list_id
const selectionGeneration = taskSelectionGeneration.value
const subtaskTitle = (await askText('添加子任务', '子任务名称', '', '添加'))?.trim()
if (!subtaskTitle) return
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
if (!subtaskTitle || addingSubtask.value) return
addingSubtask.value = true
try {
await taskMutationReconciler.run(
() => api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: parentListId, parent_id: parentId }) }) as Promise<Task>,
(child) => {
if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) {
selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]
}
toast('子任务已添加')
},
(reason) => {
if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) fail(reason)
},
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
)
} finally {
addingSubtask.value = false
}
}
async function removeSubtask(subtask: Task) {
if (taskDetailBusy.value) return
@@ -1030,19 +1155,29 @@ async function removeSubtask(subtask: Task) {
if (!parent || subtask.parent_id !== parent.id) return
if (!(await confirmAction(`删除子任务“${subtask.title}”?`, '子任务将移到回收站。', true))) return
if (taskDetailBusy.value || selectedTask.value?.id !== parent.id) return
const selectionGeneration = taskSelectionGeneration.value
removingSubtaskId.value = subtask.id
try {
await api(`/tasks/${subtask.id}`, { method: 'DELETE' })
if (selectedTask.value?.id !== parent.id) return
parent.subtasks = (parent.subtasks ?? []).filter((item) => item.id !== subtask.id)
tasks.value = tasks.value.filter((item) => item.id !== subtask.id)
await taskMutationReconciler.run(
() => api(`/tasks/${subtask.id}`, { method: 'DELETE' }),
() => {
if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) {
selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter((item) => item.id !== subtask.id)
}
toast('子任务已删除')
} catch (reason) { fail(reason) }
finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null }
},
(reason) => {
if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) fail(reason)
},
undefined,
{ affectsTaskView: true },
)
} finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null }
}
function closeTaskDetail() {
if (taskDetailBusy.value) return
recurrenceLoadToken += 1
taskSelectionGeneration.value += 1
mobileDetail.value = false
selectedTask.value = null
}
@@ -1059,6 +1194,7 @@ function clearSelectedDueDate() {
selectedTaskRepeat.value = 'none'
}
function selectTask(task: Task) {
taskSelectionGeneration.value += 1
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
const due = parseTaskDueDraft(task.due_at, task.due_has_time)
selectedDueDate.value = due.date
@@ -1676,7 +1812,7 @@ onUnmounted(() => {
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选支持 Markdown"/></label>
</div>
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</button></footer>
</AppSheet>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
+73 -41
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next'
import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
import { archivePanelFlags, changedHabitFields, createHabitMutationCoordinator, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
@@ -97,6 +97,8 @@ const confirmPassword = ref('')
const passwordBusy = ref(false)
const passwordError = ref('')
const todayKey = ref(dateKey(new Date()))
let habitMutationGeneration = 0
let habitMutationMounted = true
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitSwipeOffsets = ref<Record<string, number>>({})
@@ -110,12 +112,12 @@ function setHabitCompletionExiting(id: string, active: boolean) {
active ? next.add(id) : next.delete(id)
completionExitingHabitIds.value = next
}
async function finishHabitCompletion(h: Habit, wasDone: boolean, next: number | boolean, animateExit: boolean) {
if (wasDone || !isHabitComplete(h.kind, next, h.target ?? 1)) return
async function finishHabitCompletion(h: Habit, wasDone: boolean, next: number | boolean, animateExit: boolean, current: () => boolean = () => true) {
if (!current() || wasDone || !isHabitComplete(h.kind, next, h.target ?? 1)) return
markHabitJustCompleted(h.id)
if (!animateExit) return
await waitForCompletionExit()
setHabitCompletionExiting(h.id, false)
if (current()) setHabitCompletionExiting(h.id, false)
}
const markHabitJustCompleted = createCompletionPulse(
(id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) },
@@ -129,6 +131,10 @@ const todayHabitSummary = computed(() => ({
total: todayHabits.value.length,
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
}))
watch(() => props.view, () => {
habitMutationGeneration += 1
completionExitingHabitIds.value = new Set()
})
watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true })
watch(habitReorderAvailable, (available) => {
if (!available) habitReorderMode.value = false
@@ -251,19 +257,52 @@ function habitRowStatus(h: Habit) {
return habitAction(h).reason || habitProgressText(h) || (isDone(h, todayKey.value) ? '已完成' : '未完成')
}
function setLocalHabitValue(h: Habit, next: number | boolean) {
function setLocalHabitValue(h: Habit, next: number | boolean, day = todayKey.value) {
const update = (item: Habit) => item.id !== h.id
? item
: {
...item,
cells: (item.cells ?? []).map((cell) => cell.day === todayKey.value ? { ...cell, value: next } : cell),
cells: (item.cells ?? []).map((cell) => cell.day === day ? { ...cell, value: next } : cell),
}
habits.value = habits.value.map(update)
if (selectedHabit.value?.id === h.id) selectedHabit.value = update(selectedHabit.value)
if (selectedHabit.value?.id === h.id && day === todayKey.value) selectedHabit.value = update(selectedHabit.value)
}
function syncHabitHistoryToday(h: Habit) {
if (selectedHabit.value?.id !== h.id) return
void loadHabitHistory(true)
function syncHabitHistoryToday(h: Habit, day = todayKey.value) {
if (day !== todayKey.value || selectedHabit.value?.id !== h.id) return Promise.resolve()
return loadHabitHistory(true).then(() => undefined)
}
const habitMutationCoordinator = createHabitMutationCoordinator<number | boolean>()
async function mutateHabitValue(h: Habit, next: number | boolean, previous: number | boolean, wasDone: boolean, animateExit: boolean, notice: string) {
const day = todayKey.value
const view = props.view
const generation = habitMutationGeneration
const mutationKey = `${h.id}:${day}`
const isCurrentMutation = () => habitMutationMounted && habitMutationGeneration === generation && props.view === view && todayKey.value === day
setHabitCompletionExiting(h.id, animateExit)
setLocalHabitValue(h, next, day)
await habitMutationCoordinator.run(
mutationKey,
previous,
next,
() => request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) }),
{
current: isCurrentMutation,
success: async (ownership) => {
if (!ownership.current()) return
await syncHabitHistoryToday(h, day)
if (!ownership.current()) return
await finishHabitCompletion(h, wasDone, next, animateExit, ownership.current)
if (ownership.current()) emit('notice', notice)
},
error: (rollback, reason, ownership) => {
if (!ownership.current()) return
setHabitCompletionExiting(h.id, false)
setLocalHabitValue(h, rollback, day)
error.value = reason instanceof Error ? reason.message : '请求失败'
},
},
)
}
async function applyHabitSwipe(h: Habit, deltaX: number) {
@@ -275,21 +314,7 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
: previousHabitSwipeValue(h.kind, current)
const previous = current ?? 0
const animateExit = !wasDone && isHabitComplete(h.kind, next, h.target ?? 1) && shouldAnimateCompletionExit({ completing: true, showCompleted: props.showCompleted })
if (!animateExit) setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
syncHabitHistoryToday(h)
if (animateExit) {
setHabitCompletionExiting(h.id, true)
setLocalHabitValue(h, next)
}
await finishHabitCompletion(h, wasDone, next, animateExit)
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
} catch (e) {
setHabitCompletionExiting(h.id, false)
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
await mutateHabitValue(h, next, previous, wasDone, animateExit, next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
}
function suppressHabitDetailClick() {
@@ -344,18 +369,7 @@ async function toggleHabitFromButton(h: Habit) {
const previous = current ?? 0
const next = habitButtonValue(h.kind, current, h.target ?? 1)
const animateExit = !wasDone && isHabitComplete(h.kind, next, h.target ?? 1) && shouldAnimateCompletionExit({ completing: true, showCompleted: props.showCompleted })
if (animateExit) setHabitCompletionExiting(h.id, true)
setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
syncHabitHistoryToday(h)
await finishHabitCompletion(h, wasDone, next, animateExit)
emit('notice', habitButtonNotice(h.kind, previous, next))
} catch (e) {
setHabitCompletionExiting(h.id, false)
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
await mutateHabitValue(h, next, previous, wasDone, animateExit, habitButtonNotice(h.kind, previous, next))
}
function currentHabitForm(): HabitFormValues {
return {
@@ -427,6 +441,7 @@ function openHabitComposer(origin?: { x: number; y: number }) {
void nextTick(() => habitNameInput.value?.focus())
}
function editHabit(h: Habit) {
if (busy.value) return
editingHabit.value = h
habitName.value = h.name
habitType.value = h.kind === 'numeric' ? 'numeric' : 'boolean'
@@ -444,7 +459,12 @@ function editHabit(h: Habit) {
habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus())
}
function closeHabitComposer() { habitComposerOpen.value = false; editingHabit.value = null; originalHabitForm.value = null }
function closeHabitComposer() {
if (busy.value) return
habitComposerOpen.value = false
editingHabit.value = null
originalHabitForm.value = null
}
function formatHabitHistoryDay(day: string) {
const [year, month, date] = day.split('-').map(Number)
const value = new Date(year, month - 1, date)
@@ -521,14 +541,22 @@ function closeHabitDetail() {
}
defineExpose({ openHabitComposer, refreshHabits: loadHabits, refreshSettings: loadSettings })
async function archiveHabit(h: Habit) {
if (busy.value) return
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
await safe(async () => {
if (busy.value) return
busy.value = true
error.value = ''
try {
await request(`/habits/${h.id}`, { method: 'DELETE' })
selectedHabit.value = null
await loadHabits()
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
emit('notice', '习惯已归档')
})
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '请求失败'
} finally {
busy.value = false
}
}
async function restoreHabit(h: Habit) {
if (!h.archived_at || busy.value) return
@@ -589,6 +617,8 @@ async function toggleArchivedHabits() {
function refreshHabitDay() {
const next = dateKey(new Date())
if (next !== todayKey.value) {
habitMutationGeneration += 1
completionExitingHabitIds.value = new Set()
todayKey.value = next
if (props.view === 'habits') void loadHabits()
}
@@ -762,6 +792,8 @@ onMounted(() => {
}
})
onBeforeUnmount(() => {
habitMutationMounted = false
habitMutationGeneration += 1
cancelPreflight()
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
})
@@ -864,7 +896,7 @@ onBeforeUnmount(() => {
<small v-else-if="habitHistory.length && !habitHistoryLoading" class="habit-history__end">没有更早记录了</small>
</section>
</div>
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer habit-detail-active-actions"><button type="button" class="habit-detail-archive-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button><button type="button" class="soft-button habit-detail-edit-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button></footer>
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer habit-detail-active-actions"><button type="button" class="habit-detail-archive-button" :disabled="busy" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button><button type="button" class="soft-button habit-detail-edit-button" :disabled="busy" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button></footer>
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"><button type="button" class="danger-text habit-delete-button" :disabled="busy" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button><button type="button" class="soft-button habit-restore-button" :disabled="busy" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button></footer>
</template>
</AppSheet>
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from 'vitest'
import { createMutationReconciler, reconcileCurrentTaskView } from './lib/mvp-utils'
type ViewContext = { navigation: number; view: 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits'; listId: string }
type State = { tasks: string[]; total: number; loading: boolean; error: string }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => { resolve = res })
return { promise, resolve }
}
const sameContext = (left: ViewContext, right: ViewContext) =>
left.navigation === right.navigation && left.view === right.view && left.listId === right.listId
function mutationHarness() {
let current: ViewContext = { navigation: 1, view: 'tasks', listId: 'inbox' }
const state: State = { tasks: ['inbox task'], total: 1, loading: false, error: '' }
const loadTaskView = vi.fn(async (context: ViewContext, ownership: { current: () => boolean }) => {
if (ownership.current()) Object.assign(state, { tasks: [`${context.listId} task`], total: 1, loading: false, error: '' })
})
const loadTrash = vi.fn(async (ownership: { current: () => boolean }) => {
if (ownership.current()) Object.assign(state, { tasks: ['trashed task'], total: 7, loading: false, error: '' })
})
const reconcileCurrentView = (options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) => reconcileCurrentTaskView({
capture: () => current,
isTaskBacked: context => ['tasks', 'today', 'upcoming'].includes(context.view),
isTrash: context => context.view === 'trash',
sameContext,
loadTaskView,
loadTrash,
affectsTrash: options.affectsTrash,
affectsTaskView: options.affectsTaskView,
})
const reconciler = createMutationReconciler(
() => current,
sameContext,
reconcileCurrentView,
)
return {
state,
loadTaskView,
loadTrash,
reconciler,
navigate(next: ViewContext, nextState: State) {
current = next
Object.assign(state, nextState)
},
}
}
describe('current-view-aware task mutation reconciliation', () => {
it.each([
{ mutation: 'create', affectsTrash: false },
{ mutation: 'add subtask', affectsTrash: false },
{ mutation: 'delete', affectsTrash: true },
{ mutation: 'remove subtask', affectsTrash: true },
])('keeps resolved Trash authoritative after a stale $mutation settles', async ({ affectsTrash }) => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash },
)
harness.navigate(
{ navigation: 2, view: 'trash', listId: 'inbox' },
{ tasks: ['target trash'], total: 9, loading: false, error: 'target error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
if (affectsTrash) {
expect(harness.loadTrash).toHaveBeenCalledOnce()
expect(harness.state).toEqual({ tasks: ['trashed task'], total: 7, loading: false, error: '' })
} else {
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['target trash'], total: 9, loading: false, error: 'target error' })
}
})
it.each(['create', 'delete', 'add subtask', 'remove subtask'])('keeps a non-task view authoritative after a stale %s settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true },
)
harness.navigate(
{ navigation: 2, view: 'habits', listId: 'inbox' },
{ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' })
})
it('does not reload Trash when a stale subtask removal settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: false },
)
harness.navigate(
{ navigation: 2, view: 'trash', listId: 'inbox' },
{ tasks: ['target trash'], total: 9, loading: false, error: 'target error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['target trash'], total: 9, loading: false, error: 'target error' })
})
it.each([
{ mutation: 'restore', affectsTaskView: true, expectedTaskLoads: 1 },
{ mutation: 'purge', affectsTaskView: false, expectedTaskLoads: 0 },
])('reconciles stale Trash $mutation only when it can affect the new task-backed view', async ({ affectsTaskView, expectedTaskLoads }) => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true, affectsTaskView },
)
harness.navigate(
{ navigation: 2, view: 'tasks', listId: 'work' },
{ tasks: ['target work'], total: 3, loading: false, error: '' },
)
request.resolve()
await pending
expect(harness.loadTaskView).toHaveBeenCalledTimes(expectedTaskLoads)
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual(expectedTaskLoads
? { tasks: ['work task'], total: 1, loading: false, error: '' }
: { tasks: ['target work'], total: 3, loading: false, error: '' })
})
it.each(['restore', 'purge'])('leaves a non-task view untouched when stale Trash %s settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(
() => request.promise,
() => undefined,
undefined,
undefined,
{ affectsTrash: true, affectsTaskView: true },
)
harness.navigate(
{ navigation: 2, view: 'habits', listId: 'inbox' },
{ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' },
)
request.resolve()
await pending
expect(harness.loadTaskView).not.toHaveBeenCalled()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['habit-owned state'], total: 4, loading: false, error: 'habit error' })
})
it('refreshes the currently visible task-backed list after a stale mutation settles', async () => {
const harness = mutationHarness()
const request = deferred<void>()
const pending = harness.reconciler.run(() => request.promise, () => undefined)
harness.navigate(
{ navigation: 2, view: 'tasks', listId: 'work' },
{ tasks: ['target work'], total: 3, loading: false, error: '' },
)
request.resolve()
await pending
expect(harness.loadTaskView).toHaveBeenCalledOnce()
expect(harness.loadTrash).not.toHaveBeenCalled()
expect(harness.state).toEqual({ tasks: ['work task'], total: 1, loading: false, error: '' })
})
})
@@ -0,0 +1,303 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { createHabitMutationCoordinator, createKeyedLatestMutationQueue } from './lib/mvp-utils'
const panel = readFileSync('src/MvpPanel.vue', 'utf8')
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function block(start: string, end: string) {
return panel.slice(panel.indexOf(start), panel.indexOf(end, panel.indexOf(start)))
}
describe('habit mutation coordinator behavior', () => {
it.each(['navigation', 'unmount', 'rollover'] as const)('suppresses latest habit effects after %s while keeping the captured day request', async (change) => {
const pending = deferred<void>()
const sent: string[] = []
const effects: string[] = []
const coordinator = createHabitMutationCoordinator<number>()
let context = { generation: 1, view: 'habits', day: '2026-09-19', mounted: true }
const captured = { ...context }
const isCurrent = () => context.generation === captured.generation
&& context.view === captured.view
&& context.day === captured.day
&& context.mounted
const mutation = coordinator.run('habit-1:2026-09-19', 0, 1, async () => {
sent.push('/habits/habit-1/logs/2026-09-19')
await pending.promise
}, {
current: isCurrent,
success: () => { effects.push('history|notice|animation') },
error: () => { effects.push('rollback|error') },
})
await vi.waitFor(() => expect(sent).toEqual(['/habits/habit-1/logs/2026-09-19']))
if (change === 'navigation') context = { ...context, generation: 2, view: 'settings' }
if (change === 'unmount') context = { ...context, generation: 2, mounted: false }
if (change === 'rollover') context = { ...context, generation: 2, day: '2026-09-20' }
pending.resolve()
await mutation
expect(sent).toEqual(['/habits/habit-1/logs/2026-09-19'])
expect(effects).toEqual([])
})
it('serializes distinct numeric increments and only latest intent owns effects', async () => {
const first = deferred<void>()
const second = deferred<void>()
const sent: number[] = []
const effects: string[] = []
const coordinator = createHabitMutationCoordinator<number>()
const one = coordinator.run('habit-1:today', 0, 1, async () => { sent.push(1); await first.promise }, {
success: () => { effects.push('history:1') },
error: () => { effects.push('rollback:1') },
})
const two = coordinator.run('habit-1:today', 1, 2, async () => { sent.push(2); await second.promise }, {
success: () => { effects.push('history:2|notice:2|animation:2') },
error: (rollback) => { effects.push(`rollback:${rollback}|error:2`) },
})
await vi.waitFor(() => expect(sent).toEqual([1]))
first.reject(new Error('obsolete failure'))
await vi.waitFor(() => expect(sent).toEqual([1, 2]))
second.resolve()
await Promise.all([one, two])
expect(effects).toEqual(['history:2|notice:2|animation:2'])
const third = coordinator.run('habit-1:today', 2, 3, async () => { throw new Error('latest failure') }, {
success: () => { effects.push('history:3') },
error: (rollback) => { effects.push(`rollback:${rollback}|error:3`) },
})
await third
expect(effects.at(-1)).toBe('rollback:2|error:3')
})
it('revokes habit effect ownership when a newer same-key intent starts while history awaits', async () => {
const historyStarted = deferred<void>()
const releaseHistory = deferred<void>()
const secondMutation = deferred<void>()
const effects: string[] = []
const coordinator = createHabitMutationCoordinator<number>()
const first = coordinator.run('habit-1:today', 0, 1, async () => undefined, {
success: async (ownership) => {
effects.push('history-start')
historyStarted.resolve()
await releaseHistory.promise
if (!ownership.current()) return
effects.push('animation|notice|commit')
},
})
await historyStarted.promise
const second = coordinator.run('habit-1:today', 1, 2, () => secondMutation.promise)
releaseHistory.resolve()
secondMutation.resolve()
await Promise.all([first, second])
expect(effects).toEqual(['history-start'])
})
})
describe('keyed latest habit mutation queue', () => {
it('serializes same-key mutations while preserving every numeric increment', async () => {
const queue = createKeyedLatestMutationQueue()
const first = deferred<void>()
const calls: number[] = []
const one = queue.run('habit-1:2026-09-19', async () => { calls.push(1); await first.promise })
const two = queue.run('habit-1:2026-09-19', async () => { calls.push(2) })
await vi.waitFor(() => expect(calls).toEqual([1]))
first.resolve()
await Promise.all([one, two])
expect(calls).toEqual([1, 2])
})
it('carries the latest optimistic value into a queued numeric mutation', async () => {
const queue = createKeyedLatestMutationQueue()
const first = deferred<void>()
let optimisticValue = 0
const sent: number[] = []
const mutate = (delta: number) => {
optimisticValue += delta
const intendedValue = optimisticValue
return queue.run('habit-1:2026-09-19', async () => {
sent.push(intendedValue)
if (intendedValue === 1) await first.promise
})
}
const one = mutate(1)
const two = mutate(1)
await vi.waitFor(() => expect(sent).toEqual([1]))
expect(optimisticValue).toBe(2)
first.resolve()
await Promise.all([one, two])
expect(sent).toEqual([1, 2])
})
it('allows only the latest same-key intent to commit callbacks', async () => {
const queue = createKeyedLatestMutationQueue()
const first = deferred<void>()
const staleSuccess = vi.fn()
const latestSuccess = vi.fn()
const one = queue.run('habit-1:2026-09-19', () => first.promise, { success: staleSuccess })
const two = queue.run('habit-1:2026-09-19', async () => undefined, { success: latestSuccess })
first.resolve()
await Promise.all([one, two])
expect(staleSuccess).not.toHaveBeenCalled()
expect(latestSuccess).toHaveBeenCalledOnce()
})
it('does not let an obsolete failure roll back or report an error', async () => {
const queue = createKeyedLatestMutationQueue()
const first = deferred<void>()
const staleError = vi.fn()
const latestSuccess = vi.fn()
const one = queue.run('habit-1:2026-09-19', () => first.promise, { error: staleError })
const two = queue.run('habit-1:2026-09-19', async () => undefined, { success: latestSuccess })
first.reject(new Error('obsolete failure'))
await Promise.all([one, two])
expect(staleError).not.toHaveBeenCalled()
expect(latestSuccess).toHaveBeenCalledOnce()
})
it('does not serialize mutations for different habit/day keys', async () => {
const queue = createKeyedLatestMutationQueue()
const first = deferred<void>()
const calls: string[] = []
const one = queue.run('habit-1:2026-09-19', async () => { calls.push('one'); await first.promise })
const two = queue.run('habit-2:2026-09-19', async () => { calls.push('two') })
await two
expect(calls).toEqual(['one', 'two'])
first.resolve()
await one
})
})
describe('habit mutation integration', () => {
it('coordinates swipe and button through one per-habit/day mutation path', () => {
const swipe = block('async function applyHabitSwipe', 'function suppressHabitDetailClick')
const button = block('async function toggleHabitFromButton', 'function currentHabitForm')
expect(swipe).toContain('mutateHabitValue(h, next')
expect(button).toContain('mutateHabitValue(h, next')
expect(panel).toContain("const habitMutationCoordinator = createHabitMutationCoordinator<number | boolean>()")
expect(panel).toContain('const day = todayKey.value')
expect(panel).toContain('`${h.id}:${day}`')
})
it('commits history, feedback, rollback, and completion animation only for the latest intent', () => {
const mutation = block('async function mutateHabitValue', 'async function applyHabitSwipe')
expect(mutation).toContain('await habitMutationCoordinator.run(')
expect(mutation).toContain('success: async (ownership) =>')
expect(mutation).toContain('error: (rollback, reason, ownership) =>')
expect(mutation).toContain('setHabitCompletionExiting(h.id, animateExit)')
expect(mutation).not.toContain('if (animateExit) setHabitCompletionExiting')
expect(mutation).toContain('syncHabitHistoryToday(h, day)')
expect(mutation).toContain('finishHabitCompletion(h, wasDone, next, animateExit, ownership.current)')
expect(mutation).toContain("emit('notice', notice)")
expect(mutation).toContain('setLocalHabitValue(h, rollback, day)')
})
})
describe('habit coordinator retained-state cleanup', () => {
it('drains successful burst state and starts a later failure from its new previous value', async () => {
const first = deferred<void>()
const rollbacks: number[] = []
const coordinator = createHabitMutationCoordinator<number>()
const one = coordinator.run('habit-1:today', 0, 1, () => first.promise)
const two = coordinator.run('habit-1:today', 1, 2, async () => undefined)
first.resolve()
await Promise.all([one, two])
expect(coordinator.size()).toBe(0)
await coordinator.run('habit-1:today', 9, 10, async () => { throw new Error('failed') }, {
error: (rollback) => { rollbacks.push(rollback) },
})
expect(rollbacks).toEqual([9])
expect(coordinator.size()).toBe(0)
})
it.each(['stale', 'current-false', 'failure'] as const)('cleans %s habit state without deleting a replacement burst', async (outcome) => {
const first = deferred<void>()
const coordinator = createHabitMutationCoordinator<number>()
let current = outcome !== 'current-false'
const one = coordinator.run('habit-1:today', 0, 1, async () => {
await first.promise
if (outcome === 'failure') throw new Error('failed')
}, { current: () => current })
const two = outcome === 'stale'
? coordinator.run('habit-1:today', 1, 2, async () => undefined)
: Promise.resolve(false)
current = false
first.resolve()
await Promise.all([one, two])
expect(coordinator.size()).toBe(0)
})
it('propagates callback failures while still releasing habit state', async () => {
const coordinator = createHabitMutationCoordinator<number>()
await expect(coordinator.run('habit-1:today', 0, 1, async () => undefined, {
reconcile: () => { throw new Error('refresh failed') },
})).rejects.toThrow('refresh failed')
expect(coordinator.size()).toBe(0)
})
it('releases state for many unique habit/day keys', async () => {
const coordinator = createHabitMutationCoordinator<number>()
await Promise.all(Array.from({ length: 100 }, (_, index) => coordinator.run(
`habit-${index}:2026-09-19`,
index,
index + 1,
async () => undefined,
)))
expect(coordinator.size()).toBe(0)
})
})
describe('habit lifecycle busy contract', () => {
it('guards archive before and after confirmation and owns busy during mutation', () => {
const archive = block('async function archiveHabit', 'async function restoreHabit')
expect(archive).toContain('if (busy.value) return')
expect(archive.match(/if \(busy\.value\) return/g)).toHaveLength(2)
expect(archive).toContain('busy.value = true')
expect(archive).toContain('finally')
expect(archive).toContain('busy.value = false')
})
it('guards detail edit and direct composer close while busy', () => {
const edit = block('function editHabit', 'function closeHabitComposer')
const closeComposer = block('function closeHabitComposer', 'function formatHabitHistoryDay')
expect(edit).toContain('if (busy.value) return')
expect(closeComposer).toContain('if (busy.value) return')
})
it('disables every conflicting detail action while busy', () => {
const detail = panel.slice(panel.indexOf('<AppSheet :open="Boolean(selectedHabit)"'), panel.indexOf('</AppSheet>', panel.indexOf('<AppSheet :open="Boolean(selectedHabit)"')))
expect(detail).toContain('class="habit-detail-archive-button" :disabled="busy"')
expect(detail).toContain('class="soft-button habit-detail-edit-button" :disabled="busy"')
expect(detail).toContain('class="danger-text habit-delete-button" :disabled="busy"')
expect(detail).toContain('class="soft-button habit-restore-button" :disabled="busy"')
})
})
+273 -8
View File
@@ -323,7 +323,268 @@ export async function runLatestRequest<T>(
return committed
}
type MutationOwnership = { current: () => boolean }
type LatestMutationCallbacks<T> = {
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (reason: unknown, ownership: MutationOwnership) => void | Promise<void>
settled?: (result: { ok: true; value: T } | { ok: false; reason: unknown }, ownership: MutationOwnership) => void | Promise<void>
}
export function createKeyedLatestMutationQueue() {
type Entry = { generation: number; tail: Promise<void> }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run<T>(key: string, mutation: () => Promise<T>, callbacks: LatestMutationCallbacks<T> = {}) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve() }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = { current: () => entries.get(key) === entry && entry.generation === generation }
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
try {
const value = await pending
await callbacks.settled?.({ ok: true, value }, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} catch (reason) {
await callbacks.settled?.({ ok: false, reason }, ownership)
if (ownership.current()) await callbacks.error?.(reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createHabitMutationCoordinator<T>() {
type Entry = { generation: number; tail: Promise<void>; confirmed: T }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run(
key: string,
previous: T,
next: T,
mutation: () => Promise<unknown>,
callbacks: {
current?: () => boolean
reconcile?: (confirmed: T) => void | Promise<void>
success?: (ownership: MutationOwnership) => void | Promise<void>
error?: (rollback: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve(), confirmed: previous }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = {
current: () => entries.get(key) === entry && entry.generation === generation && callbacks.current?.() !== false,
}
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
let result: { ok: true } | { ok: false; reason: unknown }
try {
await pending
result = { ok: true }
} catch (reason) {
result = { ok: false, reason }
}
try {
if (result.ok) {
entry.confirmed = next
await callbacks.reconcile?.(entry.confirmed)
if (!ownership.current()) return false
await callbacks.success?.(ownership)
return ownership.current()
}
await callbacks.reconcile?.(entry.confirmed)
if (ownership.current()) await callbacks.error?.(entry.confirmed, result.reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createTaskToggleCoordinator<T extends { completed: boolean; version: number }>() {
type Entry = { tail: Promise<void>; intendedCompleted: boolean; confirmed: T; generation: number }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async toggle(
key: string,
initial: T,
mutation: (payload: { completed: boolean; version: number }) => Promise<T>,
callbacks: {
current?: () => boolean
beforeReconcile?: (value: T, ownership: MutationOwnership) => void | Promise<void>
reconcile?: (confirmed: T, ownership: MutationOwnership) => void | Promise<void>
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (confirmed: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { tail: Promise.resolve(), intendedCompleted: initial.completed, confirmed: initial, generation: 0 }
entries.set(key, entry)
}
entry.intendedCompleted = !entry.intendedCompleted
const intendedCompleted = entry.intendedCompleted
const generation = ++entry.generation
const ownership = { current: () => entry!.generation === generation && callbacks.current?.() !== false }
const pending = entry.tail.then(async () => {
const value = await mutation({ completed: intendedCompleted, version: entry!.confirmed.version })
entry!.confirmed = value
return value
})
entry.tail = pending.then(() => undefined, () => undefined)
try {
let value: T
try {
value = await pending
} catch (reason) {
await callbacks.reconcile?.(entry.confirmed, ownership)
if (ownership.current()) await callbacks.error?.(entry.confirmed, reason, ownership)
return false
}
if (ownership.current()) await callbacks.beforeReconcile?.(value, ownership)
await callbacks.reconcile?.(value, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} finally {
if (entry.generation === generation && entries.get(key) === entry) entries.delete(key)
}
},
}
}
type CurrentViewReconciliationOptions<TContext> = {
capture: () => TContext
isTaskBacked: (context: TContext) => boolean
isTrash?: (context: TContext) => boolean
sameContext: (left: TContext, right: TContext) => boolean
loadTaskView: (context: TContext, ownership: MutationOwnership) => Promise<void>
loadTrash?: (ownership: MutationOwnership) => Promise<void>
affectsTrash?: boolean
affectsTaskView?: boolean
}
export async function reconcileCurrentTaskView<TContext>(options: CurrentViewReconciliationOptions<TContext>) {
const context = options.capture()
const ownership = { current: () => options.sameContext(context, options.capture()) }
if (options.affectsTaskView !== false && options.isTaskBacked(context)) {
await options.loadTaskView(context, ownership)
return ownership.current()
}
if (options.affectsTrash && options.isTrash?.(context) && options.loadTrash) {
await options.loadTrash(ownership)
return ownership.current()
}
return false
}
export function createTaskCompletionExitCoordinator(setExit: (key: string, active: boolean) => void) {
const tokens = new Map<string, object>()
const supersede = (key: string) => {
tokens.delete(key)
setExit(key, false)
}
const clearAll = () => {
const keys = [...tokens.keys()]
tokens.clear()
keys.forEach((key) => setExit(key, false))
}
const begin = (key: string, animate: boolean) => {
tokens.delete(key)
if (!animate) {
setExit(key, false)
return null
}
const token = {}
tokens.set(key, token)
setExit(key, true)
return token
}
const wait = async (key: string, token: object | null, waitForExit: () => Promise<void>) => {
if (!token) return
try {
await waitForExit()
} finally {
if (tokens.get(key) === token) {
tokens.delete(key)
setExit(key, false)
}
}
}
return {
size: () => tokens.size,
supersede,
clearAll,
begin,
wait,
async run(key: string, animate: boolean, waitForExit: () => Promise<void>) {
const token = begin(key, animate)
await wait(key, token, waitForExit)
},
}
}
type TaskToggleState = {
id: string
list_id: string
parent_id: string | null
title: string
description: string
priority: number
completed: boolean
completed_at: string | null
version: number
due_at: string | null
due_has_time: boolean
subtasks?: TaskToggleState[]
}
export function taskVersionedPatchPayload<T extends { version: number }, P extends object>(task: T, patch: P): P & { version: number } {
return { ...patch, version: task.version }
}
export function mergeTaskToggleResponse<T extends TaskToggleState>(draft: T, response: TaskToggleState): T {
return {
...response,
list_id: draft.list_id,
parent_id: draft.parent_id,
title: draft.title,
description: draft.description,
priority: draft.priority,
due_at: draft.due_at,
due_has_time: draft.due_has_time,
subtasks: draft.subtasks,
} as T
}
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
type MutationReconciliationOptions = { affectsTrash?: boolean; affectsTaskView?: boolean }
export type MutationReconciler<TContext> = {
run<T>(
@@ -331,26 +592,27 @@ export type MutationReconciler<TContext> = {
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: MutationSuccessCallback<T>,
reconciliation?: MutationReconciliationOptions,
): Promise<boolean>
}
export function createMutationReconciler<TContext>(
currentContext: () => TContext,
sameContext: (left: TContext, right: TContext) => boolean,
refresh: () => Promise<unknown>,
reconcileCurrentView: (options?: MutationReconciliationOptions) => Promise<unknown>,
): MutationReconciler<TContext> {
let dirty = 0
let reconciled = 0
let refreshLoop: Promise<void> | null = null
const reconcile = (context: TContext) => {
const reconcile = (context: TContext, options?: MutationReconciliationOptions) => {
if (!sameContext(context, currentContext())) return Promise.resolve()
dirty += 1
if (!refreshLoop) {
refreshLoop = (async () => {
while (reconciled < dirty && sameContext(context, currentContext())) {
const target = dirty
await refresh()
await reconcileCurrentView(options)
if (!sameContext(context, currentContext())) break
reconciled = target
}
@@ -360,22 +622,25 @@ export function createMutationReconciler<TContext>(
}
return {
async run(mutation, onSuccess, onError, onCurrentSuccess) {
async run(mutation, onSuccess, onError, onCurrentSuccess, reconciliation) {
const context = currentContext()
let value: Awaited<ReturnType<typeof mutation>>
try {
value = await mutation()
} catch (reason) {
onError?.(reason)
if (sameContext(context, currentContext())) onError?.(reason)
return false
}
onSuccess(value)
if (sameContext(context, currentContext())) {
onSuccess(value)
const currentSuccess = onCurrentSuccess?.(value)
if (currentSuccess instanceof Promise) await currentSuccess
} else {
await reconcileCurrentView(reconciliation)
return true
}
await reconcile(context)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
await reconcile(context, reconciliation)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context, reconciliation)
return true
},
}
+2 -2
View File
@@ -140,7 +140,7 @@ describe('request generation protection', () => {
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 () => {
it('suppresses stale success feedback and refreshes the current navigation', async () => {
const events: string[] = []
let context = { view: 'today' }
let resolveMutation!: () => void
@@ -156,7 +156,7 @@ describe('request generation protection', () => {
context = { view: 'tasks' }
resolveMutation()
await mutation
expect(events).toEqual(['success'])
expect(events).toEqual(['refresh'])
})
it('prevents stale Trash success, error, and finally callbacks from committing', async () => {
+28 -24
View File
@@ -363,9 +363,12 @@ describe('approved UI detail direction', () => {
for (const [, attributes] of buttons) expect(attributes).toMatch(/\btype="(?:button|submit)"/)
const removeBlock = app.slice(app.indexOf('async function removeSubtask('), app.indexOf('function closeTaskDetail('))
expect(removeBlock).toContain('if (taskDetailBusy.value) return')
expect(removeBlock).toContain("await api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('parent.subtasks = (parent.subtasks ?? []).filter')
expect(removeBlock).toContain('tasks.value = tasks.value.filter')
expect(removeBlock).toContain("() => api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('await taskMutationReconciler.run(')
expect(removeBlock).toContain('{ affectsTaskView: true }')
expect(removeBlock).not.toContain('affectsTrash: true')
expect(removeBlock).toContain('selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter')
expect(removeBlock).not.toContain('tasks.value = tasks.value.filter')
expect(removeBlock).not.toContain('selectedTask.value = null')
})
@@ -655,7 +658,7 @@ describe('mobile list row language', () => {
expect(css).toContain('.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);overflow:hidden')
expect(mvpPanel).toContain("habitHistoryNextTo.value = ''")
expect(mvpPanel).toContain('habitHistoryHasMore.value = false')
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
expect(mvpPanel).toContain('syncHabitHistoryToday(h, day)')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
@@ -801,16 +804,14 @@ describe('task and habit row decoration', () => {
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain('return await runLatestRequest')
expect(mutationBlock).toContain('const result = await performTrashMutation(')
expect(mutationBlock).toContain('trash.value = trash.value.filter((item) => item.id !== task.id)')
expect(mutationBlock).toContain('totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)')
expect(mutationBlock).toContain('if (page.value > totalPages.value) page.value = totalPages.value')
expect(mutationBlock).toContain('if (!result.mutated)')
expect(mutationBlock).toContain('fail(result.error)')
expect(mutationBlock).toContain('if (!result.refreshed)')
expect(mutationBlock).toContain('请重试刷新')
expect(mutationBlock).toContain('await taskMutationReconciler.run(')
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
expect(mutationBlock).not.toContain('performTrashMutation(')
expect(mutationBlock).not.toContain('loadTrash')
expect(restoreBlock).toContain("await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`")
expect(restoreBlock).toContain("'任务已恢复', true")
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
expect(purgeBlock).toContain("'任务已永久删除', false")
})
it('supports pointer dragging for desktop task completion in regular and overdue rows', () => {
@@ -828,11 +829,12 @@ describe('task and habit row decoration', () => {
})
it('updates habit rows locally after swiping instead of refreshing the whole Today section', () => {
expect(mvpPanel).toContain('setLocalHabitValue(h, next)')
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('async function finishHabitSwipe'))
const mutationBlock = mvpPanel.slice(mvpPanel.indexOf('async function mutateHabitValue'), mvpPanel.indexOf('function suppressHabitDetailClick'))
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('function suppressHabitDetailClick'))
expect(mutationBlock).toContain('setLocalHabitValue(h, next, day)')
expect(mutationBlock).toContain('setLocalHabitValue(h, rollback, day)')
expect(applyBlock).not.toContain('await loadHabits()')
expect(applyBlock).toContain('setLocalHabitValue(h, next)')
expect(applyBlock).toContain('setLocalHabitValue(h, previous)')
expect(applyBlock).toContain('mutateHabitValue(h, next')
})
it('keeps swipe behavior without rendering the swipe background layer', () => {
@@ -882,10 +884,10 @@ describe('task and habit row decoration', () => {
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 taskMutationReconciler.run(')
expect(toggleBlock).toContain("() => toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated)')
expect(toggleBlock).toContain('const completing = updated.completed')
expect(toggleBlock).toContain('await taskToggleCoordinator.toggle(')
expect(toggleBlock).toContain("toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated, !preserveSelectedDraft)')
expect(toggleBlock).not.toContain("beginLatestRequest('tasks')")
})
@@ -897,8 +899,7 @@ describe('task and habit row decoration', () => {
expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer')
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('function currentHabitForm'))
expect(toggleBlock).toContain('setLocalHabitValue(h, next)')
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
expect(toggleBlock).toContain('mutateHabitValue(h, next')
expect(toggleBlock).not.toContain('await loadHabits()')
})
@@ -1038,7 +1039,10 @@ describe('task and habit row decoration', () => {
expect(app).toContain('return Boolean(dueToday || completedToday)')
expect(app).toContain("params.set('completed_from', isoAtLocalDayOffset(0))")
expect(app).toContain("params.set('completed_to', isoAtLocalDayOffset(1))")
expect(app).toContain("if (activeView.value === 'today' && showCompleted.value) await loadAll()")
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('reconcile: async (confirmed, ownership) =>')
expect(toggleBlock).toContain('await reconcileCurrentViewAfterTaskMutation()')
expect(toggleBlock).not.toContain('await loadAll()')
})
it('reclassifies Today tasks after due edits without toggling page loading', () => {
@@ -1203,7 +1207,7 @@ describe('approved habit safety and U2 title hierarchy', () => {
expect(mvpPanel).toContain('habitAction(h)')
expect(mvpPanel).toContain(':disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain(':aria-disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain('setLocalHabitValue(h, previous)')
expect(mvpPanel).toContain('setLocalHabitValue(h, rollback, day)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})
+490
View File
@@ -0,0 +1,490 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, mergeTaskToggleResponse, taskVersionedPatchPayload } from './lib/mvp-utils'
const app = readFileSync('src/App.vue', 'utf8')
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function block(start: string, end: string) {
return app.slice(app.indexOf(start), app.indexOf(end, app.indexOf(start)))
}
describe('task completion exit retained-state cleanup', () => {
it('supersede removes obsolete animation state immediately', async () => {
const wait = deferred<void>()
const states: boolean[] = []
const coordinator = createTaskCompletionExitCoordinator((_, active) => states.push(active))
const animation = coordinator.run('task-1', true, () => wait.promise)
await vi.waitFor(() => expect(states).toEqual([true]))
coordinator.supersede('task-1')
expect(states).toEqual([true, false])
expect(coordinator.size()).toBe(0)
wait.resolve()
await animation
expect(states).toEqual([true, false])
})
it('clears every pending exit immediately when navigation supersedes the rendered view', async () => {
const waitOne = deferred<void>()
const waitTwo = deferred<void>()
const states: string[] = []
const coordinator = createTaskCompletionExitCoordinator((id, active) => states.push(`${id}:${active}`))
const one = coordinator.run('task-1', true, () => waitOne.promise)
const two = coordinator.run('task-2', true, () => waitTwo.promise)
await vi.waitFor(() => expect(coordinator.size()).toBe(2))
coordinator.clearAll()
expect(coordinator.size()).toBe(0)
expect(states).toEqual(['task-1:true', 'task-2:true', 'task-1:false', 'task-2:false'])
waitOne.resolve()
waitTwo.resolve()
await Promise.all([one, two])
expect(states).toHaveLength(4)
})
it('animate=false and rejected wait retain no IDs', async () => {
const coordinator = createTaskCompletionExitCoordinator(() => undefined)
await coordinator.run('no-animation', false, async () => undefined)
expect(coordinator.size()).toBe(0)
await expect(coordinator.run('failed', true, async () => { throw new Error('wait failed') })).rejects.toThrow('wait failed')
expect(coordinator.size()).toBe(0)
})
it('an old animation completion cannot remove a newer token', async () => {
const firstWait = deferred<void>()
const secondWait = deferred<void>()
const coordinator = createTaskCompletionExitCoordinator(() => undefined)
const first = coordinator.run('task-1', true, () => firstWait.promise)
const second = coordinator.run('task-1', true, () => secondWait.promise)
firstWait.resolve()
await first
expect(coordinator.size()).toBe(1)
secondWait.resolve()
await second
expect(coordinator.size()).toBe(0)
})
it('releases animation state for many unique IDs', async () => {
const coordinator = createTaskCompletionExitCoordinator(() => undefined)
await Promise.all(Array.from({ length: 100 }, (_, index) => coordinator.run(
`task-${index}`,
true,
async () => undefined,
)))
expect(coordinator.size()).toBe(0)
})
})
describe('task async operation reliability', () => {
it('serializes rapid toggles with returned versions and preserves both intents', async () => {
const first = deferred<{ completed: boolean; version: number }>()
const sent: Array<{ completed: boolean; version: number }> = []
const effects: string[] = []
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const initial = { completed: false, version: 4 }
const request = async (payload: { completed: boolean; version: number }) => {
sent.push(payload)
if (sent.length === 1) return first.promise
return { completed: payload.completed, version: payload.version + 1 }
}
const one = coordinator.toggle('task-1', initial, request, {
success: (task) => { effects.push(`success:${task.completed}:${task.version}`) },
error: () => { effects.push('error') },
})
const two = coordinator.toggle('task-1', initial, request, {
success: (task) => { effects.push(`success:${task.completed}:${task.version}`) },
error: () => { effects.push('error') },
})
await vi.waitFor(() => expect(sent).toEqual([{ completed: true, version: 4 }]))
first.resolve({ completed: true, version: 5 })
await Promise.all([one, two])
expect(sent).toEqual([
{ completed: true, version: 4 },
{ completed: false, version: 5 },
])
expect(effects).toEqual(['success:false:6'])
})
it('keeps the completed row rendered until its exit animation finishes, then reconciles it away', async () => {
const animation = deferred<void>()
const events: string[] = []
let tasks = [{ id: 'task-1', completed: false, version: 4 }]
const exiting = new Set<string>()
const renderedIds = () => tasks.filter((task) => !task.completed || exiting.has(task.id)).map((task) => task.id)
const exits = createTaskCompletionExitCoordinator((id, active) => {
active ? exiting.add(id) : exiting.delete(id)
events.push(active ? 'exit:start' : 'exit:clear')
})
const coordinator = createTaskToggleCoordinator<(typeof tasks)[number]>()
const pending = coordinator.toggle('task-1', tasks[0], async () => ({ id: 'task-1', completed: true, version: 5 }), {
beforeReconcile: async (updated) => {
tasks = [updated]
events.push('local:completed')
await exits.run(updated.id, true, () => animation.promise)
},
reconcile: async () => {
events.push('reconcile')
tasks = []
},
})
await vi.waitFor(() => expect(events).toEqual(['local:completed', 'exit:start']))
expect(renderedIds()).toEqual(['task-1'])
animation.resolve()
await pending
expect(events).toEqual(['local:completed', 'exit:start', 'exit:clear', 'reconcile'])
expect(renderedIds()).toEqual([])
})
it('superseding reopen clears a pending exit and both generations reconcile authoritatively', async () => {
const animation = deferred<void>()
const reopenRequest = deferred<{ id: string; completed: boolean; version: number }>()
const events: string[] = []
let tasks = [{ id: 'task-1', completed: false, version: 4 }]
const exiting = new Set<string>()
const exits = createTaskCompletionExitCoordinator((id, active) => {
active ? exiting.add(id) : exiting.delete(id)
})
const coordinator = createTaskToggleCoordinator<(typeof tasks)[number]>()
let requests = 0
const mutate = async () => {
requests += 1
return requests === 1 ? { id: 'task-1', completed: true, version: 5 } : reopenRequest.promise
}
const callbacks = {
beforeReconcile: async (updated: (typeof tasks)[number], ownership: { current: () => boolean }) => {
if (!ownership.current()) return
tasks = [updated]
await exits.run(updated.id, updated.completed, () => animation.promise)
},
reconcile: async (confirmed: (typeof tasks)[number]) => {
tasks = [confirmed]
events.push(`reconcile:${confirmed.completed}:${confirmed.version}`)
},
}
const complete = coordinator.toggle('task-1', tasks[0], mutate, callbacks)
await vi.waitFor(() => expect(exiting.has('task-1')).toBe(true))
exits.supersede('task-1')
const reopen = coordinator.toggle('task-1', tasks[0], mutate, callbacks)
expect(exiting.has('task-1')).toBe(false)
animation.resolve()
reopenRequest.resolve({ id: 'task-1', completed: false, version: 6 })
await Promise.all([complete, reopen])
expect(events).toEqual(['reconcile:true:5', 'reconcile:false:6'])
expect(tasks).toEqual([{ id: 'task-1', completed: false, version: 6 }])
expect(exiting.size).toBe(0)
})
it('reconciles current view after successful toggle becomes stale through navigation without stale effects', async () => {
const request = deferred<{ completed: boolean; version: number }>()
const events: string[] = []
let view = 'today'
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const pending = coordinator.toggle('task-1', { completed: false, version: 4 }, () => request.promise, {
current: () => view === 'today',
reconcile: async (confirmed) => { events.push(`refresh:${view}:${confirmed.completed}:${confirmed.version}`) },
success: () => { events.push('toast|animation') },
error: () => { events.push('error') },
})
view = 'tasks'
request.resolve({ completed: true, version: 5 })
await pending
expect(events).toEqual(['refresh:tasks:true:5'])
})
it('publishes the last confirmed task after a queued reopen fails', async () => {
const first = deferred<{ completed: boolean; version: number }>()
const published: Array<{ completed: boolean; version: number }> = []
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const initial = { completed: false, version: 4 }
let calls = 0
const request = async () => {
calls += 1
if (calls === 1) return first.promise
throw new Error('reopen failed')
}
const complete = coordinator.toggle('task-1', initial, request, {
reconcile: (confirmed) => { published.push({ ...confirmed }) },
})
const reopen = coordinator.toggle('task-1', initial, request, {
reconcile: (confirmed) => { published.push({ ...confirmed }) },
error: (confirmed) => { published.push({ ...confirmed }) },
})
first.resolve({ completed: true, version: 5 })
await Promise.all([complete, reopen])
expect(published.at(-1)).toEqual({ completed: true, version: 5 })
expect(published).not.toContainEqual({ completed: false, version: 4 })
})
it('revokes task effect ownership when a newer toggle starts while the prior effect awaits', async () => {
const effectStarted = deferred<void>()
const releaseEffect = deferred<void>()
const secondRequest = deferred<{ completed: boolean; version: number }>()
const events: string[] = []
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const initial = { completed: false, version: 4 }
const first = coordinator.toggle('task-1', initial, async () => ({ completed: true, version: 5 }), {
success: async (_value, ownership) => {
events.push('animation-start')
effectStarted.resolve()
await releaseEffect.promise
if (!ownership.current()) return
events.push('animation-clear|toast|commit')
},
})
await effectStarted.promise
const second = coordinator.toggle('task-1', initial, () => secondRequest.promise)
releaseEffect.resolve()
secondRequest.resolve({ completed: false, version: 6 })
await Promise.all([first, second])
expect(events).toEqual(['animation-start'])
})
it('does not let an older reconcile overwrite a newer confirmed task or selected draft', async () => {
const firstRefresh = deferred<void>()
const published: Array<{ completed: boolean; version: number }> = []
const selected = { completed: false, version: 4, title: 'edited draft' }
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
let refreshes = 0
const reconcile = async (confirmed: { completed: boolean; version: number }, ownership: { current: () => boolean }) => {
refreshes += 1
if (refreshes === 1) await firstRefresh.promise
if (ownership.current()) {
published.push({ ...confirmed })
selected.completed = confirmed.completed
selected.version = confirmed.version
}
}
const complete = coordinator.toggle('task-1', { completed: false, version: 4 }, async () => ({ completed: true, version: 5 }), { reconcile })
await vi.waitFor(() => expect(refreshes).toBe(1))
const reopen = coordinator.toggle('task-1', { completed: false, version: 4 }, async (payload) => ({ completed: false, version: payload.version + 1 }), { reconcile })
firstRefresh.resolve()
await Promise.all([complete, reopen])
expect(published).toEqual([{ completed: false, version: 6 }])
expect(selected).toEqual({ completed: false, version: 6, title: 'edited draft' })
})
it('publishes exact confirmed completion/version into list and draft after reopen failure', async () => {
const first = deferred<{ completed: boolean; version: number }>()
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const list = { completed: false, version: 4 }
const draft = { completed: false, version: 4, title: 'edited locally' }
const reconcile = (confirmed: { completed: boolean; version: number }, ownership: { current: () => boolean }) => {
if (!ownership.current()) return
Object.assign(list, confirmed)
Object.assign(draft, confirmed)
}
let calls = 0
const request = async () => {
calls += 1
if (calls === 1) return first.promise
throw new Error('reopen failed')
}
const complete = coordinator.toggle('task-1', list, request, { reconcile })
const reopen = coordinator.toggle('task-1', list, request, { reconcile })
first.resolve({ completed: true, version: 41 })
await Promise.all([complete, reopen])
expect(list).toEqual({ completed: true, version: 41 })
expect(draft).toEqual({ completed: true, version: 41, title: 'edited locally' })
})
it('clears an obsolete completion exit immediately when reopen supersedes its awaiting animation', async () => {
const releaseAnimation = deferred<void>()
const states: boolean[] = []
const exits = createTaskCompletionExitCoordinator((_, active) => states.push(active))
const complete = exits.run('task-1', true, () => releaseAnimation.promise)
await vi.waitFor(() => expect(states).toEqual([true]))
const reopen = exits.run('task-1', false, async () => undefined)
expect(states).toEqual([true, false])
releaseAnimation.resolve()
await Promise.all([complete, reopen])
expect(states).toEqual([true, false])
})
it.each(['success', 'failure', 'current-false'] as const)('removes %s task queue entries without deleting replacements', async (outcome) => {
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
const firstRefresh = deferred<void>()
const initial = { completed: false, version: 4 }
let current = outcome !== 'current-false'
const first = coordinator.toggle('task-1', initial, async () => {
if (outcome === 'failure') throw new Error('failed')
return { completed: true, version: 5 }
}, {
current: () => current,
reconcile: async () => { await firstRefresh.promise },
})
await Promise.resolve()
const second = coordinator.toggle('task-1', initial, async (payload) => ({ completed: false, version: payload.version + 1 }))
current = false
firstRefresh.resolve()
await Promise.all([first, second])
expect(coordinator.size()).toBe(0)
const sent: Array<{ completed: boolean; version: number }> = []
await coordinator.toggle('task-1', { completed: false, version: 20 }, async (payload) => {
sent.push(payload)
return { completed: true, version: 21 }
})
expect(sent).toEqual([{ completed: true, version: 20 }])
expect(coordinator.size()).toBe(0)
})
it('removes the queue entry when a reconcile callback throws', async () => {
const coordinator = createTaskToggleCoordinator<{ completed: boolean; version: number }>()
await expect(coordinator.toggle('task-1', { completed: false, version: 4 }, async () => ({ completed: true, version: 5 }), {
reconcile: () => { throw new Error('refresh failed') },
})).rejects.toThrow('refresh failed')
expect(coordinator.size()).toBe(0)
})
it('merges toggle-owned fields into a draft and saves with the returned version', () => {
const draft = {
id: 'task-1', title: 'edited locally', description: 'draft note', priority: 3,
version: 4, completed: false, completed_at: null, list_id: 'inbox', parent_id: null,
due_at: null, due_has_time: false,
}
const response = { ...draft, title: 'old server title', description: 'old server note', priority: 0, version: 5, completed: true, completed_at: '2026-09-19T01:00:00Z' }
const merged = mergeTaskToggleResponse(draft, response)
expect(merged).toMatchObject({ title: 'edited locally', description: 'draft note', priority: 3, version: 5, completed: true, completed_at: '2026-09-19T01:00:00Z' })
expect(taskVersionedPatchPayload(merged, { title: merged.title, description: merged.description })).toEqual({ title: 'edited locally', description: 'draft note', version: 5 })
})
it.each(['success', 'error'] as const)('suppresses stale navigation %s feedback while still reconciling', async (outcome) => {
const events: string[] = []
let context = { view: 'today' }
let resolve!: () => void
let reject!: (reason: unknown) => void
const reconciler = createMutationReconciler(
() => context,
(left, right) => left.view === right.view,
async () => { events.push('refresh') },
)
const pending = reconciler.run(
() => new Promise<void>((res, rej) => { resolve = res; reject = rej }),
() => events.push('notice'),
() => events.push('error'),
)
context = { view: 'tasks' }
if (outcome === 'success') resolve()
else reject(new Error('late failure'))
await pending
expect(events).toEqual(outcome === 'success' ? ['refresh'] : [])
})
it('marks an overdue completion before applying the update that can remove it', () => {
const toggle = block('async function toggle(task: Task)', 'function isInteractiveTarget')
const marker = toggle.indexOf('taskCompletionExitCoordinator.begin(task.id, animateExit)')
const update = toggle.indexOf('applyTaskUpdate(task, updated, !preserveSelectedDraft)')
const wait = toggle.indexOf('await taskCompletionExitCoordinator.wait(task.id, exitOwnership, waitForCompletionExit)')
expect(marker).toBeGreaterThan(-1)
expect(update).toBeGreaterThan(marker)
expect(wait).toBeGreaterThan(update)
})
it('commits detail completion to task lists without replacing the open task draft', () => {
const toggle = block('async function toggle(task: Task)', 'function isInteractiveTarget')
expect(toggle).toContain('const preserveSelectedDraft = selectedTask.value?.id === task.id')
expect(toggle).toContain('applyTaskUpdate(task, updated, !preserveSelectedDraft)')
})
it('locks delete and only closes the same selected task generation', () => {
const remove = block('async function removeTask(task: Task)', 'async function mutateTrashTask')
expect(app).toContain('const removingTaskId = ref<string | null>(null)')
expect(app).toContain('const taskSelectionGeneration = ref(0)')
expect(remove).toContain('removingTaskId.value === task.id')
expect(remove).toContain('const selectionGeneration = taskSelectionGeneration.value')
expect(remove).toContain('removingTaskId.value = task.id')
expect(remove).toContain("if (selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration)")
expect(remove).toContain("if (removingTaskId.value === task.id) removingTaskId.value = null")
})
it('commits a created subtask to its captured parent without contaminating a newer selection', () => {
const add = block('async function addSubtask()', 'async function removeSubtask')
expect(app).toContain('const addingSubtask = ref(false)')
expect(add).toContain('if (!selectedTask.value || addingSubtask.value) return')
expect(add).toContain('const parent = selectedTask.value')
expect(add).toContain('const parentId = parent.id')
expect(add).toContain('const parentListId = parent.list_id')
expect(add).toContain('const selectionGeneration = taskSelectionGeneration.value')
expect(add).toContain('list_id: parentListId, parent_id: parentId')
expect(add).toContain('await taskMutationReconciler.run(')
expect(add).toContain("selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration")
})
it('locks task creation and reconciles only the captured navigation context', () => {
const submit = block('async function submitTaskCompose()', 'function toggleSidebar()')
expect(app).toContain('const creatingTask = ref(false)')
expect(app).toContain('const taskComposeGeneration = ref(0)')
expect(submit).toContain('if (creatingTask.value) return')
expect(submit).toContain('const composeGeneration = taskComposeGeneration.value')
expect(submit).toContain('const targetListId = composeListId.value')
expect(submit).toContain('creatingTask.value = true')
expect(submit).toContain('await taskMutationReconciler.run(')
expect(submit).toContain('list_id: targetListId')
expect(submit).not.toContain('tasks.value.push(task)')
expect(submit).not.toContain('nextTotalAfterLocalTaskAdd')
expect(submit).toContain('if (taskComposeGeneration.value === composeGeneration) taskComposeOpen.value = false')
expect(submit).toContain('finally { creatingTask.value = false }')
expect(app).toContain(':disabled="creatingTask || !composeTitle.trim() || !composeListId"')
})
it('deletes through reconciliation so totals and filtered membership reload from the server', () => {
const remove = block('async function removeTask(task: Task)', 'async function mutateTrashTask')
expect(remove).toContain('await taskMutationReconciler.run(')
expect(remove).not.toContain('tasks.value = tasks.value.filter')
expect(remove).not.toContain('overdueTasks.value = overdueTasks.value.filter')
})
it('creates subtasks through reconciliation so parent trees and totals reload from the server', () => {
const add = block('async function addSubtask()', 'async function removeSubtask')
expect(add).toContain('await taskMutationReconciler.run(')
expect(add).not.toContain('tasks.value = appendSubtaskToParent')
expect(add).not.toContain('overdueTasks.value = appendSubtaskToParent')
})
it('suppresses stale recurrence load errors after leaving the task selection', () => {
const load = block('async function loadTaskRecurrence(task: Task)', 'async function submitTaskCompose()')
expect(load).toContain('const selectionGeneration = taskSelectionGeneration.value')
expect(load).toContain('token === recurrenceLoadToken && taskSelectionGeneration.value === selectionGeneration && selectedTask.value?.id === task.id')
expect(load).toContain('if (selectionIsCurrent()) fail(reason)')
expect(load).toContain('if (selectionIsCurrent()) recurrenceLoading.value = false')
})
})