diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 602e0ba..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "client", - "runtimeExecutable": "npm", - "runtimeArgs": ["start", "--workspace=client"], - "port": 3000 - } - ] -} diff --git a/client/src/lib/motion.ts b/client/src/lib/motion.ts index f99b7f0..b19e1ea 100644 --- a/client/src/lib/motion.ts +++ b/client/src/lib/motion.ts @@ -36,11 +36,19 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul const reduced = usePrefersReducedMotion(); const [display, setDisplay] = useState(value); const frame = useRef(); + const settleTimer = useRef(); const from = useRef(0); useEffect(() => { - if (value == null || reduced) { + const settle = () => { setDisplay(value); + from.current = value ?? 0; + }; + + // Nothing to animate towards, or the viewer asked for no motion. Also + // covers a hidden tab, where requestAnimationFrame does not run at all. + if (value == null || reduced || document.hidden) { + settle(); return; } @@ -52,12 +60,23 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul const t = Math.min(1, (now - start) / durationMs); setDisplay(origin + delta * easeOut(t)); if (t < 1) frame.current = requestAnimationFrame(tick); - else from.current = value; + else settle(); }; frame.current = requestAnimationFrame(tick); + + /* The animation is decoration; the number is not. + This used to be rAF alone, so when frames never arrived — a + backgrounded tab, a throttled webview — `setDisplay` was never called + and the card kept rendering the *previous* value. Stepping back a day + changed the date in the header while every figure stayed on the day + before: not a missing animation, but the wrong data shown confidently. + The timer guarantees the final value lands either way. */ + settleTimer.current = window.setTimeout(settle, durationMs + 250); + return () => { if (frame.current) cancelAnimationFrame(frame.current); + window.clearTimeout(settleTimer.current); }; }, [value, durationMs, reduced]);