Merge branch 'feat/derived-window-days' into release/v1.37.28

This commit is contained in:
Marc-André Bombeck committed 2026-08-24 02:57:12 +02:00
commit e417376d29
11 files changed
+659 -9

No files matched your search

+118 -4
View File
@@ -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<T>` 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
@@ -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<typeof vi.fn>;
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<string, unknown>).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<string, unknown>).sort(),
);
});
});
@@ -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<string, string>): 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();
}
});
});
+28 -2
View File
@@ -1,7 +1,7 @@
/**
* v1.10.0 — generic derived-wellness-metric route.
*
* `GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<MeasurementType>]`
* `GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<MeasurementType>][&windowDays=N]`
* serves the compute-once `Derived<T>` 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
@@ -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);
},
);
});
@@ -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([
{
+26
View File
@@ -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<T> = DerivedOk<T> | DerivedInsufficient;
/** Narrowing type guard — `true` when the value computed successfully. */
+11 -1
View File
@@ -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<ZodOpenApiObject["components"]> = {
// 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
+1
View File
@@ -4,3 +4,4 @@
* Part of the OpenAPI route table; aggregated in `../index.ts`.
*/
export { insightsPaths } from "./paths";
export { wellnessScoreValue } from "./schemas";
+1 -1
View File
@@ -804,7 +804,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Derived wellness metric (compute-once)",
description:
"v1.10.0 — the compute-once `Derived<T>` 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<T>` 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<T>` 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<T>` 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,
},
+93 -1
View File
@@ -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