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
24 changes: 24 additions & 0 deletions packages/mobile/components/HealthStatusCards.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-native";
import { HealthStatusCards } from "./HealthStatusCards";

const readyBaselineProgress = {
requiredObservationDays: 3,
observedObservationDays: 3,
hasMeasurableVariation: true,
blocker: null,
requirement: "A current value plus at least 2 more recorded days with measurable variation.",
summary: "The baseline is ready.",
action: "No action needed.",
} as const;

const collectingBaselineProgress = {
requiredObservationDays: 3,
observedObservationDays: 1,
hasMeasurableVariation: false,
blocker: "collecting" as const,
requirement: "A current value plus at least 2 more recorded days with measurable variation.",
summary: "The baseline is still collecting observations.",
action: "Keep syncing data for at least 2 more days.",
};

const meta = {
title: "Components/HealthStatusCards",
component: HealthStatusCards,
Expand All @@ -22,6 +42,7 @@ const meta = {
statusLabel: "Moving as intended",
evaluationRule: "Below your baseline, where lower values support this metric",
explanation: "Trend Weight is below your baseline, in line with your weight goal.",
baselineProgress: readyBaselineProgress,
},
{
metric: "body_fat_percentage",
Expand All @@ -41,6 +62,7 @@ const meta = {
"Outside your usual range: 1 to less than 2 standard deviations from baseline",
explanation:
"Body Fat % is above your usual range enough to stand out from recent variation.",
baselineProgress: readyBaselineProgress,
},
],
},
Expand Down Expand Up @@ -71,6 +93,7 @@ export const InsufficientData: Story = {
statusLabel: "Not enough data",
evaluationRule: "Needs a current value, baseline, and measurable day-to-day variation",
explanation: "Not enough varied data yet to compare this value with your usual range.",
baselineProgress: collectingBaselineProgress,
},
],
},
Expand All @@ -97,6 +120,7 @@ export const FarFromBaseline: Story = {
"Well outside your usual range: at least 2 standard deviations from baseline",
explanation:
"Resting Heart Rate is well above your usual range compared with recent variation.",
baselineProgress: readyBaselineProgress,
},
],
},
Expand Down
66 changes: 66 additions & 0 deletions packages/mobile/components/HealthStatusCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { HealthStatusCards } from "./HealthStatusCards";

const readyBaselineProgress = {
requiredObservationDays: 3,
observedObservationDays: 3,
hasMeasurableVariation: true,
blocker: null,
requirement: "A current value plus at least 2 more recorded days with measurable variation.",
summary: "The baseline is ready.",
action: "No action needed.",
} as const;

