feat: animate completed items out
This commit is contained in:
+19
-7
@@ -8,7 +8,7 @@ import {
|
|||||||
import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, 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, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||||
@@ -100,6 +100,12 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS
|
|||||||
const expandedFolders = ref(new Set<string>())
|
const expandedFolders = ref(new Set<string>())
|
||||||
const collapsedTaskIds = ref(new Set<string>())
|
const collapsedTaskIds = ref(new Set<string>())
|
||||||
const justCompletedTaskIds = ref(new Set<string>())
|
const justCompletedTaskIds = ref(new Set<string>())
|
||||||
|
const completionExitingTaskIds = ref(new Set<string>())
|
||||||
|
function setTaskCompletionExiting(id: string, active: boolean) {
|
||||||
|
const next = new Set(completionExitingTaskIds.value)
|
||||||
|
active ? next.add(id) : next.delete(id)
|
||||||
|
completionExitingTaskIds.value = next
|
||||||
|
}
|
||||||
const markTaskJustCompleted = createCompletionPulse(
|
const markTaskJustCompleted = createCompletionPulse(
|
||||||
(id) => { justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id) },
|
(id) => { justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id) },
|
||||||
(id) => { const next = new Set(justCompletedTaskIds.value); next.delete(id); justCompletedTaskIds.value = next },
|
(id) => { const next = new Set(justCompletedTaskIds.value); next.delete(id); justCompletedTaskIds.value = next },
|
||||||
@@ -606,7 +612,7 @@ function applyTaskUpdate(task: Task, updated: Task) {
|
|||||||
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
|
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
|
||||||
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
|
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
|
||||||
if (overdueIndex >= 0) {
|
if (overdueIndex >= 0) {
|
||||||
if (updated.completed) overdueTasks.value.splice(overdueIndex, 1)
|
if (updated.completed && !completionExitingTaskIds.value.has(task.id)) overdueTasks.value.splice(overdueIndex, 1)
|
||||||
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
|
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
|
||||||
}
|
}
|
||||||
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||||
@@ -624,13 +630,19 @@ const taskMutationReconciler = createMutationReconciler(
|
|||||||
)
|
)
|
||||||
async function toggle(task: Task) {
|
async function toggle(task: Task) {
|
||||||
const completing = !task.completed
|
const completing = !task.completed
|
||||||
|
const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value })
|
||||||
await taskMutationReconciler.run(
|
await taskMutationReconciler.run(
|
||||||
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
|
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
|
||||||
() => toast(completing ? '完成啦' : '已重新打开'),
|
() => toast(completing ? '完成啦' : '已重新打开'),
|
||||||
fail,
|
fail,
|
||||||
(updated) => {
|
async (updated) => {
|
||||||
|
if (animateExit) setTaskCompletionExiting(task.id, true)
|
||||||
applyTaskUpdate(task, updated)
|
applyTaskUpdate(task, updated)
|
||||||
if (completing) markTaskJustCompleted(task.id)
|
if (completing) markTaskJustCompleted(task.id)
|
||||||
|
if (animateExit) {
|
||||||
|
await waitForCompletionExit()
|
||||||
|
setTaskCompletionExiting(task.id, false)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1326,8 +1338,8 @@ onUnmounted(() => {
|
|||||||
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
|
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
|
||||||
<div class="task-list overdue-list">
|
<div class="task-list overdue-list">
|
||||||
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
||||||
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
|
<article :data-task-id="node.task.id" class="task-row overdue-task" :class="{'completion-exiting':completionExitingTaskIds.has(node.task.id)}"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
|
||||||
<article v-for="subtask in node.subtasks" :key="`overdue-subtask-${subtask.id}`" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
|
<article v-for="subtask in node.subtasks" :key="`overdue-subtask-${subtask.id}`" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),'completion-exiting':completionExitingTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1337,13 +1349,13 @@ onUnmounted(() => {
|
|||||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||||
<section class="task-list" :class="{loading}">
|
<section class="task-list" :class="{loading}">
|
||||||
<template v-for="node in taskTree" :key="node.task.id">
|
<template v-for="node in taskTree" :key="node.task.id">
|
||||||
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||||
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||||||
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
||||||
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
||||||
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button></span>
|
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button></span>
|
||||||
</article>
|
</article>
|
||||||
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button v-if="activeView!=='trash'" class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
|
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),'completion-exiting':completionExitingTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button v-if="activeView!=='trash'" class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(subtask)"><strong :title="subtask.title">{{subtask.title}}</strong></div><span v-if="subtask.due_at" class="task-tail"><TaskDueDisplay :due-at="subtask.due_at" :due-has-time="subtask.due_has_time" :completed="subtask.completed" :now-ms="taskDueNowMs" /></span></article>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, Grip
|
|||||||
import { moveItemWithinScope } from './lib/task-utils'
|
import { moveItemWithinScope } from './lib/task-utils'
|
||||||
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||||
@@ -61,13 +61,26 @@ const habitSwipeOffsets = ref<Record<string, number>>({})
|
|||||||
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||||||
const habitReorderTarget = ref('')
|
const habitReorderTarget = ref('')
|
||||||
const justCompletedHabitIds = ref(new Set<string>())
|
const justCompletedHabitIds = ref(new Set<string>())
|
||||||
|
const completionExitingHabitIds = ref(new Set<string>())
|
||||||
|
function setHabitCompletionExiting(id: string, active: boolean) {
|
||||||
|
const next = new Set(completionExitingHabitIds.value)
|
||||||
|
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
|
||||||
|
markHabitJustCompleted(h.id)
|
||||||
|
if (!animateExit) return
|
||||||
|
await waitForCompletionExit()
|
||||||
|
setHabitCompletionExiting(h.id, false)
|
||||||
|
}
|
||||||
const markHabitJustCompleted = createCompletionPulse(
|
const markHabitJustCompleted = createCompletionPulse(
|
||||||
(id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) },
|
(id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) },
|
||||||
(id) => { const next = new Set(justCompletedHabitIds.value); next.delete(id); justCompletedHabitIds.value = next },
|
(id) => { const next = new Set(justCompletedHabitIds.value); next.delete(id); justCompletedHabitIds.value = next },
|
||||||
)
|
)
|
||||||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||||||
const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value)))
|
const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
||||||
const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value)))
|
const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
||||||
const todayHabitSummary = computed(() => ({
|
const todayHabitSummary = computed(() => ({
|
||||||
total: todayHabits.value.length,
|
total: todayHabits.value.length,
|
||||||
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
|
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
|
||||||
@@ -222,12 +235,18 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
|
|||||||
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
|
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
|
||||||
: previousHabitSwipeValue(h.kind, current)
|
: previousHabitSwipeValue(h.kind, current)
|
||||||
const previous = current ?? 0
|
const previous = current ?? 0
|
||||||
setLocalHabitValue(h, next)
|
const animateExit = !wasDone && isHabitComplete(h.kind, next, h.target ?? 1) && shouldAnimateCompletionExit({ completing: true, showCompleted: props.showCompleted })
|
||||||
|
if (!animateExit) setLocalHabitValue(h, next)
|
||||||
try {
|
try {
|
||||||
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||||
if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id)
|
if (animateExit) {
|
||||||
|
setHabitCompletionExiting(h.id, true)
|
||||||
|
setLocalHabitValue(h, next)
|
||||||
|
}
|
||||||
|
await finishHabitCompletion(h, wasDone, next, animateExit)
|
||||||
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
|
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setHabitCompletionExiting(h.id, false)
|
||||||
setLocalHabitValue(h, previous)
|
setLocalHabitValue(h, previous)
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : '请求失败'
|
||||||
}
|
}
|
||||||
@@ -284,12 +303,15 @@ async function toggleHabitFromButton(h: Habit) {
|
|||||||
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
|
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
|
||||||
const previous = current ?? 0
|
const previous = current ?? 0
|
||||||
const next = habitButtonValue(h.kind, current, h.target ?? 1)
|
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)
|
setLocalHabitValue(h, next)
|
||||||
try {
|
try {
|
||||||
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||||
if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id)
|
await finishHabitCompletion(h, wasDone, next, animateExit)
|
||||||
emit('notice', habitButtonNotice(h.kind, previous, next))
|
emit('notice', habitButtonNotice(h.kind, previous, next))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setHabitCompletionExiting(h.id, false)
|
||||||
setLocalHabitValue(h, previous)
|
setLocalHabitValue(h, previous)
|
||||||
error.value = e instanceof Error ? e.message : '请求失败'
|
error.value = e instanceof Error ? e.message : '请求失败'
|
||||||
}
|
}
|
||||||
@@ -567,7 +589,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||||||
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||||||
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||||
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
||||||
<div class="habit-main">
|
<div class="habit-main">
|
||||||
<span class="habit-name">{{ h.name }}</span>
|
<span class="habit-name">{{ h.name }}</span>
|
||||||
@@ -601,7 +623,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 习惯列表支持整行滑动记录。 -->
|
<!-- 习惯列表支持整行滑动记录。 -->
|
||||||
<div v-if="view === 'habits'" class="habit-list">
|
<div v-if="view === 'habits'" class="habit-list">
|
||||||
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||||
<button class="drag-handle habit-drag-handle" :disabled="!showCompleted" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
|
<button class="drag-handle habit-drag-handle" :disabled="!showCompleted" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
|
||||||
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
||||||
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
|
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ type CompletionId = string
|
|||||||
type CompletionTimer = number
|
type CompletionTimer = number
|
||||||
type CompletionFrame = number
|
type CompletionFrame = number
|
||||||
|
|
||||||
|
export const COMPLETION_EXIT_DURATION = 320
|
||||||
|
|
||||||
|
export function shouldAnimateCompletionExit({ completing, showCompleted }: { completing: boolean; showCompleted: boolean }) {
|
||||||
|
return completing && !showCompleted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completionExitDelay(reducedMotion: boolean) {
|
||||||
|
return reducedMotion ? 0 : COMPLETION_EXIT_DURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefersReducedMotion() {
|
||||||
|
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForCompletionExit(reducedMotion = prefersReducedMotion()) {
|
||||||
|
const delay = completionExitDelay(reducedMotion)
|
||||||
|
return delay ? new Promise<void>((resolve) => window.setTimeout(resolve, delay)) : Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
export function createCompletionPulse(
|
export function createCompletionPulse(
|
||||||
activate: (id: CompletionId) => void,
|
activate: (id: CompletionId) => void,
|
||||||
deactivate: (id: CompletionId) => void,
|
deactivate: (id: CompletionId) => void,
|
||||||
|
|||||||
@@ -227,13 +227,15 @@ export async function runLatestRequest<T>(
|
|||||||
return committed
|
return committed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
|
||||||
|
|
||||||
export type MutationReconciler<TContext> = {
|
export type MutationReconciler<TContext> = {
|
||||||
run<T>(
|
run<T>(
|
||||||
mutation: () => Promise<T>,
|
mutation: () => Promise<T>,
|
||||||
onSuccess: (value: T) => void,
|
onSuccess: (value: T) => void,
|
||||||
onError?: (reason: unknown) => void,
|
onError?: (reason: unknown) => void,
|
||||||
onCurrentSuccess?: (value: T) => void,
|
onCurrentSuccess?: MutationSuccessCallback<T>,
|
||||||
): Promise<void>
|
): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMutationReconciler<TContext>(
|
export function createMutationReconciler<TContext>(
|
||||||
@@ -269,12 +271,16 @@ export function createMutationReconciler<TContext>(
|
|||||||
value = await mutation()
|
value = await mutation()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
onError?.(reason)
|
onError?.(reason)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
onSuccess(value)
|
onSuccess(value)
|
||||||
if (sameContext(context, currentContext())) onCurrentSuccess?.(value)
|
if (sameContext(context, currentContext())) {
|
||||||
|
const currentSuccess = onCurrentSuccess?.(value)
|
||||||
|
if (currentSuccess instanceof Promise) await currentSuccess
|
||||||
|
}
|
||||||
await reconcile(context)
|
await reconcile(context)
|
||||||
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
|
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,31 @@ describe('request generation protection', () => {
|
|||||||
await Promise.all([first, second])
|
await Promise.all([first, second])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('waits for current-context success work before refreshing', async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
let releaseExit!: () => void
|
||||||
|
const reconciler = createMutationReconciler(
|
||||||
|
() => ({ view: 'today' }),
|
||||||
|
(left, right) => left.view === right.view,
|
||||||
|
async () => { events.push('refresh') },
|
||||||
|
)
|
||||||
|
const mutation = reconciler.run(
|
||||||
|
async () => 'done',
|
||||||
|
() => events.push('success'),
|
||||||
|
undefined,
|
||||||
|
async () => {
|
||||||
|
events.push('exit:start')
|
||||||
|
await new Promise<void>((resolve) => { releaseExit = resolve })
|
||||||
|
events.push('exit:end')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(events).toEqual(['success', 'exit:start'])
|
||||||
|
releaseExit()
|
||||||
|
await mutation
|
||||||
|
expect(events).toEqual(['success', 'exit:start', 'exit:end', 'refresh'])
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes again when the first refresh finishes before the second mutation commits', async () => {
|
it('refreshes again when the first refresh finishes before the second mutation commits', async () => {
|
||||||
const events: string[] = []
|
const events: string[] = []
|
||||||
const context = { view: 'today' }
|
const context = { view: 'today' }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { createCompletionPulse } from './lib/completion-motion'
|
import { createCompletionPulse, completionExitDelay, shouldAnimateCompletionExit } from './lib/completion-motion'
|
||||||
|
|
||||||
const css = readFileSync('src/style.css', 'utf8')
|
const css = readFileSync('src/style.css', 'utf8')
|
||||||
const app = readFileSync('src/App.vue', 'utf8')
|
const app = readFileSync('src/App.vue', 'utf8')
|
||||||
@@ -200,13 +200,28 @@ describe('solid cream material system', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('completion feedback motion', () => {
|
describe('completion feedback motion', () => {
|
||||||
|
it('delays removal only when a completed row will become hidden', () => {
|
||||||
|
expect(shouldAnimateCompletionExit({ completing: true, showCompleted: false })).toBe(true)
|
||||||
|
expect(shouldAnimateCompletionExit({ completing: true, showCompleted: true })).toBe(false)
|
||||||
|
expect(shouldAnimateCompletionExit({ completing: false, showCompleted: false })).toBe(false)
|
||||||
|
expect(completionExitDelay(false)).toBe(320)
|
||||||
|
expect(completionExitDelay(true)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps completed task and habit rows mounted for the exit motion', () => {
|
||||||
|
expect(app).toContain('completion-exiting')
|
||||||
|
expect(mvpPanel).toContain('completion-exiting')
|
||||||
|
expect(css).toContain('.task-row.completion-exiting,.habit-row.completion-exiting{')
|
||||||
|
expect(css).toContain('@keyframes completion-slide-out')
|
||||||
|
})
|
||||||
|
|
||||||
it('animates only transient just-completed rows and respects reduced motion', () => {
|
it('animates only transient just-completed rows and respects reduced motion', () => {
|
||||||
expect(css).toContain('.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}')
|
expect(css).toContain('.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}')
|
||||||
expect(css).toContain('.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}')
|
expect(css).toContain('.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}')
|
||||||
expect(css).not.toContain('.task-row.done,.habit-row.done{animation:')
|
expect(css).not.toContain('.task-row.done,.habit-row.done{animation:')
|
||||||
expect(css).toContain('@keyframes completion-row-settle')
|
expect(css).toContain('@keyframes completion-row-settle')
|
||||||
expect(css).toContain('@keyframes completion-check-pop')
|
expect(css).toContain('@keyframes completion-check-pop')
|
||||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}')
|
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncs the browser IANA timezone before loading user task data', () => {
|
it('syncs the browser IANA timezone before loading user task data', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user