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;
+}