fix: scope completion motion to user actions
This commit is contained in:
+14
-3
@@ -59,6 +59,7 @@ const todayHabitCompleted = ref(0)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||||
const expandedFolders = ref(new Set<string>())
|
||||
const collapsedTaskIds = ref(new Set<string>())
|
||||
const justCompletedTaskIds = ref(new Set<string>())
|
||||
const navigationLoaded = ref(false)
|
||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
@@ -494,9 +495,19 @@ async function patchTask(task: Task, patch: Partial<Task>) {
|
||||
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||
return updated as Task
|
||||
}
|
||||
function markTaskJustCompleted(id: string) {
|
||||
justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id)
|
||||
window.setTimeout(() => {
|
||||
const next = new Set(justCompletedTaskIds.value)
|
||||
next.delete(id)
|
||||
justCompletedTaskIds.value = next
|
||||
}, 420)
|
||||
}
|
||||
async function toggle(task: Task) {
|
||||
const completing = !task.completed
|
||||
try {
|
||||
await patchTask(task, { completed: !task.completed })
|
||||
await patchTask(task, { completed: completing })
|
||||
if (completing) markTaskJustCompleted(task.id)
|
||||
await loadTasksPage()
|
||||
if (activeView.value === 'today') {
|
||||
await loadOverdueTasks()
|
||||
@@ -852,7 +863,7 @@ onMounted(bootstrap)
|
||||
<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}">
|
||||
<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,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),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="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>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
|
||||
@@ -861,7 +872,7 @@ onMounted(bootstrap)
|
||||
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
|
||||
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||||
</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,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="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 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>{{subtask.title}}</strong></div></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 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 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>{{subtask.title}}</strong></div></article>
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||||
</section>
|
||||
|
||||
@@ -42,6 +42,7 @@ const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const habitSwipeOffsets = ref<Record<string, number>>({})
|
||||
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||||
const habitReorderTarget = ref('')
|
||||
const justCompletedHabitIds = ref(new Set<string>())
|
||||
const HIDE_COMPLETED_HABITS_STORAGE_KEY = 'dodo.hide-completed-habits'
|
||||
const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))
|
||||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||||
@@ -209,8 +210,17 @@ function setLocalHabitValue(h: Habit, next: number | boolean) {
|
||||
})
|
||||
}
|
||||
|
||||
function markHabitJustCompleted(id: string) {
|
||||
justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id)
|
||||
window.setTimeout(() => {
|
||||
const next = new Set(justCompletedHabitIds.value)
|
||||
next.delete(id)
|
||||
justCompletedHabitIds.value = next
|
||||
}, 420)
|
||||
}
|
||||
async function applyHabitSwipe(h: Habit, deltaX: number) {
|
||||
const current = logFor(h, todayKey.value)?.value
|
||||
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
|
||||
const next = deltaX > 0
|
||||
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
|
||||
: previousHabitSwipeValue(h.kind, current)
|
||||
@@ -218,6 +228,7 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
|
||||
setLocalHabitValue(h, next)
|
||||
try {
|
||||
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)
|
||||
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
|
||||
} catch (e) {
|
||||
setLocalHabitValue(h, previous)
|
||||
@@ -272,11 +283,13 @@ function cancelHabitSwipe(h?: Habit) {
|
||||
}
|
||||
async function toggleHabitFromButton(h: Habit) {
|
||||
const current = logFor(h, todayKey.value)?.value
|
||||
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
|
||||
const previous = current ?? 0
|
||||
const next = habitButtonValue(h.kind, current, h.target ?? 1)
|
||||
setLocalHabitValue(h, next)
|
||||
try {
|
||||
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)
|
||||
emit('notice', habitButtonNotice(h.kind, previous, next))
|
||||
} catch (e) {
|
||||
setLocalHabitValue(h, previous)
|
||||
@@ -444,7 +457,7 @@ onBeforeUnmount(() => {
|
||||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||||
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||||
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
||||
<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), 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), 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" :aria-label="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">
|
||||
<span class="habit-name">{{ h.name }}</span>
|
||||
@@ -470,7 +483,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 习惯列表支持整行滑动记录。 -->
|
||||
<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), 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), 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="hideCompletedHabits" 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" :aria-label="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)">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -49,12 +49,23 @@ describe('warm Liquid Glass styling', () => {
|
||||
})
|
||||
|
||||
describe('completion feedback motion', () => {
|
||||
it('animates completed task and habit rows without ignoring reduced motion', () => {
|
||||
expect(css).toContain('.task-row.done,.habit-row.done{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}')
|
||||
expect(css).toContain('.task-row.done .task-check-mark,.habit-row.done .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}')
|
||||
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 .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).toContain('@keyframes completion-row-settle')
|
||||
expect(css).toContain('@keyframes completion-check-pop')
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.done,.habit-row.done,.task-row.done .task-check-mark,.habit-row.done .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}}')
|
||||
})
|
||||
|
||||
it('adds transient completion classes only for incomplete to complete transitions', () => {
|
||||
expect(app).toContain("const justCompletedTaskIds = ref(new Set<string>())")
|
||||
expect(app).toContain("if (completing) markTaskJustCompleted(task.id)")
|
||||
expect(app).toContain("'just-completed': justCompletedTaskIds.has(node.task.id)")
|
||||
expect(app).toContain("'just-completed': justCompletedTaskIds.has(subtask.id)")
|
||||
expect(mvpPanel).toContain("const justCompletedHabitIds = ref(new Set<string>())")
|
||||
expect(mvpPanel.match(/if \(!wasDone && isHabitComplete\(h\.kind, next, h\.target \?\? 1\)\) markHabitJustCompleted\(h\.id\)/g)).toHaveLength(2)
|
||||
expect(mvpPanel.match(/'just-completed': justCompletedHabitIds\.has\(h\.id\)/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +202,8 @@ describe('task and habit row decoration', () => {
|
||||
|
||||
it('refreshes the current task data after toggling completion', () => {
|
||||
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
|
||||
expect(toggleBlock).toContain('await patchTask(task, { completed: !task.completed })')
|
||||
expect(toggleBlock).toContain('const completing = !task.completed')
|
||||
expect(toggleBlock).toContain('await patchTask(task, { completed: completing })')
|
||||
expect(toggleBlock).toContain('await loadTasksPage()')
|
||||
expect(toggleBlock).toContain("if (activeView.value === 'today')")
|
||||
expect(toggleBlock).toContain('await loadOverdueTasks()')
|
||||
|
||||
Reference in New Issue
Block a user