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 { 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 { isTaskView, nextTotalAfterLocalTaskAdd, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
import { createCompletionPulse } from './lib/completion-motion'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
import FloatingAddButton from './components/FloatingAddButton.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 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 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 navigationLoaded = ref(false)
|
||||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||||
const taskPointerStart = 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 }
|
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||||
return updated as Task
|
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) {
|
async function toggle(task: Task) {
|
||||||
const completing = !task.completed
|
const completing = !task.completed
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogO
|
|||||||
import { moveItemWithinScope } from './lib/task-utils'
|
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 { dateKey, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, readStoredBoolean, shouldToggleRowSwipe, writeHabitGridCache, writeStoredBoolean } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
import { createCompletionPulse } from './lib/completion-motion'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
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> }
|
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 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 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 HIDE_COMPLETED_HABITS_STORAGE_KEY = 'dodo.hide-completed-habits'
|
||||||
const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))
|
const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))
|
||||||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
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) {
|
async function applyHabitSwipe(h: Habit, deltaX: number) {
|
||||||
const current = logFor(h, todayKey.value)?.value
|
const current = logFor(h, todayKey.value)?.value
|
||||||
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
|
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 { 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 css = readFileSync('src/style.css', 'utf8')
|
||||||
const app = readFileSync('src/App.vue', '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}}')
|
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', () => {
|
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
|
||||||
expect(app).toContain("const justCompletedTaskIds = ref(new Set<string>())")
|
vi.useFakeTimers()
|
||||||
expect(app).toContain("if (completing) markTaskJustCompleted(task.id)")
|
const active = new Set<string>()
|
||||||
expect(app).toContain("'just-completed': justCompletedTaskIds.has(node.task.id)")
|
const pulse = createCompletionPulse((id) => active.add(id), (id) => active.delete(id), 420)
|
||||||
expect(app).toContain("'just-completed': justCompletedTaskIds.has(subtask.id)")
|
pulse('item-1')
|
||||||
expect(mvpPanel).toContain("const justCompletedHabitIds = ref(new Set<string>())")
|
vi.advanceTimersByTime(16)
|
||||||
expect(mvpPanel.match(/if \(!wasDone && isHabitComplete\(h\.kind, next, h\.target \?\? 1\)\) markHabitJustCompleted\(h\.id\)/g)).toHaveLength(2)
|
expect(active.has('item-1')).toBe(true)
|
||||||
expect(mvpPanel.match(/'just-completed': justCompletedHabitIds\.has\(h\.id\)/g)).toHaveLength(2)
|
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