From aff070d2f7cc2d8f00ec871248820f7a9a8b0e4c Mon Sep 17 00:00:00 2001 From: bboysoul Date: Wed, 9 Sep 2026 09:45:38 +0800 Subject: [PATCH] fix: scope completion motion to user actions --- frontend/src/App.vue | 17 ++++++++++++++--- frontend/src/MvpPanel.vue | 17 +++++++++++++++-- frontend/src/style.css | 2 +- frontend/src/style.test.ts | 22 +++++++++++++++++----- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 78ef54a..4f04953 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -59,6 +59,7 @@ const todayHabitCompleted = ref(0) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const expandedFolders = ref(new Set()) const collapsedTaskIds = ref(new Set()) +const justCompletedTaskIds = ref(new Set()) 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) { 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)
{{page}} / {{totalPages}}
{{query?'没有匹配的任务':'这里还很安静'}}{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}
diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index cba1ca0..b37cae0 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -42,6 +42,7 @@ const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null) const habitSwipeOffsets = ref>({}) const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null) const habitReorderTarget = ref('') +const justCompletedHabitIds = ref(new Set()) 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(() => {
-
+
{{ h.name }} @@ -470,7 +483,7 @@ onBeforeUnmount(() => {
-
+
diff --git a/frontend/src/style.css b/frontend/src/style.css index b589abe..255be3e 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -39,7 +39,7 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient .overdue-section{margin-top:18px}.overdue-heading{color:var(--danger);margin-bottom:6px}.overdue-heading span{min-width:22px;height:22px;padding:0 7px;display:inline-grid;place-items:center;border-radius:999px;background:#fde2dc;color:var(--danger);font-size:11px}.overdue-list{margin-bottom:18px}.overdue-task{background:#fff8f6}.overdue-task .meta,.overdue-badge{color:var(--danger)}.overdue-badge{font-size:11px;font-weight:700;background:#fde2dc;border-radius:999px;padding:4px 8px}.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.section-heading{display:flex;align-items:center;gap:7px;margin:16px 0 10px;font-size:16px;line-height:1.35;color:#514a40}.section-heading svg{width:17px;height:17px;color:var(--accent)}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.more-settings{border:1px solid var(--line);border-radius:10px;background:#fff}.more-settings summary{cursor:pointer;padding:11px 13px;color:#756d61;font-size:12px;font-weight:700}.more-settings-body{display:grid;gap:12px;padding:0 13px 13px}.more-settings-body>label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:7px;color:#756d61;font-size:12px;font-weight:650}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}.countdown-view{display:grid;gap:16px;padding-bottom:36px}.countdown-content{display:grid;gap:16px}.countdown-hero{display:flex;align-items:end;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:10px}.countdown-hero small{display:block;color:var(--muted);font-size:12px}.countdown-layout{display:grid;gap:22px;align-items:start}.countdown-timeline{display:grid;gap:20px;max-width:820px;width:100%;margin:0 auto}.countdown-group{display:grid;gap:2px}.countdown-group>h3{margin:0 0 7px;padding-left:12px;color:#8a8175;font-size:11px;font-weight:750;letter-spacing:.08em}.countdown-row{--countdown-tone:#d9a17c;position:relative;width:100%;min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) 86px;align-items:center;gap:14px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;padding:9px 10px 9px 17px;text-align:left;color:inherit;transition:background .15s ease,transform .15s ease}.countdown-row:before{content:'';position:absolute;left:3px;top:50%;width:4px;height:4px;border-radius:50%;background:var(--countdown-tone);transform:translateY(-50%)}.countdown-row:first-of-type{border-top:1px solid var(--line)}.countdown-row:hover{background:#fffaf3}.countdown-row:active{transform:scale(.995)}.countdown-row.kind-anniversary{--countdown-tone:#b69b7d}.countdown-row.kind-birthday{--countdown-tone:#9cab8d}.countdown-row.today{--countdown-tone:#71856b}.countdown-row.past{--countdown-tone:#aaa094;opacity:.66}.countdown-row .pinned-icon{position:absolute;right:4px;top:5px}.countdown-icon{width:38px;height:38px;border-radius:12px;background:#fff2e8;display:grid;place-items:center;font-size:19px}.countdown-main{min-width:0;display:grid;gap:4px}.countdown-main>b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px;color:#3f3932}.countdown-main>small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.countdown-badges{display:flex;gap:5px}.countdown-badges em{font-style:normal;color:#8a7f72;font-size:10px}.countdown-badges em+em:before{content:'·';margin-right:5px;color:#c1b7aa}.countdown-state{display:grid;justify-items:end;gap:3px}.countdown-state strong{font-variant-numeric:tabular-nums;font-size:26px;line-height:1;color:#62594e}.countdown-state small{color:var(--muted);font-size:10px}.countdown-row.today .countdown-state strong,.countdown-row.today .countdown-state small{color:#71856b}.countdown-row.past .countdown-state strong{color:#887d70}.countdown-focus{--countdown-tone:#d9a17c;width:min(720px,100%);min-height:255px;margin:0 auto;display:grid;place-content:center;justify-items:center;gap:7px;border:1px solid color-mix(in srgb,var(--countdown-tone) 34%,var(--line));border-radius:16px;background:color-mix(in srgb,var(--countdown-tone) 7%,#fffdf8);padding:28px 24px;text-align:center;color:inherit;box-shadow:none;transition:border-color .15s ease,background .15s ease}.countdown-focus:hover{border-color:color-mix(in srgb,var(--countdown-tone) 54%,var(--line));background:color-mix(in srgb,var(--countdown-tone) 10%,#fffdf8)}.countdown-focus.kind-anniversary{--countdown-tone:#b69b7d}.countdown-focus.kind-birthday{--countdown-tone:#9cab8d}.countdown-focus>small{color:#8b8276;font-size:10px;font-weight:750;letter-spacing:.1em}.countdown-focus h3{margin:7px 0 0;font-size:20px;font-weight:680}.countdown-focus p{margin:1px 0 0;color:var(--muted);font-size:11px}.countdown-number{display:flex;align-items:end;gap:6px;margin:4px 0 3px}.countdown-number strong{font-variant-numeric:tabular-nums;font-size:76px;line-height:.9;font-weight:720;letter-spacing:-.05em;color:#3f3932}.countdown-number span{font-size:13px;color:#7a7165;padding-bottom:6px}.countdown-copy{font-size:12px;color:#71685e;font-weight:650}.countdown-empty{min-height:260px;background:#fffdf8;border:1px dashed var(--line);border-radius:16px;padding:26px;color:#766d60;text-align:center;display:grid;gap:9px;place-content:center;justify-items:center}.countdown-empty svg{color:var(--accent);width:30px;height:30px}.countdown-empty span{font-size:12px}.countdown-empty button{margin-top:5px}.archived-toggle,.archived-countdowns button{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:10px;padding:8px 11px;font-size:12px}.archived-toggle{justify-self:start}.archived-countdowns{display:grid;gap:10px}.archived-countdowns article{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px 14px}.archived-countdowns article b{flex:1}.archived-countdowns article small{color:var(--muted)}.countdown-detail-mask,.countdown-modal-mask{position:fixed;inset:0;background:rgba(45,38,31,.36);display:grid;padding:20px}.countdown-detail-mask{z-index:85;place-items:end center}.countdown-detail-sheet{width:min(500px,100%);background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.countdown-detail-sheet header{display:grid;grid-template-columns:42px 1fr 44px;align-items:center;gap:10px}.countdown-detail-sheet header small{color:var(--muted);font-size:11px}.countdown-detail-sheet header h3{margin:2px 0 0;font-size:19px}.countdown-detail-sheet header button{min-width:44px;min-height:44px;border:0;background:transparent;color:#766d60;border-radius:8px;width:44px;height:44px;display:grid;place-items:center}.countdown-modal header button{min-width:44px;min-height:44px;border:0;background:transparent;color:#766d60;border-radius:8px;width:44px;height:44px;display:grid;place-items:center}.countdown-detail-days{display:flex;align-items:baseline;gap:6px;padding:8px 0}.countdown-detail-days strong{font-variant-numeric:tabular-nums;font-size:46px;line-height:1}.countdown-detail-days span{color:var(--muted)}.countdown-detail-days b{margin-left:auto;color:var(--accent);font-size:13px}.countdown-detail-sheet dl{margin:0;display:grid;gap:1px;background:var(--line)}.countdown-detail-sheet dl div{display:grid;grid-template-columns:62px 1fr;gap:10px;background:#fffdf8;padding:10px 0}.countdown-detail-sheet dt{color:var(--muted);font-size:12px}.countdown-detail-sheet dd{margin:0;font-size:13px}.countdown-detail-sheet footer{display:flex;justify-content:flex-end;gap:8px}.countdown-detail-sheet footer button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:10px;padding:9px 13px}.countdown-modal-mask{z-index:90;place-items:center}.countdown-modal{width:min(420px,96vw);background:#fffdf8;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);padding:18px;display:grid;gap:12px}.countdown-modal header{display:flex;justify-content:space-between;align-items:start}.countdown-modal header small{color:var(--muted)}.countdown-modal header h3{margin:2px 0 0}.countdown-modal label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.countdown-modal input,.countdown-modal select{border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.countdown-modal input:focus,.countdown-modal select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.countdown-title-fields{display:grid;grid-template-columns:72px 1fr;gap:8px}.countdown-modal footer{display:flex;justify-content:flex-end;gap:8px;padding-top:6px}.countdown-advanced{border:1px solid var(--line);border-radius:11px;background:#fffaf4}.countdown-advanced summary{min-height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;cursor:pointer;color:#756d61;font-size:12px;font-weight:700;list-style:none}.countdown-advanced summary::-webkit-details-marker{display:none}.countdown-advanced summary svg{width:16px;height:16px;transition:transform .16s ease}.countdown-advanced[open] summary svg{transform:rotate(180deg)}.countdown-advanced>label,.countdown-advanced>.countdown-title-fields{margin:0 12px 12px}.countdown-detail-enter-active,.countdown-detail-leave-active{transition:background .2s ease}.countdown-detail-enter-active .countdown-detail-sheet,.countdown-detail-leave-active .countdown-detail-sheet{transition:transform .24s ease,opacity .2s ease}.countdown-detail-enter-from .countdown-detail-sheet,.countdown-detail-leave-to .countdown-detail-sheet{transform:translateY(100%);opacity:.6}@media(max-width:930px){.countdown-layout{grid-template-columns:1fr;gap:16px}.countdown-focus{display:grid;min-height:205px;padding:22px 16px}.countdown-number strong{font-size:64px}.countdown-timeline{gap:16px}}@media(max-width:800px){.countdown-hero{align-items:center}.countdown-hero h2{font-size:19px}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:9px 4px;gap:9px}.countdown-state strong{font-size:25px}.countdown-detail-mask,.countdown-modal-mask{place-items:end;padding:0}.countdown-detail-sheet,.countdown-modal{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom));max-height:min(88dvh,760px);overflow:auto}.countdown-view{position:relative}} @media(max-width:930px){.countdown-row,.countdown-row:first-of-type{min-height:62px;grid-template-columns:minmax(0,1fr) 72px;gap:8px;padding:8px 9px 8px 17px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;box-shadow:none}.countdown-row:first-of-type{border-top:1px solid var(--line)}.countdown-row.pinned,.countdown-row.today{background:transparent}.countdown-state strong{font-size:24px}} -.task-row.done,.habit-row.done{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.done .task-check-mark,.habit-row.done .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@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}} +.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.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)}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@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}} /* Warm Liquid Glass: restrained depth for chrome and overlays, not list rows. */ :root{--radius-control:12px;--radius-card:20px;--radius-panel:28px;--glass-surface:rgba(255,253,248,.74);--glass-surface-strong:rgba(255,253,248,.84);--glass-border:rgba(255,255,255,.72);--glass-shadow:0 18px 48px rgba(67,48,30,.12),inset 0 1px 0 rgba(255,255,255,.78);--sheet-radius:28px} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 1778c49..49939aa 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -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())") + 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) }) }) @@ -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()')