mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/derived-window-days' into release/v1.37.28
This commit is contained in:
commit
e417376d29
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