diff --git a/packages/mobile/app/(tabs)/index.stories.tsx b/packages/mobile/app/(tabs)/index.stories.tsx index 6930d1dd02..1901a9f60a 100644 --- a/packages/mobile/app/(tabs)/index.stories.tsx +++ b/packages/mobile/app/(tabs)/index.stories.tsx @@ -72,10 +72,12 @@ function createSeededProviders(sleepDataUnavailable: boolean) { sleepNeed: sleepDataUnavailable ? { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: MISSING_PREVIOUS_NIGHT_MESSAGE, } : { availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 480, strainDebtMinutes: 16, accumulatedDebtMinutes: 28, @@ -114,6 +116,7 @@ function createSeededProviders(sleepDataUnavailable: boolean) { [["todayPlan", "get"], { input: { endDate: todayDate }, type: "query" }], { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: todayDate, action: { id: "strain_target", diff --git a/packages/mobile/app/(tabs)/index.test.tsx b/packages/mobile/app/(tabs)/index.test.tsx index 4129e41282..75607d66cd 100644 --- a/packages/mobile/app/(tabs)/index.test.tsx +++ b/packages/mobile/app/(tabs)/index.test.tsx @@ -182,6 +182,7 @@ describe("TodayScreen independent loading states", () => { mockTodayPlanError = null; mockTodayPlanData = { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-03-21", action: { id: "strain_target", @@ -226,6 +227,7 @@ describe("TodayScreen independent loading states", () => { }, sleepNeed: { availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 480, strainDebtMinutes: 20, accumulatedDebtMinutes: 10, @@ -420,6 +422,7 @@ describe("TodayScreen independent loading states", () => { }, sleepNeed: { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }, }; @@ -440,6 +443,25 @@ describe("TodayScreen independent loading states", () => { expect(screen.queryByText("8h 37m")).toBeNull(); }); + it("keeps a legacy cached sleep prerequisite visible without a status", async () => { + mockDashboardData = { + ...mockDashboardData, + sleep: { lastNight: null, sleepDebt: 0 }, + sleepNeed: { + availability: "missing_previous_night", + message: "Sync last night's sleep data to see tonight's sleep need.", + }, + }; + + const { default: TodayScreen } = await import("./index"); + render(); + + expect( + screen.getByText("Sync last night's sleep data to see tonight's sleep need."), + ).toBeTruthy(); + expect(screen.queryByText("Unavailable")).toBeNull(); + }); + it("renders the server-authored next action for insufficient sleep need data", async () => { mockDashboardData = { ...mockDashboardData, @@ -457,6 +479,7 @@ describe("TodayScreen independent loading states", () => { }, sleepNeed: { availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", diff --git a/packages/mobile/app/(tabs)/index.tsx b/packages/mobile/app/(tabs)/index.tsx index ede98517d1..16d7a4b202 100644 --- a/packages/mobile/app/(tabs)/index.tsx +++ b/packages/mobile/app/(tabs)/index.tsx @@ -282,6 +282,7 @@ export default function TodayScreen() { .easing(Easing.bezier(0.16, 1, 0.3, 1))} > + {sleepNeed.epistemicStatus?.label} {sleepNeed.message} @@ -340,6 +341,7 @@ export default function TodayScreen() { No sleep data ) : sleepNeed.availability === "available" ? ( <> + {sleepNeed.epistemicStatus.label} {`${sleepNeed.estimateMetadata.valueQualifier} ${formatDurationMinutes(sleepNeed.totalNeedMinutes)}`} @@ -392,6 +394,7 @@ export default function TodayScreen() { ) : ( <> + {sleepNeed.epistemicStatus.label} {sleepNeed.message} {sleepNeed.nextAction} diff --git a/packages/mobile/app/(tabs)/recovery.stories.tsx b/packages/mobile/app/(tabs)/recovery.stories.tsx index 0f5e7e3af1..4ae0ce2bf1 100644 --- a/packages/mobile/app/(tabs)/recovery.stories.tsx +++ b/packages/mobile/app/(tabs)/recovery.stories.tsx @@ -178,7 +178,9 @@ function createSeededProviders(healthspanInsufficient = false, recoveryUnavailab weight: trendDates.map((date, index) => ({ date, rawWeight: 74.2 - index * 0.05, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 74.2 - index * 0.05, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -0.2, interpolated: false, })), diff --git a/packages/mobile/app/(tabs)/recovery.test.tsx b/packages/mobile/app/(tabs)/recovery.test.tsx index 18661116fc..b1c17dd35c 100644 --- a/packages/mobile/app/(tabs)/recovery.test.tsx +++ b/packages/mobile/app/(tabs)/recovery.test.tsx @@ -616,14 +616,18 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { { date: "2026-04-05", rawWeight: 80.2, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 79.8, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-04-06", rawWeight: 80, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 79.8, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, @@ -636,12 +640,40 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { expect(screen.getByText("TREND WEIGHT")).toBeTruthy(); expect(screen.getByText("79.8 kg")).toBeTruthy(); - expect(screen.getByText("Scale: 80.0 kg")).toBeTruthy(); + expect(screen.getByText("Estimated")).toBeTruthy(); + expect(screen.getByText("Observed: 80.0 kg")).toBeTruthy(); expect( screen.getByText("Moves 10% toward each day's scale weight; gaps are interpolated."), ).toBeTruthy(); }); + it("keeps a legacy cached weight row visible without epistemic statuses", async () => { + mockRecoveryData = { + hrvVariability: [], + hrvBaseline: [], + readinessScore: [], + stress: { daily: [], weekly: [], latestScore: null, trend: "stable" }, + trends: null, + dailyMetrics: [], + weight: [ + { + date: "2026-04-06", + rawWeight: 80, + smoothedWeight: 79.8, + weeklyChange: null, + interpolated: false, + }, + ], + healthspan: insufficientHealthspan, + }; + + const { default: RecoveryScreen } = await import("./recovery"); + render(); + + expect(screen.getByText("79.8 kg")).toBeTruthy(); + expect(screen.queryByText("Estimated")).toBeNull(); + }); + it("uses neutral text for weight-rate direction", async () => { mockRecoveryData = { hrvVariability: [], @@ -654,7 +686,9 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { { date: "2026-04-06", rawWeight: 80, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 79.8, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, diff --git a/packages/mobile/app/(tabs)/recovery.tsx b/packages/mobile/app/(tabs)/recovery.tsx index 1630ee0106..8a01ae4277 100644 --- a/packages/mobile/app/(tabs)/recovery.tsx +++ b/packages/mobile/app/(tabs)/recovery.tsx @@ -614,9 +614,11 @@ export default function RecoveryScreen() { {formatMeasurementText(units.formatWeight(latestWeight.smoothedWeight))} - {latestWeight.rawWeight != null && ( + {latestWeight.smoothedWeightStatus?.label} + {latestWeight.rawWeight != null && latestWeight.rawWeightStatus != null && ( - Scale: {formatMeasurementText(units.formatWeight(latestWeight.rawWeight))} + {latestWeight.rawWeightStatus.label}:{" "} + {formatMeasurementText(units.formatWeight(latestWeight.rawWeight))} )} diff --git a/packages/mobile/app/correlation.stories.tsx b/packages/mobile/app/correlation.stories.tsx index 4dfcd13506..9650c0ed31 100644 --- a/packages/mobile/app/correlation.stories.tsx +++ b/packages/mobile/app/correlation.stories.tsx @@ -35,6 +35,7 @@ const metrics = [ const availableResult = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.72, regression: { slope: 0.42, intercept: 8, rSquared: 0.46 }, dataPoints: [ @@ -73,6 +74,7 @@ const availableResult = { const insufficientResult = { analysisVersion: 2, availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, diff --git a/packages/mobile/app/correlation.test.tsx b/packages/mobile/app/correlation.test.tsx index 5705306962..c648ef863e 100644 --- a/packages/mobile/app/correlation.test.tsx +++ b/packages/mobile/app/correlation.test.tsx @@ -150,6 +150,7 @@ describe("CorrelationScreen", () => { state.correlationData = { analysisVersion: 2, availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, @@ -243,6 +244,7 @@ describe("CorrelationScreen", () => { render(); expect(screen.getByText("n = 0")).toBeTruthy(); + expect(screen.getByText("Unavailable")).toBeTruthy(); expect(screen.getByText("5 more paired calendar days needed")).toBeTruthy(); expect(screen.getByText("90 selected")).toBeTruthy(); expect(screen.getByText("90 missing pairs")).toBeTruthy(); @@ -256,6 +258,18 @@ describe("CorrelationScreen", () => { expect(screen.queryByText(/^p =/)).toBeNull(); }); + it("renders a legacy cached result without an epistemic status", async () => { + const legacyCorrelationData = { ...(state.correlationData ?? {}) }; + delete legacyCorrelationData.epistemicStatus; + state.correlationData = legacyCorrelationData; + + const { default: CorrelationScreen } = await import("./correlation"); + render(); + + expect(screen.getByText("n = 0")).toBeTruthy(); + expect(screen.queryByText("Unavailable")).toBeNull(); + }); + it("uses singular sample wording when one additional sample is required", async () => { state.correlationData = { ...state.correlationData, @@ -274,6 +288,7 @@ describe("CorrelationScreen", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.75, regression: { slope: 1, intercept: 0, rSquared: 0.49 }, dataPoints: [], @@ -306,6 +321,7 @@ describe("CorrelationScreen", () => { render(); expect(screen.getByText("Spearman rho = +0.75")).toBeTruthy(); + expect(screen.getByText("Associated")).toBeTruthy(); expect(screen.getByText("95% block-bootstrap interval: +0.42 to +0.86")).toBeTruthy(); expect(screen.getByText("Slope = 1.000 ms per g")).toBeTruthy(); expect(screen.getByText("R² = 0.490")).toBeTruthy(); @@ -319,6 +335,7 @@ describe("CorrelationScreen", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: -0.75, regression: { slope: -1, intercept: 3, rSquared: 0.49 }, dataPoints: [ @@ -372,6 +389,7 @@ describe("CorrelationScreen", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.75, regression: { slope: 1, intercept: 0, rSquared: 0.49 }, dataPoints: [ diff --git a/packages/mobile/app/correlation.tsx b/packages/mobile/app/correlation.tsx index fb206ab118..fa7c45d01e 100644 --- a/packages/mobile/app/correlation.tsx +++ b/packages/mobile/app/correlation.tsx @@ -689,6 +689,7 @@ export default function CorrelationScreen() { description="The rank correlation, dependence-aware interval, and calendar-day coverage for the selected metrics." textStyle={styles.cardTitle} /> + {data.epistemicStatus?.label} {data.availability === "available" ? ( <> diff --git a/packages/mobile/components/TodayPlanCard.stories.tsx b/packages/mobile/components/TodayPlanCard.stories.tsx index 1d8337b5e7..7c1958130c 100644 --- a/packages/mobile/components/TodayPlanCard.stories.tsx +++ b/packages/mobile/components/TodayPlanCard.stories.tsx @@ -4,6 +4,7 @@ import { TodayPlanCard } from "./TodayPlanCard"; const readyPlan: TodayPlanResult = { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-07-26", action: { id: "strain_target", @@ -26,6 +27,7 @@ const readyPlan: TodayPlanResult = { const insufficientPlan: TodayPlanResult = { status: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, date: "2026-07-26", action: null, supportingFacts: [], diff --git a/packages/mobile/components/TodayPlanCard.test.tsx b/packages/mobile/components/TodayPlanCard.test.tsx index ee8b92b2dd..2e7746685c 100644 --- a/packages/mobile/components/TodayPlanCard.test.tsx +++ b/packages/mobile/components/TodayPlanCard.test.tsx @@ -5,6 +5,7 @@ import { TodayPlanCard } from "./TodayPlanCard"; const readyPlan: TodayPlanResult = { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-07-26", action: { id: "strain_target", @@ -27,6 +28,7 @@ const readyPlan: TodayPlanResult = { const insufficientPlan: TodayPlanResult = { status: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, date: "2026-07-26", action: null, supportingFacts: [], @@ -60,6 +62,7 @@ describe("TodayPlanCard", () => { expect(screen.getByText("Sleep performance")).toBeTruthy(); expect(screen.getByText("88 (Good)")).toBeTruthy(); expect(screen.getByText("High confidence")).toBeTruthy(); + expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByText(/Recovery data from 2026-07-26/)).toBeTruthy(); expect(screen.getByText(/Sleep data from 2026-07-26/)).toBeTruthy(); }); @@ -86,6 +89,7 @@ describe("TodayPlanCard", () => { "Connect a recovery source and wait for today's recovery score before a training plan can be generated.", ), ).toBeTruthy(); + expect(screen.getByText("Unavailable")).toBeTruthy(); }); it("renders a loading state", () => { @@ -106,4 +110,22 @@ describe("TodayPlanCard", () => { expect(screen.getByText("Train hard today — aim for 16.2 strain")).toBeTruthy(); expect(screen.getByText("Today plan refresh failed")).toBeTruthy(); }); + + it("keeps a legacy cached ready plan visible without a status", () => { + const legacyPlan = { ...readyPlan }; + Reflect.deleteProperty(legacyPlan, "epistemicStatus"); + render(); + + expect(screen.getByText("Train hard today — aim for 16.2 strain")).toBeTruthy(); + expect(screen.queryByText("Suggested")).toBeNull(); + }); + + it("keeps a legacy cached insufficient plan visible without a status", () => { + const legacyPlan = { ...insufficientPlan }; + Reflect.deleteProperty(legacyPlan, "epistemicStatus"); + render(); + + expect(screen.getByText("WHAT MATTERS TODAY")).toBeTruthy(); + expect(screen.queryByText("Unavailable")).toBeNull(); + }); }); diff --git a/packages/mobile/components/TodayPlanCard.tsx b/packages/mobile/components/TodayPlanCard.tsx index f3c5723ce5..1a04fd1148 100644 --- a/packages/mobile/components/TodayPlanCard.tsx +++ b/packages/mobile/components/TodayPlanCard.tsx @@ -58,6 +58,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro WHAT MATTERS TODAY {refreshWarning} {plan.message} + {plan.epistemicStatus?.label} {formatTodayPlanConfidence(plan.confidence)} ); @@ -106,6 +107,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro ) : null} ) : null} + {plan.epistemicStatus?.label} {formatTodayPlanConfidence(plan.confidence)} {freshness != null ? {freshness} : null} diff --git a/packages/mobile/lib/mobile-query-persistence.test.tsx b/packages/mobile/lib/mobile-query-persistence.test.tsx index 5b1409a0e9..cf4ecfce8a 100644 --- a/packages/mobile/lib/mobile-query-persistence.test.tsx +++ b/packages/mobile/lib/mobile-query-persistence.test.tsx @@ -7,6 +7,7 @@ import { import { beforeEach, describe, expect, it } from "vitest"; import { createMobileQueryPersister, + MOBILE_QUERY_CACHE_CONTRACT_VERSION, MOBILE_QUERY_CACHE_MAX_PERSISTED_BYTES, mobileQueryCacheBuster, removeMobileQueryCache, @@ -26,6 +27,11 @@ describe("mobile query persistence", () => { await AsyncStorage.clear(); }); + it("uses a new cache contract buster for epistemic status fields", () => { + expect(MOBILE_QUERY_CACHE_CONTRACT_VERSION).toBe(7); + expect(mobileQueryCacheBuster("user-1")).toBe("user-1:v7"); + }); + it("restores persisted user data before the query refetches", async () => { let resolveRefetch: ((value: { readiness: string }) => void) | undefined; const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default; @@ -71,7 +77,7 @@ describe("mobile query persistence", () => { "dofek-query-cache:user-1", JSON.stringify({ timestamp: Date.now(), - buster: "user-1:v5", + buster: "user-1:v6", clientState: dehydrate(seedClient), }), ); diff --git a/packages/mobile/lib/mobile-query-persistence.ts b/packages/mobile/lib/mobile-query-persistence.ts index 316ad581cc..1635d2af9a 100644 --- a/packages/mobile/lib/mobile-query-persistence.ts +++ b/packages/mobile/lib/mobile-query-persistence.ts @@ -6,7 +6,7 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client import { createElement, type ReactNode, useMemo } from "react"; import { captureException } from "./telemetry"; -export const MOBILE_QUERY_CACHE_CONTRACT_VERSION = 6; +export const MOBILE_QUERY_CACHE_CONTRACT_VERSION = 7; export const MOBILE_QUERY_CACHE_MAX_PERSISTED_BYTES = 5 * 1024 * 1024; export function mobileQueryCacheBuster(userId: string) { diff --git a/packages/scoring/README.md b/packages/scoring/README.md index f76ecbd630..24c3fa764c 100644 --- a/packages/scoring/README.md +++ b/packages/scoring/README.md @@ -40,6 +40,7 @@ console.log({ | `@dofek/scoring/tokens` | Framework-neutral typography, spacing, radius, animation, and chart tokens | | `@dofek/scoring/strain-target` | Daily strain-target calculation and result types | | `@dofek/scoring/today-plan` | Deterministic ready/insufficient-data Today Plan result with a primary action, supporting facts, server-authored caveats, confidence, freshness, and shared presentation helpers | +| `@dofek/scoring/epistemic-status` | Shared Observed / Estimated / Associated / Suggested / Unavailable status vocabulary | | `@dofek/scoring/sleep-performance` | Sleep-performance components, tiers, and recommended-bedtime calculation | | `@dofek/scoring/healthspan-years` | Score-to-years mapping and formatting | | `@dofek/scoring/menstrual-cycle` | Cycle-phase estimation, display metadata, and shared safety copy | diff --git a/packages/scoring/package.json b/packages/scoring/package.json index cc13415fa8..775c7acb1d 100644 --- a/packages/scoring/package.json +++ b/packages/scoring/package.json @@ -23,6 +23,7 @@ "./tokens": "./src/tokens.ts", "./strain-target": "./src/strain-target.ts", "./today-plan": "./src/today-plan.ts", + "./epistemic-status": "./src/epistemic-status.ts", "./sleep-performance": "./src/sleep-performance.ts", "./healthspan-years": "./src/healthspan-years.ts", "./menstrual-cycle": "./src/menstrual-cycle.ts", @@ -53,6 +54,10 @@ "types": "./dist/today-plan.d.ts", "import": "./dist/today-plan.js" }, + "./epistemic-status": { + "types": "./dist/epistemic-status.d.ts", + "import": "./dist/epistemic-status.js" + }, "./sleep-performance": { "types": "./dist/sleep-performance.d.ts", "import": "./dist/sleep-performance.js" diff --git a/packages/scoring/src/epistemic-status.test.ts b/packages/scoring/src/epistemic-status.test.ts new file mode 100644 index 0000000000..00c65cfa12 --- /dev/null +++ b/packages/scoring/src/epistemic-status.test.ts @@ -0,0 +1,28 @@ +import { + EPISTEMIC_STATUS, + EPISTEMIC_STATUS_KINDS, + getEpistemicStatus, +} from "./epistemic-status.ts"; + +describe("epistemic status vocabulary", () => { + it("exposes the complete shared vocabulary with exact display labels", () => { + expect(EPISTEMIC_STATUS_KINDS).toEqual([ + "observed", + "estimated", + "associated", + "suggested", + "unavailable", + ]); + expect(EPISTEMIC_STATUS).toEqual({ + observed: { kind: "observed", label: "Observed" }, + estimated: { kind: "estimated", label: "Estimated" }, + associated: { kind: "associated", label: "Associated" }, + suggested: { kind: "suggested", label: "Suggested" }, + unavailable: { kind: "unavailable", label: "Unavailable" }, + }); + }); + + it("returns the server-owned status descriptor for a kind", () => { + expect(getEpistemicStatus("estimated")).toEqual({ kind: "estimated", label: "Estimated" }); + }); +}); diff --git a/packages/scoring/src/epistemic-status.ts b/packages/scoring/src/epistemic-status.ts new file mode 100644 index 0000000000..3483f57a47 --- /dev/null +++ b/packages/scoring/src/epistemic-status.ts @@ -0,0 +1,27 @@ +export const EPISTEMIC_STATUS_KINDS = [ + "observed", + "estimated", + "associated", + "suggested", + "unavailable", +] as const; + +export type EpistemicStatusKind = (typeof EPISTEMIC_STATUS_KINDS)[number]; + +export type EpistemicStatus = { + [K in EpistemicStatusKind]: { kind: K; label: Capitalize }; +}[EpistemicStatusKind]; + +export const EPISTEMIC_STATUS = { + observed: { kind: "observed", label: "Observed" }, + estimated: { kind: "estimated", label: "Estimated" }, + associated: { kind: "associated", label: "Associated" }, + suggested: { kind: "suggested", label: "Suggested" }, + unavailable: { kind: "unavailable", label: "Unavailable" }, +} as const satisfies Record; + +export function getEpistemicStatus( + kind: K, +): (typeof EPISTEMIC_STATUS)[K] { + return EPISTEMIC_STATUS[kind]; +} diff --git a/packages/scoring/src/today-plan.test.ts b/packages/scoring/src/today-plan.test.ts index 168be214b5..3b5eceeed8 100644 --- a/packages/scoring/src/today-plan.test.ts +++ b/packages/scoring/src/today-plan.test.ts @@ -17,6 +17,7 @@ describe("buildTodayPlan", () => { expect(plan).toEqual({ status: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, date: "2026-07-26", action: null, supportingFacts: [], @@ -50,6 +51,7 @@ describe("buildTodayPlan", () => { expect(plan.status).toBe("ready"); if (plan.status !== "ready") return; + expect(plan.epistemicStatus).toEqual({ kind: "suggested", label: "Suggested" }); expect(plan.action).toEqual({ id: "strain_target", title: "Train hard today — aim for 16.2 strain", diff --git a/packages/scoring/src/today-plan.ts b/packages/scoring/src/today-plan.ts index 3b4c27aede..b00e559ab9 100644 --- a/packages/scoring/src/today-plan.ts +++ b/packages/scoring/src/today-plan.ts @@ -1,3 +1,5 @@ +import { type EpistemicStatus, getEpistemicStatus } from "./epistemic-status.ts"; + export type TodayPlanConfidence = "high" | "moderate" | "low"; export type TodayPlanZone = "Push" | "Maintain" | "Recovery"; export type TodayPlanSleepTier = "Excellent" | "Good" | "Fair" | "Poor"; @@ -39,6 +41,7 @@ export interface BuildTodayPlanInput { export type TodayPlanResult = | { status: "ready"; + epistemicStatus: EpistemicStatus; date: string; action: TodayPlanAction; supportingFacts: [TodayPlanSupportingFact, TodayPlanSupportingFact]; @@ -51,6 +54,7 @@ export type TodayPlanResult = } | { status: "insufficient_data"; + epistemicStatus: EpistemicStatus; date: string; action: null; supportingFacts: []; @@ -162,6 +166,7 @@ export function buildTodayPlan(input: BuildTodayPlanInput): TodayPlanResult { if (input.strainTarget == null) { return { status: "insufficient_data", + epistemicStatus: getEpistemicStatus("unavailable"), date: input.endDate, action: null, supportingFacts: [], @@ -178,6 +183,7 @@ export function buildTodayPlan(input: BuildTodayPlanInput): TodayPlanResult { return { status: "ready", + epistemicStatus: getEpistemicStatus("suggested"), date: input.endDate, action: { id: "strain_target", diff --git a/packages/server/src/contracts/epistemic-status-contract.test.ts b/packages/server/src/contracts/epistemic-status-contract.test.ts new file mode 100644 index 0000000000..6b73c0aa7f --- /dev/null +++ b/packages/server/src/contracts/epistemic-status-contract.test.ts @@ -0,0 +1,17 @@ +import { EPISTEMIC_STATUS, EPISTEMIC_STATUS_KINDS } from "@dofek/scoring/epistemic-status"; +import { describe, expect, it } from "vitest"; +import { epistemicStatusSchema } from "./epistemic-status-contract.ts"; + +describe("epistemic status contract", () => { + it("accepts every shared status with its canonical label", () => { + for (const kind of EPISTEMIC_STATUS_KINDS) { + expect(epistemicStatusSchema.parse(EPISTEMIC_STATUS[kind])).toEqual(EPISTEMIC_STATUS[kind]); + } + }); + + it("rejects a status whose label does not match its kind", () => { + expect(epistemicStatusSchema.safeParse({ kind: "estimated", label: "Observed" }).success).toBe( + false, + ); + }); +}); diff --git a/packages/server/src/contracts/epistemic-status-contract.ts b/packages/server/src/contracts/epistemic-status-contract.ts new file mode 100644 index 0000000000..068ef43961 --- /dev/null +++ b/packages/server/src/contracts/epistemic-status-contract.ts @@ -0,0 +1,35 @@ +import { EPISTEMIC_STATUS } from "@dofek/scoring/epistemic-status"; +import { z } from "zod"; + +export const epistemicStatusSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("observed"), + label: z.literal(EPISTEMIC_STATUS.observed.label), + }) + .strict(), + z + .object({ + kind: z.literal("estimated"), + label: z.literal(EPISTEMIC_STATUS.estimated.label), + }) + .strict(), + z + .object({ + kind: z.literal("associated"), + label: z.literal(EPISTEMIC_STATUS.associated.label), + }) + .strict(), + z + .object({ + kind: z.literal("suggested"), + label: z.literal(EPISTEMIC_STATUS.suggested.label), + }) + .strict(), + z + .object({ + kind: z.literal("unavailable"), + label: z.literal(EPISTEMIC_STATUS.unavailable.label), + }) + .strict(), +]); diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts index c588086030..4c7555cb7c 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.test.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.test.ts @@ -120,7 +120,9 @@ function validRecoveryFixture(): z.input { { date: input.endDate, rawWeight: 73.5, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 73.6, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -0.2, interpolated: false, }, diff --git a/packages/server/src/contracts/mobile-dashboard-contracts.ts b/packages/server/src/contracts/mobile-dashboard-contracts.ts index 542a1e08d3..3d5661b570 100644 --- a/packages/server/src/contracts/mobile-dashboard-contracts.ts +++ b/packages/server/src/contracts/mobile-dashboard-contracts.ts @@ -4,6 +4,7 @@ import { baselineComparisonDirectionSchema, baselineRelativeMetricSchema, } from "./baseline-relative-metrics.ts"; +import { epistemicStatusSchema } from "./epistemic-status-contract.ts"; import { progressiveOverloadRowSchema } from "./progressive-overload.ts"; import { trainingChartAvailabilitySchema } from "./training-chart-availability.ts"; @@ -227,7 +228,9 @@ export const mobileRecoveryTabOutputSchema = z.object({ z.object({ date: dateSchema, rawWeight: nonnegativeNumberSchema.nullable(), + rawWeightStatus: epistemicStatusSchema.nullable(), smoothedWeight: nonnegativeNumberSchema, + smoothedWeightStatus: epistemicStatusSchema, weeklyChange: z.number().nullable(), interpolated: z.boolean(), }), diff --git a/packages/server/src/contracts/sleep-need-contract.test.ts b/packages/server/src/contracts/sleep-need-contract.test.ts index c9a2a68e70..89b51e273e 100644 --- a/packages/server/src/contracts/sleep-need-contract.test.ts +++ b/packages/server/src/contracts/sleep-need-contract.test.ts @@ -36,6 +36,7 @@ describe("sleep need contract", () => { expect(toSleepNeedV2(computation)).toEqual({ availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 480, strainDebtMinutes: 12, accumulatedDebtMinutes: 90, @@ -121,6 +122,7 @@ describe("sleep need contract", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: MISSING_PREVIOUS_NIGHT_MESSAGE, }); expect( @@ -209,6 +211,7 @@ describe("sleep need contract", () => { expect(result).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", @@ -236,6 +239,7 @@ describe("sleep need contract", () => { expect(result).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "missing_previous_day_load", message: "Sync yesterday's activity data to include training load in sleep need.", nextAction: "Sync activity data for the previous day.", @@ -246,6 +250,7 @@ describe("sleep need contract", () => { expect( sleepNeedV2Schema.safeParse({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "missing_previous_day_load", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", diff --git a/packages/server/src/contracts/sleep-need-contract.ts b/packages/server/src/contracts/sleep-need-contract.ts index a1ecd73cc2..5ec6abb979 100644 --- a/packages/server/src/contracts/sleep-need-contract.ts +++ b/packages/server/src/contracts/sleep-need-contract.ts @@ -1,4 +1,6 @@ +import { getEpistemicStatus } from "@dofek/scoring/epistemic-status"; import { z } from "zod"; +import { epistemicStatusSchema } from "./epistemic-status-contract.ts"; export const MISSING_PREVIOUS_NIGHT_MESSAGE = "Sync last night's sleep data to see tonight's sleep need."; @@ -77,6 +79,7 @@ export type SleepNeedResult = z.infer; const availableSleepNeedV2Schema = sleepNeedRecommendationSchema .extend({ availability: z.literal("available"), + epistemicStatus: epistemicStatusSchema, debtRecoveryMinutes: z.number(), estimateMetadata: availableSleepNeedEstimateMetadataSchema, }) @@ -85,6 +88,7 @@ const availableSleepNeedV2Schema = sleepNeedRecommendationSchema const missingPreviousNightSleepNeedV2Schema = z .object({ availability: z.literal("missing_previous_night"), + epistemicStatus: epistemicStatusSchema, message: z.literal(MISSING_PREVIOUS_NIGHT_MESSAGE), }) .strict(); @@ -92,6 +96,7 @@ const missingPreviousNightSleepNeedV2Schema = z const insufficientBaselineSleepNeedV2Schema = z .object({ availability: z.literal("insufficient_data"), + epistemicStatus: epistemicStatusSchema, reason: z.literal("insufficient_baseline_history"), message: z.literal(INSUFFICIENT_BASELINE_HISTORY_MESSAGE), nextAction: z.literal(INSUFFICIENT_BASELINE_HISTORY_NEXT_ACTION), @@ -101,6 +106,7 @@ const insufficientBaselineSleepNeedV2Schema = z const missingPreviousDayLoadSleepNeedV2Schema = z .object({ availability: z.literal("insufficient_data"), + epistemicStatus: epistemicStatusSchema, reason: z.literal("missing_previous_day_load"), message: z.literal(MISSING_PREVIOUS_DAY_LOAD_MESSAGE), nextAction: z.literal(MISSING_PREVIOUS_DAY_LOAD_NEXT_ACTION), @@ -183,6 +189,7 @@ export function toSleepNeedV2(computation: SleepNeedComputation): SleepNeedV2 { if (!computation.hasPreviousNight) { return { availability: "missing_previous_night", + epistemicStatus: getEpistemicStatus("unavailable"), message: MISSING_PREVIOUS_NIGHT_MESSAGE, }; } @@ -190,6 +197,7 @@ export function toSleepNeedV2(computation: SleepNeedComputation): SleepNeedV2 { if (!computation.hasYesterdayLoad) { return { availability: "insufficient_data", + epistemicStatus: getEpistemicStatus("unavailable"), reason: "missing_previous_day_load", message: MISSING_PREVIOUS_DAY_LOAD_MESSAGE, nextAction: MISSING_PREVIOUS_DAY_LOAD_NEXT_ACTION, @@ -199,6 +207,7 @@ export function toSleepNeedV2(computation: SleepNeedComputation): SleepNeedV2 { if (computation.baselineQualifyingNightCount < 7) { return { availability: "insufficient_data", + epistemicStatus: getEpistemicStatus("unavailable"), reason: "insufficient_baseline_history", message: INSUFFICIENT_BASELINE_HISTORY_MESSAGE, nextAction: INSUFFICIENT_BASELINE_HISTORY_NEXT_ACTION, @@ -212,6 +221,7 @@ export function toSleepNeedV2(computation: SleepNeedComputation): SleepNeedV2 { return sleepNeedV2Schema.parse({ availability: "available", + epistemicStatus: getEpistemicStatus("estimated"), baselineMinutes: computation.baselineMinutes, strainDebtMinutes: computation.strainDebtMinutes, accumulatedDebtMinutes: computation.accumulatedDebtMinutes, diff --git a/packages/server/src/repositories/body-analytics-repository.test.ts b/packages/server/src/repositories/body-analytics-repository.test.ts index 6d81840a37..d06bf29639 100644 --- a/packages/server/src/repositories/body-analytics-repository.test.ts +++ b/packages/server/src/repositories/body-analytics-repository.test.ts @@ -411,6 +411,11 @@ describe("BodyAnalyticsRepository", () => { expect(result[1]?.interpolated).toBe(false); expect(result[0]?.rawWeight).not.toBeNull(); expect(result[1]?.rawWeight).not.toBeNull(); + expect(result[0]?.rawWeightStatus).toEqual({ kind: "observed", label: "Observed" }); + expect(result[0]?.smoothedWeightStatus).toEqual({ + kind: "estimated", + label: "Estimated", + }); }); it("fills missing days with interpolation and marks them", async () => { @@ -425,6 +430,11 @@ describe("BodyAnalyticsRepository", () => { expect(result[0]?.rawWeight).toBe(80); expect(result[1]?.interpolated).toBe(true); expect(result[1]?.rawWeight).toBeNull(); + expect(result[1]?.rawWeightStatus).toBeNull(); + expect(result[1]?.smoothedWeightStatus).toEqual({ + kind: "estimated", + label: "Estimated", + }); expect(result[2]?.interpolated).toBe(false); expect(result[2]?.rawWeight).toBe(82); }); diff --git a/packages/server/src/repositories/body-analytics-repository.ts b/packages/server/src/repositories/body-analytics-repository.ts index 971490dfc0..89234cca48 100644 --- a/packages/server/src/repositories/body-analytics-repository.ts +++ b/packages/server/src/repositories/body-analytics-repository.ts @@ -1,4 +1,5 @@ import { formatDateYmdInTimeZone } from "@dofek/format/format"; +import { type EpistemicStatus, getEpistemicStatus } from "@dofek/scoring/epistemic-status"; import type { Database } from "dofek/db"; import { captureException } from "dofek/lib/error-reporting"; import type { AccessWindow } from "../billing/entitlement.ts"; @@ -18,7 +19,11 @@ export interface SmoothedWeightRow { date: string; /** Raw weight measurement in kg. Null for interpolated days. */ rawWeight: number | null; + /** Server-authored epistemic status for the raw measurement, when present. */ + rawWeightStatus: EpistemicStatus | null; smoothedWeight: number; + /** Server-authored epistemic status for the smoothed trend value. */ + smoothedWeightStatus: EpistemicStatus; weeklyChange: number | null; /** True when this day had no actual measurement and was linearly interpolated. */ interpolated: boolean; @@ -374,7 +379,9 @@ export class BodyAnalyticsRepository extends BaseRepository { result.push({ date: point.date, rawWeight: point.interpolated ? null : roundWeight(point.value), + rawWeightStatus: point.interpolated ? null : getEpistemicStatus("observed"), smoothedWeight: roundWeight(smoothed), + smoothedWeightStatus: getEpistemicStatus("estimated"), weeklyChange, interpolated: point.interpolated, }); diff --git a/packages/server/src/repositories/correlation-repository.ts b/packages/server/src/repositories/correlation-repository.ts index d286c6017e..13cefec52e 100644 --- a/packages/server/src/repositories/correlation-repository.ts +++ b/packages/server/src/repositories/correlation-repository.ts @@ -1,3 +1,4 @@ +import { getEpistemicStatus } from "@dofek/scoring/epistemic-status"; import { circularMovingBlockBootstrapInterval } from "@dofek/stats/block-bootstrap"; import { CORRELATION_METRICS, @@ -426,6 +427,7 @@ export function computeCorrelation(joined: JoinedDay[], input: CorrelationInput) const additionalSamplesRequired = MIN_CORRELATION_PAIRS - pairCount; return { availability: "insufficient" as const, + epistemicStatus: getEpistemicStatus("unavailable"), dataPoints: analysis.pairs, sampleCount: pairCount, additionalSamplesRequired, @@ -461,6 +463,7 @@ export function computeCorrelation(joined: JoinedDay[], input: CorrelationInput) return { availability: "available" as const, + epistemicStatus: getEpistemicStatus("associated"), spearmanRho: spearman.rho, spearmanPValue: spearman.pValue, pearsonR: pearson.r, @@ -486,6 +489,7 @@ export function computeCorrelationV2(joined: JoinedDay[], input: CorrelationInpu return { analysisVersion: 2 as const, availability: "insufficient" as const, + epistemicStatus: getEpistemicStatus("unavailable"), dataPoints: analysis.pairs, sampleCount: pairCount, additionalSamplesRequired, @@ -524,6 +528,7 @@ export function computeCorrelationV2(joined: JoinedDay[], input: CorrelationInpu return { analysisVersion: 2 as const, availability: "available" as const, + epistemicStatus: getEpistemicStatus("associated"), spearmanRho, regression, dataPoints: downsample(analysis.pairs, MAX_DATA_POINTS), diff --git a/packages/server/src/routers/body-analytics.test.ts b/packages/server/src/routers/body-analytics.test.ts index 3ae61c857f..d925adcbb6 100644 --- a/packages/server/src/routers/body-analytics.test.ts +++ b/packages/server/src/routers/body-analytics.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { BodyAnalyticsRepository } from "../repositories/body-analytics-repository.ts"; import { createTestCallerFactory, makeMockSensorStore } from "./test-helpers.ts"; +const cachedQueryOptions = vi.hoisted((): Array<{ maxAge: number; keyVersion?: string }> => []); + vi.mock("dofek/lib/error-reporting", () => ({ captureException: vi.fn(), })); @@ -25,7 +27,10 @@ vi.mock("../trpc.ts", async () => { return { router: trpc.router, protectedProcedure: trpc.procedure, - cachedProtectedQuery: () => trpc.procedure, + cachedProtectedQuery: (options: { maxAge: number; keyVersion?: string }) => { + cachedQueryOptions.push(options); + return trpc.procedure; + }, CacheTTL: { SHORT: 120_000, MEDIUM: 600_000, LONG: 3_600_000 }, }; }); @@ -74,6 +79,12 @@ beforeEach(() => { }); describe("bodyAnalyticsRouter", () => { + it("versions both weight response cache contracts", () => { + expect( + cachedQueryOptions.filter((options) => options.keyVersion === "health-status-evidence-v4"), + ).toHaveLength(2); + }); + describe("smoothedWeight", () => { it("returns empty array when no data", async () => { const caller = makeCaller([]); diff --git a/packages/server/src/routers/body-analytics.ts b/packages/server/src/routers/body-analytics.ts index 6f44bd2d7d..736d5d7e80 100644 --- a/packages/server/src/routers/body-analytics.ts +++ b/packages/server/src/routers/body-analytics.ts @@ -3,6 +3,7 @@ import type { Database } from "dofek/db"; import { queryCache } from "dofek/lib/cache"; import { captureException } from "dofek/lib/error-reporting"; import { z } from "zod"; +import { epistemicStatusSchema } from "../contracts/epistemic-status-contract.ts"; import { selectedChartDateRangeQuery } from "../lib/chart-range.ts"; import { selectedDateRangeInput } from "../lib/date-window.ts"; import { BodyAnalyticsRepository } from "../repositories/body-analytics-repository.ts"; @@ -46,7 +47,9 @@ const dateWindowInput = selectedDateRangeInput(90); const smoothedWeightOutputSchema = z.object({ date: z.string(), rawWeight: z.number().nullable(), + rawWeightStatus: epistemicStatusSchema.nullable(), smoothedWeight: z.number(), + smoothedWeightStatus: epistemicStatusSchema, weeklyChange: z.number().nullable(), interpolated: z.boolean(), }); @@ -94,8 +97,12 @@ function createBodyAnalyticsRepository(ctx: AuthenticatedContext) { // ── Router ─────────────────────────────────────────────────────────── export const bodyAnalyticsRouter = router({ - smoothedWeight: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM }) + smoothedWeight: cachedProtectedQuery({ + maxAge: CacheTTL.MEDIUM, + keyVersion: HEALTH_STATUS_CACHE_KEY_VERSION, + }) .input(dateWindowInput) + .output(z.array(smoothedWeightOutputSchema)) .query(({ ctx, input }) => { const repo = createBodyAnalyticsRepository(ctx); return repo.getSmoothedWeight(input.days, input.endDate); diff --git a/packages/server/src/routers/correlation.test.ts b/packages/server/src/routers/correlation.test.ts index 2a4b98259a..348e9e2651 100644 --- a/packages/server/src/routers/correlation.test.ts +++ b/packages/server/src/routers/correlation.test.ts @@ -657,6 +657,17 @@ describe("correlationRouter", () => { }); }); + it("versions both correlation result cache contracts", () => { + expect(cachedQueryOptions).toContainEqual({ + maxAge: 600_000, + keyVersion: "correlation.compute:v2", + }); + expect(cachedQueryOptions).toContainEqual({ + maxAge: 600_000, + keyVersion: "correlation.computeV2:v2", + }); + }); + it("returns available correlation metrics", async () => { const caller = createCaller({ db: { execute: vi.fn().mockResolvedValue([]) }, @@ -702,6 +713,7 @@ describe("correlationRouter", () => { it("validates and strips unknown repository output fields", async () => { const repositoryResult = { availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, @@ -827,6 +839,7 @@ describe("correlationRouter", () => { const repositoryResult = { analysisVersion: 2, availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, diff --git a/packages/server/src/routers/correlation.ts b/packages/server/src/routers/correlation.ts index 170a07b027..f1eb452b8d 100644 --- a/packages/server/src/routers/correlation.ts +++ b/packages/server/src/routers/correlation.ts @@ -2,6 +2,7 @@ import { formatDateYmdInTimeZone } from "@dofek/format/format"; import { MIN_CORRELATION_PAIRS } from "@dofek/stats/correlation"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { epistemicStatusSchema } from "../contracts/epistemic-status-contract.ts"; import { selectedChartCustomRangeQuery, selectedChartRangeSchema } from "../lib/chart-range.ts"; import { dateStringSchema } from "../lib/typed-sql.ts"; import { @@ -81,6 +82,7 @@ const correlationStatsSchema = z.object({ }); const correlationResultBaseShape = { + epistemicStatus: epistemicStatusSchema, dataPoints: z.array(correlationDataPointSchema), sampleCount: z.number().int().nonnegative(), insight: z.string(), @@ -156,6 +158,7 @@ const correlationUncertaintySchema = z.discriminatedUnion("availability", [ ]); const correlationV2BaseShape = { + epistemicStatus: epistemicStatusSchema, analysisVersion: z.literal(2), dataPoints: z.array(correlationDataPointSchema), sampleCount: z.number().int().nonnegative(), @@ -248,6 +251,7 @@ export const correlationRouter = router({ ); }, correlationComputeOutputSchema, + { keyVersion: "correlation.compute:v2" }, ), computeV2: selectedChartCustomRangeQuery( @@ -266,6 +270,7 @@ export const correlationRouter = router({ ); }, correlationComputeV2OutputSchema, + { keyVersion: "correlation.computeV2:v2" }, ), observations: selectedChartCustomRangeQuery( diff --git a/packages/server/src/routers/mobile-dashboard.test.ts b/packages/server/src/routers/mobile-dashboard.test.ts index 80f1662c35..b301695495 100644 --- a/packages/server/src/routers/mobile-dashboard.test.ts +++ b/packages/server/src/routers/mobile-dashboard.test.ts @@ -236,6 +236,17 @@ function emptyRecoveryTabResult(): import("../services/mobile-recovery-tab.ts"). } describe("mobileDashboard.dashboardV2", () => { + it("versions the changed dashboard and recovery response contracts", () => { + expect(cachedQueryOptions).toContainEqual({ + maxAge: 120_000, + keyVersion: "sleep-need-metadata-v2", + }); + expect(cachedQueryOptions).toContainEqual({ + maxAge: 600_000, + keyVersion: "health-status-evidence-v4", + }); + }); + it("fails loudly when ClickHouse activity analytics are unavailable", async () => { const caller = createCaller({ db: { execute: vi.fn() }, @@ -260,6 +271,7 @@ describe("mobileDashboard.dashboardV2", () => { expect(result.sleepNeed).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); expect(result.sleepNeed).not.toHaveProperty("totalNeedMinutes"); @@ -325,6 +337,7 @@ describe("mobileDashboard.dashboardV2", () => { expect(result.sleepNeed).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", diff --git a/packages/server/src/routers/mobile-dashboard.ts b/packages/server/src/routers/mobile-dashboard.ts index 4273cbbcb2..729e5028f9 100644 --- a/packages/server/src/routers/mobile-dashboard.ts +++ b/packages/server/src/routers/mobile-dashboard.ts @@ -147,7 +147,7 @@ export const mobileDashboardRouter = router({ dashboardV2: cachedProtectedQuery({ maxAge: CacheTTL.SHORT, - keyVersion: "sleep-need-metadata-v1", + keyVersion: "sleep-need-metadata-v2", }) .input(z.object({ endDate: endDateSchema })) .output(mobileDashboardV2OutputSchema) diff --git a/packages/server/src/routers/sleep-need.integration.test.ts b/packages/server/src/routers/sleep-need.integration.test.ts index e9af91ebf8..d8cbfc9cf6 100644 --- a/packages/server/src/routers/sleep-need.integration.test.ts +++ b/packages/server/src/routers/sleep-need.integration.test.ts @@ -138,6 +138,7 @@ describe("sleep-need router integration", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); expect(result).not.toHaveProperty("totalNeedMinutes"); @@ -153,13 +154,14 @@ describe("sleep-need router integration", () => { "sleepNeed.calculateV2", input, "UTC", - "sleep-need-metadata-v1", + "sleep-need-metadata-v2", ); await queryCache.set(legacyKey, { availability: "legacy-contract" }, CacheTTL.SHORT); await queryCache.set( versionedKey, { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }, CacheTTL.SHORT, @@ -169,6 +171,7 @@ describe("sleep-need router integration", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); }); @@ -204,6 +207,7 @@ describe("sleep-need router integration", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); expect(result).not.toHaveProperty("totalNeedMinutes"); diff --git a/packages/server/src/routers/sleep-need.test.ts b/packages/server/src/routers/sleep-need.test.ts index afe275033c..17409c57fc 100644 --- a/packages/server/src/routers/sleep-need.test.ts +++ b/packages/server/src/routers/sleep-need.test.ts @@ -157,6 +157,7 @@ describe("sleepNeedRouter", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); }); @@ -173,6 +174,7 @@ describe("sleepNeedRouter", () => { expect(result).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); }); @@ -191,6 +193,7 @@ describe("sleepNeedRouter", () => { expect(result).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", @@ -204,6 +207,7 @@ describe("sleepNeedRouter", () => { expect(result).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "missing_previous_day_load", message: "Sync yesterday's activity data to include training load in sleep need.", nextAction: "Sync activity data for the previous day.", @@ -234,6 +238,7 @@ describe("sleepNeedRouter", () => { expect(result).toMatchObject({ availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, accumulatedDebtMinutes: 0, debtRecoveryMinutes: 0, totalNeedMinutes: 480, diff --git a/packages/server/src/routers/sleep-need.ts b/packages/server/src/routers/sleep-need.ts index b912c246fd..ed95f6f702 100644 --- a/packages/server/src/routers/sleep-need.ts +++ b/packages/server/src/routers/sleep-need.ts @@ -308,7 +308,7 @@ export const sleepNeedRouter = router({ */ calculateV2: cachedProtectedQuery({ maxAge: CacheTTL.SHORT, - keyVersion: "sleep-need-metadata-v1", + keyVersion: "sleep-need-metadata-v2", }) .input(z.object({ endDate: endDateSchema })) .output(sleepNeedV2Schema) diff --git a/packages/server/src/routers/today-plan.test.ts b/packages/server/src/routers/today-plan.test.ts index 34100e1b05..bf73d01a21 100644 --- a/packages/server/src/routers/today-plan.test.ts +++ b/packages/server/src/routers/today-plan.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { createTestCallerFactory } from "./test-helpers.ts"; +const cachedQueryOptions = vi.hoisted((): Array<{ maxAge: number; keyVersion?: string }> => []); + vi.mock("../trpc.ts", async () => { const { initTRPC } = await import("@trpc/server"); const trpc = initTRPC @@ -15,7 +17,10 @@ vi.mock("../trpc.ts", async () => { return { router: trpc.router, protectedProcedure: trpc.procedure, - cachedProtectedQuery: () => trpc.procedure, + cachedProtectedQuery: (options: { maxAge: number; keyVersion?: string }) => { + cachedQueryOptions.push(options); + return trpc.procedure; + }, CacheTTL: { SHORT: 120_000, MEDIUM: 600_000, LONG: 3_600_000 }, }; }); @@ -46,6 +51,13 @@ function makeSensorStore(queryImpl: SensorStore["query"]): SensorStore { } describe("todayPlanRouter.get", () => { + it("versions the Today Plan response cache contract", () => { + expect(cachedQueryOptions).toContainEqual({ + maxAge: 600_000, + keyVersion: "today-plan-evidence-v2", + }); + }); + it("returns the insufficient_data contract when recovery is missing", async () => { const caller = createCaller({ db: { execute: vi.fn() }, diff --git a/packages/server/src/routers/today-plan.ts b/packages/server/src/routers/today-plan.ts index c839ce42ad..cd297c5039 100644 --- a/packages/server/src/routers/today-plan.ts +++ b/packages/server/src/routers/today-plan.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { epistemicStatusSchema } from "../contracts/epistemic-status-contract.ts"; import { endDateSchema } from "../lib/date-window.ts"; import { loadTodayPlan } from "../services/today-plan.ts"; import { CacheTTL, cachedProtectedQuery, router } from "../trpc.ts"; @@ -11,6 +12,7 @@ const supportingFactSchema = z.object({ const todayPlanResultSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("ready"), + epistemicStatus: epistemicStatusSchema, date: z.string(), action: z.object({ id: z.literal("strain_target"), @@ -29,6 +31,7 @@ const todayPlanResultSchema = z.discriminatedUnion("status", [ }), z.object({ status: z.literal("insufficient_data"), + epistemicStatus: epistemicStatusSchema, date: z.string(), action: z.null(), supportingFacts: z.tuple([]), @@ -45,7 +48,7 @@ const todayPlanResultSchema = z.discriminatedUnion("status", [ export const todayPlanRouter = router({ get: cachedProtectedQuery({ maxAge: CacheTTL.MEDIUM, - keyVersion: "today-plan-evidence-v1", + keyVersion: "today-plan-evidence-v2", }) .input(z.object({ days: z.number().default(30), endDate: endDateSchema })) .output(todayPlanResultSchema) diff --git a/packages/server/src/services/dashboard-overview.test.ts b/packages/server/src/services/dashboard-overview.test.ts index 5c270d7fc8..943bdfdff1 100644 --- a/packages/server/src/services/dashboard-overview.test.ts +++ b/packages/server/src/services/dashboard-overview.test.ts @@ -124,6 +124,7 @@ describe("loadDashboardOverview", () => { expect(result.sleepNeedV2).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); }); @@ -150,6 +151,7 @@ describe("loadDashboardOverview", () => { ); expect(result.sleepNeedV2).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); }); @@ -164,6 +166,7 @@ describe("loadDashboardOverview", () => { expect(result.sleepNeedV2).toEqual({ availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: "Sync last night's sleep data to see tonight's sleep need.", }); expect(result.sleep.lastNight).toBeNull(); @@ -179,6 +182,7 @@ describe("loadDashboardOverview", () => { }); expect(noLoadRow.sleepNeedV2).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "missing_previous_day_load", message: "Sync yesterday's activity data to include training load in sleep need.", nextAction: "Sync activity data for the previous day.", @@ -192,6 +196,7 @@ describe("loadDashboardOverview", () => { }); expect(zeroLoadRow.sleepNeedV2).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", @@ -233,6 +238,7 @@ describe("loadDashboardOverview", () => { expect(result.sleepNeedV2).toEqual({ availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", @@ -257,6 +263,7 @@ describe("loadDashboardOverview", () => { expect(result.sleepNeedV2).toMatchObject({ availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, accumulatedDebtMinutes: 0, debtRecoveryMinutes: 0, totalNeedMinutes: 480, @@ -286,6 +293,7 @@ describe("loadDashboardOverview", () => { expect(result.sleepNeedV2).toMatchObject({ availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, accumulatedDebtMinutes: 0, debtRecoveryMinutes: 0, totalNeedMinutes: 429, diff --git a/packages/server/src/services/health-status.ts b/packages/server/src/services/health-status.ts index e6227f8710..c99b409e61 100644 --- a/packages/server/src/services/health-status.ts +++ b/packages/server/src/services/health-status.ts @@ -40,7 +40,7 @@ function hasFiniteValue( return observation.value != null && Number.isFinite(observation.value); } -export const HEALTH_STATUS_CACHE_KEY_VERSION = "health-status-evidence-v3"; +export const HEALTH_STATUS_CACHE_KEY_VERSION = "health-status-evidence-v4"; interface HealthStatusSummaryInput { metric: HealthStatusMetric["metric"]; diff --git a/packages/server/src/services/mobile-recovery-tab.test.ts b/packages/server/src/services/mobile-recovery-tab.test.ts index 82c4f4e9d2..d02f1578d3 100644 --- a/packages/server/src/services/mobile-recovery-tab.test.ts +++ b/packages/server/src/services/mobile-recovery-tab.test.ts @@ -548,14 +548,18 @@ describe("loadMobileRecoveryTab", () => { { date: "2026-03-27", rawWeight: 80, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 80, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-28", rawWeight: 79, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 79, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -1, interpolated: false, }, diff --git a/packages/web/src/components/SleepNeedCard.stories.tsx b/packages/web/src/components/SleepNeedCard.stories.tsx index 8095b139d9..098f0aa084 100644 --- a/packages/web/src/components/SleepNeedCard.stories.tsx +++ b/packages/web/src/components/SleepNeedCard.stories.tsx @@ -13,6 +13,7 @@ const emptyProvenance: Pick< const sampleData = { availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 462, strainDebtMinutes: 12, accumulatedDebtMinutes: 85, @@ -131,6 +132,7 @@ export const CannotRecommend: Story = { args: { data: { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: MISSING_PREVIOUS_NIGHT_MESSAGE, }, }, diff --git a/packages/web/src/components/SleepNeedCard.test.tsx b/packages/web/src/components/SleepNeedCard.test.tsx index b682d99749..b6034f9d0e 100644 --- a/packages/web/src/components/SleepNeedCard.test.tsx +++ b/packages/web/src/components/SleepNeedCard.test.tsx @@ -26,6 +26,7 @@ const emptyProvenance: Pick< const mockData = { availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 480, strainDebtMinutes: 12, accumulatedDebtMinutes: 90, @@ -129,6 +130,7 @@ describe("SleepNeedCard", () => { it("presents the available value as an uncalibrated heuristic estimate", () => { capturedOption = null; render(); + expect(screen.getByText("Estimated sleep need tonight")).toBeDefined(); expect(screen.getByText("About 8h 35m")).toBeDefined(); expect(screen.getByText("Heuristic estimate")).toBeDefined(); expect(screen.getByText("Previous-day load adjustment")).toBeDefined(); @@ -229,6 +231,7 @@ describe("SleepNeedCard", () => { capturedOption = null; const unavailableData: SleepNeedV2 = { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: MISSING_PREVIOUS_NIGHT_MESSAGE, }; render(); @@ -244,6 +247,7 @@ describe("SleepNeedCard", () => { capturedOption = null; const insufficientData: SleepNeedV2 = { availability: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, reason: "insufficient_baseline_history", message: "Sync at least 7 qualifying nights to estimate sleep need.", nextAction: "Sync more sleep and recovery data.", @@ -251,6 +255,7 @@ describe("SleepNeedCard", () => { render(); + expect(screen.getByText("Unavailable")).toBeDefined(); expect(screen.getByText(insufficientData.message)).toBeDefined(); expect(screen.getByText(insufficientData.nextAction)).toBeDefined(); expect(screen.queryByTestId("echarts")).toBeNull(); diff --git a/packages/web/src/components/SleepNeedCard.tsx b/packages/web/src/components/SleepNeedCard.tsx index f8b4a106f9..fcb872b907 100644 --- a/packages/web/src/components/SleepNeedCard.tsx +++ b/packages/web/src/components/SleepNeedCard.tsx @@ -38,6 +38,7 @@ export function SleepNeedCard({ data, loading }: SleepNeedCardProps) { return (

Sleep Need Tonight

+

{data.epistemicStatus.label}

{data.message}

{data.availability === "insufficient_data" && (

{data.nextAction}

@@ -119,7 +120,9 @@ export function SleepNeedCard({ data, loading }: SleepNeedCardProps) {
-

Estimated sleep need tonight

+

+ {data.epistemicStatus.label} sleep need tonight +

{`${data.estimateMetadata.valueQualifier} ${formatDurationMinutes(data.totalNeedMinutes)}`}

diff --git a/packages/web/src/components/SleepOverviewCards.stories.tsx b/packages/web/src/components/SleepOverviewCards.stories.tsx index b39bbc8778..829da69376 100644 --- a/packages/web/src/components/SleepOverviewCards.stories.tsx +++ b/packages/web/src/components/SleepOverviewCards.stories.tsx @@ -4,6 +4,7 @@ import { SleepOverviewCards } from "./SleepOverviewCards"; const availableSleepNeed = { availability: "available", + epistemicStatus: { kind: "estimated", label: "Estimated" }, baselineMinutes: 480, strainDebtMinutes: 12, accumulatedDebtMinutes: 85, @@ -89,6 +90,7 @@ export const SleepDataNeeded: Story = { args: { sleepNeed: { availability: "missing_previous_night", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, message: MISSING_PREVIOUS_NIGHT_MESSAGE, }, sleepPerformance: null, diff --git a/packages/web/src/components/SleepOverviewCards.test.tsx b/packages/web/src/components/SleepOverviewCards.test.tsx index 8639ef9585..89d367f077 100644 --- a/packages/web/src/components/SleepOverviewCards.test.tsx +++ b/packages/web/src/components/SleepOverviewCards.test.tsx @@ -33,6 +33,7 @@ describe("SleepOverviewCards", () => { { { = 7 ? Math.round(trendPerDay * 7 * 100) / 100 : null, interpolated: false, }); diff --git a/packages/web/src/components/SmoothedWeightChart.test.tsx b/packages/web/src/components/SmoothedWeightChart.test.tsx index 74f469a75f..74fe4462c1 100644 --- a/packages/web/src/components/SmoothedWeightChart.test.tsx +++ b/packages/web/src/components/SmoothedWeightChart.test.tsx @@ -41,70 +41,90 @@ const sampleData: SmoothedWeightRow[] = [ { date: "2026-03-01", rawWeight: 84.0, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 84.0, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-02", rawWeight: 83.8, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.98, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-03", rawWeight: 84.2, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 84.0, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-04", rawWeight: 83.7, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.97, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-05", rawWeight: 84.1, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.98, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-06", rawWeight: 83.9, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.97, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-07", rawWeight: 84.0, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.98, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-08", rawWeight: 83.6, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.94, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -0.06, interpolated: false, }, { date: "2026-03-09", rawWeight: 83.5, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.9, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -0.08, interpolated: false, }, { date: "2026-03-10", rawWeight: 83.4, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.85, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -0.15, interpolated: false, }, @@ -119,9 +139,9 @@ describe("SmoothedWeightChart", () => { it("shows the latest Trend Weight and scale reading", () => { render(); - expect(screen.getByText("Trend Weight")).toBeDefined(); + expect(screen.getByText("Estimated Trend Weight")).toBeDefined(); expect(screen.getByText("83.9 kg")).toBeDefined(); - expect(screen.getByText("Scale: 83.4 kg")).toBeDefined(); + expect(screen.getByText("Observed: 83.4 kg")).toBeDefined(); }); it("renders loading state", () => { @@ -200,21 +220,27 @@ describe("SmoothedWeightChart", () => { { date: "2026-03-01", rawWeight: 84.0, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 84.0, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, { date: "2026-03-02", rawWeight: null, + rawWeightStatus: null, smoothedWeight: 83.9, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: true, }, { date: "2026-03-03", rawWeight: 83.8, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 83.89, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, @@ -237,7 +263,9 @@ describe("SmoothedWeightChart", () => { { date: "2026-03-01", rawWeight: 72.36123, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 72.36091, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: -15.51971, interpolated: false, }, diff --git a/packages/web/src/components/SmoothedWeightChart.tsx b/packages/web/src/components/SmoothedWeightChart.tsx index 67c2c6261e..21f33e68ce 100644 --- a/packages/web/src/components/SmoothedWeightChart.tsx +++ b/packages/web/src/components/SmoothedWeightChart.tsx @@ -44,10 +44,14 @@ export function SmoothedWeightChart({ data, prediction, loading }: SmoothedWeigh const ratePerWeekKg = prediction?.ratePerWeek ?? null; const latest = data.at(-1); let latestScaleWeight: number | null = null; + let latestScaleWeightStatus: string | null = null; for (let index = data.length - 1; index >= 0; index--) { - const rawWeight = data[index]?.rawWeight; + const row = data[index]; + if (!row) continue; + const rawWeight = row?.rawWeight; if (rawWeight != null) { latestScaleWeight = rawWeight; + latestScaleWeightStatus = row.rawWeightStatus?.label ?? null; break; } } @@ -201,13 +205,16 @@ export function SmoothedWeightChart({ data, prediction, loading }: SmoothedWeigh
-
Trend Weight
+
+ {latest?.smoothedWeightStatus.label} Trend Weight +
{latest ? formatMeasurementText(units.formatWeight(latest.smoothedWeight)) : "—"}
- {latestScaleWeight != null && ( + {latestScaleWeight != null && latestScaleWeightStatus != null && (
- Scale: {formatMeasurementText(units.formatWeight(latestScaleWeight))} + {latestScaleWeightStatus}:{" "} + {formatMeasurementText(units.formatWeight(latestScaleWeight))}
)}
diff --git a/packages/web/src/components/TodayPlanCard.stories.tsx b/packages/web/src/components/TodayPlanCard.stories.tsx index 5d430d25f3..40d100527e 100644 --- a/packages/web/src/components/TodayPlanCard.stories.tsx +++ b/packages/web/src/components/TodayPlanCard.stories.tsx @@ -4,6 +4,7 @@ import { TodayPlanCard } from "./TodayPlanCard.tsx"; const readyPlan: TodayPlanResult = { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-07-26", action: { id: "strain_target", @@ -26,6 +27,7 @@ const readyPlan: TodayPlanResult = { const insufficientPlan: TodayPlanResult = { status: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, date: "2026-07-26", action: null, supportingFacts: [], diff --git a/packages/web/src/components/TodayPlanCard.test.tsx b/packages/web/src/components/TodayPlanCard.test.tsx index e3ceeb1075..d8268c31c8 100644 --- a/packages/web/src/components/TodayPlanCard.test.tsx +++ b/packages/web/src/components/TodayPlanCard.test.tsx @@ -7,6 +7,7 @@ import { TodayPlanCard } from "./TodayPlanCard.tsx"; const readyPlan: TodayPlanResult = { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-07-26", action: { id: "strain_target", @@ -29,6 +30,7 @@ const readyPlan: TodayPlanResult = { const insufficientPlan: TodayPlanResult = { status: "insufficient_data", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, date: "2026-07-26", action: null, supportingFacts: [], @@ -62,6 +64,7 @@ describe("TodayPlanCard", () => { expect(screen.getByText("Sleep performance")).toBeTruthy(); expect(screen.getByText("88 (Good)")).toBeTruthy(); expect(screen.getByText("High confidence")).toBeTruthy(); + expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByText(/Recovery data from 2026-07-26/)).toBeTruthy(); }); @@ -121,6 +124,7 @@ describe("TodayPlanCard", () => { ), ).toBeTruthy(); expect(screen.queryByText("Train hard today — aim for 16.2 strain")).toBeNull(); + expect(screen.getByText("Unavailable")).toBeTruthy(); }); it("renders a loading state", () => { diff --git a/packages/web/src/components/TodayPlanCard.tsx b/packages/web/src/components/TodayPlanCard.tsx index 1615a46238..08e7b3a37f 100644 --- a/packages/web/src/components/TodayPlanCard.tsx +++ b/packages/web/src/components/TodayPlanCard.tsx @@ -52,6 +52,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro {refreshWarning}

{plan.message}

+

{plan.epistemicStatus.label}

{formatTodayPlanConfidence(plan.confidence)}

); @@ -107,6 +108,7 @@ export function TodayPlanCard({ plan, loading = false, error }: TodayPlanCardPro
) : null}
+

{plan.epistemicStatus.label}

{formatTodayPlanConfidence(plan.confidence)}

{freshness != null ?

{freshness}

: null}
diff --git a/packages/web/src/pages/BodyPage.test.tsx b/packages/web/src/pages/BodyPage.test.tsx index 30174f1d9c..d94d7f9342 100644 --- a/packages/web/src/pages/BodyPage.test.tsx +++ b/packages/web/src/pages/BodyPage.test.tsx @@ -112,7 +112,9 @@ const healthyWeightOverview = { { date: "2026-07-25", rawWeight: 80, + rawWeightStatus: { kind: "observed", label: "Observed" }, smoothedWeight: 80, + smoothedWeightStatus: { kind: "estimated", label: "Estimated" }, weeklyChange: null, interpolated: false, }, diff --git a/packages/web/src/pages/CorrelationExplorerPage.stories.tsx b/packages/web/src/pages/CorrelationExplorerPage.stories.tsx index 8221b520e8..3a644b65ed 100644 --- a/packages/web/src/pages/CorrelationExplorerPage.stories.tsx +++ b/packages/web/src/pages/CorrelationExplorerPage.stories.tsx @@ -39,6 +39,7 @@ const metrics = [ const availableResult = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.72, regression: { slope: 0.42, intercept: 8, rSquared: 0.46 }, dataPoints: [ @@ -77,6 +78,7 @@ const availableResult = { const insufficientResult = { analysisVersion: 2, availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, diff --git a/packages/web/src/pages/CorrelationExplorerPage.test.tsx b/packages/web/src/pages/CorrelationExplorerPage.test.tsx index bec531234d..f4bdbcbe9d 100644 --- a/packages/web/src/pages/CorrelationExplorerPage.test.tsx +++ b/packages/web/src/pages/CorrelationExplorerPage.test.tsx @@ -134,6 +134,7 @@ describe("CorrelationExplorerPage", () => { state.correlationData = { analysisVersion: 2, availability: "insufficient", + epistemicStatus: { kind: "unavailable", label: "Unavailable" }, dataPoints: [], sampleCount: 0, additionalSamplesRequired: 5, @@ -230,6 +231,7 @@ describe("CorrelationExplorerPage", () => { render(); expect(screen.getByText("n = 0")).toBeTruthy(); + expect(screen.getByText("Unavailable")).toBeTruthy(); expect(screen.getByText("5 more paired calendar days needed")).toBeTruthy(); expect(screen.getByText("90 selected")).toBeTruthy(); expect(screen.getByText("90 missing pairs")).toBeTruthy(); @@ -261,6 +263,7 @@ describe("CorrelationExplorerPage", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.75, regression: { slope: 1, intercept: 0, rSquared: 0.49 }, dataPoints: [], @@ -293,6 +296,7 @@ describe("CorrelationExplorerPage", () => { render(); expect(screen.getByText("Spearman rho = 0.75")).toBeTruthy(); + expect(screen.getByText("Associated")).toBeTruthy(); expect(screen.getByText("95% block-bootstrap interval: 0.42 to 0.86")).toBeTruthy(); expect(screen.getByText("Slope = 1.000 ms per g")).toBeTruthy(); expect(screen.getByText("R² = 0.490")).toBeTruthy(); @@ -307,6 +311,7 @@ describe("CorrelationExplorerPage", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: -0.75, regression: { slope: -1, intercept: 3, rSquared: 0.49 }, dataPoints: [ @@ -351,6 +356,7 @@ describe("CorrelationExplorerPage", () => { state.correlationData = { analysisVersion: 2, availability: "available", + epistemicStatus: { kind: "associated", label: "Associated" }, spearmanRho: 0.75, regression: { slope: 1, intercept: 0, rSquared: 0.49 }, dataPoints: [ diff --git a/packages/web/src/pages/CorrelationExplorerPage.tsx b/packages/web/src/pages/CorrelationExplorerPage.tsx index 45585868d0..2a721207e8 100644 --- a/packages/web/src/pages/CorrelationExplorerPage.tsx +++ b/packages/web/src/pages/CorrelationExplorerPage.tsx @@ -492,6 +492,7 @@ export function CorrelationExplorerPage() {

Correlation Evidence

+

{data.epistemicStatus.label}

{data.availability === "available" ? ( <> diff --git a/packages/web/src/pages/Dashboard.test.tsx b/packages/web/src/pages/Dashboard.test.tsx index d636a3e8f3..3f524dd87d 100644 --- a/packages/web/src/pages/Dashboard.test.tsx +++ b/packages/web/src/pages/Dashboard.test.tsx @@ -222,6 +222,7 @@ describe("Dashboard", () => { mockTodayPlanQuery.mockReturnValue({ data: { status: "ready", + epistemicStatus: { kind: "suggested", label: "Suggested" }, date: "2026-05-27", action: { id: "strain_target",