diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a732bb79e..09a513bb4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10416,7 +10416,9 @@ paths: coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine - values across metrics. Auth via cookie or Bearer. + values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; + `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that + actually backed it. Auth via cookie or Bearer. parameters: - in: query name: metric @@ -10478,6 +10480,30 @@ paths: the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving. + - in: query + name: windowDays + schema: + description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 + days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 + for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where + the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. + Always read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` + composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The + window is a request, not a promise about coverage — `coverage.historyDays` is how many days of record + actually backed the answer, and it is smaller than `windowDays` for anyone whose history is shorter than + the window. Not accepted on the batch route, which always runs the engine defaults." + type: integer + minimum: 1 + maximum: 90 + description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 + days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for + the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the + derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always + read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` composes + fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a + request, not a promise about coverage — `coverage.historyDays` is how many days of record actually backed + the answer, and it is smaller than `windowDays` for anyone whose history is shorter than the window. Not + accepted on the batch route, which always runs the engine defaults." responses: "200": description: The flat derived-metric value (ok or insufficient). @@ -51988,9 +52014,14 @@ components: type: string additionalProperties: {} - type: "null" - description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, - series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); - null when 'insufficient'. + description: Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by + `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / + `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in + `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to + discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, + sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The + remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside + their engines under `src/lib/insights/derived/`. coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: @@ -68041,6 +68072,89 @@ components: group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent." + WellnessScoreValue: + type: object + properties: + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The latest persisted 0–100 score. + band: + type: string + enum: + - green + - yellow + - red + description: "Server's verdict on the score. Direction-aware: recovery bands high-is-good, stress and strain invert, so + a client must never re-band the number itself." + trendDelta: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Score minus the mean of the earlier days in the window; null when the window holds only one day. + daysInWindow: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter + record answers a wide request with fewer days, and this is where that shows. + asOf: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "`measuredAt` of the latest score." + series: + type: array + items: + type: number + description: "Trailing scores, oldest → newest, capped at 30 points however wide the window is. For RECOVERY these are + the canonical per-night values: a worn band and the server's own proxy write the same night on different + clocks, and the server collapses them to one value per night before this array is built." + anchor: + description: STRAIN only — whether this score was judged against the user's own training history or the cold-start + population reference. Null for recovery and stress. + anyOf: + - type: string + enum: + - personal + - population + - type: "null" + components: + description: RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery + percentage is not our blend and carries no decomposition. + anyOf: + - type: array + items: + type: object + properties: + key: + type: string + description: Contributor id, e.g. rhr / hrv / sleep. + value: + anyOf: + - type: number + - type: "null" + description: 0–100 sub-score, null when the input was missing. + weight: + type: number + description: Effective weight after redistributing missing inputs. + required: + - key + - value + - weight + additionalProperties: false + - type: "null" + required: + - score + - band + - trendDelta + - daysInWindow + - asOf + - series + additionalProperties: false parameters: AccountSelector: name: X-HealthLog-Account diff --git a/src/__tests__/derived-wellness-value-contract.test.ts b/src/__tests__/derived-wellness-value-contract.test.ts new file mode 100644 index 000000000..9034c98da --- /dev/null +++ b/src/__tests__/derived-wellness-value-contract.test.ts @@ -0,0 +1,115 @@ +/** + * The published `WellnessScoreValue` schema against the value the engine + * actually returns. + * + * `GET /api/insights/derived` types its `value` as an open record — one + * envelope carries eighteen differently-shaped metrics, so no single `$ref` + * fits — which meant every metric's payload shape was undocumented and a + * client had to inspect a live response to learn what was in it. The three + * wellness scores are modelled now, published through the forced-components + * slot, and this holds the published shape to the runtime one: a field added + * to the engine and not to the schema, or the reverse, fails here rather than + * shipping as a spec that describes a payload nobody sends. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + strainTrimpCache: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { findUnique: vi.fn().mockResolvedValue({ timezone: "UTC" }) }, + }, +})); +vi.mock("@/lib/insights/derived/readiness", () => ({ + computeReadiness: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { computeReadiness } from "@/lib/insights/derived/readiness"; +import { computeWellnessScore } from "@/lib/insights/derived/wellness-scores"; +import { wellnessScoreValue } from "@/lib/openapi/routes/insights/schemas"; +import { openApiComponents } from "@/lib/openapi/routes"; + +const findMany = prisma.measurement.findMany as ReturnType; +const NOW = new Date("2026-06-02T08:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +beforeEach(() => { + vi.mocked(computeReadiness).mockResolvedValue({ + status: "ok", + value: { + score: 74, + band: "green", + components: [ + { key: "rhr", value: 90, weight: 0.5 }, + { key: "hrv", value: null, weight: 0 }, + ], + }, + } as never); +}); + +describe("published WellnessScoreValue", () => { + it("is registered as a component even though nothing $refs it", () => { + expect(openApiComponents.schemas).toHaveProperty("WellnessScoreValue"); + expect(openApiComponents.schemas?.WellnessScoreValue).toBe( + wellnessScoreValue, + ); + }); + + it("accepts the recovery value the engine emits, field for field", async () => { + findMany.mockResolvedValue([ + { + value: 72.4, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + { + value: 60, + measuredAt: new Date("2026-05-31T12:00:00Z"), + source: "COMPUTED", + }, + ]); + const derived = await computeWellnessScore( + "RECOVERY_SCORE", + "u1", + PROFILE, + { now: NOW }, + ); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + + // Not just "parses" — the published field list and the emitted one are the + // same set. A permissive schema would pass the parse above while quietly + // omitting a field, which is the failure this whole change is about. + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + }); + + it("accepts the strain value, whose anchor the other two never carry", async () => { + findMany.mockResolvedValue([ + { + value: 55, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + ]); + vi.mocked(prisma.strainTrimpCache.findUnique).mockResolvedValue({ + anchor: "personal", + } as never); + const derived = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + }); +}); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index d787c2475..2a3f56e68 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -89,6 +89,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { computeDerivedMetric } from "@/lib/insights/derived"; +import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -108,6 +109,14 @@ function makeReq(metric?: string, type?: string): NextRequest { return new NextRequest(url); } +function makeReqWith(params: Record): NextRequest { + const url = new URL("http://localhost/api/insights/derived"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new NextRequest(url); +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); @@ -205,3 +214,59 @@ describe("GET /api/insights/derived", () => { expect(body.data?.reason).toBe("no_readings_in_window"); }); }); + +describe("GET /api/insights/derived — windowDays", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("threads a caller-supplied window into the compute call", async () => { + await callGet(makeReqWith({ metric: "VITALS_BASELINE", windowDays: "30" })); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ metric: "VITALS_BASELINE", windowDays: 30 }), + ); + }); + + it("leaves the window undefined when the caller omits it, so each engine keeps its own default", async () => { + await callGet(makeReq("VITALS_BASELINE")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ windowDays: undefined }), + ); + }); + + it("accepts the ceiling exactly", async () => { + const res = await callGet( + makeReqWith({ + metric: "VITALS_BASELINE", + windowDays: String(DERIVED_MAX_WINDOW_DAYS), + }), + ); + expect(res.status).toBe(200); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ windowDays: DERIVED_MAX_WINDOW_DAYS }), + ); + }); + + it("422s one day past the ceiling without touching the compute layer", async () => { + const res = await callGet( + makeReqWith({ + metric: "VITALS_BASELINE", + windowDays: String(DERIVED_MAX_WINDOW_DAYS + 1), + }), + ); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a zero, negative, fractional or non-numeric window", async () => { + for (const windowDays of ["0", "-7", "1.5", "thirty", ""]) { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet( + makeReqWith({ metric: "VITALS_BASELINE", windowDays }), + ); + expect(res.status, `windowDays=${windowDays}`).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 7d6b716e9..4d904aa4c 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -1,7 +1,7 @@ /** * v1.10.0 — generic derived-wellness-metric route. * - * `GET /api/insights/derived?metric=[&type=]` + * `GET /api/insights/derived?metric=[&type=][&windowDays=N]` * serves the compute-once `Derived` value for any metric registered * in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route * precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed @@ -33,6 +33,7 @@ import { DERIVED_METRIC_IDS, type DerivedMetricId, } from "@/lib/insights/derived"; +import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -66,6 +67,20 @@ const derivedQuerySchema = z.object({ // the dispatcher; an unsupported value yields an `insufficient`, not a // 422, so the contract stays forgiving for iOS combinations. type: z.string().optional(), + // Trailing window override, in days. Absent means "engine default" — each + // compute keeps its own (14 for the wellness trend, 30 for the baselines, a + // year for vascular age), so omitting the parameter changes nothing. The + // ceiling is the point past which the derived engines stop resolving against + // DAY rollup buckets and degrade to an uncapped raw read; the reasoning is + // written out at `DERIVED_MAX_WINDOW_DAYS`. Out of range is a 422 rather + // than a silent clamp: a caller that asked for a year should learn that it + // is not on offer, not read back a number it did not choose. + windowDays: z.coerce + .number() + .int() + .min(1) + .max(DERIVED_MAX_WINDOW_DAYS) + .optional(), }); export const GET = apiHandler(async (request: NextRequest) => { @@ -88,6 +103,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = derivedQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), type: request.nextUrl.searchParams.get("type") ?? undefined, + windowDays: request.nextUrl.searchParams.get("windowDays") ?? undefined, }); if (!parsed.success) { annotate({ @@ -113,11 +129,21 @@ export const GET = apiHandler(async (request: NextRequest) => { userId: user.id, profile, type: parsed.data.type ?? null, + windowDays: parsed.data.windowDays, }); annotate({ action: { name: "insights.derived" }, - meta: { metric, status: derived.status }, + meta: { + metric, + status: derived.status, + // Both halves of the window story: what the caller asked for (null when + // they asked for nothing) and what the engine actually read. They differ + // for a metric that composes fixed per-pillar windows, and a dashboard + // that sees them drift apart is looking at a real answer, not a bug. + window_days_requested: parsed.data.windowDays ?? null, + window_days_effective: derived.provenance.windowDays, + }, }); // v1.13.2 — additive per-score assessment: a short "why is this score what diff --git a/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts b/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts new file mode 100644 index 000000000..1a6618097 --- /dev/null +++ b/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts @@ -0,0 +1,129 @@ +/** + * `windowDays` is a route parameter now, and a route parameter that a + * dispatch arm quietly drops is worse than no parameter at all — the caller + * asks for sixty days, gets fourteen, and has nothing in the payload to tell + * them apart. This suite freezes the two halves of that contract: + * + * 1. Every dispatch arm that CAN honour a caller-supplied window does, and + * says so in `provenance.windowDays`. An arm added later that forgets to + * thread `args.windowDays` fails here rather than shipping silently. + * 2. The arms that structurally cannot honour it are named, and their + * `provenance.windowDays` still reports the window they actually used — + * so a client comparing what it asked for against what came back can + * see the difference instead of guessing. + * + * The check is behavioural, not a grep over the dispatcher's source: it calls + * each metric with an unmistakable window and reads the answer back. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + strainTrimpCache: { findUnique: vi.fn().mockResolvedValue(null) }, + intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("UTC"), +})); +// HEALTH_SCORE recomposes the whole pillar report; stub it at the adapter +// boundary and hand back a provenance whose window is one the caller could +// never have asked for, so "it ignored me" is observable rather than inferred. +vi.mock("@/lib/analytics/score/derived", () => ({ + computeHealthScoreDerived: vi.fn(async () => ({ + status: "insufficient" as const, + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { + inputs: [], + source: "none" as const, + windowDays: 365, + computedAt: "2026-06-02T09:00:00+02:00", + }, + reason: "no_pillars", + })), +})); + +import { computeDerivedMetric } from "../dispatch"; +import { DERIVED_METRIC_IDS, getDerivedMetricMeta } from "../registry"; +import type { DerivedMetricId } from "../registry"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); +/** A window no engine uses as a default, so an echo cannot be a coincidence. */ +const ASKED = 77; + +/** + * Dispatch arms that structurally cannot honour a caller-supplied window. + * + * `HEALTH_SCORE` is a composite of pillar reads, each pinned to the window its + * own clinical convention requires (an HbA1c pillar does not become a 77-day + * question because the caller typed 77). Its `provenance.windowDays` reports + * the widest pillar window it actually used, which is the honest answer. + * + * Adding an id here is a deliberate act: it means the parameter is documented + * as inert for that metric, not that threading it was forgotten. + */ +const WINDOW_AGNOSTIC: DerivedMetricId[] = ["HEALTH_SCORE"]; + +beforeEach(() => vi.clearAllMocks()); + +describe("derived dispatch — windowDays", () => { + const implemented = DERIVED_METRIC_IDS.filter( + (id) => getDerivedMetricMeta(id)?.implemented === true, + ); + + it("has metrics to check (an empty sweep would pass vacuously)", () => { + expect(implemented.length).toBeGreaterThan(10); + }); + + it.each(implemented.filter((id) => !WINDOW_AGNOSTIC.includes(id)))( + "%s reports back the window it was asked for", + async (metric) => { + const derived = await computeDerivedMetric({ + metric, + userId: "u1", + profile: PROFILE, + windowDays: ASKED, + now: NOW, + }); + // A stub arm would answer `not_implemented` with a zero window; the + // registry says these are implemented, so that would be the drift. + expect( + derived.status === "insufficient" ? derived.reason : "ok", + ).not.toBe("not_implemented"); + expect(derived.provenance.windowDays).toBe(ASKED); + }, + ); + + it.each(WINDOW_AGNOSTIC)( + "%s ignores the window but still reports the one it used", + async (metric) => { + const derived = await computeDerivedMetric({ + metric, + userId: "u1", + profile: PROFILE, + windowDays: ASKED, + now: NOW, + }); + expect(derived.provenance.windowDays).not.toBe(ASKED); + expect(derived.provenance.windowDays).toBeGreaterThan(0); + }, + ); +}); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index ce8ae1d1b..e7106d71a 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -21,6 +21,7 @@ import { bandWellnessScore, type WellnessScoreValue, } from "../wellness-scores"; +import { SPARKLINE_MAX_POINTS } from "../types"; const PROFILE = { ageYears: 40, sex: "MALE" as const }; const NOW = new Date("2026-06-02T08:00:00Z"); @@ -228,6 +229,77 @@ describe("computeWellnessScore", () => { expect(readinessMock).not.toHaveBeenCalled(); }); + it("a widened window still collapses every night through the canonical resolver", async () => { + // The wake-day trap: a worn band stamps the wake morning, the COMPUTED + // proxy stamps the night that ended, so ONE night arrives as two rows a + // calendar day apart. Only `resolveCanonicalRecovery` pairs them. With the + // window fixed at 14 days a bypass would be invisible in most fixtures, so + // this seeds FORTY such nights and asks for sixty days. + const NIGHTS = 40; + const rows: Array<{ value: number; measuredAt: Date; source: string }> = []; + for (let back = 0; back < NIGHTS; back += 1) { + const wake = new Date("2026-06-02T06:00:00Z"); + wake.setUTCDate(wake.getUTCDate() - back); + const priorNoon = new Date("2026-06-01T12:00:00Z"); + priorNoon.setUTCDate(priorNoon.getUTCDate() - back); + rows.push({ value: 80, measuredAt: wake, source: "WHOOP" }); + rows.push({ value: 50, measuredAt: priorNoon, source: "COMPUTED" }); + } + // The reader orders newest-first; mirror that. + rows.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime()); + findMany.mockResolvedValue(rows); + + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + windowDays: 60, + }); + + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + const v = r.value as WellnessScoreValue; + // Forty nights, not eighty rows — the resolver ran over the whole window, + // not just its head. + expect(v.daysInWindow).toBe(NIGHTS); + expect(r.coverage.historyDays).toBe(NIGHTS); + // Every canonical row is the WHOOP 80, so the trend against the prior + // nights is flat. A bypass would leave the COMPUTED 50s in the mean and + // push this to roughly +15. + expect(v.score).toBe(80); + expect(v.trendDelta).toBe(0); + expect(v.series.every((point) => point === 80)).toBe(true); + // The sparkline stays capped however wide the window gets. + expect(v.series.length).toBe(SPARKLINE_MAX_POINTS); + // The read itself honoured the sixty days. + const where = findMany.mock.calls[0][0].where as { + measuredAt: { gte: Date }; + }; + const spanDays = Math.round( + (NOW.getTime() - where.measuredAt.gte.getTime()) / (24 * 60 * 60 * 1000), + ); + expect(spanDays).toBe(60); + }); + + it("reports the requested window and the days actually covered as two different numbers", async () => { + // Three weeks of record, thirty days asked for. The window is a request, + // not a promise: provenance carries what was asked, coverage carries what + // backed the answer. + const rows = Array.from({ length: 21 }, (_, back) => { + const at = new Date("2026-06-01T12:00:00Z"); + at.setUTCDate(at.getUTCDate() - back); + return { value: 60, measuredAt: at, source: "COMPUTED" }; + }); + findMany.mockResolvedValue(rows); + const r = await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + expect(r.provenance.windowDays).toBe(30); + expect(r.coverage.historyDays).toBe(21); + expect((r.value as WellnessScoreValue).daysInWindow).toBe(21); + }); + it("STRESS still hard-filters to the COMPUTED source", async () => { findMany.mockResolvedValue([ { diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts index 434dd67f5..6a36f04b4 100644 --- a/src/lib/insights/derived/types.ts +++ b/src/lib/insights/derived/types.ts @@ -93,6 +93,32 @@ export interface DerivedInsufficient { */ export const SPARKLINE_MAX_POINTS = 30; +/** + * Ceiling on a CALLER-SUPPLIED trailing window (`?windowDays=` on + * `GET /api/insights/derived`). Engine defaults are untouched by it — a metric + * whose own default is wider (vascular age reads a year, cardio fitness half of + * one) keeps reading that far when the caller names no window. This bounds only + * what a request may ask for. + * + * Ninety is where the tier this route promises runs out, not a tidy number. + * `readBestGranularityRollups` walks its granularity floors coarsest-first and + * the WEEK floor opens at 91 days, so a 91-day request resolves to WEEK buckets + * for any account with rollup coverage. The baseline engines cannot use those — + * a spread composed from WEEK `sd` is not the same statistic — so they reject + * the coarser tier and fall through to the per-type live read, which is a raw + * `measurement.findMany` with no row cap. For a densely sampled type (an + * Apple-Health heart-rate stream is hundreds of rows a day) that is the whole + * history scan this route was built to avoid, and the analytics-read budget + * allows 120 of them a minute per account. One day either side of the floor is + * therefore the difference between a bounded bucket read and an unbounded one. + * + * Nothing renderable is lost at the ceiling: a trailing `series` is capped to + * `SPARKLINE_MAX_POINTS` regardless, so past thirty days a wider window only + * broadens the trend mean behind the value, and ninety days is already three + * times what the sparkline can show and six times the trend default. + */ +export const DERIVED_MAX_WINDOW_DAYS = 90; + export type Derived = DerivedOk | DerivedInsufficient; /** Narrowing type guard — `true` when the value computed successfully. */ diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index ba4a04da3..df326ba58 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -57,7 +57,7 @@ import { encounterPaths } from "./encounters"; import { vaccinationPaths } from "./vaccinations"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; -import { insightsPaths } from "./insights"; +import { insightsPaths, wellnessScoreValue } from "./insights"; import { integrationPaths } from "./integrations"; import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; @@ -200,12 +200,22 @@ export const openApiComponents: NonNullable = { // but every actual consumer is a `.extend()` of it (`PutCoachPrefsRequest`, // `CoachPrefsResponse`) — `.extend()` builds a new object, so the base // never carries forward to a $ref on its own. + // + // `WellnessScoreValue` is here for a different reason. `GET + // /api/insights/derived` serves eighteen metrics through one envelope, so + // its `value` has to stay an open record — no single `$ref` can describe + // it. That left every metric's payload undocumented, and a client team read + // the DTO and reported two fields as missing that were already on the wire. + // Publishing the shape a `$ref` cannot reach is exactly what this slot is + // for; the `value` description names it as the shape the three score ids + // return. schemas: { Medication: medicationResource, MedicationDoseHistoryImportFatalReason: medicationDoseHistoryImportFatalReasonEnum, CoachPrefs: coachPrefsSchema, MoodTagLayout: moodTagLayout, + WellnessScoreValue: wellnessScoreValue, }, // v1.36.0 — the per-request account selector, defined in the sharing route // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps diff --git a/src/lib/openapi/routes/insights/index.ts b/src/lib/openapi/routes/insights/index.ts index 22c8041a7..ad2739082 100644 --- a/src/lib/openapi/routes/insights/index.ts +++ b/src/lib/openapi/routes/insights/index.ts @@ -4,3 +4,4 @@ * Part of the OpenAPI route table; aggregated in `../index.ts`. */ export { insightsPaths } from "./paths"; +export { wellnessScoreValue } from "./schemas"; diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index ec3b8220c..4d4f0d0f5 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -804,7 +804,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Derived wellness metric (compute-once)", description: - "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that actually backed it. Auth via cookie or Bearer.", requestParams: { query: derivedMetricQuery, }, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 31778b692..a3391607f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -13,6 +13,10 @@ import { VITALS_BASELINE_TYPES, SAME_TIME_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; +import { + DERIVED_MAX_WINDOW_DAYS, + SPARKLINE_MAX_POINTS, +} from "@/lib/insights/derived/types"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; @@ -144,9 +148,97 @@ export const derivedMetricQuery = z .describe( "The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving.", ), + windowDays: z.coerce + .number() + .int() + .min(1) + .max(DERIVED_MAX_WINDOW_DAYS) + .optional() + .describe( + `Trailing window the metric summarises, in days (1–${DERIVED_MAX_WINDOW_DAYS}). Omit it and each metric keeps its own default — 14 days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always read \`provenance.windowDays\` back: it is the window the engine ACTUALLY used, and \`HEALTH_SCORE\` composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a request, not a promise about coverage — \`coverage.historyDays\` is how many days of record actually backed the answer, and it is smaller than \`windowDays\` for anyone whose history is shorter than the window. Not accepted on the batch route, which always runs the engine defaults.`, + ), }) .meta({ id: "DerivedMetricQuery" }); +/** + * The `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` value object. + * + * Modelled here because the free-form `value` record left every derived + * metric's payload shape undocumented, and a client reading the DTO could not + * see that `series`, `daysInWindow` and `asOf` were already on the wire. + * + * Nothing `$ref`s it — `value` has to stay an open record while one route + * serves eighteen differently-shaped value objects — so it is registered in + * the forced-components slot in `../index.ts`, the same slot `Medication` and + * `CoachPrefs` use. Its fields are held against the runtime interface by + * `derived-wellness-value-contract.test.ts`, so the published shape cannot + * drift away from what the engine actually returns. + * + * STILL OPAQUE, and known to be: the value shapes of `VITALS_BASELINE` (prose + * only, in the `value` description), `FITNESS_AGE`, `VASCULAR_AGE_DELTA`, + * `HRV_BALANCE`, `BMI`, `SLEEP_SCORE`, `READINESS`, `COINCIDENT_DEVIATION`, + * `TRAJECTORY`, `SAME_TIME_BASELINE`, `SIX_MINUTE_WALK_BAND`, `HEALTH_SCORE` + * and the three fixed-type baselines. Each is a TypeScript interface next to + * its engine under `src/lib/insights/derived/`; modelling them is a schema per + * engine and belongs in its own change rather than riding along here. + */ +export const wellnessScoreValue = z + .object({ + score: z.number().int().describe("The latest persisted 0–100 score."), + band: z + .enum(["green", "yellow", "red"]) + .describe( + "Server's verdict on the score. Direction-aware: recovery bands high-is-good, stress and strain invert, so a client must never re-band the number itself.", + ), + trendDelta: z + .number() + .int() + .nullable() + .describe( + "Score minus the mean of the earlier days in the window; null when the window holds only one day.", + ), + daysInWindow: z + .number() + .int() + .describe( + "Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter record answers a wide request with fewer days, and this is where that shows.", + ), + asOf: z.iso + .datetime({ offset: true }) + .describe("`measuredAt` of the latest score."), + series: z + .array(z.number()) + .describe( + `Trailing scores, oldest → newest, capped at ${SPARKLINE_MAX_POINTS} points however wide the window is. For RECOVERY these are the canonical per-night values: a worn band and the server's own proxy write the same night on different clocks, and the server collapses them to one value per night before this array is built.`, + ), + anchor: z + .enum(["personal", "population"]) + .nullable() + .optional() + .describe( + "STRAIN only — whether this score was judged against the user's own training history or the cold-start population reference. Null for recovery and stress.", + ), + components: z + .array( + z.object({ + key: z.string().describe("Contributor id, e.g. rhr / hrv / sleep."), + value: z + .number() + .nullable() + .describe("0–100 sub-score, null when the input was missing."), + weight: z + .number() + .describe("Effective weight after redistributing missing inputs."), + }), + ) + .nullable() + .optional() + .describe( + "RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition.", + ), + }) + .meta({ id: "WellnessScoreValue" }); + export const derivedCoverage = z .object({ requiredInputs: z @@ -238,7 +330,7 @@ export const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", + "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", ), coverage: derivedCoverage, confidence: derivedConfidence