describe("HealthStatusCards", () => {
it("renders server-authored HRV and steps text instead of recomputing raw values", () => {
render(
Expand All @@ -25,6 +35,7 @@ describe("HealthStatusCards", () => {
statusLabel: "Near baseline",
evaluationRule: "Server-selected rule.",
explanation: "Server-selected explanation.",
baselineProgress: readyBaselineProgress,
},
{
metric: "steps",
Expand All @@ -42,6 +53,7 @@ describe("HealthStatusCards", () => {
statusLabel: "Near baseline",
evaluationRule: "Server-selected rule.",
explanation: "Server-selected explanation.",
baselineProgress: readyBaselineProgress,
},
]}
formatValue={() => "client-recomputed value"}
Expand Down Expand Up @@ -75,6 +87,7 @@ describe("HealthStatusCards", () => {
statusLabel: "Moving as intended",
evaluationRule: "Below your baseline, where lower values support this metric",
explanation: "Trend Weight is below your baseline, in line with your weight goal.",
baselineProgress: readyBaselineProgress,
},
]}
formatValue={() => "176.4 lb"}
Expand All @@ -93,6 +106,57 @@ describe("HealthStatusCards", () => {
expect(screen.getByLabelText("Moving as intended status").textContent).toBe("✓");
});

it("renders server-authored baseline requirements, progress, and action", () => {
render(
<HealthStatusCards
metrics={[
{
metric: "resting_heart_rate",
label: "Resting Heart Rate",
value: 56,
baseline: 56,
sampleDeviation: null,
deviation: null,
direction: "unknown",
intent: "lower",
statusToken: "insufficient_data",
statusColor: "muted",
statusLabel: "Waiting for baseline",
evaluationRule: "Server-selected rule.",
explanation: "Server-selected explanation.",
baselineProgress: {
requiredObservationDays: 3,
observedObservationDays: 1,
hasMeasurableVariation: false,
blocker: "collecting",
requirement:
"A current value plus at least 2 more recorded days with measurable variation.",
summary:
"Resting Heart Rate has 1 of 3 required days recorded; the baseline is still collecting observations.",
action: "Keep syncing resting heart rate data for at least 2 more days.",
},
},
]}
/>,
);

expect(screen.getByText("Waiting for baseline")).toBeTruthy();
expect(
screen.getByText(
"A current value plus at least 2 more recorded days with measurable variation.",
),
).toBeTruthy();
expect(screen.getByText("1 of 3 required days recorded")).toBeTruthy();
expect(
screen.getByText(
"Resting Heart Rate has 1 of 3 required days recorded; the baseline is still collecting observations.",
),
).toBeTruthy();
expect(
screen.getByText("Keep syncing resting heart rate data for at least 2 more days."),
).toBeTruthy();
});

it("does not reinterpret a server status from the numeric fields", () => {
render(
<HealthStatusCards
Expand All @@ -113,6 +177,7 @@ describe("HealthStatusCards", () => {
statusLabel: "Server-selected label",
evaluationRule: "Server-selected rule.",
explanation: "Server-selected explanation.",
baselineProgress: readyBaselineProgress,
},
]}
/>,
Expand Down Expand Up @@ -154,6 +219,7 @@ describe("HealthStatusCards", () => {
statusLabel,
evaluationRule: "Server-selected rule.",
explanation: "Server-selected explanation.",
baselineProgress: readyBaselineProgress,
},
]}
/>,
Expand Down
37 changes: 37 additions & 0 deletions packages/mobile/components/HealthStatusCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ export function HealthStatusCards({ metrics, formatValue }: HealthStatusCardsPro
</Text>
<Text style={styles.rule}>{metric.evaluationRule}</Text>
<Text style={styles.explanation}>{metric.explanation}</Text>
{metric.baselineProgress.blocker !== null ? (
<View
accessibilityLabel={`${metric.label} baseline progress`}
style={styles.progress}
>
<Text style={styles.progressRequirement}>
{metric.baselineProgress.requirement}
</Text>
<Text style={styles.progressCount}>
{metric.baselineProgress.observedObservationDays} of{" "}
{metric.baselineProgress.requiredObservationDays} required days recorded
</Text>
<Text style={styles.explanation}>{metric.baselineProgress.summary}</Text>
<Text style={styles.action}>{metric.baselineProgress.action}</Text>
</View>
) : null}
</View>
);
})}
Expand Down Expand Up @@ -138,4 +154,25 @@ const styles = StyleSheet.create({
fontSize: 12,
lineHeight: 17,
},
progress: {
gap: spacing.xs,
marginTop: spacing.xs,
},
progressRequirement: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: "600",
lineHeight: 17,
},
progressCount: {
color: colors.textSecondary,
fontSize: 12,
lineHeight: 17,
},
action: {
color: colors.text,
fontSize: 12,
fontWeight: "600",
lineHeight: 17,
},
});
22 changes: 22 additions & 0 deletions packages/server/src/contracts/mobile-dashboard-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ export const healthMetricKeySchema = z.enum([

export const healthMetricIntentSchema = z.enum(["higher", "lower", "maintain", "neutral"]);

export const baselineProgressBlockerSchema = z.enum([
"missing_source_data",
"collecting",
"needs_variation",
"syncing",
"sync_error",
]);

export const baselineProgressSchema = z.object({
requiredObservationDays: z.number().int().positive(),
observedObservationDays: z.number().int().nonnegative(),
hasMeasurableVariation: z.boolean(),
blocker: baselineProgressBlockerSchema.nullable(),
requirement: z.string(),
summary: z.string(),
action: z.string(),
});

export type BaselineProgress = z.infer<typeof baselineProgressSchema>;
export type BaselineProgressBlocker = z.infer<typeof baselineProgressBlockerSchema>;

export const healthStatusMetricSchema = z.object({
metric: healthMetricKeySchema,
label: z.string(),
Expand All @@ -141,6 +162,7 @@ export const healthStatusMetricSchema = z.object({
statusLabel: z.string(),
evaluationRule: z.string(),
explanation: z.string(),
baselineProgress: baselineProgressSchema,
});

export type HealthMetricKey = z.infer<typeof healthMetricKeySchema>;
Expand Down
13 changes: 13 additions & 0 deletions packages/server/src/repositories/daily-metrics-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ function makeTrendsRow(overrides: Record<string, unknown> = {}): Record<string,
latest_spo2: "98",
latest_steps: "9200",
latest_skin_temp: "33.2",
sample_count_hrv: "4",
sample_count_resting_hr: "3",
sample_count_spo2: "4",
sample_count_steps: "4",
sample_count_skin_temp: "4",
latest_date: "2025-03-15",
latest_steps_date: "2025-03-15",
...overrides,
Expand All @@ -84,6 +89,11 @@ function makeAllNullTrendsRow(): Record<string, unknown> {
latest_spo2: null,
latest_steps: null,
latest_skin_temp: null,
sample_count_hrv: 0,
sample_count_resting_hr: 0,
sample_count_spo2: 0,
sample_count_steps: 0,
sample_count_skin_temp: 0,
latest_date: null,
latest_steps_date: null,
};
Expand Down Expand Up @@ -257,10 +267,13 @@ describe("DailyMetricsRepository", () => {
expect(result?.latest_hrv).toBe(48);
expect(result?.latest_resting_hr).toBe(55);
expect(result?.stddev_steps).toBe(1200);
expect(result?.sample_count_hrv).toBe(4);
expect(result?.sample_count_resting_hr).toBe(3);
expect(result?.latest_date).toBe("2025-03-15");
expect(execute).toHaveBeenCalledTimes(1);
const compiledQuery = new PgDialect().sqlToQuery(execute.mock.calls[0]?.[0]);
expect(compiledQuery.sql).toContain("STDDEV(steps) AS stddev_steps");
expect(compiledQuery.sql).toContain("COUNT(hrv) AS sample_count_hrv");
});

it("joins resting heart rate values into the trends query", async () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/server/src/repositories/daily-metrics-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export const trendsRowSchema = z.object({
latest_spo2: z.coerce.number().nullable(),
latest_steps: z.coerce.number().nullable(),
latest_skin_temp: z.coerce.number().nullable(),
sample_count_hrv: z.coerce.number().int().nonnegative(),
sample_count_resting_hr: z.coerce.number().int().nonnegative(),
sample_count_spo2: z.coerce.number().int().nonnegative(),
sample_count_steps: z.coerce.number().int().nonnegative(),
sample_count_skin_temp: z.coerce.number().int().nonnegative(),
latest_date: dateStringSchema.nullable(),
latest_steps_date: dateStringSchema.nullable(),
});
Expand Down Expand Up @@ -243,7 +248,12 @@ export class DailyMetricsRepository extends BaseRepository {
STDDEV(resting_hr) AS stddev_resting_hr,
STDDEV(spo2_avg) AS stddev_spo2,
STDDEV(steps) AS stddev_steps,
STDDEV(skin_temp_c) AS stddev_skin_temp
STDDEV(skin_temp_c) AS stddev_skin_temp,
COUNT(hrv) AS sample_count_hrv,
COUNT(*) FILTER (WHERE resting_hr > 0) AS sample_count_resting_hr,
COUNT(spo2_avg) AS sample_count_spo2,
COUNT(steps) AS sample_count_steps,
COUNT(skin_temp_c) AS sample_count_skin_temp
Comment thread
coderabbitai[bot] marked this conversation as resolved.
FROM current
),
representative_resting_heart_rate AS (
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routers/body-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const bodyAnalyticsRouter = router({
row.bodyFatPct == null ? [] : [row.bodyFatPct],
),
intent: "neutral",
processingStatus: null,
}),
],
};
Expand Down
Loading
Loading