Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions components/renderers/DashboardViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -117,13 +120,25 @@ function MetricWidget({
<ActivityIndicator size="small" className="mt-3 self-start" />
) : (
<>
<Text
className="mt-1 text-2xl font-bold text-card-foreground"
numberOfLines={1}
adjustsFontSizeToFit
>
{formatMetricValue(widget, value === "—" ? undefined : value)}
</Text>
{isNumeric ? (
<AnimatedNumber
value={value}
format={(n) =>
formatMetricValue(widget, Number.isInteger(value) ? Math.round(n) : n)
}
className="mt-1 text-2xl font-bold text-card-foreground"
numberOfLines={1}
adjustsFontSizeToFit
/>
) : (
<Text
className="mt-1 text-2xl font-bold text-card-foreground"
numberOfLines={1}
adjustsFontSizeToFit
>
{formatMetricValue(widget, value === "—" ? undefined : value)}
</Text>
)}
{trend && (
<View className="mt-2 flex-row">
<View
Expand Down Expand Up @@ -417,10 +432,11 @@ export function DashboardViewRenderer({
</View>
)}

{/* Widget grid */}
{/* Widget grid — rows ease in with a gentle downward stagger. */}
{rows.map((row, rowIdx) => (
<View
<Animated.View
key={`row-${rowIdx}`}
entering={FadeInDown.delay(rowIdx * 70).duration(380)}
style={{
flexDirection: "row",
marginBottom: GRID_GAP,
Expand All @@ -440,7 +456,7 @@ export function DashboardViewRenderer({
</View>
);
})}
</View>
</Animated.View>
))}
</ScrollView>
);
Expand Down
32 changes: 32 additions & 0 deletions components/ui/AnimatedNumber.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Text>` 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 (
<Text className={className} {...rest}>
{format(animated)}
</Text>
);
}
45 changes: 45 additions & 0 deletions hooks/useCountUp.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(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;
}
Loading