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
159 changes: 159 additions & 0 deletions apps/native/app/(tabs)/__tests__/metrics.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<MetricsScreen />
</QueryClientProvider>,
);
}

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");
});
});
1 change: 1 addition & 0 deletions apps/native/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function TabLayout() {
<Tabs.Screen name="workouts" options={{ title: "Workouts" }} />
<Tabs.Screen name="history" options={{ title: "History" }} />
<Tabs.Screen name="progress" options={{ title: "Progress" }} />
<Tabs.Screen name="metrics" options={{ title: "Metrics" }} />
<Tabs.Screen name="settings" options={{ title: "Settings" }} />
</Tabs>
);
Expand Down
164 changes: 164 additions & 0 deletions apps/native/app/(tabs)/metrics.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[styles.emptyContainer, { paddingBottom: insets.bottom }]}>
<ActivityIndicator testID="metrics-loading" size="large" color={Colors.accent} />
</View>
);
}

if (definitions.length === 0) {
return (
<View style={[styles.emptyContainer, { paddingBottom: insets.bottom }]}>
<Text style={styles.emptyEmoji}>📊</Text>
<Text style={styles.emptyTitle}>No Metrics Yet</Text>
<Text style={styles.emptySubtitle}>Track body metrics like weight, body fat, and more</Text>
</View>
);
}

return (
<View style={styles.container}>
<FlatList
data={definitions}
keyExtractor={(item) => item.id}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 20 }]}
ListHeaderComponent={<Text style={styles.header}>Metrics</Text>}
renderItem={({ item, index }) => {
const entries = entriesQueries[index]?.data ?? [];
const latest = entries[0];
const trend = computeTrend(entries);

return (
<Pressable
testID={`metric-card-${item.id}`}
onPress={() => router.push(`/metric/${item.id}`)}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
>
<View style={styles.cardHeader}>
<Text style={styles.metricName}>{item.name}</Text>
<Text testID={`trend-${item.id}`} style={[styles.trendArrow, trendStyle(trend)]}>
{trendArrow(trend)}
</Text>
</View>
<View style={styles.cardValue}>
<Text style={styles.valueText}>{latest ? String(latest.value) : "—"}</Text>
<Text style={styles.unitText}>{item.unit}</Text>
</View>
</Pressable>
);
}}
/>
</View>
);
}

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,
},
});
1 change: 1 addition & 0 deletions apps/native/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function StackLayout() {
<Stack.Screen name="workout/[id]" />
<Stack.Screen name="log/[workoutId]" options={{ presentation: "fullScreenModal" }} />
<Stack.Screen name="session/[id]" />
<Stack.Screen name="metric/[id]" />
</Stack>
);
}
Expand Down
Loading
Loading