From 2d23ce93beefcc5012d3d20caf49d36c600e208e Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sat, 1 Aug 2026 23:50:54 -0700 Subject: [PATCH 1/4] fix(dashboard): show baseline progress Refs #2107 --- .../components/HealthStatusCards.stories.tsx | 24 ++++ .../components/HealthStatusCards.test.tsx | 64 +++++++++ .../mobile/components/HealthStatusCards.tsx | 65 ++++----- .../contracts/mobile-dashboard-contracts.ts | 24 ++++ .../daily-metrics-repository.test.ts | 13 ++ .../repositories/daily-metrics-repository.ts | 12 +- packages/server/src/routers/body-analytics.ts | 1 + .../routers/daily-metrics.integration.test.ts | 12 ++ .../server/src/routers/daily-metrics.test.ts | 47 +++++++ packages/server/src/routers/daily-metrics.ts | 40 ++++-- .../src/routers/mobile-dashboard.test.ts | 12 ++ .../server/src/routers/mobile-dashboard.ts | 6 + .../src/services/baseline-progress.test.ts | 132 ++++++++++++++++++ .../server/src/services/baseline-progress.ts | 99 +++++++++++++ .../server/src/services/health-status.test.ts | 47 +++++++ packages/server/src/services/health-status.ts | 106 +++++++++++++- .../src/services/mobile-recovery-tab.test.ts | 5 +- .../src/services/mobile-recovery-tab.ts | 25 +++- .../DashboardEvidenceOverview.stories.tsx | 3 + .../DashboardEvidenceOverview.test.tsx | 64 ++++++++- .../components/DashboardEvidenceOverview.tsx | 37 +++-- .../components/HealthStatusBar.stories.tsx | 9 ++ .../src/components/HealthStatusBar.test.tsx | 9 ++ .../web/src/components/HealthStatusBar.tsx | 14 ++ packages/web/src/lib/healthStatus.test.ts | 20 +++ packages/web/src/lib/healthStatus.ts | 42 ++---- packages/web/src/pages/Dashboard.test.tsx | 13 ++ packages/web/src/pages/Dashboard.tsx | 6 + 28 files changed, 852 insertions(+), 99 deletions(-) create mode 100644 packages/server/src/services/baseline-progress.test.ts create mode 100644 packages/server/src/services/baseline-progress.ts diff --git a/packages/mobile/components/HealthStatusCards.stories.tsx b/packages/mobile/components/HealthStatusCards.stories.tsx index b67b39e8f5..23960f34e3 100644 --- a/packages/mobile/components/HealthStatusCards.stories.tsx +++ b/packages/mobile/components/HealthStatusCards.stories.tsx @@ -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, @@ -20,6 +40,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", @@ -37,6 +58,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, }, ], }, @@ -65,6 +87,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, }, ], }, @@ -89,6 +112,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, }, ], }, diff --git a/packages/mobile/components/HealthStatusCards.test.tsx b/packages/mobile/components/HealthStatusCards.test.tsx index ec712da68f..57bfba69f5 100644 --- a/packages/mobile/components/HealthStatusCards.test.tsx +++ b/packages/mobile/components/HealthStatusCards.test.tsx @@ -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 the canonical status and explanation returned by the server", () => { render( @@ -23,6 +33,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"} @@ -41,6 +52,57 @@ describe("HealthStatusCards", () => { expect(screen.getByLabelText("Moving as intended status").textContent).toBe("✓"); }); + it("renders server-authored baseline requirements, progress, and action", () => { + render( + , + ); + + 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( { statusLabel: "Server-selected label", evaluationRule: "Server-selected rule.", explanation: "Server-selected explanation.", + baselineProgress: readyBaselineProgress, }, ]} />, @@ -98,6 +161,7 @@ describe("HealthStatusCards", () => { statusLabel, evaluationRule: "Server-selected rule.", explanation: "Server-selected explanation.", + baselineProgress: readyBaselineProgress, }, ]} />, diff --git a/packages/mobile/components/HealthStatusCards.tsx b/packages/mobile/components/HealthStatusCards.tsx index e5c2f27005..c56de11162 100644 --- a/packages/mobile/components/HealthStatusCards.tsx +++ b/packages/mobile/components/HealthStatusCards.tsx @@ -1,38 +1,7 @@ +import type { HealthStatusMetric } from "dofek-server/mobile-dashboard-contracts"; import { StyleSheet, Text, View } from "react-native"; import { colors, radius, spacing } from "../theme"; -type HealthMetricKey = - | "hrv" - | "resting_heart_rate" - | "respiratory_rate" - | "sleep_efficiency" - | "spo2" - | "steps" - | "skin_temperature" - | "trend_weight" - | "body_fat_percentage"; - -interface HealthStatusMetric { - metric: HealthMetricKey; - label: string; - value: number | null; - baseline: number | null; - sampleDeviation: number | null; - deviation: number | null; - direction: "above" | "below" | "aligned" | "unknown"; - intent: "higher" | "lower" | "maintain" | "neutral"; - statusToken: - | "insufficient_data" - | "near_baseline" - | "moving_as_intended" - | "notable_deviation" - | "far_from_baseline"; - statusColor: "positive" | "warning" | "danger" | "muted"; - statusLabel: string; - evaluationRule: string; - explanation: string; -} - interface HealthStatusCardsProps { metrics: HealthStatusMetric[]; formatValue?: (metric: HealthStatusMetric) => string; @@ -82,6 +51,17 @@ export function HealthStatusCards({ metrics, formatValue }: HealthStatusCardsPro {metric.evaluationRule} {metric.explanation} + {metric.baselineProgress.blocker !== null ? ( + + {metric.baselineProgress.requirement} + + {metric.baselineProgress.observedObservationDays} of{" "} + {metric.baselineProgress.requiredObservationDays} required days recorded + + {metric.baselineProgress.summary} + {metric.baselineProgress.action} + + ) : null} ))} @@ -143,4 +123,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, + }, }); diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index 466a2b110f..53656a6396 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -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; +export type BaselineProgressBlocker = z.infer; + export const healthStatusMetricSchema = z.object({ metric: healthMetricKeySchema, label: z.string(), @@ -139,8 +160,11 @@ export const healthStatusMetricSchema = z.object({ statusLabel: z.string(), evaluationRule: z.string(), explanation: z.string(), + baselineProgress: baselineProgressSchema, }); +export type HealthStatusMetric = z.infer; + export const mobileRecoveryTabOutputSchema = z.object({ hrvVariability: z.array( z.object({ diff --git a/packages/server/src/repositories/daily-metrics-repository.test.ts b/packages/server/src/repositories/daily-metrics-repository.test.ts index 3fbb1907f9..21fdfd95d2 100644 --- a/packages/server/src/repositories/daily-metrics-repository.test.ts +++ b/packages/server/src/repositories/daily-metrics-repository.test.ts @@ -61,6 +61,11 @@ function makeTrendsRow(overrides: Record = {}): Record { 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, }; @@ -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 () => { diff --git a/packages/server/src/repositories/daily-metrics-repository.ts b/packages/server/src/repositories/daily-metrics-repository.ts index b0fd0a22b7..6b6d8e356a 100644 --- a/packages/server/src/repositories/daily-metrics-repository.ts +++ b/packages/server/src/repositories/daily-metrics-repository.ts @@ -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(), }); @@ -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(resting_hr) 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 FROM current ), representative_resting_heart_rate AS ( diff --git a/packages/server/src/routers/body-analytics.ts b/packages/server/src/routers/body-analytics.ts index 69af41d6e1..6f44bd2d7d 100644 --- a/packages/server/src/routers/body-analytics.ts +++ b/packages/server/src/routers/body-analytics.ts @@ -149,6 +149,7 @@ export const bodyAnalyticsRouter = router({ row.bodyFatPct == null ? [] : [row.bodyFatPct], ), intent: "neutral", + processingStatus: null, }), ], }; diff --git a/packages/server/src/routers/daily-metrics.integration.test.ts b/packages/server/src/routers/daily-metrics.integration.test.ts index 08aa546062..2418dcd2f0 100644 --- a/packages/server/src/routers/daily-metrics.integration.test.ts +++ b/packages/server/src/routers/daily-metrics.integration.test.ts @@ -191,6 +191,18 @@ describe("dailyMetrics data correctness", () => { expect(result.stddev_steps).toBeGreaterThan(0); }); + it("returns exact observed-day counts for baseline progress", async () => { + const result = await query<{ + sample_count_hrv: number; + sample_count_spo2: number; + sample_count_steps: number; + }>("dailyMetrics.trends", { days: 30, endDate }); + + expect(result.sample_count_hrv).toBe(27); + expect(result.sample_count_spo2).toBe(27); + expect(result.sample_count_steps).toBe(27); + }); + it("uses a representative recent resting heart rate instead of one noisy latest night", async () => { const repo = new DailyMetricsRepository(testCtx.db, TEST_USER_ID, "UTC"); const result = await repo.getTrends( diff --git a/packages/server/src/routers/daily-metrics.test.ts b/packages/server/src/routers/daily-metrics.test.ts index 14acc6a752..305a87f619 100644 --- a/packages/server/src/routers/daily-metrics.test.ts +++ b/packages/server/src/routers/daily-metrics.test.ts @@ -34,6 +34,17 @@ vi.mock("../lib/typed-sql.ts", async (importOriginal) => { }; }); +vi.mock("../repositories/processing-repository.ts", () => ({ + ProcessingRepository: class { + async status() { + return { + overallStatus: "ready", + datasets: [{ key: "recovery", status: "ready" }], + }; + } + }, +})); + import { dailyMetricsRouter } from "./daily-metrics.ts"; const createCaller = createTestCallerFactory(dailyMetricsRouter); @@ -249,6 +260,11 @@ describe("dailyMetricsRouter", () => { latest_steps: 9000, latest_active_energy: 550, latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, latest_date: "2024-01-16", latest_steps_date: "2024-01-16", latest_active_energy_date: "2024-01-16", @@ -272,8 +288,14 @@ describe("dailyMetricsRouter", () => { latest_spo2: 98, latest_steps: 9000, latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, latest_date: "2024-01-16", latest_steps_date: "2024-01-16", + restingHeartRateTrendLabel: "below average", baselineRelative: [], healthStatus: expect.arrayContaining([ expect.objectContaining({ @@ -303,6 +325,11 @@ describe("dailyMetricsRouter", () => { latest_spo2: null, latest_steps: null, latest_skin_temp: null, + sample_count_hrv: 1, + sample_count_resting_hr: 1, + sample_count_spo2: 0, + sample_count_steps: 0, + sample_count_skin_temp: 0, latest_date: "2024-01-16", latest_steps_date: null, }, @@ -398,6 +425,11 @@ describe("dailyMetricsRouter", () => { latest_steps: 9000, latest_active_energy: 550, latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, latest_date: "2024-01-16", latest_steps_date: "2024-01-16", latest_active_energy_date: "2024-01-16", @@ -433,6 +465,11 @@ describe("dailyMetricsRouter", () => { latest_steps: 9000, latest_active_energy: 550, latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, latest_date: "2024-01-16", latest_steps_date: "2024-01-16", latest_active_energy_date: "2024-01-16", @@ -491,6 +528,11 @@ describe("dailyMetricsRouter", () => { latest_steps: 9000, latest_active_energy: 550, latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, latest_date: "2024-01-16", latest_steps_date: "2024-01-16", latest_active_energy_date: "2024-01-16", @@ -534,6 +576,11 @@ describe("dailyMetricsRouter", () => { latest_spo2: null, latest_steps: null, latest_skin_temp: null, + sample_count_hrv: 1, + sample_count_resting_hr: 1, + sample_count_spo2: 0, + sample_count_steps: 0, + sample_count_skin_temp: 0, latest_date: "2024-01-16", latest_steps_date: null, }, diff --git a/packages/server/src/routers/daily-metrics.ts b/packages/server/src/routers/daily-metrics.ts index 27900d5b69..2ccd50d491 100644 --- a/packages/server/src/routers/daily-metrics.ts +++ b/packages/server/src/routers/daily-metrics.ts @@ -9,13 +9,16 @@ import { HRV_BASELINE_WARMUP_DAYS, trendsRowSchema, } from "../repositories/daily-metrics-repository.ts"; +import { ProcessingRepository } from "../repositories/processing-repository.ts"; import { latestRecoveryBaselineMetrics, RecoveryBaselineRepository, } from "../repositories/recovery-baseline-repository.ts"; import { fetchRestingHeartRateValuesCte } from "../repositories/resting-heart-rate-query.ts"; +import { baselineProcessingStatus } from "../services/baseline-progress.ts"; import { buildDailyMetricHealthStatuses, + buildRestingHeartRateTrendLabel, HEALTH_STATUS_CACHE_KEY_VERSION, healthStatusMetricSchema, } from "../services/health-status.ts"; @@ -78,7 +81,8 @@ export const dailyMetricsRouter = router({ sensorStore, ctx.accessWindow, ); - const [restingHeartRateCte, baselineRelative] = await Promise.all([ + const processingRepository = new ProcessingRepository(ctx.db, ctx.userId); + const [restingHeartRateCte, baselineRelative, processingSnapshot] = await Promise.all([ fetchRestingHeartRateValuesCte({ sensorStore, userId: ctx.userId, @@ -95,15 +99,34 @@ export const dailyMetricsRouter = router({ { priority: "dashboard" }, ) .then(latestRecoveryBaselineMetrics), + processingRepository.status({ datasets: ["recovery"] }), ]); const trends = await repo.getTrends(range.days, input.endDate, restingHeartRateCte); - return trends - ? { - ...trends, - baselineRelative, - healthStatus: buildDailyMetricHealthStatuses(trends, baselineRelative), - } - : null; + if (!trends) return null; + + const processingStatus = baselineProcessingStatus(processingSnapshot, "recovery"); + const healthStatus = buildDailyMetricHealthStatuses( + trends, + baselineRelative, + processingStatus, + ); + const restingHeartRateStatus = healthStatus.find( + (metric) => metric.metric === "resting_heart_rate", + ); + if (!restingHeartRateStatus) { + throw new Error("Daily metric health status omitted resting heart rate"); + } + + return { + ...trends, + restingHeartRateTrendLabel: buildRestingHeartRateTrendLabel({ + latest: trends.latest_resting_hr, + average: trends.avg_resting_hr, + baselineProgress: restingHeartRateStatus.baselineProgress, + }), + baselineRelative, + healthStatus, + }; }, { keyVersion: HEALTH_STATUS_CACHE_KEY_VERSION, @@ -111,6 +134,7 @@ export const dailyMetricsRouter = router({ .extend({ baselineRelative: z.array(baselineRelativeMetricSchema), healthStatus: z.array(healthStatusMetricSchema), + restingHeartRateTrendLabel: z.string(), }) .nullable(), }, diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 800cac0546..9dca788b06 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -151,6 +151,17 @@ vi.mock("../repositories/training-recommendation.ts", () => ({ computeReadinessScore: vi.fn(() => 62), })); +vi.mock("../repositories/processing-repository.ts", () => ({ + ProcessingRepository: class { + async status() { + return { + overallStatus: "ready", + datasets: [{ key: "recovery", status: "ready" }], + }; + } + }, +})); + vi.mock("../logger.ts", () => ({ logger: { info: vi.fn(), @@ -949,6 +960,7 @@ describe("mobileDashboard.recovery", () => { timezone: "UTC", accessWindow: fullAccessWindow, sensorStore: expect.anything(), + processingStatus: null, }, 30, "2026-03-28", diff --git a/packages/server/src/routers/mobile-dashboard.ts b/packages/server/src/routers/mobile-dashboard.ts index bd58345f47..4273cbbcb2 100644 --- a/packages/server/src/routers/mobile-dashboard.ts +++ b/packages/server/src/routers/mobile-dashboard.ts @@ -8,6 +8,8 @@ import { dateWindowInput, endDateSchema } from "../lib/date-window.ts"; import { logger } from "../logger.ts"; import type { ActivitySensorStore } from "../repositories/activity-repository.ts"; import type { AnomalyCheckResult } from "../repositories/anomaly-detection-repository.ts"; +import { ProcessingRepository } from "../repositories/processing-repository.ts"; +import { baselineProcessingStatus } from "../services/baseline-progress.ts"; import { loadDashboardOverview } from "../services/dashboard-overview.ts"; import { HEALTH_STATUS_CACHE_KEY_VERSION } from "../services/health-status.ts"; import { @@ -180,6 +182,9 @@ export const mobileDashboardRouter = router({ .query(async ({ ctx, input }) => { const sensorStore = requireSensorStore(ctx.sensorStore, "mobileDashboard.recovery"); const tabStart = performance.now(); + const processingSnapshot = await new ProcessingRepository(ctx.db, ctx.userId).status({ + datasets: ["recovery"], + }); const result = await loadMobileRecoveryTab( { db: ctx.db, @@ -187,6 +192,7 @@ export const mobileDashboardRouter = router({ timezone: ctx.timezone ?? "UTC", accessWindow: requireAccessWindow(ctx.accessWindow, "mobileDashboard.recovery"), sensorStore, + processingStatus: baselineProcessingStatus(processingSnapshot, "recovery"), }, input.days, input.endDate, diff --git a/packages/server/src/services/baseline-progress.test.ts b/packages/server/src/services/baseline-progress.test.ts new file mode 100644 index 0000000000..51e8e771e5 --- /dev/null +++ b/packages/server/src/services/baseline-progress.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + BASELINE_REQUIRED_OBSERVATION_DAYS, + baselineProcessingStatus, + buildBaselineProgress, +} from "./baseline-progress.ts"; + +const input = { + label: "Resting Heart Rate", + value: 56, + sampleDeviation: 2, + processingStatus: null, +}; + +describe("buildBaselineProgress", () => { + it("reports the source-data requirement when the baseline window is empty", () => { + expect( + buildBaselineProgress({ + ...input, + value: null, + observedDays: 0, + }), + ).toEqual({ + requiredObservationDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + observedObservationDays: 0, + hasMeasurableVariation: false, + blocker: "missing_source_data", + requirement: "A current value plus at least 2 more recorded days with measurable variation.", + summary: "No Resting Heart Rate data is available in this baseline window.", + action: "Connect or sync a source that records resting heart rate.", + }); + }); + + it("reports collection progress for a partial baseline", () => { + const result = buildBaselineProgress({ + ...input, + observedDays: 1, + sampleDeviation: null, + }); + + expect(result).toMatchObject({ + observedObservationDays: 1, + blocker: "collecting", + 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.", + }); + }); + + it("distinguishes enough identical observations from missing observations", () => { + expect( + buildBaselineProgress({ + ...input, + observedDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + sampleDeviation: 0, + }), + ).toMatchObject({ + blocker: "needs_variation", + hasMeasurableVariation: false, + summary: "Resting Heart Rate has enough observations, but they have not varied yet.", + action: "Continue recording resting heart rate data until the values vary.", + }); + }); + + it("lets processing state take precedence over an empty canonical window", () => { + expect( + buildBaselineProgress({ + ...input, + value: null, + observedDays: 0, + processingStatus: "syncing", + }), + ).toMatchObject({ + blocker: "syncing", + summary: "Resting Heart Rate baseline data is still syncing.", + action: "Wait for the sync to finish, then check your baseline again.", + }); + + expect( + buildBaselineProgress({ + ...input, + value: null, + observedDays: 0, + processingStatus: "sync_error", + }), + ).toMatchObject({ + blocker: "sync_error", + summary: "Resting Heart Rate baseline data could not sync.", + action: "Reconnect the data source and start the sync again.", + }); + }); + + it("reports a ready baseline after varied observations", () => { + expect( + buildBaselineProgress({ + ...input, + observedDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + }), + ).toEqual({ + requiredObservationDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + observedObservationDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + hasMeasurableVariation: true, + blocker: null, + requirement: "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Resting Heart Rate baseline is ready.", + action: "No action needed.", + }); + }); +}); + +describe("baselineProcessingStatus", () => { + it("maps dataset processing states to user-facing baseline blockers", () => { + const snapshot = { + overallStatus: "ready", + datasets: [{ key: "recovery", status: "active" }], + }; + + expect(baselineProcessingStatus(snapshot, "recovery")).toBe("syncing"); + expect( + baselineProcessingStatus( + { overallStatus: "failed", datasets: [{ key: "recovery", status: "failed" }] }, + "recovery", + ), + ).toBe("sync_error"); + expect( + baselineProcessingStatus( + { overallStatus: "ready", datasets: [{ key: "activity", status: "ready" }] }, + "recovery", + ), + ).toBeNull(); + }); +}); diff --git a/packages/server/src/services/baseline-progress.ts b/packages/server/src/services/baseline-progress.ts new file mode 100644 index 0000000000..27ff03a336 --- /dev/null +++ b/packages/server/src/services/baseline-progress.ts @@ -0,0 +1,99 @@ +import type { DerivedProcessingStatus } from "dofek/processing/processing-state"; +import type { + BaselineProgress, + BaselineProgressBlocker, +} from "../contracts/mobile-dashboard-contracts.ts"; + +export const BASELINE_REQUIRED_OBSERVATION_DAYS = 3; + +export type BaselineProcessingStatus = "syncing" | "sync_error" | null; + +interface BaselineProgressInput { + label: string; + value: number | null; + observedDays: number; + sampleDeviation: number | null; + processingStatus: BaselineProcessingStatus; +} + +interface ProcessingStatusSnapshotLike { + overallStatus: DerivedProcessingStatus; + datasets: readonly { key: string; status: DerivedProcessingStatus }[]; +} + +function hasMeasurableVariation(sampleDeviation: number | null): boolean { + return sampleDeviation != null && Number.isFinite(sampleDeviation) && sampleDeviation > 0; +} + +function remainingObservationText(days: number): string { + return `${days} more ${days === 1 ? "day" : "days"}`; +} + +function progressSummary(label: string, observedDays: number): string { + return `${label} has ${observedDays} of ${BASELINE_REQUIRED_OBSERVATION_DAYS} required days recorded; the baseline is still collecting observations.`; +} + +function progressAction(label: string, observedDays: number): string { + const remainingDays = Math.max(0, BASELINE_REQUIRED_OBSERVATION_DAYS - observedDays); + return `Keep syncing ${label.toLowerCase()} data for at least ${remainingObservationText(remainingDays)}.`; +} + +function missingSourceSummary(label: string): string { + return `No ${label} data is available in this baseline window.`; +} + +function missingSourceAction(label: string): string { + return `Connect or sync a source that records ${label.toLowerCase()}.`; +} + +export function buildBaselineProgress(input: BaselineProgressInput): BaselineProgress { + const observedDays = Math.max(0, Math.trunc(input.observedDays)); + const variation = observedDays > 1 && hasMeasurableVariation(input.sampleDeviation); + let blocker: BaselineProgressBlocker | null = null; + let summary = `${input.label} baseline is ready.`; + let action = "No action needed."; + + if (input.processingStatus === "syncing") { + blocker = "syncing"; + summary = `${input.label} baseline data is still syncing.`; + action = "Wait for the sync to finish, then check your baseline again."; + } else if (input.processingStatus === "sync_error") { + blocker = "sync_error"; + summary = `${input.label} baseline data could not sync.`; + action = "Reconnect the data source and start the sync again."; + } else if (observedDays === 0 || input.value == null) { + blocker = "missing_source_data"; + summary = missingSourceSummary(input.label); + action = missingSourceAction(input.label); + } else if (observedDays < BASELINE_REQUIRED_OBSERVATION_DAYS) { + blocker = "collecting"; + summary = progressSummary(input.label, observedDays); + action = progressAction(input.label, observedDays); + } else if (!variation) { + blocker = "needs_variation"; + summary = `${input.label} has enough observations, but they have not varied yet.`; + action = `Continue recording ${input.label.toLowerCase()} data until the values vary.`; + } + + return { + requiredObservationDays: BASELINE_REQUIRED_OBSERVATION_DAYS, + observedObservationDays: observedDays, + hasMeasurableVariation: variation, + blocker, + requirement: `A current value plus at least ${BASELINE_REQUIRED_OBSERVATION_DAYS - 1} more recorded days with measurable variation.`, + summary, + action, + }; +} + +export function baselineProcessingStatus( + snapshot: ProcessingStatusSnapshotLike, + datasetKey: string, +): BaselineProcessingStatus { + const status = + snapshot.datasets.find((dataset) => dataset.key === datasetKey)?.status ?? + snapshot.overallStatus; + if (["waiting", "active", "partial", "delayed"].includes(status)) return "syncing"; + if (["blocked", "failed"].includes(status)) return "sync_error"; + return null; +} diff --git a/packages/server/src/services/health-status.test.ts b/packages/server/src/services/health-status.test.ts index e3805fe72e..321b2ce2d4 100644 --- a/packages/server/src/services/health-status.test.ts +++ b/packages/server/src/services/health-status.test.ts @@ -28,6 +28,11 @@ describe("buildDailyMetricHealthStatuses", () => { latest_spo2: 98, latest_steps: null, latest_skin_temp: null, + sample_count_hrv: 0, + sample_count_resting_hr: 0, + sample_count_spo2: 1, + sample_count_steps: 0, + sample_count_skin_temp: 0, latest_date: "2026-07-25", latest_steps_date: null, }, @@ -57,6 +62,11 @@ describe("buildDailyMetricHealthStatuses", () => { latest_spo2: null, latest_steps: null, latest_skin_temp: null, + sample_count_hrv: 1, + sample_count_resting_hr: 0, + sample_count_spo2: 0, + sample_count_steps: 0, + sample_count_skin_temp: 0, latest_date: "2026-07-25", latest_steps_date: null, }, @@ -124,6 +134,32 @@ describe("buildDailyMetricHealthStatuses", () => { }); describe("buildHealthStatusFromSummary", () => { + it("includes server-authored baseline requirements and action for insufficient data", () => { + const result = buildHealthStatusFromSummary({ + metric: "resting_heart_rate", + label: "Resting Heart Rate", + value: 56, + baseline: 56, + sampleDeviation: null, + intent: "lower", + observedDays: 1, + processingStatus: null, + }); + + expect(result).toMatchObject({ + statusToken: "insufficient_data", + baselineProgress: { + observedObservationDays: 1, + 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.", + }, + }); + }); + it("treats a positive deviation as moving as intended when higher values are supported", () => { expect( buildHealthStatusFromSummary({ @@ -133,6 +169,8 @@ describe("buildHealthStatusFromSummary", () => { baseline: 50, sampleDeviation: 10, intent: "higher", + observedDays: 3, + processingStatus: null, }), ).toMatchObject({ deviation: 1.5, @@ -154,6 +192,8 @@ describe("buildHealthStatusFromSummary", () => { baseline: 50, sampleDeviation: 10, intent: "higher", + observedDays: 3, + processingStatus: null, }), ).toMatchObject({ deviation: -1.5, @@ -196,6 +236,8 @@ describe("buildHealthStatusFromSummary", () => { baseline: 50, sampleDeviation: 10, intent: "neutral", + observedDays: 3, + processingStatus: null, }), ).toMatchObject({ statusToken, evaluationRule }); }); @@ -211,6 +253,8 @@ describe("buildHealthStatusFromSummary", () => { baseline: 50, sampleDeviation: 10, intent: "neutral", + observedDays: 3, + processingStatus: null, }); expect(result).toMatchObject({ @@ -234,6 +278,8 @@ describe("buildHealthStatusFromSummary", () => { metric: "skin_temperature", label: "Skin Temperature", intent: "neutral", + observedDays: 3, + processingStatus: null, ...input, }), ).toMatchObject({ @@ -255,6 +301,7 @@ describe("buildHealthStatusFromValues", () => { label: "Steps", values: [70, 72, 74], intent: "neutral", + processingStatus: null, }), ).toMatchObject({ value: 74, diff --git a/packages/server/src/services/health-status.ts b/packages/server/src/services/health-status.ts index 48c3790893..d34347105c 100644 --- a/packages/server/src/services/health-status.ts +++ b/packages/server/src/services/health-status.ts @@ -1,19 +1,21 @@ import { mean, sampleStandardDeviation } from "simple-statistics"; import type { z } from "zod"; import type { BaselineRelativeMetric } from "../contracts/baseline-relative-metrics.ts"; +import type { BaselineProgress } from "../contracts/mobile-dashboard-contracts.ts"; import { healthMetricIntentSchema, healthMetricKeySchema, healthStatusMetricSchema, } from "../contracts/mobile-dashboard-contracts.ts"; import type { TrendsRow } from "../repositories/daily-metrics-repository.ts"; +import { type BaselineProcessingStatus, buildBaselineProgress } from "./baseline-progress.ts"; export { healthMetricIntentSchema, healthMetricKeySchema, healthStatusMetricSchema }; export type HealthMetricIntent = z.infer; export type HealthStatusMetric = z.infer; -export const HEALTH_STATUS_CACHE_KEY_VERSION = "health-status-evidence-v2"; +export const HEALTH_STATUS_CACHE_KEY_VERSION = "health-status-evidence-v3"; interface HealthStatusSummaryInput { metric: HealthStatusMetric["metric"]; @@ -22,6 +24,8 @@ interface HealthStatusSummaryInput { baseline: number | null; sampleDeviation: number | null; intent: HealthMetricIntent; + observedDays: number; + processingStatus: BaselineProcessingStatus; } interface HealthStatusValuesInput { @@ -29,6 +33,7 @@ interface HealthStatusValuesInput { label: string; values: readonly number[]; intent: HealthMetricIntent; + processingStatus: BaselineProcessingStatus; } interface WeightGoalIntentInput { @@ -38,8 +43,21 @@ interface WeightGoalIntentInput { } function insufficientData(input: HealthStatusSummaryInput): HealthStatusMetric { + const baselineProgress = buildBaselineProgress({ + label: input.label, + value: input.value, + observedDays: input.observedDays, + sampleDeviation: input.sampleDeviation, + processingStatus: input.processingStatus, + }); + return { - ...input, + metric: input.metric, + label: input.label, + value: input.value, + baseline: input.baseline, + sampleDeviation: input.sampleDeviation, + intent: input.intent, deviation: null, direction: "unknown", statusToken: "insufficient_data", @@ -47,6 +65,28 @@ function insufficientData(input: HealthStatusSummaryInput): HealthStatusMetric { 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, + }; +} + +function baselineProgressFor(input: HealthStatusSummaryInput): BaselineProgress { + return buildBaselineProgress({ + label: input.label, + value: input.value, + observedDays: input.observedDays, + sampleDeviation: input.sampleDeviation, + processingStatus: input.processingStatus, + }); +} + +function metricFields(input: HealthStatusSummaryInput) { + return { + metric: input.metric, + label: input.label, + value: input.value, + baseline: input.baseline, + sampleDeviation: input.sampleDeviation, + intent: input.intent, }; } @@ -92,6 +132,8 @@ function deviationExplanation( } export function buildHealthStatusFromSummary(input: HealthStatusSummaryInput): HealthStatusMetric { + const baselineProgress = baselineProgressFor(input); + if ( input.value == null || input.baseline == null || @@ -109,7 +151,8 @@ export function buildHealthStatusFromSummary(input: HealthStatusSummaryInput): H if (isMovingAsIntended(input.intent, direction)) { return { - ...input, + ...metricFields(input), + baselineProgress, deviation, direction, statusToken: "moving_as_intended", @@ -123,7 +166,8 @@ export function buildHealthStatusFromSummary(input: HealthStatusSummaryInput): H const absoluteDeviation = Math.abs(deviation); if (absoluteDeviation < 1) { return { - ...input, + ...metricFields(input), + baselineProgress, deviation, direction, statusToken: "near_baseline", @@ -136,7 +180,8 @@ export function buildHealthStatusFromSummary(input: HealthStatusSummaryInput): H if (direction === "aligned") { return { - ...input, + ...metricFields(input), + baselineProgress, deviation, direction, statusToken: "near_baseline", @@ -149,7 +194,8 @@ export function buildHealthStatusFromSummary(input: HealthStatusSummaryInput): H const farFromBaseline = absoluteDeviation >= 2; return { - ...input, + ...metricFields(input), + baselineProgress, deviation, direction, statusToken: farFromBaseline ? "far_from_baseline" : "notable_deviation", @@ -175,6 +221,8 @@ export function buildHealthStatusFromValues(input: HealthStatusValuesInput): Hea baseline, sampleDeviation, intent: input.intent, + observedDays: values.length, + processingStatus: input.processingStatus, }); } @@ -198,6 +246,7 @@ export function resolveWeightGoalIntent({ export function buildWeightHealthStatus( values: readonly number[], goalWeightKg: number | null, + processingStatus: BaselineProcessingStatus = null, ): HealthStatusMetric { const finiteValues = values.filter(Number.isFinite); const currentWeightKg = finiteValues.at(-1) ?? null; @@ -210,6 +259,8 @@ export function buildWeightHealthStatus( baseline: baselineKg, sampleDeviation: finiteValues.length > 1 ? sampleStandardDeviation(finiteValues) : null, intent: resolveWeightGoalIntent({ goalWeightKg, currentWeightKg, baselineKg }), + observedDays: finiteValues.length, + processingStatus, }); } @@ -222,6 +273,7 @@ const recoveryMetricIntents: Record + buildHealthStatusFromBaselineMetric(metric, processingStatus), + ); + const restingHeartRateStatus = baselineRelative.some( + (metric) => metric.metric === "resting_heart_rate", + ) + ? null + : buildHealthStatusFromSummary({ + metric: "resting_heart_rate", + label: "Resting Heart Rate", + value: trends.latest_resting_hr, + baseline: trends.avg_resting_hr, + sampleDeviation: trends.stddev_resting_hr, + intent: "lower", + observedDays: trends.sample_count_resting_hr ?? 0, + processingStatus, + }); + return [ - ...baselineRelative.map(buildHealthStatusFromBaselineMetric), + ...baselineStatuses, + ...(restingHeartRateStatus ? [restingHeartRateStatus] : []), buildHealthStatusFromSummary({ metric: "spo2", label: "Blood Oxygen Saturation (SpO2)", @@ -246,6 +320,8 @@ export function buildDailyMetricHealthStatuses( baseline: trends.avg_spo2, sampleDeviation: trends.stddev_spo2, intent: "neutral", + observedDays: trends.sample_count_spo2 ?? 0, + processingStatus, }), buildHealthStatusFromSummary({ metric: "steps", @@ -254,6 +330,8 @@ export function buildDailyMetricHealthStatuses( baseline: trends.avg_steps, sampleDeviation: trends.stddev_steps, intent: "neutral", + observedDays: trends.sample_count_steps ?? 0, + processingStatus, }), buildHealthStatusFromSummary({ metric: "skin_temperature", @@ -262,6 +340,20 @@ export function buildDailyMetricHealthStatuses( baseline: trends.avg_skin_temp, sampleDeviation: trends.stddev_skin_temp, intent: "neutral", + observedDays: trends.sample_count_skin_temp ?? 0, + processingStatus, }), ]; } + +export function buildRestingHeartRateTrendLabel(input: { + latest: number | null; + average: number | null; + baselineProgress: Pick; +}): string { + if (input.baselineProgress.blocker !== null) return "Waiting for baseline"; + if (input.latest == null || input.average == null) return "Waiting for baseline"; + if (input.latest < input.average) return "below average"; + if (input.latest > input.average) return "above average"; + return "at average"; +} diff --git a/packages/server/src/services/mobile-recovery-tab.test.ts b/packages/server/src/services/mobile-recovery-tab.test.ts index bc9d7faaaa..ea22f271f0 100644 --- a/packages/server/src/services/mobile-recovery-tab.test.ts +++ b/packages/server/src/services/mobile-recovery-tab.test.ts @@ -585,21 +585,24 @@ describe("loadMobileRecoveryTab", () => { label: "SpO2", values: [97, 99], intent: "neutral", + processingStatus: null, }, { metric: "steps", label: "Steps", values: [8_000, 10_000], intent: "neutral", + processingStatus: null, }, { metric: "skin_temperature", label: "Skin Temperature", values: [33.1, 33.5], intent: "neutral", + processingStatus: null, }, ]); - expect(buildWeightHealthStatus).toHaveBeenCalledWith([80, 79], 75); + expect(buildWeightHealthStatus).toHaveBeenCalledWith([80, 79], 75, null); }); it("rounds HRV deviation to 2 decimal places", async () => { diff --git a/packages/server/src/services/mobile-recovery-tab.ts b/packages/server/src/services/mobile-recovery-tab.ts index c692c0f326..085ed3773a 100644 --- a/packages/server/src/services/mobile-recovery-tab.ts +++ b/packages/server/src/services/mobile-recovery-tab.ts @@ -36,6 +36,7 @@ import type { StressResult } from "../repositories/stress-repository.ts"; import { buildHealthspanResult } from "../routers/healthspan.ts"; import { fetchHealthspanRawData } from "../routers/healthspan-query.ts"; import type { HrvVariabilityRow, ReadinessRow } from "../routers/recovery.ts"; +import type { BaselineProcessingStatus } from "./baseline-progress.ts"; import { buildHealthStatusFromBaselineMetric, buildHealthStatusFromValues, @@ -52,6 +53,7 @@ interface MobileRecoveryTabContext { timezone: string; accessWindow: AccessWindow; sensorStore: ActivitySensorStore; + processingStatus?: BaselineProcessingStatus; } function findRecoveryMetric(row: DailyRecoveryBaseline, metric: BaselineRelativeMetric["metric"]) { @@ -219,6 +221,7 @@ export async function loadMobileRecoveryTab( const stress = computeStressFromRows(recoveryRowsInWindow, effective.stressThresholds); const dailyMetrics = filterDailyMetrics(dailyMetricsRows, days, endDate); const baselineRelative = latestRecoveryBaselineMetrics(recoveryRowsInWindow); + const processingStatus = ctx.processingStatus ?? null; const parsedGoalWeightKg = goalSetting?.value != null ? Number(goalSetting.value) : null; const goalWeightKg = @@ -240,29 +243,49 @@ export async function loadMobileRecoveryTab( ), ]); + const restingHeartRateBaseline = baselineRelative.find( + (metric) => metric.metric === "resting_heart_rate", + ); + const restingHeartRateStatus = restingHeartRateBaseline + ? buildHealthStatusFromBaselineMetric(restingHeartRateBaseline, processingStatus) + : buildHealthStatusFromValues({ + metric: "resting_heart_rate", + label: "Resting Heart Rate", + values: hrvBaseline.flatMap((row) => (row.resting_hr == null ? [] : [row.resting_hr])), + intent: "lower", + processingStatus, + }); + const healthStatus = [ - ...baselineRelative.map(buildHealthStatusFromBaselineMetric), + ...baselineRelative + .filter((metric) => metric.metric !== "resting_heart_rate") + .map((metric) => buildHealthStatusFromBaselineMetric(metric, processingStatus)), + restingHeartRateStatus, buildHealthStatusFromValues({ metric: "spo2", label: "SpO2", values: dailyMetrics.flatMap((row) => (row.spo2_avg == null ? [] : [row.spo2_avg])), intent: "neutral", + processingStatus, }), buildHealthStatusFromValues({ metric: "steps", label: "Steps", values: dailyMetrics.flatMap((row) => (row.steps == null ? [] : [row.steps])), intent: "neutral", + processingStatus, }), buildHealthStatusFromValues({ metric: "skin_temperature", label: "Skin Temperature", values: dailyMetrics.flatMap((row) => (row.skin_temp_c == null ? [] : [row.skin_temp_c])), intent: "neutral", + processingStatus, }), buildWeightHealthStatus( weight.map((row) => row.smoothedWeight), goalWeightKg, + processingStatus, ), ]; diff --git a/packages/web/src/components/DashboardEvidenceOverview.stories.tsx b/packages/web/src/components/DashboardEvidenceOverview.stories.tsx index 6354aed82e..c254f6d39f 100644 --- a/packages/web/src/components/DashboardEvidenceOverview.stories.tsx +++ b/packages/web/src/components/DashboardEvidenceOverview.stories.tsx @@ -23,6 +23,7 @@ const meta = { trend: { latestRestingHeartRate: 52, averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "below average", restingHeartRatePoints: [ { date: "2026-05-23", value: 57 }, { date: "2026-05-24", value: 56 }, @@ -100,6 +101,7 @@ export const Loading: Story = { trend: { latestRestingHeartRate: undefined, averageRestingHeartRate: undefined, + restingHeartRateTrendLabel: "Waiting for baseline", restingHeartRatePoints: null, }, trainingSleepPoints: null, @@ -124,6 +126,7 @@ export const Empty: Story = { trend: { latestRestingHeartRate: null, averageRestingHeartRate: null, + restingHeartRateTrendLabel: "Waiting for baseline", restingHeartRatePoints: null, }, trainingSleepPoints: null, diff --git a/packages/web/src/components/DashboardEvidenceOverview.test.tsx b/packages/web/src/components/DashboardEvidenceOverview.test.tsx index c324804f4e..9dfbde2d17 100644 --- a/packages/web/src/components/DashboardEvidenceOverview.test.tsx +++ b/packages/web/src/components/DashboardEvidenceOverview.test.tsx @@ -13,12 +13,62 @@ describe("DashboardEvidenceOverview helpers", () => { }); it("labels resting heart rate position against baseline", () => { - expect(trendPositionLabel({ latestRestingHeartRate: 52, averageRestingHeartRate: 56 })).toBe( - "below average", - ); - expect(trendPositionLabel({ latestRestingHeartRate: null, averageRestingHeartRate: 56 })).toBe( - "Waiting for baseline", + expect( + trendPositionLabel({ + latestRestingHeartRate: 52, + averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "below average", + }), + ).toBe("below average"); + expect( + trendPositionLabel({ + latestRestingHeartRate: null, + averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "Waiting for baseline", + }), + ).toBe("Waiting for baseline"); + }); + + it("renders the server-authored baseline requirement, progress, and action", () => { + render( + Health monitor} + />, ); + + 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(); }); }); @@ -35,6 +85,7 @@ describe("DashboardEvidenceOverview", () => { trend={{ latestRestingHeartRate: 52, averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "below average", restingHeartRatePoints: [ { date: "2026-05-26", value: 56 }, { date: "2026-05-27", value: 52 }, @@ -217,6 +268,7 @@ describe("DashboardEvidenceOverview", () => { trend={{ latestRestingHeartRate: 52, averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "below average", restingHeartRatePoints: [ { date: "2026-05-25", value: 57 }, { date: "2026-05-26", value: 55 }, @@ -346,6 +398,7 @@ describe("DashboardEvidenceOverview", () => { trend={{ latestRestingHeartRate: 52, averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "below average", restingHeartRatePoints: [ { date: "2026-05-26", value: 56 }, { date: "2026-05-27", value: 52 }, @@ -367,6 +420,7 @@ describe("DashboardEvidenceOverview", () => { trend={{ latestRestingHeartRate: 60, averageRestingHeartRate: 56, + restingHeartRateTrendLabel: "above average", restingHeartRatePoints: [ { date: "2026-05-26", value: 56 }, { date: "2026-05-27", value: 60 }, diff --git a/packages/web/src/components/DashboardEvidenceOverview.tsx b/packages/web/src/components/DashboardEvidenceOverview.tsx index bf9ab50559..27342d962c 100644 --- a/packages/web/src/components/DashboardEvidenceOverview.tsx +++ b/packages/web/src/components/DashboardEvidenceOverview.tsx @@ -1,5 +1,6 @@ import { formatDateShort } from "@dofek/format/format"; import { formatMeasurementText } from "@dofek/format/units"; +import type { BaselineProgress } from "dofek-server/mobile-dashboard-contracts"; import type { ReactNode } from "react"; import { useUnitConverter } from "../lib/unitContext.ts"; import { ChartContainer } from "./ChartContainer.tsx"; @@ -10,6 +11,8 @@ import { QueryStatePanel } from "./QueryStatePanel.tsx"; export interface DashboardTrendSnapshot { latestRestingHeartRate: number | null | undefined; averageRestingHeartRate: number | null | undefined; + restingHeartRateTrendLabel?: string | null; + restingHeartRateBaselineProgress?: BaselineProgress | null; restingHeartRatePoints?: RestingHeartRatePoint[] | null | undefined; } @@ -33,13 +36,7 @@ export function formatDashboardRange(endDate: string, days: number): string { } export function trendPositionLabel(trend: DashboardTrendSnapshot): string { - const { latestRestingHeartRate, averageRestingHeartRate } = trend; - if (latestRestingHeartRate == null || averageRestingHeartRate == null) { - return "Waiting for baseline"; - } - if (latestRestingHeartRate < averageRestingHeartRate) return "below average"; - if (latestRestingHeartRate > averageRestingHeartRate) return "above average"; - return "at average"; + return trend.restingHeartRateTrendLabel ?? "Waiting for baseline"; } interface ChartLabels { @@ -71,15 +68,15 @@ function formatChartNumber(value: number): string { } function restingHeartRateTone(trend: DashboardTrendSnapshot): RestingHeartRateTone { - const { latestRestingHeartRate, averageRestingHeartRate } = trend; - if (latestRestingHeartRate == null || averageRestingHeartRate == null) { + const trendLabel = trendPositionLabel(trend); + if (trendLabel === "Waiting for baseline") { return { className: "text-muted", colorVariable: "var(--color-muted)", fillOpacity: "0.08", }; } - if (latestRestingHeartRate <= averageRestingHeartRate) { + if (trendLabel === "below average" || trendLabel === "at average") { return { className: "text-accent", colorVariable: "var(--color-accent)", @@ -242,6 +239,26 @@ export function DashboardEvidenceOverview({ /> + {trend.restingHeartRateBaselineProgress && + trend.restingHeartRateBaselineProgress.blocker !== null ? ( +
+

