fix: replay completion motion reliably
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user