diff --git a/apps/native/app/(tabs)/__tests__/metrics.test.tsx b/apps/native/app/(tabs)/__tests__/metrics.test.tsx
new file mode 100644
index 0000000..f080cf3
--- /dev/null
+++ b/apps/native/app/(tabs)/__tests__/metrics.test.tsx
@@ -0,0 +1,159 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import React from "react";
+
+// Mock expo-router
+const mockPush = jest.fn();
+jest.mock("expo-router", () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+// Mock react-native-safe-area-context
+jest.mock("react-native-safe-area-context", () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
+
+// Mock reanimated
+jest.mock("react-native-reanimated", () => {
+ const RN = require("react-native");
+ return {
+ __esModule: true,
+ default: {
+ createAnimatedComponent: (c: unknown) => c,
+ View: RN.View,
+ Text: RN.Text,
+ },
+ Easing: { in: jest.fn(), out: jest.fn(), inOut: jest.fn(), bezier: jest.fn() },
+ useSharedValue: jest.fn((init: number) => ({ value: init })),
+ useAnimatedStyle: jest.fn(() => ({})),
+ withTiming: jest.fn((val: number) => val),
+ withRepeat: jest.fn((val: number) => val),
+ withSequence: jest.fn((...vals: number[]) => vals[0]),
+ createAnimatedComponent: (c: unknown) => c,
+ runOnJS: jest.fn((fn: unknown) => fn),
+ useAnimatedReaction: jest.fn(),
+ useDerivedValue: jest.fn((fn: () => unknown) => ({ value: fn() })),
+ useAnimatedScrollHandler: jest.fn(),
+ interpolate: jest.fn(),
+ };
+});
+
+// Mock heroui-native
+jest.mock("heroui-native", () => ({
+ cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
+}));
+
+// Mock trpc
+const mockListDefinitions = jest.fn();
+const mockListEntries = jest.fn();
+jest.mock("@/utils/trpc", () => ({
+ trpc: {
+ metrics: {
+ listDefinitions: {
+ queryOptions: () => ({
+ queryKey: ["metrics", "listDefinitions"],
+ queryFn: mockListDefinitions,
+ }),
+ },
+ listEntries: {
+ queryOptions: (input: unknown) => ({
+ queryKey: ["metrics", "listEntries", input],
+ queryFn: () => mockListEntries(input),
+ }),
+ },
+ },
+ },
+ queryClient: new (require("@tanstack/react-query").QueryClient)(),
+}));
+
+import MetricsScreen from "../metrics";
+
+function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+}
+
+function renderWithProviders(queryClient: QueryClient) {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe("MetricsScreen", () => {
+ it("shows empty state when no definitions exist", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([]);
+ renderWithProviders(qc);
+
+ expect(await screen.findByText("No Metrics Yet")).toBeTruthy();
+ });
+
+ it("renders metric cards with name, latest value, and unit", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([
+ { id: "def-1", name: "Weight", unit: "kg" },
+ { id: "def-2", name: "Body Fat", unit: "%" },
+ ]);
+ mockListEntries.mockImplementation((input: { metricDefinitionId: string }) => {
+ if (input.metricDefinitionId === "def-1") {
+ return Promise.resolve([
+ { id: "e1", value: 80, date: "2026-02-28" },
+ { id: "e2", value: 79.5, date: "2026-02-27" },
+ ]);
+ }
+ return Promise.resolve([{ id: "e3", value: 15.2, date: "2026-02-28" }]);
+ });
+ renderWithProviders(qc);
+
+ expect(await screen.findByText("Weight")).toBeTruthy();
+ expect(await screen.findByText("80")).toBeTruthy();
+ expect(screen.getByText("kg")).toBeTruthy();
+ expect(screen.getByText("Body Fat")).toBeTruthy();
+ expect(screen.getByText("15.2")).toBeTruthy();
+ expect(screen.getByText("%")).toBeTruthy();
+ });
+
+ it("shows trend arrows based on entry data", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([
+ { id: "def-1", name: "Weight", unit: "kg" },
+ { id: "def-2", name: "Body Fat", unit: "%" },
+ ]);
+ mockListEntries.mockImplementation((input: { metricDefinitionId: string }) => {
+ if (input.metricDefinitionId === "def-1") {
+ // 80 > 79.5 → up
+ return Promise.resolve([
+ { id: "e1", value: 80, date: "2026-02-28" },
+ { id: "e2", value: 79.5, date: "2026-02-27" },
+ ]);
+ }
+ // only 1 entry → flat
+ return Promise.resolve([{ id: "e3", value: 15.2, date: "2026-02-28" }]);
+ });
+ renderWithProviders(qc);
+
+ const weightTrend = await screen.findByTestId("trend-def-1");
+ expect(weightTrend.props.children).toBe("↑");
+
+ const bfTrend = screen.getByTestId("trend-def-2");
+ expect(bfTrend.props.children).toBe("→");
+ });
+
+ it("navigates to detail screen when metric card is pressed", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([{ id: "def-1", name: "Weight", unit: "kg" }]);
+ mockListEntries.mockResolvedValue([]);
+ renderWithProviders(qc);
+
+ const card = await screen.findByTestId("metric-card-def-1");
+ fireEvent.press(card);
+ expect(mockPush).toHaveBeenCalledWith("/metric/def-1");
+ });
+});
diff --git a/apps/native/app/(tabs)/_layout.tsx b/apps/native/app/(tabs)/_layout.tsx
index faf97fb..90aa183 100644
--- a/apps/native/app/(tabs)/_layout.tsx
+++ b/apps/native/app/(tabs)/_layout.tsx
@@ -9,6 +9,7 @@ export default function TabLayout() {
+
);
diff --git a/apps/native/app/(tabs)/metrics.tsx b/apps/native/app/(tabs)/metrics.tsx
new file mode 100644
index 0000000..34d17e5
--- /dev/null
+++ b/apps/native/app/(tabs)/metrics.tsx
@@ -0,0 +1,164 @@
+import { useQueries, useQuery } from "@tanstack/react-query";
+import { useRouter } from "expo-router";
+import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { Colors, RADIUS_CARD } from "@/theme";
+import { computeTrend, type Trend } from "@/utils/metrics";
+import { trpc } from "@/utils/trpc";
+
+export default function MetricsScreen() {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+
+ const definitionsQuery = useQuery(trpc.metrics.listDefinitions.queryOptions());
+ const definitions = definitionsQuery.data ?? [];
+
+ const entriesQueries = useQueries({
+ queries: definitions.map((def) =>
+ trpc.metrics.listEntries.queryOptions({ metricDefinitionId: def.id }),
+ ),
+ });
+
+ if (definitionsQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (definitions.length === 0) {
+ return (
+
+ 📊
+ No Metrics Yet
+ Track body metrics like weight, body fat, and more
+
+ );
+ }
+
+ return (
+
+ item.id}
+ contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
+ ListHeaderComponent={Metrics}
+ renderItem={({ item, index }) => {
+ const entries = entriesQueries[index]?.data ?? [];
+ const latest = entries[0];
+ const trend = computeTrend(entries);
+
+ return (
+ router.push(`/metric/${item.id}`)}
+ style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
+ >
+
+ {item.name}
+
+ {trendArrow(trend)}
+
+
+
+ {latest ? String(latest.value) : "—"}
+ {item.unit}
+
+
+ );
+ }}
+ />
+
+ );
+}
+
+function trendArrow(trend: Trend): string {
+ if (trend === "up") return "↑";
+ if (trend === "down") return "↓";
+ return "→";
+}
+
+function trendStyle(trend: Trend) {
+ if (trend === "up") return { color: Colors.green };
+ if (trend === "down") return { color: Colors.red };
+ return { color: Colors.text3 };
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.bg,
+ },
+ content: {
+ paddingTop: 16,
+ paddingHorizontal: 20,
+ gap: 12,
+ },
+ header: {
+ color: Colors.text1,
+ fontFamily: "BebasNeue_400Regular",
+ fontSize: 28,
+ paddingBottom: 12,
+ },
+ emptyContainer: {
+ flex: 1,
+ backgroundColor: Colors.bg,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyEmoji: {
+ fontSize: 48,
+ marginBottom: 12,
+ },
+ emptyTitle: {
+ color: Colors.text1,
+ fontFamily: "BebasNeue_400Regular",
+ fontSize: 24,
+ },
+ emptySubtitle: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 14,
+ marginTop: 4,
+ },
+ card: {
+ backgroundColor: Colors.surface1,
+ borderRadius: RADIUS_CARD,
+ padding: 16,
+ },
+ cardPressed: {
+ opacity: 0.7,
+ },
+ cardHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ },
+ metricName: {
+ color: Colors.text2,
+ fontFamily: "DMSans_500Medium",
+ fontSize: 14,
+ },
+ trendArrow: {
+ fontSize: 18,
+ fontFamily: "DMSans_600SemiBold",
+ },
+ cardValue: {
+ flexDirection: "row",
+ alignItems: "baseline",
+ gap: 6,
+ marginTop: 8,
+ },
+ valueText: {
+ color: Colors.text1,
+ fontFamily: "DMSans_600SemiBold",
+ fontSize: 28,
+ },
+ unitText: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 16,
+ },
+});
diff --git a/apps/native/app/_layout.tsx b/apps/native/app/_layout.tsx
index 34cb31f..7a7328c 100644
--- a/apps/native/app/_layout.tsx
+++ b/apps/native/app/_layout.tsx
@@ -56,6 +56,7 @@ function StackLayout() {
+
);
}
diff --git a/apps/native/app/metric/[id].tsx b/apps/native/app/metric/[id].tsx
new file mode 100644
index 0000000..0314767
--- /dev/null
+++ b/apps/native/app/metric/[id].tsx
@@ -0,0 +1,271 @@
+import { useQuery } from "@tanstack/react-query";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { useMemo, useState } from "react";
+import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { Colors, RADIUS_CARD, TAP_MIN } from "@/theme";
+import { getStartDateForRange, type TimeRange } from "@/utils/metrics";
+import { trpc } from "@/utils/trpc";
+
+const RANGES: TimeRange[] = ["1W", "1M", "3M", "6M", "1Y"];
+
+export default function MetricDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [range, setRange] = useState("3M");
+
+ const definitionsQuery = useQuery(trpc.metrics.listDefinitions.queryOptions());
+ const definition = useMemo(
+ () => (definitionsQuery.data ?? []).find((d) => d.id === id),
+ [definitionsQuery.data, id],
+ );
+
+ const startDate = getStartDateForRange(range);
+ const entriesQuery = useQuery(
+ trpc.metrics.listEntries.queryOptions({
+ metricDefinitionId: id!,
+ startDate,
+ }),
+ );
+ const entries = entriesQuery.data ?? [];
+
+ if (definitionsQuery.isLoading || entriesQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!definition) {
+ return (
+
+ router.back()}
+ style={styles.backBtn}
+ hitSlop={8}
+ >
+ ←
+
+ Metric not found
+
+ );
+ }
+
+ const maxValue = entries.length > 0 ? Math.max(...entries.map((e) => e.value)) : 1;
+
+ return (
+
+ router.back()} style={styles.backBtn} hitSlop={8}>
+ ←
+
+
+ {definition.name}
+ {definition.unit}
+
+ {/* Range pills */}
+
+ {RANGES.map((r) => (
+ setRange(r)}
+ style={[styles.rangePill, r === range && styles.rangePillActive]}
+ >
+
+ {r}
+
+
+ ))}
+
+
+ {/* Chart */}
+
+
+ {entries.length === 0 ? (
+ No entries in this range
+ ) : (
+ [...entries].reverse().map((entry) => (
+
+ {entry.value}
+
+ {formatShortDate(entry.date)}
+
+ ))
+ )}
+
+
+
+ {/* Entry history */}
+ History
+ {entries.map((entry) => (
+
+ {formatShortDate(entry.date)}
+ {String(entry.value)}
+
+ ))}
+
+ );
+}
+
+function formatShortDate(dateStr: string): string {
+ const [, month, day] = dateStr.split("-");
+ const months = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ ];
+ return `${Number(day)} ${months[Number(month) - 1]}`;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.bg,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ },
+ centered: {
+ flex: 1,
+ backgroundColor: Colors.bg,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ backBtn: {
+ width: TAP_MIN,
+ height: TAP_MIN,
+ justifyContent: "center",
+ },
+ backText: {
+ color: Colors.text1,
+ fontSize: 22,
+ },
+ notFoundText: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 16,
+ },
+ title: {
+ color: Colors.text1,
+ fontFamily: "BebasNeue_400Regular",
+ fontSize: 28,
+ },
+ subtitle: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 14,
+ marginTop: 2,
+ },
+ rangeRow: {
+ flexDirection: "row",
+ gap: 8,
+ marginTop: 16,
+ },
+ rangePill: {
+ backgroundColor: Colors.surface2,
+ borderRadius: 20,
+ paddingHorizontal: 14,
+ paddingVertical: 6,
+ minHeight: TAP_MIN,
+ justifyContent: "center",
+ },
+ rangePillActive: {
+ backgroundColor: Colors.accent,
+ },
+ rangePillText: {
+ color: Colors.text2,
+ fontFamily: "DMSans_500Medium",
+ fontSize: 13,
+ },
+ rangePillTextActive: {
+ color: Colors.bg,
+ },
+ chartCard: {
+ backgroundColor: Colors.surface1,
+ borderRadius: RADIUS_CARD,
+ padding: 16,
+ marginTop: 16,
+ },
+ chartBarArea: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ justifyContent: "space-around",
+ height: 140,
+ gap: 8,
+ },
+ chartBarCol: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "flex-end",
+ height: "100%",
+ },
+ chartBarValue: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 10,
+ marginBottom: 4,
+ },
+ chartBar: {
+ width: "80%",
+ backgroundColor: Colors.accent,
+ borderRadius: 4,
+ minHeight: 4,
+ },
+ chartBarLabel: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 10,
+ marginTop: 4,
+ },
+ chartEmptyText: {
+ color: Colors.text3,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 14,
+ textAlign: "center",
+ flex: 1,
+ textAlignVertical: "center",
+ },
+ historyHeader: {
+ color: Colors.text2,
+ fontFamily: "DMSans_600SemiBold",
+ fontSize: 16,
+ marginTop: 24,
+ marginBottom: 12,
+ },
+ historyRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ paddingVertical: 10,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.border,
+ },
+ historyDate: {
+ color: Colors.text2,
+ fontFamily: "DMSans_400Regular",
+ fontSize: 14,
+ },
+ historyValue: {
+ color: Colors.text1,
+ fontFamily: "DMSans_600SemiBold",
+ fontSize: 14,
+ },
+});
diff --git a/apps/native/app/metric/__tests__/[id].test.tsx b/apps/native/app/metric/__tests__/[id].test.tsx
new file mode 100644
index 0000000..def00f4
--- /dev/null
+++ b/apps/native/app/metric/__tests__/[id].test.tsx
@@ -0,0 +1,157 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import React from "react";
+
+// Mock expo-router
+const mockBack = jest.fn();
+jest.mock("expo-router", () => ({
+ useLocalSearchParams: () => ({ id: "def-1" }),
+ useRouter: () => ({ back: mockBack }),
+}));
+
+// Mock react-native-safe-area-context
+jest.mock("react-native-safe-area-context", () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
+
+// Mock reanimated
+jest.mock("react-native-reanimated", () => {
+ const RN = require("react-native");
+ return {
+ __esModule: true,
+ default: {
+ createAnimatedComponent: (c: unknown) => c,
+ View: RN.View,
+ Text: RN.Text,
+ },
+ Easing: { in: jest.fn(), out: jest.fn(), inOut: jest.fn(), bezier: jest.fn() },
+ useSharedValue: jest.fn((init: number) => ({ value: init })),
+ useAnimatedStyle: jest.fn(() => ({})),
+ withTiming: jest.fn((val: number) => val),
+ withRepeat: jest.fn((val: number) => val),
+ withSequence: jest.fn((...vals: number[]) => vals[0]),
+ createAnimatedComponent: (c: unknown) => c,
+ runOnJS: jest.fn((fn: unknown) => fn),
+ useAnimatedReaction: jest.fn(),
+ useDerivedValue: jest.fn((fn: () => unknown) => ({ value: fn() })),
+ useAnimatedScrollHandler: jest.fn(),
+ interpolate: jest.fn(),
+ };
+});
+
+// Mock heroui-native
+jest.mock("heroui-native", () => ({
+ cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
+}));
+
+// Mock trpc
+const mockListDefinitions = jest.fn();
+const mockListEntries = jest.fn();
+jest.mock("@/utils/trpc", () => ({
+ trpc: {
+ metrics: {
+ listDefinitions: {
+ queryOptions: () => ({
+ queryKey: ["metrics", "listDefinitions"],
+ queryFn: mockListDefinitions,
+ }),
+ },
+ listEntries: {
+ queryOptions: (input: unknown) => ({
+ queryKey: ["metrics", "listEntries", input],
+ queryFn: () => mockListEntries(input),
+ }),
+ },
+ },
+ },
+ queryClient: new (require("@tanstack/react-query").QueryClient)(),
+}));
+
+import MetricDetailScreen from "../[id]";
+
+function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+}
+
+function renderWithProviders(queryClient: QueryClient) {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe("MetricDetailScreen", () => {
+ it("renders metric name and chart container", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([{ id: "def-1", name: "Weight", unit: "kg" }]);
+ mockListEntries.mockResolvedValue([
+ { id: "e1", value: 80, date: "2026-02-28" },
+ { id: "e2", value: 79.5, date: "2026-02-27" },
+ ]);
+ renderWithProviders(qc);
+
+ expect(await screen.findByText("Weight")).toBeTruthy();
+ expect(screen.getByTestId("metric-chart")).toBeTruthy();
+ });
+
+ it("renders entry history with dates and values", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([{ id: "def-1", name: "Weight", unit: "kg" }]);
+ mockListEntries.mockResolvedValue([
+ { id: "e1", value: 80, date: "2026-02-28" },
+ { id: "e2", value: 79.5, date: "2026-02-27" },
+ ]);
+ renderWithProviders(qc);
+
+ // Wait for full render
+ expect(await screen.findByText("Weight")).toBeTruthy();
+ expect((await screen.findAllByText("28 Feb")).length).toBeGreaterThan(0);
+ expect(screen.getAllByText("27 Feb").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("80").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("79.5").length).toBeGreaterThan(0);
+ });
+
+ it("navigates back when back button is pressed", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([{ id: "def-1", name: "Weight", unit: "kg" }]);
+ mockListEntries.mockResolvedValue([]);
+ renderWithProviders(qc);
+
+ const backBtn = await screen.findByTestId("back-btn");
+ fireEvent.press(backBtn);
+ expect(mockBack).toHaveBeenCalled();
+ });
+
+ it("renders range pills and filters entries on press", async () => {
+ const qc = createQueryClient();
+ mockListDefinitions.mockResolvedValue([{ id: "def-1", name: "Weight", unit: "kg" }]);
+ mockListEntries.mockResolvedValue([]);
+ renderWithProviders(qc);
+
+ // All range pills rendered
+ expect(await screen.findByTestId("range-1W")).toBeTruthy();
+ expect(screen.getByTestId("range-1M")).toBeTruthy();
+ expect(screen.getByTestId("range-3M")).toBeTruthy();
+ expect(screen.getByTestId("range-6M")).toBeTruthy();
+ expect(screen.getByTestId("range-1Y")).toBeTruthy();
+
+ // Press 1M range — entries query called with startDate param
+ mockListEntries.mockClear();
+ fireEvent.press(screen.getByTestId("range-1M"));
+
+ // The query should now include a startDate parameter
+ expect(mockListEntries).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metricDefinitionId: "def-1",
+ startDate: expect.any(String),
+ }),
+ );
+ });
+});
diff --git a/apps/native/utils/__tests__/metrics.test.ts b/apps/native/utils/__tests__/metrics.test.ts
new file mode 100644
index 0000000..8ed4b9f
--- /dev/null
+++ b/apps/native/utils/__tests__/metrics.test.ts
@@ -0,0 +1,54 @@
+import { computeTrend, getStartDateForRange } from "../metrics";
+
+describe("computeTrend", () => {
+ it("returns 'up' when latest value is greater than previous", () => {
+ expect(computeTrend([{ value: 80 }, { value: 75 }])).toBe("up");
+ });
+
+ it("returns 'down' when latest value is less than previous", () => {
+ expect(computeTrend([{ value: 70 }, { value: 75 }])).toBe("down");
+ });
+
+ it("returns 'flat' when latest equals previous", () => {
+ expect(computeTrend([{ value: 75 }, { value: 75 }])).toBe("flat");
+ });
+
+ it("returns 'flat' with only one entry", () => {
+ expect(computeTrend([{ value: 75 }])).toBe("flat");
+ });
+
+ it("returns 'flat' with empty array", () => {
+ expect(computeTrend([])).toBe("flat");
+ });
+});
+
+describe("getStartDateForRange", () => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2026-03-01T12:00:00Z"));
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ it("returns 7 days ago for 1W", () => {
+ expect(getStartDateForRange("1W")).toBe("2026-02-22");
+ });
+
+ it("returns 1 month ago for 1M", () => {
+ expect(getStartDateForRange("1M")).toBe("2026-02-01");
+ });
+
+ it("returns 3 months ago for 3M", () => {
+ expect(getStartDateForRange("3M")).toBe("2025-12-01");
+ });
+
+ it("returns 6 months ago for 6M", () => {
+ expect(getStartDateForRange("6M")).toBe("2025-09-01");
+ });
+
+ it("returns 1 year ago for 1Y", () => {
+ expect(getStartDateForRange("1Y")).toBe("2025-03-01");
+ });
+});
diff --git a/apps/native/utils/metrics.ts b/apps/native/utils/metrics.ts
new file mode 100644
index 0000000..ddd12c9
--- /dev/null
+++ b/apps/native/utils/metrics.ts
@@ -0,0 +1,34 @@
+export type Trend = "up" | "down" | "flat";
+
+export function computeTrend(entries: { value: number }[]): Trend {
+ if (entries.length < 2) return "flat";
+ const latest = entries[0].value;
+ const prev = entries[1].value;
+ if (latest > prev) return "up";
+ if (latest < prev) return "down";
+ return "flat";
+}
+
+export type TimeRange = "1W" | "1M" | "3M" | "6M" | "1Y";
+
+export function getStartDateForRange(range: TimeRange): string {
+ const now = new Date();
+ switch (range) {
+ case "1W":
+ now.setDate(now.getDate() - 7);
+ break;
+ case "1M":
+ now.setMonth(now.getMonth() - 1);
+ break;
+ case "3M":
+ now.setMonth(now.getMonth() - 3);
+ break;
+ case "6M":
+ now.setMonth(now.getMonth() - 6);
+ break;
+ case "1Y":
+ now.setFullYear(now.getFullYear() - 1);
+ break;
+ }
+ return now.toISOString().slice(0, 10);
+}