From 17005b0a5090830d73a88e3849be84a823f1a5a0 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Wed, 9 Sep 2026 09:52:53 +0800 Subject: [PATCH] fix: replay completion motion reliably --- frontend/src/App.vue | 13 +++++-------- frontend/src/MvpPanel.vue | 13 +++++-------- frontend/src/lib/completion-motion.ts | 26 +++++++++++++++++++++++++ frontend/src/style.test.ts | 28 ++++++++++++++++++--------- 4 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/completion-motion.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4f04953..5e98aa9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,6 +8,7 @@ import { import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' import { isTaskView, nextTotalAfterLocalTaskAdd, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' +import { createCompletionPulse } from './lib/completion-motion' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' @@ -60,6 +61,10 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS const expandedFolders = ref(new Set()) const collapsedTaskIds = ref(new Set()) const justCompletedTaskIds = ref(new Set()) +const markTaskJustCompleted = createCompletionPulse( + (id) => { justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id) }, + (id) => { const next = new Set(justCompletedTaskIds.value); next.delete(id); justCompletedTaskIds.value = next }, +) 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) @@ -495,14 +500,6 @@ async function patchTask(task: Task, patch: Partial) { 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 { diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index b37cae0..6b823a4 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -4,6 +4,7 @@ import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogO import { moveItemWithinScope } from './lib/task-utils' import { dateKey, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, readStoredBoolean, shouldToggleRowSwipe, writeHabitGridCache, writeStoredBoolean } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' +import { createCompletionPulse } from './lib/completion-motion' type View = 'habits' | 'today-habits' | 'settings' type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record } @@ -43,6 +44,10 @@ const habitSwipeOffsets = ref>({}) const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null) const habitReorderTarget = ref('') const justCompletedHabitIds = ref(new Set()) +const markHabitJustCompleted = createCompletionPulse( + (id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) }, + (id) => { const next = new Set(justCompletedHabitIds.value); next.delete(id); justCompletedHabitIds.value = next }, +) 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))) @@ -210,14 +215,6 @@ 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) diff --git a/frontend/src/lib/completion-motion.ts b/frontend/src/lib/completion-motion.ts new file mode 100644 index 0000000..d1c3e6c --- /dev/null +++ b/frontend/src/lib/completion-motion.ts @@ -0,0 +1,26 @@ +type CompletionId = string + +type CompletionTimer = number + +export function createCompletionPulse( + activate: (id: CompletionId) => void, + deactivate: (id: CompletionId) => void, + duration = 420, +) { + const timers = new Map() + + return (id: CompletionId) => { + const previous = timers.get(id) + if (previous !== undefined) { + window.clearTimeout(previous) + deactivate(id) + } + window.requestAnimationFrame(() => { + activate(id) + timers.set(id, window.setTimeout(() => { + deactivate(id) + timers.delete(id) + }, duration)) + }) + } +} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 49939aa..4d51162 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { createCompletionPulse } from './lib/completion-motion' const css = readFileSync('src/style.css', 'utf8') const app = readFileSync('src/App.vue', 'utf8') @@ -58,14 +59,23 @@ describe('completion feedback motion', () => { 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())") - 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())") - 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) + it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => { + vi.useFakeTimers() + const active = new Set() + const pulse = createCompletionPulse((id) => active.add(id), (id) => active.delete(id), 420) + pulse('item-1') + vi.advanceTimersByTime(16) + expect(active.has('item-1')).toBe(true) + vi.advanceTimersByTime(200) + pulse('item-1') + expect(active.has('item-1')).toBe(false) + vi.advanceTimersByTime(16) + expect(active.has('item-1')).toBe(true) + vi.advanceTimersByTime(220) + expect(active.has('item-1')).toBe(true) + vi.advanceTimersByTime(200) + expect(active.has('item-1')).toBe(false) + vi.useRealTimers() }) })