From e0dd831b67c666ca3a9825595e6843e8a9fcaba9 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 20:02:35 +0500 Subject: [PATCH] feat(ux): animated count-up KPIs + staggered dashboard entrance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the "top-tier motion" pass. - `useCountUp` (RAF, easeOutCubic) + `AnimatedNumber` — dashboard KPI headlines now count up to their value instead of snapping. RAF rather than reanimated so a few cheap number re-renders behave identically on web and native with no worklet/New-Arch caveats; integer targets round per-frame so counts stay clean. - Dashboard widget rows now ease in with a gentle staggered `FadeInDown` (reanimated) — confirmed rendering on web. tsc + lint clean, full suite 1337 passing. Verified in the browser: the Task Overview KPIs count up (8 / 0 / 0 / 13%) and the grid staggers in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../renderers/DashboardViewRenderer.tsx | 36 ++++++++++----- components/ui/AnimatedNumber.tsx | 32 +++++++++++++ hooks/useCountUp.ts | 45 +++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 components/ui/AnimatedNumber.tsx create mode 100644 hooks/useCountUp.ts diff --git a/components/renderers/DashboardViewRenderer.tsx b/components/renderers/DashboardViewRenderer.tsx index 30d18b7..d826b26 100644 --- a/components/renderers/DashboardViewRenderer.tsx +++ b/components/renderers/DashboardViewRenderer.tsx @@ -14,6 +14,8 @@ import { WidgetChart } from "./charts/WidgetChart"; import { useTranslation } from "react-i18next"; import { formatByPattern, formatCurrency, formatNumber } from "~/lib/formatting"; import { useThemeColors } from "~/lib/theme-colors"; +import { AnimatedNumber } from "~/components/ui/AnimatedNumber"; +import Animated, { FadeInDown } from "react-native-reanimated"; import type { DashboardMeta, DashboardWidgetMeta } from "./types"; /** Skeleton grid shown while dashboard metadata + widget data load. */ @@ -96,6 +98,7 @@ function MetricWidget({ const value = data?.value ?? "—"; const trend = data?.trend; const isPositive = trend?.startsWith("+"); + const isNumeric = typeof value === "number" && isFinite(value); // Compact tile (p-4, not p-5) — these pack two-up on phones, so the title // and headline must stay tight. Title is given a two-line floor so a @@ -117,13 +120,25 @@ function MetricWidget({ ) : ( <> - - {formatMetricValue(widget, value === "—" ? undefined : value)} - + {isNumeric ? ( + + formatMetricValue(widget, Number.isInteger(value) ? Math.round(n) : n) + } + className="mt-1 text-2xl font-bold text-card-foreground" + numberOfLines={1} + adjustsFontSizeToFit + /> + ) : ( + + {formatMetricValue(widget, value === "—" ? undefined : value)} + + )} {trend && ( )} - {/* Widget grid */} + {/* Widget grid — rows ease in with a gentle downward stagger. */} {rows.map((row, rowIdx) => ( - ); })} - + ))} ); diff --git a/components/ui/AnimatedNumber.tsx b/components/ui/AnimatedNumber.tsx new file mode 100644 index 0000000..268ef08 --- /dev/null +++ b/components/ui/AnimatedNumber.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Text, type TextProps } from "react-native"; +import { useCountUp } from "~/hooks/useCountUp"; + +export interface AnimatedNumberProps extends TextProps { + /** The target numeric value to count up/down to. */ + value: number; + /** Format the (animating, fractional) value into the display string. */ + format: (n: number) => string; + /** Count-up duration in ms. */ + durationMs?: number; + className?: string; +} + +/** + * A `` whose numeric value animates (counts up) to `value` and formats + * each interpolated frame via `format`. Used for dashboard KPI headlines. + */ +export function AnimatedNumber({ + value, + format, + durationMs, + className, + ...rest +}: AnimatedNumberProps) { + const animated = useCountUp(value, durationMs); + return ( + + {format(animated)} + + ); +} diff --git a/hooks/useCountUp.ts b/hooks/useCountUp.ts new file mode 100644 index 0000000..9e06e86 --- /dev/null +++ b/hooks/useCountUp.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Animate a number from its previous value to `target` over `durationMs`, + * easing out. Drives KPI/metric headlines so they count up the way top-tier + * dashboards do instead of snapping. + * + * RAF-based (not reanimated) on purpose: a handful of KPI tiles re-rendering a + * cheap number for ~half a second is trivial, and it behaves identically on web + * and native without worklet/New-Architecture caveats. + */ +export function useCountUp(target: number, durationMs = 650): number { + const [value, setValue] = useState(target); + // Latest rendered value — the start point when `target` changes mid-flight. + const valueRef = useRef(target); + valueRef.current = value; + const rafRef = useRef(null); + + useEffect(() => { + const from = valueRef.current; + if (!isFinite(target) || from === target) { + setValue(target); + return; + } + + const start = Date.now(); + const tick = () => { + const t = Math.min(1, (Date.now() - start) / durationMs); + const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic + setValue(from + (target - from) * eased); + if (t < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setValue(target); + } + }; + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + }; + }, [target, durationMs]); + + return value; +}