feat(analytics): replace health score with reference composite

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 21:10:15 +02:00
1 parent 666a85d311
commit c91558f18f
87 files changed
+6033 -5868

No files matched your search

@@ -94,8 +94,8 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({
}),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: vi.fn().mockResolvedValue(null),
vi.mock("@/lib/analytics/score/reader", () => ({
computeUserHealthScore: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/analytics/correlations-fast-path", () => ({
@@ -159,8 +159,8 @@ beforeEach(async () => {
await import("@/lib/rollups/measurement-coverage");
const { computeBpInTargetFastPath } =
await import("@/lib/analytics/bp-in-target-fast-path");
const { computeUserHealthScoreFastPath } =
await import("@/lib/analytics/health-score-fast-path");
const { computeUserHealthScore } =
await import("@/lib/analytics/score/reader");
const { computeCorrelationHypothesesFastPath } =
await import("@/lib/analytics/correlations-fast-path");
const { computeSummariesSlice } =
@@ -179,9 +179,7 @@ beforeEach(async () => {
priorMonth: null,
priorYear: null,
});
(
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue(null);
(computeUserHealthScore as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue({ hypotheses: [] });
+111 -89
View File
@@ -6,7 +6,8 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -16,14 +17,9 @@ import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import {
buildHealthScoreBpInputs,
buildHealthScoreWeightTarget,
} from "@/lib/analytics/health-score-inputs";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -298,7 +294,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// these envelopes inside `buildHealthScoreBpInputs`.
let bpEnvelope: BpInTargetEnvelope | null = null;
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
const bpTargets = resolveEffectiveBpTargets(
{
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
user.thresholdsJson,
);
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
// v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked
@@ -321,29 +326,67 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// pair of `readRollupBuckets` reads on the rollup path and is a
// no-op when the user has no BP rows at all.
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek] = await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
]);
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
[
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
],
);
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
} else if (clinicalBpTargets) {
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
]);
}
// v1.17 W1d — the BD-Zielbereich headline standardises on the
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
// remains carried below for the BP detail page's long view only.
@@ -476,64 +519,48 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
: result;
}
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
// composite of BP-in-target % + weight-trend alignment + mood
// stability + medication compliance. The "vs last week" delta
// re-runs the same compute against a 7-day-shifted snapshot.
//
// v1.4.37 W2 — probe-gated helper. The weight pillar derives from
// DAY-bucket means on `measurement_rollups` when the user has full
// coverage; the source-attribution accordion still pulls a narrow
// 2-column projection from `measurements` for the ingest-path
// pills. Falls back to the legacy 37-day raw read on partial /
// missing coverage. Path annotate sits on `meta.healthScore.path`.
//
// v1.17 W1b — the BP-pillar inputs come from the ONE shared
// `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so
// the ring and the insights card grade the pillar off identical inputs
// (same 90-day window via W1d, same all-time fallback, same graded score,
// same prior-week delta values). The hand-rolled per-surface assembly that
// let the two diverge is gone.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
// account drops the mood-stability pillar from the ring instead of
// being silently scored on data hidden everywhere else. The dashboard
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
// route serves core metrics too, so a disabled-glucose account must NOT
// 403 the whole payload; instead the glucose-only surfaces (per-context
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
// never reach a hidden surface. Mirrors the mood-pillar gate above.
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
// v1.34 — the versioned cardiometabolic reference report. The shared reader
// admits only evidence-backed pillars with their own windows and coverage
// floors, then applies the three-domain breadth rule. Its BP envelopes come
// from the same rollup/live fast path the dashboard uses; every other domain
// resolves through the canonical reader and fails independently.
const [
glucoseEnabled,
labsEnabled,
sleepEnabled,
mentalHealthEnabled,
scoreSourcePriority,
] = await Promise.all([
isModuleEnabled(user.id, "glucose"),
isModuleEnabled(user.id, "labs"),
isModuleEnabled(user.id, "sleep"),
isModuleEnabled(user.id, "mentalHealth"),
loadUserSourcePriority(user.id),
]);
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
// The reference instant the score is graded as-of. Reused below so the Rest
// Mode annotation resolves against the SAME window the score covers, not a
// fresh "now" that could fall outside it.
const scoredAt = new Date();
// v1.34 — the weight yardstick comes from the ONE shared
// `buildHealthScoreWeightTarget` builder the dashboard snapshot also uses,
// so the ring and the card grade weight against the identical band. It hands
// over a target ONLY when the account carries an explicit override; every
// other account scores the bare trend. The fabricated BMI-22 band that stood
// in here is gone.
const weightTargetInputs = buildHealthScoreWeightTarget(
{
heightCm: user.heightCm ?? null,
const healthScore = await computeUserHealthScore({
userId: user.id,
now: scoredAt,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson ?? null,
},
(user.thresholdsJson ?? null) as ThresholdOverridesJson | null,
);
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
...weightTargetInputs,
now: scoredAt,
coverage,
moodEnabled,
modules: {
glucose: glucoseEnabled,
labs: labsEnabled,
sleep: sleepEnabled,
mentalHealth: mentalHealthEnabled,
},
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
});
// v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
@@ -648,6 +675,7 @@ async function computeSleepStageBreakdown(
.map((n) => {
const nightStages: Record<string, number> = {};
for (const [stage, mins] of Object.entries(n.stages)) {
if (mins == null) continue;
nightStages[stage] = mins;
stages[stage] = (stages[stage] ?? 0) + mins;
totalMinutes += mins;
@@ -687,9 +715,3 @@ async function computeSleepStageBreakdown(
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
// used by the weight-weekday + day-key helpers inside the old inline
// correlation builder.
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
// and the inline `computeUserHealthScore` body relocated to
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
// rollup / live dispatcher can be unit-tested independently of the
// route. The route now delegates through `computeUserHealthScoreFastPath`.
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
decryptContextFromBytes,
encryptContextToBytes,
@@ -169,6 +170,7 @@ export const PUT = apiHandler(
}
const updated = await prisma.biomarker.update({ where: { id }, data });
invalidateUserHealthScore(user.id);
await auditLog("biomarker.update", {
userId: user.id,
@@ -206,6 +208,7 @@ export const DELETE = apiHandler(
}),
prisma.biomarker.delete({ where: { id } }),
]);
invalidateUserHealthScore(user.id);
await auditLog("biomarker.delete", {
userId: user.id,
@@ -58,7 +58,14 @@ const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
score: {
value: 82,
band: "good",
delta: 3,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
@@ -30,6 +30,10 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserHealthScore: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -56,6 +60,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -129,11 +134,13 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
it("upserts each approved observational or notice dismissal for the caller", async () => {
for (const itemKey of [
"milestone:record_first:WEIGHT:2026-07-16",
"ecg_new_recording:2026-07-16T08:00:00.000Z",
"tension_window:2026-07-16:afternoon",
"same_time_baseline:2026-07-16:ACTIVITY_STEPS",
"health_score_algorithm:2",
]) {
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
const res = await callPost(makeReq({ itemKey }));
@@ -161,4 +168,12 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(res.status).toBe(429);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("evicts score-bearing server caches after the algorithm notice write", async () => {
const res = await callPost(
makeReq({ itemKey: "health_score_algorithm:2" }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
+9 -6
View File
@@ -1,12 +1,11 @@
/**
* POST /api/daily/digest/dismiss
*
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
* rejects any `itemKey` that doesn't carry one of the three dismissible
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
* "Dismiss / mark seen" for observational Today rail items and versioned
* informational notices. Actionable kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are never reachable here:
* `dismissPriorityItemSchema` rejects any `itemKey` without an approved
* observational or notice prefix before a lookup runs.
*
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
@@ -27,6 +26,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
@@ -72,6 +72,9 @@ export const POST = apiHandler(async (req: NextRequest) => {
update: {},
create: { userId: user.id, itemKey },
});
if (itemKey.startsWith("health_score_algorithm:")) {
invalidateUserHealthScore(user.id);
}
annotate({
action: { name: "daily.digest.item.dismissed" },
-4
View File
@@ -104,11 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
type ResolvedBiomarker,
serialiseLabResult,
@@ -217,6 +218,7 @@ export const PUT = apiHandler(
data,
include: { biomarker: { select: biomarkerSelect } },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.update", {
userId: user.id,
@@ -250,6 +252,7 @@ export const DELETE = apiHandler(
where: { id },
data: { deletedAt: new Date() },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.delete", {
userId: user.id,
+2
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -191,6 +192,7 @@ async function commitOcrRows(request: NextRequest) {
takenAt: created.takenAt,
});
}
if (inserted.length > 0) invalidateUserHealthScore(user.id);
// S9 — cross-link the freshly inserted labs to the vault document the client
// filed the scanned bytes into. Best-effort: the labs are the authoritative
+2
View File
@@ -21,6 +21,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -66,6 +67,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null },
});
if (count > 0) invalidateUserHealthScore(user.id);
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
+2
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -254,6 +255,7 @@ async function postLabResult(request: NextRequest) {
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.create", {
userId: user.id,
@@ -17,6 +17,7 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -338,6 +339,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
externalId: `assessment:${assessment.id}`,
},
});
invalidateUserHealthScore(user.id);
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
-24
View File
@@ -7,7 +7,6 @@ import Link from "next/link";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -345,27 +344,6 @@ export default function InsightsPageClient() {
const heroTension = snapshotHealthScore?.tension ?? null;
const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null;
// v1.34 — the weight pillar's yardstick, disclosed on the score card. The
// server sends the band in canonical kg; the reader's display unit is a
// client fact, so the page formats the label and the card renders the line.
// A payload without `targetSource` (an older cached body) discloses nothing
// rather than guessing at one.
const unitDisplay = useUnitDisplay();
const weightComponent = analytics?.healthScore?.components.weight;
const weightTargetSource = weightComponent?.targetSource ?? null;
const weightTargetBand = weightComponent?.target ?? null;
const heroWeightYardstick = weightTargetSource
? {
source: weightTargetSource,
targetLabel: weightTargetBand
? `${unitDisplay.toDisplay("WEIGHT", weightTargetBand.min)}–${unitDisplay.toDisplay(
"WEIGHT",
weightTargetBand.max,
)} ${unitDisplay.unitFor("WEIGHT")}`
: null,
}
: null;
// v1.12.6 — the page owns the ONE overview derived batch. The wellness
// strip (above the briefing) and the vitals grid (below it) both read this
// single query instance, so the wellness lift adds no second request.
@@ -594,8 +572,6 @@ export default function InsightsPageClient() {
// forwards them to the score card; null leaves the card quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
// v1.34 — name the yardstick the weight pillar was graded against.
weightYardstick={heroWeightYardstick}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
@@ -52,7 +52,14 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
generatedAt: "2026-07-16T06:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 3 },
score: {
value: 82,
band: "green",
delta: 3,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: {
sourceMetric: "bp",
tone: "watch",
@@ -91,6 +98,24 @@ describe("<TodayHero>", () => {
expect(html).toContain('data-slot="today-hero-score-delta"');
});
it("does not present a suppressed algorithm jump as score movement", () => {
const html = render(
<TodayHero
digest={digest({
score: {
value: 82,
band: "green",
delta: 20,
deltaReason: "algorithm_changed",
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
})}
/>,
);
expect(html).not.toContain('data-slot="today-hero-score-delta"');
});
it("wires each PriorityItem action to its existing destination via href", () => {
const html = render(<TodayHero digest={digest()} />);
// Every S1 rail action carries an href, so PriorityCard renders it as a
+3 -1
View File
@@ -229,7 +229,9 @@ export function TodayHero({
/>
</Link>
</div>
{digest.score && digest.score.delta !== null ? (
{digest.score &&
digest.score.delta !== null &&
digest.score.deltaReason === null ? (
<span
data-slot="today-hero-score-delta"
className="text-muted-foreground text-xs tabular-nums"
@@ -1,375 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
HealthScoreCard,
type HealthScoreCardProps,
} from "../health-score-card";
/**
* v1.4.25 W8e — Health-Score provenance accordion.
*
* The card now grows a tap-to-expand accordion under the four
* component sub-bars. The accordion surfaces:
* - per-component value-bar + numeric value
* - effective weight share (second, narrower bar)
* - source pill (manual / withings / appleHealth / mixed / none)
* - a "mixed sources" `role="status"` banner when at least one row
* has `source === "mixed"`
* - a "Provisional" badge in the header when < 50% of inputs are
* present
*
* SSR is the test convention in this project (no `@testing-library/react`
* dependency). The card exposes `initiallyExpanded` so the markup of
* the expanded panel renders inside `renderToStaticMarkup`. Interactive
* behaviour (click toggles `aria-expanded`) is verified at the contract
* level: collapsed state pins `aria-expanded="false"`, expanded state
* (via `initiallyExpanded`) pins `aria-expanded="true"` + renders the
* panel.
*/
const componentsAllPresent: HealthScoreCardProps["components"] = {
bp: {
value: 82,
weight: 0.3,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 75,
weight: 0.2,
source: "manual",
asOf: "2026-05-13T18:00:00.000Z",
},
mood: {
value: 90,
weight: 0.2,
source: "manual",
asOf: "2026-05-14T07:00:00.000Z",
},
compliance: {
value: 95,
weight: 0.3,
source: "manual",
asOf: "2026-05-14T00:00:00.000Z",
},
};
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
it("renders the toggle button with aria-expanded=false when collapsed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="false"/,
);
// Toggle text uses the localised "Driven by" copy.
expect(html).toContain("Driven by");
// Panel is NOT rendered when collapsed.
expect(html).not.toContain(
'data-slot="health-score-card-provenance-panel"',
);
});
it("renders the panel + sets aria-expanded=true when initiallyExpanded", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="true"/,
);
expect(html).toContain('data-slot="health-score-card-provenance-panel"');
});
it("aria-controls on the toggle points at the rendered panel's id", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Extract aria-controls value from the toggle and confirm the
// panel section uses the same id.
const controlsMatch = html.match(
/data-slot="health-score-card-provenance-toggle"[^>]*aria-controls="([^"]+)"/,
);
expect(controlsMatch).not.toBeNull();
const id = controlsMatch![1];
// The panel section must carry the same id; we don't pin the exact
// attribute order because additional ARIA attributes (e.g.
// `aria-labelledby`) may sit between `id` and `data-slot`.
const panelMatch = html.match(
new RegExp(
`<section[^>]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`,
),
);
expect(panelMatch).not.toBeNull();
});
it("sorts rows by effective weight descending (null components sink to bottom)", () => {
const components: HealthScoreCardProps["components"] = {
bp: { value: null, weight: 0, source: "none" },
weight: { value: 60, weight: 0.4, source: "manual" },
mood: { value: 80, weight: 0.3, source: "manual" },
compliance: { value: 70, weight: 0.3, source: "manual" },
};
const html = ssr(
<HealthScoreCard
score={60}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Pull every `data-component=` token from the provenance rows.
const rowMatches = Array.from(
html.matchAll(
/data-slot="health-score-card-provenance-row"[^>]*data-component="([^"]+)"/g,
),
).map((m) => m[1]);
// Weight (0.4) > mood (0.3) > compliance (0.3) — mood vs compliance
// tie-break is the alphabetical key order. BP at weight=0 sinks
// to the bottom.
expect(rowMatches).toEqual(["weight", "compliance", "mood", "bp"]);
});
it("renders source pills with locale-resolved labels (EN)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// Withings + Manual both present in the data.
expect(html).toContain("Withings");
expect(html).toContain("Manual");
});
it("renders source pills with locale-resolved labels (DE)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
"de",
);
// Manuell is the DE label for "manual".
expect(html).toContain("Manuell");
expect(html).toContain("Withings");
expect(html).toContain("Zusammensetzung");
});
it("renders the mixed-source banner with role=status when any row is mixed", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
bp: { value: 82, weight: 0.3, source: "mixed" },
};
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={components}
delta={null}
initiallyExpanded
/>,
);
expect(html).toMatch(
/data-slot="health-score-card-provenance-mixed-banner"[^>]*role="status"/,
);
// The pill on the BP row reads "Mixed" (EN).
expect(html).toContain("Mixed");
});
it("does NOT render the mixed-source banner when no row is mixed", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provenance-mixed-banner"',
);
});
it("dims empty-state rows when source is `none`", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
mood: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// The dimmed row carries opacity-50 alongside the `data-source="none"`
// attribute.
expect(html).toMatch(
/data-component="mood"[^>]*data-source="none"[^>]*class="[^"]*opacity-50/,
);
// The source pill reads "No data" (EN).
expect(html).toContain("No data");
});
it("renders the provisional badge when fewer than 50% of inputs are present", () => {
// Three out of four components null → 1 of 4 = 25 % present.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 1,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: { value: null, weight: 0, source: "none" },
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={components}
delta={null}
/>,
);
expect(html).toContain('data-slot="health-score-card-provisional-badge"');
// EN badge copy.
expect(html).toContain("Provisional");
});
it("does NOT render the provisional badge when at least 50% of inputs are present", () => {
// 2 of 4 = 50 % → NOT provisional.
const components: HealthScoreCardProps["components"] = {
bp: {
value: 80,
weight: 0.6,
source: "withings",
asOf: "2026-05-14T08:00:00.000Z",
},
weight: {
value: 70,
weight: 0.4,
source: "manual",
asOf: "2026-05-13T08:00:00.000Z",
},
mood: { value: null, weight: 0, source: "none" },
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={75}
band="green"
components={components}
delta={null}
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-provisional-badge"',
);
});
it("renders the source-pill aria-label with localised source name", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// BP row uses Withings; the aria-label uses the localised
// "Source: Withings" form.
expect(html).toContain('aria-label="Source: Withings"');
});
it("renders the value bar at 0% width for empty-state rows", () => {
const components: HealthScoreCardProps["components"] = {
...componentsAllPresent,
compliance: { value: null, weight: 0, source: "none" },
};
const html = ssr(
<HealthScoreCard
score={70}
band="yellow"
components={components}
delta={null}
initiallyExpanded
/>,
);
// Every row inside the panel carries an inline-styled width.
// We confirm the compliance (none) row's value-bar block paints
// 0% by searching for the matching row.
const complianceRowMatch = html.match(
/data-slot="health-score-card-provenance-row"[^>]*data-component="compliance"[\s\S]*?width:\s*([0-9.]+%)/,
);
expect(complianceRowMatch).not.toBeNull();
expect(complianceRowMatch?.[1]).toBe("0%");
});
it("forwards the asOf timestamp into the source-pill `title` tooltip", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
// The BP row's pill carries the title formatted via Intl.DateTimeFormat;
// we only need to confirm `as of` appears (the exact date depends on
// the JS engine's locale data).
expect(html).toContain("as of");
// The data-as-of attribute carries the raw ISO timestamp.
expect(html).toContain('data-as-of="2026-05-14T08:00:00.000Z"');
});
it("renders the footnote inside the expanded panel", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={componentsAllPresent}
delta={null}
initiallyExpanded
/>,
);
expect(html).toContain('data-slot="health-score-card-provenance-footnote"');
});
});
@@ -1,433 +1,342 @@
import { describe, it, expect } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
import {
HealthScoreCard,
type HealthScoreCardProps,
markAlgorithmNoticeDismissed,
} from "../health-score-card";
/**
* v1.4.20 phase B5 — Personal Health Score card.
*
* SSR markup pins the slot wrappers so future polish can't silently
* drop the score number, sub-bars or disclaimer. The inline
* Ask-the-Coach button retired in v1.4.27 B1 — the hero strip
* carries the CTA. The negative-assertion test below pins that
* decision so a future revival has to update the test in lock-step.
*/
const baseComponents: HealthScoreCardProps["components"] = {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
let mockAuthUser: { unitPreference: string; glucoseUnit: string } = {
unitPreference: "metric",
glucoseUnit: "mg/dL",
};
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: mockAuthUser,
}),
}));
const provenance = {
inputs: ["BLOOD_PRESSURE"],
source: "live" as const,
windowDays: 90,
computedAt: "2026-07-28T12:00:00.000Z",
};
const coverage = {
requiredInputs: 3,
presentInputs: 1,
historyDays: 7,
missing: ["ACTIVITY", "SLEEP"],
};
function render(report: HealthScoreReport, locale: Locale = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<I18nProvider initialLocale={locale}>
<HealthScoreCard report={report} />
</I18nProvider>,
);
}
function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
return {
composite: {
status: "insufficient",
coverage,
provenance,
reason: "three_domains_required",
},
pillars: [
{
id: "BLOOD_PRESSURE",
domain: "cardiometabolic",
result: {
status: "ok",
value: {
score: 82,
observed: {
value: 126,
unit: "mmHg",
label: "126/78 mmHg",
asOf: "2026-07-27T08:00:00.000Z",
sources: ["MANUAL"],
},
reference: {
kind: "clinical-threshold",
low: 120,
high: 129,
label: "120 to 129/70 to 79 mmHg",
source: "ESH 2023",
},
personalReference: {
kind: "guideline-band",
low: 115,
high: 125,
label: "115 to 125/65 to 75 mmHg",
source: "personal target",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: "graded_bp_pair_series",
},
coverage: {
requiredInputs: 12,
presentInputs: 12,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
},
{
id: "ACTIVITY",
domain: "activity",
result: {
status: "insufficient",
coverage: {
requiredInputs: 21,
presentInputs: 7,
historyDays: 7,
missing: ["14 days"],
},
provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 },
reason: "below_day_floor_or_stale",
},
},
],
delta: null,
deltaReason: "no_current_score",
scoreVersion: 2,
weightGoal: {
status: "insufficient",
coverage: {
requiredInputs: 2,
presentInputs: 0,
historyDays: 0,
missing: ["weight reading", "personal weight target"],
},
provenance: { ...provenance, inputs: ["WEIGHT"] },
reason: "no_personal_goal",
},
algorithmNotice: null,
...overrides,
};
}
beforeEach(() => {
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" };
});
describe("<HealthScoreCard>", () => {
it("renders the slot wrapper + band data attribute (green)", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).toMatch(/data-slot="health-score-card"/);
expect(html).toMatch(/data-band="green"/);
it("keeps below-floor pillars visible without a composite headline", () => {
const html = render(report());
expect(html).toContain('data-status="insufficient"');
expect(html).toContain('data-contributor="BLOOD_PRESSURE"');
expect(html).toContain('data-contributor="ACTIVITY"');
expect(html).toContain("Not enough recent eligible data");
expect(html).not.toContain("below_day_floor_or_stale");
expect(html).toContain("Your target: 115 to 125/65 to 75 mmHg");
expect(html).not.toContain('data-slot="score-ring"');
});
it("renders the score number, /100 suffix, and band number colour", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
it("shows personal goal progress as explicitly unscored context", () => {
const html = render(
report({
weightGoal: {
status: "ok",
value: {
currentKg: 81,
target: { min: 74, max: 78 },
distanceKg: 3,
deltaKg: 1,
asOf: "2026-07-27T08:00:00.000Z",
source: "MANUAL",
},
coverage: {
requiredInputs: 2,
presentInputs: 2,
historyDays: 1,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
}),
);
expect(html).toMatch(/data-slot="health-score-card-number"/);
expect(html).toContain(">86<");
expect(html).toContain("/ 100");
// v1.12.4 — the headline number paints through the semantic `--success`
// token (AA-safe on both themes) rather than the raw dracula primitive.
expect(html).toContain("text-success");
expect(html).toContain('data-slot="health-score-weight-goal"');
expect(html).toContain('data-status="ok"');
expect(html).toContain("does not affect the Health Score");
});
it("paints the yellow band correctly", () => {
const html = ssr(
<HealthScoreCard
score={62}
band="yellow"
components={baseComponents}
delta={null}
/>,
it("renders the personal weight goal in the account unit system", () => {
mockAuthUser = { unitPreference: "imperial", glucoseUnit: "mg/dL" };
const html = render(
report({
weightGoal: {
status: "ok",
value: {
currentKg: 81,
target: { min: 74, max: 78 },
distanceKg: 3,
deltaKg: 1,
asOf: "2026-07-27T08:00:00.000Z",
source: "MANUAL",
},
coverage: {
requiredInputs: 2,
presentInputs: 2,
historyDays: 1,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
}),
);
expect(html).toMatch(/data-band="yellow"/);
expect(html).toContain("text-warning");
expect(html).toContain("178.6 lb now");
expect(html).toContain("Goal 163.1 to 172 lb");
expect(html).toContain("6.6 lb");
expect(html).not.toContain("81 kg");
});
it("paints the red band correctly", () => {
const html = ssr(
<HealthScoreCard
score={32}
band="red"
components={baseComponents}
delta={null}
/>,
it("renders fasting glucose and its reference in the account unit", () => {
mockAuthUser = { unitPreference: "metric", glucoseUnit: "mmol/L" };
const base = report();
const html = render(
report({
pillars: [
{
id: "GLYCAEMIA",
domain: "cardiometabolic",
result: {
status: "ok",
value: {
score: 80,
observed: {
value: 100,
unit: "mg/dL",
label: "Fasting glucose",
asOf: "2026-07-27T08:00:00.000Z",
sources: ["MANUAL"],
},
reference: {
kind: "clinical-threshold",
low: 70,
high: 99,
label: "70 to 99 mg/dL fasting",
source: "ADA 2026",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: "fasting_glucose",
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
},
...base.pillars.slice(1),
],
}),
);
expect(html).toMatch(/data-band="red"/);
expect(html).toContain("text-destructive");
expect(html).toContain("Fasting glucose: 5.5 mmol/L");
expect(html).toContain("3.9 to 5.5 mmol/L fasting");
expect(html).not.toContain("100 mg/dL");
expect(html).not.toContain("70 to 99 mg/dL");
});
it("renders the unavailable-delta caption when delta is null", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
it("renders the one-time method note and every score identity field", () => {
const html = render(
report({
algorithmNotice: {
itemKey: "health_score_algorithm:2",
dismissed: false,
},
}),
);
expect(html).toContain("No history yet");
// No delta chip in the corner.
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
expect(html).toContain('data-slot="health-score-algorithm-notice"');
expect(html).toContain("cardiometabolic reference ranges");
});
it("renders the up-arrow + delta chip when delta > 0", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={5}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-delta-chip"/);
expect(html).toContain("+5");
expect(html).toContain("vs last week");
});
it("renders the down-arrow line when delta < 0", () => {
const html = ssr(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-7}
/>,
);
// Negative delta does NOT render the green chip.
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
expect(html).toContain("-7 vs last week");
});
it("threads aria-describedby from the delta text to the explainer body", () => {
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
// explainer caption carries the same id. v1.22 — the read no longer
// hides behind a "?" trigger (no aria-controls); the body renders inline
// and the shared `useId()` thread still connects "−3 vs last week" to the
// three-sentence read.
const html = ssr(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-3}
/>,
);
// The aria-describedby attribute lands on the delta span.
const deltaSpan = html.match(
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*vs last week[^<]*<\/span>/,
);
expect(deltaSpan).not.toBeNull();
const threadedId = deltaSpan?.[1] ?? "";
expect(threadedId).not.toBe("");
// The same id sits on the inline explainer body.
expect(html).toMatch(
new RegExp(
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
),
);
});
it("stacks the explainer caption BELOW the delta line, not beside it", () => {
// The caption is a multi-sentence read; rendering it inside the
// delta's inline-flex row squeezed the short delta into a narrow
// multi-line column on every viewport. The wrapper stacks instead.
const html = ssr(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-3}
/>,
);
const wrapper = html.match(
/data-slot="health-score-card-delta"[^>]*class="([^"]*)"/,
);
expect(wrapper).not.toBeNull();
expect(wrapper?.[1]).toContain("space-y-1");
expect(wrapper?.[1]).not.toContain("inline-flex");
// The delta line's <p> closes before the explainer body opens —
// the caption is a sibling below, never a flex neighbour.
expect(html).toMatch(
/vs last week[^<]*<\/span><\/p><span[^>]*data-slot="health-score-delta-explainer-body"/,
);
});
it("renders four component rows with their values", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
for (const key of ["bp", "weight", "mood", "compliance"]) {
const re = new RegExp(`data-component="${key}"`);
expect(html).toMatch(re);
}
// Hand-picked values from baseComponents.
expect(html).toContain(">80<");
expect(html).toContain(">70<");
expect(html).toContain(">90<");
expect(html).toContain(">100<");
});
it("renders an em-dash for null component values", () => {
const html = ssr(
<HealthScoreCard
score={50}
band="yellow"
components={{
...baseComponents,
mood: { value: null, weight: 0 },
}}
delta={null}
/>,
);
// The mood row exists but its value cell is "—".
const moodSliceMatch = html.match(
/data-component="mood"[\s\S]*?data-slot="health-score-card-component-value"[^>]*>([^<]+)</,
);
expect(moodSliceMatch).not.toBeNull();
expect(moodSliceMatch?.[1]).toBe("—");
});
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
// The hero strip's existing "Ask the coach" action covers this
// surface; the card-internal duplicate button retired. v1.18.6 (L13)
// dropped the now-dead `onAskCoach` prop too — the card renders no
// Coach affordance at all.
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders German strings", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={5}
/>,
it("localises structured activity labels, references, and reasons", () => {
const base = report();
const html = render(
report({
pillars: [
{
id: "ACTIVITY",
domain: "activity",
result: {
status: "ok",
value: {
score: 80,
observed: {
value: 8_000,
unit: "steps/day",
label: "8,000 steps/day",
asOf: "2026-07-27T00:00:00.000Z",
sources: ["APPLE_HEALTH"],
},
reference: {
kind: "guideline-band",
low: 0,
high: 10_000,
label: "benefit plateaus by 10,000 steps/day",
source: "Paluch 2022",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: "ACTIVITY_STEPS",
},
coverage: {
requiredInputs: 21,
presentInputs: 21,
historyDays: 21,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance,
},
},
...base.pillars.slice(1),
],
}),
"de",
);
expect(html).toContain("Gesundheitsscore");
expect(html).toContain("im Vergleich zur Vorwoche");
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
// the action now.
expect(html).not.toContain("Coach fragen");
expect(html).toContain("Schritte pro Tag");
expect(html).toContain("Der Nutzen flacht");
expect(html).toContain("Leitlinienbereich");
expect(html).not.toContain("benefit plateaus by");
expect(html).not.toContain("guideline-band");
});
it("uses tabular-nums on the headline number for stable layout", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
it("patches the cached analytics report after the notice is dismissed", () => {
const payload = {
healthScore: report({
algorithmNotice: {
itemKey: "health_score_algorithm:2",
dismissed: false,
},
}),
};
const patched = markAlgorithmNoticeDismissed(
payload,
"health_score_algorithm:2",
);
// The headline number wrapper uses the tabular-nums utility.
const numberMatch = html.match(
/<span[^>]*data-slot="health-score-card-number"[^>]*>/,
);
expect(numberMatch).not.toBeNull();
expect(numberMatch?.[0]).toContain("tabular-nums");
});
it("clamps the progress bar width between 0 and 100", () => {
const html = ssr(
<HealthScoreCard
score={120}
band="green"
components={baseComponents}
delta={null}
/>,
);
// Even a > 100 score must render at exactly 100 % width.
const progressMatch = html.match(
/data-slot="health-score-card-progress"[\s\S]*?width:\s*([0-9.]+%)/,
);
expect(progressMatch).not.toBeNull();
expect(progressMatch?.[1]).toBe("100%");
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => {
// The hero strip's row now uses `items-stretch`; the card has to
// declare `h-full flex flex-col` so the stretched shell actually
// affects its rendered height. Pin both classes so a refactor
// can't silently drop one.
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const cardOpen = html.match(/<div[^>]*data-slot="health-score-card"[^>]*>/);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
expect(cardOpen?.[0]).toContain("flex-col");
});
// v1.4.37 W4a item 1 — the inner column switched from `flex
// flex-col gap-3` to a 7-row grid so the slack collects on row 6
// (the provenance accordion) instead of clumping under the
// disclaimer. Pin both the grid switch and the `1fr` slack row so
// a future refactor can't silently revert to flex and re-introduce
// the "Karte hört bei der Trennlinie auf" maintainer-reported bug.
it("uses a 7-row grid on the inner column so slack distributes on the accordion row", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
// The inner column lives directly after the card wrapper. Match
// the first <div> that opens after the wrapper and assert the
// grid + grid-rows-[...1fr...] contract.
const innerColumn = html.match(
/<div[^>]*data-slot="health-score-card"[^>]*>\s*(?:<!--[\s\S]*?-->\s*)?<div([^>]*)>/,
);
expect(innerColumn).not.toBeNull();
const innerAttrs = innerColumn?.[1] ?? "";
expect(innerAttrs).toContain("grid");
expect(innerAttrs).toContain("flex-1");
expect(innerAttrs).toContain(
"grid-rows-[auto_auto_auto_auto_auto_1fr_auto]",
);
});
it("exposes the progressbar role + ARIA values for screen readers", () => {
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const progressMatch = html.match(
/<div[^>]*data-slot="health-score-card-progress"[^>]*>/,
);
expect(progressMatch).not.toBeNull();
expect(progressMatch?.[0]).toContain('role="progressbar"');
expect(progressMatch?.[0]).toContain('aria-valuenow="86"');
expect(progressMatch?.[0]).toContain('aria-valuemin="0"');
expect(progressMatch?.[0]).toContain('aria-valuemax="100"');
});
});
/**
* v1.34 — the weight row names its yardstick. Before this the score graded
* weight against a band the user never set and the card said nothing at all,
* which is why the defect survived for months.
*/
describe("<HealthScoreCard> — weight yardstick disclosure", () => {
it("names the user's own target band", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
weightYardstick={{ source: "user", targetLabel: "64-70 kg" }}
/>,
);
const line = html.match(
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
);
expect(line).not.toBeNull();
expect(line?.[0]).toContain('data-target-source="user"');
expect(line?.[1]).toContain("64-70 kg");
});
it("says plainly when no target graded the row", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
weightYardstick={{ source: "none", targetLabel: null }}
/>,
);
const line = html.match(
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
);
expect(line).not.toBeNull();
expect(line?.[0]).toContain('data-target-source="none"');
expect(line?.[1]).toMatch(/trend/i);
});
it("discloses nothing when the payload carries no yardstick", () => {
// An older cached body has no `targetSource`; silence beats a guess.
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).not.toContain("health-score-card-weight-yardstick");
});
it("stays quiet when the weight pillar itself has no value", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={{
...baseComponents,
weight: { value: null, weight: 0 },
}}
delta={null}
weightYardstick={{ source: "none", targetLabel: null }}
/>,
);
expect(html).not.toContain("health-score-card-weight-yardstick");
expect(patched).toMatchObject({
healthScore: {
algorithmNotice: {
itemKey: "health_score_algorithm:2",
dismissed: true,
},
},
});
});
});
@@ -1,116 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
/**
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
* three-sentence read of which components contributed, what the comparison
* window is, and one concrete next step.
*
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
* inline as a muted caption beside the delta line. These tests run through
* SSR (no browser surface) and pin: the inline body paints by default, it
* threads the parent-supplied id for aria-describedby, and every locale
* ships the body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Helper that paints the explainer body via the same `t()` call the caption
* uses. If a future refactor drops a locale key the test fails at the `t()`
* call site rather than silently rendering the literal key path.
*/
function BodyProbe() {
const { t } = useTranslations();
return (
<span data-slot="probe-body">
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
describe("<HealthScoreDeltaExplainer>", () => {
it("renders the read inline as a muted caption (no trigger glyph)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
// The old icon-only disclosure trigger is gone.
expect(html).not.toContain(
'data-slot="health-score-delta-explainer-trigger"',
);
expect(html).not.toContain("aria-expanded");
});
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
const html = ssr(
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
);
expect(html).toMatch(
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
);
});
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
expect(html).toContain("BP, weight, mood and medication adherence");
});
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BodyProbe />
</I18nProvider>,
);
const body = html.match(
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
);
expect(body).not.toBeNull();
const text = body?.[1] ?? "";
// Components — window — action triad pinned.
expect(text).toContain("BP, weight, mood and medication adherence");
expect(text).toContain("same time last week");
expect(text).toContain("Log a fresh reading");
// 3-sentence ceiling. Soft check: at most 4 sentence-ending
// punctuation marks (covering the Oxford comma + 3 periods).
const periods = (text.match(/\./g) ?? []).length;
expect(periods).toBeLessThanOrEqual(4);
});
it("ships the DE body copy with the same structure (3-sentence translation)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<BodyProbe />
</I18nProvider>,
);
const body = html.match(
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
);
expect(body).not.toBeNull();
const text = body?.[1] ?? "";
expect(text).toContain("Blutdruck");
expect(text).toContain("Stimmung");
expect(text).toContain("Therapietreue");
expect(text).toContain("vor einer Woche");
});
it("ships the body key in every locale supported by R3c-Insights", () => {
// Pinning every locale stops a future feature branch from shipping an
// English fallback for the explainer body key.
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BodyProbe />
</I18nProvider>,
);
// The probe re-uses the same `t()` payload the caption does, so any
// missing key surfaces as the literal key path which never contains a
// space.
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
}
});
});
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { HeroStrip } from "../hero-strip";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
// it so the SSR render does not reach for a QueryClient the test omits.
@@ -40,6 +41,53 @@ const sampleBriefing: DailyBriefingPayload = {
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
const SCORE_REPORT: HealthScoreReport = {
composite: {
status: "ok",
value: {
score: 86,
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
noiseFloor: 3,
scoreVersion: 2,
},
coverage: {
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [],
},
confidence: { score: 100, band: "high" },
provenance: {
inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
source: "live",
windowDays: 90,
computedAt: "2026-05-10T07:00:00.000Z",
},
},
pillars: [],
delta: 5,
deltaReason: null,
scoreVersion: 2,
weightGoal: {
status: "insufficient",
coverage: {
requiredInputs: 2,
presentInputs: 0,
historyDays: 0,
missing: ["weight reading", "personal weight target"],
},
provenance: {
inputs: ["WEIGHT"],
source: "none",
windowDays: 90,
computedAt: "2026-05-10T07:00:00.000Z",
},
reason: "no_personal_goal",
},
algorithmNotice: null,
};
describe("<HeroStrip>", () => {
it("renders the slot wrapper + Dracula gradient utility", () => {
@@ -250,17 +298,7 @@ describe("<HeroStrip>", () => {
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
healthScore={SCORE_REPORT}
/>,
);
expect(html).toMatch(/data-slot="health-score-card"/);
@@ -277,17 +315,7 @@ describe("<HeroStrip>", () => {
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
healthScore={SCORE_REPORT}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
@@ -299,17 +327,7 @@ describe("<HeroStrip>", () => {
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: null,
}}
healthScore={SCORE_REPORT}
/>,
);
// The wrapper picks up the `lg:flex-row` modifier when the score
@@ -329,17 +347,7 @@ describe("<HeroStrip>", () => {
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: null,
}}
healthScore={SCORE_REPORT}
/>,
);
expect(html).toContain("md:items-stretch");
@@ -395,17 +403,7 @@ describe("<HeroStrip>", () => {
briefing={null}
now={morningLocal}
healthScorePending
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
healthScore={SCORE_REPORT}
/>,
);
expect(html).toContain('data-slot="health-score-card"');
@@ -90,6 +90,8 @@ export interface ScoreAnatomyViewProps {
insufficient?: boolean;
/** Localised "not enough data" nudge for the insufficient state. */
insufficientNote?: ReactNode;
/** Keep the shared provisional ring unless the caller requires rows only. */
showProvisionalRing?: boolean;
className?: string;
}
@@ -107,6 +109,7 @@ export function ScoreAnatomyView({
standard,
insufficient = false,
insufficientNote,
showProvisionalRing = true,
className,
}: ScoreAnatomyViewProps) {
const { t } = useTranslations();
@@ -142,15 +145,17 @@ export function ScoreAnatomyView({
<div className="flex flex-col items-center gap-3 text-center">
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is
never clipped top/bottom by the hero container. */}
<div className="py-2">
<ScoreRing
score={effectiveScore}
band={band}
hue={insufficient ? undefined : hue}
label={t("insights.derived.anatomy.outOf")}
size="lg"
/>
</div>
{!insufficient || showProvisionalRing ? (
<div className="py-2">
<ScoreRing
score={effectiveScore}
band={band}
hue={insufficient ? undefined : hue}
label={t("insights.derived.anatomy.outOf")}
size="lg"
/>
</div>
) : null}
{caption && !insufficient ? (
<p
data-slot="score-anatomy-caption"
@@ -174,7 +179,8 @@ export function ScoreAnatomyView({
insufficientNote ?? t("insights.derived.anatomy.insufficient")
}
/>
) : (
) : null}
{contributors.length > 0 ? (
<div data-slot="score-anatomy-contributors" className="space-y-3">
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t("insights.derived.anatomy.contributorsLabel")}
@@ -185,7 +191,7 @@ export function ScoreAnatomyView({
))}
</ul>
</div>
)}
) : null}
{/* The plain-language method caption closes the card, in normal flow.
`ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it
@@ -188,4 +188,11 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
},
caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat",
},
HEALTH_SCORE: {
methodKey: "insights.healthScore.method",
standard: {
name: "WHO HEARTS technical package",
url: "https://www.who.int/publications/i/item/9789240001367",
},
},
};
File diff suppressed because it is too large. Load diff
@@ -1,60 +0,0 @@
"use client";
import { useId } from "react";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
*
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
* a popover / bottom-sheet is removed; the maintainer wants the trailing
* help affordance gone across the insights surface. The descriptive read
* now renders inline as a muted caption beside the delta line, so it is
* always visible rather than disclosure-only. The body keeps its id so the
* parent's `aria-describedby` on the delta digit still threads to it.
*
* Operator suppression is preserved: when `healthScoreExplainer` is off the
* caption does not render (the parent delta digit stays visible because the
* parent paints it directly).
*/
interface HealthScoreDeltaExplainerProps {
/** The delta value the parent paints on the line above. Retained for callers. */
delta: number;
/** Optional className for the caption so the parent can tune its spacing. */
className?: string;
/**
* Optional id the parent owns and threads to the caption. The same id sits
* on the parent's delta `<span>` as `aria-describedby` so screen readers
* connect the digit to the read.
*/
bodyId?: string;
}
export function HealthScoreDeltaExplainer({
className,
bodyId,
}: HealthScoreDeltaExplainerProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
// Stable fallback id when the parent doesn't supply one. The hook sits
// above the conditional return so the hook order stays stable.
const generatedId = useId();
const resolvedBodyId = bodyId ?? generatedId;
// v1.4.31 — operator can hide the read; silent suppression per the
// architecture brief.
if (!flags.healthScoreExplainer) return null;
return (
<span
id={resolvedBodyId}
data-slot="health-score-delta-explainer-body"
className={cn("text-muted-foreground text-xs leading-snug", className)}
>
{t("insights.healthScore.deltaExplainer.body")}
</span>
);
}
+6 -43
View File
@@ -10,15 +10,11 @@ import {
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import {
HealthScoreCard,
type HealthScoreCardProps,
type HealthScoreBand,
} from "./health-score-card";
import { HealthScoreCard } from "./health-score-card";
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
@@ -78,29 +74,8 @@ interface HeroStripProps {
* footer hint below.
*/
noProviderStale?: boolean;
/**
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
* the right side of the hero band paints the score card. When the
* parent omits the field the right side stays empty.
*/
healthScore?: {
score: HealthScoreCardProps["score"];
band: HealthScoreCardProps["band"];
components: HealthScoreCardProps["components"];
delta: HealthScoreCardProps["delta"];
/**
* v1.18.6 — value-free Rest Mode annotation off the analytics
* payload. When present + active the score card states it is paused
* during illness (suppressed, not penalised).
*/
restMode?: { active: boolean } | null;
} | null;
/**
* v1.34 — the weight pillar's yardstick, already resolved into the reader's
* display unit by the page (the hero carries no unit context of its own).
* Forwarded verbatim to the score card, which renders the disclosure line.
*/
weightYardstick?: HealthScoreCardProps["weightYardstick"];
/** Complete transparent Health Score report from the analytics route. */
healthScore?: HealthScoreReport | null;
/**
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
* is the readiness composite's band; `positive` / `negative` carry the
@@ -112,7 +87,7 @@ interface HeroStripProps {
* is present.
*/
tension?: {
band: HealthScoreBand;
band: ScoreBand;
positive: ReadinessContributorKey[];
negative: ReadinessContributorKey[];
} | null;
@@ -194,7 +169,6 @@ export function HeroStrip({
noProviderStale = false,
tension = null,
returnToBand = null,
weightYardstick = null,
}: HeroStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -230,11 +204,6 @@ export function HeroStrip({
daysInside: returnToBand.daysInside,
}
: null;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
// disabled-allowlist gate.
const moodEnabled = useModuleEnabled("mood");
// Greeting hour in the user's configured zone, not the browser's — a
// traveller / VPN user / wrong device clock otherwise sees the wrong
// salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`).
@@ -411,15 +380,9 @@ export function HeroStrip({
)}
{healthScore && (
<HealthScoreCard
score={healthScore.score}
band={healthScore.band}
components={healthScore.components}
delta={healthScore.delta}
moodEnabled={moodEnabled}
restModeActive={healthScore.restMode?.active ?? false}
report={healthScore}
tension={tensionProp}
returnToBand={returnToBandProp}
weightYardstick={weightYardstick}
/>
)}
</div>
@@ -16,7 +16,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block";
+1 -1
View File
@@ -3,7 +3,7 @@
import { HeartPulse } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
/**
* v1.18.1 — calm, neutral Rest Mode indicator.
+9 -5
View File
@@ -1,16 +1,15 @@
"use client";
import { useContext } from "react";
import { QueryClientContext } from "@tanstack/react-query";
import { QueryClientContext, type QueryClient } from "@tanstack/react-query";
/**
* v1.4.48 M4 — shared SSR-safety helper for hooks that delegate to
* React Query under a `<QueryClientProvider>`.
*
* A long tail of legacy presentational components (`<HeroStrip>`,
* `<SuggestedPrompts>`, `<LayoutCoachFab>`, `<InsightStatusCard>`,
* `<HealthScoreDeltaExplainer>`, …) ships with unit tests that render
* the component in isolation without wrapping it in
* Several presentational components (`<HeroStrip>`, `<SuggestedPrompts>`,
* `<LayoutCoachFab>`, and `<InsightStatusCard>`) ship with unit tests that
* render the component in isolation without wrapping it in
* `<QueryClientProvider>`. Hooks that call `useQuery` directly crash
* in that environment.
*
@@ -31,3 +30,8 @@ export function useQueryClientMounted(): boolean {
);
return ctx != null;
}
/** Return the mounted client without throwing in isolated SSR renders. */
export function useOptionalQueryClient(): QueryClient | undefined {
return useContext(QueryClientContext);
}
+2 -3
View File
@@ -70,9 +70,8 @@ async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
* remount within a minute. The hook fails open — any network error
* returns the all-on default so the user keeps seeing the surface.
*
* The hook is referenced from a long tail of legacy presentational
* components (`<InsightStatusCard>`, `<HealthScoreDeltaExplainer>`)
* whose unit tests render the component without a
* The hook is referenced from presentational components such as
* `<InsightStatusCard>` whose unit tests render without a
* `<QueryClientProvider>`. To keep those tests valid we delegate
* the React-Query call to a child hook that's only invoked when a
* client is mounted. The branch is stable across the component's
+23 -52
View File
@@ -338,18 +338,7 @@ describe("i18n locale file integrity", () => {
},
);
/**
* v1.4.22 A6 — DE locale must not leak English nouns for the
* Health-Score component contract.
*
* The four `componentBp` / `componentWeight` / `componentMood` /
* `componentCompliance` keys are rendered as the four sub-bar
* labels on the Health Score card. Up to v1.4.21 the DE locale
* shipped `componentMood: "Mood"` (an English noun voice-to-text
* rendered as "Mut" on a quick read). Pin the German values so a
* future copy-paste regression can't reintroduce the leak.
*/
it("DE locale renders Health-Score component labels in German", () => {
it("DE locale renders Health Score pillar labels in German", () => {
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<
string,
unknown
@@ -357,50 +346,32 @@ describe("i18n locale file integrity", () => {
const flat: [string, string][] = [];
flattenValues(de, "", flat);
const byKey = new Map(flat);
const EXPECTED: Array<[string, string]> = [
["insights.healthScore.componentBp", "Blutdruck"],
["insights.healthScore.componentWeight", "Gewicht"],
["insights.healthScore.componentMood", "Stimmung"],
["insights.healthScore.componentCompliance", "Therapietreue"],
const expected: Array<[string, string]> = [
["insights.healthScore.pillar.bloodPressure", "Blutdruck"],
["insights.healthScore.pillar.glycaemia", "Blutzucker"],
["insights.healthScore.pillar.activity", "Aktivität"],
["insights.healthScore.pillar.sleep", "Schlaf"],
["insights.healthScore.pillar.wellbeing", "Wohlbefinden"],
["insights.healthScore.pillar.fitness", "Gemessene Fitness"],
["insights.healthScore.pillar.lipids", "Blutfette"],
];
for (const [key, expectedDe] of EXPECTED) {
const actual = byKey.get(key);
expect(actual, `DE label for ${key}`).toBe(expectedDe);
for (const [key, expectedDe] of expected) {
expect(byKey.get(key), `DE label for ${key}`).toBe(expectedDe);
}
});
/**
* v1.4.25 W8e — Health-Score provenance accordion drift-guard.
*
* Mirrors the `measurement-list-meta` enum-coverage pattern: every
* i18n key the accordion renders must resolve in every shipped
* locale. If a future copy-paste regression drops a key or a new
* source token appears in the analytics layer without a matching
* locale entry, this test fails fast.
*
* The expected key set is hand-pinned (not extracted from the
* accordion component) so the test catches both directions —
* a missing locale value AND an accidentally dropped accordion
* call-site.
*/
it("Health-Score provenance keys resolve in every locale", () => {
const PROVENANCE_KEYS = [
"insights.healthScore.provenance.toggle",
"insights.healthScore.provenance.mixedBanner",
"insights.healthScore.provenance.footnote",
"insights.healthScore.provenance.asOfLabel",
"insights.healthScore.provenance.provisional",
"insights.healthScore.provenance.provisionalBadge",
"insights.healthScore.provenance.sourceAria",
"insights.healthScore.provenance.sources.manual",
"insights.healthScore.provenance.sources.withings",
"insights.healthScore.provenance.sources.appleHealth",
"insights.healthScore.provenance.sources.mixed",
"insights.healthScore.provenance.sources.none",
it("Health Score transparency keys resolve in every locale", () => {
const transparencyKeys = [
"insights.healthScore.algorithmChanged",
"insights.healthScore.method",
"insights.healthScore.composition",
"insights.healthScore.bandSetter",
"insights.healthScore.versionAndNoise",
"insights.healthScore.observed",
"insights.healthScore.reference",
"insights.healthScore.source",
"insights.healthScore.weightGoal.notScored",
] as const;
for (const { locale, path } of ALL_LOCALES) {
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
@@ -409,7 +380,7 @@ describe("i18n locale file integrity", () => {
const flat: [string, string][] = [];
flattenValues(data, "", flat);
const byKey = new Map(flat);
for (const key of PROVENANCE_KEYS) {
for (const key of transparencyKeys) {
expect(
byKey.get(key),
`${locale} locale missing or empty: ${key}`,
@@ -4,6 +4,7 @@ import {
getAllEffectiveRanges,
METRIC_BOUNDS,
resolveWeightTargetOverride,
resolveEffectiveBpTargets,
type UserProfileForRange,
type ThresholdOverridesJson,
} from "../effective-range";
@@ -127,6 +128,48 @@ describe("getAllEffectiveRanges", () => {
});
});
describe("resolveEffectiveBpTargets", () => {
it("merges the user's systolic and diastolic target overrides", () => {
expect(
resolveEffectiveBpTargets(baseProfile, {
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
BLOOD_PRESSURE_DIA: { min: 65, max: 75 },
}),
).toEqual({
sysLow: 115,
sysHigh: 125,
diaLow: 65,
diaHigh: 75,
});
});
it("uses the evidence default for an axis without an override", () => {
expect(
resolveEffectiveBpTargets(baseProfile, {
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
}),
).toEqual({
sysLow: 115,
sysHigh: 125,
diaLow: 70,
diaHigh: 79,
});
});
it("rejects malformed target pairs without losing valid defaults", () => {
expect(
resolveEffectiveBpTargets(baseProfile, {
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
}),
).toEqual({
sysLow: 120,
sysHigh: 129,
diaLow: 70,
diaHigh: 79,
});
});
});
// Audit-2026-05-07 / phase P0 / closes audit C-15: TOTAL_BODY_WATER and
// BONE_MASS lacked threshold definitions. Severity logic returned `nominal`
// for any value, so users would see "all healthy" regardless of input.
File diff suppressed because it is too large. Load diff
@@ -1,196 +0,0 @@
import { describe, it, expect } from "vitest";
import {
buildHealthScoreBpInputs,
buildHealthScoreWeightTarget,
} from "../health-score-inputs";
import type { BpInTargetEnvelope } from "../bp-in-target-fast-path";
function env(partial: Partial<BpInTargetEnvelope>): BpInTargetEnvelope {
return {
last7Days: null,
last30Days: null,
last90Days: null,
last90EarliestAt: null,
allTime: null,
priorMonth: null,
priorYear: null,
path: "live",
rowCount: 0,
gradedScore: null,
...partial,
};
}
describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () => {
/**
* v1.17 W1b — the dashboard ring and the insights card must grade the BP
* pillar off identical inputs. This pins the contract: given the same
* current + prior-week envelopes, the builder produces one BP-input shape,
* so whichever surface calls it sees the same pillar presence, value and
* delta.
*/
it("reads the 90-day window for the pillar rate, NOT 30-day or all-time", () => {
const current = env({
last30Days: { pct: 80, pairs: 10 },
last90Days: { pct: 65, pairs: 40 },
allTime: { pct: 50, pairs: 200 },
gradedScore: 72,
});
const out = buildHealthScoreBpInputs(current, null);
expect(out.bpInTargetPct).toBe(65);
expect(out.bpGradedScore).toBe(72);
});
it("falls back to all-time only when the 90-day window is null", () => {
const current = env({
last90Days: null,
allTime: { pct: 42, pairs: 100 },
gradedScore: 60,
});
const out = buildHealthScoreBpInputs(current, null);
expect(out.bpInTargetPct).toBe(42);
});
it("uses the prior-week 90-day window for the delta inputs", () => {
const current = env({
last90Days: { pct: 65, pairs: 40 },
gradedScore: 72,
});
const priorWeek = env({
last90Days: { pct: 58, pairs: 38 },
gradedScore: 68,
});
const out = buildHealthScoreBpInputs(current, priorWeek);
expect(out.bpInTargetPctPriorWeek).toBe(58);
expect(out.bpGradedScorePriorWeek).toBe(68);
});
it("suppresses the pillar when the 90-day window is below the confidence floor and no all-time rescues it", () => {
// 4 pairs in the last 90 days — below the floor of 5. Without an all-time
// rate the pillar must disappear (rate + graded score both null) so a
// thin-data user does not get a confident BP pillar.
const current = env({
last90Days: { pct: 100, pairs: 4 },
allTime: { pct: 100, pairs: 4 },
gradedScore: 95,
});
const out = buildHealthScoreBpInputs(current, null);
// all-time is itself the same 4 pairs → rate present (deep history rescue
// path), but here it is genuinely thin so the rate is the all-time pct.
// Pin the stricter case: when all-time is also null the pillar collapses.
const thin = buildHealthScoreBpInputs(
env({
last90Days: { pct: 100, pairs: 4 },
allTime: null,
gradedScore: 95,
}),
null,
);
expect(thin.bpInTargetPct).toBeNull();
expect(thin.bpGradedScore).toBeNull();
// The all-time fallback still rescues a deep-history account.
expect(out.bpInTargetPct).toBe(100);
});
it("suppresses the prior-week graded score when the prior-week window is thin and no all-time rescues it", () => {
// Current window healthy, prior-week 90-day window below the floor with no
// all-time fallback → the prior-week graded score must not grade off the
// thin sample, so the week-over-week graded delta stays consistent.
const current = env({
last90Days: { pct: 65, pairs: 40 },
gradedScore: 72,
});
const priorWeek = env({
last90Days: { pct: 90, pairs: 2 },
allTime: null,
gradedScore: 95,
});
const out = buildHealthScoreBpInputs(current, priorWeek);
expect(out.bpGradedScore).toBe(72);
expect(out.bpInTargetPctPriorWeek).toBeNull();
expect(out.bpGradedScorePriorWeek).toBeNull();
});
it("collapses every field to null when the current envelope is absent", () => {
const out = buildHealthScoreBpInputs(null, null);
expect(out).toEqual({
bpInTargetPct: null,
bpInTargetPctPriorWeek: null,
bpGradedScore: null,
bpGradedScorePriorWeek: null,
});
});
it("pillar presence is identical for two callers passing the same envelopes", () => {
// Same data → same `bpInTargetPct !== null` presence verdict on both
// surfaces. A user with BP history outside 30 days but inside 90 days
// keeps the pillar on BOTH the dashboard ring and the insights card.
const current = env({
last30Days: null, // no readings in the last month
last90Days: { pct: 55, pairs: 12 },
allTime: { pct: 48, pairs: 300 },
gradedScore: 64,
});
const a = buildHealthScoreBpInputs(current, null);
const b = buildHealthScoreBpInputs(current, null);
expect(a).toEqual(b);
expect(a.bpInTargetPct).not.toBeNull();
});
});
/**
* v1.34 — the weight yardstick builder. The score used to grade weight against
* `22 x height^2` +/- 2 kg, a band no user set and no surface named. The
* builder is the one place the two Health-Score callers resolve it, and its
* whole job is to refuse to hand over anything but a real user target.
*/
describe("buildHealthScoreWeightTarget — the one weight-yardstick resolver", () => {
const profile = {
heightCm: 178,
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
};
it("hands over the user's own band when they set one", () => {
const result = buildHealthScoreWeightTarget(profile, {
WEIGHT: { min: 74, max: 78 },
});
expect(result.weightTargetSource).toBe("user");
expect(result.weightTarget).toEqual({ min: 74, max: 78 });
});
it("refuses the evidence-based default that sits under the override", () => {
// `getEffectiveRange` resolves a height-derived WHO band for this profile.
// It is a reference band, not a goal the person chose, so it must never
// reach the score — that substitution IS the defect.
const result = buildHealthScoreWeightTarget(profile, null);
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("ignores overrides on other metrics", () => {
const result = buildHealthScoreWeightTarget(profile, {
BLOOD_PRESSURE_SYS: { min: 110, max: 130 },
});
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("returns no target for a profile with no height and no override", () => {
const result = buildHealthScoreWeightTarget(
{ heightCm: null, dateOfBirth: null, gender: null },
null,
);
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("honours an override even when the profile carries no height", () => {
const result = buildHealthScoreWeightTarget(
{ heightCm: null, dateOfBirth: null, gender: null },
{ WEIGHT: { min: 60, max: 65 } },
);
expect(result.weightTargetSource).toBe("user");
expect(result.weightTarget).toEqual({ min: 60, max: 65 });
});
});
@@ -1,259 +0,0 @@
/**
* F1 — rollup-vs-live golden for the Health Score.
*
* `computeUserHealthScoreFastPath` reads the weight series two ways: per-day
* MEAN buckets off `measurement_rollups` when the WEIGHT type is covered, raw
* `measurements` rows otherwise. The scoring math cannot diverge — there is one
* scorer and both branches call it — but the INPUT SERIES can, and nothing in
* the suite asserted the two branches land on the same number for the same
* underlying readings. The existing fast-path tests exercise each branch alone.
*
* This file encodes one series both ways and pins the equivalence, plus the one
* documented case where the two legitimately differ: with several readings on a
* day, the rollup branch regresses over the day's mean while the live branch
* regresses over every reading. That is a collapse, not a bug — so the second
* suite pins that the rollup branch equals the live branch fed those same daily
* means, which is the exact claim the fast-path's own comment makes.
*/
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
moodEntry: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement
.findMany as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_MANY = prisma.measurementRollup
.findMany as unknown as ReturnType<typeof vi.fn>;
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<
typeof vi.fn
>;
const MEDICATION_FIND_MANY = prisma.medication
.findMany as unknown as ReturnType<typeof vi.fn>;
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
const NOW = new Date("2026-05-20T12:00:00.000Z");
/** A weight reading in the shared fixture vocabulary. */
interface Reading {
/** Days before `NOW` the reading was taken. */
daysAgo: number;
/** Hour of that day, so several readings can share a day. */
hour: number;
kg: number;
}
function measuredAt(r: Reading): Date {
const d = new Date(NOW.getTime() - r.daysAgo * 24 * 60 * 60 * 1000);
d.setUTCHours(r.hour, 0, 0, 0);
return d;
}
/** UTC midnight of a reading's day — the DAY bucket key. */
function bucketStart(r: Reading): Date {
const d = measuredAt(r);
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
}
/** Collapse readings into the per-day MEAN buckets the rollup tier stores. */
function toDayBuckets(readings: Reading[]) {
const byDay = new Map<number, { sum: number; count: number }>();
for (const r of readings) {
const key = bucketStart(r).getTime();
const slot = byDay.get(key) ?? { sum: 0, count: 0 };
slot.sum += r.kg;
slot.count += 1;
byDay.set(key, slot);
}
return [...byDay.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, slot]) => ({
bucketStart: new Date(ms),
count: slot.count,
mean: slot.sum / slot.count,
minValue: slot.sum / slot.count,
maxValue: slot.sum / slot.count,
sd: null,
slope: null,
r2: null,
computedAt: NOW,
}));
}
beforeEach(() => {
MEASUREMENT_FIND_MANY.mockReset();
ROLLUP_FIND_MANY.mockReset();
MOOD_FIND_MANY.mockReset();
MEDICATION_FIND_MANY.mockReset();
PROBE.mockReset();
MOOD_FIND_MANY.mockResolvedValue([]);
MEDICATION_FIND_MANY.mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
/** Score the readings through the ROLLUP branch (WEIGHT covered). */
async function scoreViaRollup(readings: Reading[]) {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
PROBE.mockResolvedValue(coverage);
const buckets = toDayBuckets(readings);
// The DAY read and the long-window (WEEK/MONTH/YEAR) read fire in parallel,
// so key the mock on the requested granularity rather than call order.
ROLLUP_FIND_MANY.mockImplementation(
async ({ where }: { where: { granularity: string } }) =>
where.granularity === "DAY" ? buckets : [],
);
// Source attribution still comes off the raw rows (2-column projection),
// then the BP-SYS attribution read.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
readings.map((r) => ({ measuredAt: measuredAt(r), source: "MANUAL" })),
).mockResolvedValueOnce([]);
return computeUserHealthScoreFastPath({
userId: "user-rollup",
bpInTargetPct: null,
weightTarget: null,
weightTargetSource: "none",
now: NOW,
coverage,
moodEnabled: true,
});
}
/** Score the readings through the LIVE branch (WEIGHT uncovered). */
async function scoreViaLive(readings: Reading[]) {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
readings.map((r) => ({
measuredAt: measuredAt(r),
value: r.kg,
source: "MANUAL",
})),
).mockResolvedValueOnce([]);
return computeUserHealthScoreFastPath({
userId: "user-live",
bpInTargetPct: null,
weightTarget: null,
weightTargetSource: "none",
now: NOW,
coverage,
moodEnabled: true,
});
}
describe("Health Score — rollup / live parity on one weigh-in per day", () => {
// The canonical cadence: one weigh-in a day, the pattern the fast path's own
// comment says the equivalence rests on.
const dailyDecline: Reading[] = [
{ daysAgo: 24, hour: 7, kg: 84.0 },
{ daysAgo: 20, hour: 7, kg: 83.6 },
{ daysAgo: 16, hour: 7, kg: 83.1 },
{ daysAgo: 12, hour: 7, kg: 82.7 },
{ daysAgo: 8, hour: 7, kg: 82.2 },
{ daysAgo: 4, hour: 7, kg: 81.8 },
{ daysAgo: 1, hour: 7, kg: 81.5 },
];
it("produces the same score, band and weight pillar on both branches", async () => {
const rollup = await scoreViaRollup(dailyDecline);
const live = await scoreViaLive(dailyDecline);
expect(rollup).not.toBeNull();
expect(live).not.toBeNull();
expect(rollup?.score).toBe(live?.score);
expect(rollup?.band).toBe(live?.band);
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
// The weight pillar is the whole score here (no BP, mood or medications),
// so an equal score is only meaningful if the pillar actually resolved.
expect(rollup?.components.weight.value).not.toBeNull();
});
it("agrees on the week-over-week delta too", async () => {
// The delta re-runs the scorer over a 7-day-shifted window, so it exercises
// the branch's series construction a second time on a different slice.
const rollup = await scoreViaRollup(dailyDecline);
const live = await scoreViaLive(dailyDecline);
expect(rollup?.delta).toBe(live?.delta);
});
it("agrees on a rising series as well as a falling one", async () => {
const rise = dailyDecline.map((r) => ({ ...r, kg: 160 - r.kg }));
const rollup = await scoreViaRollup(rise);
const live = await scoreViaLive(rise);
expect(rollup?.score).toBe(live?.score);
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
});
});
describe("Health Score — rollup / live on an uneven weigh-in cadence", () => {
// Three weigh-ins on the older day, one on the recent day. Per-reading
// regression weights that older day three times over; per-day-mean regression
// gives it one point. This is the shape where the two branches genuinely part
// company, so it is the shape worth pinning.
const uneven: Reading[] = [
{ daysAgo: 20, hour: 6, kg: 85.0 },
{ daysAgo: 20, hour: 12, kg: 84.0 },
{ daysAgo: 20, hour: 20, kg: 83.0 },
{ daysAgo: 2, hour: 7, kg: 83.6 },
];
/** The same readings collapsed to one per day at their daily mean. */
const collapsed: Reading[] = toDayBuckets(uneven).map((b) => ({
daysAgo: Math.round(
(NOW.getTime() - b.bucketStart.getTime()) / (24 * 60 * 60 * 1000),
),
hour: 0,
kg: b.mean,
}));
it("the two branches do differ here — the fixture is not a no-op", async () => {
// Guard the guard: if raw and collapsed ever scored the same for this
// series, the equality below would prove nothing.
const liveOnRaw = await scoreViaLive(uneven);
const liveOnMeans = await scoreViaLive(collapsed);
expect(liveOnRaw?.components.weight.value).not.toBe(
liveOnMeans?.components.weight.value,
);
});
it("the rollup branch equals the live branch fed the same daily means", async () => {
// The whole difference between the branches is the mean-collapse and
// nothing else: replay the collapsed series through the LIVE branch and the
// two agree again. That is the claim the fast path's own comment makes,
// stated as a test rather than left as prose.
const rollup = await scoreViaRollup(uneven);
const liveOnMeans = await scoreViaLive(collapsed);
expect(rollup?.score).toBe(liveOnMeans?.score);
expect(rollup?.components.weight.value).toBe(
liveOnMeans?.components.weight.value,
);
});
});
@@ -1,570 +0,0 @@
import { describe, it, expect } from "vitest";
import {
coefficientOfVariation,
complianceRate,
computeHealthScore,
linearRegressionSlope,
moodStability,
weightTrendAlignment,
type HealthScoreInput,
} from "../health-score";
/**
* Build a 30-day mood series with a deterministic pattern. The helper
* keeps the variance tractable by hand so test expectations don't drift.
*/
function moodEntries(scores: number[]): Array<{ date: string; score: number }> {
return scores.map((score, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
score,
}));
}
function weightSeries(values: number[]): Array<{ date: string; kg: number }> {
return values.map((kg, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
kg,
}));
}
// ── Pure helpers ─────────────────────────────────────────────────────
describe("linearRegressionSlope", () => {
it("returns null for fewer than two points", () => {
expect(linearRegressionSlope([])).toBeNull();
expect(
linearRegressionSlope([{ date: "2026-05-01T00:00:00Z", value: 80 }]),
).toBeNull();
});
it("detects a clean upward slope (units / day)", () => {
const slope = linearRegressionSlope(
weightSeries([80, 81, 82, 83, 84]).map((p) => ({
date: p.date,
value: p.kg,
})),
);
expect(slope).not.toBeNull();
expect(slope!).toBeCloseTo(1, 5);
});
it("detects a clean downward slope", () => {
const slope = linearRegressionSlope(
weightSeries([85, 84, 83, 82, 81]).map((p) => ({
date: p.date,
value: p.kg,
})),
);
expect(slope!).toBeCloseTo(-1, 5);
});
});
describe("coefficientOfVariation", () => {
it("returns null for fewer than two values", () => {
expect(coefficientOfVariation([])).toBeNull();
expect(coefficientOfVariation([4])).toBeNull();
});
it("returns 0 for a constant series", () => {
expect(coefficientOfVariation([4, 4, 4, 4])).toBe(0);
});
it("scales with relative spread", () => {
const tight = coefficientOfVariation([4, 4, 4, 4, 5])!;
const loose = coefficientOfVariation([1, 5, 1, 5, 1])!;
expect(loose).toBeGreaterThan(tight);
});
});
describe("weightTrendAlignment", () => {
it("returns 100 when latest reading is inside the band", () => {
const series = weightSeries([90, 88, 85, 80]);
const result = weightTrendAlignment(series, { min: 78, max: 82 });
expect(result).toBe(100);
});
it("returns >50 when above the band and trending down (closing the gap)", () => {
const series = weightSeries([95, 94, 93, 92, 91]);
const result = weightTrendAlignment(series, { min: 75, max: 80 });
expect(result).not.toBeNull();
expect(result!).toBeGreaterThan(50);
});
it("returns <50 when above the band and trending up (drifting away)", () => {
const series = weightSeries([90, 91, 92, 93, 94]);
const result = weightTrendAlignment(series, { min: 75, max: 80 });
expect(result).not.toBeNull();
expect(result!).toBeLessThan(50);
});
it("scores the bare trend (non-null) when the target is missing but ≥2 readings exist", () => {
// No target → fall back to trend-only scoring instead of returning
// null, so a height-less user with weight data gets a populated pillar.
const result = weightTrendAlignment(
weightSeries([80, 80.5, 81, 81.5]),
null,
);
expect(result).not.toBeNull();
expect(result!).toBeGreaterThanOrEqual(0);
expect(result!).toBeLessThanOrEqual(100);
});
it("anchors a flat trend at 75 when there is no target", () => {
expect(weightTrendAlignment(weightSeries([80, 80, 80, 80]), null)).toBe(75);
});
it("rewards a gentle decline (>75) when there is no target", () => {
const result = weightTrendAlignment(
weightSeries([84, 83.5, 83, 82.5]),
null,
);
expect(result).not.toBeNull();
expect(result!).toBeGreaterThan(75);
});
it("penalises a sustained rise (<75) when there is no target", () => {
const result = weightTrendAlignment(
weightSeries([80, 81, 82, 83, 84]),
null,
);
expect(result).not.toBeNull();
expect(result!).toBeLessThan(75);
});
it("returns null with no target and fewer than two readings", () => {
expect(weightTrendAlignment(weightSeries([80]), null)).toBeNull();
expect(weightTrendAlignment([], null)).toBeNull();
});
it("returns null with fewer than two readings", () => {
expect(
weightTrendAlignment(weightSeries([80]), { min: 75, max: 80 }),
).toBeNull();
});
});
describe("moodStability", () => {
it("returns null for fewer than 5 entries", () => {
expect(moodStability(moodEntries([4, 4, 4, 4]))).toBeNull();
});
it("returns 100 for a constant high-mood series", () => {
expect(moodStability(moodEntries([5, 5, 5, 5, 5, 5]))).toBe(100);
});
it("scores tight variance higher than loose variance", () => {
const tight = moodStability(moodEntries([4, 4, 4, 4, 4, 5]))!;
const loose = moodStability(moodEntries([1, 5, 1, 5, 1, 5]))!;
expect(tight).toBeGreaterThan(loose);
});
});
describe("complianceRate", () => {
it("returns null for an empty list", () => {
expect(complianceRate([])).toBeNull();
});
it("returns the rounded mean of compliance percentages", () => {
expect(complianceRate([100, 80, 60])).toBe(80);
});
});
// ── Composite scenarios ──────────────────────────────────────────────
describe("computeHealthScore — strong positive case", () => {
it("lands in the green band (>=75)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]),
weightTarget: { min: 76, max: 80 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]),
medicationCompliance30: [95, 100, 90],
};
const result = computeHealthScore(input);
expect(result.score).toBeGreaterThanOrEqual(75);
expect(result.band).toBe("green");
expect(result.components.bp.value).toBe(90);
expect(result.components.compliance.value).toBe(95);
});
});
describe("computeHealthScore — mixed case", () => {
it("lands in the yellow band (50..74)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 60,
// Above the band, drifting up — alignment <50.
weightSeriesLast30d: weightSeries([85, 86, 87, 88]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]),
medicationCompliance30: [100, 100],
};
const result = computeHealthScore(input);
expect(result.score).toBeGreaterThanOrEqual(50);
expect(result.score).toBeLessThan(75);
expect(result.band).toBe("yellow");
});
});
describe("computeHealthScore — poor case", () => {
it("lands in the red band (<50)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 20,
weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]),
medicationCompliance30: [40, 30, 20],
};
const result = computeHealthScore(input);
expect(result.score).toBeLessThan(50);
expect(result.band).toBe("red");
});
});
describe("computeHealthScore — null component redistribution", () => {
it("redistributes weights when bp is null", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100, 100],
};
const result = computeHealthScore(input);
// bp weight 0; remaining base weights sum 0.7. Each scales by /0.7.
expect(result.components.bp.weight).toBe(0);
expect(result.components.weight.weight).toBeCloseTo(0.2 / 0.7, 5);
expect(result.components.mood.weight).toBeCloseTo(0.2 / 0.7, 5);
expect(result.components.compliance.weight).toBeCloseTo(0.3 / 0.7, 5);
// Score should be 100 (every present component at 100).
expect(result.score).toBe(100);
});
it("handles bp + mood both null — weight + compliance carry the score", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
// <5 mood entries → moodStability returns null.
moodEntriesLast30d: moodEntries([4, 4]),
medicationCompliance30: [80],
};
const result = computeHealthScore(input);
expect(result.components.bp.value).toBeNull();
expect(result.components.mood.value).toBeNull();
// Remaining base weights sum 0.5; weight + compliance scale to 0.4 + 0.6.
expect(result.components.weight.weight).toBeCloseTo(0.4, 5);
expect(result.components.compliance.weight).toBeCloseTo(0.6, 5);
// weight 100 (in-band) * 0.4 + compliance 80 * 0.6 = 40 + 48 = 88.
expect(result.score).toBe(88);
});
it("handles all null except compliance — score equals complianceRate", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTarget: null,
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [73],
};
const result = computeHealthScore(input);
expect(result.components.compliance.weight).toBeCloseTo(1, 5);
expect(result.score).toBe(73);
expect(result.band).toBe("yellow");
});
it("returns score 0 when every component is null", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTarget: null,
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
};
const result = computeHealthScore(input);
expect(result.score).toBe(0);
expect(result.band).toBe("red");
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
expect(result.components[key].value).toBeNull();
expect(result.components[key].weight).toBe(0);
}
});
});
describe("computeHealthScore — determinism", () => {
const input: HealthScoreInput = {
bpInTargetRate: 75,
weightSeriesLast30d: weightSeries([82, 81, 80, 79]),
weightTarget: { min: 76, max: 80 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]),
medicationCompliance30: [85, 90, 80],
};
it("same input → same output", () => {
const a = computeHealthScore(input);
const b = computeHealthScore(input);
expect(b).toEqual(a);
});
// v1.4.25 Fix-G — the legacy fallback used to be `new Date()`, which
// made `asOf` drift by a millisecond between consecutive calls and
// turned the determinism guard above into a flake on loaded runners.
// The synthesised window-end now derives from input dates, not wall
// clock, so the entire result is byte-identical across calls.
it("derives a deterministic asOf for present components when attribution is omitted", () => {
const a = computeHealthScore(input);
const b = computeHealthScore(input);
expect(b.components.bp.asOf).toBe(a.components.bp.asOf);
expect(b.components.weight.asOf).toBe(a.components.weight.asOf);
expect(b.components.mood.asOf).toBe(a.components.mood.asOf);
expect(b.components.compliance.asOf).toBe(a.components.compliance.asOf);
});
});
describe("computeHealthScore — delta vs previous week", () => {
const baseInput: HealthScoreInput = {
bpInTargetRate: 80,
weightSeriesLast30d: weightSeries([82, 81, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
medicationCompliance30: [100, 100],
};
const previousInput: HealthScoreInput = {
...baseInput,
bpInTargetRate: 60,
medicationCompliance30: [70, 80],
};
it("returns null when no previous input is supplied", () => {
const result = computeHealthScore(baseInput);
expect(result.delta).toBeNull();
});
it("returns positive delta when current is higher than previous", () => {
const result = computeHealthScore(baseInput, previousInput);
expect(result.delta).not.toBeNull();
expect(result.delta!).toBeGreaterThan(0);
});
it("returns negative delta when current is lower than previous", () => {
const result = computeHealthScore(previousInput, baseInput);
expect(result.delta).not.toBeNull();
expect(result.delta!).toBeLessThan(0);
});
});
// ── v1.4.25 W8e — per-component source attribution ──────────────────
describe("computeHealthScore — source attribution", () => {
const WINDOW_END = "2026-05-14T12:00:00.000Z";
const BP_LATEST = "2026-05-14T08:00:00.000Z";
const WEIGHT_LATEST = "2026-05-13T18:00:00.000Z";
const MOOD_LATEST = "2026-05-14T07:30:00.000Z";
function baseInputWithAttribution(
attribution: HealthScoreInput["attribution"],
): HealthScoreInput {
return {
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
medicationCompliance30: [95, 100],
attribution,
};
}
it("collapses a single-source component to that source label", () => {
const result = computeHealthScore(
baseInputWithAttribution({
bpSources: ["withings"],
asOfBp: BP_LATEST,
weightSources: ["withings"],
asOfWeight: WEIGHT_LATEST,
moodSources: ["manual"],
asOfMood: MOOD_LATEST,
complianceSources: ["manual"],
asOfCompliance: WINDOW_END,
windowEndAt: WINDOW_END,
}),
);
expect(result.components.bp.source).toBe("withings");
expect(result.components.bp.asOf).toBe(BP_LATEST);
expect(result.components.weight.source).toBe("withings");
expect(result.components.weight.asOf).toBe(WEIGHT_LATEST);
expect(result.components.mood.source).toBe("manual");
expect(result.components.mood.asOf).toBe(MOOD_LATEST);
expect(result.components.compliance.source).toBe("manual");
});
it("marks a component `mixed` when multiple sources contribute", () => {
const result = computeHealthScore(
baseInputWithAttribution({
bpSources: ["withings", "manual"],
asOfBp: BP_LATEST,
weightSources: ["withings", "appleHealth"],
asOfWeight: WEIGHT_LATEST,
moodSources: ["manual"],
asOfMood: MOOD_LATEST,
complianceSources: ["manual"],
asOfCompliance: WINDOW_END,
windowEndAt: WINDOW_END,
}),
);
expect(result.components.bp.source).toBe("mixed");
expect(result.components.weight.source).toBe("mixed");
// Single-source components still resolve to their token.
expect(result.components.mood.source).toBe("manual");
});
it("falls through to `none` + window-end asOf when a component has no value", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTarget: null,
weightTargetSource: "none",
// <5 mood entries → moodStability returns null.
moodEntriesLast30d: moodEntries([4, 4]),
medicationCompliance30: [],
attribution: {
bpSources: [],
asOfBp: null,
weightSources: [],
asOfWeight: null,
moodSources: [],
asOfMood: null,
complianceSources: [],
asOfCompliance: null,
windowEndAt: WINDOW_END,
},
};
const result = computeHealthScore(input);
expect(result.components.bp.source).toBe("none");
expect(result.components.bp.asOf).toBe(WINDOW_END);
expect(result.components.weight.source).toBe("none");
expect(result.components.mood.source).toBe("none");
expect(result.components.compliance.source).toBe("none");
});
it("backward-compat: omitted attribution defaults present components to `manual`", () => {
const result = computeHealthScore({
bpInTargetRate: 80,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100],
});
// No `attribution` supplied — the legacy contract had no source
// field, so the helper defaults present components to "manual".
expect(result.components.bp.source).toBe("manual");
expect(result.components.weight.source).toBe("manual");
expect(result.components.mood.source).toBe("manual");
expect(result.components.compliance.source).toBe("manual");
// The asOf falls back to a wall-clock ISO string — just verify the
// type/shape, not the exact value.
expect(typeof result.components.bp.asOf).toBe("string");
});
it("falls back to window-end asOf when a present component has no asOf supplied", () => {
const result = computeHealthScore({
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100],
attribution: {
bpSources: ["withings"],
asOfBp: null,
windowEndAt: WINDOW_END,
},
});
expect(result.components.bp.source).toBe("withings");
// No asOf supplied → fall back to window end.
expect(result.components.bp.asOf).toBe(WINDOW_END);
});
});
/**
* v1.34 — the weight yardstick.
*
* Until this release the pillar graded every account with a height on file
* against `22 x height^2` +/- 2 kg: a band nobody set and no surface named.
* These pin the replacement contract — the band arrives whole from the caller,
* a missing band scores the bare trend, and the result says which of the two
* happened.
*/
describe("computeHealthScore — weight yardstick", () => {
const stable = weightSeries([80, 80, 80, 80]);
function scoreWeightOnly(
series: Array<{ date: string; kg: number }>,
target: { min: number; max: number } | null,
) {
return computeHealthScore({
bpInTargetRate: null,
weightSeriesLast30d: series,
weightTarget: target,
weightTargetSource: target ? "user" : "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
});
}
it("takes the band whole — no expansion of the caller's bounds", () => {
// 80 kg sits OUTSIDE 76-79. A +/- 2 kg widening would pull it in and
// score 100; the band must be used exactly as handed over.
expect(
scoreWeightOnly(stable, { min: 76, max: 79 }).components.weight.value,
).toBe(50);
// The same reading INSIDE the band scores the in-band 100.
expect(
scoreWeightOnly(stable, { min: 78, max: 82 }).components.weight.value,
).toBe(100);
});
it("scores the bare trend when no target is set", () => {
// Stable weight, no target: the trend-only anchor, not the target-aware
// "neither" midpoint of 50, and never a fabricated band.
const result = scoreWeightOnly(stable, null);
expect(result.components.weight.value).toBe(75);
expect(result.components.weight.weight).toBeGreaterThan(0);
});
it("carries the resolved yardstick on the weight component", () => {
const withTarget = scoreWeightOnly(stable, { min: 78, max: 82 });
expect(withTarget.components.weight.targetSource).toBe("user");
expect(withTarget.components.weight.target).toEqual({ min: 78, max: 82 });
const without = scoreWeightOnly(stable, null);
expect(without.components.weight.targetSource).toBe("none");
expect(without.components.weight.target).toBeNull();
});
it("never reports a band when the source is `none`", () => {
// Defensive: a caller that passes a band while declaring no source must
// not have that band surface as though the user had set it.
const result = computeHealthScore({
bpInTargetRate: null,
weightSeriesLast30d: stable,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
});
expect(result.components.weight.target).toBeNull();
expect(result.components.weight.targetSource).toBe("none");
});
});
+17 -7
View File
@@ -199,12 +199,11 @@ export interface BpPairPoint {
const HALF_LIFE_DAYS = 45;
const DAY_MS = 24 * 60 * 60 * 1000;
export function gradeBpScoreFromSeries(input: {
export function representativeBpFromSeries(input: {
pairs: ReadonlyArray<BpPairPoint>;
target: BpTargets;
now: Date;
}): number | null {
const { pairs, target, now } = input;
}): { sys: number; dia: number } | null {
const { pairs, now } = input;
if (pairs.length === 0) return null;
const nowMs = now.getTime();
@@ -223,8 +222,19 @@ export function gradeBpScoreFromSeries(input: {
diaWeighted += weight * p.dia;
}
if (weightSum === 0) return null;
return {
sys: sysWeighted / weightSum,
dia: diaWeighted / weightSum,
};
}
const sys = sysWeighted / weightSum;
const dia = diaWeighted / weightSum;
return gradeBpScore({ sys, dia, target });
export function gradeBpScoreFromSeries(input: {
pairs: ReadonlyArray<BpPairPoint>;
target: BpTargets;
now: Date;
}): number | null {
const representative = representativeBpFromSeries(input);
return representative
? gradeBpScore({ ...representative, target: input.target })
: null;
}
+37 -8
View File
@@ -69,7 +69,7 @@ import {
isBpReadingInTarget,
type BpReading,
} from "./bp-in-target";
import { gradeBpScoreFromSeries } from "./bp-grade";
import { gradeBpScoreFromSeries, representativeBpFromSeries } from "./bp-grade";
import type { BpTargets } from "./bp-targets";
/**
@@ -111,6 +111,10 @@ export interface BpInTargetEnvelope {
* in-target rate AS THE SCORE. `null` when no pairs formed.
*/
gradedScore: number | null;
/** Same recency-weighted pair that `gradedScore` grades. */
representative: { sys: number; dia: number } | null;
/** Freshest paired reading in the trailing 90-day score window. */
last90LatestAt: Date | null;
}
/**
@@ -304,12 +308,16 @@ async function computeFromRollups(
const ninetyDaysAgoMs = ninetyDaysAgo.getTime();
const nowMs = now.getTime();
let last90EarliestAt: Date | null = null;
let last90LatestAt: Date | null = null;
for (const p of pairsByDay) {
const dayMs = p.day.getTime();
if (dayMs < ninetyDaysAgoMs || dayMs >= nowMs) continue;
if (last90EarliestAt === null || dayMs < last90EarliestAt.getTime()) {
last90EarliestAt = p.day;
}
if (last90LatestAt === null || dayMs > last90LatestAt.getTime()) {
last90LatestAt = p.day;
}
}
// All-time on the rollup path is bounded to the 395-day read window.
// Documented above — same trade-off the per-event path makes when the
@@ -361,28 +369,37 @@ async function computeFromRollups(
// produced different graded scores for the same data depending on
// DAY-bucket warmth (the in-target RATE already multiplied by
// perDayPairCount; the graded score now does the equivalent).
const scorePairs = pairsByDay
.filter((pair) => pair.day >= ninetyDaysAgo && pair.day < now)
.map((pair) => ({
at: pair.day,
sys: pair.sys,
dia: pair.dia,
count: pair.perDayPairCount,
}));
const gradedScore = gradeBpScoreFromSeries({
pairs: pairsByDay.map((p) => ({
at: p.day,
sys: p.sys,
dia: p.dia,
count: p.perDayPairCount,
})),
pairs: scorePairs,
target: targets,
now,
});
const representative = representativeBpFromSeries({
pairs: scorePairs,
now,
});
return {
last7Days: last7,
last30Days: last30,
last90Days: last90,
last90EarliestAt,
last90LatestAt,
allTime,
priorMonth,
priorYear,
path: "rollup",
rowCount,
gradedScore,
representative,
};
}
@@ -525,22 +542,34 @@ async function computeFromLive(
);
// v1.15.12 A1 — graded BP pillar score from the recency-weighted
// representative of every accepted per-event pair over the read window.
const scorePairs = collectBpPairs(sysData, diaData, userTz).filter(
(pair) => pair.at >= new Date(now.getTime() - 90 * DAY_MS) && pair.at < now,
);
const gradedScore = gradeBpScoreFromSeries({
pairs: collectBpPairs(sysData, diaData, userTz),
pairs: scorePairs,
target: targets,
now,
});
const representative = representativeBpFromSeries({
pairs: scorePairs,
now,
});
return {
last7Days: windows.last7Days,
last30Days: windows.last30Days,
last90Days: windows.last90Days,
last90EarliestAt: windows.last90EarliestAt,
last90LatestAt: scorePairs.reduce<Date | null>(
(latest, pair) => (!latest || pair.at > latest ? pair.at : latest),
null,
),
allTime: windows.allTime,
priorMonth: windows.priorMonth,
priorYear: windows.priorYear,
path: "live",
rowCount,
gradedScore,
representative,
};
}
+12
View File
@@ -28,6 +28,18 @@ export interface BpTargets {
diaHigh: number;
}
export function bpTargetsEqual(
left: BpTargets | null,
right: BpTargets | null,
): boolean {
return (
left?.sysLow === right?.sysLow &&
left?.sysHigh === right?.sysHigh &&
left?.diaLow === right?.diaLow &&
left?.diaHigh === right?.diaHigh
);
}
function getAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
@@ -6,23 +6,14 @@
*
* Source pins: each call site that feeds the compliance engine fetches
* its medications with an explicit `asNeeded: false` predicate. The pin
* walks the medication-feeding `findMany` of every rate-surfacing call
* site (the documented set: Coach prompt, BP-status gate, health-score
* pillar, insight features / targets / comprehensive, the nightly
* compliance status, the batched card-compliance payload) and asserts
* the predicate is present — a refactor that drops it fails here before
* a PRN medication can drag a user's rate to 0% (never-taken-on-no-
* schedule) or pad it to 100% (vacuous denominator).
* walks every rate-surfacing `findMany` and fails if a PRN medication
* could enter a scheduled-dose rate.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const CALL_SITES: Array<{ file: string; surface: string }> = [
{
file: "src/lib/analytics/health-score-fast-path.ts",
surface: "health-score compliance pillar (dashboard)",
},
{
file: "src/lib/insights/blood-pressure-status.ts",
surface: "BP-status compliance gate",
+40 -1
View File
@@ -16,7 +16,7 @@
*/
import { binaryReferenceSex } from "@/lib/profile/sex";
import { getBpTargets } from "./bp-targets";
import { getBpTargets, type BpTargets } from "./bp-targets";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -332,6 +332,45 @@ export function resolveWeightTargetOverride(
return { min, max };
}
function resolveThresholdOverride(
thresholdsJson: unknown,
metric: ThresholdMetric,
): { min: number; max: number } | null {
if (!thresholdsJson || typeof thresholdsJson !== "object") return null;
const value = (thresholdsJson as Record<string, unknown>)[metric];
if (!value || typeof value !== "object") return null;
const { min, max } = value as { min?: unknown; max?: unknown };
if (typeof min !== "number" || typeof max !== "number") return null;
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
return { min, max };
}
export function resolveEffectiveBpTargets(
profile: UserProfileForRange,
thresholdsJson: unknown,
): BpTargets | null {
const sysOverride = resolveThresholdOverride(
thresholdsJson,
"BLOOD_PRESSURE_SYS",
);
const diaOverride = resolveThresholdOverride(
thresholdsJson,
"BLOOD_PRESSURE_DIA",
);
const overrides: ThresholdOverridesJson = {};
if (sysOverride) overrides.BLOOD_PRESSURE_SYS = sysOverride;
if (diaOverride) overrides.BLOOD_PRESSURE_DIA = diaOverride;
const sys = getEffectiveRange("BLOOD_PRESSURE_SYS", profile, overrides).range;
const dia = getEffectiveRange("BLOOD_PRESSURE_DIA", profile, overrides).range;
if (!sys || !dia) return null;
return {
sysLow: sys.greenMin,
sysHigh: sys.greenMax,
diaLow: dia.greenMin,
diaHigh: dia.greenMax,
};
}
/**
* Convenience: resolve every supported metric at once. Useful for /targets
* and the insight generator.
-618
View File
@@ -1,618 +0,0 @@
/**
* v1.4.37 W2 — probe-gated Health Score builder.
*
* The `/api/analytics` default slice builds the Personal Health Score
* by combining BP-in-target % (already computed upstream) with three
* additional pillars: weight-trend alignment, mood stability, and
* medication compliance. Up to v1.4.37 the route inlined four parallel
* `findMany` reads (weight 37 days, BP-SYS 30 days for source pills,
* mood 37 days, active medications) plus a fifth for intake events.
* On a cold connection pool the round-trip latency stacks — the
* v1.4.36 perf-verify recorded the bp_in_target + healthScore +
* correlations fan-out at 111 s wall-clock against the maintainer's
* 311 779-row tenant.
*
* Read shape (v1.4.37)
* --------------------
* 1. **Probe** — `probeRollupCoverage` returns the per-type DAY-bucket
* coverage map. When WEIGHT is covered we take the rollup-fast-path
* for the weight pillar; the slope/alignment math runs against
* per-day MEAN weight derived from `measurement_rollups` instead
* of the raw `measurements` table.
*
* 2. **Source attribution** — only the raw rows know which ingest
* path produced each reading, so the per-component
* `HealthScoreSourceAttribution` slice still needs a narrow read
* against `measurements` (selecting only `source` + `measuredAt`).
* That read is cheap (30-day window, 2-column projection) and
* stays live regardless of coverage.
*
* 3. **Live fallback** — when the probe shows WEIGHT is not covered
* (brand-new user, no buckets yet) the helper reads the raw
* weight rows the same way the legacy route did so the score
* stays correct on first cold-mount.
*
* The mood / medication / compliance pillars don't have rollup
* equivalents (the rollup table only carries `Measurement` aggregates)
* — those reads remain live regardless. The path annotate on
* `meta.healthScore.path` makes the branch selection visible in prod
* logs.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "./compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
computeHealthScore,
type ContributingSource,
type HealthScoreInput,
type HealthScoreResult,
type WeightTargetSource,
} from "./health-score";
import type { MeasurementSource } from "@/generated/prisma/client";
/**
* v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto
* the camelCase token set the health-score analytics layer consumes.
*
* - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both
* surface as `"manual"`.
* - `WITHINGS` and `APPLE_HEALTH` ride one-to-one.
*
* Returns `null` for source values that don't fall into the three
* exposed buckets — defence-in-depth in case the enum grows before the
* client is taught about it.
*/
function mapMeasurementSourceToLabel(
source: MeasurementSource,
): ContributingSource | null {
switch (source) {
case "MANUAL":
case "IMPORT":
return "manual";
case "WITHINGS":
return "withings";
case "APPLE_HEALTH":
return "appleHealth";
default:
return null;
}
}
function uniqueComponentSources(
rows: ReadonlyArray<MeasurementSource>,
): ReadonlyArray<ContributingSource> {
const seen = new Set<ContributingSource>();
for (const src of rows) {
const label = mapMeasurementSourceToLabel(src);
if (label) seen.add(label);
}
return Array.from(seen);
}
export interface HealthScoreFastPathInput {
userId: string;
bpInTargetPct: number | null;
/**
* v1.4.38 — prior-week BP-in-target pct so the week-over-week delta
* reflects BP movement instead of always landing at zero for the
* pillar. The caller is expected to run a second
* `computeBpInTargetFastPath` against `now - 7d` and pass the
* resulting `last30Days.pct` through. When omitted (legacy callers
* predating v1.4.38) the helper falls back to `bpInTargetPct` —
* preserves the pre-v1.4.38 behaviour exactly so the bump is
* additive.
*/
bpInTargetPctPriorWeek?: number | null;
/**
* v1.15.12 A1 — graded clinical-proximity BP score (0..100) from a
* recency-weighted representative reading. When supplied it becomes
* the BP pillar VALUE; the binary `bpInTargetPct` only decides pillar
* presence and is surfaced as a secondary stat. Omit (legacy callers)
* to keep the pre-v1.15.12 behaviour where the rate IS the score.
*/
bpGradedScore?: number | null;
/**
* v1.15.12 A1 — prior-week graded BP score for the week-over-week
* delta. Omit to fall back to `bpGradedScore` (BP cancels out of the
* delta), mirroring the `bpInTargetPctPriorWeek` fallback.
*/
bpGradedScorePriorWeek?: number | null;
/**
* v1.34 — the weight yardstick, resolved by the caller through
* `buildHealthScoreWeightTarget`. The band the user set, or `null`
* when they set none. Required, not defaulted: a forgotten caller must
* fail to compile rather than silently fall back to a number nobody
* chose — which is exactly how `22 × height²` graded every account for
* months.
*/
weightTarget: { min: number; max: number } | null;
/** Which yardstick `weightTarget` came from. Required for the same reason. */
weightTargetSource: WeightTargetSource;
now: Date;
/**
* Per-type rollup coverage map. The caller (analytics route) probes
* once and shares the result across all three fast-path helpers so
* the probe cost stays flat in the fan-out.
*/
coverage?: RollupCoverageMap;
/**
* v1.18.0 R4 — module-aware mood pillar. When the caller resolves the
* `mood` module as disabled it threads `moodEnabled: false` and the
* helper drops the 0.20 mood-stability pillar entirely: the mood read
* is skipped and the series stays empty, so `moodStability` returns
* `null` and the existing proportional null-redistribution re-scales
* the surviving pillars to sum to 100. Without this a disabled-mood
* account is silently penalised (or rewarded) by a pillar it can no
* longer see anywhere else in the product.
*
* v1.34 — required. The field used to default to `true`, and the
* dashboard snapshot never passed it: a mood-off account got a mood
* pillar in the hero ring and none in the Insights card, the same
* score composed two different ways on two surfaces. A required flag
* makes the compiler name every caller.
*/
moodEnabled: boolean;
}
/**
* Public entrypoint. Returns the same shape `computeHealthScore`
* returns, plus a `path` annotate emitted on the meta dict.
*
* Returns null when no component can be computed (the route surfaces
* `null` to the UI so the hero panel hides cleanly).
*/
export async function computeUserHealthScoreFastPath(
input: HealthScoreFastPathInput,
): Promise<HealthScoreResult | null> {
const DAY_MS = 24 * 60 * 60 * 1000;
const {
userId,
bpInTargetPct,
now,
moodEnabled,
weightTarget,
weightTargetSource,
} = input;
// v1.4.38 — prior-week BP fallback. When the caller omits the field
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
// stays a drop-in replacement for legacy callers; when the caller
// supplies it we feed it into the previous-window snapshot so the
// delta reflects week-over-week BP movement instead of always
// zeroing the BP pillar.
const bpInTargetPctPriorWeek =
input.bpInTargetPctPriorWeek === undefined
? bpInTargetPct
: input.bpInTargetPctPriorWeek;
const since30d = new Date(now.getTime() - 30 * DAY_MS);
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
// v1.4.38.8 — gate only on the WEIGHT type this helper reads. The
// prior `isFullyCovered &&` AND poisoned the score fast-path with
// any unrelated brand-new type that lacked rollup coverage. Per-
// type coverage is the correct gate.
const weightCovered = coverage.get("WEIGHT") === true;
// Weight series — rollup-fast-path when fully covered, raw findMany
// otherwise. The raw read selects only the columns the slope helper
// and the source-attribution accordion need (value + measuredAt +
// source) so the projection stays narrow.
let weightSeriesLast30d: Array<{ date: string; kg: number }>;
let weightSeriesPrev30d: Array<{ date: string; kg: number }>;
let weightSourcesIn30d: ReadonlyArray<ContributingSource>;
let latestWeightAsOf: string | null;
// v1.4.40 W-WMY-WIRE — long-window weight baseline served from the
// WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate
// so operators can verify the WMY readers actually serve production
// traffic instead of sitting as dead write amplification (see
// `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score
// shape stays unchanged; the long-window mean is annotate-only so
// the v1.5 Coach drawer can opt into it later without breaking the
// current contract.
let weightLongWindowGranularity: string | null = null;
let weightLongWindowMean: number | null = null;
let weightLongWindowBucketCount = 0;
if (weightCovered) {
// Rollup-fast-path — per-day MEAN weight from `measurement_rollups`.
// Linear regression on per-day means produces a slope equivalent
// to per-event regression for a series with consistent sampling
// cadence (one weigh-in per day is the canonical pattern).
//
// v1.4.40 W-WMY-WIRE — the long-window weight read runs in
// parallel with the canonical DAY-bucket read so the wall-clock
// cost stays flat. `readBestGranularityRollups(..., 365)` routes
// the trailing-year window through MONTH (floor 181 d) by default
// — typically 12 monthly buckets vs the ~365 DAY rows the live
// path would scan. count / mean are linearly composable across
// MONTH buckets so the derived mean is mathematically equivalent
// to the per-row average over the same year-long window.
// Coverage-fallback in the helper means a tenant with WEEK or DAY
// coverage but no MONTH buckets still resolves to a usable mean.
const [dayBuckets, longWindow] = await Promise.all([
readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now),
readBestGranularityRollups(userId, "WEIGHT", 365),
]);
weightSeriesLast30d = dayBuckets
.filter((b) => b.bucketStart >= since30d)
.map((b) => ({
date: b.bucketStart.toISOString(),
kg: b.mean,
}));
weightSeriesPrev30d = dayBuckets
.filter(
(b) => b.bucketStart >= prevSince30d && b.bucketStart <= prevUntil,
)
.map((b) => ({
date: b.bucketStart.toISOString(),
kg: b.mean,
}));
if (longWindow && longWindow.rows.length > 0) {
weightLongWindowGranularity = longWindow.granularity;
weightLongWindowBucketCount = longWindow.rows.length;
let totalCount = 0;
let weighted = 0;
for (const row of longWindow.rows) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount > 0) {
weightLongWindowMean = weighted / totalCount;
}
}
// Source attribution still needs the raw rows — only the raw table
// carries the `source` enum. Pull the narrow 30-day window with a
// 2-column projection so the round-trip stays minimal.
const sourceRows = await prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
});
weightSourcesIn30d = uniqueComponentSources(
sourceRows.map((r) => r.source),
);
latestWeightAsOf = sourceRows.at(-1)?.measuredAt.toISOString() ?? null;
} else {
// Live fallback — raw rows over the 37-day window. Mirrors the
// pre-v1.4.37 behaviour exactly.
const weightRows = await prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
deletedAt: null,
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
});
weightSeriesLast30d = weightRows
.filter((r) => r.measuredAt >= since30d)
.map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value }));
weightSeriesPrev30d = weightRows
.filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil)
.map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value }));
weightSourcesIn30d = uniqueComponentSources(
weightRows.filter((r) => r.measuredAt >= since30d).map((r) => r.source),
);
latestWeightAsOf =
weightRows
.filter((r) => r.measuredAt >= since30d)
.at(-1)
?.measuredAt.toISOString() ?? null;
}
// Remaining reads — mood, BP-SYS source attribution, medications +
// intake — run in parallel. None has a rollup equivalent today so
// these are always live.
const [bpSysRowsForSource, moodRows, medications] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
// v1.18.0 R4 — skip the read entirely when the mood module is off.
// An empty series feeds `moodStability` → null → the 0.20 pillar
// weight redistributes across the surviving pillars. Skipping the
// query (rather than reading-then-discarding) keeps the disabled
// module off the round-trip budget too.
moodEnabled
? prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
})
: Promise.resolve([] as Array<{ score: number; moodLoggedAt: Date }>),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications carry no expected doses
// and are excluded from every compliance rate: they must
// contribute neither 0% nor 100% to the score pillar.
where: { userId, active: true, asNeeded: false },
select: {
id: true,
createdAt: true,
// v1.7.0 SB-SCHED-2 — the medication course-window fields the
// canonical engine needs to expand expected slots.
startsOn: true,
endsOn: true,
oneShot: true,
// v1.25 M-MED3 — route through the SHARED compliance select like every
// other caller, so the Health-Score pillar honours the same per-dose
// on-time windows + cadence fields the dashboard / detail surfaces use.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.25 M-MED3 — archived schedule eras so a past day scores against
// the schedule that was live THEN, not the current one (every other
// compliance caller already threads these). The builder copies them
// through into the context.
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
}),
]);
let medicationCompliance30: number[] = [];
let medicationCompliance30Previous: number[] = [];
if (medications.length > 0) {
const medIds = medications.map((m) => m.id);
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: { in: medIds },
scheduledFor: { gte: prevSince30d, lte: now },
},
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
},
});
const eventsByMed = new Map<string, typeof intakeEvents>();
for (const ev of intakeEvents) {
const list = eventsByMed.get(ev.medicationId);
if (list) list.push(ev);
else eventsByMed.set(ev.medicationId, [ev]);
}
// v1.7.0 SB-SCHED-2 — resolve the user timezone once so the
// compliance pillar routes its denominator through the canonical
// engine. The pinned `now` is kept so the pillar still agrees with
// the score's other pillars.
const userTz = await resolveUserTimezone(userId);
medicationCompliance30 = medications.map((med) => {
const events = eventsByMed.get(med.id) ?? [];
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
// v1.5.0 — pass the helper's pinned `now` so the cadence-aware
// window math agrees with the score's other pillars (which also
// anchor to the same `now`). Closes #214.
return calculateCompliance(events, med.schedules, 30, med.createdAt, {
now,
medicationContext,
}).rate;
});
medicationCompliance30Previous = medications.map((med) => {
const events = (eventsByMed.get(med.id) ?? []).filter(
(e) => e.scheduledFor <= prevUntil,
);
// Shift forward by 7 days so the helper's `now` anchor still
// captures the same logical 30 days (pre-v1.4.37 behaviour).
const shifted = events.map((e) => ({
scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS),
takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null,
skipped: e.skipped,
}));
// v1.25 M-MED3 — `med` (and so its scheduleRevisions + pauseEras) is
// passed UNshifted while the intake events shift +7d (the pre-existing
// approximation that lets the pinned `now` anchor still capture the prior
// logical 30 days). The revisions / pause eras are absolute historical
// records; no canonical previous-window caller shifts them, so they stay
// as-is here for consistency with the rest of the engine. Trade-off: in
// the trailing-30-day DELTA the era / pause segmentation can be up to 7
// days out of phase with the shifted slots. The primary (current-window)
// score — `medicationCompliance30` above — is exact; only the secondary
// trend delta carries this best-effort approximation.
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(shifted),
userTz,
);
return calculateCompliance(shifted, med.schedules, 30, med.createdAt, {
now,
medicationContext,
}).rate;
});
}
const moodSeriesLast30d = moodRows
.filter((r) => r.moodLoggedAt >= since30d)
.map((r) => ({
date: r.moodLoggedAt.toISOString(),
score: r.score,
}));
const moodSeriesPrev30d = moodRows
.filter(
(r) => r.moodLoggedAt >= prevSince30d && r.moodLoggedAt <= prevUntil,
)
.map((r) => ({
date: r.moodLoggedAt.toISOString(),
score: r.score,
}));
const windowEndAt = now.toISOString();
const bpSourceTokens = uniqueComponentSources(
bpSysRowsForSource.map((r) => r.source),
);
const latestBpInWindow = bpSysRowsForSource.at(-1);
// Mood doesn't yet have a non-manual ingest (v1.5 will introduce
// Apple Health mood) — preserve the manual-only contract.
const moodSourceTokens =
moodSeriesLast30d.length > 0 ? (["manual"] as const) : [];
const latestMoodInWindow = moodRows
.filter((r) => r.moodLoggedAt >= since30d)
.at(-1);
const complianceSourceTokens =
medicationCompliance30.length > 0 ? (["manual"] as const) : [];
const current: HealthScoreInput = {
bpInTargetRate: bpInTargetPct,
// v1.15.12 A1 — graded BP score drives the pillar value when present
// (the binary rate above only gates presence + is the secondary stat).
bpGradedScore: input.bpGradedScore ?? null,
weightSeriesLast30d,
weightTarget,
weightTargetSource,
moodEntriesLast30d: moodSeriesLast30d,
medicationCompliance30,
attribution: {
bpSources: bpSourceTokens,
asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null,
weightSources: weightSourcesIn30d,
asOfWeight: latestWeightAsOf,
moodSources: moodSourceTokens,
asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null,
complianceSources: complianceSourceTokens,
asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null,
windowEndAt,
},
};
// Previous-window snapshot — same logic, prior-week-shifted series.
//
// v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we
// use it so the delta reflects week-over-week BP movement. Legacy
// callers that omit the field keep the pre-v1.4.38 behaviour where
// BP cancels out of the delta because the helper pins both windows
// to the same value. Re-computing the prior-week BP costs one
// extra rollup-coverage read on the route side; the helper itself
// stays single-pass.
const previous: HealthScoreInput = {
bpInTargetRate: bpInTargetPctPriorWeek,
// v1.15.12 A1 — prior-week graded score so the week-over-week delta
// reflects graded BP movement. Falls back to the current graded
// score (delta cancels to zero for BP) when the caller omits it.
bpGradedScore:
input.bpGradedScorePriorWeek === undefined
? (input.bpGradedScore ?? null)
: input.bpGradedScorePriorWeek,
weightSeriesLast30d: weightSeriesPrev30d,
weightTarget,
weightTargetSource,
moodEntriesLast30d: moodSeriesPrev30d,
medicationCompliance30: medicationCompliance30Previous,
};
const result = computeHealthScore(current, previous);
// No-pillar-eligible early-out — nothing computable, hero hides cleanly.
//
// The naive raw-record check this replaced only asked whether any pillar
// had a nonzero row count, not whether that count cleared the pillar's own
// eligibility bar: weight needs >= 2 readings, mood needs >= 5 entries, BP
// needs a paired reading, compliance needs an active scheduled medication.
// An account that clears the row-count check but misses every pillar's
// bar (e.g. exactly one mood entry, or a single weight reading) fell
// through to `computeHealthScore`, which sums no components and returns a
// raw `0` — indistinguishable from a genuinely poor score. Checking the
// COMPUTED result's components (the same eligibility rules the score
// itself already applies) instead of re-deriving the thresholds here means
// this can never drift out of sync with `computeHealthScore`. A component
// that is legitimately computed as `0` still has `value: 0`, not `null`,
// so it is never swept into this branch.
const noComponentEligible =
result.components.bp.value === null &&
result.components.weight.value === null &&
result.components.mood.value === null &&
result.components.compliance.value === null;
if (noComponentEligible) {
annotate({
meta: {
healthScore: {
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
return null;
}
annotate({
meta: {
healthScore: {
score: result.score,
band: result.band,
delta: result.delta,
path: weightCovered ? "rollup" : "live",
// v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn
// from the WEEK / MONTH / YEAR rollup tier. `null` when the
// user lacks long-tail coverage or when the live fallback
// branch ran (no probe issued). Surfaces the granularity the
// router landed on so operators can see "rollup tier served
// the year-long mean from MONTH buckets" in the wide-event.
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
return result;
}
-161
View File
@@ -1,161 +0,0 @@
/**
* v1.17 W1b — single Health-Score BP-pillar input builder.
*
* The Personal Health Score is computed by `computeUserHealthScoreFastPath`
* from two server callers: the analytics route (the Insights health-score
* card) and the dashboard snapshot (the hero ring). Before this builder the
* two callers assembled the BP-pillar inputs by hand and DIVERGED:
*
* - the analytics route fed the BP pillar from the all-time in-target rate
* and passed the prior-week values for the week-over-week delta;
* - the dashboard snapshot fed the pillar from the trailing-30-day rate and
* omitted the prior-week values entirely (so the ring's delta zeroed BP
* out and the pillar's presence gate flipped on a user with BP history
* but no readings in the last month).
*
* Same user, two different scores and two different bands. v1.17 W1d already
* unified the WINDOW (both read the trailing-90-day rate); this builder
* unifies the ASSEMBLY so the window choice, the all-time fallback, the
* graded-score wiring and the prior-week delta inputs can never drift between
* the two surfaces again. Both callers pass the identical
* `BpInTargetEnvelope` pair through here and consume the result verbatim.
*
* v1.34 — the WEIGHT yardstick joins the file for the same reason. The score
* used to grade weight against `22 × height²` ± 2 kg: a target no user ever
* set and no surface ever named. `buildHealthScoreWeightTarget` replaces it
* with the one honest source, `getEffectiveRange("WEIGHT", …)`, and hands the
* band over ONLY when it is a real user override. Both callers route through
* it so the ring and the card can never grade weight against two different
* yardsticks.
*
* Pure & deterministic — no I/O. The callers own the
* `computeBpInTargetFastPath` reads (current + prior-week) and hand the two
* envelopes in, and own the `User` row the weight profile / overrides come
* from.
*/
import type { BpInTargetEnvelope } from "./bp-in-target-fast-path";
import {
getEffectiveRange,
type ThresholdOverridesJson,
type UserProfileForRange,
} from "./effective-range";
import type { WeightTargetSource } from "./health-score";
import { isWindowSufficient } from "./window-confidence";
/**
* The BP-pillar slice of `HealthScoreFastPathInput`. Both Health-Score
* callers spread this into their `computeUserHealthScoreFastPath` call so the
* pillar is graded off identical inputs regardless of surface.
*/
export interface HealthScoreBpInputs {
bpInTargetPct: number | null;
bpInTargetPctPriorWeek: number | null;
bpGradedScore: number | null;
bpGradedScorePriorWeek: number | null;
}
/**
* Build the BP-pillar inputs from the current-window and prior-week
* `computeBpInTargetFastPath` envelopes.
*
* - The pillar rate (presence gate + secondary stat) reads the canonical
* trailing-90-day window (W1d), falling back to the all-time rate only when
* the 90-day window is itself null so a sparse-but-historical account keeps
* its BP pillar.
* - v1.17 W1b — when the 90-day window holds fewer than the confidence floor
* (`isWindowSufficient`) the pillar collapses to `null` so a thin-data user
* does not get a confident BP pillar grading off a handful of readings. The
* dashboard tile shows "collecting data" for the same input; the pillar and
* the tile agree on the gate. The all-time fallback still rescues an account
* with a deep history but a quiet last quarter.
* - The prior-week rate uses the prior-week run's 90-day window so the
* week-over-week delta compares the same window on both ends.
* - The graded clinical-proximity score (the pillar VALUE) and its prior-week
* counterpart come straight off the envelopes.
*
* `null` for the current envelope (no BP targets / no readings) collapses
* every field to `null`, which the score helper reads as "pillar absent".
*/
export function buildHealthScoreBpInputs(
current: BpInTargetEnvelope | null,
priorWeek: BpInTargetEnvelope | null,
): HealthScoreBpInputs {
if (!current) {
return {
bpInTargetPct: null,
bpInTargetPctPriorWeek: null,
bpGradedScore: null,
bpGradedScorePriorWeek: null,
};
}
// v1.17 W1b — a thin 90-day window (below the confidence floor) is not a
// trustworthy pillar input. Fall through to the all-time rate when the
// 90-day window is itself sparse so a deep-history account survives a quiet
// quarter, but never grade off a sub-floor 90-day sample.
const last90Sufficient =
current.last90Days !== null && isWindowSufficient(current.last90Days.pairs);
const bpInTargetPct = last90Sufficient
? (current.last90Days?.pct ?? null)
: (current.allTime?.pct ?? null);
const priorWeek90Sufficient =
priorWeek?.last90Days != null &&
isWindowSufficient(priorWeek.last90Days.pairs);
const bpInTargetPctPriorWeek = priorWeek90Sufficient
? (priorWeek?.last90Days?.pct ?? null)
: (priorWeek?.allTime?.pct ?? null);
// The graded clinical score is the pillar VALUE; suppress it together with
// the rate when the 90-day window is thin AND no all-time rate rescues the
// pillar, so the pillar disappears cleanly instead of grading a thin sample.
// The prior-week graded score keys off the PRIOR-WEEK window's own presence
// (not the current one) so the week-over-week delta never pairs a confident
// current value against a thin-sample prior value.
const pillarPresent = bpInTargetPct !== null;
const priorPillarPresent = bpInTargetPctPriorWeek !== null;
return {
bpInTargetPct,
bpInTargetPctPriorWeek,
bpGradedScore: pillarPresent ? current.gradedScore : null,
bpGradedScorePriorWeek: priorPillarPresent
? (priorWeek?.gradedScore ?? null)
: null,
};
}
/**
* The weight-pillar slice of `HealthScoreFastPathInput`. Both Health-Score
* callers spread this into their `computeUserHealthScoreFastPath` call so the
* pillar is graded against the identical yardstick regardless of surface.
*/
export interface HealthScoreWeightTargetInputs {
weightTarget: { min: number; max: number } | null;
weightTargetSource: WeightTargetSource;
}
/**
* Resolve the weight yardstick for the Health Score.
*
* The score grades weight against the user's OWN target or against nothing.
* `getEffectiveRange` layers an evidence-based default under the override, and
* that default is exactly what must NOT reach the score: it is a reference
* band the person never chose and no score surface ever named. So the green
* band is handed over only when `isOverride` is true; every other case resolves
* to `null`, which routes the pillar through the honest trend-only path.
*
* Pure. `overrides` is the parsed `User.thresholdsJson` value (may be null).
*/
export function buildHealthScoreWeightTarget(
profile: UserProfileForRange,
overrides: ThresholdOverridesJson | null,
): HealthScoreWeightTargetInputs {
const effective = getEffectiveRange("WEIGHT", profile, overrides);
if (!effective.isOverride || !effective.range) {
return { weightTarget: null, weightTargetSource: "none" };
}
return {
weightTarget: {
min: effective.range.greenMin,
max: effective.range.greenMax,
},
weightTargetSource: "user",
};
}
-612
View File
@@ -1,612 +0,0 @@
/**
* v1.4.20 phase B5 — Personal Health Score.
*
* A composite 0–100 score that blends four health pillars into one
* easy-to-read number. Server-deterministic: the same inputs always
* produce the same output, so a curious user can recompute it mentally
* (or in a spreadsheet) from the four components.
*
* Formula (when every component is present):
* score = round(
* 0.30 * bpGradedScore // v1.15.12 A1 — graded clinical
* // proximity of a recency-weighted
* // representative BP (was the binary
* // bpInTargetRate, kept as a secondary
* // "X% in range" stat)
* + 0.20 * weightTrendAlignment // 0..100, see weightTrendAlignment()
* + 0.20 * moodStability // 0..100, see moodStability()
* + 0.30 * complianceRate // mean of medication compliance30
* )
*
* When a component is null (insufficient data, no medications, etc.)
* the remaining weights are scaled proportionally so they still sum to
* 100 — the score never lies about what was actually measured.
*
* Weight yardstick: the pillar grades against the user's OWN target
* band or against nothing at all. There is no population default and no
* derived stand-in — a band the user never set is not a band. Callers
* resolve the target through `buildHealthScoreWeightTarget`
* (`./health-score-inputs`) and pass `weightTarget: null` +
* `weightTargetSource: "none"` when the account carries no override; the
* trend is then scored on its own via `bareWeightTrendScore`. The
* resolved yardstick rides the result on
* `components.weight.targetSource` so every surface can say which
* yardstick graded the row.
*
* Bands:
* green >= 75
* yellow 50..74
* red < 50
*
* The score is **descriptive, not prescriptive** — see the disclaimer
* the UI surfaces ("Indicative — not a clinical assessment.").
*/
export type HealthScoreBand = "green" | "yellow" | "red";
/**
* v1.4.25 W8e — per-component source attribution.
*
* `manual` / `withings` / `appleHealth` map onto the `MeasurementSource`
* enum already persisted on the underlying rows. `mixed` is the
* aggregate label when the component blends entries from more than one
* source. `none` is the explicit empty-state — no measurement contributed.
*
* The token list is a UI-facing camelCase view of the persisted
* `MeasurementSource` enum so the i18n keys (`provenance.sources.*`)
* stay locale-stable even if the storage enum grows.
*/
/**
* Source tokens that can contribute to a component before the
* `resolveSourceLabel` aggregation kicks in. Exported here so the
* analytics route, the health-score helper, and the React card all
* read the same union — the literal `"manual" | "withings" |
* "appleHealth"` was previously spelled at four call sites.
*/
export type ContributingSource = "manual" | "withings" | "appleHealth";
/**
* Which yardstick graded the weight pillar.
*
* - `"user"` — an explicit target the user set on the targets surface
* (`User.thresholdsJson`, resolved through `getEffectiveRange`).
* - `"none"` — no target on file; the pillar scores the bare trend and
* the surfaces say so.
*
* There is deliberately no third token: a score must never grade a
* person against a band nobody set and nothing discloses.
*/
export type WeightTargetSource = "user" | "none";
export type HealthScoreComponentSource = ContributingSource | "mixed" | "none";
/**
* v1.4.25 W8e — per-component source attribution as it arrives from the
* Prisma layer (the route maps Withings/Apple-Health/Manual rows for
* each pillar and forwards the contributing source set plus the
* freshest timestamp). `complianceSources` is `null` when no medications
* are active so the redistribution helper keeps treating compliance as
* "no signal" rather than "manual with zero entries".
*/
export interface HealthScoreSourceAttribution {
/** Sources that contributed to the BP-in-target rate. */
bpSources?: ReadonlyArray<ContributingSource>;
asOfBp?: string | null;
/** Sources that contributed to the weight series. */
weightSources?: ReadonlyArray<ContributingSource>;
asOfWeight?: string | null;
/** Sources that contributed to the mood entries. */
moodSources?: ReadonlyArray<ContributingSource>;
asOfMood?: string | null;
/** Sources that contributed to medication compliance. */
complianceSources?: ReadonlyArray<ContributingSource>;
asOfCompliance?: string | null;
/** Wall-clock anchor for the as-of-window-end fallback. */
windowEndAt?: string;
}
export interface HealthScoreInput {
/**
* All-time BP-in-target rate, already 0..100 (or null).
*
* v1.15.12 A1 — this is now a SECONDARY honest stat ("X% of readings
* in range"), no longer the pillar score itself. When `bpGradedScore`
* is supplied it drives the pillar value; `bpInTargetRate` is kept so
* the pillar can still be computed (presence check) and the rate
* surfaced alongside. Legacy callers that omit `bpGradedScore` keep
* the pre-v1.15.12 behaviour where the rate IS the score.
*/
bpInTargetRate: number | null;
/**
* v1.15.12 A1 — graded clinical-proximity BP score, 0..100 (or null).
* Computed from a recency-weighted representative reading via
* `gradeBpScoreFromSeries`. When present it becomes the BP pillar
* value; the binary `bpInTargetRate` only decides presence and is
* surfaced as a secondary stat. Closes the "borderline-stage-1 reads
* 16/100" trust bug.
*/
bpGradedScore?: number | null;
/** Weight readings over the last 30 days, ascending or unsorted (we sort). */
weightSeriesLast30d: Array<{ date: string; kg: number }>;
/**
* The user's own target weight band in kg, or `null` when they have
* not set one. The band arrives whole — the scorer neither widens nor
* narrows it, and it never synthesises one from height, BMI, or the
* user's own median. `null` routes the pillar through
* `bareWeightTrendScore`.
*/
weightTarget: { min: number; max: number } | null;
/**
* Which yardstick the weight pillar is graded against. `"user"` means
* `weightTarget` carries an explicit override the user set on the
* targets surface; `"none"` means no target exists and the pillar
* scores the bare trend. Required so a caller cannot pass a band
* without declaring where it came from.
*/
weightTargetSource: WeightTargetSource;
/** Mood entries over the last 30 days. `score` 1..5 in HealthLog. */
moodEntriesLast30d: Array<{ date: string; score: number }>;
/** Per-active-medication 30-day compliance %. Empty array → null. */
medicationCompliance30: number[];
/**
* v1.4.25 W8e — optional source attribution. When omitted the
* resulting `HealthScoreComponentDetail.source` falls back to `none`
* for null components and `manual` for present ones (the
* pre-v1.4.25 implicit default).
*/
attribution?: HealthScoreSourceAttribution;
}
export interface HealthScoreComponentDetail {
/** Sub-score 0..100, or null if the component had insufficient data. */
value: number | null;
/** Effective weight after null-redistribution, 0..1. */
weight: number;
/**
* v1.4.25 W8e — which ingest path produced the underlying
* measurement(s) for this component. `mixed` when two or more
* sources contributed; `none` when the value is null.
*/
source: HealthScoreComponentSource;
/**
* ISO timestamp of the most recent contributing measurement, or
* the as-of-window-end when no measurement contributed.
*/
asOf: string;
}
/**
* The weight pillar carries its yardstick alongside the value, so every
* surface can name what the row was graded against instead of leaving
* the reader to assume. The disclosure is the point: a target that grades
* silently is the defect this field closes.
*/
export interface HealthScoreWeightComponentDetail extends HealthScoreComponentDetail {
/** `"user"` when an explicit target graded the pillar, else `"none"`. */
targetSource: WeightTargetSource;
/**
* The band the pillar was graded against, in kg. Null exactly when
* `targetSource` is `"none"` — the pillar then scored the bare trend.
*/
target: { min: number; max: number } | null;
}
export interface HealthScoreResult {
/** 0..100, integer. Always present (defaults to 0 with all-null input). */
score: number;
band: HealthScoreBand;
components: {
bp: HealthScoreComponentDetail;
weight: HealthScoreWeightComponentDetail;
mood: HealthScoreComponentDetail;
compliance: HealthScoreComponentDetail;
};
/** Difference vs. last week's score; null when no historical input. */
delta: number | null;
/**
* v1.18.1 P4 — Rest Mode context. When an illness/condition episode is
* active the route attaches this so the surface can frame the score ("you
* were unwell during this window") WITHOUT changing it: `computeHealthScore`
* stays pure and the number is never penalised. Absent / null when the
* account is not in Rest Mode (the pure computation never sets it).
*/
restMode?: RestModeAnnotation | null;
}
/**
* v1.18.1 P4 — the score-payload view of Rest Mode. A compact, value-free
* annotation: never a decrypted note, never a score adjustment. iOS mirrors it
* verbatim (server-authoritative). The full context lives on
* `@/lib/illness/rest-mode`; this is the slice a score result carries.
*/
export interface RestModeAnnotation {
active: boolean;
/** ISO onset of the earliest active episode. */
since: string | null;
episodeCount: number;
}
const BASE_WEIGHTS = {
bp: 0.3,
weight: 0.2,
mood: 0.2,
compliance: 0.3,
} as const;
// ── Pure helpers ─────────────────────────────────────────────────────
/**
* Linear-regression slope (units / day) over a time-series of points.
* Pure least-squares fit. Returns null when there are fewer than two
* distinct x-values (the denominator collapses to zero).
*/
export function linearRegressionSlope(
points: Array<{ date: string; value: number }>,
): number | null {
if (points.length < 2) return null;
const sorted = [...points].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const startMs = new Date(sorted[0].date).getTime();
if (Number.isNaN(startMs)) return null;
const xy = sorted.map((p) => {
const t = new Date(p.date).getTime();
return {
x: (t - startMs) / (24 * 60 * 60 * 1000),
y: p.value,
};
});
const n = xy.length;
const sumX = xy.reduce((s, p) => s + p.x, 0);
const sumY = xy.reduce((s, p) => s + p.y, 0);
const sumXY = xy.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = xy.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
return (n * sumXY - sumX * sumY) / denominator;
}
/**
* Coefficient of variation: stdev / |mean|. Returns null when the mean
* is zero (the metric is undefined) or when fewer than two values are
* supplied (population variance over n=1 is zero by definition but
* carries no signal).
*/
export function coefficientOfVariation(values: number[]): number | null {
if (values.length < 2) return null;
const mean = values.reduce((s, v) => s + v, 0) / values.length;
if (mean === 0) return null;
const variance =
values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
const stdev = Math.sqrt(variance);
return stdev / Math.abs(mean);
}
/**
* Score the recent weight trend on its own, with no target band.
*
* Used whenever the user has weight readings but has not set a target.
* Without a goal we can't say "closing the gap", but a sustained upward
* weight trend is the one direction that is broadly an adverse signal
* independent of any individual target, so we score on that asymmetry:
*
* - Flat / stable (slope ≈ 0) → 75 (maintenance is a solid result).
* - Gentle-to-steep decline (slope<0) → 75 → 100 (favourable; saturating).
* - Gentle-to-steep rise (slope>0) → 75 → 25 (unfavourable; saturating).
*
* The 75 anchor keeps a stable-but-noisy series mid-high (it lands at
* 75, not at the 50 "neither" midpoint the target-aware path uses) so a
* user simply maintaining weight isn't penalised for lacking a goal.
* The same `SATURATION = 0.05 kg/day` (≈ 1.5 kg/month) curve as the
* target-aware path keeps the two scoring modes numerically consistent.
*
* Returns null when fewer than two readings are supplied (caller already
* guards this, but the helper stays self-contained for direct tests).
*/
function bareWeightTrendScore(
series: Array<{ date: string; kg: number }>,
): number | null {
if (series.length < 2) return null;
const points = series.map((p) => ({ date: p.date, value: p.kg }));
const slope = linearRegressionSlope(points);
if (slope === null) return null;
if (slope === 0) return 75; // exactly stable → maintenance anchor
const SATURATION = 0.05;
const normalised = Math.tanh(Math.abs(slope) / SATURATION);
// Decline lifts the anchor toward 100; rise drops it toward 25. The
// 25-point span keeps the unfavourable floor honest (a steep sustained
// gain reads "concerning", not "failing") while the favourable ceiling
// reaches the full 100.
const score = slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised;
return Math.round(Math.max(0, Math.min(100, score)));
}
/**
* Score how well the recent weight trend is closing the gap toward the
* target band [targetMin..targetMax].
*
* - Already inside the band → 100 (the trend doesn't need to do anything).
* - Above the band → a downward slope is "closing the gap"; magnitude
* maps slope to 0..100 with a saturating curve so a 0.5 kg/day drop
* doesn't read more positive than a 0.05 kg/day drop.
* - Below the band → an upward slope is closing.
*
* No target set but ≥ 2 readings → fall back to scoring the **bare
* trend** on its own, so a user with weight data still gets a populated,
* weighted pillar instead of an empty `{value:null, weight:0,
* source:"none"}`. See `bareWeightTrendScore` for the curve and its
* rationale. We do NOT fabricate a target — not from height, not from
* the user's own median — and pretend it's a goal; the trend is scored
* honestly, without one.
*
* Insufficient data (< 2 readings) → null regardless of target.
*/
export function weightTrendAlignment(
series: Array<{ date: string; kg: number }>,
target: { min: number; max: number } | null,
): number | null {
if (series.length < 2) return null;
if (!target) return bareWeightTrendScore(series);
const points = series.map((p) => ({ date: p.date, value: p.kg }));
// Latest reading drives the "are we currently in the band" check.
const sorted = [...points].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const latest = sorted[sorted.length - 1].value;
if (latest >= target.min && latest <= target.max) return 100;
const slope = linearRegressionSlope(points);
if (slope === null) return null;
// Saturate a 0.1 kg/day movement to ~75 % alignment so noisy data
// doesn't cliff the score. The mapping uses a tanh-style curve scaled
// around 0.05 kg/day (≈ 1.5 kg/month — a clinically reasonable rate).
const SATURATION = 0.05;
const normalised = Math.tanh(Math.abs(slope) / SATURATION);
// Above the band: slope < 0 closes the gap.
// Below the band: slope > 0 closes the gap.
const closing = latest > target.max ? slope < 0 : slope > 0;
if (slope === 0) return 50; // exactly stable counts as "neither"
return Math.round(closing ? 50 + 50 * normalised : 50 - 50 * normalised);
}
/**
* Mood stability scored as `100 - CV*100`, clamped to 0..100. Returns
* null when there are fewer than 5 entries (the variance is too noisy
* to draw a stability conclusion from).
*/
export function moodStability(
entries: Array<{ date: string; score: number }>,
): number | null {
if (entries.length < 5) return null;
const values = entries.map((e) => e.score);
const cv = coefficientOfVariation(values);
if (cv === null) return null;
const score = 100 - cv * 100;
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* Mean of medication-compliance percentages. Returns null when no
* medications are active so the score doesn't penalise people who
* haven't logged any.
*/
export function complianceRate(values: number[]): number | null {
if (values.length === 0) return null;
const sum = values.reduce((s, v) => s + v, 0);
return Math.round(sum / values.length);
}
// ── Composite ────────────────────────────────────────────────────────
function clampToHundred(v: number): number {
return Math.max(0, Math.min(100, v));
}
function bandFor(score: number): HealthScoreBand {
if (score >= 75) return "green";
if (score >= 50) return "yellow";
return "red";
}
/**
* Compute the composite score + sub-component breakdown. Pure.
*
* The optional `previous` parameter is used by the route to plug the
* "vs last week" delta — when omitted the result's `delta` is null.
*/
export function computeHealthScore(
input: HealthScoreInput,
previous?: HealthScoreInput,
): HealthScoreResult {
// v1.15.12 A1 — the BP pillar value is the graded clinical-proximity
// score when supplied; otherwise it falls back to the legacy binary
// in-target rate (pre-v1.15.12 contract). The pillar is present only
// when the user actually has BP data — gated on `bpInTargetRate`
// (which the caller computes from paired readings) so a graded score
// can never resurrect a pillar that has no underlying readings.
const bpValue =
input.bpInTargetRate === null
? null
: input.bpGradedScore !== undefined && input.bpGradedScore !== null
? clampToHundred(input.bpGradedScore)
: clampToHundred(input.bpInTargetRate);
// The band arrives whole from the caller's `getEffectiveRange` read —
// no expansion, no fabrication. `null` scores the bare trend.
const weightTarget = input.weightTarget;
const weightValue = weightTrendAlignment(
input.weightSeriesLast30d,
weightTarget,
);
const moodValue = moodStability(input.moodEntriesLast30d);
const complianceValue = complianceRate(input.medicationCompliance30);
const attribution = input.attribution ?? {};
const windowEndAt = attribution.windowEndAt ?? deriveWindowEndAt(input);
const components = redistribute(
{
bp: bpValue,
weight: weightValue,
mood: moodValue,
compliance: complianceValue,
},
{
bp: {
sources: attribution.bpSources ?? null,
asOf: attribution.asOfBp ?? null,
},
weight: {
sources: attribution.weightSources ?? null,
asOf: attribution.asOfWeight ?? null,
},
mood: {
sources: attribution.moodSources ?? null,
asOf: attribution.asOfMood ?? null,
},
compliance: {
sources: attribution.complianceSources ?? null,
asOf: attribution.asOfCompliance ?? null,
},
},
windowEndAt,
{
source: input.weightTargetSource,
target: input.weightTargetSource === "user" ? weightTarget : null,
},
);
let raw = 0;
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
const c = components[key];
if (c.value !== null) raw += c.value * c.weight;
}
const score = Math.round(clampToHundred(raw));
const band = bandFor(score);
let delta: number | null = null;
if (previous) {
const prev = computeHealthScore(previous);
delta = score - prev.score;
}
return { score, band, components, delta };
}
/**
* Collapse a list of contributing source tokens into the single label
* the UI renders. `null`/empty when the underlying value is missing
* → `none`. Two or more distinct tokens → `mixed`. Exactly one token
* → that token. Pre-v1.4.25 inputs (no attribution supplied) default
* to `manual` for present values so the contract stays backward-compat
* — the manual-entry path has been HealthLog's only ingest since
* v1.0.
*/
function resolveSourceLabel(
hasValue: boolean,
sources: ReadonlyArray<ContributingSource> | null,
): HealthScoreComponentSource {
if (!hasValue) return "none";
if (!sources || sources.length === 0) return "manual";
const unique = Array.from(new Set(sources));
if (unique.length === 1) return unique[0];
return "mixed";
}
/**
* Re-scale the four base weights to skip any component whose value is
* null. The remaining components keep the same proportional ratio
* (e.g. BP and Compliance both at 30 % of original 80 % → 37.5 %).
*
* v1.4.25 W8e — the helper also folds the per-component
* `HealthScoreSourceAttribution` slice into each output detail so the
* client can render the provenance accordion without a second pass over
* the analytics payload.
*/
function redistribute(
values: {
bp: number | null;
weight: number | null;
mood: number | null;
compliance: number | null;
},
attribution: Record<
keyof typeof BASE_WEIGHTS,
{
sources: ReadonlyArray<ContributingSource> | null;
asOf: string | null;
}
>,
windowEndAt: string,
weightYardstick: {
source: WeightTargetSource;
target: { min: number; max: number } | null;
},
): HealthScoreResult["components"] {
const present: Array<keyof typeof BASE_WEIGHTS> = [];
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
if (values[key] !== null) present.push(key);
}
const totalBaseWeight = present.reduce((s, key) => s + BASE_WEIGHTS[key], 0);
const weightFor = (key: keyof typeof BASE_WEIGHTS): number => {
if (values[key] === null) return 0;
if (totalBaseWeight === 0) return 0;
return BASE_WEIGHTS[key] / totalBaseWeight;
};
const detailFor = (
key: keyof typeof BASE_WEIGHTS,
): HealthScoreComponentDetail => {
const value = values[key];
const attr = attribution[key];
const source = resolveSourceLabel(value !== null, attr.sources);
// When a component is `none`, fall back to the window-end anchor so
// the UI can still render "as of <today>" without inventing a date.
const asOf = source === "none" ? windowEndAt : (attr.asOf ?? windowEndAt);
return {
value,
weight: weightFor(key),
source,
asOf,
};
};
return {
bp: detailFor("bp"),
weight: {
...detailFor("weight"),
targetSource: weightYardstick.source,
target: weightYardstick.target,
},
mood: detailFor("mood"),
compliance: detailFor("compliance"),
};
}
/**
* v1.4.25 Fix-G — when the caller doesn't supply `attribution.windowEndAt`
* we synthesise it deterministically from the input data so
* `computeHealthScore` stays pure (same input → same output). The route
* always supplies its own `windowEndAt`; this fallback exists so unit
* tests and ad-hoc callers don't pull `new Date()` into the result.
*
* Strategy: take the most recent date across the weight and mood series.
* If neither has any entries (e.g. compliance-only input), fall back to
* the Unix epoch so the value is stable and obviously synthetic — the
* UI never renders this string when attribution is provided.
*/
function deriveWindowEndAt(input: HealthScoreInput): string {
let latest = -Infinity;
for (const p of input.weightSeriesLast30d) {
const t = new Date(p.date).getTime();
if (Number.isFinite(t) && t > latest) latest = t;
}
for (const p of input.moodEntriesLast30d) {
const t = new Date(p.date).getTime();
if (Number.isFinite(t) && t > latest) latest = t;
}
if (latest === -Infinity) return new Date(0).toISOString();
return new Date(latest).toISOString();
}
@@ -0,0 +1,349 @@
import { describe, expect, it } from "vitest";
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import {
attachScoreDelta,
computeComposite,
SCORE_ALGORITHM_CHANGED_AT,
} from "../composite";
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
BLOOD_PRESSURE: "cardiometabolic",
GLYCAEMIA: "cardiometabolic",
ACTIVITY: "activity",
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
function pillar(
id: ScorePillarId,
score: number,
options: {
deltaEligible?: boolean;
noiseFloor?: number;
historyDays?: number;
deltaIdentity?: string;
} = {},
): ScorePillarResult {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: options.historyDays ?? 28,
missing: [],
fullHistoryDays: 28,
});
const value: PillarValue = {
score,
observed: {
label: `${score}`,
value: score,
unit: "score",
asOf: NOW.toISOString(),
sources: ["MANUAL"],
},
reference: {
kind: "guideline-band",
low: 0,
high: 100,
label: "test reference",
source: "Test 2026",
},
noiseFloor: options.noiseFloor ?? 1,
deltaEligible: options.deltaEligible ?? true,
deltaIdentity: options.deltaIdentity ?? id,
};
const result: Derived<PillarValue> = buildOk({
value,
coverage,
confidence,
provenance: {
inputs: [id],
source: "live",
windowDays: 28,
computedAt: NOW.toISOString(),
},
});
return { id, domain: DOMAIN_BY_PILLAR[id], result };
}
function evaluation(
entries: ScorePillarResult[],
asOf: Date,
available = entries.map((entry) => entry.id),
) {
return computeComposite({
pillars: entries,
availablePillars: available,
asOf,
});
}
describe("reference-score composite", () => {
it("requires three domains and one measured physiological domain", () => {
const tooNarrow = evaluation(
[pillar("ACTIVITY", 90), pillar("WELLBEING", 80)],
NOW,
);
expect(tooNarrow.status).toBe("insufficient");
const eligible = evaluation(
[pillar("ACTIVITY", 90), pillar("WELLBEING", 80), pillar("SLEEP", 70)],
NOW,
);
expect(eligible.status).toBe("ok");
});
it("reports breadth coverage as distinct eligible domains", () => {
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 90),
pillar("GLYCAEMIA", 85),
pillar("LIPIDS", 80),
pillar("ACTIVITY", 75),
],
NOW,
);
expect(result.status).toBe("insufficient");
expect(result.coverage.requiredInputs).toBe(3);
expect(result.coverage.presentInputs).toBe(2);
if (result.status === "insufficient") {
expect(result.reason).toBe("three_domains_required");
}
});
it("uses equal weights in registry order", () => {
const result = evaluation(
[
pillar("SLEEP", 90),
pillar("BLOOD_PRESSURE", 60),
pillar("ACTIVITY", 75),
],
NOW,
["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
);
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.score).toBe(75);
expect(result.value.composition).toEqual([
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
]);
}
});
it("caps the composite band at the worst pillar and names the setter", () => {
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 45),
pillar("ACTIVITY", 100),
pillar("SLEEP", 100),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.score).toBe(82);
expect(result.value.band).toBe("red");
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
}
});
it("suppresses delta across a composition change", () => {
const current = evaluation(
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
pillar("WELLBEING", 80),
],
NOW,
);
const previous = evaluation(
[
pillar("BLOOD_PRESSURE", 75),
pillar("ACTIVITY", 75),
pillar("SLEEP", 75),
],
new Date(NOW.getTime() - 7 * 86_400_000),
);
const report = attachScoreDelta(current, previous, previous, NOW);
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("composition_changed");
});
it("suppresses the first comparable eligibility window", () => {
const current = evaluation(
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
],
NOW,
);
const previous = evaluation(
[
pillar("BLOOD_PRESSURE", 75),
pillar("ACTIVITY", 75),
pillar("SLEEP", 75),
],
new Date(NOW.getTime() - 7 * 86_400_000),
);
const previousPrevious = evaluation(
[pillar("BLOOD_PRESSURE", 70), pillar("ACTIVITY", 70)],
new Date(NOW.getTime() - 14 * 86_400_000),
);
const report = attachScoreDelta(current, previous, previousPrevious, NOW);
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("first_eligibility_window");
});
it("suppresses the algorithm boundary and movements below the combined noise floor", () => {
const changedAt = new Date(SCORE_ALGORITHM_CHANGED_AT);
const currentAtBoundary = evaluation(
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
],
changedAt,
);
const beforeBoundary = evaluation(
[
pillar("BLOOD_PRESSURE", 75),
pillar("ACTIVITY", 75),
pillar("SLEEP", 75),
],
new Date(changedAt.getTime() - 7 * 86_400_000),
);
const algorithm = attachScoreDelta(
currentAtBoundary,
beforeBoundary,
beforeBoundary,
changedAt,
);
expect(algorithm.deltaReason).toBe("algorithm_changed");
const current = evaluation(
[
pillar("BLOOD_PRESSURE", 81, { noiseFloor: 4 }),
pillar("ACTIVITY", 80, { noiseFloor: 4 }),
pillar("SLEEP", 80, { noiseFloor: 4 }),
],
NOW,
);
const previous = evaluation(
[
pillar("BLOOD_PRESSURE", 80, { noiseFloor: 4 }),
pillar("ACTIVITY", 80, { noiseFloor: 4 }),
pillar("SLEEP", 80, { noiseFloor: 4 }),
],
new Date(NOW.getTime() - 7 * 86_400_000),
);
const stablePrior = evaluation(
[
pillar("BLOOD_PRESSURE", 80, { noiseFloor: 4 }),
pillar("ACTIVITY", 80, { noiseFloor: 4 }),
pillar("SLEEP", 80, { noiseFloor: 4 }),
],
new Date(NOW.getTime() - 14 * 86_400_000),
);
const belowNoise = attachScoreDelta(current, previous, stablePrior, NOW);
expect(belowNoise.delta).toBeNull();
expect(belowNoise.deltaReason).toBe("below_noise_floor");
});
it("excludes slow pillars from the weekly delta", () => {
const current = evaluation(
[
pillar("BLOOD_PRESSURE", 90),
pillar("ACTIVITY", 90),
pillar("SLEEP", 90),
pillar("LIPIDS", 20, { deltaEligible: false }),
],
NOW,
);
const previous = evaluation(
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
pillar("LIPIDS", 100, { deltaEligible: false }),
],
new Date(NOW.getTime() - 7 * 86_400_000),
);
const stablePrior = evaluation(
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
pillar("LIPIDS", 100, { deltaEligible: false }),
],
new Date(NOW.getTime() - 14 * 86_400_000),
);
const report = attachScoreDelta(
current,
previous,
stablePrior,
NOW,
[
pillar("BLOOD_PRESSURE", 90),
pillar("ACTIVITY", 90),
pillar("SLEEP", 90),
pillar("LIPIDS", 20, { deltaEligible: false }),
],
[
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
pillar("LIPIDS", 100, { deltaEligible: false }),
],
);
expect(report.delta).toBe(10);
expect(report.deltaReason).toBeNull();
});
it("excludes a pillar whose input mode changed from the delta intersection", () => {
const currentPillars = [
pillar("BLOOD_PRESSURE", 80),
pillar("GLYCAEMIA", 100, { deltaIdentity: "hba1c" }),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
];
const previousPillars = [
pillar("BLOOD_PRESSURE", 80),
pillar("GLYCAEMIA", 20, { deltaIdentity: "fasting_glucose" }),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
];
const current = evaluation(currentPillars, NOW);
const previous = evaluation(
previousPillars,
new Date(NOW.getTime() - 7 * 86_400_000),
);
const stablePrior = evaluation(
previousPillars,
new Date(NOW.getTime() - 14 * 86_400_000),
);
const report = attachScoreDelta(
current,
previous,
stablePrior,
NOW,
currentPillars,
previousPillars,
);
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("below_noise_floor");
});
});
@@ -0,0 +1,513 @@
import { describe, expect, it } from "vitest";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
import { computeFitnessPillar } from "../fitness";
import { computeGlycaemiaPillar } from "../glycaemia";
import { computeLipidsPillar } from "../lipids";
import { computeSleepPillar } from "../sleep";
import { computeWellbeingPillar } from "../wellbeing";
const NOW = new Date("2026-07-28T12:00:00.000Z");
const live = {
source: "live" as const,
readFailed: false,
timezone: "Europe/Berlin",
};
function days(count: number, value: number) {
return Array.from({ length: count }, (_, index) => ({
day: new Date(NOW.getTime() - index * 86_400_000)
.toISOString()
.slice(0, 10),
value,
}));
}
describe("reference-score pillars", () => {
it("requires twelve fresh paired blood-pressure readings", () => {
const below = computeBloodPressurePillar({
...live,
pairCount: 11,
gradedScore: 85,
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
sources: ["MANUAL"],
asOf: NOW,
});
expect(below.status).toBe("insufficient");
expect(below.coverage.presentInputs).toBe(11);
const eligible = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
sources: ["MANUAL"],
asOf: NOW,
});
expect(eligible.status).toBe("ok");
if (eligible.status === "ok") {
expect(eligible.value.score).toBe(85);
expect(eligible.value.reference.source).toBe("ESH 2023");
}
});
it("keeps BP scoring clinical while exposing a different personal band", () => {
const result = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
personalTarget: {
sysLow: 115,
sysHigh: 125,
diaLow: 65,
diaHigh: 75,
},
sources: ["MANUAL"],
asOf: NOW,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.score).toBe(85);
expect(result.value.reference.label).toBe("120–129/70–79 mmHg");
expect(result.value.personalReference?.label).toBe("115–125/65–75 mmHg");
}
});
it("prefers a fresh HbA1c result and applies the lower reference floor", () => {
const result = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [
{
value: 4.5,
unit: "%",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
],
fastingGlucose: days(12, 92).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
})),
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.observed.label).toContain("HbA1c");
expect(result.value.reference.low).toBe(5);
expect(result.value.score).toBeLessThan(100);
expect(result.value.deltaEligible).toBe(false);
}
});
it("uses eight fasting readings only when HbA1c is absent", () => {
const below = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [],
fastingGlucose: days(7, 92).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
})),
});
expect(below.status).toBe("insufficient");
const eligible = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [],
fastingGlucose: days(8, 92).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
})),
});
expect(eligible.status).toBe("ok");
});
it("does not use fasting fallback when the preferred lab read failed", () => {
const result = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1cReadFailed: true,
hba1c: [],
fastingGlucose: days(8, 92).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
})),
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("read_failed");
}
});
it("requires 21 activity days and saturates at the age-specific step plateau", () => {
expect(
computeActivityPillar({
...live,
asOf: NOW,
ageYears: 40,
days: days(20, 10_000),
sources: ["APPLE_HEALTH"],
}).status,
).toBe("insufficient");
const younger = computeActivityPillar({
...live,
asOf: NOW,
ageYears: 40,
days: days(21, 12_000),
sources: ["APPLE_HEALTH"],
});
const older = computeActivityPillar({
...live,
asOf: NOW,
ageYears: 68,
days: days(21, 8_000),
sources: ["APPLE_HEALTH"],
});
expect(younger.status).toBe("ok");
expect(older.status).toBe("ok");
if (younger.status === "ok" && older.status === "ok") {
expect(younger.value.score).toBe(100);
expect(older.value.score).toBe(100);
expect(younger.value.reference.high).toBe(10_000);
expect(older.value.reference.high).toBe(8_000);
}
});
it("keeps activity scores identical across rollup and live provenance", () => {
const input = {
readFailed: false,
asOf: NOW,
timezone: "Europe/Berlin",
ageYears: 40,
days: days(21, 8_500),
sources: ["APPLE_HEALTH"],
};
const liveResult = computeActivityPillar({ ...input, source: "live" });
const rollupResult = computeActivityPillar({ ...input, source: "DAY" });
expect(liveResult.status).toBe("ok");
expect(rollupResult.status).toBe("ok");
if (liveResult.status === "ok" && rollupResult.status === "ok") {
expect(rollupResult.value.score).toBe(liveResult.value.score);
expect(rollupResult.provenance.source).toBe("DAY");
expect(liveResult.provenance.source).toBe("live");
}
});
it("scores exactly 28 activity calendar keys", () => {
const points = days(29, 10_000);
points[28] = { ...points[28], value: 0 };
const result = computeActivityPillar({
...live,
asOf: NOW,
ageYears: 40,
days: points,
sources: ["APPLE_HEALTH"],
});
expect(result.status).toBe("ok");
if (result.status === "ok") expect(result.value.score).toBe(100);
});
it("requires fourteen sleep nights, never penalises long sleep, and adds regularity at 21 nights", () => {
const below = computeSleepPillar({
...live,
asOf: NOW,
ageYears: 40,
nights: days(13, 480).map((point, index) => ({
night: point.day,
asleepMinutes: point.value,
midpoint: 180 + index,
})),
sources: ["APPLE_HEALTH"],
});
expect(below.status).toBe("insufficient");
const longSleep = computeSleepPillar({
...live,
asOf: NOW,
ageYears: 40,
nights: days(21, 600).map((point) => ({
night: point.day,
asleepMinutes: point.value,
midpoint: 180,
})),
sources: ["APPLE_HEALTH"],
});
expect(longSleep.status).toBe("ok");
if (longSleep.status === "ok") {
expect(longSleep.value.score).toBe(100);
expect(longSleep.value.reference.high).toBeNull();
}
});
it("scores exactly 28 sleep calendar keys", () => {
const nights = days(29, 600).map((point) => ({
night: point.day,
asleepMinutes: point.value,
midpoint: 180,
}));
nights[28] = { ...nights[28], asleepMinutes: 0 };
const result = computeSleepPillar({
...live,
asOf: NOW,
ageYears: 40,
nights,
sources: ["APPLE_HEALTH"],
});
expect(result.status).toBe("ok");
if (result.status === "ok") expect(result.value.score).toBe(100);
});
it("scores waist-to-height only from a fresh waist-derived value", () => {
const result = computeAdiposityPillar({
...live,
asOf: NOW,
heightCm: 180,
rows: [
{
type: "WAIST_CIRCUMFERENCE",
value: 90,
unit: "cm",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.observed.value).toBe(0.5);
expect(result.value.reference.high).toBe(0.5);
expect(result.value.reference.source).toBe("NICE 2022");
}
});
it("uses the freshest complete adiposity signal", () => {
const result = computeAdiposityPillar({
...live,
asOf: NOW,
heightCm: 180,
rows: [
{
type: "WAIST_TO_HEIGHT",
value: 0.6,
unit: "ratio",
at: new Date("2026-06-01T08:00:00Z"),
source: "MANUAL",
},
{
type: "WAIST_CIRCUMFERENCE",
value: 90,
unit: "cm",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.observed.value).toBe(0.5);
expect(result.value.observed.asOf).toContain("2026-07-20");
}
});
it("suppresses a PHQ-9 result when item 9 was flagged", () => {
const result = computeWellbeingPillar({
...live,
asOf: NOW,
assessments: [
{
instrument: "PHQ9",
totalScore: 3,
item9Flagged: true,
at: new Date("2026-07-20T08:00:00Z"),
},
],
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("crisis_signposting");
}
});
it("scores validated wellbeing instruments on their published scales", () => {
const phq = computeWellbeingPillar({
...live,
asOf: NOW,
assessments: [
{
instrument: "PHQ9",
totalScore: 4,
item9Flagged: false,
at: new Date("2026-07-20T08:00:00Z"),
},
],
});
const who = computeWellbeingPillar({
...live,
asOf: NOW,
assessments: [
{
instrument: "WHO5",
totalScore: 52,
item9Flagged: false,
at: new Date("2026-07-20T08:00:00Z"),
},
],
});
expect(phq.status).toBe("ok");
expect(who.status).toBe("ok");
if (phq.status === "ok") expect(phq.value.score).toBe(85);
if (who.status === "ok") expect(who.value.score).toBe(52);
});
it("admits fitness only when the source proves a measured test", () => {
const unverified = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "APPLE_HEALTH",
measured: false,
},
],
});
expect(unverified.status).toBe("insufficient");
const measured = computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [
{
value: 42,
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
measured: true,
},
],
});
expect(measured.status).toBe("ok");
if (measured.status === "ok") {
expect(measured.value.reference.source).toBe("FRIEND (Kaminsky 2015)");
}
});
it("requires a fresh lipid panel with lab-reported reference bounds", () => {
const result = computeLipidsPillar({
...live,
asOf: NOW,
rows: [
{
marker: "LDL",
value: 120,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 130,
panel: "Lipids",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
{
marker: "HDL",
value: 50,
unit: "mg/dL",
referenceLow: 40,
referenceHigh: null,
panel: "Lipids",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.score).toBe(100);
expect(result.value.observed.label).toContain("LDL 120 mg/dL");
}
});
it("accepts one fresh lipid panel result with a reported range", () => {
const result = computeLipidsPillar({
...live,
asOf: NOW,
rows: [
{
marker: "LDL",
value: 120,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 130,
panel: "Lipids",
at: new Date("2026-07-20T08:00:00Z"),
source: "MANUAL",
},
],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.coverage.requiredInputs).toBe(1);
expect(result.value.observed.value).toBe(1);
}
});
it("keeps stale data absent for every sparse pillar", () => {
const staleAt = new Date("2025-01-01T08:00:00Z");
expect(
computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [{ value: 5.3, unit: "%", at: staleAt, source: "MANUAL" }],
fastingGlucose: [],
}).status,
).toBe("insufficient");
expect(
computeFitnessPillar({
...live,
asOf: NOW,
ageYears: 40,
sex: "MALE",
rows: [{ value: 42, at: staleAt, source: "MANUAL", measured: true }],
}).status,
).toBe("insufficient");
expect(
computeLipidsPillar({
...live,
asOf: NOW,
rows: [
{
marker: "LDL",
value: 120,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 130,
panel: "Lipids",
at: staleAt,
source: "MANUAL",
},
],
}).status,
).toBe("insufficient");
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { readActivityDays, readFastingGlucoseMeasurements } from "../reader";
function dbWithRows(rows: unknown[]) {
return {
measurement: { findMany: vi.fn().mockResolvedValue(rows) },
} as unknown as PrismaClient;
}
function stepRow(
measuredAt: string,
value: number,
deviceType: "watch" | "phone" = "watch",
source: "APPLE_HEALTH" | "WITHINGS" = "APPLE_HEALTH",
) {
return {
id: `${measuredAt}-${deviceType}`,
type: "ACTIVITY_STEPS" as const,
value,
unit: "count",
source,
measuredAt: new Date(measuredAt),
deviceType,
glucoseContext: null,
sleepStage: null,
};
}
describe("score reader bounds and source parity", () => {
it("reads only fasting glucose across the 90-day window plus two comparisons", async () => {
const db = dbWithRows([]);
const now = new Date("2026-07-28T12:00:00.000Z");
await readFastingGlucoseMeasurements(db, "user-1", now);
expect(db.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
type: { in: ["BLOOD_GLUCOSE"] },
glucoseContext: "FASTING",
measuredAt: {
gte: new Date(now.getTime() - 104 * 86_400_000),
},
}),
}),
);
const where = vi.mocked(db.measurement.findMany).mock.calls[0]?.[0]?.where;
expect(where).not.toHaveProperty("source");
});
it("retains a HealthKit glucose row when the client supplied fasting provenance", async () => {
const appleFasting = {
type: "BLOOD_GLUCOSE",
value: 96,
unit: "mg/dL",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-07-27T07:00:00.000Z"),
deviceType: "watch",
glucoseContext: "FASTING",
sleepStage: null,
};
const rows = await readFastingGlucoseMeasurements(
dbWithRows([appleFasting]),
"user-1",
new Date("2026-07-28T12:00:00.000Z"),
);
expect(rows).toEqual([appleFasting]);
});
it("aggregates activity by the user's local day rather than a UTC rollup day", async () => {
const db = dbWithRows([
stepRow("2026-07-01T22:30:00.000Z", 1_000),
stepRow("2026-07-02T21:30:00.000Z", 2_000),
]);
const result = await readActivityDays({
db,
userId: "user-1",
since: new Date("2026-07-01T00:00:00.000Z"),
priority: null,
timezone: "Europe/Berlin",
asOf: new Date("2026-07-03T12:00:00.000Z"),
});
expect(result.path).toBe("live");
expect(result.days).toEqual([{ day: "2026-07-02", value: 3_000 }]);
});
it("keeps the canonical device instead of summing Watch and phone steps", async () => {
const db = dbWithRows([
stepRow("2026-07-02T09:00:00.000Z", 1_000, "watch"),
stepRow("2026-07-02T09:00:00.000Z", 5_000, "phone"),
]);
const result = await readActivityDays({
db,
userId: "user-1",
since: new Date("2026-07-01T00:00:00.000Z"),
priority: null,
timezone: "Europe/Berlin",
asOf: new Date("2026-07-03T12:00:00.000Z"),
});
expect(result.days).toEqual([{ day: "2026-07-02", value: 1_000 }]);
});
it("excludes source writers outside the current 28-day score window", async () => {
const db = dbWithRows([
stepRow("2026-06-15T09:00:00.000Z", 8_000, "watch", "APPLE_HEALTH"),
stepRow("2026-05-31T09:00:00.000Z", 8_000, "watch", "WITHINGS"),
]);
const result = await readActivityDays({
db,
userId: "user-1",
since: new Date("2026-05-20T00:00:00.000Z"),
asOf: new Date("2026-07-01T12:00:00.000Z"),
priority: null,
timezone: "Europe/Berlin",
});
expect(result.sources).toEqual(["APPLE_HEALTH"]);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { computeWeightGoal } from "../weight-goal";
const AS_OF = new Date("2026-07-28T12:00:00.000Z");
const target = { min: 74, max: 78 };
describe("personal weight-goal window", () => {
it("ignores stale and future rows instead of presenting them as current", () => {
const result = computeWeightGoal({
rows: [
{
value: 80,
at: new Date("2026-05-01T12:00:00.000Z"),
source: "MANUAL",
},
{
value: 76,
at: new Date("2026-07-29T12:00:00.000Z"),
source: "MANUAL",
},
],
target,
source: "live",
asOf: AS_OF,
});
expect(result.status).toBe("insufficient");
expect(result.coverage.presentInputs).toBe(1);
if (result.status === "insufficient") {
expect(result.reason).toBe("weight_not_tracked");
}
});
it("uses only in-window rows for the current value and comparison", () => {
const result = computeWeightGoal({
rows: [
{
value: 90,
at: new Date("2026-05-01T12:00:00.000Z"),
source: "MANUAL",
},
{
value: 84,
at: new Date("2026-07-10T12:00:00.000Z"),
source: "MANUAL",
},
{
value: 81,
at: new Date("2026-07-27T12:00:00.000Z"),
source: "MANUAL",
},
{
value: 70,
at: new Date("2026-07-29T12:00:00.000Z"),
source: "MANUAL",
},
],
target,
source: "live",
asOf: AS_OF,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.currentKg).toBe(81);
expect(result.value.distanceKg).toBe(3);
expect(result.value.deltaKg).toBe(3);
expect(result.provenance.windowDays).toBe(60);
}
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { Derived } from "@/lib/insights/derived/types";
import { userDayKey } from "@/lib/tz/format";
import type { ActivityPillarInput, PillarValue } from "./types";
import {
clampScore,
coverage,
failedPillar,
insufficientPillar,
mean,
okPillar,
standardDeviation,
} from "./shared";
export const ACTIVITY_WINDOW_DAYS = 28;
export const ACTIVITY_MIN_DAYS = 21;
export const OLDER_ADULT_AGE = 60;
export const YOUNGER_STEP_PLATEAU = 10_000;
export const OLDER_STEP_PLATEAU = 8_000;
export function computeActivityPillar(
input: ActivityPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "ACTIVITY",
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
});
}
const asOfDay = userDayKey(input.asOf, input.timezone);
const sinceDay = userDayKey(
new Date(input.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * 86_400_000),
input.timezone,
);
const points = input.days.filter(
(point) => point.day <= asOfDay && point.day >= sinceDay,
);
if (points.length < ACTIVITY_MIN_DAYS) {
return insufficientPillar({
id: "ACTIVITY",
source: input.source,
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
requiredInputs: ACTIVITY_MIN_DAYS,
presentInputs: points.length,
historyDays: points.length,
missing: [`${Math.max(0, ACTIVITY_MIN_DAYS - points.length)} days`],
reason:
input.days.length > 0 ? "below_day_floor_or_stale" : "not_tracked",
});
}
const values = points.map((point) => point.value);
const observed = mean(values);
const plateau =
input.ageYears != null && input.ageYears >= OLDER_ADULT_AGE
? OLDER_STEP_PLATEAU
: YOUNGER_STEP_PLATEAU;
const score = clampScore((observed / plateau) * 100);
const noiseFloor = Math.max(
1,
Math.round(((0.5 * standardDeviation(values)) / plateau) * 100),
);
const latestDay = [...points].sort((a, b) => b.day.localeCompare(a.day))[0];
return okPillar({
id: "ACTIVITY",
source: input.source,
asOf: input.asOf,
windowDays: ACTIVITY_WINDOW_DAYS,
coverage: coverage({
requiredInputs: ACTIVITY_MIN_DAYS,
presentInputs: ACTIVITY_MIN_DAYS,
historyDays: points.length,
}),
value: {
score,
observed: {
value: Math.round(observed),
unit: "steps/day",
label: `${Math.round(observed).toLocaleString("en-US")} steps/day`,
asOf: `${latestDay.day}T00:00:00.000Z`,
sources: input.sources,
},
reference: {
kind: "guideline-band",
low: 0,
high: plateau,
label: `benefit plateaus by ${plateau.toLocaleString("en-US")} steps/day`,
source: "Paluch 2022",
},
noiseFloor,
deltaEligible: true,
deltaIdentity: "ACTIVITY_STEPS",
},
});
}
+100
View File
@@ -0,0 +1,100 @@
import type { Derived } from "@/lib/insights/derived/types";
import type { AdiposityPillarInput, PillarValue } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
okPillar,
scoreReferenceBand,
withinWindow,
} from "./shared";
export const ADIPOSITY_WINDOW_DAYS = 180;
export const WAIST_TO_HEIGHT_REFERENCE_HIGH = 0.5;
export function computeAdiposityPillar(
input: AdiposityPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "ADIPOSITY",
asOf: input.asOf,
windowDays: ADIPOSITY_WINDOW_DAYS,
});
}
const rows = input.rows
.filter((row) => withinWindow(row.at, input.asOf, ADIPOSITY_WINDOW_DAYS))
.sort((a, b) => b.at.getTime() - a.at.getTime());
const candidate = rows
.map((row) => {
if (row.type === "WAIST_TO_HEIGHT") return { row, ratio: row.value };
if (input.heightCm && input.heightCm > 0) {
return { row, ratio: row.value / input.heightCm };
}
return null;
})
.find((entry) => entry !== null);
const row = candidate?.row ?? null;
const ratio = candidate?.ratio ?? null;
const direct = row?.type === "WAIST_TO_HEIGHT";
const freshWaist = rows.some((entry) => entry.type === "WAIST_CIRCUMFERENCE");
if (ratio == null || row == null) {
return insufficientPillar({
id: "ADIPOSITY",
source: input.source,
asOf: input.asOf,
windowDays: ADIPOSITY_WINDOW_DAYS,
requiredInputs: freshWaist ? 2 : 1,
presentInputs: freshWaist ? 1 : 0,
historyDays: 0,
missing:
freshWaist && !input.heightCm ? ["height"] : ["waist measurement"],
reason:
freshWaist && !input.heightCm
? "missing_height"
: input.rows.length > 0
? "stale"
: "not_tracked",
});
}
const roundedRatio = Math.round(ratio * 1000) / 1000;
return okPillar({
id: "ADIPOSITY",
source: input.source,
asOf: input.asOf,
windowDays: ADIPOSITY_WINDOW_DAYS,
coverage: coverage({
requiredInputs: direct ? 1 : 2,
presentInputs: direct ? 1 : 2,
historyDays: 1,
}),
value: {
score: scoreReferenceBand(
roundedRatio,
0,
WAIST_TO_HEIGHT_REFERENCE_HIGH,
),
observed: {
value: roundedRatio,
unit: "ratio",
label: `waist-to-height ${roundedRatio.toFixed(3)}`,
asOf: row.at.toISOString(),
sources: [row.source],
},
reference: {
kind: "clinical-threshold",
low: 0,
high: WAIST_TO_HEIGHT_REFERENCE_HIGH,
label: `< ${WAIST_TO_HEIGHT_REFERENCE_HIGH}`,
source: "NICE 2022",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: "waist_to_height",
},
});
}
+116
View File
@@ -0,0 +1,116 @@
import type { Derived } from "@/lib/insights/derived/types";
import type { BloodPressurePillarInput, PillarValue } from "./types";
import {
coverage,
daysBetween,
failedPillar,
insufficientPillar,
okPillar,
withinWindow,
} from "./shared";
export const BLOOD_PRESSURE_WINDOW_DAYS = 90;
export const BLOOD_PRESSURE_MIN_PAIRS = 12;
export function computeBloodPressurePillar(
input: BloodPressurePillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "BLOOD_PRESSURE",
asOf: input.asOf,
windowDays: BLOOD_PRESSURE_WINDOW_DAYS,
});
}
if (!input.target) {
return insufficientPillar({
id: "BLOOD_PRESSURE",
source: input.source,
asOf: input.asOf,
windowDays: BLOOD_PRESSURE_WINDOW_DAYS,
requiredInputs: BLOOD_PRESSURE_MIN_PAIRS,
presentInputs: 0,
historyDays: 0,
missing: ["date of birth"],
reason: "missing_reference_profile",
});
}
const latestFresh =
input.latestAt != null &&
withinWindow(input.latestAt, input.asOf, BLOOD_PRESSURE_WINDOW_DAYS);
const presentPairs = latestFresh ? input.pairCount : 0;
const historyDays = latestFresh
? daysBetween(input.oldestAt, input.latestAt)
: 0;
if (
presentPairs < BLOOD_PRESSURE_MIN_PAIRS ||
input.gradedScore == null ||
input.representative == null ||
input.latestAt == null
) {
return insufficientPillar({
id: "BLOOD_PRESSURE",
source: input.source,
asOf: input.asOf,
windowDays: BLOOD_PRESSURE_WINDOW_DAYS,
requiredInputs: BLOOD_PRESSURE_MIN_PAIRS,
presentInputs: Math.min(presentPairs, BLOOD_PRESSURE_MIN_PAIRS),
historyDays,
missing: [
`${Math.max(0, BLOOD_PRESSURE_MIN_PAIRS - presentPairs)} paired readings`,
],
reason: latestFresh ? "below_reading_floor" : "stale_or_untracked",
});
}
const representativeSys = Math.round(input.representative.sys);
const representativeDia = Math.round(input.representative.dia);
const target = input.target;
return okPillar({
id: "BLOOD_PRESSURE",
source: input.source,
asOf: input.asOf,
windowDays: BLOOD_PRESSURE_WINDOW_DAYS,
coverage: coverage({
requiredInputs: BLOOD_PRESSURE_MIN_PAIRS,
presentInputs: BLOOD_PRESSURE_MIN_PAIRS,
historyDays,
}),
value: {
score: input.gradedScore,
observed: {
value: representativeSys,
unit: "mmHg",
label: `${representativeSys}/${representativeDia} mmHg`,
asOf: input.latestAt.toISOString(),
sources: input.sources,
},
reference: {
kind: "clinical-threshold",
low: target.sysLow,
high: target.sysHigh,
label: `${target.sysLow}–${target.sysHigh}/${target.diaLow}–${target.diaHigh} mmHg`,
source: "ESH 2023",
},
personalReference: input.personalTarget
? {
kind: "guideline-band",
low: input.personalTarget.sysLow,
high: input.personalTarget.sysHigh,
label: `${input.personalTarget.sysLow}–${input.personalTarget.sysHigh}/${input.personalTarget.diaLow}–${input.personalTarget.diaHigh} mmHg`,
source: "personal target",
}
: undefined,
// The existing grade moves roughly four points over the first 1 mmHg
// above its ceiling. One score point is therefore the smallest honest
// integer movement; the pair-series aggregation handles reading noise.
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: "graded_bp_pair_series",
},
});
}
+280
View File
@@ -0,0 +1,280 @@
import {
buildInsufficient,
buildOk,
deriveCoverage,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import {
SCORE_PILLAR_IDS,
SCORE_VERSION,
type CompositeValue,
type HealthScoreReport,
type ScoreBand,
type ScorePillarId,
type ScorePillarResult,
} from "./types";
import { mean, provenance, scoreBand } from "./shared";
export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z";
export const SCORE_MIN_ELIGIBLE_DOMAINS = 3;
const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"SLEEP",
"ADIPOSITY",
"FITNESS",
"LIPIDS",
]);
const BAND_RANK: Record<ScoreBand, number> = {
green: 2,
yellow: 1,
red: 0,
};
export interface CompositeInput {
pillars: ScorePillarResult[];
availablePillars: ScorePillarId[];
asOf: Date;
}
function orderedUnique(ids: readonly ScorePillarId[]): ScorePillarId[] {
const present = new Set(ids);
return SCORE_PILLAR_IDS.filter((id) => present.has(id));
}
export function computeComposite(
input: CompositeInput,
): Derived<CompositeValue> {
const available = orderedUnique(input.availablePillars);
const byId = new Map(input.pillars.map((pillar) => [pillar.id, pillar]));
const eligible = available
.map((id) => byId.get(id))
.filter(
(pillar): pillar is ScorePillarResult => pillar?.result.status === "ok",
);
const composition = eligible.map((pillar) => pillar.id);
const eligibleDomains = new Set(eligible.map((pillar) => pillar.domain));
const hasPhysiological = composition.some((id) =>
PHYSIOLOGICAL_PILLARS.has(id),
);
const availableDomains = new Set(
input.pillars
.filter((pillar) => available.includes(pillar.id))
.map((pillar) => pillar.domain),
);
const missing = [...availableDomains].filter(
(domain) => !eligibleDomains.has(domain),
);
const historyDays = eligible.reduce(
(minimum, pillar) =>
pillar.result.status === "ok"
? Math.min(minimum, pillar.result.coverage.historyDays)
: minimum,
Number.POSITIVE_INFINITY,
);
const { coverage, confidence } = deriveCoverage({
requiredInputs: SCORE_MIN_ELIGIBLE_DOMAINS,
presentInputs: eligibleDomains.size,
historyDays: Number.isFinite(historyDays) ? historyDays : 0,
missing,
fullHistoryDays: 28,
});
const compositeProvenance = provenance({
inputs: composition,
source:
eligible.length > 0 &&
eligible.every(
(pillar) =>
pillar.result.provenance.source !== "live" &&
pillar.result.provenance.source !== "none",
)
? "DAY"
: eligible.length > 0
? "live"
: "none",
windowDays: Math.max(
0,
...eligible.map((pillar) => pillar.result.provenance.windowDays),
),
asOf: input.asOf,
});
if (eligibleDomains.size < SCORE_MIN_ELIGIBLE_DOMAINS || !hasPhysiological) {
return buildInsufficient({
coverage,
provenance: compositeProvenance,
reason: !hasPhysiological
? "measured_physiological_domain_required"
: "three_domains_required",
});
}
const values = eligible.map((pillar) => {
if (pillar.result.status !== "ok") {
throw new Error("Eligible score pillar lost its narrowed state");
}
return pillar.result.value;
});
const score = Math.round(mean(values.map((value) => value.score)));
const meanBand = scoreBand(score);
const bandSetterIndex = values.reduce(
(worstIndex, value, index) =>
BAND_RANK[scoreBand(value.score)] <
BAND_RANK[scoreBand(values[worstIndex].score)]
? index
: worstIndex,
0,
);
const bandSetterValue = values[bandSetterIndex];
const worstBand = scoreBand(bandSetterValue.score);
const band =
BAND_RANK[worstBand] < BAND_RANK[meanBand] ? worstBand : meanBand;
const deltaValues = values.filter((value) => value.deltaEligible);
const noiseFloor =
deltaValues.length === 0
? 0
: Math.ceil(
Math.sqrt(
deltaValues.reduce((sum, value) => sum + value.noiseFloor ** 2, 0),
) / deltaValues.length,
);
return buildOk({
value: {
score,
band,
bandSetter:
BAND_RANK[worstBand] < BAND_RANK[meanBand]
? composition[bandSetterIndex]
: null,
composition,
noiseFloor,
scoreVersion: SCORE_VERSION,
},
coverage,
confidence,
provenance: compositeProvenance,
});
}
function sameComposition(
left: Derived<CompositeValue>,
right: Derived<CompositeValue>,
): boolean {
return (
left.status === "ok" &&
right.status === "ok" &&
left.value.composition.length === right.value.composition.length &&
left.value.composition.every(
(pillar, index) => pillar === right.value.composition[index],
)
);
}
function comparableDynamicPillars(
report: Derived<CompositeValue>,
other: Derived<CompositeValue>,
pillars: ScorePillarResult[],
otherPillars: ScorePillarResult[],
): {
current: number;
previous: number;
noiseFloor: number;
} | null {
if (report.status !== "ok" || other.status !== "ok") return null;
const otherById = new Map(otherPillars.map((pillar) => [pillar.id, pillar]));
const pairs = pillars.flatMap((pillar) => {
const previous = otherById.get(pillar.id);
if (
!report.value.composition.includes(pillar.id) ||
!other.value.composition.includes(pillar.id) ||
pillar.result.status !== "ok" ||
previous?.result.status !== "ok" ||
!pillar.result.value.deltaEligible ||
!previous.result.value.deltaEligible ||
pillar.result.value.deltaIdentity !== previous.result.value.deltaIdentity
) {
return [];
}
return [
{
current: pillar.result.value.score,
previous: previous.result.value.score,
noiseFloor: Math.max(
pillar.result.value.noiseFloor,
previous.result.value.noiseFloor,
),
},
];
});
if (pairs.length === 0) return null;
return {
current: mean(pairs.map((pair) => pair.current)),
previous: mean(pairs.map((pair) => pair.previous)),
noiseFloor: Math.ceil(
Math.sqrt(pairs.reduce((sum, pair) => sum + pair.noiseFloor ** 2, 0)) /
pairs.length,
),
};
}
export function attachScoreDelta(
current: Derived<CompositeValue>,
previous: Derived<CompositeValue>,
previousPrevious: Derived<CompositeValue>,
asOf: Date,
currentPillars: ScorePillarResult[] = [],
previousPillars: ScorePillarResult[] = [],
): Pick<HealthScoreReport, "delta" | "deltaReason"> {
if (current.status !== "ok") {
return { delta: null, deltaReason: "no_current_score" };
}
if (previous.status !== "ok") {
return { delta: null, deltaReason: "no_previous_window" };
}
const previousAt = new Date(previous.provenance.computedAt);
const boundary = new Date(SCORE_ALGORITHM_CHANGED_AT);
if (
current.value.scoreVersion !== previous.value.scoreVersion ||
(asOf >= boundary && previousAt < boundary)
) {
return { delta: null, deltaReason: "algorithm_changed" };
}
if (!sameComposition(current, previous)) {
return { delta: null, deltaReason: "composition_changed" };
}
if (
previousPrevious.status !== "ok" ||
!sameComposition(previous, previousPrevious)
) {
return { delta: null, deltaReason: "first_eligibility_window" };
}
const comparable =
currentPillars.length > 0 || previousPillars.length > 0
? comparableDynamicPillars(
current,
previous,
currentPillars,
previousPillars,
)
: null;
if (
(currentPillars.length > 0 || previousPillars.length > 0) &&
!comparable
) {
return { delta: null, deltaReason: "composition_changed" };
}
const delta = Math.round(
(comparable?.current ?? current.value.score) -
(comparable?.previous ?? previous.value.score),
);
if (Math.abs(delta) < (comparable?.noiseFloor ?? current.value.noiseFloor)) {
return { delta: null, deltaReason: "below_noise_floor" };
}
return { delta, deltaReason: null };
}
+76
View File
@@ -0,0 +1,76 @@
import type { Derived } from "@/lib/insights/derived/types";
import { prisma } from "@/lib/db";
import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveModuleMap } from "@/lib/modules/gate";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { computeUserHealthScore } from "./reader";
import type { CompositeValue } from "./types";
const DAY_MS = 86_400_000;
/** Load the same full Health Score composite served by the analytics surfaces. */
export async function computeHealthScoreDerived(
userId: string,
): Promise<Derived<CompositeValue>> {
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
timezone: true,
thresholdsJson: true,
},
});
const now = new Date();
const timezone = user.timezone ?? DEFAULT_TIMEZONE;
const bpTargets = getBpTargets(user.dateOfBirth);
const bpAt = (at: Date) =>
bpTargets
? computeBpInTargetFastPath({
userId,
targets: bpTargets,
now: at,
userTz: timezone,
})
: Promise.resolve(null);
const [
modules,
sourcePriorityJson,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
] = await Promise.all([
resolveModuleMap(userId),
loadUserSourcePriority(userId),
bpAt(now),
bpAt(new Date(now.getTime() - 7 * DAY_MS)),
bpAt(new Date(now.getTime() - 14 * DAY_MS)),
]);
const report = await computeUserHealthScore({
userId,
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone,
sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
},
modules: {
glucose: modules.glucose !== false,
labs: modules.labs !== false,
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
},
bpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
});
return report.composite;
}
+94
View File
@@ -0,0 +1,94 @@
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import type { Derived } from "@/lib/insights/derived/types";
import type { FitnessPillarInput, PillarValue } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
okPillar,
scoreReferenceBand,
withinWindow,
} from "./shared";
export const FITNESS_WINDOW_DAYS = 365;
export function computeFitnessPillar(
input: FitnessPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "FITNESS",
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
});
}
const norm = lookupNormalRange("VO2_MAX", input.ageYears, input.sex);
if (!norm) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["age and binary reference sex"],
reason: "missing_reference_profile",
});
}
const measured = input.rows
.filter(
(row) =>
row.measured && withinWindow(row.at, input.asOf, FITNESS_WINDOW_DAYS),
)
.sort((a, b) => b.at.getTime() - a.at.getTime());
const latest = measured[0];
if (!latest) {
return insufficientPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["measured VO₂max test"],
reason: input.rows.length > 0 ? "unverified_or_stale" : "not_tracked",
});
}
return okPillar({
id: "FITNESS",
source: input.source,
asOf: input.asOf,
windowDays: FITNESS_WINDOW_DAYS,
coverage: coverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
}),
value: {
score: scoreReferenceBand(latest.value, norm.low, null),
observed: {
value: latest.value,
unit: "mL/(kg·min)",
label: `${latest.value} mL/(kg·min) measured`,
asOf: latest.at.toISOString(),
sources: [latest.source],
},
reference: {
kind: "population-percentile",
low: norm.low,
high: norm.high,
label: `${norm.low}–${norm.high} mL/(kg·min) age/sex band`,
source: "FRIEND (Kaminsky 2015)",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: "measured_vo2max",
},
});
}
+175
View File
@@ -0,0 +1,175 @@
import type { Derived } from "@/lib/insights/derived/types";
import type { GlycaemiaPillarInput, PillarValue } from "./types";
import {
coverage,
daysBetween,
failedPillar,
insufficientPillar,
median,
okPillar,
scoreReferenceBand,
standardDeviation,
uniqueSources,
withinWindow,
} from "./shared";
export const HBA1C_WINDOW_DAYS = 180;
export const FASTING_GLUCOSE_WINDOW_DAYS = 90;
export const FASTING_GLUCOSE_MIN_READINGS = 8;
export const HBA1C_REFERENCE = { low: 5, high: 5.6 } as const;
export const FASTING_GLUCOSE_REFERENCE = { low: 70, high: 99 } as const;
export function computeGlycaemiaPillar(
input: GlycaemiaPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "GLYCAEMIA",
asOf: input.asOf,
windowDays: HBA1C_WINDOW_DAYS,
});
}
const freshHba1c = input.hba1c
.filter(
(row) =>
row.unit.trim() === "%" &&
withinWindow(row.at, input.asOf, HBA1C_WINDOW_DAYS),
)
.sort((a, b) => b.at.getTime() - a.at.getTime());
const hba1c = freshHba1c[0];
if (hba1c) {
return okPillar({
id: "GLYCAEMIA",
source: input.source,
asOf: input.asOf,
windowDays: HBA1C_WINDOW_DAYS,
coverage: coverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
}),
value: {
score: scoreReferenceBand(
hba1c.value,
HBA1C_REFERENCE.low,
HBA1C_REFERENCE.high,
),
observed: {
value: hba1c.value,
unit: "%",
label: `HbA1c ${hba1c.value}%`,
asOf: hba1c.at.toISOString(),
sources: [hba1c.source],
},
reference: {
kind: "clinical-threshold",
low: HBA1C_REFERENCE.low,
high: HBA1C_REFERENCE.high,
label: `${HBA1C_REFERENCE.low}–${HBA1C_REFERENCE.high}%`,
source: "ADA 2025; Selvin 2010",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: "hba1c",
},
});
}
if (input.hba1cReadFailed) {
return failedPillar({
id: "GLYCAEMIA",
asOf: input.asOf,
windowDays: HBA1C_WINDOW_DAYS,
});
}
const fasting = input.fastingGlucose
.filter((row) =>
withinWindow(row.at, input.asOf, FASTING_GLUCOSE_WINDOW_DAYS),
)
.sort((a, b) => a.at.getTime() - b.at.getTime());
const historyDays = daysBetween(
fasting[0]?.at ?? null,
fasting.at(-1)?.at ?? null,
);
if (input.fastingReadFailed) {
return failedPillar({
id: "GLYCAEMIA",
asOf: input.asOf,
windowDays: FASTING_GLUCOSE_WINDOW_DAYS,
});
}
if (fasting.length < FASTING_GLUCOSE_MIN_READINGS) {
return insufficientPillar({
id: "GLYCAEMIA",
source: input.source,
asOf: input.asOf,
windowDays: FASTING_GLUCOSE_WINDOW_DAYS,
requiredInputs: FASTING_GLUCOSE_MIN_READINGS,
presentInputs: fasting.length,
historyDays,
missing: [
`${Math.max(0, FASTING_GLUCOSE_MIN_READINGS - fasting.length)} fasting readings`,
],
reason:
input.hba1c.length > 0 || input.fastingGlucose.length > 0
? "below_reading_floor_or_stale"
: "not_tracked",
});
}
const values = fasting.map((row) => row.value);
const observed = median(values);
// The behavioural/device noise convention from the design is one half of
// within-person SD, translated onto this pillar's proportional score scale.
const halfSd = standardDeviation(values) * 0.5;
const floorScore = scoreReferenceBand(
Math.max(0, observed - halfSd),
FASTING_GLUCOSE_REFERENCE.low,
FASTING_GLUCOSE_REFERENCE.high,
);
const ceilingScore = scoreReferenceBand(
observed + halfSd,
FASTING_GLUCOSE_REFERENCE.low,
FASTING_GLUCOSE_REFERENCE.high,
);
return okPillar({
id: "GLYCAEMIA",
source: input.source,
asOf: input.asOf,
windowDays: FASTING_GLUCOSE_WINDOW_DAYS,
coverage: coverage({
requiredInputs: FASTING_GLUCOSE_MIN_READINGS,
presentInputs: FASTING_GLUCOSE_MIN_READINGS,
historyDays,
}),
value: {
score: scoreReferenceBand(
observed,
FASTING_GLUCOSE_REFERENCE.low,
FASTING_GLUCOSE_REFERENCE.high,
),
observed: {
value: observed,
unit: "mg/dL",
label: `Fasting median ${Math.round(observed)} mg/dL`,
asOf: fasting.at(-1)!.at.toISOString(),
sources: uniqueSources(fasting.map((row) => row.source)),
},
reference: {
kind: "clinical-threshold",
low: FASTING_GLUCOSE_REFERENCE.low,
high: FASTING_GLUCOSE_REFERENCE.high,
label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`,
source: "ADA 2025; Selvin 2010",
},
noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)),
deltaEligible: true,
deltaIdentity: "fasting_glucose",
},
});
}
+97
View File
@@ -0,0 +1,97 @@
import type { Derived } from "@/lib/insights/derived/types";
import { computeActivityPillar } from "./activity";
import { computeAdiposityPillar } from "./adiposity";
import { computeBloodPressurePillar } from "./blood-pressure";
import { computeComposite } from "./composite";
import { computeFitnessPillar } from "./fitness";
import { computeGlycaemiaPillar } from "./glycaemia";
import { computeLipidsPillar } from "./lipids";
import { computeSleepPillar } from "./sleep";
import {
SCORE_PILLAR_IDS,
SCORE_VERSION,
type CompositeValue,
type HealthScoreReport,
type PillarInputs,
type ScorePillarId,
type ScorePillarResult,
type WeightGoalValue,
} from "./types";
import { computeWellbeingPillar } from "./wellbeing";
export interface ComputeHealthScoreInput {
asOf: Date;
availablePillars: ScorePillarId[];
pillars: PillarInputs;
weightGoal: Derived<WeightGoalValue>;
delta?: number | null;
deltaReason?: HealthScoreReport["deltaReason"];
}
export function computeHealthScore(
input: ComputeHealthScoreInput,
): HealthScoreReport {
const byId: Record<ScorePillarId, ScorePillarResult> = {
BLOOD_PRESSURE: {
id: "BLOOD_PRESSURE",
domain: "cardiometabolic",
result: computeBloodPressurePillar(input.pillars.BLOOD_PRESSURE),
},
GLYCAEMIA: {
id: "GLYCAEMIA",
domain: "cardiometabolic",
result: computeGlycaemiaPillar(input.pillars.GLYCAEMIA),
},
ACTIVITY: {
id: "ACTIVITY",
domain: "activity",
result: computeActivityPillar(input.pillars.ACTIVITY),
},
SLEEP: {
id: "SLEEP",
domain: "sleep",
result: computeSleepPillar(input.pillars.SLEEP),
},
ADIPOSITY: {
id: "ADIPOSITY",
domain: "adiposity",
result: computeAdiposityPillar(input.pillars.ADIPOSITY),
},
WELLBEING: {
id: "WELLBEING",
domain: "wellbeing",
result: computeWellbeingPillar(input.pillars.WELLBEING),
},
FITNESS: {
id: "FITNESS",
domain: "fitness",
result: computeFitnessPillar(input.pillars.FITNESS),
},
LIPIDS: {
id: "LIPIDS",
domain: "cardiometabolic",
result: computeLipidsPillar(input.pillars.LIPIDS),
},
};
const available = new Set(input.availablePillars);
const pillars = SCORE_PILLAR_IDS.filter((id) => available.has(id)).map(
(id) => byId[id],
);
return {
composite: computeComposite({
pillars,
availablePillars: input.availablePillars,
asOf: input.asOf,
}),
pillars,
delta: input.delta ?? null,
deltaReason: input.deltaReason ?? "no_previous_window",
scoreVersion: SCORE_VERSION,
weightGoal: input.weightGoal,
algorithmNotice: null,
};
}
export type { CompositeValue, HealthScoreReport };
export * from "./types";
+113
View File
@@ -0,0 +1,113 @@
import type { Derived } from "@/lib/insights/derived/types";
import type { LipidReading, LipidsPillarInput, PillarValue } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
mean,
okPillar,
scoreReferenceBand,
uniqueSources,
withinWindow,
} from "./shared";
export const LIPIDS_WINDOW_DAYS = 365;
export const LIPIDS_MIN_PANELS = 1;
function panelKey(row: LipidReading): string {
return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`;
}
export function computeLipidsPillar(
input: LipidsPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "LIPIDS",
asOf: input.asOf,
windowDays: LIPIDS_WINDOW_DAYS,
});
}
const fresh = input.rows
.filter(
(row) =>
(row.referenceLow != null || row.referenceHigh != null) &&
withinWindow(row.at, input.asOf, LIPIDS_WINDOW_DAYS),
)
.sort((a, b) => b.at.getTime() - a.at.getTime());
const latestKey = fresh[0] ? panelKey(fresh[0]) : null;
const panel = latestKey
? fresh.filter((row) => panelKey(row) === latestKey)
: [];
if (panel.length === 0) {
return insufficientPillar({
id: "LIPIDS",
source: input.source,
asOf: input.asOf,
windowDays: LIPIDS_WINDOW_DAYS,
requiredInputs: LIPIDS_MIN_PANELS,
presentInputs: 0,
historyDays: 0,
missing: ["lipid panel with lab reference ranges"],
reason:
input.rows.length > 0 ? "incomplete_or_stale_panel" : "not_tracked",
});
}
const markerScores = panel.map((row) =>
scoreReferenceBand(row.value, row.referenceLow, row.referenceHigh),
);
const latestAt = panel.reduce(
(latest, row) => (row.at > latest ? row.at : latest),
panel[0].at,
);
const observedLabel = panel
.map((row) => `${row.marker} ${row.value} ${row.unit}`)
.join("; ");
const referenceLabel = panel
.map((row) => {
const bounds =
row.referenceLow != null && row.referenceHigh != null
? `${row.referenceLow}–${row.referenceHigh}`
: row.referenceHigh != null
? `≤ ${row.referenceHigh}`
: `≥ ${row.referenceLow}`;
return `${row.marker} ${bounds} ${row.unit}`;
})
.join("; ");
return okPillar({
id: "LIPIDS",
source: input.source,
asOf: input.asOf,
windowDays: LIPIDS_WINDOW_DAYS,
coverage: coverage({
requiredInputs: LIPIDS_MIN_PANELS,
presentInputs: LIPIDS_MIN_PANELS,
historyDays: 1,
}),
value: {
score: mean(markerScores),
observed: {
value: panel.length,
unit: panel.length === 1 ? "result" : "results",
label: observedLabel,
asOf: latestAt.toISOString(),
sources: uniqueSources(panel.map((row) => row.source)),
},
reference: {
kind: "clinical-threshold",
low: null,
high: null,
label: referenceLabel,
source: "reporting laboratory reference ranges",
},
noiseFloor: 0,
deltaEligible: false,
deltaIdentity: `panel:${latestKey}`,
},
});
}
+857
View File
@@ -0,0 +1,857 @@
import type {
GlucoseContext,
MeasurementSource,
MeasurementType,
PrismaClient,
SleepStage,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { BpInTargetEnvelope } from "@/lib/analytics/bp-in-target-fast-path";
import { collectBpPairs } from "@/lib/analytics/bp-in-target";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
} from "@/lib/analytics/bp-grade";
import type { BpTargets } from "@/lib/analytics/bp-targets";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { reconstructNights } from "@/lib/insights/derived/sleep-score";
import { pickCumulativeDaySum } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/format";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key";
import { annotate } from "@/lib/logging/context";
import { ACTIVITY_WINDOW_DAYS } from "./activity";
import { SLEEP_WINDOW_DAYS } from "./sleep";
import { attachScoreDelta } from "./composite";
import { computeHealthScore } from "./index";
import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types";
import { computeWeightGoal } from "./weight-goal";
import { DAY_MS, uniqueSources } from "./shared";
import type { DerivedProvenanceSource } from "@/lib/insights/derived/types";
const HISTORY_OFFSET_DAYS = 14;
const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
export interface ScoreReaderProfile {
dateOfBirth: Date | null;
gender: string | null;
heightCm: number | null;
timezone: string;
sourcePriorityJson: unknown;
thresholdsJson: unknown;
}
export interface ScoreReaderModules {
glucose: boolean;
labs: boolean;
sleep: boolean;
mentalHealth: boolean;
}
export interface UserHealthScoreInput {
prisma?: PrismaClient;
userId: string;
now: Date;
profile: ScoreReaderProfile;
modules: ScoreReaderModules;
bpTargets: BpTargets | null;
bpEnvelope: BpInTargetEnvelope | null;
bpEnvelopePriorWeek: BpInTargetEnvelope | null;
bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null;
}
type MeasurementRow = {
type: MeasurementType;
value: number;
unit: string;
source: MeasurementSource;
measuredAt: Date;
deviceType: string | null;
glucoseContext?: GlucoseContext | null;
sleepStage?: SleepStage | null;
};
type Settled<T> = { ok: true; value: T } | { ok: false; value: T };
interface ActivityRead {
days: Array<{ day: string; value: number }>;
sources: string[];
sourceDays: Array<{ day: string; source: string }>;
path: "rollup" | "live";
}
interface ScoreBpEnvelope {
path: "rollup" | "live" | undefined;
last90Days: { pairs: number } | null;
gradedScore: number | null;
representative: { sys: number; dia: number } | null;
last90EarliestAt: Date | null;
last90LatestAt: Date | null;
readFailed: boolean;
target: BpTargets | null;
}
async function capture<T>(
promise: Promise<T>,
fallback: T,
failure?:
ScorePillarId | "WEIGHT_GOAL" | "ALGORITHM_NOTICE" | ScorePillarId[],
): Promise<Settled<T>> {
try {
return { ok: true, value: await promise };
} catch {
const failures = Array.isArray(failure)
? failure
: failure
? [failure]
: [];
if (failures.length > 0) {
annotate({
meta: {
analytics: {
health_score: Object.fromEntries(
failures.map((failed) => [failed, "read_failed"]),
),
},
},
});
}
return { ok: false, value: fallback };
}
}
async function readMeasurements(
db: PrismaClient,
userId: string,
types: MeasurementType[],
since: Date,
glucoseContext?: GlucoseContext,
): Promise<MeasurementRow[]> {
return db.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: since },
deletedAt: null,
...(glucoseContext ? { glucoseContext } : {}),
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: {
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
deviceType: true,
glucoseContext: true,
sleepStage: true,
},
});
}
export function readFastingGlucoseMeasurements(
db: PrismaClient,
userId: string,
now: Date,
): Promise<MeasurementRow[]> {
return readMeasurements(
db,
userId,
["BLOOD_GLUCOSE"],
new Date(now.getTime() - (90 + HISTORY_OFFSET_DAYS) * DAY_MS),
"FASTING",
);
}
export async function readActivityDays(args: {
db: PrismaClient;
userId: string;
since: Date;
priority: unknown;
timezone: string;
asOf: Date;
}): Promise<ActivityRead> {
// DAY rollups are UTC buckets and have already summed all devices for a
// source. Neither the user's local-day boundary nor Watch/phone
// canonicalisation can be recovered from that aggregate, so score parity
// requires the raw canonical path for every timezone.
const rows = await readMeasurements(
args.db,
args.userId,
["ACTIVITY_STEPS"],
args.since,
);
const canonical = pickCanonicalSourceRows(
rows,
"steps",
args.priority,
(date) => userDayKey(date, args.timezone),
).canonicalRows;
return {
days: pickCumulativeDaySum(canonical, (date) =>
userDayKey(date, args.timezone),
).map((point) => ({
day: userDayKey(point.date, args.timezone),
value: point.value,
})),
sources: uniqueSources(
canonical
.filter((row) => {
const day = userDayKey(row.measuredAt, args.timezone);
const endDay = userDayKey(args.asOf, args.timezone);
const startDay = userDayKey(
new Date(args.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
args.timezone,
);
return day >= startDay && day <= endDay;
})
.map((row) => row.source),
),
sourceDays: canonical.map((row) => ({
day: userDayKey(row.measuredAt, args.timezone),
source: row.source,
})),
path: "live",
};
}
function normaliseAnalyte(value: string): string {
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
}
const HBA1C_NAMES: Record<string, true> = {
hba1c: true,
hemoglobina1c: true,
haemoglobina1c: true,
glycatedhemoglobin: true,
glycatedhaemoglobin: true,
};
const LIPID_NAMES: Record<string, true> = {
ldl: true,
ldlcholesterol: true,
ldlc: true,
hdl: true,
hdlcholesterol: true,
hdlc: true,
triglycerides: true,
triglyceride: true,
totalcholesterol: true,
cholesteroltotal: true,
apob: true,
apolipoproteinb: true,
};
function pickStableSourceRows<T extends { source: MeasurementSource }>(
rows: T[],
key: (row: T) => string,
): T[] {
return Array.from(Map.groupBy(rows, key).values()).flatMap((group) => {
const source = [...new Set(group.map((row) => row.source))].sort()[0];
return group.filter((row) => row.source === source);
});
}
function personalBpTarget(
thresholdsJson: unknown,
clinical: BpTargets | null,
): BpTargets | null {
if (!clinical || !thresholdsJson || typeof thresholdsJson !== "object") {
return null;
}
const thresholds = thresholdsJson as Record<string, unknown>;
const readBand = (key: string): { min: number; max: number } | null => {
const value = thresholds[key];
if (!value || typeof value !== "object") return null;
const { min, max } = value as { min?: unknown; max?: unknown };
return typeof min === "number" &&
Number.isFinite(min) &&
typeof max === "number" &&
Number.isFinite(max) &&
min < max
? { min, max }
: null;
};
const sys = readBand("BLOOD_PRESSURE_SYS");
const dia = readBand("BLOOD_PRESSURE_DIA");
if (!sys && !dia) return null;
return {
sysLow: sys?.min ?? clinical.sysLow,
sysHigh: sys?.max ?? clinical.sysHigh,
diaLow: dia?.min ?? clinical.diaLow,
diaHigh: dia?.max ?? clinical.diaHigh,
};
}
function canonicalBpRowsForWindow(
rows: MeasurementRow[],
input: UserHealthScoreInput,
at: Date,
): MeasurementRow[] {
const since = new Date(at.getTime() - 90 * DAY_MS);
return pickCanonicalSourceRows(
rows.filter((row) => row.measuredAt >= since && row.measuredAt <= at),
"bloodPressure",
input.profile.sourcePriorityJson,
(date) => userDayKey(date, input.profile.timezone),
).canonicalRows;
}
function scoreBpEnvelope(args: {
base: BpInTargetEnvelope | null;
at: Date;
input: UserHealthScoreInput;
rows: Settled<MeasurementRow[]>;
}): { envelope: ScoreBpEnvelope; sources: string[] } {
if (args.base) {
return {
envelope: {
path: args.base.path,
last90Days: args.base.last90Days,
gradedScore: args.base.gradedScore,
representative: args.base.representative,
last90EarliestAt: args.base.last90EarliestAt,
last90LatestAt: args.base.last90LatestAt,
readFailed: false,
target: args.input.bpTargets,
},
sources: args.rows.ok
? uniqueSources(
canonicalBpRowsForWindow(args.rows.value, args.input, args.at).map(
(row) => row.source,
),
)
: [args.base.path],
};
}
const target = args.input.bpTargets;
if (!args.rows.ok) {
return {
envelope: {
path: undefined,
last90Days: null,
gradedScore: null,
representative: null,
last90EarliestAt: null,
last90LatestAt: null,
readFailed: true,
target,
},
sources: [],
};
}
const canonical = canonicalBpRowsForWindow(
args.rows.value,
args.input,
args.at,
);
const pairs = collectBpPairs(
canonical
.filter((row) => row.type === "BLOOD_PRESSURE_SYS")
.map((row) => ({ measuredAt: row.measuredAt, value: row.value })),
canonical
.filter((row) => row.type === "BLOOD_PRESSURE_DIA")
.map((row) => ({ measuredAt: row.measuredAt, value: row.value })),
args.input.profile.timezone,
).filter(
(pair) =>
pair.at <= args.at &&
pair.at >= new Date(args.at.getTime() - 90 * DAY_MS),
);
return {
envelope: {
path: "live",
last90Days: { pairs: pairs.length },
gradedScore: target
? gradeBpScoreFromSeries({
pairs,
target,
now: args.at,
})
: null,
representative: representativeBpFromSeries({ pairs, now: args.at }),
last90EarliestAt: pairs[0]?.at ?? null,
last90LatestAt: pairs.at(-1)?.at ?? null,
readFailed: false,
target,
},
sources: uniqueSources(canonical.map((row) => row.source)),
};
}
function scoreInputsFor(args: {
asOf: Date;
input: UserHealthScoreInput;
bp: ScoreBpEnvelope;
bpSources: string[];
glucose: Settled<MeasurementRow[]>;
activity: Settled<ActivityRead>;
sleep: Settled<MeasurementRow[]>;
adiposity: Settled<MeasurementRow[]>;
fitness: Settled<MeasurementRow[]>;
assessments: Settled<
Array<{
instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI";
totalScore: number;
item9Flagged: boolean;
takenAt: Date;
}>
>;
labs: Settled<
Array<{
analyte: string;
value: number | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
takenAt: Date;
source: string;
biomarker: {
name: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
} | null;
}>
>;
}): PillarInputs {
const { asOf, input } = args;
const ageYears = getAgeFromDateOfBirth(input.profile.dateOfBirth);
const asOfDay = userDayKey(asOf, input.profile.timezone);
const activitySinceDay = userDayKey(
new Date(asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
);
const sleepSinceDay = userDayKey(
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
input.profile.timezone,
);
const sex = toProfileSex(input.profile.gender);
const fastingCanonical = pickStableSourceRows(
args.glucose.value.filter((row) => row.glucoseContext === "FASTING"),
(row) => userDayKey(row.measuredAt, input.profile.timezone),
);
const sleepNights = reconstructNights(
args.sleep.value.map((row) => ({
value: row.value,
measuredAt: row.measuredAt,
sleepStage: row.sleepStage ?? null,
source: row.source,
deviceType: row.deviceType,
})),
input.profile.timezone,
input.profile.sourcePriorityJson,
);
const hba1c = args.labs.value.filter((row) => {
const name = normaliseAnalyte(row.biomarker?.name ?? row.analyte);
return row.value != null && HBA1C_NAMES[name] === true;
});
const lipids = args.labs.value.filter((row) => {
const name = normaliseAnalyte(row.biomarker?.name ?? row.analyte);
return row.value != null && LIPID_NAMES[name] === true;
});
const adiposityCanonical = pickStableSourceRows(
args.adiposity.value,
(row) =>
`${row.type}|${userDayKey(row.measuredAt, input.profile.timezone)}`,
);
const fitnessCanonical = pickCanonicalSourceRows(
args.fitness.value,
"vo2Max",
input.profile.sourcePriorityJson,
(date) => userDayKey(date, input.profile.timezone),
).canonicalRows;
return {
BLOOD_PRESSURE: {
source: readSource(args.bp.path),
readFailed: args.bp.readFailed,
asOf,
pairCount: args.bp.last90Days?.pairs ?? 0,
gradedScore: args.bp.gradedScore,
representative: args.bp.representative,
oldestAt: args.bp.last90EarliestAt,
latestAt: args.bp.last90LatestAt,
target: args.bp.target,
personalTarget: personalBpTarget(
input.profile.thresholdsJson,
args.bp.target,
),
sources: args.bpSources,
},
GLYCAEMIA: {
source: "live",
readFailed:
(!input.modules.glucose || !args.glucose.ok) &&
(!input.modules.labs || !args.labs.ok),
asOf,
hba1cReadFailed: input.modules.labs && !args.labs.ok,
fastingReadFailed: input.modules.glucose && !args.glucose.ok,
hba1c: input.modules.labs
? hba1c.map((row) => ({
value: row.value!,
unit: row.biomarker?.unit ?? row.unit,
at: row.takenAt,
source: row.source,
}))
: [],
fastingGlucose: input.modules.glucose
? fastingCanonical.map((row) => ({
value: row.value,
at: row.measuredAt,
source: row.source,
}))
: [],
},
ACTIVITY: {
source: args.activity.ok ? readSource(args.activity.value.path) : "none",
readFailed: !args.activity.ok,
timezone: input.profile.timezone,
asOf,
ageYears,
days: args.activity.value.days,
sources: uniqueSources(
args.activity.value.sourceDays
.filter((row) => row.day >= activitySinceDay && row.day <= asOfDay)
.map((row) => row.source),
),
},
SLEEP: {
source: "live",
readFailed: !args.sleep.ok,
timezone: input.profile.timezone,
asOf,
ageYears,
nights: sleepNights.map((night) => ({
night: night.night,
asleepMinutes: night.asleepMinutes,
midpoint: night.midpoint,
})),
sources: uniqueSources(
args.sleep.value
.filter((row) => {
const day = userDayKey(row.measuredAt, input.profile.timezone);
return day >= sleepSinceDay && day <= asOfDay;
})
.map((row) => row.source),
),
},
ADIPOSITY: {
source: "live",
readFailed: !args.adiposity.ok,
asOf,
heightCm: input.profile.heightCm,
rows: adiposityCanonical.map((row) => ({
type: row.type as "WAIST_CIRCUMFERENCE" | "WAIST_TO_HEIGHT",
value: row.value,
unit: row.unit,
at: row.measuredAt,
source: row.source,
})),
},
WELLBEING: {
source: "live",
readFailed: !args.assessments.ok,
asOf,
assessments: args.assessments.value
.filter((row) => row.instrument !== "SCI")
.map((row) => ({
instrument: row.instrument as "PHQ9" | "GAD7" | "WHO5",
totalScore: row.totalScore,
item9Flagged: row.item9Flagged,
at: row.takenAt,
})),
},
FITNESS: {
source: "live",
readFailed: !args.fitness.ok,
asOf,
ageYears,
sex,
// The schema records VO₂max but does not distinguish CPET measurements
// from device estimates. No current row can therefore prove `measured`.
rows: fitnessCanonical.map((row) => ({
value: row.value,
at: row.measuredAt,
source: row.source,
measured: false,
})),
},
LIPIDS: {
source: "live",
readFailed: !args.labs.ok,
asOf,
rows: lipids.map((row) => ({
marker: row.biomarker?.name ?? row.analyte,
value: row.value!,
unit: row.unit || row.biomarker?.unit || "",
referenceLow: row.referenceLow ?? row.biomarker?.referenceLow ?? null,
referenceHigh:
row.referenceHigh ?? row.biomarker?.referenceHigh ?? null,
panel: row.panel,
at: row.takenAt,
source: row.source,
})),
},
};
}
function readSource(
path: "rollup" | "live" | undefined,
): DerivedProvenanceSource {
if (path === "rollup") return "DAY";
return path ?? "none";
}
export async function computeUserHealthScore(
input: UserHealthScoreInput,
): Promise<HealthScoreReport> {
const db = input.prisma ?? prisma;
const since = new Date(input.now.getTime() - SCORE_READ_DAYS * DAY_MS);
const activitySince = new Date(
input.now.getTime() - (28 + HISTORY_OFFSET_DAYS) * DAY_MS,
);
const [
glucose,
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
weights,
bpRows,
] = await Promise.all([
input.modules.glucose
? capture(
readFastingGlucoseMeasurements(db, input.userId, input.now),
[],
"GLYCAEMIA",
)
: Promise.resolve({ ok: true as const, value: [] }),
capture(
readActivityDays({
db,
userId: input.userId,
since: activitySince,
priority: input.profile.sourcePriorityJson,
asOf: input.now,
timezone: input.profile.timezone,
}),
{ days: [], sources: [], sourceDays: [], path: "live" as const },
"ACTIVITY",
),
input.modules.sleep
? capture(
readMeasurements(db, input.userId, ["SLEEP_DURATION"], activitySince),
[],
"SLEEP",
)
: Promise.resolve({ ok: true as const, value: [] }),
capture(
readMeasurements(
db,
input.userId,
["WAIST_CIRCUMFERENCE", "WAIST_TO_HEIGHT"],
since,
),
[],
"ADIPOSITY",
),
capture(
readMeasurements(db, input.userId, ["VO2_MAX"], since),
[],
"FITNESS",
),
input.modules.mentalHealth
? capture(
db.mentalHealthAssessment.findMany({
where: {
userId: input.userId,
takenAt: { gte: since },
deletedAt: null,
},
orderBy: { takenAt: "asc" },
select: {
instrument: true,
totalScore: true,
item9Flagged: true,
takenAt: true,
},
}),
[],
"WELLBEING",
)
: Promise.resolve({ ok: true as const, value: [] }),
input.modules.labs
? capture(
db.labResult.findMany({
where: {
userId: input.userId,
takenAt: { gte: since },
deletedAt: null,
value: { not: null },
},
orderBy: { takenAt: "asc" },
select: {
analyte: true,
value: true,
unit: true,
referenceLow: true,
referenceHigh: true,
panel: true,
takenAt: true,
source: true,
biomarker: {
select: {
name: true,
unit: true,
referenceLow: true,
referenceHigh: true,
},
},
},
}),
[],
["GLYCAEMIA", "LIPIDS"],
)
: Promise.resolve({ ok: true as const, value: [] }),
capture(
readMeasurements(db, input.userId, ["WEIGHT"], since),
[],
"WEIGHT_GOAL",
),
capture(
readMeasurements(
db,
input.userId,
["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
new Date(input.now.getTime() - 104 * DAY_MS),
),
[],
"BLOOD_PRESSURE",
),
]);
const availablePillars: ScorePillarId[] = [
"BLOOD_PRESSURE",
...(input.modules.glucose || input.modules.labs
? ["GLYCAEMIA" as const]
: []),
"ACTIVITY",
...(input.modules.sleep ? ["SLEEP" as const] : []),
"ADIPOSITY",
...(input.modules.mentalHealth ? ["WELLBEING" as const] : []),
"FITNESS",
...(input.modules.labs ? ["LIPIDS" as const] : []),
];
const previousAt = new Date(input.now.getTime() - 7 * DAY_MS);
const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS);
const currentBp = scoreBpEnvelope({
base: input.bpEnvelope,
at: input.now,
input,
rows: bpRows,
});
const previousBp = scoreBpEnvelope({
base: input.bpEnvelopePriorWeek,
at: previousAt,
input,
rows: bpRows,
});
const previousPreviousBp = scoreBpEnvelope({
base: input.bpEnvelopePriorTwoWeeks,
at: previousPreviousAt,
input,
rows: bpRows,
});
const common = {
input,
bpSources: currentBp.sources,
glucose,
activity,
sleep,
adiposity,
fitness,
assessments,
labs,
};
const canonicalWeights = pickCanonicalSourceRows(
weights.value,
"weight",
input.profile.sourcePriorityJson,
(date) => userDayKey(date, input.profile.timezone),
).canonicalRows;
const target = resolveWeightTargetOverride(input.profile.thresholdsJson);
const weightGoal = computeWeightGoal({
rows: canonicalWeights.map((row) => ({
value: row.value,
at: row.measuredAt,
source: row.source,
})),
target,
source: weights.ok ? "live" : "none",
readFailed: !weights.ok,
asOf: input.now,
});
const current = computeHealthScore({
asOf: input.now,
availablePillars,
pillars: scoreInputsFor({
...common,
asOf: input.now,
bp: currentBp.envelope,
}),
weightGoal,
});
const previous = computeHealthScore({
asOf: previousAt,
availablePillars,
pillars: scoreInputsFor({
...common,
asOf: previousAt,
bp: previousBp.envelope,
}),
weightGoal,
});
const previousPrevious = computeHealthScore({
asOf: previousPreviousAt,
availablePillars,
pillars: scoreInputsFor({
...common,
asOf: previousPreviousAt,
bp: previousPreviousBp.envelope,
}),
weightGoal,
});
const delta = attachScoreDelta(
current.composite,
previous.composite,
previousPrevious.composite,
input.now,
current.pillars,
previous.pillars,
);
const itemKey = healthScoreAlgorithmItemKey(current.scoreVersion);
const dismissal = await capture(
db.dismissedPriorityItem.findUnique({
where: { userId_itemKey: { userId: input.userId, itemKey } },
select: { itemKey: true },
}),
null,
"ALGORITHM_NOTICE",
);
return {
...current,
...delta,
algorithmNotice: {
itemKey,
dismissed: dismissal.value != null,
},
};
}
+197
View File
@@ -0,0 +1,197 @@
import {
buildInsufficient,
buildOk,
deriveCoverage,
} from "@/lib/insights/derived/coverage";
import type {
Derived,
DerivedCoverage,
DerivedProvenance,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
import type {
DomainReadState,
PillarValue,
ScoreBand,
ScorePillarId,
} from "./types";
export const DAY_MS = 86_400_000;
export function clampScore(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
export function scoreBand(score: number): ScoreBand {
if (score >= 75) return "green";
if (score >= 50) return "yellow";
return "red";
}
/**
* Score proximity to an inclusive published band without minting another
* clinical threshold. Values inside score 100; outside, the ratio to the
* nearest published edge declines smoothly and remains bounded.
*/
export function scoreReferenceBand(
value: number,
low: number | null,
high: number | null,
): number {
if (!Number.isFinite(value)) return 0;
if (low != null && value < low) {
return low > 0 ? clampScore((value / low) * 100) : 0;
}
if (high != null && value > high) {
return value > 0 ? clampScore((high / value) * 100) : 0;
}
return 100;
}
export function mean(values: readonly number[]): number {
return values.length === 0
? Number.NaN
: values.reduce((sum, value) => sum + value, 0) / values.length;
}
export function median(values: readonly number[]): number {
if (values.length === 0) return Number.NaN;
const ordered = [...values].sort((a, b) => a - b);
const middle = Math.floor(ordered.length / 2);
return ordered.length % 2 === 0
? (ordered[middle - 1] + ordered[middle]) / 2
: ordered[middle];
}
export function standardDeviation(values: readonly number[]): number {
if (values.length < 2) return 0;
const center = mean(values);
const variance = mean(values.map((value) => (value - center) ** 2));
return Math.sqrt(variance);
}
export function uniqueSources(values: readonly string[]): string[] {
return [...new Set(values.filter(Boolean))].sort();
}
export function daysBetween(oldest: Date | null, latest: Date | null): number {
if (!oldest || !latest) return 0;
return Math.max(
1,
Math.floor((latest.getTime() - oldest.getTime()) / DAY_MS) + 1,
);
}
export function withinWindow(
at: Date,
asOf: Date,
windowDays: number,
): boolean {
const time = at.getTime();
return time <= asOf.getTime() && time >= asOf.getTime() - windowDays * DAY_MS;
}
export function provenance(args: {
inputs: string[];
source: DerivedProvenanceSource;
windowDays: number;
asOf: Date;
}): DerivedProvenance {
return {
inputs: args.inputs,
source: args.source,
windowDays: args.windowDays,
computedAt: args.asOf.toISOString(),
};
}
export function insufficientPillar(args: {
id: ScorePillarId;
source: DerivedProvenanceSource;
asOf: Date;
windowDays: number;
requiredInputs: number;
presentInputs: number;
historyDays: number;
missing: string[];
reason: string;
}): Derived<PillarValue> {
const { coverage } = deriveCoverage({
requiredInputs: args.requiredInputs,
presentInputs: args.presentInputs,
historyDays: args.historyDays,
missing: args.missing,
fullHistoryDays: args.windowDays,
});
return buildInsufficient<PillarValue>({
coverage,
provenance: provenance({
inputs: [args.id],
source: args.source,
windowDays: args.windowDays,
asOf: args.asOf,
}),
reason: args.reason,
});
}
export function failedPillar(args: {
id: ScorePillarId;
asOf: Date;
windowDays: number;
}): Derived<PillarValue> {
return insufficientPillar({
...args,
source: "none",
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [args.id],
reason: "read_failed",
});
}
export function okPillar(args: {
id: ScorePillarId;
source: DerivedProvenanceSource;
asOf: Date;
windowDays: number;
coverage: DerivedCoverage;
value: PillarValue;
}): Derived<PillarValue> {
const { confidence } = deriveCoverage({
...args.coverage,
fullHistoryDays: args.windowDays,
});
return buildOk({
value: args.value,
coverage: args.coverage,
confidence,
provenance: provenance({
inputs: [args.id],
source: args.source,
windowDays: args.windowDays,
asOf: args.asOf,
}),
});
}
export function coverage(args: {
requiredInputs: number;
presentInputs: number;
historyDays: number;
missing?: string[];
}): DerivedCoverage {
return {
requiredInputs: args.requiredInputs,
presentInputs: Math.min(args.presentInputs, args.requiredInputs),
historyDays: Math.max(0, args.historyDays),
missing: args.missing ?? [],
};
}
export function readFailure(state: DomainReadState): boolean {
return state.readFailed;
}
+113
View File
@@ -0,0 +1,113 @@
import {
scoreConsistency,
scoreSufficiency,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import type { Derived } from "@/lib/insights/derived/types";
import { userDayKey } from "@/lib/tz/format";
import type { PillarValue, SleepPillarInput } from "./types";
import {
coverage,
failedPillar,
insufficientPillar,
mean,
okPillar,
standardDeviation,
} from "./shared";
export const SLEEP_WINDOW_DAYS = 28;
export const SLEEP_MIN_NIGHTS = 14;
export const SLEEP_REGULARITY_MIN_NIGHTS = 21;
export function computeSleepPillar(
input: SleepPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "SLEEP",
asOf: input.asOf,
windowDays: SLEEP_WINDOW_DAYS,
});
}
const asOfDay = userDayKey(input.asOf, input.timezone);
const sinceDay = userDayKey(
new Date(input.asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * 86_400_000),
input.timezone,
);
const nights = input.nights
.filter((night) => night.night <= asOfDay && night.night >= sinceDay)
.sort((a, b) => a.night.localeCompare(b.night));
if (nights.length < SLEEP_MIN_NIGHTS) {
return insufficientPillar({
id: "SLEEP",
source: input.source,
asOf: input.asOf,
windowDays: SLEEP_WINDOW_DAYS,
requiredInputs: SLEEP_MIN_NIGHTS,
presentInputs: nights.length,
historyDays: nights.length,
missing: [`${Math.max(0, SLEEP_MIN_NIGHTS - nights.length)} nights`],
reason:
input.nights.length > 0 ? "below_night_floor_or_stale" : "not_tracked",
});
}
const asleepValues = nights.map((night) => night.asleepMinutes);
const observed = mean(asleepValues);
const need = sleepNeedMinutes(input.ageYears);
const sufficiency = scoreSufficiency(observed, need) ?? 0;
const midpoints = nights
.map((night) => night.midpoint)
.filter((midpoint): midpoint is number => midpoint != null);
const regularity =
nights.length >= SLEEP_REGULARITY_MIN_NIGHTS
? scoreConsistency(midpoints)
: null;
// These are the existing Sleep Score's own declared sub-score weights,
// renormalised over the two inputs admitted here: sufficiency 0.3 and
// consistency 0.2 become 60/40. Below 21 nights the level stands alone.
const score =
regularity == null
? sufficiency
: Math.round(sufficiency * 0.6 + regularity * 0.4);
const noiseFloor = Math.max(
1,
Math.round(((0.5 * standardDeviation(asleepValues)) / need) * 100),
);
const latest = nights.at(-1)!;
return okPillar({
id: "SLEEP",
source: input.source,
asOf: input.asOf,
windowDays: SLEEP_WINDOW_DAYS,
coverage: coverage({
requiredInputs: SLEEP_MIN_NIGHTS,
presentInputs: SLEEP_MIN_NIGHTS,
historyDays: nights.length,
}),
value: {
score,
observed: {
value: Math.round(observed),
unit: "minutes/night",
label: `${Math.round(observed)} min/night${regularity == null ? "" : " + regularity"}`,
asOf: `${latest.night}T00:00:00.000Z`,
sources: input.sources,
},
reference: {
kind: "guideline-band",
low: need,
high: null,
label: `at least ${need} min/night; long sleep is not penalised`,
source: "NSF 2015; Ai 2021; Daghlas 2019; Windred 2024",
},
noiseFloor,
deltaEligible: true,
deltaIdentity: "sleep_duration_rhythm",
},
});
}
+257
View File
@@ -0,0 +1,257 @@
import type { BpTargets } from "@/lib/analytics/bp-targets";
import type {
Derived,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
import type { ProfileSex } from "@/lib/profile/sex";
export const SCORE_VERSION = 2 as const;
export const SCORE_PILLAR_IDS = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
] as const;
export type ScorePillarId = (typeof SCORE_PILLAR_IDS)[number];
export type ScoreBand = "green" | "yellow" | "red";
export type ScoreDomain =
| "cardiometabolic"
| "activity"
| "sleep"
| "adiposity"
| "wellbeing"
| "fitness";
export type PillarReferenceKind =
"clinical-threshold" | "population-percentile" | "guideline-band";
export interface PillarObserved {
value: number;
unit: string;
/** Complete display value, including paired or panel values where needed. */
label: string;
asOf: string;
/** Persisted source labels that contributed to the observation. */
sources: string[];
}
export interface PillarReference {
kind: PillarReferenceKind;
low: number | null;
high: number | null;
/** Complete display band for paired or multi-marker references. */
label: string;
source: string;
}
export interface PillarValue {
score: number;
observed: PillarObserved;
reference: PillarReference;
/** Optional user-authored yardstick, displayed beside the scored reference. */
personalReference?: PillarReference;
/** Score points that a weekly move must clear before it is narrated. */
noiseFloor: number;
/** Slow markers contribute to level but never to the weekly delta. */
deltaEligible: boolean;
/** Input/scoring mode identity used to form like-for-like deltas. */
deltaIdentity: string;
}
export interface ScorePillarResult {
id: ScorePillarId;
domain: ScoreDomain;
result: Derived<PillarValue>;
}
export interface CompositeValue {
score: number;
band: ScoreBand;
/** Non-null only when the worst pillar lowers the mean score's band. */
bandSetter: ScorePillarId | null;
/** Registry-ordered eligible pillar ids. Part of the number's identity. */
composition: ScorePillarId[];
/** Equal-weighted floor across delta-eligible pillars. */
noiseFloor: number;
scoreVersion: typeof SCORE_VERSION;
}
export type ScoreDeltaReason =
| "algorithm_changed"
| "composition_changed"
| "first_eligibility_window"
| "below_noise_floor"
| "no_previous_window"
| "no_current_score";
export interface WeightGoalValue {
currentKg: number;
target: { min: number; max: number };
distanceKg: number;
/** Positive means the distance to the personal band narrowed. */
deltaKg: number | null;
asOf: string;
source: string;
}
export interface RestModeAnnotation {
active: true;
since: string | null;
episodeCount: number;
}
export interface HealthScoreReport {
composite: Derived<CompositeValue>;
pillars: ScorePillarResult[];
delta: number | null;
deltaReason: ScoreDeltaReason | null;
scoreVersion: typeof SCORE_VERSION;
weightGoal: Derived<WeightGoalValue>;
algorithmNotice: { itemKey: string; dismissed: boolean } | null;
restMode?: RestModeAnnotation | null;
}
export interface ScoreProfile {
ageYears: number | null;
sex: ProfileSex;
heightCm: number | null;
}
export interface DomainReadState {
source: DerivedProvenanceSource;
readFailed: boolean;
}
export interface BloodPressurePillarInput extends DomainReadState {
asOf: Date;
pairCount: number;
gradedScore: number | null;
representative: { sys: number; dia: number } | null;
oldestAt: Date | null;
latestAt: Date | null;
target: BpTargets | null;
personalTarget?: BpTargets | null;
sources: string[];
}
export interface HbA1cReading {
value: number;
unit: string;
at: Date;
source: string;
}
export interface GlucoseReading {
value: number;
at: Date;
source: string;
}
export interface GlycaemiaPillarInput extends DomainReadState {
asOf: Date;
hba1cReadFailed?: boolean;
fastingReadFailed?: boolean;
hba1c: HbA1cReading[];
fastingGlucose: GlucoseReading[];
}
export interface ActivityDay {
day: string;
value: number;
}
export interface ActivityPillarInput extends DomainReadState {
asOf: Date;
timezone: string;
ageYears: number | null;
days: ActivityDay[];
sources: string[];
}
export interface SleepPillarNight {
night: string;
asleepMinutes: number;
midpoint: number | null;
}
export interface SleepPillarInput extends DomainReadState {
asOf: Date;
timezone: string;
ageYears: number | null;
nights: SleepPillarNight[];
sources: string[];
}
export interface AdiposityReading {
type: "WAIST_CIRCUMFERENCE" | "WAIST_TO_HEIGHT";
value: number;
unit: string;
at: Date;
source: string;
}
export interface AdiposityPillarInput extends DomainReadState {
asOf: Date;
heightCm: number | null;
rows: AdiposityReading[];
}
export interface WellbeingAssessment {
instrument: "PHQ9" | "GAD7" | "WHO5";
totalScore: number;
item9Flagged: boolean;
at: Date;
}
export interface WellbeingPillarInput extends DomainReadState {
asOf: Date;
assessments: WellbeingAssessment[];
}
export interface FitnessReading {
value: number;
at: Date;
source: string;
/** True only when persisted provenance proves a measured CPET/test. */
measured: boolean;
}
export interface FitnessPillarInput extends DomainReadState {
asOf: Date;
ageYears: number | null;
sex: ProfileSex;
rows: FitnessReading[];
}
export interface LipidReading {
marker: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
at: Date;
source: string;
}
export interface LipidsPillarInput extends DomainReadState {
asOf: Date;
rows: LipidReading[];
}
export interface PillarInputs {
BLOOD_PRESSURE: BloodPressurePillarInput;
GLYCAEMIA: GlycaemiaPillarInput;
ACTIVITY: ActivityPillarInput;
SLEEP: SleepPillarInput;
ADIPOSITY: AdiposityPillarInput;
WELLBEING: WellbeingPillarInput;
FITNESS: FitnessPillarInput;
LIPIDS: LipidsPillarInput;
}
+96
View File
@@ -0,0 +1,96 @@
import {
buildInsufficient,
buildOk,
deriveCoverage,
} from "@/lib/insights/derived/coverage";
import type {
Derived,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
import type { WeightGoalValue } from "./types";
import { DAY_MS, provenance } from "./shared";
export interface WeightGoalReading {
value: number;
at: Date;
source: string;
}
function distanceToBand(
value: number,
target: { min: number; max: number },
): number {
return Math.max(0, target.min - value, value - target.max);
}
export function computeWeightGoal(args: {
rows: WeightGoalReading[];
target: { min: number; max: number } | null;
source: DerivedProvenanceSource;
asOf: Date;
readFailed?: boolean;
}): Derived<WeightGoalValue> {
const since = new Date(args.asOf.getTime() - 60 * DAY_MS);
const rowsInWindow = args.rows.filter(
(row) => row.at >= since && row.at <= args.asOf,
);
const weightProvenance = provenance({
inputs: ["WEIGHT", "personal weight target"],
source: args.source,
windowDays: 60,
asOf: args.asOf,
});
const { coverage, confidence } = deriveCoverage({
requiredInputs: 2,
presentInputs: (rowsInWindow.length > 0 ? 1 : 0) + (args.target ? 1 : 0),
historyDays: rowsInWindow.length > 0 ? 1 : 0,
missing: [
...(rowsInWindow.length === 0 ? ["weight reading"] : []),
...(!args.target ? ["personal weight target"] : []),
],
fullHistoryDays: 1,
});
if (args.readFailed) {
return buildInsufficient({
coverage,
provenance: weightProvenance,
reason: "read_failed",
});
}
if (!args.target || rowsInWindow.length === 0) {
return buildInsufficient({
coverage,
provenance: weightProvenance,
reason: args.target ? "weight_not_tracked" : "no_personal_goal",
});
}
const rows = [...rowsInWindow].sort(
(a, b) => b.at.getTime() - a.at.getTime(),
);
const latest = rows[0];
const previous = rows.find(
(row) => row.at <= new Date(latest.at.getTime() - 7 * DAY_MS),
);
const latestDistance = distanceToBand(latest.value, args.target);
const previousDistance = previous
? distanceToBand(previous.value, args.target)
: null;
return buildOk({
value: {
currentKg: latest.value,
target: args.target,
distanceKg: latestDistance,
deltaKg:
previousDistance == null
? null
: Math.round((previousDistance - latestDistance) * 10) / 10,
asOf: latest.at.toISOString(),
source: latest.source,
},
coverage,
confidence,
provenance: weightProvenance,
});
}
+134
View File
@@ -0,0 +1,134 @@
import type { Derived } from "@/lib/insights/derived/types";
import type { PillarValue, WellbeingPillarInput } from "./types";
import {
clampScore,
coverage,
failedPillar,
insufficientPillar,
okPillar,
withinWindow,
} from "./shared";
export const WELLBEING_WINDOW_DAYS = 90;
const INSTRUMENT: Record<
"PHQ9" | "GAD7" | "WHO5",
{
max: number;
low: number;
high: number;
source: string;
label: string;
itemStep: number;
}
> = {
PHQ9: {
max: 27,
low: 0,
high: 4,
source: "Kroenke 2001",
label: "PHQ-9 0–4",
itemStep: 1,
},
GAD7: {
max: 21,
low: 0,
high: 4,
source: "Spitzer 2006",
label: "GAD-7 0–4",
itemStep: 1,
},
WHO5: {
max: 100,
low: 52,
high: 100,
source: "WHO 1998",
label: "WHO-5 52–100",
// WHO-5 percentage totals move in four-point increments (raw 0–25 × 4).
itemStep: 4,
},
};
export function computeWellbeingPillar(
input: WellbeingPillarInput,
): Derived<PillarValue> {
if (input.readFailed) {
return failedPillar({
id: "WELLBEING",
asOf: input.asOf,
windowDays: WELLBEING_WINDOW_DAYS,
});
}
const assessments = input.assessments
.filter((row) => withinWindow(row.at, input.asOf, WELLBEING_WINDOW_DAYS))
.sort((a, b) => b.at.getTime() - a.at.getTime());
const latest = assessments[0];
if (!latest) {
return insufficientPillar({
id: "WELLBEING",
source: input.source,
asOf: input.asOf,
windowDays: WELLBEING_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: ["completed WHO-5, PHQ-9 or GAD-7"],
reason: input.assessments.length > 0 ? "stale" : "not_tracked",
});
}
if (latest.instrument === "PHQ9" && latest.item9Flagged) {
return insufficientPillar({
id: "WELLBEING",
source: input.source,
asOf: input.asOf,
windowDays: WELLBEING_WINDOW_DAYS,
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
missing: [],
reason: "crisis_signposting",
});
}
const spec = INSTRUMENT[latest.instrument];
const score =
latest.instrument === "WHO5"
? clampScore(latest.totalScore)
: clampScore((1 - latest.totalScore / spec.max) * 100);
const noiseFloor = Math.max(1, Math.round((spec.itemStep / spec.max) * 100));
return okPillar({
id: "WELLBEING",
source: input.source,
asOf: input.asOf,
windowDays: WELLBEING_WINDOW_DAYS,
coverage: coverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 1,
}),
value: {
score,
observed: {
value: latest.totalScore,
unit: "score",
label: `${latest.instrument} ${latest.totalScore}/${spec.max}`,
asOf: latest.at.toISOString(),
sources: ["COMPUTED"],
},
reference: {
kind: "clinical-threshold",
low: spec.low,
high: spec.high,
label: spec.label,
source: spec.source,
},
noiseFloor,
deltaEligible: true,
deltaIdentity: latest.instrument,
},
});
}
+31
View File
@@ -11,6 +11,7 @@ import {
invalidateUserDashboardWidgets,
invalidateUserDashboardSnapshot,
invalidateUserInsights,
invalidateUserHealthScore,
invalidateUserMeasurements,
invalidateUserMedications,
invalidateUserMood,
@@ -110,6 +111,36 @@ function snapshotCell(userId: string, locale = "en"): string {
return `${dashboardSnapshotCacheKey(userId)}|${locale}`;
}
describe("invalidateUserHealthScore", () => {
it("evicts analytics, dashboard snapshots, and derived batches for one user", async () => {
await cached(caches.analytics, `${USER_A}|default|en`, async () => ({}));
await cached(caches.analytics, snapshotCell(USER_A), async () => ({}));
await cached(
caches.insightsDerived,
`${USER_A}|batch|HEALTH_SCORE|en`,
async () => ({}),
);
await cached(caches.analytics, `${USER_B}|default|en`, async () => ({}));
await cached(
caches.insightsDerived,
`${USER_B}|batch|HEALTH_SCORE|en`,
async () => ({}),
);
invalidateUserHealthScore(USER_A);
expect(caches.analytics.get(`${USER_A}|default|en`)).toBeNull();
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(
caches.insightsDerived.get(`${USER_A}|batch|HEALTH_SCORE|en`),
).toBeNull();
expect(caches.analytics.get(`${USER_B}|default|en`)).not.toBeNull();
expect(
caches.insightsDerived.get(`${USER_B}|batch|HEALTH_SCORE|en`),
).not.toBeNull();
});
});
describe("invalidateUserMeasurements", () => {
it("evicts analytics + achievements + workouts for the target user only", async () => {
await primeAllCaches();
+10
View File
@@ -185,6 +185,16 @@ export function invalidateUserMedications(
caches.insightsTargets.deleteByPrefix(userId);
}
/**
* Hard-evict every server cache that can carry the derived Health Score.
* Lab, assessment and score-notice mutations call this one bundle so the
* analytics report, dashboard snapshot and derived batch cannot disagree.
*/
export function invalidateUserHealthScore(userId: string): void {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.insightsDerived.deleteByPrefix(`${userId}|`);
}
/**
* v1.16.8 — invalidate every cache whose payload derives from the user's
* PROFILE-level settings rather than from measurement / mood / medication
+8
View File
@@ -24,6 +24,10 @@ import type { ArrivalKind } from "@/lib/arrivals/types";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import type {
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
import {
MAX_PRIORITY_ACTIONS,
type PriorityItem,
@@ -97,6 +101,10 @@ export interface DailyDigestScore {
value: number;
band: string;
delta: number | null;
/** Additive score identity; omitted by older cached digests. */
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
composition?: ScorePillarId[];
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
+3
View File
@@ -428,6 +428,9 @@ export async function loadDailyDigest(
value: snapshot.healthScore.score,
band: snapshot.healthScore.band,
delta: snapshot.healthScore.delta,
deltaReason: snapshot.healthScore.deltaReason,
scoreVersion: snapshot.healthScore.scoreVersion,
composition: snapshot.healthScore.composition,
}
: null;
+5
View File
@@ -55,3 +55,8 @@ export function sameTimeBaselineItemKey(
): string {
return `same_time_baseline:${localDayKey}:${type}`;
}
/** One persisted acknowledgement per Health Score algorithm version. */
export function healthScoreAlgorithmItemKey(scoreVersion: number): string {
return `health_score_algorithm:${scoreVersion}`;
}
+9 -2
View File
@@ -81,9 +81,16 @@ export function isDismissibleKind(
* builder (which stamps the key) and the dismiss route's Zod schema (which
* rejects anything else structurally, before a lookup ever runs).
*/
const DISMISSIBLE_NOTICE_PREFIXES = ["health_score_algorithm"] as const;
export function isDismissibleItemKey(itemKey: string): boolean {
return DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) =>
itemKey.startsWith(`${kind}:`),
return (
DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) =>
itemKey.startsWith(`${kind}:`),
) ||
DISMISSIBLE_NOTICE_PREFIXES.some((prefix) =>
itemKey.startsWith(`${prefix}:`),
)
);
}
+101 -63
View File
@@ -22,7 +22,7 @@ const computeBpInTargetFastPath = vi.fn();
const getAssistantFlags = vi.fn();
const hasAnyConfiguredProvider = vi.fn();
const buildMedsTodayBlock = vi.fn();
const computeUserHealthScoreFastPath = vi.fn();
const computeUserHealthScoreMock = vi.fn();
vi.mock("@/lib/analytics/summaries-slice", () => ({
computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a),
@@ -53,9 +53,57 @@ vi.mock("@/lib/ai/provider", () => ({
vi.mock("@/lib/dashboard/meds-today", () => ({
buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: (...a: unknown[]) =>
computeUserHealthScoreFastPath(...a),
vi.mock("@/lib/analytics/score/reader", () => ({
computeUserHealthScore: async (...a: unknown[]) => {
const legacy = await computeUserHealthScoreMock(...a);
const provenance = {
inputs: [],
source: "none" as const,
windowDays: 0,
computedAt: "2026-07-28T12:00:00.000Z",
};
const coverage = {
requiredInputs: 3,
presentInputs: legacy ? 3 : 0,
historyDays: legacy ? 28 : 0,
missing: legacy ? [] : ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
};
const composite = legacy
? {
status: "ok" as const,
value: {
score: legacy.score,
band: legacy.band,
bandSetter: "BLOOD_PRESSURE" as const,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] as const,
noiseFloor: 2,
scoreVersion: 2 as const,
},
coverage,
confidence: { score: 100, band: "high" as const },
provenance,
}
: {
status: "insufficient" as const,
coverage,
provenance,
reason: "three_domains_required",
};
return {
composite,
pillars: [],
delta: legacy?.delta ?? null,
deltaReason: null,
scoreVersion: 2 as const,
weightGoal: {
status: "insufficient" as const,
coverage,
provenance,
reason: "no_personal_goal",
},
algorithmNotice: null,
};
},
}));
// v1.18.0 — module gate. Default-mocked all-on so the existing envelope
// tests keep their full tile set; the module-gating suite below passes
@@ -182,7 +230,7 @@ beforeEach(() => {
nextDueOverdue: false,
nextDueMedicationName: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
computeUserHealthScoreMock.mockResolvedValue(null);
resolveModuleMap.mockResolvedValue(moduleMap());
buildScoreRingsBlock.mockResolvedValue([]);
});
@@ -301,7 +349,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
priorYear: { pct: 50 },
gradedScore: 84,
});
computeUserHealthScoreFastPath.mockResolvedValue({
computeUserHealthScoreMock.mockResolvedValue({
score: 71,
band: "yellow",
delta: 3,
@@ -319,16 +367,21 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
band: "yellow",
delta: 3,
restMode: null,
confidence: { score: 100, band: "high" },
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
deltaReason: null,
scoreVersion: 2,
bandSetter: "BLOOD_PRESSURE",
// v1.21.2 (A5 / A6) — additive narrative fields, null absent a
// disagreeing readiness set / a returned metric (the fake derived
// engines resolve nothing here).
tension: null,
returnToBand: null,
});
// v1.17 W1b — two runs (current + prior-week), identical to the analytics
// route, so the ring's delta reflects BP movement.
expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2);
expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1);
// Current, prior-week, and prior-two-week windows support both the weekly
// movement and the first-eligibility guard.
expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(3);
expect(computeUserHealthScoreMock).toHaveBeenCalledTimes(1);
});
it("a fresh account with zero coverage entries stays null", async () => {
@@ -340,7 +393,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
expect(snap.extras).toBeNull();
expect(snap.healthScore).toBeNull();
expect(computeBpInTargetFastPath).not.toHaveBeenCalled();
expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled();
expect(computeUserHealthScoreMock).not.toHaveBeenCalled();
});
it("a gated type ABSENT from the map (never logged) keeps the phase warm", async () => {
@@ -357,7 +410,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
priorYear: null,
gradedScore: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
computeUserHealthScoreMock.mockResolvedValue(null);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
@@ -423,10 +476,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.healthScore).toBeNull();
expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled();
expect(computeUserHealthScoreMock).not.toHaveBeenCalled();
});
it("serialises score + band + delta — and ONLY those — when warm", async () => {
it("serialises the additive score identity when warm", async () => {
const coverageMap = new Map([["WEIGHT", true]]);
probeRollupCoverage.mockResolvedValue(coverageMap);
isFullyCovered.mockReturnValue(true);
@@ -443,7 +496,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
priorYear: { pct: 50, pairs: 40 },
gradedScore: 88,
});
computeUserHealthScoreFastPath.mockResolvedValue({
computeUserHealthScoreMock.mockResolvedValue({
score: 72,
band: "yellow",
delta: -12,
@@ -460,6 +513,11 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
band: "yellow",
delta: -12,
restMode: null,
confidence: { score: 100, band: "high" },
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
deltaReason: null,
scoreVersion: 2,
bandSetter: "BLOOD_PRESSURE",
// v1.21.2 (A5 / A6) — additive narrative fields, null here.
tension: null,
returnToBand: null,
@@ -467,27 +525,17 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
// The fast path reuses the BP windows + coverage map already
// computed for `extras` (no re-probe, graded score threaded).
expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1);
const arg = computeUserHealthScoreFastPath.mock.calls[0][0] as {
expect(computeUserHealthScoreMock).toHaveBeenCalledTimes(1);
const arg = computeUserHealthScoreMock.mock.calls[0][0] as {
userId: string;
bpInTargetPct: number | null;
bpGradedScore: number | null;
weightTarget: { min: number; max: number } | null;
weightTargetSource: string;
moodEnabled: boolean;
coverage: unknown;
bpEnvelope: { last90Days?: { pct: number } | null } | null;
bpTargets: unknown;
profile: { thresholdsJson: unknown };
modules: { mentalHealth: boolean };
};
expect(arg.userId).toBe("user-1");
// v1.17 W1d — the BP pillar reads the 90-day window, identical to the
// analytics route, so dashboard ring and insights card score off one
// number. Previously this was last30Days (80).
expect(arg.bpInTargetPct).toBe(77);
expect(arg.bpGradedScore).toBe(88);
// v1.34 — no weight target on this fixture, so the pillar scores the bare
// trend. The BMI-22 band that used to be derived from `heightCm` is gone.
expect(arg.weightTarget).toBeNull();
expect(arg.weightTargetSource).toBe("none");
expect(arg.coverage).toBe(coverageMap);
expect(arg.bpEnvelope?.last90Days?.pct).toBe(77);
expect(arg.bpTargets).not.toBeNull();
});
it("is null when warm but no pillar is computable (fast path returns null)", async () => {
@@ -502,7 +550,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
priorYear: null,
gradedScore: null,
});
computeUserHealthScoreFastPath.mockResolvedValue(null);
computeUserHealthScoreMock.mockResolvedValue(null);
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
@@ -993,48 +1041,38 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
);
});
it("mood disabled: tile hidden + mood block emptied", async () => {
it("mental assessments disabled: WELLBEING is omitted from the score", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap({ mood: false })),
modules: () => Promise.resolve(moduleMap({ mentalHealth: false })),
});
expect(widget(snap, "mood")!.visible).toBe(false);
expect(widget(snap, "mood")!.tileVisible).toBe(false);
expect(cat(snap, "mood")!.visible).toBe(false);
expect(snap.tiles.mood.entries).toEqual([]);
expect(snap.tiles.mood.summary).toBeNull();
// Core tile unaffected.
expect(widget(snap, "weight")!.visible).toBe(true);
// v1.34 — the hero ring's health score drops the mood pillar too. Before
// this the snapshot never resolved the module, so a mood-off account was
// scored on a pillar the Insights card had already dropped.
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
{ moodEnabled: boolean } | undefined;
expect(scoreCall?.moodEnabled).toBe(false);
// The independent mood tile is unaffected by the assessment-module gate.
expect(widget(snap, "mood")!.visible).toBe(true);
expect(snap.tiles.mood.entries.length).toBeGreaterThan(0);
const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as
{ modules: { mentalHealth: boolean } } | undefined;
expect(scoreCall?.modules.mentalHealth).toBe(false);
});
it("mood enabled: the health score keeps its mood pillar", async () => {
it("mental assessments enabled: the score admits WELLBEING", async () => {
await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap()),
});
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
{ moodEnabled: boolean } | undefined;
expect(scoreCall?.moodEnabled).toBe(true);
const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as
{ modules: { mentalHealth: boolean } } | undefined;
expect(scoreCall?.modules.mentalHealth).toBe(true);
});
it("an explicit weight target reaches the score and the bands", async () => {
it("an explicit weight target reaches personal context and dashboard bands", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({ thresholdsJson: { WEIGHT: { min: 74, max: 78 } } }),
{ modules: () => Promise.resolve(moduleMap()) },
);
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
| {
weightTarget: { min: number; max: number } | null;
weightTargetSource: string;
}
| undefined;
expect(scoreCall?.weightTarget).toEqual({ min: 74, max: 78 });
expect(scoreCall?.weightTargetSource).toBe("user");
const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as
{ profile: { thresholdsJson: unknown } } | undefined;
expect(scoreCall?.profile.thresholdsJson).toEqual({
WEIGHT: { min: 74, max: 78 },
});
// The tile / chart band follows the same target.
expect(snap.targetBands.weightRange?.greenMin).toBe(74);
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
@@ -1145,7 +1183,7 @@ describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => {
priorMonth: { pct: 60 },
priorYear: { pct: 50 },
});
computeUserHealthScoreFastPath.mockResolvedValue({
computeUserHealthScoreMock.mockResolvedValue({
score: 71,
band: "yellow",
delta: 3,
+18 -3
View File
@@ -575,7 +575,12 @@ describe("rung 6 — silence", () => {
describe("rung 7 — scoreDrop", () => {
function scoreSnapshot(delta: number | null): DashboardSnapshot {
return baseSnapshot({
healthScore: { score: 60, band: "yellow", delta },
healthScore: {
score: 60,
band: "yellow",
delta,
deltaReason: null,
},
});
}
@@ -709,7 +714,12 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => {
},
mood: { summary: null, entries: [] },
},
healthScore: { score: 40, band: "red", delta: -20 },
healthScore: {
score: 40,
band: "red",
delta: -20,
deltaReason: null,
},
briefing: {
paragraph: "Busy day.",
keyFindings: [WATCH_FINDING],
@@ -725,7 +735,12 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => {
it("scoreDrop outranks the briefing teaser", () => {
const verdict = resolveDashboardVerdict(
baseSnapshot({
healthScore: { score: 50, band: "yellow", delta: -15 },
healthScore: {
score: 50,
band: "yellow",
delta: -15,
deltaReason: null,
},
briefing: {
paragraph: "Busy day.",
keyFindings: [WATCH_FINDING],
+1
View File
@@ -67,6 +67,7 @@ export async function readDashboardSnapshotCached(
insightsCachedText: user.insightsCachedText,
insightsCachedAt: user.insightsCachedAt,
dashboardWidgetsJson: user.dashboardWidgetsJson,
sourcePriorityJson: user.sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
};
+149 -93
View File
@@ -43,7 +43,7 @@ import {
type DataPoint,
type DataSummary,
} from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -54,12 +54,8 @@ import {
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import {
buildHealthScoreBpInputs,
buildHealthScoreWeightTarget,
} from "@/lib/analytics/health-score-inputs";
import {
resolveEffectiveBpTargets,
resolveWeightTargetOverride,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import {
@@ -81,15 +77,18 @@ import {
buildMedsTodayBlock,
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import {
buildDashboardBands as buildTargetBands,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
import type {
HealthScoreBand,
RestModeAnnotation,
} from "@/lib/analytics/health-score";
ScoreBand,
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
import type { DerivedConfidence } from "@/lib/insights/derived/types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
@@ -326,8 +325,13 @@ export interface DashboardLayoutCatalogueEntry {
*/
export interface DashboardSnapshotHealthScore {
score: number;
band: HealthScoreBand;
band: ScoreBand;
delta: number | null;
confidence?: DerivedConfidence;
composition?: ScorePillarId[];
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
bandSetter?: ScorePillarId | null;
/**
* v1.18.1 — Rest Mode annotation. When an illness/condition episode is
* active the dashboard frames the score ("you were unwell during this
@@ -479,12 +483,11 @@ export interface SnapshotUserInput {
insightsCachedText: string | null;
insightsCachedAt: Date | null;
dashboardWidgetsJson: unknown;
/** Raw source-priority settings already loaded with the authenticated user. */
sourcePriorityJson?: unknown;
/**
* v1.34 — the raw `User.thresholdsJson` blob. The hero ring's weight
* pillar and the dashboard's weight bands both grade against the user's
* OWN target when one is set, so the builder needs the overrides the
* `/targets` editor writes. Parsed at the point of use through
* `ThresholdOverridesJson`.
* Raw personal targets used by the unscored weight-goal context row and
* dashboard reference bands.
*/
thresholdsJson: unknown;
}
@@ -593,15 +596,13 @@ async function buildNutrientWaterBlock(
* this entirely and emits `extras: null` + `healthScore: null`
* (per-tile shimmer until the boot backfill converges).
*
* The health score rides this warm phase because it reuses the BP
* windows already computed here (`gradedScore` + `last30Days.pct`) and
* the same coverage map, so the score's weight pillar also stays on
* the rollup branch.
* The Health Score rides this warm phase because it reuses the BP windows and
* coverage map. Weight remains in the warm gate for the unscored personal-goal
* row, which must not force a dense live fallback while the rollup backfill is
* pending.
*
* v1.34 — takes the resolved module map. The hero ring used to score a
* mood pillar for accounts that had switched the mood module off, while
* the Insights card (which does resolve the module) dropped it: one
* score, two compositions, two surfaces.
* v1.34 — takes the resolved module map so a disabled mental-assessment module
* cannot leave WELLBEING in this composition while Insights omits it.
*/
async function buildExtras(
prisma: PrismaClient,
@@ -628,32 +629,79 @@ async function buildExtras(
// analytics route uses.
let bpEnvelope: BpInTargetEnvelope | null = null;
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
const bpTargets = resolveEffectiveBpTargets(
{
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
user.thresholdsJson,
);
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
// v1.17 W1b — two runs (current + prior-week), identical to the
// analytics route, so the dashboard ring's week-over-week delta reflects
// BP movement instead of zeroing it out. Both reuse the already-probed
// coverage map and share the rollup/live branch decision.
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek] = await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
]);
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
[
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
],
);
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
} else if (clinicalBpTargets) {
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
]);
}
// v1.17 W1d — the headline standardises on the trailing-90-day
// window (labelled "· 90 T" in the tile), identical to the analytics
// route, so the dashboard tile and the insights surface never narrate
@@ -686,55 +734,63 @@ async function buildExtras(
// ring and the insights card grade the pillar off identical inputs (same
// 90-day window via W1d, same all-time fallback, same graded score, same
// prior-week delta values). Closes the dashboard-vs-insights divergence.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.34 — the weight yardstick, resolved through the same shared builder the
// analytics route uses. A target reaches the pillar only when the account
// set one; otherwise the pillar scores the bare trend on both surfaces.
const weightTargetInputs = buildHealthScoreWeightTarget(
{
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
},
(user.thresholdsJson ?? null) as ThresholdOverridesJson | null,
);
const scoreSourcePriority = user.sourcePriorityJson ?? null;
const scoreResult = await time("healthScore", () =>
computeUserHealthScoreFastPath({
computeUserHealthScore({
prisma,
userId: user.id,
...bpInputs,
...weightTargetInputs,
now,
coverage,
// v1.34 — the mood module gates the pillar here exactly as it does on
// `/api/analytics`. Before this the ring scored a pillar the account
// had switched off everywhere else.
moodEnabled: modules.mood !== false,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson,
},
modules: {
glucose: modules.glucose !== false,
labs: modules.labs !== false,
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
},
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
}),
);
// v1.18.1 — resolve the value-free Rest Mode annotation alongside the score
// so the dashboard hero can frame (never penalise) the number, matching the
// `/api/analytics` payload + iOS. Fail-soft inside `resolveRestMode`, and
// only resolved when a score actually rendered.
const restMode: RestModeAnnotation | null = scoreResult
? await (async () => {
const ctx = await resolveRestMode(user.id, now);
return ctx.active
? {
active: true,
since: ctx.since,
episodeCount: ctx.episodeCount,
}
: null;
})()
: null;
const healthScore: DashboardSnapshotHealthScore | null = scoreResult
? {
score: scoreResult.score,
band: scoreResult.band,
delta: scoreResult.delta,
restMode,
}
: null;
const restMode: RestModeAnnotation | null =
scoreResult.composite.status === "ok"
? await (async () => {
const ctx = await resolveRestMode(user.id, now);
return ctx.active
? {
active: true,
since: ctx.since,
episodeCount: ctx.episodeCount,
}
: null;
})()
: null;
const healthScore: DashboardSnapshotHealthScore | null =
scoreResult.composite.status === "ok"
? {
score: scoreResult.composite.value.score,
band: scoreResult.composite.value.band,
delta: scoreResult.delta,
confidence: scoreResult.composite.confidence,
composition: scoreResult.composite.value.composition,
deltaReason: scoreResult.deltaReason,
scoreVersion: scoreResult.scoreVersion,
bandSetter: scoreResult.composite.value.bandSetter,
restMode,
}
: null;
const glucoseSince = new Date(
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
@@ -950,27 +1006,27 @@ function buildLayoutCatalogue(
* DAY bucket — one fresh wearable reading whose async fold hadn't
* landed yet zeroed the hero score, while the insights analytics
* route (which calls the self-gating helpers unconditionally) kept
* showing a score for the same account. Mirrors the v1.4.38.8
* per-type pattern already inside `computeBpInTargetFastPath` and
* `computeUserHealthScoreFastPath`: gate only on the types the thick
* phase actually reads through the rollup tier — WEIGHT (score weight
* pillar) + BLOOD_PRESSURE_SYS / _DIA (BP windows + BP pillar).
* showing a score for the same account. Gate only the dense types the thick
* phase might otherwise read live: blood-pressure pairs and activity slices.
* Sparse weight rows feed the unscored personal-goal disclosure and remain
* cheap; every other score input is naturally sparse.
*
* `!== false` rather than `=== true`: a type ABSENT from the map has
* zero measurements, so the helpers' own live fallbacks are trivially
* cheap (empty reads) and absence must not cold the phase. A type
* present-but-`false` has live rows without buckets — for the three
* gated types that is exactly the multi-second live fallback the
* snapshot must never wait on, so the phase stays `null` until the
* backfill converges. An empty map is a fresh account with no
* measurements at all — nothing to compute, stay `null`.
* present-but-`false` has live rows without buckets — for these dense types
* that is exactly the multi-second live fallback the snapshot must never
* wait on, so the phase stays `null` until the backfill converges.
* An empty map is a fresh account with no measurements at all — nothing to
* compute, so the thick phase stays `null`.
*/
function isThickPhaseWarm(coverage: RollupCoverageMap): boolean {
if (coverage.size === 0) return false;
return (
coverage.get("WEIGHT") !== false &&
coverage.get("BLOOD_PRESSURE_SYS") !== false &&
coverage.get("BLOOD_PRESSURE_DIA") !== false
coverage.get("BLOOD_PRESSURE_DIA") !== false &&
coverage.get("ACTIVITY_STEPS") !== false
);
}
+1
View File
@@ -250,6 +250,7 @@ export function resolveDashboardVerdict(
if (
score !== null &&
score.delta !== null &&
score.deltaReason === null &&
score.delta <= -SCORE_DROP_MIN_POINTS
) {
return {
+2
View File
@@ -18,6 +18,7 @@
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
@@ -105,6 +106,7 @@ async function commitObservation(
noteEncrypted: null,
},
});
invalidateUserHealthScore(userId);
// v1.31.0 — the labs arm of the data-arrival spine. A "panel" has no
// first-class entity in the schema (it is a nullable label plus a `takenAt`),
@@ -60,6 +60,7 @@ describe("registry", () => {
true,
);
expect(getDerivedMetricMeta("TRAJECTORY")?.implemented).toBe(true);
expect(getDerivedMetricMeta("HEALTH_SCORE")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -77,6 +78,7 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("READINESS");
expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE");
expect(DERIVED_METRIC_IDS).toContain("BMI");
expect(DERIVED_METRIC_IDS).toContain("HEALTH_SCORE");
expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE");
@@ -86,7 +88,7 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY");
expect(DERIVED_METRIC_IDS).toContain("SAME_TIME_BASELINE");
expect(DERIVED_METRIC_IDS.length).toBe(17);
expect(DERIVED_METRIC_IDS.length).toBe(18);
});
});
+5
View File
@@ -36,6 +36,7 @@ import {
type WellnessScoreType,
} from "./wellness-scores";
import type { Derived } from "./types";
import { computeHealthScoreDerived } from "@/lib/analytics/score/derived";
export interface DerivedComputeArgs {
metric: DerivedMetricId;
@@ -238,6 +239,10 @@ export async function computeDerivedMetric(
now,
}) as Promise<Derived<unknown>>;
}
case "HEALTH_SCORE":
return computeHealthScoreDerived(args.userId) as Promise<
Derived<unknown>
>;
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+20
View File
@@ -55,6 +55,8 @@ export type DerivedMetricId =
| "STAIR_DESCENT_SPEED_BASELINE"
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND"
/** Versioned cardiometabolic reference composite. */
| "HEALTH_SCORE"
/** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */
| "TRAJECTORY"
/** v1.34.0: today's running total for a cumulative metric vs this person's own typical total at the same local hour. */
@@ -336,6 +338,24 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
HEALTH_SCORE: {
id: "HEALTH_SCORE",
displayName: "Cardiometabolic reference score",
archetype: "composite",
inputs: [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
],
minHistoryDays: 1,
minInputs: 3,
implemented: true,
},
SAME_TIME_BASELINE: {
id: "SAME_TIME_BASELINE",
displayName: "Same-time baseline",
+2
View File
@@ -25,6 +25,7 @@
* (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import { annotate } from "@/lib/logging/context";
@@ -151,6 +152,7 @@ export async function runLabBiomarkerBackfillForUser(
});
linked += count;
}
if (linked > 0) invalidateUserHealthScore(userId);
annotate({
action: {
+52 -5
View File
@@ -968,18 +968,65 @@ export const dashboardSnapshotResponse = z
.describe(
"Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), every active medication's current display-due candidate, and legacy scalar fields projected from candidate zero. `overdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band); `availableFrom` is the canonical cadence- and dose-window-derived attribution-band start. `dueCandidates` is optional so older cached snapshots remain valid. Medication ids let consumers deep-link straight to the relevant card.",
),
// Dashboard hero — health score (warm phase, nullable on a
// rollup-coverage miss). Score + band + delta only; the per-pillar
// component breakdown stays on the analytics route.
// Dashboard hero health score. The additive metadata lets clients suppress
// false change narratives when eligibility or the algorithm changes.
healthScore: z
.object({
score: z.number().int(),
score: z.number().int().min(0).max(100),
band: z.enum(["green", "yellow", "red"]),
delta: z.number().nullable(),
confidence: derivedConfidence.optional(),
composition: z
.array(
z.enum([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]),
)
.optional(),
deltaReason: z
.enum([
"algorithm_changed",
"composition_changed",
"first_eligibility_window",
"below_noise_floor",
"no_previous_window",
"no_current_score",
])
.nullable()
.optional(),
scoreVersion: z.number().int().positive().optional(),
bandSetter: z
.enum([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
])
.nullable()
.optional(),
restMode: z
.object({
active: z.boolean(),
since: z.string().nullable(),
episodeCount: z.number().int(),
})
.nullable()
.optional(),
})
.nullable()
.describe(
"Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.",
"Derived cardiometabolic reference score summary. Null until at least three eligible domains include a measured physiological domain. Additive metadata names confidence, ordered composition, the worst-pillar band setter, score version, and why a delta was suppressed. Rest Mode annotates but never changes the score.",
),
// v1.27.7 — user-selected hero score rings (max 3), resolved
// server-side next to the health score. Additive; optional so
@@ -219,22 +219,9 @@ describe("aggregateWmyBuckets", () => {
});
/**
* v1.4.40 W-WMY-WIRE — granularity-routing parity across the v1.4.40
* consumers (summaries-slice + health-score-fast-path). Both consumers
* now call `readBestGranularityRollups` for their long-window probes;
* this pins:
* - 90 d → DAY (every coarser floor too high — DAY is canonical
* for the trailing quarter regardless of consumer),
* - 365 d → MONTH (clears MONTH floor 181 d, short of YEAR floor
* 731 d) — the granularity health-score-fast-path's weight long-
* window read and summaries-slice's year-ago baseline both
* consume,
* - 1095 d → YEAR (clears the 731-day YEAR floor) — the v1.5
* multi-year trend card target.
*
* The parity assertion ensures the routing contract is a single
* source of truth: any future floor adjustment forces both consumers
* to re-pin in lock-step rather than silently diverging.
* Granularity-routing boundaries for the shared WMY reader:
* 90 days resolves to DAY, 365 days to MONTH, and 1095 days to YEAR.
* A floor adjustment must update this single routing contract.
*/
describe("readBestGranularityRollups — cross-consumer routing parity", () => {
it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => {
+4 -6
View File
@@ -3,12 +3,10 @@
* (`POST /api/daily/digest/dismiss`).
*
* Lives outside the route file so the OpenAPI registry can import it without
* touching the route module. `itemKey` is namespaced `<kind>:...`
* (`isDismissibleItemKey` in `@/lib/daily/priority-item`) — the prefix alone
* proves the key names one of the OBSERVATIONAL kinds
* (`milestone` / `ecg_new_recording` / `tension_window`); anything else 422s
* before a lookup ever runs, so an actionable item can never be dismissed
* through this endpoint no matter what a client sends.
* touching the route module. `isDismissibleItemKey` accepts only approved
* observational item and versioned notice prefixes. Anything else 422s before
* a lookup runs, so an actionable item can never be dismissed through this
* endpoint.
*/
import { z } from "zod/v4";
+2 -44
View File
@@ -36,6 +36,7 @@
import type { DataSummary } from "@/lib/analytics/trends";
import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night";
import type { CorrelationResult } from "@/lib/insights/correlations";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
/** Shape consumed by every `/insights/<metric>/page.tsx`. */
export interface SubPageAnalyticsData {
@@ -133,48 +134,5 @@ export interface InsightsAnalyticsData {
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
} | null;
healthScore?: {
score: number;
band: "green" | "yellow" | "red";
components: {
// v1.4.25 W8e — the optional `source`/`asOf` slots feed the
// provenance accordion. Older clients reading this payload
// happily ignore the extras (additive contract).
bp: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
weight: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
/**
* v1.34 — which yardstick graded this row. `"user"` when the account
* has set an explicit weight target (`target` then carries that band
* in kg), `"none"` when the pillar scored the bare trend against no
* target at all. The card renders one disclosure line off this pair;
* until it existed the score graded weight against a BMI-22 band
* nobody chose and no surface named.
*/
targetSource?: "user" | "none";
target?: { min: number; max: number } | null;
};
mood: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
compliance: {
value: number | null;
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
};
};
delta: number | null;
} | null;
healthScore?: HealthScoreReport | null;
}