+ {trend.restingHeartRateBaselineProgress.requirement} +

+

+ {trend.restingHeartRateBaselineProgress.observedObservationDays} of{" "} + {trend.restingHeartRateBaselineProgress.requiredObservationDays} required days + recorded +

+

{trend.restingHeartRateBaselineProgress.summary}

+

+ {trend.restingHeartRateBaselineProgress.action} +

+
+ ) : null} diff --git a/packages/web/src/components/HealthStatusBar.stories.tsx b/packages/web/src/components/HealthStatusBar.stories.tsx index 5c7c7bd9c3..5f04790144 100644 --- a/packages/web/src/components/HealthStatusBar.stories.tsx +++ b/packages/web/src/components/HealthStatusBar.stories.tsx @@ -18,6 +18,15 @@ function hrvMetric(overrides: Partial = {}): HealthStatusMet statusLabel: "Moving as intended", evaluationRule: "Above your baseline, where higher values support this metric", explanation: "Heart Rate Variability (HRV) is above your baseline.", + baselineProgress: { + requiredObservationDays: 3, + observedObservationDays: 3, + hasMeasurableVariation: true, + blocker: null, + requirement: "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Heart Rate Variability (HRV) baseline is ready.", + action: "No action needed.", + }, ...overrides, }; } diff --git a/packages/web/src/components/HealthStatusBar.test.tsx b/packages/web/src/components/HealthStatusBar.test.tsx index 5b77de91da..1d37af9f12 100644 --- a/packages/web/src/components/HealthStatusBar.test.tsx +++ b/packages/web/src/components/HealthStatusBar.test.tsx @@ -17,6 +17,15 @@ const serverMetric = { statusLabel: "Near baseline", evaluationRule: "Within your usual range: less than 1 standard deviation from baseline", explanation: "Skin Temperature is close to your usual range.", + baselineProgress: { + requiredObservationDays: 3, + observedObservationDays: 3, + hasMeasurableVariation: true, + blocker: null, + requirement: "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Skin Temperature baseline is ready.", + action: "No action needed.", + }, }; describe("HealthStatusBar", () => { diff --git a/packages/web/src/components/HealthStatusBar.tsx b/packages/web/src/components/HealthStatusBar.tsx index d5aadf5674..850b2c12fe 100644 --- a/packages/web/src/components/HealthStatusBar.tsx +++ b/packages/web/src/components/HealthStatusBar.tsx @@ -162,6 +162,20 @@ export function HealthStatusBar({
{metric.evaluationRule}
{metric.explanation}
+ {metric.baselineProgress.blocker !== null ? ( +
+
{metric.baselineProgress.requirement}
+
+ {metric.baselineProgress.observedObservationDays} of{" "} + {metric.baselineProgress.requiredObservationDays} required days recorded +
+
{metric.baselineProgress.summary}
+
{metric.baselineProgress.action}
+
+ ) : null} {baselineContext ? (
{formatBaselineContext(baselineContext, { diff --git a/packages/web/src/lib/healthStatus.test.ts b/packages/web/src/lib/healthStatus.test.ts index 4add994071..daafc04cb8 100644 --- a/packages/web/src/lib/healthStatus.test.ts +++ b/packages/web/src/lib/healthStatus.test.ts @@ -20,6 +20,16 @@ describe("healthStatusMetricSchema", () => { statusLabel: "Moving as intended", evaluationRule: "Above your baseline, where higher values support this metric", explanation: "Heart Rate Variability is above your baseline.", + baselineProgress: { + requiredObservationDays: 3, + observedObservationDays: 3, + hasMeasurableVariation: true, + blocker: null, + requirement: + "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Heart Rate Variability baseline is ready.", + action: "No action needed.", + }, }), ).toEqual({ metric: "hrv", @@ -35,6 +45,16 @@ describe("healthStatusMetricSchema", () => { statusLabel: "Moving as intended", evaluationRule: "Above your baseline, where higher values support this metric", explanation: "Heart Rate Variability is above your baseline.", + baselineProgress: { + requiredObservationDays: 3, + observedObservationDays: 3, + hasMeasurableVariation: true, + blocker: null, + requirement: + "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Heart Rate Variability baseline is ready.", + action: "No action needed.", + }, }); }); diff --git a/packages/web/src/lib/healthStatus.ts b/packages/web/src/lib/healthStatus.ts index a5f87f0044..9a95b18797 100644 --- a/packages/web/src/lib/healthStatus.ts +++ b/packages/web/src/lib/healthStatus.ts @@ -1,36 +1,10 @@ -import { z } from "zod"; +import type { HealthStatusMetric } from "dofek-server/mobile-dashboard-contracts"; +import { + healthMetricIntentSchema, + healthMetricKeySchema, + healthStatusMetricSchema, +} from "dofek-server/mobile-dashboard-contracts"; -export const healthStatusMetricSchema = z.object({ - metric: z.enum([ - "hrv", - "resting_heart_rate", - "respiratory_rate", - "sleep_efficiency", - "spo2", - "steps", - "skin_temperature", - "trend_weight", - "body_fat_percentage", - ]), - label: z.string(), - value: z.number().nullable(), - baseline: z.number().nullable(), - sampleDeviation: z.number().nullable(), - deviation: z.number().nullable(), - direction: z.enum(["above", "below", "aligned", "unknown"]), - intent: z.enum(["higher", "lower", "maintain", "neutral"]), - statusToken: z.enum([ - "insufficient_data", - "near_baseline", - "moving_as_intended", - "notable_deviation", - "far_from_baseline", - ]), - statusColor: z.enum(["positive", "warning", "danger", "muted"]), - statusLabel: z.string(), - evaluationRule: z.string(), - explanation: z.string(), -}); - -export type HealthStatusMetric = z.infer; +export { healthMetricIntentSchema, healthMetricKeySchema, healthStatusMetricSchema }; +export type { HealthStatusMetric }; export type HealthMetricKey = HealthStatusMetric["metric"]; diff --git a/packages/web/src/pages/Dashboard.test.tsx b/packages/web/src/pages/Dashboard.test.tsx index f8c412d10e..c1eb5fd791 100644 --- a/packages/web/src/pages/Dashboard.test.tsx +++ b/packages/web/src/pages/Dashboard.test.tsx @@ -496,6 +496,7 @@ describe("Dashboard", () => { latest_steps: null, latest_skin_temp: null, latest_date: "2026-05-27", + restingHeartRateTrendLabel: "below average", baselineRelative: [], healthStatus: [], }, @@ -567,6 +568,7 @@ describe("Dashboard", () => { latest_steps: null, latest_skin_temp: null, latest_date: "2026-05-27", + restingHeartRateTrendLabel: "below average", baselineRelative: [], healthStatus: [], }, @@ -711,6 +713,16 @@ describe("buildHealthMetrics", () => { statusLabel: "Moving as intended", evaluationRule: "Below your baseline, where lower values support this metric", explanation: "Resting Heart Rate is below your baseline.", + baselineProgress: { + requiredObservationDays: 3, + observedObservationDays: 3, + hasMeasurableVariation: true, + blocker: null, + requirement: + "A current value plus at least 2 more recorded days with measurable variation.", + summary: "Resting Heart Rate baseline is ready.", + action: "No action needed.", + }, }; const metrics = buildHealthMetrics({ avg_hrv: 43.8, @@ -729,6 +741,7 @@ describe("buildHealthMetrics", () => { latest_steps: null, latest_skin_temp: null, latest_date: "2025-03-15", + restingHeartRateTrendLabel: "below average", baselineRelative: [], healthStatus: [restingHeartRateStatus], }); diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index 5ff40f8845..9be61f2fa1 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -36,6 +36,7 @@ const trendRowSchema = z.object({ latest_steps: z.number().nullable(), latest_skin_temp: z.number().nullable(), latest_date: z.string().nullable(), + restingHeartRateTrendLabel: z.string(), baselineRelative: z.array(baselineRelativeMetricSchema), healthStatus: z.array(healthStatusMetricSchema), }); @@ -179,6 +180,9 @@ export function Dashboard() { }, [insightsQuery.data]); const healthMetrics = useMemo(() => buildHealthMetrics(trendData), [trendData]); + const restingHeartRateStatus = healthMetrics.find( + (metric) => metric.metric === "resting_heart_rate", + ); const restingHeartRatePoints = useMemo( () => restingHeartRateRows.flatMap((row) => @@ -260,6 +264,8 @@ export function Dashboard() { trend={{ latestRestingHeartRate: trendData?.latest_resting_hr, averageRestingHeartRate: trendData?.avg_resting_hr, + restingHeartRateTrendLabel: trendData?.restingHeartRateTrendLabel, + restingHeartRateBaselineProgress: restingHeartRateStatus?.baselineProgress, restingHeartRatePoints, }} restingHeartRateLoading={heartRateBaseline.isLoading} From 6468ca25cbe7b372ec0a132efc8ec267ad4a5241 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sun, 2 Aug 2026 00:10:41 -0700 Subject: [PATCH 2/4] test(dashboard): cover baseline branches --- .../server/src/routers/daily-metrics.test.ts | 29 +++- .../src/routers/mobile-dashboard.test.ts | 30 +++- .../server/src/services/health-status.test.ts | 135 ++++++++++++++++++ .../src/services/mobile-recovery-tab.test.ts | 48 ++++++- 4 files changed, 229 insertions(+), 13 deletions(-) diff --git a/packages/server/src/routers/daily-metrics.test.ts b/packages/server/src/routers/daily-metrics.test.ts index 305a87f619..b695a1541e 100644 --- a/packages/server/src/routers/daily-metrics.test.ts +++ b/packages/server/src/routers/daily-metrics.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { collectSqlText, createTestCallerFactory, makeMockSensorStore } from "./test-helpers.ts"; +type ProcessingStatusQuery = { datasets?: readonly ["recovery"] }; + +const processingStatusMock = vi.hoisted(() => + vi.fn(async (_input: ProcessingStatusQuery) => ({ + overallStatus: "ready" as const, + datasets: [{ key: "recovery" as const, status: "ready" as const }], + })), +); + vi.mock("../trpc.ts", async () => { const { initTRPC } = await import("@trpc/server"); const trpc = initTRPC @@ -36,11 +45,8 @@ vi.mock("../lib/typed-sql.ts", async (importOriginal) => { vi.mock("../repositories/processing-repository.ts", () => ({ ProcessingRepository: class { - async status() { - return { - overallStatus: "ready", - datasets: [{ key: "recovery", status: "ready" }], - }; + status(input: ProcessingStatusQuery) { + return processingStatusMock(input); } }, })); @@ -240,6 +246,14 @@ describe("dailyMetricsRouter", () => { }); describe("trends", () => { + it("requests processing status for the recovery dataset", async () => { + processingStatusMock.mockClear(); + + await makeCaller([]).trends({ days: 30, endDate: "2024-01-16" }); + + expect(processingStatusMock).toHaveBeenCalledWith({ datasets: ["recovery"] }); + }); + it("returns first row or null", async () => { const rows = [ { @@ -343,7 +357,7 @@ describe("dailyMetricsRouter", () => { hrv_mean_30d: 60, hrv_sd_30d: 6, hrv_z_score: 2, - hrv_baseline_sample_count: 24, + hrv_baseline_sample_count: 1, hrv_baseline_coverage: 0.8, hrv_mean_7d: 66, hrv_mean_previous_28d: 61, @@ -386,9 +400,10 @@ describe("dailyMetricsRouter", () => { expect(result?.baselineRelative[0]).toMatchObject({ metric: "hrv", value: 72, - baseline: { mean: 60, zScore: 2, sampleCount: 24, coverage: 0.8 }, + baseline: { mean: 60, zScore: 2, sampleCount: 1, coverage: 0.8 }, comparison: { delta: 5, direction: "increasing" }, }); + expect(result?.restingHeartRateTrendLabel).toBe("below average"); }); it("coerces PostgreSQL string aggregates to numbers via Zod schema", async () => { diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 9dca788b06..575369766d 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -7,6 +7,13 @@ import { } from "./test-helpers.ts"; const cachedQueryOptions = vi.hoisted((): Array<{ maxAge: number; keyVersion?: string }> => []); +type ProcessingStatusQuery = { datasets?: readonly ["recovery"] }; +const processingStatusMock = vi.hoisted(() => + vi.fn(async (_input: ProcessingStatusQuery) => ({ + overallStatus: "ready" as const, + datasets: [{ key: "recovery" as const, status: "ready" as const }], + })), +); vi.mock("../trpc.ts", async () => { const { initTRPC } = await import("@trpc/server"); @@ -153,11 +160,8 @@ vi.mock("../repositories/training-recommendation.ts", () => ({ vi.mock("../repositories/processing-repository.ts", () => ({ ProcessingRepository: class { - async status() { - return { - overallStatus: "ready", - datasets: [{ key: "recovery", status: "ready" }], - }; + status(input: ProcessingStatusQuery) { + return processingStatusMock(input); } }, })); @@ -831,6 +835,22 @@ describe("mobileDashboard.recovery", () => { }); }); + it("requests processing status for the recovery dataset", async () => { + processingStatusMock.mockClear(); + + const caller = createCaller({ + db: { execute: vi.fn().mockResolvedValue([]), transaction: vi.fn() }, + userId: "user-1", + timezone: "UTC", + accessWindow: fullAccessWindow, + sensorStore: makeSensorStore(), + }); + + await caller.recovery({ days: 30, endDate: "2026-03-28" }); + + expect(processingStatusMock).toHaveBeenCalledWith({ datasets: ["recovery"] }); + }); + it("returns consolidated recovery tab data", async () => { const query = vi.fn(async (_schema: unknown, sqlText: unknown) => { if (String(sqlText).includes("analytics.daily_recovery")) { diff --git a/packages/server/src/services/health-status.test.ts b/packages/server/src/services/health-status.test.ts index 321b2ce2d4..e865160b40 100644 --- a/packages/server/src/services/health-status.test.ts +++ b/packages/server/src/services/health-status.test.ts @@ -5,6 +5,7 @@ import { buildHealthStatusFromBaselineMetric, buildHealthStatusFromSummary, buildHealthStatusFromValues, + buildRestingHeartRateTrendLabel, buildWeightHealthStatus, resolveWeightGoalIntent, } from "./health-status.ts"; @@ -91,6 +92,97 @@ describe("buildDailyMetricHealthStatuses", () => { sampleDeviation: 6, deviation: 2, }); + expect(statuses.filter((status) => status.metric === "resting_heart_rate")).toHaveLength(1); + }); + + it("uses the canonical resting heart rate baseline without adding a duplicate trend status", () => { + const statuses = buildDailyMetricHealthStatuses( + { + avg_hrv: null, + avg_resting_hr: 54, + avg_spo2: null, + avg_steps: null, + avg_skin_temp: null, + stddev_hrv: null, + stddev_resting_hr: 2, + stddev_spo2: null, + stddev_steps: null, + stddev_skin_temp: null, + latest_hrv: null, + latest_resting_hr: 52, + latest_spo2: null, + latest_steps: null, + latest_skin_temp: null, + sample_count_hrv: 0, + sample_count_resting_hr: 3, + sample_count_spo2: 0, + sample_count_steps: 0, + sample_count_skin_temp: 0, + latest_date: "2026-07-25", + latest_steps_date: null, + }, + [ + buildBaselineRelativeMetric({ + metric: "resting_heart_rate", + label: "Resting Heart Rate", + value: 52, + baselineMean: 54, + baselineStandardDeviation: 2, + zScore: -1, + baselineSampleCount: 30, + baselineCoverage: 1, + recentMean: 52, + comparisonMean: 54, + }), + ], + ); + + expect(statuses.filter((status) => status.metric === "resting_heart_rate")).toHaveLength(1); + expect(statuses.find((status) => status.metric === "resting_heart_rate")).toMatchObject({ + value: 52, + baseline: 54, + sampleDeviation: 2, + }); + }); + + it("uses each aggregate metric's server-provided observation count", () => { + const statuses = buildDailyMetricHealthStatuses( + { + avg_hrv: null, + avg_resting_hr: null, + avg_spo2: 97, + avg_steps: 100, + avg_skin_temp: 36, + stddev_hrv: null, + stddev_resting_hr: null, + stddev_spo2: 1, + stddev_steps: 10, + stddev_skin_temp: 0.1, + latest_hrv: null, + latest_resting_hr: null, + latest_spo2: 98, + latest_steps: 120, + latest_skin_temp: 36.2, + sample_count_hrv: 0, + sample_count_resting_hr: 0, + sample_count_spo2: 3, + sample_count_steps: 4, + sample_count_skin_temp: 5, + latest_date: "2026-07-25", + latest_steps_date: "2026-07-25", + }, + [], + ); + + expect(statuses.find((status) => status.metric === "spo2")?.baselineProgress).toMatchObject({ + observedObservationDays: 3, + }); + expect(statuses.find((status) => status.metric === "steps")?.baselineProgress).toMatchObject({ + observedObservationDays: 4, + }); + expect( + statuses.find((status) => status.metric === "skin_temperature")?.baselineProgress, + ).toMatchObject({ observedObservationDays: 5 }); }); it.each([ @@ -133,6 +225,49 @@ describe("buildDailyMetricHealthStatuses", () => { }); }); +describe("buildRestingHeartRateTrendLabel", () => { + it("waits for the baseline before comparing a current value", () => { + expect( + buildRestingHeartRateTrendLabel({ + latest: 48, + average: 54, + baselineProgress: { blocker: "collecting" }, + }), + ).toBe("Waiting for baseline"); + }); + + it.each([ + { latest: null, average: 54 }, + { latest: 48, average: null }, + ])("waits when the $latest/$average comparison value is missing", ({ latest, average }) => { + expect( + buildRestingHeartRateTrendLabel({ + latest, + average, + baselineProgress: { blocker: null }, + }), + ).toBe("Waiting for baseline"); + }); + + it.each([ + { latest: 48, average: 54, label: "below average" }, + { latest: 60, average: 54, label: "above average" }, + { latest: 54, average: 54, label: "at average" }, + ])("returns the server-owned comparison label for $latest/$average", ({ + latest, + average, + label, + }) => { + expect( + buildRestingHeartRateTrendLabel({ + latest, + average, + baselineProgress: { blocker: null }, + }), + ).toBe(label); + }); +}); + describe("buildHealthStatusFromSummary", () => { it("includes server-authored baseline requirements and action for insufficient data", () => { const result = buildHealthStatusFromSummary({ diff --git a/packages/server/src/services/mobile-recovery-tab.test.ts b/packages/server/src/services/mobile-recovery-tab.test.ts index ea22f271f0..01258c76d9 100644 --- a/packages/server/src/services/mobile-recovery-tab.test.ts +++ b/packages/server/src/services/mobile-recovery-tab.test.ts @@ -541,7 +541,7 @@ describe("loadMobileRecoveryTab", () => { vi.mocked(buildHealthStatusFromValues).mockClear(); vi.mocked(buildWeightHealthStatus).mockClear(); - await runRecoveryTab([recoveryRow()], { + const result = await runRecoveryTab([recoveryRow()], { weight: [ { date: "2026-03-27", @@ -603,6 +603,52 @@ describe("loadMobileRecoveryTab", () => { }, ]); expect(buildWeightHealthStatus).toHaveBeenCalledWith([80, 79], 75, null); + expect( + result.healthStatus.find((status) => status.metric === "resting_heart_rate"), + ).toMatchObject({ value: 52, baseline: 54, sampleDeviation: 2 }); + }); + + it("builds the resting heart rate status from HRV baseline rows when recovery data is absent", async () => { + const result = await runRecoveryTab([], { + hrvBaseline: [ + { + date: "2026-03-26", + hrv: 50, + resting_hr: 50, + mean_60d: 50, + sd_60d: 2, + mean_7d: 50, + resting_hr_mean_7d: 50, + }, + { + date: "2026-03-27", + hrv: 52, + resting_hr: 52, + mean_60d: 51, + sd_60d: 2, + mean_7d: 51, + resting_hr_mean_7d: 51, + }, + { + date: "2026-03-28", + hrv: 54, + resting_hr: 54, + mean_60d: 52, + sd_60d: 2, + mean_7d: 52, + resting_hr_mean_7d: 52, + }, + ], + }); + + expect( + result.healthStatus.find((status) => status.metric === "resting_heart_rate"), + ).toMatchObject({ + value: 54, + baseline: 52, + sampleDeviation: 2, + baselineProgress: { observedObservationDays: 3 }, + }); }); it("rounds HRV deviation to 2 decimal places", async () => { From 88d81006bb0f3582cbcdcfc9e371838ecf425c75 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Sun, 2 Aug 2026 00:52:49 -0700 Subject: [PATCH 3/4] fix(dashboard): surface baseline blockers --- .../repositories/daily-metrics-repository.ts | 2 +- .../routers/daily-metrics.integration.test.ts | 5 +- .../server/src/routers/daily-metrics.test.ts | 100 ++++++++++++------ packages/server/src/routers/daily-metrics.ts | 6 +- .../src/routers/mobile-dashboard.test.ts | 85 ++++++++++++++- .../routers/router-sql.integration.test.ts | 2 +- .../src/services/baseline-progress.test.ts | 16 +++ .../server/src/services/baseline-progress.ts | 6 +- .../server/src/services/health-status.test.ts | 34 ++++++ .../src/services/mobile-recovery-tab.test.ts | 23 +++- .../src/services/mobile-recovery-tab.ts | 2 +- .../DashboardEvidenceOverview.stories.tsx | 23 ++++ .../DashboardEvidenceOverview.test.tsx | 10 ++ .../components/HealthStatusBar.stories.tsx | 31 ++++++ .../src/components/HealthStatusBar.test.tsx | 47 ++++++++ src/db/schema/core.ts | 2 +- 16 files changed, 349 insertions(+), 45 deletions(-) diff --git a/packages/server/src/repositories/daily-metrics-repository.ts b/packages/server/src/repositories/daily-metrics-repository.ts index 6b6d8e356a..c63c30befa 100644 --- a/packages/server/src/repositories/daily-metrics-repository.ts +++ b/packages/server/src/repositories/daily-metrics-repository.ts @@ -250,7 +250,7 @@ export class DailyMetricsRepository extends BaseRepository { STDDEV(steps) AS stddev_steps, STDDEV(skin_temp_c) AS stddev_skin_temp, COUNT(hrv) AS sample_count_hrv, - COUNT(resting_hr) AS sample_count_resting_hr, + 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 diff --git a/packages/server/src/routers/daily-metrics.integration.test.ts b/packages/server/src/routers/daily-metrics.integration.test.ts index 2418dcd2f0..8c3a5dd522 100644 --- a/packages/server/src/routers/daily-metrics.integration.test.ts +++ b/packages/server/src/routers/daily-metrics.integration.test.ts @@ -17,7 +17,7 @@ import { makeMockSensorStore } from "./test-helpers.ts"; * cannot. */ describe("dailyMetrics data correctness", () => { - const staleViewUserId = "00000000-0000-0000-0000-000000000002"; + const staleViewUserId = "00000000-0000-4000-8000-000000000002"; let server: ReturnType; let baseUrl: string; let testCtx: TestContext; @@ -213,10 +213,13 @@ describe("dailyMetrics data correctness", () => { { date: subtractDays(endDate, 23), resting_hr: 57 }, { date: subtractDays(endDate, 22), resting_hr: 55 }, { date: subtractDays(endDate, 21), resting_hr: 85 }, + { date: subtractDays(endDate, 20), resting_hr: 0 }, + { date: subtractDays(endDate, 19), resting_hr: -1 }, ]), ); expect(result?.latest_resting_hr).toBe(56); + expect(result?.sample_count_resting_hr).toBe(4); }); it("returns all-null values when no data exists in the window", async () => { diff --git a/packages/server/src/routers/daily-metrics.test.ts b/packages/server/src/routers/daily-metrics.test.ts index b695a1541e..5fabac9746 100644 --- a/packages/server/src/routers/daily-metrics.test.ts +++ b/packages/server/src/routers/daily-metrics.test.ts @@ -2,12 +2,19 @@ import { describe, expect, it, vi } from "vitest"; import { collectSqlText, createTestCallerFactory, makeMockSensorStore } from "./test-helpers.ts"; type ProcessingStatusQuery = { datasets?: readonly ["recovery"] }; +type ProcessingStatus = "ready" | "active" | "failed"; +type ProcessingStatusResult = { + overallStatus: ProcessingStatus; + datasets: readonly [{ key: "recovery"; status: ProcessingStatus }]; +}; const processingStatusMock = vi.hoisted(() => - vi.fn(async (_input: ProcessingStatusQuery) => ({ - overallStatus: "ready" as const, - datasets: [{ key: "recovery" as const, status: "ready" as const }], - })), + vi.fn( + async (_input: ProcessingStatusQuery): Promise => ({ + overallStatus: "ready" as const, + datasets: [{ key: "recovery" as const, status: "ready" as const }], + }), + ), ); vi.mock("../trpc.ts", async () => { @@ -64,6 +71,37 @@ function makeCaller(rows: Record[] = []) { }); } +function makeTrendsRow(overrides: Record = {}) { + return { + avg_hrv: 60, + avg_resting_hr: 54, + avg_spo2: 98, + avg_steps: 8000, + avg_active_energy: 500, + avg_skin_temp: 36.5, + stddev_hrv: 10.5, + stddev_resting_hr: 2.5, + stddev_spo2: 0.5, + stddev_steps: 1200, + stddev_skin_temp: 0.3, + latest_hrv: 62, + latest_resting_hr: 53, + latest_spo2: 98, + latest_steps: 9000, + latest_active_energy: 550, + latest_skin_temp: 36.6, + sample_count_hrv: 4, + sample_count_resting_hr: 3, + sample_count_spo2: 4, + sample_count_steps: 4, + sample_count_skin_temp: 4, + latest_date: "2024-01-16", + latest_steps_date: "2024-01-16", + latest_active_energy_date: "2024-01-16", + ...overrides, + }; +} + describe("dailyMetricsRouter", () => { describe("list", () => { it("returns daily metric rows", async () => { @@ -255,35 +293,7 @@ describe("dailyMetricsRouter", () => { }); it("returns first row or null", async () => { - const rows = [ - { - avg_hrv: 60, - avg_resting_hr: 54, - avg_spo2: 98, - avg_steps: 8000, - avg_active_energy: 500, - avg_skin_temp: 36.5, - stddev_hrv: 10.5, - stddev_resting_hr: 2.5, - stddev_spo2: 0.5, - stddev_steps: 1200, - stddev_skin_temp: 0.3, - latest_hrv: 62, - latest_resting_hr: 53, - latest_spo2: 98, - latest_steps: 9000, - latest_active_energy: 550, - latest_skin_temp: 36.6, - sample_count_hrv: 4, - sample_count_resting_hr: 3, - sample_count_spo2: 4, - sample_count_steps: 4, - sample_count_skin_temp: 4, - latest_date: "2024-01-16", - latest_steps_date: "2024-01-16", - latest_active_energy_date: "2024-01-16", - }, - ]; + const rows = [makeTrendsRow()]; const caller = makeCaller(rows); const result = await caller.trends({ days: 30, endDate: "2024-01-16" }); expect(result).toEqual({ @@ -321,6 +331,30 @@ describe("dailyMetricsRouter", () => { }); }); + it.each([ + { rawStatus: "active" as const, normalizedStatus: "syncing" as const }, + { rawStatus: "failed" as const, normalizedStatus: "sync_error" as const }, + ])("surfaces a non-ready recovery processing status in resting heart rate baseline progress", async ({ + rawStatus, + normalizedStatus, + }) => { + processingStatusMock.mockResolvedValueOnce({ + overallStatus: rawStatus, + datasets: [{ key: "recovery", status: rawStatus }], + }); + + const result = await makeCaller([makeTrendsRow()]).trends({ + days: 30, + endDate: "2024-01-16", + }); + + expect( + result?.healthStatus.find((status) => status.metric === "resting_heart_rate"), + ).toMatchObject({ + baselineProgress: { blocker: normalizedStatus }, + }); + }); + it("returns canonical recovery baseline context with aggregate trends", async () => { const execute = vi.fn().mockResolvedValue([ { diff --git a/packages/server/src/routers/daily-metrics.ts b/packages/server/src/routers/daily-metrics.ts index 2ccd50d491..4ff75c1ae2 100644 --- a/packages/server/src/routers/daily-metrics.ts +++ b/packages/server/src/routers/daily-metrics.ts @@ -114,7 +114,11 @@ export const dailyMetricsRouter = router({ (metric) => metric.metric === "resting_heart_rate", ); if (!restingHeartRateStatus) { - throw new Error("Daily metric health status omitted resting heart rate"); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Daily metric health status omitted resting heart rate. Refresh the dashboard and try again.", + }); } return { diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 575369766d..80f1662c35 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -8,11 +8,18 @@ import { const cachedQueryOptions = vi.hoisted((): Array<{ maxAge: number; keyVersion?: string }> => []); type ProcessingStatusQuery = { datasets?: readonly ["recovery"] }; +type ProcessingStatus = "ready" | "active" | "failed"; +type ProcessingStatusResult = { + overallStatus: ProcessingStatus; + datasets: readonly [{ key: "recovery"; status: ProcessingStatus }]; +}; const processingStatusMock = vi.hoisted(() => - vi.fn(async (_input: ProcessingStatusQuery) => ({ - overallStatus: "ready" as const, - datasets: [{ key: "recovery" as const, status: "ready" as const }], - })), + vi.fn( + async (_input: ProcessingStatusQuery): Promise => ({ + overallStatus: "ready" as const, + datasets: [{ key: "recovery" as const, status: "ready" as const }], + }), + ), ); vi.mock("../trpc.ts", async () => { @@ -190,6 +197,44 @@ const fullAccessWindow = { reason: "paid_grant" as const, }; +function emptyRecoveryTabResult(): import("../services/mobile-recovery-tab.ts").MobileRecoveryTabResult { + return { + hrvVariability: [], + hrvBaseline: [], + readinessScore: [], + stress: { daily: [], weekly: [], latestScore: null, trend: "stable" }, + trends: null, + dailyMetrics: [], + baselineRelative: [], + weight: [], + weightPrediction: { + ratePerWeek: null, + rateConfidence: null, + impliedDailyCalories: null, + periodDeltas: { days7: null, days14: null, days30: null }, + goal: null, + projectionLine: [], + }, + healthStatus: [], + healthspan: { + healthspanScore: null, + yearsDelta: null, + availability: { + status: "insufficient_data", + availableMetricCount: 0, + requiredMetricCount: 3, + missingMetricLabels: [], + summary: "0 of 3 required Healthspan metrics are available.", + nextCondition: + "The score becomes available after 3 more supported metrics sync successfully.", + }, + metrics: [], + history: [], + trend: null, + }, + }; +} + describe("mobileDashboard.dashboardV2", () => { it("fails loudly when ClickHouse activity analytics are unavailable", async () => { const caller = createCaller({ @@ -851,6 +896,38 @@ describe("mobileDashboard.recovery", () => { expect(processingStatusMock).toHaveBeenCalledWith({ datasets: ["recovery"] }); }); + it.each([ + { rawStatus: "active" as const, normalizedStatus: "syncing" as const }, + { rawStatus: "failed" as const, normalizedStatus: "sync_error" as const }, + ])("passes the normalized $normalizedStatus status to the recovery tab loader", async ({ + rawStatus, + normalizedStatus, + }) => { + processingStatusMock.mockResolvedValueOnce({ + overallStatus: rawStatus, + datasets: [{ key: "recovery", status: rawStatus }], + }); + const loadSpy = vi + .spyOn(mobileRecoveryTab, "loadMobileRecoveryTab") + .mockResolvedValue(emptyRecoveryTabResult()); + + const caller = createCaller({ + db: { execute: vi.fn().mockResolvedValue([]), transaction: vi.fn() }, + userId: "user-1", + timezone: "UTC", + accessWindow: fullAccessWindow, + sensorStore: makeSensorStore(), + }); + + await caller.recovery({ days: 30, endDate: "2026-03-28" }); + + expect(loadSpy).toHaveBeenCalledWith( + expect.objectContaining({ processingStatus: normalizedStatus }), + 30, + "2026-03-28", + ); + }); + it("returns consolidated recovery tab data", async () => { const query = vi.fn(async (_schema: unknown, sqlText: unknown) => { if (String(sqlText).includes("analytics.daily_recovery")) { diff --git a/packages/server/src/routers/router-sql.integration.test.ts b/packages/server/src/routers/router-sql.integration.test.ts index ec497741ba..e9276b037b 100644 --- a/packages/server/src/routers/router-sql.integration.test.ts +++ b/packages/server/src/routers/router-sql.integration.test.ts @@ -21,7 +21,7 @@ describe("Router SQL validity", () => { testCtx = await setupTestDatabase(); // Create a session for the fixture user so protected procedures work - const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; + const TEST_USER_ID = "00000000-0000-4000-8000-000000000001"; const session = await createSession(testCtx.db, TEST_USER_ID); sessionCookie = `session=${session.sessionId}`; diff --git a/packages/server/src/services/baseline-progress.test.ts b/packages/server/src/services/baseline-progress.test.ts index 51e8e771e5..ed5d38d982 100644 --- a/packages/server/src/services/baseline-progress.test.ts +++ b/packages/server/src/services/baseline-progress.test.ts @@ -47,6 +47,22 @@ describe("buildBaselineProgress", () => { }); }); + it("distinguishes a missing current value from an empty baseline window", () => { + expect( + buildBaselineProgress({ + ...input, + value: null, + observedDays: 1, + sampleDeviation: null, + }), + ).toMatchObject({ + observedObservationDays: 1, + blocker: "missing_source_data", + summary: "No current Resting Heart Rate value is available yet.", + action: "Sync resting heart rate data again to record a current value.", + }); + }); + it("distinguishes enough identical observations from missing observations", () => { expect( buildBaselineProgress({ diff --git a/packages/server/src/services/baseline-progress.ts b/packages/server/src/services/baseline-progress.ts index 27ff03a336..a981dab527 100644 --- a/packages/server/src/services/baseline-progress.ts +++ b/packages/server/src/services/baseline-progress.ts @@ -61,10 +61,14 @@ export function buildBaselineProgress(input: BaselineProgressInput): BaselinePro blocker = "sync_error"; summary = `${input.label} baseline data could not sync.`; action = "Reconnect the data source and start the sync again."; - } else if (observedDays === 0 || input.value == null) { + } else if (observedDays === 0) { blocker = "missing_source_data"; summary = missingSourceSummary(input.label); action = missingSourceAction(input.label); + } else if (input.value == null) { + blocker = "missing_source_data"; + summary = `No current ${input.label} value is available yet.`; + action = `Sync ${input.label.toLowerCase()} data again to record a current value.`; } else if (observedDays < BASELINE_REQUIRED_OBSERVATION_DAYS) { blocker = "collecting"; summary = progressSummary(input.label, observedDays); diff --git a/packages/server/src/services/health-status.test.ts b/packages/server/src/services/health-status.test.ts index 45b86bdf09..472c8f0aef 100644 --- a/packages/server/src/services/health-status.test.ts +++ b/packages/server/src/services/health-status.test.ts @@ -295,6 +295,40 @@ describe("buildHealthStatusFromSummary", () => { }); }); + it.each([ + { + processingStatus: "syncing" as const, + blocker: "syncing" as const, + summary: "Resting Heart Rate baseline data is still syncing.", + action: "Wait for the sync to finish, then check your baseline again.", + }, + { + processingStatus: "sync_error" as const, + blocker: "sync_error" as const, + summary: "Resting Heart Rate baseline data could not sync.", + action: "Reconnect the data source and start the sync again.", + }, + ])("surfaces the $processingStatus baseline processing state", (fixture) => { + expect( + buildHealthStatusFromSummary({ + metric: "resting_heart_rate", + label: "Resting Heart Rate", + value: null, + baseline: null, + sampleDeviation: null, + intent: "lower", + observedDays: 0, + processingStatus: fixture.processingStatus, + }), + ).toMatchObject({ + baselineProgress: { + blocker: fixture.blocker, + summary: fixture.summary, + action: fixture.action, + }, + }); + }); + it.each([ { metric: "hrv" as const, diff --git a/packages/server/src/services/mobile-recovery-tab.test.ts b/packages/server/src/services/mobile-recovery-tab.test.ts index 01258c76d9..7ae0dd56d7 100644 --- a/packages/server/src/services/mobile-recovery-tab.test.ts +++ b/packages/server/src/services/mobile-recovery-tab.test.ts @@ -107,6 +107,7 @@ async function runRecoveryTab( goalWeight?: string | null; days?: number; endDate?: string; + processingStatus?: "syncing" | "sync_error" | null; } = {}, ) { const query = vi.fn(async (_schema: unknown, sqlText: unknown) => { @@ -121,6 +122,7 @@ async function runRecoveryTab( timezone: "UTC", accessWindow: { kind: "full" as const, paid: true as const, reason: "paid_grant" as const }, sensorStore: { query }, + processingStatus: options.processingStatus ?? null, }; vi.spyOn( @@ -582,7 +584,7 @@ describe("loadMobileRecoveryTab", () => { expect(vi.mocked(buildHealthStatusFromValues).mock.calls.map(([input]) => input)).toEqual([ { metric: "spo2", - label: "SpO2", + label: "Blood Oxygen Saturation (SpO2)", values: [97, 99], intent: "neutral", processingStatus: null, @@ -608,6 +610,25 @@ describe("loadMobileRecoveryTab", () => { ).toMatchObject({ value: 52, baseline: 54, sampleDeviation: 2 }); }); + it.each([ + "syncing", + "sync_error", + ] as const)("passes %s to every health status builder", async (processingStatus) => { + vi.mocked(buildHealthStatusFromBaselineMetric).mockClear(); + vi.mocked(buildHealthStatusFromValues).mockClear(); + vi.mocked(buildWeightHealthStatus).mockClear(); + + await runRecoveryTab([recoveryRow()], { processingStatus }); + + expect( + vi.mocked(buildHealthStatusFromBaselineMetric).mock.calls.map(([, status]) => status), + ).toEqual([processingStatus, processingStatus, processingStatus, processingStatus]); + expect( + vi.mocked(buildHealthStatusFromValues).mock.calls.map(([input]) => input.processingStatus), + ).toEqual([processingStatus, processingStatus, processingStatus]); + expect(vi.mocked(buildWeightHealthStatus)).toHaveBeenCalledWith([], null, processingStatus); + }); + it("builds the resting heart rate status from HRV baseline rows when recovery data is absent", async () => { const result = await runRecoveryTab([], { hrvBaseline: [ diff --git a/packages/server/src/services/mobile-recovery-tab.ts b/packages/server/src/services/mobile-recovery-tab.ts index 085ed3773a..7b7b9b22e5 100644 --- a/packages/server/src/services/mobile-recovery-tab.ts +++ b/packages/server/src/services/mobile-recovery-tab.ts @@ -263,7 +263,7 @@ export async function loadMobileRecoveryTab( restingHeartRateStatus, buildHealthStatusFromValues({ metric: "spo2", - label: "SpO2", + label: "Blood Oxygen Saturation (SpO2)", values: dailyMetrics.flatMap((row) => (row.spo2_avg == null ? [] : [row.spo2_avg])), intent: "neutral", processingStatus, diff --git a/packages/web/src/components/DashboardEvidenceOverview.stories.tsx b/packages/web/src/components/DashboardEvidenceOverview.stories.tsx index c254f6d39f..c329235170 100644 --- a/packages/web/src/components/DashboardEvidenceOverview.stories.tsx +++ b/packages/web/src/components/DashboardEvidenceOverview.stories.tsx @@ -133,3 +133,26 @@ export const Empty: Story = { healthMonitor:

No recent health metrics yet.

, }, }; + +export const BlockedBaseline: Story = { + args: { + trend: { + latestRestingHeartRate: null, + averageRestingHeartRate: null, + restingHeartRateTrendLabel: "Waiting for baseline", + restingHeartRateBaselineProgress: { + 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.", + }, + restingHeartRatePoints: null, + }, + healthMonitor:

Baseline evidence is collecting.

, + }, +}; diff --git a/packages/web/src/components/DashboardEvidenceOverview.test.tsx b/packages/web/src/components/DashboardEvidenceOverview.test.tsx index 9dfbde2d17..aeca0a83aa 100644 --- a/packages/web/src/components/DashboardEvidenceOverview.test.tsx +++ b/packages/web/src/components/DashboardEvidenceOverview.test.tsx @@ -29,6 +29,16 @@ describe("DashboardEvidenceOverview helpers", () => { ).toBe("Waiting for baseline"); }); + it.each([null, undefined])("falls back when the server trend label is %s", (label) => { + expect( + trendPositionLabel({ + latestRestingHeartRate: 52, + averageRestingHeartRate: 56, + restingHeartRateTrendLabel: label, + }), + ).toBe("Waiting for baseline"); + }); + it("renders the server-authored baseline requirement, progress, and action", () => { render( { + it("renders the server-authored blocked baseline requirement and action", () => { + render( + , + ); + + expect(screen.getByText("1 of 3 required days recorded")).toBeDefined(); + expect( + screen.getByText( + "A current value plus at least 2 more recorded days with measurable variation.", + ), + ).toBeDefined(); + expect( + screen.getByText( + "Skin Temperature has 1 of 3 required days recorded; the baseline is still collecting observations.", + ), + ).toBeDefined(); + expect( + screen.getByText("Keep syncing skin temperature data for at least 2 more days."), + ).toBeDefined(); + }); + it("renders server-authored HRV and steps text instead of recomputing raw values", () => { render( Date: Sun, 2 Aug 2026 01:12:54 -0700 Subject: [PATCH 4/4] test(integration): isolate dashboard UUID fixtures --- .../routers/daily-metrics.integration.test.ts | 33 ++++++++++++------- .../routers/router-sql.integration.test.ts | 11 +++++++ src/db/schema/core.ts | 2 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/server/src/routers/daily-metrics.integration.test.ts b/packages/server/src/routers/daily-metrics.integration.test.ts index 8c3a5dd522..9cf11a7e30 100644 --- a/packages/server/src/routers/daily-metrics.integration.test.ts +++ b/packages/server/src/routers/daily-metrics.integration.test.ts @@ -1,7 +1,6 @@ import { queryCache } from "dofek/lib/cache"; import { sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { TEST_USER_ID } from "../../../../src/db/schema/core.ts"; import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts"; import { createSession } from "../auth/session.ts"; import { createApp } from "../index.ts"; @@ -17,6 +16,7 @@ import { makeMockSensorStore } from "./test-helpers.ts"; * cannot. */ describe("dailyMetrics data correctness", () => { + const dashboardTestUserId = "00000000-0000-4000-8000-000000000001"; const staleViewUserId = "00000000-0000-4000-8000-000000000002"; let server: ReturnType; let baseUrl: string; @@ -33,7 +33,18 @@ describe("dailyMetrics data correctness", () => { testCtx = await setupTestDatabase(); await queryCache.invalidateAll(); - const session = await createSession(testCtx.db, TEST_USER_ID); + await testCtx.db.execute( + sql`INSERT INTO fitness.user_profile (id, name) + VALUES (${dashboardTestUserId}, 'Dashboard Test User') + ON CONFLICT (id) DO NOTHING`, + ); + await testCtx.db.execute( + sql`INSERT INTO fitness.user_billing (user_id, paid_grant_reason) + VALUES (${dashboardTestUserId}, 'existing_account') + ON CONFLICT (user_id) DO NOTHING`, + ); + + const session = await createSession(testCtx.db, dashboardTestUserId); sessionCookie = `session=${session.sessionId}`; // Get the DB's current date so endDate is consistent with inserted data @@ -45,17 +56,17 @@ describe("dailyMetrics data correctness", () => { // Insert provider await testCtx.db.execute( sql`INSERT INTO fitness.provider (id, name, user_id) - VALUES ('apple_health', 'Apple Health', ${TEST_USER_ID}) + VALUES ('apple_health', 'Apple Health', ${dashboardTestUserId}) ON CONFLICT DO NOTHING`, ); await testCtx.db.execute( sql`INSERT INTO fitness.provider (id, name, user_id) - VALUES ('garmin', 'Garmin Connect', ${TEST_USER_ID}) + VALUES ('garmin', 'Garmin Connect', ${dashboardTestUserId}) ON CONFLICT DO NOTHING`, ); - // ── Insert 30 days of daily metrics from apple_health with real data ── - for (let i = 30; i >= 4; i--) { + // ── Insert 27 health-data days inside the 30-day window ── + for (let i = 29; i >= 3; i--) { const hrv = 50 + Math.round(Math.sin(i * 0.3) * 10); const steps = 8000 + Math.round(Math.sin(i) * 2000); const spo2 = 96 + Math.round(Math.sin(i * 0.5) * 2); @@ -64,21 +75,21 @@ describe("dailyMetrics data correctness", () => { date, provider_id, user_id, hrv, steps, spo2_avg ) VALUES ( CURRENT_DATE - ${i}::int, - 'apple_health', ${TEST_USER_ID}, ${hrv}, ${steps}, ${spo2} + 'apple_health', ${dashboardTestUserId}, ${hrv}, ${steps}, ${spo2} ) ON CONFLICT DO NOTHING`, ); } - // ── Days 3, 2, 1, 0: garmin creates rows with NO health metrics ── + // ── Days 2, 1, 0: garmin creates rows with NO health metrics ── // This simulates the production bug where garmin sync creates empty rows // for recent days, making latest_date point to a row with no actual data. - for (let i = 3; i >= 0; i--) { + for (let i = 2; i >= 0; i--) { await testCtx.db.execute( sql`INSERT INTO fitness.daily_metrics ( date, provider_id, user_id, distance_km ) VALUES ( CURRENT_DATE - ${i}::int, - 'garmin', ${TEST_USER_ID}, 0 + 'garmin', ${dashboardTestUserId}, 0 ) ON CONFLICT DO NOTHING`, ); } @@ -204,7 +215,7 @@ describe("dailyMetrics data correctness", () => { }); it("uses a representative recent resting heart rate instead of one noisy latest night", async () => { - const repo = new DailyMetricsRepository(testCtx.db, TEST_USER_ID, "UTC"); + const repo = new DailyMetricsRepository(testCtx.db, dashboardTestUserId, "UTC"); const result = await repo.getTrends( 30, endDate, diff --git a/packages/server/src/routers/router-sql.integration.test.ts b/packages/server/src/routers/router-sql.integration.test.ts index e9276b037b..46054c7603 100644 --- a/packages/server/src/routers/router-sql.integration.test.ts +++ b/packages/server/src/routers/router-sql.integration.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { setupTestDatabase, type TestContext } from "../../../../src/db/test-helpers.ts"; import { createSession } from "../auth/session.ts"; @@ -22,6 +23,16 @@ describe("Router SQL validity", () => { // Create a session for the fixture user so protected procedures work const TEST_USER_ID = "00000000-0000-4000-8000-000000000001"; + await testCtx.db.execute( + sql`INSERT INTO fitness.user_profile (id, name) + VALUES (${TEST_USER_ID}, 'Router SQL Test User') + ON CONFLICT (id) DO NOTHING`, + ); + await testCtx.db.execute( + sql`INSERT INTO fitness.user_billing (user_id, paid_grant_reason) + VALUES (${TEST_USER_ID}, 'existing_account') + ON CONFLICT (user_id) DO NOTHING`, + ); const session = await createSession(testCtx.db, TEST_USER_ID); sessionCookie = `session=${session.sessionId}`; diff --git a/src/db/schema/core.ts b/src/db/schema/core.ts index c2e9b73f83..907ab30a51 100644 --- a/src/db/schema/core.ts +++ b/src/db/schema/core.ts @@ -5,7 +5,7 @@ import { getTokenUserId } from "../token-user-context.ts"; export const fitness = pgSchema("fitness"); // Stable user ID used in integration tests and fixtures. -export const TEST_USER_ID = "00000000-0000-4000-8000-000000000001"; +export const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; export function resolveImplicitUserId(): string { const userId = getTokenUserId();