Files
HealthLog/src/hooks/use-count-up.ts
T
Marc-André BombeckandClaude Opus 4.8 20d7187b0d feat(insights): premium animated wellness-score rings
Rebuild the wellness-score strip on /insights as a signature, high-end moment
instead of a flat dial.

- Five distinct per-metric hues — readiness green, recovery teal, sleep
  indigo-blue, stress amber, strain violet — hand-tuned per theme so each holds
  contrast on the white light-mode card. Fixes the dark-theme collapse to a
  cyan/green wash (the semantic info token resolved to the same cyan as three
  rings) and the light-theme wash-out of the raw cyan on white.
- A spring-eased arc sweep that overshoots ~6% then settles, a left-to-right
  staggered reveal with each number trailing its own arc, a conic specular
  sheen that sweeps once, a token-tinted bloom (a gentle breathing pulse only
  on a top-band ring), a segmented instrument-style track, and a faint
  film-grain tile surface for material depth.
- A "vs your normal" ghost arc under the live arc for recovery, stress and
  strain, derived from the trailing score series already on the payload, so the
  user sees today against their baseline at a glance.

The reveal plays once per browser session (sessionStorage-gated) so a
background sync refetch never re-triggers it. All motion is CSS-first — no
animation dependency, @property + a sampled linear() spring — and is fully
disabled under prefers-reduced-motion. The arc stays a hand-rolled SVG gauge;
the band semantic still rides data-band + the band word + the aria-label, never
colour alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:40:00 +02:00

112 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* v1.10.0 — a tiny zero-dependency count-up animation hook for the
* score-ring / score-number "sweep to fill on first paint" affordance
* the premium-health direction calls for. No motion library — ~30 LOC of
* `requestAnimationFrame` instead, per the 0 KB runtime budget.
*
* Returns a number that eases from 0 to `target` once, on mount (and
* re-runs when `target` changes). Honours `prefers-reduced-motion`: a
* reduced-motion user (or any environment without rAF, e.g. SSR / the
* Vitest jsdom render) gets the final value immediately with no
* intermediate frames — the state is seeded to `target` so no
* synchronous-in-effect setState is needed for the no-animation path.
*/
export function useCountUp(
target: number,
options: { durationMs?: number; enabled?: boolean; startDelayMs?: number } = {},
): number {
const { durationMs = 600, enabled = true, startDelayMs = 0 } = options;
// Seed at the final value so the reduced-motion / SSR path is already
// correct without any setState; only the animated path drives frames,
// and those setState calls all happen inside the rAF callback (async).
const [value, setValue] = useState<number>(target);
const frameRef = useRef<number | null>(null);
useEffect(() => {
const prefersReduced =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (
!enabled ||
prefersReduced ||
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function" ||
!Number.isFinite(target)
) {
// The animated path is skipped; the rAF callback below never runs,
// so the displayed value stays at whatever the last frame / seed
// left it. Schedule a single async correction in case the previous
// target differed — done via rAF so it is not a sync-in-effect set.
if (
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
frameRef.current = window.requestAnimationFrame(() =>
setValue(target),
);
return () => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}
return;
}
let start = 0;
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(1, elapsed / durationMs);
// ease-out cubic — matches the "never bouncy, ease-out ~300–500ms"
// motion note in the design direction.
const eased = 1 - Math.pow(1 - progress, 3);
setValue(target * eased);
if (progress < 1) {
frameRef.current = window.requestAnimationFrame(tick);
} else {
setValue(target);
}
};
// Hold the number at 0 during the stagger delay so it matches the empty
// ring, then start the rAF loop. Every setState runs inside a rAF/timeout
// callback (async) — never synchronously in the effect body — so there is
// no cascading render. `startDelayMs` lets the strip stagger each tile's
// count-up so the number trails its arc, left-to-right.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const begin = () => {
frameRef.current = window.requestAnimationFrame((now) => {
start = now;
tick(now);
});
};
const arm = window.requestAnimationFrame(() => {
setValue(0);
if (startDelayMs > 0) {
timeoutId = setTimeout(begin, startDelayMs);
} else {
begin();
}
});
return () => {
window.cancelAnimationFrame(arm);
if (timeoutId != null) clearTimeout(timeoutId);
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [target, durationMs, enabled, startDelayMs]);
return value;
}