mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): let a caller choose the derived-metric window
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and threads it to the dispatcher. Omitting it changes nothing: every engine keeps its own default, so a caller that asks for nothing reads exactly what it read before. Ninety is where the tier this route promises runs out. The rollup router opens its WEEK floor at 91 days, and the baseline engines cannot compose a spread from WEEK buckets, so a 91-day request falls through to a raw `measurement.findMany` with no row cap — for a densely sampled type that is a full history scan, and the analytics-read budget allows 120 of them a minute per account. The trailing `series` is capped at 30 points regardless, so past the ceiling a wider window would only broaden the mean behind the value. Out of range is a 422 rather than a clamp. An unhonoured window is visible rather than silent. `provenance.windowDays` is the window the engine actually used and `coverage.historyDays` the days that actually backed it, so a three-week record answering a thirty-day request reports both numbers instead of implying coverage it does not have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the widest; a new suite freezes that it is the only metric that does, so an arm added later cannot quietly drop the parameter. Recovery keeps its canonical resolution under the wider window. A worn band stamps the wake morning and the computed proxy the night before, and only `resolveCanonicalRecovery` pairs them; a test seeds forty such nights, asks for sixty days, and holds the answer to forty nights with a flat trend — a bypass leaves eighty rows and a false climb. Contract: model `WellnessScoreValue` field for field and publish it. The envelope's `value` has to stay an open record because one route serves eighteen differently-shaped metrics, so nothing can `$ref` the shape and it went out undocumented — `series`, `daysInWindow` and `asOf` were on the wire with no way to find them short of inspecting a live response. It is registered in the forced-components slot instead, held against the runtime interface by a test, and the fields it does not cover are named in the schema comment so the remaining gap is known rather than invisible.
This commit is contained in:
1 parent
d290422923
commit
d7d8e437ee
11 files changed
+659
-9
No files matched your search
+118
-4
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
* Part of the OpenAPI route table; aggregated in `../index.ts`.
|
||||
*/
|
||||
export { insightsPaths } from "./paths";
|
||||
export { wellnessScoreValue } from "./schemas";
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user