fix: harden async task and habit mutations
This commit is contained in:
+225
-89
@@ -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({
|
||||
title: taskTitle,
|
||||
list_id: composeListId.value,
|
||||
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)
|
||||
await taskMutationReconciler.run(
|
||||
() => api('/tasks', { method: 'POST', body: JSON.stringify({
|
||||
title: taskTitle,
|
||||
list_id: targetListId,
|
||||
due_at: fromDateTimeLocal(dueValue),
|
||||
due_has_time: composeHasTime.value,
|
||||
priority: composePriority.value,
|
||||
description: composeDescription.value,
|
||||
...recurrencePayload,
|
||||
}) }) as Promise<Task>,
|
||||
() => {
|
||||
if (taskComposeGeneration.value === composeGeneration) taskComposeOpen.value = false
|
||||
toast('任务已添加')
|
||||
},
|
||||
(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)
|
||||
}
|
||||
if (activeView.value === 'today') void loadTodayTaskSummary()
|
||||
taskComposeOpen.value = false
|
||||
toast('任务已添加')
|
||||
} catch (reason) { 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
|
||||
applyTaskUpdate(task, updated, updateSelected)
|
||||
return updated
|
||||
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)
|
||||
},
|
||||
(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,
|
||||
)
|
||||
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)
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
await loadTasksPage(request)
|
||||
if (ownership.current() && isLatestRequest('tasks', request)) overdueTasks.value = []
|
||||
}
|
||||
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
|
||||
}
|
||||
if (activeView.value === 'today' && showCompleted.value) await loadAll()
|
||||
},
|
||||
})
|
||||
}
|
||||
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) {
|
||||
tasks.value = previous
|
||||
selectedTask.value = previousSelected
|
||||
fail(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) }
|
||||
await taskMutationReconciler.run(
|
||||
() => api(`/tasks/${task.id}`, { method: 'DELETE' }),
|
||||
() => {
|
||||
if (selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
|
||||
selectedTask.value = null
|
||||
taskSelectionGeneration.value += 1
|
||||
mobileDetail.value = false
|
||||
}
|
||||
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) {
|
||||
const result = await performTrashMutation(
|
||||
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string, affectsTaskView: boolean) {
|
||||
await taskMutationReconciler.run(
|
||||
mutation,
|
||||
() => {
|
||||
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,
|
||||
() => toast(successMessage),
|
||||
fail,
|
||||
undefined,
|
||||
{ affectsTrash: true, affectsTaskView },
|
||||
)
|
||||
if (!result.mutated) {
|
||||
fail(result.error)
|
||||
return
|
||||
}
|
||||
if (!result.refreshed) toast(`${successMessage},但列表刷新失败,请重试刷新`)
|
||||
else toast(successMessage)
|
||||
}
|
||||
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)
|
||||
toast('子任务已删除')
|
||||
} catch (reason) { fail(reason) }
|
||||
finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null }
|
||||
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('子任务已删除')
|
||||
},
|
||||
(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
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user