fix: replay completion motion reliably
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-09 09:52:53 +08:00
parent aff070d2f7
commit 17005b0a50
4 changed files with 55 additions and 25 deletions
+5 -8
View File
@@ -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<string>())
const collapsedTaskIds = ref(new Set<string>())
const justCompletedTaskIds = ref(new Set<string>())
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<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 {
+5 -8
View File
@@ -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<string, number> }
@@ -43,6 +44,10 @@ 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 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)
+26
View File
@@ -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<CompletionId, CompletionTimer>()
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))
})
}
}
+19 -9
View File
@@ -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<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)
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
vi.useFakeTimers()
const active = new Set<string>()
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()
})
})