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