Merge work/score-configured-flag

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 20:31:01 +02:00
commit 2b95d7ebc4
58 files changed
+5885 -64

No files matched your search

+30 -1
View File
@@ -28112,11 +28112,19 @@ components:
- WELLBEING
- FITNESS
- LIPIDS
configured:
description: "True when the account's own recipe narrows the score's composition below what its defaults would resolve
to today. Server-resolved, so a client never interprets a configuration blob: an account that kept
every pillar reads false, and so does one whose disabled modules alone narrow the set. The
configuration itself is never on this wire. Optional so older cached snapshots without the field
stay valid."
type: boolean
deltaReason:
anyOf:
- type: string
enum:
- algorithm_changed
- config_changed
- composition_changed
- first_eligibility_window
- below_noise_floor
@@ -35261,13 +35269,34 @@ components:
anyOf:
- type: number
- type: "null"
configured:
description: True when the account's own recipe narrows the score's composition below what its defaults would resolve to
today. Resolved on the server and carried straight from the dashboard snapshot; the configuration
itself never rides this wire. Optional so older cached digests without the field stay valid.
type: boolean
deltaReason:
description: Why a delta was suppressed; null when it was not.
anyOf:
- type: string
- type: "null"
scoreVersion:
description: Algorithm version that produced the value.
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
composition:
description: Registry-ordered pillar ids the value was composed of.
type: array
items:
type: string
required:
- value
- band
- delta
additionalProperties: false
- type: "null"
description: Health score + band + week-over-week delta; null when none.
description: Health score + band + week-over-week delta, plus whether the account authored the composition; null when
none.
topSignal:
anyOf:
- type: object
+1
View File
@@ -168,6 +168,7 @@ const HEALTH_SCORE = {
band: "green" as const,
bandSetter: "SLEEP",
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
},
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Gesamtstufe bestimmt durch {pillar}",
"versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.",
"methodVersion": "Methodenversion {version}.",
"configured": "Du hast festgelegt, welche Bereiche in diesen Score einfließen.",
"none": "keiner",
"delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum",
"deltaReason": {
"algorithm_changed": "Kein Vergleich, weil sich die Score-Methode geändert hat.",
"config_changed": "Kein Vergleich, weil du geändert hast, was in deinen Score einfließt.",
"composition_changed": "Kein Vergleich, weil sich die einbezogenen Bereiche geändert haben.",
"first_eligibility_window": "Kein Vergleich, weil dies der erste geeignete Zeitraum ist.",
"below_noise_floor": "Die Veränderung liegt innerhalb der gemeinsamen Messschwankung.",
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Overall band set by {pillar}",
"versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.",
"methodVersion": "Method version {version}.",
"configured": "You chose which pillars count toward this score.",
"none": "none",
"delta": "{delta} vs the previous comparable window",
"deltaReason": {
"algorithm_changed": "No comparison because the score method changed.",
"config_changed": "No comparison because you changed what counts toward your score.",
"composition_changed": "No comparison because the included pillars changed.",
"first_eligibility_window": "No comparison because this is the first eligible window.",
"below_noise_floor": "Change is within the combined measurement noise.",
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Categoría general determinada por {pillar}",
"versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.",
"methodVersion": "Versión del método {version}.",
"configured": "Tú has elegido qué pilares cuentan para esta puntuación.",
"none": "ninguno",
"delta": "{delta} frente al periodo comparable anterior",
"deltaReason": {
"algorithm_changed": "No hay comparación porque cambió el método de puntuación.",
"config_changed": "No hay comparación porque cambiaste lo que cuenta para tu puntuación.",
"composition_changed": "No hay comparación porque cambiaron los pilares incluidos.",
"first_eligibility_window": "No hay comparación porque este es el primer periodo apto.",
"below_noise_floor": "El cambio está dentro de la variación conjunta de las mediciones.",
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Catégorie globale fixée par {pillar}",
"versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.",
"methodVersion": "Version de la méthode {version}.",
"configured": "Vous avez choisi les piliers qui comptent pour ce score.",
"none": "aucun",
"delta": "{delta} par rapport à la période comparable précédente",
"deltaReason": {
"algorithm_changed": "Aucune comparaison car la méthode de calcul a changé.",
"config_changed": "Aucune comparaison car vous avez modifié ce qui compte dans votre score.",
"composition_changed": "Aucune comparaison car les piliers inclus ont changé.",
"first_eligibility_window": "Aucune comparaison car il s'agit de la première période admissible.",
"below_noise_floor": "La variation reste dans la marge de fluctuation combinée des mesures.",
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Fascia complessiva determinata da {pillar}",
"versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.",
"methodVersion": "Versione del metodo {version}.",
"configured": "Hai scelto quali pilastri contano per questo punteggio.",
"none": "nessuno",
"delta": "{delta} rispetto al periodo precedente confrontabile",
"deltaReason": {
"algorithm_changed": "Nessun confronto perché è cambiato il metodo di calcolo.",
"config_changed": "Nessun confronto perché hai cambiato ciò che conta per il tuo punteggio.",
"composition_changed": "Nessun confronto perché sono cambiati i pilastri inclusi.",
"first_eligibility_window": "Nessun confronto perché questo è il primo periodo idoneo.",
"below_noise_floor": "La variazione rientra nella fluttuazione combinata delle misurazioni.",
+2
View File
@@ -2275,10 +2275,12 @@
"bandSetter": "Ogólny zakres wyznacza {pillar}",
"versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.",
"methodVersion": "Wersja metody {version}.",
"configured": "Wybrane przez Ciebie filary decydują o tym wyniku.",
"none": "brak",
"delta": "{delta} względem poprzedniego porównywalnego okresu",
"deltaReason": {
"algorithm_changed": "Brak porównania, ponieważ zmieniła się metoda obliczeń.",
"config_changed": "Brak porównania, ponieważ zmieniono, co wlicza się do wyniku.",
"composition_changed": "Brak porównania, ponieważ zmieniły się uwzględnione filary.",
"first_eligibility_window": "Brak porównania, ponieważ to pierwszy kwalifikujący się okres.",
"below_noise_floor": "Zmiana mieści się w łącznej zmienności pomiarów.",
@@ -0,0 +1,35 @@
-- v1.35.0 — per-user Health Score composition.
--
-- A single nullable JSONB column on `users` carrying which pillars the
-- person chose to count toward the composite Health Score. Follows the
-- established per-user JSON-column convention (`module_preferences_json`,
-- `dashboard_widgets_json`, `coach_prefs_json`).
--
-- Shape (validated by `src/lib/analytics/score/config.ts`):
-- { "excludedPillars": ["SLEEP", "LIPIDS"],
-- "version": 3,
-- "changedAt": "2026-07-31T09:12:44.000Z" }
--
-- The stored list is a DESELECTION list, the same posture as
-- `module_preferences_json`, and it is stored that way on purpose. A
-- pillar shipped after the person made their choice is simply absent
-- from the list, so it counts by default and the score keeps its
-- "everything eligible" starting point without a backfill. Storing the
-- positive selection instead would make a newly shipped pillar
-- indistinguishable from one the person deliberately removed.
--
-- NULL = the person never chose, which is NOT the same as choosing to
-- count nothing (an `excludedPillars` list naming every pillar). The
-- resolver keeps those two apart because only the first one may inherit
-- a later change of defaults.
--
-- `version` is the per-user recipe version: it increments on every
-- accepted write and, with `changedAt`, is what a score record and the
-- weekly delta key off to tell "you changed what counts" apart from a
-- real movement in the person's health.
--
-- Additive + nullable: every existing account keeps NULL and the
-- resolver treats NULL as "counts every pillar", which is exactly what
-- those accounts get today.
ALTER TABLE "users" ADD COLUMN "health_score_config_json" JSONB;
@@ -0,0 +1,63 @@
-- v1.35.0 — the health score as it was shown, one row per account per local day.
--
-- Nothing can rebuild this. The live computation always answers for today from
-- today's rows, so once the readings behind a past day have been corrected,
-- re-synced or simply grown, the number the person saw on that day is gone
-- unless it was written down. This table is the writing down.
--
-- Two properties are enforced here rather than left to the application:
--
-- * one row per account per local day, so a second computation on the same
-- day cannot mint a rival record of the same day, and the writer's
-- ON CONFLICT DO NOTHING has something to conflict against;
-- * a closed set of bands, as a type rather than as a CHECK. The value the
-- application speaks is lowercase, so the enum carries the same labels and
-- needs no translation layer. A type also keeps the schema-driven wipe
-- seeder working: it reads `pg_enum` for a value it cannot otherwise
-- guess, where a CHECK would have forced a hand-written entry into a
-- fixture whose whole point is not having one.
--
-- The composite is range-checked because it is free to check and a number
-- outside 0-100 would render as a score nobody can read. The day key's format
-- and the composition's non-emptiness are NOT checked here: the writer builds
-- the key through the timezone helper and refuses an empty composition before
-- it inserts, and the restore validates both against the wire schema before
-- the transaction opens. A third copy of those two rules would buy nothing.
--
-- There is deliberately no updated_at column. The row is written once and
-- never rewritten: a later computation under different readings, or under a
-- different configuration, leaves the earlier day exactly as it stood.
CREATE TYPE "health_score_band" AS ENUM ('green', 'yellow', 'red');
CREATE TABLE "health_score_records" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"day_key" VARCHAR(10) NOT NULL,
"timezone" TEXT NOT NULL,
"composite" INTEGER NOT NULL,
"band" "health_score_band" NOT NULL,
"score_version" INTEGER NOT NULL,
"composition" TEXT[] NOT NULL,
"pillar_scores" JSONB NOT NULL,
"input_fingerprint" VARCHAR(64) NOT NULL,
"config_version" INTEGER,
"config_changed_at" TIMESTAMPTZ(3),
"computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"),
CONSTRAINT "health_score_records_composite_range_check"
CHECK ("composite" >= 0 AND "composite" <= 100)
);
CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key"
ON "health_score_records" ("user_id", "day_key");
CREATE INDEX "health_score_records_user_id_day_key_desc_idx"
ON "health_score_records" ("user_id", "day_key" DESC);
ALTER TABLE "health_score_records"
ADD CONSTRAINT "health_score_records_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+116
View File
@@ -602,6 +602,30 @@ model User {
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
modulePreferencesJson Json? @map("module_preferences_json")
// v1.35.0 — which pillars count toward the composite Health Score.
//
// A DESELECTION list, the same posture as `modulePreferencesJson`:
// { "excludedPillars": ["SLEEP"], "version": 2, "changedAt": "<ISO>" }
//
// A pillar shipped after the person made their choice is absent from
// the list and therefore counts, so the score keeps its "everything
// eligible" starting point with no backfill. Storing the positive
// selection instead would make a newly shipped pillar look exactly
// like one the person deliberately removed.
//
// NULL means the person never chose and is deliberately distinct from
// a list naming every pillar ("count nothing"): only the first may
// inherit a later change of defaults.
//
// `version` increments on every accepted write and, with `changedAt`,
// is the per-user recipe identity a score record and the weekly delta
// read to tell an authored change apart from a health movement.
//
// Schema + resolver: `src/lib/analytics/score/config.ts`. Written by
// `PATCH /api/auth/me/health-score-config` with field-by-field merge
// (no mass assignment) under the write-time breadth rule.
healthScoreConfigJson Json? @map("health_score_config_json")
// Document vault — per-user storage-quota override in bytes. NULL = the
// instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set
// only (the admin user PUT); resolved by
@@ -846,6 +870,10 @@ model User {
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
correlationPatterns CorrelationPattern[]
// v1.35.0 — one row per local day holding the health score as it was shown
// that day. Append-only: a later recomputation of the same day never
// rewrites the row it finds.
healthScoreRecords HealthScoreRecord[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
@@ -5050,6 +5078,94 @@ model UserAchievement {
@@map("user_achievements")
}
// ─── Health Score record ──────
/// The three bands a composite can land in, lowercase because that is the
/// value every surface already speaks. Closed on purpose: the column refuses
/// a band no renderer knows.
enum HealthScoreBand {
green
yellow
red
@@map("health_score_band")
}
/// The health score as it was SHOWN to the account on one local day.
///
/// Not a cache and not a derived tier: nothing can rebuild it. The live
/// computation always answers for today from today's rows, so once the rows
/// behind an old day have been edited, re-synced, corrected or simply grown,
/// the number the person actually saw that day is gone unless it was written
/// down. That is what this table is, and it is why the write is append-only:
/// the FIRST score computed on a local day is the one that is kept, and a
/// later computation of the same day leaves the row exactly as it found it.
///
/// It never overrides the live computation either. Every surface keeps
/// reading the live score; this table is read only where a past day is the
/// question.
model HealthScoreRecord {
id String @id @default(cuid())
userId String @map("user_id")
/// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in
/// `timezone`. Lexicographically ordered, so a trailing-window read is a
/// range scan.
dayKey String @map("day_key") @db.VarChar(10)
/// The IANA zone the day was cut in. An account that moves zones leaves
/// behind days cut on the old clock; carrying the zone is what lets a
/// reader see that rather than silently compare days of different lengths.
timezone String
/// The composite 0-100 as shown. Integer, matching the composite's own
/// rounding.
composite Int
/// The band as shown. Stored rather than re-derived from `composite`,
/// because the worst pillar can pull the band below the one the mean alone
/// would give, so the number does not determine the band.
band HealthScoreBand
/// Algorithm identity the row was computed under.
scoreVersion Int @map("score_version")
/// Registry-ordered pillar ids that produced the composite. Part of the
/// number's identity: the same value under a different composition is a
/// different measurement.
composition String[]
/// Each counted pillar's own 0-100 score, keyed by pillar id. The composite
/// breaks across a composition change; these stay comparable through one,
/// which is the whole reason they are stored beside it rather than folded
/// away.
pillarScores Json @map("pillar_scores")
/// SHA-256 over exactly what the stored composite and band depend on: the
/// score version, the composition, and every counted pillar's score with
/// its scoring-mode identity. Two rows with the same fingerprint were
/// computed from the same standing; two rows that differ, were not.
inputFingerprint String @db.VarChar(64) @map("input_fingerprint")
/// The account's health-score configuration version in force when the row
/// was written, and when that configuration last changed. Supplied on every
/// write by `computeAndRecordUserHealthScore`, which resolves them from the
/// same stored recipe the composition came from; an account that never chose
/// records version 0 with no date, which is a real answer and not an absence.
///
/// Read by `scoreSeriesSeams` (`src/lib/analytics/score/series.ts`), which
/// marks the first row after a version move as a series break, refuses to
/// join the composite line across it, and keeps the per-pillar rows running
/// through it. Nullable only because the columns predate the writer by one
/// release; nothing written since supplies null.
configVersion Int? @map("config_version")
configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3)
/// When the score was computed. Distinct from `dayKey`: the day says which
/// day was scored, this says when the scoring ran.
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, dayKey])
@@index([userId, dayKey(sort: Desc)])
@@map("health_score_records")
}
// ─── Host Metrics (admin system-status host-load chart) ──────
/// Per-minute snapshot of host-process load + memory captured by the
@@ -257,6 +257,29 @@ function sourceClient() {
},
]),
},
// The score as it was SHOWN on a local day. Not recomputable: today's
// number always comes from today's rows, so once the readings behind this
// day moved, nothing can reproduce what it said.
healthScoreRecord: {
findMany: vi.fn().mockResolvedValue([
{
id: "score-day-1",
userId: OWNER,
dayKey: "2026-07-18",
timezone: "Europe/Berlin",
composite: 76,
band: "yellow",
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"],
pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 },
inputFingerprint: "a".repeat(64),
configVersion: null,
configChangedAt: null,
computedAt: new Date("2026-07-18T20:00:00.000Z"),
createdAt: new Date("2026-07-18T20:00:00.000Z"),
},
]),
},
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -586,6 +609,44 @@ describe("backup round trip — export, wire schema, restore", () => {
expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE);
});
it("writes the recorded score day back with the number it showed", async () => {
const { written } = await roundTrip();
const write = written.find(
(w) => w.model === "healthScoreRecord" && w.op === "createMany",
);
expect(
write,
"the recorded score day reached the file and nothing wrote it back",
).toBeDefined();
const rows = write!.data as unknown as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
id: "score-day-1",
userId: OWNER,
dayKey: "2026-07-18",
timezone: "Europe/Berlin",
composite: 76,
band: "yellow",
scoreVersion: 2,
inputFingerprint: "a".repeat(64),
});
// The composition and the per-pillar scores are what keep the stored day
// readable across a later change to what counts. A row that came back
// with only its headline would look restored and answer nothing.
expect(rows[0].composition).toEqual([
"BLOOD_PRESSURE",
"SLEEP",
"ADIPOSITY",
]);
expect(rows[0].pillarScores).toEqual({
BLOOD_PRESSURE: 86,
SLEEP: 100,
ADIPOSITY: 42,
});
});
it("writes the account's own cycle symptom back before resolving its links", async () => {
const { written, res } = await roundTrip();
@@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -71,6 +72,7 @@ interface RestoreResponse {
customMetrics: number;
correlationPatterns: number;
intradayProfiles: number;
healthScoreRecords: number;
};
}
@@ -764,6 +766,14 @@ const handler = apiHandler(
payload,
);
// The score as it was shown on each local day. Both ends live in
// `src/lib/export/health-score-backup.ts`, same as the two above.
const healthScoreCleared = await restoreHealthScoreData(
tx,
ownerId,
payload,
);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
@@ -1128,6 +1138,7 @@ const handler = apiHandler(
customMetrics: profileCleared.customMetrics,
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
healthScoreRecords: healthScoreCleared.healthScoreRecords,
};
},
{
+3 -2
View File
@@ -20,7 +20,7 @@ import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
@@ -554,7 +554,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
const scoredAt = new Date();
const healthScore = await computeUserHealthScore({
const healthScore = await computeAndRecordUserHealthScore({
userId: user.id,
now: scoredAt,
profile: {
@@ -571,6 +571,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
sleep: sleepEnabled,
mentalHealth: mentalHealthEnabled,
},
healthScoreConfigJson: user.healthScoreConfigJson,
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
@@ -0,0 +1,400 @@
/**
* v1.35.0 — GET/PATCH /api/auth/me/health-score-config.
*
* Covers the read projection, the positive-selection-in /
* deselection-out inversion, the write-time breadth refusal, the
* monotonic recipe version, and the score-cache eviction. The
* invalidation assertion is here because a missing one is invisible:
* the write succeeds, the response is right, and the person keeps
* seeing the old number for an hour.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import type { ScorePillarId } from "@/lib/analytics/score/types";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const ALL_EIGHT: ScorePillarId[] = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
];
interface ResolvedBody {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
updatedAt?: string;
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/health-score-config", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
/** Mutable row, so a write is visible to the read that follows it. */
function primeUser(healthScoreConfigJson: unknown = null) {
const row: { healthScoreConfigJson: unknown; updatedAt: Date } = {
healthScoreConfigJson,
updatedAt: new Date("2026-07-31T09:00:00.000Z"),
};
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { healthScoreConfigJson?: unknown };
}) => {
if (args.data && "healthScoreConfigJson" in args.data) {
row.healthScoreConfigJson = args.data.healthScoreConfigJson;
}
return Promise.resolve({ updatedAt: row.updatedAt });
}) as never);
return row;
}
function writtenConfig(): {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
} {
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: {
healthScoreConfigJson: {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
};
};
};
return call.data.healthScoreConfigJson;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(401);
});
it("reports every pillar counting, and no selection, for a fresh account", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(ALL_EIGHT);
expect(env.data.hasSelection).toBe(false);
expect(env.data.version).toBe(0);
expect(env.data.updatedAt).toBeUndefined();
});
it("reports a stored selection as the pillars it leaves counting", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["SLEEP", "LIPIDS"],
version: 2,
changedAt: "2026-07-30T08:00:00.000Z",
});
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(
ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(env.data.hasSelection).toBe(true);
expect(env.data.version).toBe(2);
expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z");
});
});
describe("PATCH /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("stores the complement of the selection and returns the selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: selection }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(selection);
expect(env.data.hasSelection).toBe(true);
const stored = writtenConfig();
expect(stored.excludedPillars).toEqual(
ALL_EIGHT.filter((id) => !selection.includes(id)),
);
expect(stored.version).toBe(1);
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(auditLog).toHaveBeenCalledWith(
"user.health_score_config.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("increments the recipe version on every accepted write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["LIPIDS"],
version: 7,
changedAt: "2026-07-01T00:00:00.000Z",
});
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
expect(writtenConfig().version).toBe(8);
});
it("keeps a kept-everything selection as an authored choice", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(writtenConfig().excludedPillars).toEqual([]);
expect(env.data.hasSelection).toBe(true);
});
it("evicts the score caches so the old number is not served on", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
describe("PATCH — the breadth rule", () => {
it("refuses activity and wellbeing alone, in words a person can act on", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { errorCode?: string; reason?: string };
};
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(env.meta?.reason).toBe("measured_physiological_domain_required");
expect(env.error).toContain("at least one physical measurement");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
it("refuses the cardiometabolic triple, which is three pillars in one area", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { reason?: string };
};
expect(env.meta?.reason).toBe("three_domains_required");
expect(env.error).toContain("three different areas");
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: [] }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("accepts the smallest selection that clears the rule", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
});
describe("PATCH — envelope and transport", () => {
it("rejects an unknown pillar id with 422 and writes nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unknown body key with 422 (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a userId in the body", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/health-score-config",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_EIGHT }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("guards the write on the base token and 409s a stale one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({
pillars: ALL_EIGHT,
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("health_score_config_conflict");
expect(prisma.user.update).not.toHaveBeenCalled();
// A write that never landed must not evict the caches either.
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,219 @@
/**
* v1.35.0 — which pillars count toward the Health Score.
*
* GET /api/auth/me/health-score-config — the resolved composition:
* which pillars count, which
* the person took out, whether
* they have chosen at all, and
* the recipe version.
* PATCH /api/auth/me/health-score-config — body `{ pillars: [...] }`,
* the pillars that should
* count. Field-by-field write
* of the columns this route
* owns; no mass assignment.
*
* The body speaks the positive selection because that is what a person
* chooses. The column stores its complement (see
* `src/lib/analytics/score/config.ts`), so the inversion happens once,
* here, on the way in.
*
* Two things this route is responsible for beyond storing a list.
*
* **It refuses a selection that cannot produce a score.** Three distinct
* areas of health and at least one physical measurement, or the
* composite does not exist. Saving such a selection would leave the
* person with a settings page that says one thing and a score that has
* silently vanished, so the write is refused with a sentence they can
* act on. The scorer applies the same rule again at read time through
* the same function, deliberately: a config written before a rule change
* or edited outside the app still meets an honest refusal rather than a
* missing number.
*
* **It evicts the score caches.** The person just changed what the
* number means; serving the old one for the next hour would make the
* change look broken. `invalidateUserHealthScore` is the same call the
* lab and assessment write paths make.
*
* `userId` is always narrowed from `requireAuth()`; the body never
* carries it.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import { evaluateScoreBreadth } from "@/lib/analytics/score/breadth";
import {
healthScoreConfigFromSelection,
resolveHealthScoreConfig,
scorePillarIdSchema,
} from "@/lib/analytics/score/config";
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
import { z } from "zod";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
/**
* The request body. `.strict()` and enum-typed: a client sending an id
* this build does not know is a mistake worth reporting, unlike a
* stored blob, which is filtered instead so one stale string cannot
* discard a recipe (see the resolver).
*/
const healthScoreConfigPatchSchema = z
.object({
pillars: z.array(scorePillarIdSchema).max(SCORE_PILLAR_IDS.length),
})
.strict();
/**
* Why a selection cannot produce a score, said the way a person would
* say it. The cardiometabolic sentence is the one that surprises people:
* blood pressure, glucose and cholesterol are three pillars but one area.
*/
const BREADTH_REFUSAL = {
measured_physiological_domain_required:
"A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.",
three_domains_required:
"A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one.",
} as const;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.health-score-config.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { healthScoreConfigJson: true, updatedAt: true },
});
const resolved = resolveHealthScoreConfig(row?.healthScoreConfigJson);
// A person who never chose has nothing to guard a write against, so
// they get no token — the first save is the unconditional write.
if (row?.healthScoreConfigJson == null) return apiSuccess(resolved);
return apiSuccess({ ...resolved, updatedAt: row.updatedAt?.toISOString() });
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`health-score-config:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "health-score-config.body.invalid_json");
}
// Strip the optimistic-concurrency token before the strict parse, the
// way the modules route does: the schema must stay strict, so the
// transport token can never reach it.
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = healthScoreConfigPatchSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.health-score-config.patch.invalid_shape" },
meta: { issues: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "health_score_config.invalid",
});
}
const breadth = evaluateScoreBreadth(parsed.data.pillars);
if (!breadth.ok) {
annotate({
action: { name: "auth.me.health-score-config.patch.too_narrow" },
meta: {
reason: breadth.reason,
selected: parsed.data.pillars.length,
domains: breadth.domains.length,
},
});
return apiError(BREADTH_REFUSAL[breadth.reason], 422, {
errorCode: "health_score_config.too_narrow",
reason: breadth.reason,
});
}
// The recipe version is monotonic per account: a score record has to
// be able to say which recipe produced it, and a comparison between
// two windows has to be able to see that the recipe moved between
// them. Read the previous version, increment, write.
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { healthScoreConfigJson: true },
});
const previous = resolveHealthScoreConfig(existing?.healthScoreConfigJson);
const config = healthScoreConfigFromSelection({
selection: parsed.data.pillars,
version: previous.version + 1,
changedAt: new Date(),
});
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { healthScoreConfigJson: config },
conflict: {
action: "auth.me.health-score-config.conflict",
errorCode: "health_score_config_conflict",
message: "The score selection changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
// The composite the caches hold was computed from the old selection.
invalidateUserHealthScore(user.id);
const resolved = resolveHealthScoreConfig(config);
await auditLog("user.health_score_config.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
counted: resolved.pillars,
excluded: resolved.excludedPillars,
version: resolved.version,
},
});
annotate({
action: { name: "auth.me.health-score-config.patch" },
meta: {
counted: resolved.pillars.length,
excluded: resolved.excludedPillars.length,
version: resolved.version,
},
});
return apiSuccess({ ...resolved, updatedAt: guarded.updatedAt });
});
@@ -51,6 +51,12 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => {
};
});
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -66,6 +72,7 @@ import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAssistantFlags } from "@/lib/feature-flags";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -320,6 +327,39 @@ describe("PATCH /api/auth/me/modules", () => {
});
});
/**
* v1.35.0 — four of the score's pillars come and go with a module
* toggle, so a toggle changes what the composite is made of. The score
* caches hold the old composition and are served for up to an hour, so
* the person who just turned a module off keeps seeing a number
* computed from it. Nothing about that is visible in a test that does
* not assert the eviction, which is why it went unnoticed for so long.
*/
describe("PATCH /api/auth/me/modules — the score caches", () => {
it("evicts the score caches after a module toggle lands", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("does not evict them when the write never landed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE
* the `.strict()` schema parse, so it never trips strict validation; a stale
+7
View File
@@ -35,6 +35,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
@@ -137,6 +138,12 @@ export const PATCH = apiHandler(async (req: Request) => {
});
if ("conflict" in guarded) return guarded.conflict;
// A module toggle can add or remove a Health Score pillar, so the
// cached composite was computed from a composition that no longer
// holds. Without this the score stays stale for up to an hour after a
// change the person made on purpose.
invalidateUserHealthScore(user.id);
const modules = await resolveModuleMap(user.id);
await auditLog("user.modules.update", {
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
// The hourly shape of a cumulative day, read by
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -96,6 +97,7 @@ beforeEach(() => {
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
// The hourly shape of a cumulative day, read by
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -147,6 +147,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
band: "green",
bandSetter: null,
composition: pillars.map((p) => p.id),
configured: false,
noiseFloor: 3,
scoreVersion: 2,
},
@@ -239,6 +239,7 @@ function scoredReport(
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
},
@@ -368,6 +369,7 @@ describe("<HealthScoreCard> pillars", () => {
band: "red",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
},
@@ -673,6 +675,44 @@ describe("<HealthScoreCard> footer", () => {
expect(region).toContain("equal-weighted average");
});
it("states an authored composition in the method footer, and only when the server says so", () => {
// The footer reads the resolved flag off the composite and says one
// sentence. It never names the pillars the person took out — that is
// the settings surface's job — and it never appears for an account
// whose composition is the default one.
const configured = render(
<HealthScoreCard
report={scoredReport({
composite: {
...(scoredReport().composite as Extract<
HealthScoreReport["composite"],
{ status: "ok" }
>),
value: {
...(
scoredReport().composite as Extract<
HealthScoreReport["composite"],
{ status: "ok" }
>
).value,
configured: true,
},
},
})}
/>,
);
expect(anatomyRegion(configured)).toContain(
'data-slot="health-score-configured"',
);
expect(anatomyRegion(configured)).toContain(
"You chose which pillars count toward this score.",
);
const inherited = render(<HealthScoreCard report={scoredReport()} />);
expect(inherited).not.toContain('data-slot="health-score-configured"');
expect(inherited).not.toContain("You chose which pillars count");
});
it("shows the personal weight goal as explicitly unscored context", () => {
const html = render(
<HealthScoreCard
@@ -114,6 +114,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE"],
configured: false,
noiseFloor: 3,
scoreVersion: 2,
},
@@ -594,6 +594,22 @@ export function HealthScoreCard({
noise: composite.value.noiseFloor,
})}
</p>
{/*
* The server resolved whether this composition is the
* person's own; the footer states it and nothing more.
* Which pillars they took out is the settings surface's
* to show, not this one's, and re-deriving the answer
* from a configuration blob here would put a second
* decider behind one sentence.
*/}
{composite.value.configured ? (
<p
data-slot="health-score-configured"
className="text-muted-foreground text-xs"
>
{t("insights.healthScore.configured")}
</p>
) : null}
</>
) : (
<p className="text-muted-foreground text-xs">
@@ -0,0 +1,172 @@
/**
* v1.35.0 — the breadth rule, and the proof that both layers apply the
* same one.
*
* The settings write refuses a selection that cannot produce a score;
* the scorer refuses to produce one from a set that fails the same rule.
* Two layers, on purpose. The danger in two layers is that they drift,
* so the last block below drives the real `computeComposite` and asserts
* it refuses exactly the sets `evaluateScoreBreadth` refuses, with the
* same reason.
*/
import { describe, expect, it } from "vitest";
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { evaluateScoreBreadth } from "../breadth";
import { computeComposite } from "../composite";
import {
SCORE_PILLAR_DOMAINS,
SCORE_PILLAR_IDS,
type PillarValue,
type ScorePillarId,
type ScorePillarResult,
} from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
function pillar(id: ScorePillarId, score = 80): ScorePillarResult {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
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: 1,
deltaEligible: true,
deltaIdentity: id,
};
const result: Derived<PillarValue> = buildOk({
value,
coverage,
confidence,
provenance: {
inputs: [id],
source: "live",
windowDays: 28,
computedAt: NOW.toISOString(),
},
});
return { id, domain: SCORE_PILLAR_DOMAINS[id], result };
}
/** The sets the rule has an opinion about, each with the expected verdict. */
const CASES: Array<{
what: string;
ids: ScorePillarId[];
reason: string | null;
}> = [
{
what: "the two non-physiological pillars alone",
ids: ["ACTIVITY", "WELLBEING"],
reason: "measured_physiological_domain_required",
},
{
what: "activity and wellbeing plus nothing measured",
ids: ["ACTIVITY"],
reason: "measured_physiological_domain_required",
},
{
what: "an empty selection",
ids: [],
reason: "measured_physiological_domain_required",
},
{
what: "the whole cardiometabolic triple, which is one domain",
ids: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
reason: "three_domains_required",
},
{
what: "two domains with a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY"],
reason: "three_domains_required",
},
{
what: "three domains including a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
reason: null,
},
{
what: "three domains where two pillars share one",
ids: ["BLOOD_PRESSURE", "LIPIDS", "ACTIVITY", "SLEEP"],
reason: null,
},
{ what: "every pillar", ids: [...SCORE_PILLAR_IDS], reason: null },
];
describe("evaluateScoreBreadth", () => {
for (const { what, ids, reason } of CASES) {
it(`${reason ? "refuses" : "accepts"} ${what}`, () => {
const verdict = evaluateScoreBreadth(ids);
expect(verdict.ok).toBe(reason === null);
expect(verdict.reason).toBe(reason);
});
}
it("names the missing physiological measure before the missing domain", () => {
// Three domains, none of them a physiological measure is impossible
// with the current registry, so the precedence shows on the set that
// fails both: one non-physiological pillar, one domain.
expect(evaluateScoreBreadth(["ACTIVITY"]).reason).toBe(
"measured_physiological_domain_required",
);
});
it("reports the distinct domains the set spans", () => {
expect(
evaluateScoreBreadth(["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"]).domains,
).toEqual(["cardiometabolic"]);
});
});
describe("the scorer refuses exactly what the write refuses", () => {
for (const { what, ids } of CASES) {
it(`agrees on ${what}`, () => {
const composite = computeComposite({
pillars: ids.map((id) => pillar(id)),
availablePillars: ids,
asOf: NOW,
configured: false,
});
const verdict = evaluateScoreBreadth(ids);
expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient");
if (composite.status === "insufficient") {
expect(composite.reason).toBe(verdict.reason);
}
});
}
});
describe("the pillar-to-domain map", () => {
it("covers every pillar in the registry", () => {
for (const id of SCORE_PILLAR_IDS) {
expect(SCORE_PILLAR_DOMAINS[id]).toBeTruthy();
}
});
it("keeps the three cardiometabolic pillars in one domain", () => {
// The triple-count is the reason a five-pillar selection can still
// fail the rule, so it is pinned rather than left to the map's shape.
expect(SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.GLYCAEMIA).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.LIPIDS).toBe("cardiometabolic");
});
});
@@ -7,6 +7,7 @@ import {
computeComposite,
SCORE_ALGORITHM_CHANGED_AT,
} from "../composite";
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
@@ -82,6 +83,7 @@ function evaluation(
pillars: entries,
availablePillars: available,
asOf,
configured: false,
});
}
@@ -175,7 +177,13 @@ describe("reference-score composite", () => {
],
new Date(NOW.getTime() - 7 * 86_400_000),
);
const report = attachScoreDelta(current, previous, previous, NOW);
const report = attachScoreDelta(
current,
previous,
previous,
NOW,
UNCONFIGURED_SCORE_BOUNDARY,
);
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("composition_changed");
});
@@ -201,7 +209,13 @@ describe("reference-score composite", () => {
[pillar("BLOOD_PRESSURE", 70), pillar("ACTIVITY", 70)],
new Date(NOW.getTime() - 14 * 86_400_000),
);
const report = attachScoreDelta(current, previous, previousPrevious, NOW);
const report = attachScoreDelta(
current,
previous,
previousPrevious,
NOW,
UNCONFIGURED_SCORE_BOUNDARY,
);
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("first_eligibility_window");
});
@@ -229,6 +243,7 @@ describe("reference-score composite", () => {
beforeBoundary,
beforeBoundary,
changedAt,
UNCONFIGURED_SCORE_BOUNDARY,
);
expect(algorithm.deltaReason).toBe("algorithm_changed");
@@ -256,7 +271,13 @@ describe("reference-score composite", () => {
],
new Date(NOW.getTime() - 14 * 86_400_000),
);
const belowNoise = attachScoreDelta(current, previous, stablePrior, NOW);
const belowNoise = attachScoreDelta(
current,
previous,
stablePrior,
NOW,
UNCONFIGURED_SCORE_BOUNDARY,
);
expect(belowNoise.delta).toBeNull();
expect(belowNoise.deltaReason).toBe("below_noise_floor");
});
@@ -294,6 +315,7 @@ describe("reference-score composite", () => {
previous,
stablePrior,
NOW,
UNCONFIGURED_SCORE_BOUNDARY,
[
pillar("BLOOD_PRESSURE", 90),
pillar("ACTIVITY", 90),
@@ -339,6 +361,7 @@ describe("reference-score composite", () => {
previous,
stablePrior,
NOW,
UNCONFIGURED_SCORE_BOUNDARY,
currentPillars,
previousPillars,
);
@@ -0,0 +1,506 @@
/**
* v1.35.0 — what decides the Health Score's composition, pinned.
*
* The module switches used to decide it alone, from a stack of ternaries
* inside the reader that nothing could see and nothing tested. This file
* is the regression test the capability audit found missing, written for
* the NEW semantics: the stored recipe says what counts, the modules say
* what there can be data for, and the score takes the intersection.
*
* Three promises live here.
*
* 1. **Nobody's number moves on upgrade.** Every module combination with
* a never-written recipe resolves to exactly the set the ternaries
* built. The old expression is reproduced verbatim below as
* `compositionBeforeTheSwap`, so this is a real before-and-after
* comparison rather than a restatement of the new code.
*
* 2. **A module that is off removes the pillar entirely.** Not a score,
* not a "waiting for data" line, no row at all. That line belongs to
* a pillar someone selected and could still record today; a module
* they switched off is not waiting for anything.
*
* 3. **One breadth rule, applied twice.** The settings write refuses a
* selection that cannot produce a score; the reader refuses to
* produce one from a set that fails the same rule. The block at the
* bottom drives the REAL reader and asserts its refusal reason is
* `evaluateScoreBreadth`'s verdict on the reader's own resolved set,
* so a second copy of the rule that disagreed would show up here.
*
* The reader runs against a fake Prisma rather than hand-built pillar
* inputs on purpose: the composition is assembled halfway down that
* function, and a test that skips it would prove the wrong end.
*/
import { describe, expect, it } from "vitest";
import type {
GlucoseContext,
MeasurementSource,
MeasurementType,
PrismaClient,
SleepStage,
} from "@/generated/prisma/client";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { evaluateScoreBreadth } from "../breadth";
import { healthScoreConfigFromSelection } from "../config";
import {
MODULE_GATED_PILLARS,
MODULE_SCORE_PILLARS,
pillarsWithModuleData,
type ScoreModuleKey,
type ScoreReaderModules,
} from "../modules";
import { computeUserHealthScore } from "../reader";
import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
const DAY_MS = 86_400_000;
/**
* The `availablePillars` expression exactly as it stood before the swap,
* kept here as the "before" side of the inheritance proof. It must never
* be edited to follow the implementation: the day it disagrees with the
* reader is the day somebody's number moved.
*/
function compositionBeforeTheSwap(
modules: ScoreReaderModules,
): ScorePillarId[] {
return [
"BLOOD_PRESSURE",
...(modules.glucose || modules.labs ? (["GLYCAEMIA"] as const) : []),
"ACTIVITY",
...(modules.sleep ? (["SLEEP"] as const) : []),
"ADIPOSITY",
...(modules.mentalHealth ? (["WELLBEING"] as const) : []),
"FITNESS",
...(modules.labs ? (["LIPIDS"] as const) : []),
];
}
/** All sixteen on/off states of the four modules the reader is handed. */
const MODULE_COMBINATIONS: ScoreReaderModules[] = [0, 1].flatMap((glucose) =>
[0, 1].flatMap((labs) =>
[0, 1].flatMap((sleep) =>
[0, 1].map((mentalHealth) => ({
glucose: glucose === 1,
labs: labs === 1,
sleep: sleep === 1,
mentalHealth: mentalHealth === 1,
})),
),
),
);
const ALL_MODULES_ON: ScoreReaderModules = {
glucose: true,
labs: true,
sleep: true,
mentalHealth: true,
};
function describeModules(modules: ScoreReaderModules): string {
const off = (Object.keys(modules) as ScoreModuleKey[]).filter(
(key) => !modules[key],
);
return off.length === 0 ? "every module on" : `${off.join(" + ")} off`;
}
type MeasurementRow = {
type: MeasurementType;
value: number;
unit: string;
source: MeasurementSource;
measuredAt: Date;
deviceType: string | null;
glucoseContext: GlucoseContext | null;
sleepStage: SleepStage | null;
};
type AssessmentRow = {
instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI";
totalScore: number;
item9Flagged: boolean;
takenAt: Date;
};
function measurement(
type: MeasurementType,
value: number,
measuredAt: Date,
extra: Partial<MeasurementRow> = {},
): MeasurementRow {
return {
type,
value,
unit: "",
source: "MANUAL",
measuredAt,
deviceType: null,
glucoseContext: null,
sleepStage: null,
...extra,
};
}
/**
* An account with enough data for four pillars to score: steps, sleep,
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia, lipids
* and fitness stay honestly absent, which is what lets the same fixture
* show a selected-but-dataless pillar beside a module-withdrawn one.
*/
const MEASUREMENTS: MeasurementRow[] = [
...Array.from({ length: 45 }, (_, index) =>
measurement(
"ACTIVITY_STEPS",
9_000,
new Date(NOW.getTime() - (44 - index) * DAY_MS),
),
),
...Array.from({ length: 45 }, (_, index) =>
measurement(
"SLEEP_DURATION",
450,
new Date(
new Date(NOW.getTime() - (44 - index) * DAY_MS).setUTCHours(6, 0, 0, 0),
),
{ sleepStage: "ASLEEP" },
),
),
measurement("WAIST_TO_HEIGHT", 0.45, new Date(NOW.getTime() - 20 * DAY_MS)),
];
const ASSESSMENTS: AssessmentRow[] = [
{
instrument: "WHO5",
totalScore: 80,
item9Flagged: false,
takenAt: new Date(NOW.getTime() - 20 * DAY_MS),
},
];
function fakePrisma(): PrismaClient {
return {
measurement: {
findMany: async (query: {
where: {
type: { in: MeasurementType[] };
measuredAt: { gte: Date };
glucoseContext?: GlucoseContext;
};
}) =>
MEASUREMENTS.filter(
(row) =>
query.where.type.in.includes(row.type) &&
row.measuredAt >= query.where.measuredAt.gte &&
(query.where.glucoseContext === undefined ||
row.glucoseContext === query.where.glucoseContext),
),
},
mentalHealthAssessment: { findMany: async () => ASSESSMENTS },
labResult: { findMany: async () => [] },
dismissedPriorityItem: { findUnique: async () => null },
} as unknown as PrismaClient;
}
function score(args: {
modules: ScoreReaderModules;
healthScoreConfigJson: unknown;
}) {
return computeUserHealthScore({
prisma: fakePrisma(),
userId: "user-1",
now: NOW,
profile: {
dateOfBirth: null,
gender: null,
heightCm: null,
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
},
modules: args.modules,
healthScoreConfigJson: args.healthScoreConfigJson,
bpTargets: null,
bpEnvelope: null,
bpEnvelopePriorWeek: null,
bpEnvelopePriorTwoWeeks: null,
});
}
/** A stored recipe, built through the same boundary the write route uses. */
function recipe(selection: readonly ScorePillarId[]): unknown {
return healthScoreConfigFromSelection({
selection,
version: 1,
changedAt: new Date("2026-08-01T00:00:00.000Z"),
});
}
describe("the module-to-pillar map", () => {
it("reproduces the ternaries it replaced, for every module combination", () => {
for (const modules of MODULE_COMBINATIONS) {
expect(
pillarsWithModuleData(modules),
`availability for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
}
});
it("keeps glycaemia fed by either glucose or labs alone", () => {
const both = { ...ALL_MODULES_ON, glucose: false, labs: false };
expect(pillarsWithModuleData({ ...both, glucose: true })).toContain(
"GLYCAEMIA",
);
expect(pillarsWithModuleData({ ...both, labs: true })).toContain(
"GLYCAEMIA",
);
expect(pillarsWithModuleData(both)).not.toContain("GLYCAEMIA");
});
it("drops lipids with labs but never with glucose", () => {
expect(
pillarsWithModuleData({ ...ALL_MODULES_ON, labs: false }),
).not.toContain("LIPIDS");
expect(
pillarsWithModuleData({ ...ALL_MODULES_ON, glucose: false }),
).toContain("LIPIDS");
});
it("leaves the four core-domain pillars ungated", () => {
// These read weight, blood pressure, steps and waist — domains with
// no module switch at all, so no module can withdraw their data.
// Pinned rather than derived so an addition to the map that started
// gating one of them has to be a deliberate edit here too.
const ungated = SCORE_PILLAR_IDS.filter(
(id) => !MODULE_GATED_PILLARS.has(id),
);
expect(ungated).toEqual([
"BLOOD_PRESSURE",
"ACTIVITY",
"ADIPOSITY",
"FITNESS",
]);
const allOff: ScoreReaderModules = {
glucose: false,
labs: false,
sleep: false,
mentalHealth: false,
};
expect(pillarsWithModuleData(allOff)).toEqual(ungated);
});
it("names only real modules and real pillars", () => {
for (const key of Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]) {
expect(MODULE_KEYS as readonly string[]).toContain(key);
for (const id of MODULE_SCORE_PILLARS[key]) {
expect(SCORE_PILLAR_IDS as readonly string[]).toContain(id);
}
}
});
});
describe("inheritance: a never-written recipe keeps the number it had", () => {
it("resolves the pre-swap composition for every module combination", async () => {
for (const modules of MODULE_COMBINATIONS) {
const report = await score({ modules, healthScoreConfigJson: null });
expect(
report.pillars.map((pillar) => pillar.id),
`composition for ${describeModules(modules)}`,
).toEqual(compositionBeforeTheSwap(modules));
}
});
it("gives the sleep-module-off account the same composition and the same number as before", async () => {
const modules = { ...ALL_MODULES_ON, sleep: false };
const report = await score({ modules, healthScoreConfigJson: null });
expect(report.pillars.map((pillar) => pillar.id)).toEqual([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]);
expect(report.composite.status).toBe("ok");
if (report.composite.status !== "ok") return;
expect(report.composite.value.composition).toEqual([
"ACTIVITY",
"ADIPOSITY",
"WELLBEING",
]);
// Steps 9,000/day against the 10,000 plateau scores 90, a
// waist-to-height of 0.45 scores 100, a WHO-5 of 80 scores 80, and
// the mean is 90. The number itself is pinned, because "the
// composition is right" and "the number did not move" are two
// different claims and the second one is the promise.
expect(report.composite.value.score).toBe(90);
});
it("cannot be re-included by a recipe that selects everything", async () => {
const modules = { ...ALL_MODULES_ON, sleep: false };
const inherited = await score({ modules, healthScoreConfigJson: null });
const authored = await score({
modules,
healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]),
});
expect(authored.pillars.map((pillar) => pillar.id)).toEqual(
inherited.pillars.map((pillar) => pillar.id),
);
expect(authored.composite.status).toBe("ok");
if (authored.composite.status !== "ok") return;
if (inherited.composite.status !== "ok") return;
expect(authored.composite.value.score).toBe(
inherited.composite.value.score,
);
});
});
describe("a module and a recipe answer different questions", () => {
it("changes what is read, not what is composed, when a module is toggled", async () => {
const selection = [...SCORE_PILLAR_IDS];
const configJson = recipe(selection);
const on = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: configJson,
});
const off = await score({
modules: { ...ALL_MODULES_ON, sleep: false },
healthScoreConfigJson: configJson,
});
// The authored recipe is the same blob either way — the toggle wrote
// nothing to it. What changed is whether sleep is being recorded.
expect(on.pillars.map((pillar) => pillar.id)).toContain("SLEEP");
expect(off.pillars.map((pillar) => pillar.id)).not.toContain("SLEEP");
expect(off.pillars.map((pillar) => pillar.id)).toEqual(
on.pillars.map((pillar) => pillar.id).filter((id) => id !== "SLEEP"),
);
// Sleep really is scoring in this fixture, so the toggle is moving a
// pillar that counts rather than one that was absent anyway. Without
// this the two assertions above would pass on an empty account.
expect(
on.pillars.find((pillar) => pillar.id === "SLEEP")?.result.status,
).toBe("ok");
if (on.composite.status !== "ok" || off.composite.status !== "ok") {
throw new Error("both windows should score");
}
expect(on.composite.value.composition).toContain("SLEEP");
expect(off.composite.value.composition).not.toContain("SLEEP");
});
it("drops a deselected pillar even while its module keeps recording", async () => {
const report = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: recipe(
SCORE_PILLAR_IDS.filter((id) => id !== "SLEEP"),
),
});
expect(report.pillars.map((pillar) => pillar.id)).not.toContain("SLEEP");
expect(report.composite.status).toBe("ok");
if (report.composite.status !== "ok") return;
expect(report.composite.value.composition).not.toContain("SLEEP");
});
});
describe("selected and waiting for data versus switched off", () => {
it("keeps a selected pillar with no data as an honestly absent row", async () => {
// Lipids is selected, labs is on, and there is no lab result. That is
// the "selected, waiting for data" state: the row exists, it just has
// nothing to score yet, and recording a lipid panel would fill it.
const report = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]),
});
const lipids = report.pillars.find((pillar) => pillar.id === "LIPIDS");
expect(lipids).toBeDefined();
expect(lipids?.result.status).toBe("insufficient");
});
it("shows nothing at all for a pillar whose module is off", async () => {
// The decision this test exists to pin: a module the person switched
// off is not "waiting" for anything. Telling them their score wants
// sleep data they chose to stop recording would be nagging in the
// voice of their own settings, and with a never-written recipe the
// word "selected" would be describing a default they never picked.
const report = await score({
modules: { ...ALL_MODULES_ON, sleep: false },
healthScoreConfigJson: recipe([...SCORE_PILLAR_IDS]),
});
expect(report.pillars.some((pillar) => pillar.id === "SLEEP")).toBe(false);
if (report.composite.status !== "ok") return;
expect(report.composite.value.composition).not.toContain("SLEEP");
});
it("never renders a switched-off module's pillar as a failed read", async () => {
// Absence and failure carry different affordances. A module that is
// off is neither: it produces no row, so it can never be mistaken
// for a read that broke.
const report = await score({
modules: { ...ALL_MODULES_ON, mentalHealth: false },
healthScoreConfigJson: null,
});
expect(report.pillars.some((pillar) => pillar.id === "WELLBEING")).toBe(
false,
);
});
});
describe("the breadth rule still refuses at read time", () => {
const NARROW: Array<{ what: string; selection: ScorePillarId[] }> = [
{
what: "a recipe that leaves two scoring domains",
selection: ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE"],
},
{
what: "a recipe with nothing physiological that scores",
selection: ["ACTIVITY", "WELLBEING"],
},
{
what: "a recipe of pillars that have no data at all",
selection: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
},
];
for (const { what, selection } of NARROW) {
it(`refuses ${what}, with the reason the shared rule gives`, async () => {
const report = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: recipe(selection),
});
const scoring = report.pillars
.filter((pillar) => pillar.result.status === "ok")
.map((pillar) => pillar.id);
const verdict = evaluateScoreBreadth(scoring);
expect(verdict.ok).toBe(false);
expect(report.composite.status).toBe("insufficient");
if (report.composite.status !== "insufficient") return;
expect(report.composite.reason).toBe(verdict.reason);
});
}
it("still produces a score when the recipe clears the rule", async () => {
const report = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: recipe([
"ACTIVITY",
"ADIPOSITY",
"WELLBEING",
"SLEEP",
]),
});
expect(report.composite.status).toBe("ok");
});
});
@@ -0,0 +1,556 @@
/**
* The named failure, driven end to end: someone opens the score
* settings, changes which pillars count, and the app tells them their
* health dropped.
*
* The bug is subtle enough that it survives any test which stops at one
* end. All three comparison windows are computed in ONE request under
* whatever recipe is in force at that moment, so both sides carry the
* same composition, `sameComposition` agrees, and the subtraction looks
* legitimate. What actually moved is the construct: the week-over-week
* delta after a reconfiguration averages a different set of pillars
* than the one before it, so a week that read as a couple of points can
* read as fourteen the moment the person leaves the settings page.
*
* So this file refuses to hand a payload from one end to the other. It
* drives the real chain, in the order a request does:
*
* fixture rows
* → `computeUserHealthScore` (the real reader, real pillars)
* → `computeAndRecordUserHealthScore` (the real write path)
* → `buildDashboardSnapshot` (the real snapshot mapping)
* → `resolveDashboardVerdict` (the real `scoreDrop` rung)
* → `loadDailyDigest` → `buildDailyDigest` (the real morning line)
*
* Only the IO around that chain is faked. A version of this test that
* built a `DashboardSnapshot` literal by hand would have been green
* through a release where the assembly between the two ends dropped the
* field, which is a thing that has happened here.
*
* Every case runs twice with the same data: once with the recipe
* changed inside the comparison window, once with it changed a month
* before. The second run is not decoration. It is what proves the
* fixture really does produce a ten-point-plus drop and that the first
* run's silence is the guard doing work rather than an account with
* nothing to say.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/generated/prisma/client";
const DAY_MS = 86_400_000;
const NOW = new Date("2026-08-20T12:00:00.000Z");
/**
* A steady, month-long decline in steps and sleep.
*
* Gradual on purpose. A cliff would blow up each pillar's own standard
* deviation, the noise floor would rise with it, and `below_noise_floor`
* would suppress the delta for a reason that has nothing to do with the
* recipe — a test that passes because the number never moved proves
* nothing about a guard that stops numbers from moving.
*/
const FIXTURE = vi.hoisted(() => {
const DAY = 86_400_000;
const AT = new Date("2026-08-20T12:00:00.000Z").getTime();
const rows: Array<{
type: string;
value: number;
unit: string;
source: string;
measuredAt: Date;
deviceType: string | null;
glucoseContext: string | null;
sleepStage: string | null;
}> = [];
for (let back = 44; back >= 0; back -= 1) {
const measuredAt = new Date(AT - back * DAY);
rows.push({
type: "ACTIVITY_STEPS",
value: 4_200 + back * 260,
unit: "steps",
source: "MANUAL",
measuredAt,
deviceType: null,
glucoseContext: null,
sleepStage: null,
});
rows.push({
type: "SLEEP_DURATION",
value: 300 + back * 5,
unit: "min",
source: "MANUAL",
measuredAt: new Date(new Date(measuredAt).setUTCHours(6, 0, 0, 0)),
deviceType: null,
glucoseContext: null,
sleepStage: "ASLEEP",
});
}
rows.push({
type: "WAIST_TO_HEIGHT",
value: 0.45,
unit: "ratio",
source: "MANUAL",
measuredAt: new Date(AT - 20 * DAY),
deviceType: null,
glucoseContext: null,
sleepStage: null,
});
return rows;
});
/**
* One fake client behind BOTH `@/lib/db` and the snapshot builder's
* injected client, so the reader's measurement reads, the score
* record's insert and the digest's own light reads all see the same
* account.
*/
const writtenRecords = vi.hoisted(() => [] as Array<Record<string, unknown>>);
/** The dismiss ledger, as a set of item keys the account has acknowledged. */
const dismissedKeys = vi.hoisted(() => new Set<string>());
const fakeDb = vi.hoisted(() => {
const rows = FIXTURE;
const written = writtenRecords;
const none = async () => [];
return {
measurement: {
// Both callers' `where` shapes: the score reader asks for a set of
// types, the snapshot's glucose read asks for one.
findMany: async (query: {
where: {
type: string | { in: string[] };
measuredAt?: { gte: Date };
glucoseContext?: string;
};
}) => {
const wanted =
typeof query.where.type === "string"
? [query.where.type]
: query.where.type.in;
const since = query.where.measuredAt?.gte ?? new Date(0);
return rows.filter(
(row) =>
wanted.includes(row.type) &&
row.measuredAt >= since &&
(query.where.glucoseContext === undefined ||
row.glucoseContext === query.where.glucoseContext),
);
},
},
mentalHealthAssessment: { findMany: none },
labResult: { findMany: none },
dismissedPriorityItem: {
// Honours the composite key. A stub that ignored `where` would
// make "dismissed once per recipe version" untestable: every key
// would read as dismissed, or none would.
findUnique: async (query: {
where: { userId_itemKey: { userId: string; itemKey: string } };
}) =>
dismissedKeys.has(query.where.userId_itemKey.itemKey)
? { itemKey: query.where.userId_itemKey.itemKey }
: null,
findMany: none,
},
healthScoreRecord: {
createMany: async (args: { data: Array<Record<string, unknown>> }) => {
written.push(...args.data);
return { count: args.data.length };
},
},
illnessEpisode: { findMany: none },
moodEntry: { findMany: none },
nutrientIntakeDay: { findMany: none },
integrationStatus: { findMany: none },
measurementReminder: { findMany: none },
coachPlan: { findMany: none },
ecgRecording: { findFirst: async () => null },
personalRecord: { findMany: none },
arrivalReaction: { findMany: none },
};
});
vi.mock("@/lib/db", () => ({ prisma: fakeDb }));
// ── Everything around the score, faked at its own seam ──────────────
const computeSummariesSlice = vi.fn();
const probeRollupCoverage = vi.fn();
const isFullyCovered = vi.fn();
const readMoodDayRollups = vi.fn();
const ensureUserMoodRollupsFresh = vi.fn();
const computeBpInTargetFastPath = vi.fn();
const getAssistantFlags = vi.fn();
const hasAnyConfiguredProvider = vi.fn();
const buildMedsTodayBlock = vi.fn();
const resolveModuleMap = vi.fn();
const buildScoreRingsBlock = vi.fn();
const readDashboardSnapshotCached = vi.fn();
const loadIntradayPulse = vi.fn();
const readDayMeanSeries = vi.fn();
vi.mock("@/lib/analytics/summaries-slice", () => ({
computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a),
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a),
isFullyCovered: (...a: unknown[]) => isFullyCovered(...a),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
readMoodDayRollups: (...a: unknown[]) => readMoodDayRollups(...a),
ensureUserMoodRollupsFresh: (...a: unknown[]) =>
ensureUserMoodRollupsFresh(...a),
}));
vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({
computeBpInTargetFastPath: (...a: unknown[]) =>
computeBpInTargetFastPath(...a),
}));
vi.mock("@/lib/feature-flags", () => ({
getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a),
}));
vi.mock("@/lib/ai/provider", () => ({
hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a),
}));
vi.mock("@/lib/dashboard/meds-today", () => ({
buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a),
}));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a),
}));
vi.mock("@/lib/dashboard/score-rings", () => ({
buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a),
}));
vi.mock("@/lib/dashboard/snapshot-read", () => ({
readDashboardSnapshotCached: (...a: unknown[]) =>
readDashboardSnapshotCached(...a),
}));
vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
loadIntradayPulse: (...a: unknown[]) => loadIntradayPulse(...a),
}));
vi.mock("@/lib/insights/derived/baseline", () => ({
readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a),
}));
import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot";
import type { SnapshotUserInput } from "@/lib/dashboard/snapshot";
import { resolveDashboardVerdict } from "@/lib/dashboard/verdict";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import type { ModuleKey } from "@/lib/modules/gate";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { healthScoreConfigFromSelection } from "../config";
import { computeUserHealthScore } from "../reader";
import { SCORE_PILLAR_IDS } from "../types";
/** The recipe as the settings write would have stored it. */
function recipe(changedAt: Date): unknown {
return healthScoreConfigFromSelection({
// The person keeps the four pillars they have data for and drops the
// rest. The composition this resolves to is the same either way in
// this fixture, which is exactly the case a composition comparison
// cannot see.
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
version: 3,
changedAt,
});
}
function moduleMap(): Record<ModuleKey, boolean> {
return Object.fromEntries(MODULE_KEYS.map((key) => [key, true])) as Record<
ModuleKey,
boolean
>;
}
function user(healthScoreConfigJson: unknown): SnapshotUserInput {
return {
id: "user-1",
username: "tester",
displayName: null,
timezone: "UTC",
heightCm: 180,
dateOfBirth: new Date("1990-01-01T00:00:00.000Z"),
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
disableCoach: false,
insightsCachedText: null,
insightsCachedAt: null,
dashboardWidgetsJson: null,
thresholdsJson: null,
healthScoreConfigJson,
} as SnapshotUserInput;
}
/** The reader's own input, as the snapshot builder assembles it. */
function scoreInput(healthScoreConfigJson: unknown) {
return {
userId: "user-1",
now: NOW,
profile: {
dateOfBirth: new Date("1990-01-01T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
},
modules: {
glucose: true,
labs: true,
sleep: true,
mentalHealth: true,
},
healthScoreConfigJson,
bpTargets: null,
bpEnvelope: null,
bpEnvelopePriorWeek: null,
bpEnvelopePriorTwoWeeks: null,
};
}
/**
* The whole chain for one account, from fixture rows to the sentence
* that would ride a morning push.
*/
async function runChain(healthScoreConfigJson: unknown) {
__resetAllCachesForTests();
writtenRecords.length = 0;
const snapshot = await buildDashboardSnapshot(
fakeDb as never,
user(healthScoreConfigJson),
);
const verdict = resolveDashboardVerdict(snapshot, NOW);
readDashboardSnapshotCached.mockResolvedValue({
body: {
...snapshot,
layout: {
...snapshot.layout,
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
},
},
locale: "en",
});
const digest = await loadDailyDigest(
{
id: "user-1",
timezone: "UTC",
morningDigestRefreshedOn: null,
} as unknown as User,
NOW,
);
return { snapshot, verdict, digest, record: writtenRecords[0] ?? null };
}
beforeEach(() => {
// `buildDashboardSnapshot` reads its own clock — it takes no `now`.
// Without a pinned system clock the fixture's dates would sit in the
// future relative to the real one, every window would miss, and the
// suite would go green on an account that has no comparable window
// rather than on a suppressed one. Only `Date` is faked; the SWR cells
// inside the digest loader still need working timers.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
vi.clearAllMocks();
__resetAllCachesForTests();
dismissedKeys.clear();
computeSummariesSlice.mockResolvedValue({
summaries: {},
lastSeenByType: {},
bmi: null,
});
probeRollupCoverage.mockResolvedValue(
new Map([
["WEIGHT", true],
["BLOOD_PRESSURE_SYS", true],
["BLOOD_PRESSURE_DIA", true],
]),
);
isFullyCovered.mockReturnValue(true);
readMoodDayRollups.mockResolvedValue([]);
// No blood pressure in the fixture: the envelope resolves with empty
// windows rather than absent, which is what the builder expects and
// what keeps BLOOD_PRESSURE honestly unscored.
computeBpInTargetFastPath.mockResolvedValue({
path: "live",
last7Days: null,
last30Days: null,
last90Days: null,
last90EarliestAt: null,
last90LatestAt: null,
allTime: null,
priorMonth: null,
priorYear: null,
graded: null,
representative: null,
});
getAssistantFlags.mockResolvedValue({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
});
hasAnyConfiguredProvider.mockResolvedValue(false);
buildMedsTodayBlock.mockResolvedValue({
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
});
resolveModuleMap.mockResolvedValue(moduleMap());
buildScoreRingsBlock.mockResolvedValue([]);
loadIntradayPulse.mockResolvedValue({ tension: null });
readDayMeanSeries.mockResolvedValue({ points: [], source: "none" });
});
afterEach(() => {
vi.useRealTimers();
});
/**
* The control. Same rows, same recipe, changed a month ago — outside
* every comparison window — so nothing suppresses and the account's
* genuine decline is narrated in full.
*/
const SETTLED = recipe(new Date(NOW.getTime() - 30 * DAY_MS));
/** The act: the recipe changed three days ago. */
const JUST_CHANGED = recipe(new Date(NOW.getTime() - 3 * DAY_MS));
describe("a settings change is never narrated as a health event", () => {
it("shows a real ten-point-plus drop when no recipe changed in the window", async () => {
const { snapshot, verdict, digest } = await runChain(SETTLED);
// If this ever stops holding, every assertion in the next test is
// vacuous: an account with no drop to suppress would pass it while
// the guard did nothing at all.
expect(snapshot.healthScore).not.toBeNull();
expect(snapshot.healthScore!.deltaReason).toBeNull();
expect(snapshot.healthScore!.delta).not.toBeNull();
expect(snapshot.healthScore!.delta!).toBeLessThanOrEqual(-10);
expect(verdict.variant).toBe("scoreDrop");
expect(digest.score?.delta).toBe(snapshot.healthScore!.delta);
expect(digest.score?.deltaReason).toBeNull();
});
it("fires no scoreDrop verdict when the recipe changed inside the window", async () => {
// The verdict first and on its own line: this is the sentence the
// person would read, and it is what has to go red when the guard
// goes missing. Asserting the reason before it would fail one step
// earlier and never exercise the rung at all.
const { verdict } = await runChain(JUST_CHANGED);
expect(verdict.variant).not.toBe("scoreDrop");
});
it("suppresses the delta with the reason that names the act", async () => {
const { snapshot } = await runChain(JUST_CHANGED);
expect(snapshot.healthScore).not.toBeNull();
expect(snapshot.healthScore!.deltaReason).toBe("config_changed");
expect(snapshot.healthScore!.delta).toBeNull();
});
it("carries no unexplained drop on the morning line", async () => {
// The digest is the push's only source of words. A delta that
// reached it without its reason is a drop nothing can explain: the
// hero renders the chip on `deltaReason === null` alone.
const { snapshot, digest } = await runChain(JUST_CHANGED);
expect(digest.score).not.toBeNull();
expect(digest.score!.delta).toBeNull();
expect(digest.score!.deltaReason).toBe("config_changed");
// The push body is `digest.line` verbatim. It states today's number
// and nothing about its movement, which is the only honest sentence
// available across a recipe change.
expect(digest.line).toBe(
`Your health score today is ${snapshot.healthScore!.score}.`,
);
expect(digest.line).not.toMatch(/[-−]\s?\d/);
// The rail is the other place a drop could reach a person. Nothing
// in it may be narrating the score either.
expect(digest.worthALook.map((item) => item.kind)).not.toContain(
"score_drop",
);
});
it("still shows the number itself, only never its movement", async () => {
// Suppressing the delta must not suppress the score. A person who
// changed what counts should see what the new recipe says today;
// what they must not see is a subtraction across the change.
const { snapshot, digest } = await runChain(JUST_CHANGED);
expect(snapshot.healthScore!.score).toBeGreaterThan(0);
expect(digest.score!.value).toBe(snapshot.healthScore!.score);
});
it("writes the recipe that produced the day onto the stored record", async () => {
// The supply half of the same change. The row is written by the real
// path inside the same request the assertions above read from, so a
// version that never reached the writer would show up here rather
// than a release later when the seam turns out to be underivable.
const { record } = await runChain(JUST_CHANGED);
expect(record).not.toBeNull();
expect(record!.configVersion).toBe(3);
expect(record!.configChangedAt).toEqual(
new Date(NOW.getTime() - 3 * DAY_MS),
);
});
it("raises the recipe notice once, under a key the person can dismiss once", async () => {
// The reader is driven directly here because the notice never
// reaches the dashboard snapshot — it rides the analytics report.
// Same account, same fixture, same request path the chain above
// used, so the key asserted is the key the ledger will be asked for.
const configured = await computeUserHealthScore(scoreInput(JUST_CHANGED));
const unconfigured = await computeUserHealthScore(scoreInput(null));
expect(configured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:3",
);
expect(configured.algorithmNotice!.dismissed).toBe(false);
// An account that never chose keeps the key its acknowledgement is
// already filed under.
expect(unconfigured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2",
);
dismissedKeys.add("health_score_algorithm:2:config:3");
expect(
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
.dismissed,
).toBe(true);
// The next recipe change raises it again, on its own key, and the
// earlier dismissal does not carry over.
const next = await computeUserHealthScore(
scoreInput(
healthScoreConfigFromSelection({
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
version: 4,
changedAt: new Date(NOW.getTime() - DAY_MS),
}),
),
);
expect(next.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:2:config:4",
);
expect(next.algorithmNotice!.dismissed).toBe(false);
});
it("stamps an honest zero when the account never chose", async () => {
// Not an absence. The first authored recipe has to be recognisable
// as a move from something, and 0 is what it moves from.
const { record } = await runChain(null);
expect(record).not.toBeNull();
expect(record!.configVersion).toBe(0);
expect(record!.configChangedAt).toBeNull();
});
});
@@ -0,0 +1,476 @@
/**
* The per-user recipe boundary, at the three places it exists.
*
* The end-to-end proof that a settings change is never narrated as a
* health event lives in `config-change-is-not-a-health-event.test.ts`,
* over the real snapshot and digest. This file covers what that one
* cannot reach without a fixture per case: the boundary arithmetic's
* edges, the notice key's two dimensions, and the seam's shape.
*
* Each guard here has a counter-case beside it. A boundary that
* suppressed everything would pass every "no false drop" assertion ever
* written and silence every honest week as well, which is the same
* defect wearing the opposite sign.
*/
import { describe, expect, it } from "vitest";
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
import { attachScoreDelta, crossesConfigBoundary } from "../composite";
import {
resolveHealthScoreConfig,
healthScoreConfigFromSelection,
scoreConfigBoundary,
UNCONFIGURED_SCORE_BOUNDARY,
} from "../config";
import {
scoreCompositeSegments,
scorePillarSeries,
scoreSeriesSeams,
type ScoreSeriesRecord,
} from "../series";
import type {
CompositeValue,
PillarValue,
ScorePillarId,
ScorePillarResult,
} from "../types";
const DAY_MS = 86_400_000;
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): ScorePillarResult {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 28,
missing: [],
fullHistoryDays: 28,
});
const value: PillarValue = {
score,
observed: {
value: score,
unit: "score",
label: `${score}`,
asOf: NOW.toISOString(),
sources: ["MANUAL"],
},
reference: {
kind: "guideline-band",
low: 0,
high: 100,
label: "band",
source: "Test 2026",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: id,
};
return {
id,
domain: DOMAIN_BY_PILLAR[id],
result: buildOk({
value,
coverage,
confidence,
provenance: {
inputs: [id],
source: "live",
windowDays: 28,
computedAt: NOW.toISOString(),
},
}),
};
}
/** A composite standing at `score`, computed as of `asOf`. */
function composite(score: number, asOf: Date): Derived<CompositeValue> {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 3,
presentInputs: 3,
historyDays: 28,
missing: [],
fullHistoryDays: 28,
});
return buildOk<CompositeValue>({
value: {
score,
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
},
coverage,
confidence,
provenance: {
inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
source: "live",
windowDays: 28,
computedAt: asOf.toISOString(),
},
});
}
const CURRENT_PILLARS = [
pillar("BLOOD_PRESSURE", 60),
pillar("ACTIVITY", 60),
pillar("SLEEP", 60),
];
const EARLIER_PILLARS = [
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 80),
pillar("SLEEP", 80),
];
/** A twenty-point fall, well clear of the noise floor. */
function fallingDelta(changedAt: Date | null) {
return attachScoreDelta(
composite(60, NOW),
composite(80, new Date(NOW.getTime() - 7 * DAY_MS)),
composite(80, new Date(NOW.getTime() - 14 * DAY_MS)),
NOW,
{
version: changedAt === null ? 0 : 2,
changedAt: changedAt?.toISOString() ?? null,
},
CURRENT_PILLARS,
EARLIER_PILLARS,
);
}
describe("the recipe boundary", () => {
it("suppresses a fall the person's own settings change straddles", () => {
const report = fallingDelta(new Date(NOW.getTime() - 3 * DAY_MS));
expect(report.delta).toBeNull();
expect(report.deltaReason).toBe("config_changed");
});
it("stays out of the way when the recipe has not moved in the window", () => {
// The counter-case. A guard that suppressed here would hide every
// genuine decline an account with a recipe ever has.
const report = fallingDelta(new Date(NOW.getTime() - 30 * DAY_MS));
expect(report.deltaReason).toBeNull();
expect(report.delta).toBe(-20);
});
it("stays out of the way for an account that never chose", () => {
const report = fallingDelta(null);
expect(report.deltaReason).toBeNull();
expect(report.delta).toBe(-20);
});
it("names the act rather than its consequence when both would fire", () => {
// A change that also moved the composition trips two rules at once.
// The order matters to the sentence a person reads: "the included
// pillars changed" describes what happened to the number and leaves
// out who did it, which is the whole confusion this guard exists to
// remove.
const narrower = buildOk<CompositeValue>({
value: {
score: 60,
band: "green",
bandSetter: null,
composition: ["BLOOD_PRESSURE", "ACTIVITY"],
configured: false,
noiseFloor: 1,
scoreVersion: 2,
},
coverage: {
requiredInputs: 3,
presentInputs: 2,
historyDays: 28,
missing: [],
},
confidence: { score: 80, band: "high" },
provenance: {
inputs: ["BLOOD_PRESSURE", "ACTIVITY"],
source: "live",
windowDays: 28,
computedAt: NOW.toISOString(),
},
});
const report = attachScoreDelta(
narrower,
composite(80, new Date(NOW.getTime() - 7 * DAY_MS)),
composite(80, new Date(NOW.getTime() - 14 * DAY_MS)),
NOW,
{
version: 2,
changedAt: new Date(NOW.getTime() - 3 * DAY_MS).toISOString(),
},
CURRENT_PILLARS,
EARLIER_PILLARS,
);
expect(report.deltaReason).toBe("config_changed");
});
it("suppresses at the boundary instant and not one moment past it", () => {
const previousAt = new Date(NOW.getTime() - 7 * DAY_MS);
const boundary = (changedAt: Date) =>
crossesConfigBoundary(
{ version: 1, changedAt: changedAt.toISOString() },
previousAt,
NOW,
);
// A change on the previous window's own instant is not inside the
// window: that window was scored under it.
expect(boundary(previousAt)).toBe(false);
expect(boundary(new Date(previousAt.getTime() + 1))).toBe(true);
expect(boundary(NOW)).toBe(true);
// A change stamped after the current window is a clock that ran
// ahead, not a comparison to break.
expect(boundary(new Date(NOW.getTime() + 1))).toBe(false);
});
it("refuses to read a boundary it cannot parse, rather than suppressing forever", () => {
expect(
crossesConfigBoundary(
{ version: 4, changedAt: "not a date" },
new Date(NOW.getTime() - 7 * DAY_MS),
NOW,
),
).toBe(false);
expect(
crossesConfigBoundary(
{ version: 4, changedAt: null },
new Date(NOW.getTime() - 7 * DAY_MS),
NOW,
),
).toBe(false);
});
it("never resolves a recipe version without the date the guard reads", () => {
// The invariant the boundary rests on. `crossesConfigBoundary` asks
// only about the date, which is safe exactly because a resolved
// config never carries a version without one — an authored recipe
// always has both, and a never-authored one has neither. If a
// resolver path ever produced a version with no date, the guard
// would wave that account's recipe change straight through.
const blobs: unknown[] = [
null,
undefined,
"not an object",
{},
{ excludedPillars: [] },
{ excludedPillars: ["SLEEP"] },
{ excludedPillars: ["SLEEP"], version: 2 },
{ excludedPillars: ["nonsense"], version: 9 },
{ excludedPillars: [], changedAt: "2026-08-01T00:00:00.000Z" },
{ version: 4, changedAt: "2026-08-01T00:00:00.000Z" },
];
for (const blob of blobs) {
const resolved = resolveHealthScoreConfig(blob);
expect(
resolved.version >= 1 || resolved.changedAt === null,
`version without a change date for ${JSON.stringify(blob)}`,
).toBe(true);
}
});
it("carries the resolver's own answer, not a second reading of the blob", () => {
const changedAt = new Date(NOW.getTime() - 2 * DAY_MS);
const stored = healthScoreConfigFromSelection({
selection: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
version: 7,
changedAt,
});
expect(scoreConfigBoundary(resolveHealthScoreConfig(stored))).toEqual({
version: 7,
changedAt: changedAt.toISOString(),
});
expect(scoreConfigBoundary(resolveHealthScoreConfig(null))).toEqual(
UNCONFIGURED_SCORE_BOUNDARY,
);
});
});
describe("the notice key", () => {
it("moves when the method moves and when the recipe moves", () => {
const base = healthScoreNoticeItemKey(2, 3);
expect(healthScoreNoticeItemKey(3, 3)).not.toBe(base);
expect(healthScoreNoticeItemKey(2, 4)).not.toBe(base);
// …and only then. The same pair is the same acknowledgement, so the
// notice is raised once and dismissed once per recipe version.
expect(healthScoreNoticeItemKey(2, 3)).toBe(base);
});
it("leaves a never-configured account's existing key exactly as it was", () => {
// Lengthening this key for everybody would orphan every dismissal
// already in the ledger and re-raise the method notice for the whole
// install base on upgrade.
expect(healthScoreNoticeItemKey(2, 0)).toBe("health_score_algorithm:2");
});
it("stays a key the dismiss route already accepts", () => {
// The whole reason for folding the recipe version into this key
// rather than minting a second notice system: dismissal, the ledger
// prefix and the score-cache eviction all key on the prefix and need
// no change to carry the new notice.
const key = healthScoreNoticeItemKey(2, 5);
expect(key.startsWith("health_score_algorithm:")).toBe(true);
expect(isDismissibleItemKey(key)).toBe(true);
expect(dismissPriorityItemSchema.safeParse({ itemKey: key }).success).toBe(
true,
);
});
});
describe("the seam", () => {
function day(
dayKey: string,
configVersion: number | null,
): ScoreSeriesRecord {
return {
dayKey,
composite: 70,
band: "yellow",
composition: ["ACTIVITY", "SLEEP", "ADIPOSITY"],
pillarScores: { ACTIVITY: 70, SLEEP: 70, ADIPOSITY: 70 },
configVersion,
};
}
it("marks the first day after a change and nothing else", () => {
const points = scoreSeriesSeams([
day("2026-08-17", 1),
day("2026-08-18", 1),
day("2026-08-19", 2),
day("2026-08-20", 2),
]);
expect(points.map((point) => point.seamBreak)).toEqual([
false,
false,
true,
false,
]);
});
it("never marks the first day of the series", () => {
// A beginning is not a break. Flagging it would put a "you changed
// what counts here" marker on the day someone started.
expect(scoreSeriesSeams([day("2026-08-20", 3)])[0].seamBreak).toBe(false);
expect(scoreSeriesSeams([])).toEqual([]);
});
it("stays quiet across days that share a recipe", () => {
const points = scoreSeriesSeams([
day("2026-08-18", 0),
day("2026-08-19", 0),
day("2026-08-20", 0),
]);
expect(points.some((point) => point.seamBreak)).toBe(false);
expect(scoreCompositeSegments(points)).toHaveLength(1);
});
it("orders by day before comparing, so an unordered read cannot invent a seam", () => {
const points = scoreSeriesSeams([
day("2026-08-20", 2),
day("2026-08-18", 1),
day("2026-08-19", 2),
]);
expect(points.map((point) => point.dayKey)).toEqual([
"2026-08-18",
"2026-08-19",
"2026-08-20",
]);
expect(points.map((point) => point.seamBreak)).toEqual([
false,
true,
false,
]);
});
it("treats a day that names no recipe as a break rather than a link", () => {
// Both directions, including two unknowns in a row. Without the
// second case the arm would be doing nothing that plain inequality
// does not already do: `null !== 1` breaks on its own, while
// `null !== null` would quietly claim two rows share a basis that
// neither of them names.
expect(
scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", 1)])[1]
.seamBreak,
).toBe(true);
expect(
scoreSeriesSeams([day("2026-08-19", 1), day("2026-08-20", null)])[1]
.seamBreak,
).toBe(true);
expect(
scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", null)])[1]
.seamBreak,
).toBe(true);
});
it("breaks the composite line and never rejoins it", () => {
const points = scoreSeriesSeams([
day("2026-08-17", 1),
day("2026-08-18", 1),
day("2026-08-19", 2),
day("2026-08-20", 2),
]);
const segments = scoreCompositeSegments(points);
expect(segments).toHaveLength(2);
expect(segments[0].map((point) => point.dayKey)).toEqual([
"2026-08-17",
"2026-08-18",
]);
expect(segments[1].map((point) => point.dayKey)).toEqual([
"2026-08-19",
"2026-08-20",
]);
// Every day is in exactly one segment: no day is repeated to make
// the ends meet, and none is dropped to hide the gap.
expect(segments.flat().map((point) => point.dayKey)).toEqual(
points.map((point) => point.dayKey),
);
});
it("keeps a pillar's own row running straight through the seam", () => {
const points = scoreSeriesSeams([
{ ...day("2026-08-18", 1), pillarScores: { SLEEP: 61 } },
{ ...day("2026-08-19", 2), pillarScores: { SLEEP: 62 } },
{ ...day("2026-08-20", 2), pillarScores: { SLEEP: 63 } },
]);
expect(points[1].seamBreak).toBe(true);
expect(scorePillarSeries(points, "SLEEP")).toEqual([
{ dayKey: "2026-08-18", score: 61 },
{ dayKey: "2026-08-19", score: 62 },
{ dayKey: "2026-08-20", score: 63 },
]);
});
it("leaves out a day the pillar did not count on, rather than scoring it zero", () => {
const points = scoreSeriesSeams([
{ ...day("2026-08-19", 1), pillarScores: { SLEEP: 61 } },
{ ...day("2026-08-20", 1), pillarScores: { ACTIVITY: 70 } },
]);
expect(scorePillarSeries(points, "SLEEP")).toEqual([
{ dayKey: "2026-08-19", score: 61 },
]);
});
});
@@ -0,0 +1,191 @@
/**
* v1.35.0 — the per-user Health Score composition resolver.
*
* The failure this file exists to catch is silent by nature: a resolver
* that folds "never chose" into "chose nothing", or that quietly widens
* an authored recipe, changes people's numbers without anyone seeing a
* stack trace. Every assertion below is about a distinction staying a
* distinction.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_HEALTH_SCORE_CONFIG,
healthScoreConfigFromSelection,
resolveHealthScoreConfig,
} from "../config";
import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
describe("resolveHealthScoreConfig — never chose", () => {
it("treats a NULL row as counting every pillar, with no selection", () => {
const resolved = resolveHealthScoreConfig(null);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.excludedPillars).toEqual([]);
expect(resolved.hasSelection).toBe(false);
expect(resolved.version).toBe(0);
expect(resolved.changedAt).toBeNull();
});
it("treats an empty object and a blob without the key the same way", () => {
for (const raw of [{}, { version: 4, changedAt: "2026-07-31" }]) {
const resolved = resolveHealthScoreConfig(raw);
expect(resolved.hasSelection).toBe(false);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.version).toBe(0);
}
});
it("falls back to counting everything when the blob no longer parses", () => {
for (const raw of [
{ excludedPillars: "SLEEP" },
{ excludedPillars: [17] },
"not an object",
42,
]) {
const resolved = resolveHealthScoreConfig(raw);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.hasSelection).toBe(false);
}
});
it("hands back a copy, so a caller cannot mutate the shared defaults", () => {
const resolved = resolveHealthScoreConfig(null);
resolved.pillars.pop();
expect(DEFAULT_HEALTH_SCORE_CONFIG.pillars).toEqual(ALL);
expect(resolveHealthScoreConfig(null).pillars).toEqual(ALL);
});
});
describe("resolveHealthScoreConfig — never chose is not chose nothing", () => {
it("keeps an empty selection empty instead of reading it as no choice", () => {
const choseNothing = resolveHealthScoreConfig({
excludedPillars: ALL,
version: 2,
changedAt: "2026-07-31T09:00:00.000Z",
});
expect(choseNothing.pillars).toEqual([]);
expect(choseNothing.hasSelection).toBe(true);
expect(choseNothing.version).toBe(2);
const neverChose = resolveHealthScoreConfig(null);
expect(neverChose.pillars).not.toEqual(choseNothing.pillars);
expect(neverChose.hasSelection).not.toBe(choseNothing.hasSelection);
});
it("counts a kept-everything selection as an authored choice", () => {
const kept = resolveHealthScoreConfig({
excludedPillars: [],
version: 1,
changedAt: "2026-07-31T09:00:00.000Z",
});
// Same composition as an account that never chose, different state:
// this person opened the surface and kept every pillar.
expect(kept.pillars).toEqual(ALL);
expect(kept.hasSelection).toBe(true);
expect(kept.version).toBe(1);
expect(resolveHealthScoreConfig(null).hasSelection).toBe(false);
});
});
describe("resolveHealthScoreConfig — the catalogue wins", () => {
it("drops ids the build does not know", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["SLEEP", "MOON_PHASE", "LIPIDS"],
version: 1,
});
expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(resolved.pillars).not.toContain("SLEEP");
expect(resolved.pillars).not.toContain("LIPIDS");
expect(resolved.pillars).toContain("BLOOD_PRESSURE");
});
it("de-duplicates and restores registry order", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["LIPIDS", "SLEEP", "LIPIDS"],
version: 1,
});
expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(resolved.pillars).toEqual(
ALL.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
});
it("partitions the catalogue exactly: counted plus excluded is everything", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["WELLBEING"],
version: 1,
});
expect([...resolved.pillars, ...resolved.excludedPillars].sort()).toEqual(
[...ALL].sort(),
);
});
});
/**
* The reconciliation the deselection list buys: a pillar that did not
* exist when the person chose is absent from their stored list, so it
* counts. The blob below is one written in a smaller world, naming only
* ids that existed then. Every later pillar must arrive counted, and no
* choice the person actually made may be undone in the process.
*/
describe("resolveHealthScoreConfig — a pillar shipped later", () => {
const STORED_IN_A_SMALLER_WORLD = {
excludedPillars: ["SLEEP"],
version: 3,
changedAt: "2026-01-04T08:00:00.000Z",
};
it("counts every pillar the person never had the chance to remove", () => {
const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD);
for (const id of ALL) {
if (id === "SLEEP") continue;
expect(resolved.pillars).toContain(id);
}
});
it("still honours the one removal the person did make", () => {
const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD);
expect(resolved.pillars).not.toContain("SLEEP");
expect(resolved.excludedPillars).toEqual(["SLEEP"]);
expect(resolved.version).toBe(3);
expect(resolved.changedAt).toBe("2026-01-04T08:00:00.000Z");
});
});
describe("healthScoreConfigFromSelection", () => {
it("stores the complement of the selection and round-trips it", () => {
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const blob = healthScoreConfigFromSelection({
selection,
version: 5,
changedAt: new Date("2026-07-31T10:15:00.000Z"),
});
expect(blob.excludedPillars).toEqual(
ALL.filter((id) => !selection.includes(id)),
);
expect(blob.version).toBe(5);
expect(blob.changedAt).toBe("2026-07-31T10:15:00.000Z");
const resolved = resolveHealthScoreConfig(blob);
expect(resolved.pillars).toEqual(selection);
expect(resolved.hasSelection).toBe(true);
expect(resolved.version).toBe(5);
});
it("stores every pillar as excluded when the selection is empty", () => {
const blob = healthScoreConfigFromSelection({
selection: [],
version: 1,
changedAt: new Date("2026-07-31T10:15:00.000Z"),
});
expect(blob.excludedPillars).toEqual(ALL);
expect(resolveHealthScoreConfig(blob).pillars).toEqual([]);
});
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_HEALTH_SCORE_CONFIG, resolveScoreConfigured } from "../config";
import type { ScorePillarId } from "../types";
/**
* The "this score is configured" flag, at the level where its definition
* lives.
*
* The flag was proven end to end over the real snapshot, digest and derived
* wires, which is the right place to prove that it ARRIVES. It is the wrong
* place to be the only proof of what it MEANS: that suite needs Docker, so on
* a machine without a container runtime the definition is unguarded, and a
* change to it passes the whole unit suite in silence. Verified: replacing the
* default side of the comparison with the unnarrowed catalogue, which is the
* exact defect the docblock warns about, left 130 unit tests green.
*
* So the definition gets its own fast test. The rule it pins, in one line:
* configured means the composition the recipe resolves to differs from the one
* the account's defaults would resolve to today, with BOTH sides narrowed by
* the same modules.
*/
const ALL = DEFAULT_HEALTH_SCORE_CONFIG.pillars;
/** Every pillar the modules record, for an account with nothing switched off. */
const RECORDS_EVERYTHING = [...ALL];
describe("resolveScoreConfigured", () => {
it("reads false when the person never chose", () => {
expect(
resolveScoreConfigured({
config: { pillars: [...ALL] },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(false);
});
it("reads true once the person's own recipe narrows the composition", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
});
it("reads false when disabled modules alone narrow the set", () => {
// The person authored nothing. The modules withdrew SLEEP, and both sides
// of the comparison are narrowed by the same modules, so the difference
// cancels. Calling this "configured" would attribute a choice to someone
// who never made one — and it is the failure the docblock names, because
// an unnarrowed default side produces exactly this wrong answer.
const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[];
expect(
resolveScoreConfigured({
config: { pillars: [...ALL] },
recordedPillars: recorded,
}),
).toBe(false);
});
it("reads false when a taken-out pillar is one the modules had already withdrawn", () => {
// The recipe removes SLEEP; the sleep module is off anyway. Nothing about
// the composition changes, so nothing is claimed. The recipe is still
// stored and starts mattering the day the module comes back.
const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[];
const withoutSleep = ALL.filter((id) => id !== "SLEEP");
expect(
resolveScoreConfigured({
config: { pillars: withoutSleep },
recordedPillars: recorded,
}),
).toBe(false);
});
it("reads true when the recipe narrows further than the modules already did", () => {
const recorded = ALL.filter((id) => id !== "SLEEP") as ScorePillarId[];
const alsoWithoutLipids = recorded.filter((id) => id !== "LIPIDS");
expect(
resolveScoreConfigured({
config: { pillars: alsoWithoutLipids },
recordedPillars: recorded,
}),
).toBe(true);
});
it("reads false again when the recipe is undone", () => {
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
expect(
resolveScoreConfigured({
config: { pillars: withoutFitness },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(true);
expect(
resolveScoreConfigured({
config: { pillars: [...ALL] },
recordedPillars: RECORDS_EVERYTHING,
}),
).toBe(false);
});
});
@@ -0,0 +1,411 @@
/**
* The shape of a stored score day, and the promise never to restate one.
*
* The database half of that promise is proved against real Postgres in
* `tests/integration/health-score-record.test.ts`; this file covers the parts
* a database cannot see: what the fingerprint covers, which pillars reach the
* row, and that the write path reaches for an insert that yields rather than
* an update that overwrites.
*/
import { describe, expect, it, vi } from "vitest";
import { HealthScoreBand } from "@/generated/prisma/client";
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { computeComposite } from "../composite";
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
import {
buildHealthScoreRecord,
healthScoreInputFingerprint,
recordHealthScore,
SCORE_BANDS,
} from "../record";
import type {
HealthScoreReport,
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: { deltaIdentity?: string } = {},
): ScorePillarResult {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
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: 1,
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 };
}
/** A report built through the real composite, not a hand-written envelope. */
function report(
pillars: ScorePillarResult[],
available = pillars.map((p) => p.id),
): HealthScoreReport {
const composite = computeComposite({
pillars,
availablePillars: available,
asOf: NOW,
configured: false,
});
return {
composite,
pillars,
delta: null,
deltaReason: "no_previous_window",
scoreVersion: 2,
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
algorithmNotice: null,
};
}
const HEALTHY = [
pillar("BLOOD_PRESSURE", 88),
pillar("SLEEP", 74),
pillar("ADIPOSITY", 66),
];
describe("stored bands", () => {
it("matches the band type the column actually accepts", () => {
// The column is a Postgres enum, so a band the composite can produce and
// the type does not carry is a constraint violation on a write path that
// swallows its own failures — the score would keep rendering and the day
// would silently go unrecorded. Both ends are literals: the generated
// enum on one side, the list the writer uses on the other.
expect(Object.values(HealthScoreBand).sort()).toEqual(
[...SCORE_BANDS].sort(),
);
});
it("lists every band the composite can produce, and nothing else", () => {
const produced = new Set<string>();
for (const score of [95, 85, 65, 55, 40, 20, 0]) {
const built = report([
pillar("BLOOD_PRESSURE", score),
pillar("SLEEP", score),
pillar("ADIPOSITY", score),
]);
if (built.composite.status === "ok")
produced.add(built.composite.value.band);
}
expect([...produced].sort()).toEqual([...SCORE_BANDS].sort());
});
});
describe("input fingerprint", () => {
const base = {
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
pillars: HEALTHY,
};
it("is stable for the same standing", () => {
expect(healthScoreInputFingerprint(base)).toBe(
healthScoreInputFingerprint({ ...base, pillars: [...HEALTHY].reverse() }),
);
});
it("moves when the algorithm version moves", () => {
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
healthScoreInputFingerprint(base),
);
});
it("moves when the composition narrows", () => {
const narrowed = {
...base,
composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[],
};
expect(healthScoreInputFingerprint(narrowed)).not.toBe(
healthScoreInputFingerprint(base),
);
});
it("moves when a different pillar set produces the same numbers", () => {
// Same count, same scores, same order, same scoring modes — only WHICH
// pillars counted differs. Without the pillar id in the hash this is the
// case that reads as an unchanged standing, and it is exactly the change
// a configuration edit makes.
const swapped = {
...base,
composition: ["GLYCAEMIA", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
pillars: [
pillar("GLYCAEMIA", 88, { deltaIdentity: "shared" }),
pillar("SLEEP", 74, { deltaIdentity: "shared" }),
pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }),
],
};
const original = {
...base,
pillars: [
pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "shared" }),
pillar("SLEEP", 74, { deltaIdentity: "shared" }),
pillar("ADIPOSITY", 66, { deltaIdentity: "shared" }),
],
};
expect(healthScoreInputFingerprint(swapped)).not.toBe(
healthScoreInputFingerprint(original),
);
});
it("moves when a counted pillar's score moves", () => {
const shifted = {
...base,
pillars: [pillar("BLOOD_PRESSURE", 87), HEALTHY[1], HEALTHY[2]],
};
expect(healthScoreInputFingerprint(shifted)).not.toBe(
healthScoreInputFingerprint(base),
);
});
it("moves when a pillar's scoring mode changes under an unchanged score", () => {
// The number is identical; what produced it is not. Without the scoring
// identity in the hash the two standings would be indistinguishable, and
// the seam that a scoring-mode change deserves would never be drawn.
const remoded = {
...base,
pillars: [
pillar("BLOOD_PRESSURE", 88, { deltaIdentity: "bp:personal-target" }),
HEALTHY[1],
HEALTHY[2],
],
};
expect(healthScoreInputFingerprint(remoded)).not.toBe(
healthScoreInputFingerprint(base),
);
});
it("ignores a pillar the composite did not count", () => {
const withSpectator = {
...base,
pillars: [...HEALTHY, pillar("ACTIVITY", 12)],
};
expect(healthScoreInputFingerprint(withSpectator)).toBe(
healthScoreInputFingerprint(base),
);
});
});
describe("building the row", () => {
it("records the composite, the band, and every counted pillar", () => {
const built = report(HEALTHY);
const draft = buildHealthScoreRecord(built, {
timezone: "Europe/Berlin",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
});
expect(draft).not.toBeNull();
expect(built.composite.status).toBe("ok");
if (built.composite.status !== "ok") throw new Error("unreachable");
expect(draft!.composite).toBe(built.composite.value.score);
expect(draft!.band).toBe(built.composite.value.band);
expect(draft!.composition).toEqual(built.composite.value.composition);
expect(draft!.pillarScores).toEqual({
BLOOD_PRESSURE: 88,
SLEEP: 74,
ADIPOSITY: 66,
});
expect(draft!.inputFingerprint).toMatch(/^[0-9a-f]{64}$/);
});
it("cuts the day on the account's clock, not on UTC", () => {
// 23:30 UTC on the 20th is already the 21st in Auckland. A row filed under
// the UTC day would put the score on a day the person never saw it.
const lateEvening = new Date("2026-08-20T23:30:00.000Z");
const built = report(HEALTHY);
expect(
buildHealthScoreRecord(built, {
timezone: "Pacific/Auckland",
now: lateEvening,
config: UNCONFIGURED_SCORE_BOUNDARY,
})!.dayKey,
).toBe("2026-08-21");
expect(
buildHealthScoreRecord(built, {
timezone: "UTC",
now: lateEvening,
config: UNCONFIGURED_SCORE_BOUNDARY,
})!.dayKey,
).toBe("2026-08-20");
});
it("leaves out a pillar that was graded but not counted", () => {
// ACTIVITY is available and graded, but the composite is built from the
// three that were eligible. Storing the spectator would report a pillar
// as contributing when it did not.
const built = report(
[...HEALTHY, pillar("ACTIVITY", 12)],
["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"],
);
const draft = buildHealthScoreRecord(built, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
})!;
expect(Object.keys(draft.pillarScores)).not.toContain("ACTIVITY");
expect(draft.composition).not.toContain("ACTIVITY");
});
it("records nothing when the composite did not resolve", () => {
// Two domains: below the breadth floor, so there is no number. Absence has
// to reach the table as absence — a stored 0 would be a day the person is
// told they scored zero.
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
expect(thin.composite.status).toBe("insufficient");
expect(
buildHealthScoreRecord(thin, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
}),
).toBeNull();
});
it("carries the configuration version and its change date through", () => {
const changedAt = "2026-08-19T09:00:00.000Z";
const draft = buildHealthScoreRecord(report(HEALTHY), {
timezone: "UTC",
now: NOW,
config: { version: 4, changedAt },
})!;
expect(draft.configVersion).toBe(4);
expect(draft.configChangedAt).toEqual(new Date(changedAt));
});
it("writes an honest zero for an account that never chose, never a made-up version", () => {
// Version 0 is a real answer — "this person never opened the surface" —
// and it is what the seam derivation compares the first authored day
// against. A null here would make the account's very first recipe look
// like a row whose basis is unknown.
const draft = buildHealthScoreRecord(report(HEALTHY), {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
})!;
expect(draft.configVersion).toBe(0);
expect(draft.configChangedAt).toBeNull();
});
});
describe("writing the row", () => {
/**
* A delegate that answers `createMany` and nothing else. Reaching for
* `upsert` or `update` throws here rather than quietly restating a day, so
* the never-restate promise is a compile-and-run property of this file and
* not a comment.
*/
function delegate(count: number) {
const createMany = vi.fn(async (args: { skipDuplicates?: boolean }) => {
void args;
return { count };
});
return {
db: { healthScoreRecord: { createMany } } as never,
createMany,
};
}
it("inserts with a conflict that yields, and never updates", async () => {
const { db, createMany } = delegate(1);
const outcome = await recordHealthScore(db, "user-1", report(HEALTHY), {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
});
expect(outcome).toBe("written");
expect(createMany).toHaveBeenCalledTimes(1);
expect(createMany.mock.calls[0][0]).toMatchObject({ skipDuplicates: true });
});
it("reports the day as already recorded when the insert yielded", async () => {
const { db } = delegate(0);
expect(
await recordHealthScore(db, "user-1", report(HEALTHY), {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
}),
).toBe("already_recorded");
});
it("never touches the database when there is no score to record", async () => {
const { db, createMany } = delegate(1);
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
expect(
await recordHealthScore(db, "user-1", thin, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
}),
).toBe("no_score");
expect(createMany).not.toHaveBeenCalled();
});
it("reports a failed write as failed, and does not take the read down", async () => {
const db = {
healthScoreRecord: {
createMany: vi.fn(async () => {
throw new Error("connection reset");
}),
},
} as never;
await expect(
recordHealthScore(db, "user-1", report(HEALTHY), {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
}),
).resolves.toBe("failed");
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* v1.35.0 — the breadth rule, in one place.
*
* A composite Health Score exists only when the pillars behind it span
* at least three distinct domains and include at least one physiological
* measure. Before per-user composition the rule lived inside
* `computeComposite` alone, because nothing else could narrow the set.
* Now two layers apply it: the settings write refuses a selection that
* cannot produce a score, and the scorer refuses to produce one anyway.
* That is deliberate defence in depth, and it is only honest if both
* layers ask the same question, so both call this function.
*/
import {
SCORE_PILLAR_DOMAINS,
type ScoreDomain,
type ScorePillarId,
} from "./types";
export const SCORE_MIN_ELIGIBLE_DOMAINS = 3;
/**
* Pillars that rest on a physiological measurement. `ACTIVITY` and
* `WELLBEING` are the only two that do not, which is why a selection of
* those two alone yields no score however many domains it spans.
*/
export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"SLEEP",
"ADIPOSITY",
"FITNESS",
"LIPIDS",
]);
export type ScoreBreadthFailure =
"three_domains_required" | "measured_physiological_domain_required";
/**
* Discriminated on `ok` so a caller that has checked cannot then read a
* reason that is not there, and cannot skip the check to get one.
* `domains` carries the distinct domains the set spans, for a caller
* that has to explain the refusal.
*/
export type ScoreBreadthVerdict =
| { ok: true; reason: null; domains: ScoreDomain[] }
| { ok: false; reason: ScoreBreadthFailure; domains: ScoreDomain[] };
/**
* Judge a pillar set against the breadth rule.
*
* The missing-physiological reason wins when both fail, because it is
* the more specific thing to tell someone: adding a third domain that
* is neither blood pressure nor a lab still leaves them without a score.
*/
export function evaluateScoreBreadth(
ids: readonly ScorePillarId[],
): ScoreBreadthVerdict {
const domains = [...new Set(ids.map((id) => SCORE_PILLAR_DOMAINS[id]))];
const hasPhysiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id));
if (!hasPhysiological) {
return {
ok: false,
reason: "measured_physiological_domain_required",
domains,
};
}
if (domains.length < SCORE_MIN_ELIGIBLE_DOMAINS) {
return { ok: false, reason: "three_domains_required", domains };
}
return { ok: true, reason: null, domains };
}
+77 -23
View File
@@ -6,7 +6,7 @@ import {
import type { Derived } from "@/lib/insights/derived/types";
import {
SCORE_PILLAR_IDS,
orderedUniquePillars,
SCORE_VERSION,
type CompositeValue,
type HealthScoreReport,
@@ -15,18 +15,15 @@ import {
type ScorePillarResult,
} from "./types";
import { mean, provenance, scoreBand } from "./shared";
import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth";
import type { ScoreConfigBoundary } from "./config";
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",
]);
// The rule itself lives in `./breadth`, where the settings write reads it
// too. Re-exported here because this is where every existing caller looks
// for it.
export { SCORE_MIN_ELIGIBLE_DOMAINS };
const BAND_RANK: Record<ScoreBand, number> = {
green: 2,
@@ -38,17 +35,20 @@ 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));
/**
* Whether `availablePillars` is an authored recipe rather than the
* account's defaults. Resolved by `resolveScoreConfigured` before the
* pillar list gets here, because the comparison needs the config and
* the modules and the composite has neither. Required, so a caller
* cannot stay silent and publish `false` for a configured account.
*/
configured: boolean;
}
export function computeComposite(
input: CompositeInput,
): Derived<CompositeValue> {
const available = orderedUnique(input.availablePillars);
const available = orderedUniquePillars(input.availablePillars);
const byId = new Map(input.pillars.map((pillar) => [pillar.id, pillar]));
const eligible = available
.map((id) => byId.get(id))
@@ -57,9 +57,7 @@ export function computeComposite(
);
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 breadth = evaluateScoreBreadth(composition);
const availableDomains = new Set(
input.pillars
.filter((pillar) => available.includes(pillar.id))
@@ -102,13 +100,11 @@ export function computeComposite(
asOf: input.asOf,
});
if (eligibleDomains.size < SCORE_MIN_ELIGIBLE_DOMAINS || !hasPhysiological) {
if (!breadth.ok) {
return buildInsufficient({
coverage,
provenance: compositeProvenance,
reason: !hasPhysiological
? "measured_physiological_domain_required"
: "three_domains_required",
reason: breadth.reason,
});
}
@@ -151,6 +147,7 @@ export function computeComposite(
? composition[bandSetterIndex]
: null,
composition,
configured: input.configured,
noiseFloor,
scoreVersion: SCORE_VERSION,
},
@@ -221,11 +218,61 @@ function comparableDynamicPillars(
};
}
/**
* Whether the person's own recipe changed BETWEEN the two windows being
* compared.
*
* The exact shape of the global algorithm boundary one function below,
* for the same reason and with the same arithmetic: a dated change, and
* a comparison that straddles the date. What makes the per-user version
* necessary at all is that the two windows are computed in ONE request
* under whatever recipe is in force right now. Both sides therefore
* carry the new composition, `sameComposition` agrees, and the
* subtraction looks legitimate. Nothing inside the two composites can
* see the change; only the recipe's own `changedAt` can.
*
* The date is the whole signal, and the boundary's `version` is
* deliberately NOT consulted here. An earlier draft also required
* `version >= 1`, which reads like defence in depth and is not: the
* resolver hands back a version below 1 only on the path that also
* hands back a null date, so no input could ever reach that arm and no
* test could make it fail. A condition nothing can trip is decoration
* on a guard. The version earns its keep elsewhere — it identifies the
* recipe on the stored row and in the notice key — but the question
* "did the recipe move inside this window" has one honest answer and it
* is a date.
*
* No date means nothing to compare, and that does not suppress:
* refusing every delta forever over an unreadable timestamp would trade
* one silent lie for a permanent silence. Both cases — an account that
* never chose, and a stored date this build cannot read — parse to NaN,
* and every comparison against NaN is false, so the refusal falls out
* of the arithmetic. Two early returns stood here and neither could
* change an answer, which makes them a comment wearing an `if`.
*/
export function crossesConfigBoundary(
boundary: ScoreConfigBoundary,
previousAt: Date,
asOf: Date,
): boolean {
const changedAt = Date.parse(boundary.changedAt ?? "");
return asOf.getTime() >= changedAt && previousAt.getTime() < changedAt;
}
/**
* `config` is required and has no default on purpose. Every caller has
* to state which recipe the two windows were computed under, and the
* account that authored none says so with
* {@link UNCONFIGURED_SCORE_BOUNDARY}. A defaulted boundary would let a
* future caller thread nothing and get the pre-v1.35.0 behaviour back —
* a false drop narrated at a person who had just used the settings page.
*/
export function attachScoreDelta(
current: Derived<CompositeValue>,
previous: Derived<CompositeValue>,
previousPrevious: Derived<CompositeValue>,
asOf: Date,
config: ScoreConfigBoundary,
currentPillars: ScorePillarResult[] = [],
previousPillars: ScorePillarResult[] = [],
): Pick<HealthScoreReport, "delta" | "deltaReason"> {
@@ -244,6 +291,13 @@ export function attachScoreDelta(
) {
return { delta: null, deltaReason: "algorithm_changed" };
}
// Ahead of the composition check deliberately. When both fire, the
// person changed what counts and that is the truer sentence; "the
// included pillars changed" would describe the consequence and leave
// out the act.
if (crossesConfigBoundary(config, previousAt, asOf)) {
return { delta: null, deltaReason: "config_changed" };
}
if (!sameComposition(current, previous)) {
return { delta: null, deltaReason: "composition_changed" };
}
+284
View File
@@ -0,0 +1,284 @@
/**
* v1.35.0 — per-user Health Score composition: the stored shape, the
* schema that guards it, and the resolver every reader goes through.
*
* Three things this file is careful about, each because getting it
* wrong is silent rather than loud.
*
* 1. **"Never chose" is not "chose nothing."** `excludedPillars` is
* `.optional()` and deliberately not `.default([])`. An absent key
* means the person never opened the surface, and such an account may
* still inherit a change of defaults later without its number
* jumping. A present list, even a long one, is an authored recipe
* and is never widened behind the person's back. Collapse the two
* and every never-configured account drifts the day defaults move.
*
* 2. **The stored list is a deselection list.** It records what the
* person took out, not what they left in. A pillar shipped after
* they chose is simply not in the list, so it counts, which is the
* "everything eligible" starting point applied to an existing
* account with no backfill. Had the column stored the positive
* selection, a newly shipped pillar would be indistinguishable from
* one the person deliberately removed, and the resolver would have
* to guess. This is the same posture as `modulePreferencesJson`.
*
* 3. **The catalogue wins on read.** Ids outside `SCORE_PILLAR_IDS` are
* dropped rather than trusted, so a retired pillar, a hand-edited
* blob, or a row written by a newer build can never put an unknown
* id in front of the scorer.
*
* The write route (`PATCH /api/auth/me/health-score-config`) speaks the
* positive selection, because that is what a person sees and what the
* score's published composition means. The inversion happens once, at
* the boundary, in `healthScoreConfigFromSelection`.
*
* ## What happens to an account that already exists
*
* Nothing, on the day it upgrades, and that is the whole promise. The
* column starts null, the resolver reads null as version 0 with no
* change date, and version 0 resolves to every pillar — which,
* intersected with what the account's modules record, is exactly the
* composition the module switches were producing before this release.
* The number does not move, no series break is drawn, no notice is
* raised, and no delta is suppressed. There is no backfill because
* there is nothing to back-fill: the inheritance is the resolver's
* behaviour, not a migration's.
*
* The first time someone opens the surface and saves, the version goes
* 0 → 1 and a dated change appears beside it. From that instant three
* things follow, all of them once:
*
* * the week-over-week delta is suppressed for as long as the
* comparison window still straddles the change, with the reason
* `config_changed` — the person changed what counts, and a
* subtraction across that is not a health event;
* * the next stored day is marked as a series break, so the composite
* line is drawn in two segments rather than one line through the
* change (`./series.ts`), while the per-pillar rows run straight
* through it;
* * the notice key gains the recipe version, so the "you changed what
* counts" note is raised once and dismissed once per version, and a
* dismissal of one version never silences the next.
*
* What makes the previously invisible choice visible for the first time
* is the surface itself, which is where the one-time note belongs and
* where it is written. This file owns the behaviour that note describes;
* saying it twice, in two places that can drift, is how a note ends up
* describing something the code stopped doing.
*/
import { z } from "zod";
import {
orderedUniquePillars,
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "./types";
export const scorePillarIdSchema = z.enum(SCORE_PILLAR_IDS);
/**
* The persisted blob.
*
* Deliberately not `.strict()`, and deliberately loose about the ids:
* a row written by a newer build, or carrying a pillar this build has
* retired, must degrade to what this build understands rather than
* failing the parse. A failed parse reads as "never chose", which would
* silently discard an authored recipe over one unrecognised string. The
* ids are filtered against the catalogue on the way out instead. The
* REQUEST body is the strict end: an unknown id there is a 422, because
* a person's client has no business sending one.
*/
export const healthScoreConfigSchema = z.object({
excludedPillars: z.array(z.string().max(64)).max(64).optional(),
/** Per-user recipe version. Increments on every accepted write. */
version: z.number().int().min(1).optional(),
/** When the recipe last changed, ISO 8601. */
changedAt: z.string().max(40).optional(),
});
export type HealthScoreConfig = z.infer<typeof healthScoreConfigSchema>;
/**
* What an account that never chose gets: every pillar counts, no
* recipe version, no change date. Read-time reconciliation is against
* this, so a pillar added to `SCORE_PILLAR_IDS` later appears here and
* in every resolved config without a backfill.
*/
export const DEFAULT_HEALTH_SCORE_CONFIG: Readonly<ResolvedHealthScoreConfig> =
Object.freeze({
pillars: Object.freeze([...SCORE_PILLAR_IDS]) as ScorePillarId[],
excludedPillars: Object.freeze([] as ScorePillarId[]) as ScorePillarId[],
hasSelection: false,
version: 0,
changedAt: null,
});
/** A fresh, caller-owned copy of the defaults. */
function defaults(): ResolvedHealthScoreConfig {
return {
...DEFAULT_HEALTH_SCORE_CONFIG,
pillars: [...DEFAULT_HEALTH_SCORE_CONFIG.pillars],
excludedPillars: [],
};
}
export interface ResolvedHealthScoreConfig {
/**
* Registry-ordered pillars the person counts toward the score. This
* is the composition the scorer starts from; data availability
* narrows it further, and that narrowing is not a choice.
*/
pillars: ScorePillarId[];
/** Registry-ordered pillars the person took out. */
excludedPillars: ScorePillarId[];
/**
* True once a selection has been written. It says the person chose,
* not that their choice differs from the default: an account that
* opened the surface and kept every pillar has a selection.
*/
hasSelection: boolean;
/** Per-user recipe version. 0 while no selection has been written. */
version: number;
/** When the recipe last changed. Null while no selection exists. */
changedAt: string | null;
}
/**
* Resolve a stored `healthScoreConfigJson` blob into the composition
* the scorer and the settings surface both read.
*
* A null row, a non-object, or a shape that no longer parses all
* resolve to the defaults, which is the honest fallback: the account
* counts everything, exactly as it did before the column existed.
*/
export function resolveHealthScoreConfig(
raw: unknown,
): ResolvedHealthScoreConfig {
if (raw == null || typeof raw !== "object") return defaults();
const parsed = healthScoreConfigSchema.safeParse(raw);
if (!parsed.success) return defaults();
if (parsed.data.excludedPillars === undefined) return defaults();
const excludedPillars = orderedUniquePillars(parsed.data.excludedPillars);
const excluded = new Set(excludedPillars);
return {
pillars: SCORE_PILLAR_IDS.filter((id) => !excluded.has(id)),
excludedPillars,
hasSelection: true,
version: parsed.data.version ?? 1,
changedAt: parsed.data.changedAt ?? null,
};
}
/**
* Does this account's score run on an authored recipe?
*
* One boolean, resolved here so that no client ever reads a config blob
* to answer it. The web hero, the iOS widget, the watch complication and
* the public API all read the same value off the wire.
*
* **The definition, in one sentence:** the score is configured when the
* composition it resolves to differs from the composition the account's
* defaults would resolve to today. Both sides are narrowed by the same
* modules, so the comparison is about the person's choice and nothing
* else.
*
* Three consequences, each deliberate:
*
* * An account that opened the surface and kept every pillar is NOT
* configured. It has a selection, and that selection is the default;
* nothing about its number is attributable to an authored recipe.
* This is why `hasSelection` is not this flag — that one answers
* "did the person write something", which is a different question
* and the wrong one to put in front of a widget.
* * An account whose modules alone narrow the set is NOT configured.
* The modules say what is being recorded, not what should count, and
* the defaults resolve through them identically.
* * An account that took out a pillar no module is feeding IS
* configured, because its resolved composition is genuinely
* narrower than the default one. Data availability narrows further
* still, and that narrowing is not a choice — so the comparison runs
* before it, and the flag does not flicker as readings arrive.
*
* The mirror of the third: taking out a pillar whose module is already
* off changes nothing, and the flag says so. The recipe is stored and
* comes back the moment the module returns; today it makes no difference
* to the number, and claiming otherwise would be a surface claiming more
* than it does.
*
* `recordedPillars` is what `pillarsWithModuleData(modules)` returns.
* Required, and deliberately not defaulted: a caller that left it out
* would compare against every pillar in the catalogue and report a
* module-narrowed account as configured, which is precisely the answer
* this flag exists to avoid.
*/
export function resolveScoreConfigured(args: {
config: Pick<ResolvedHealthScoreConfig, "pillars">;
recordedPillars: readonly ScorePillarId[];
}): boolean {
const recorded = new Set(args.recordedPillars);
const resolved = args.config.pillars.filter((id) => recorded.has(id));
const byDefault = DEFAULT_HEALTH_SCORE_CONFIG.pillars.filter((id) =>
recorded.has(id),
);
return (
resolved.length !== byDefault.length ||
resolved.some((id, index) => id !== byDefault[index])
);
}
/**
* The recipe's identity, reduced to the two things a comparison needs:
* which version of the person's own recipe produced a number, and when
* that recipe last moved.
*
* Carried separately from the full resolved config because the delta
* guard and the stored record have no business seeing the pillar list —
* they compare recipes, they do not apply them.
*/
export interface ScoreConfigBoundary {
/** Per-user recipe version. 0 while the person never chose. */
version: number;
/** When the recipe last changed, ISO 8601, or null while it never has. */
changedAt: string | null;
}
/**
* The boundary of an account that never authored a recipe.
*
* Exported so a caller that genuinely has no configuration says so by
* name instead of leaving an argument out. A guard parameter with a
* plausible default is how a caller stays silent and the guard stays
* quiet about it.
*/
export const UNCONFIGURED_SCORE_BOUNDARY: Readonly<ScoreConfigBoundary> =
Object.freeze({ version: 0, changedAt: null });
/** Narrow a resolved config to the identity a comparison reads. */
export function scoreConfigBoundary(
config: Pick<ResolvedHealthScoreConfig, "version" | "changedAt">,
): ScoreConfigBoundary {
return { version: config.version, changedAt: config.changedAt };
}
/**
* Turn the positive selection a person made into the blob the column
* stores. Unknown ids are dropped on the way in, so the stored
* deselection list only ever names pillars this build knows.
*
* `version` is supplied by the caller, which reads the previous version
* off the row and increments it: the recipe version has to be monotonic
* per account for a score record to say which recipe produced it.
*/
export function healthScoreConfigFromSelection(input: {
selection: readonly ScorePillarId[];
version: number;
changedAt: Date;
}): HealthScoreConfig {
const selected = new Set(orderedUniquePillars(input.selection));
return {
excludedPillars: SCORE_PILLAR_IDS.filter((id) => !selected.has(id)),
version: input.version,
changedAt: input.changedAt.toISOString(),
};
}
+4 -2
View File
@@ -6,7 +6,7 @@ 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 { computeAndRecordUserHealthScore } from "./record";
import type { CompositeValue } from "./types";
const DAY_MS = 86_400_000;
@@ -23,6 +23,7 @@ export async function computeHealthScoreDerived(
heightCm: true,
timezone: true,
thresholdsJson: true,
healthScoreConfigJson: true,
},
});
const now = new Date();
@@ -50,7 +51,7 @@ export async function computeHealthScoreDerived(
bpAt(new Date(now.getTime() - 7 * DAY_MS)),
bpAt(new Date(now.getTime() - 14 * DAY_MS)),
]);
const report = await computeUserHealthScore({
const report = await computeAndRecordUserHealthScore({
userId,
now,
profile: {
@@ -67,6 +68,7 @@ export async function computeHealthScoreDerived(
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
},
healthScoreConfigJson: user.healthScoreConfigJson,
bpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
+16 -8
View File
@@ -9,6 +9,7 @@ import { computeGlycaemiaPillar } from "./glycaemia";
import { computeLipidsPillar } from "./lipids";
import { computeSleepPillar } from "./sleep";
import {
SCORE_PILLAR_DOMAINS,
SCORE_PILLAR_IDS,
SCORE_VERSION,
type CompositeValue,
@@ -23,6 +24,12 @@ import { computeWellbeingPillar } from "./wellbeing";
export interface ComputeHealthScoreInput {
asOf: Date;
availablePillars: ScorePillarId[];
/**
* Whether `availablePillars` is an authored recipe rather than the
* account's defaults, resolved by `resolveScoreConfigured`. Rides to
* the composite unchanged; nothing here re-derives it.
*/
configured: boolean;
pillars: PillarInputs;
weightGoal: Derived<WeightGoalValue>;
delta?: number | null;
@@ -35,42 +42,42 @@ export function computeHealthScore(
const byId: Record<ScorePillarId, ScorePillarResult> = {
BLOOD_PRESSURE: {
id: "BLOOD_PRESSURE",
domain: "cardiometabolic",
domain: SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE,
result: computeBloodPressurePillar(input.pillars.BLOOD_PRESSURE),
},
GLYCAEMIA: {
id: "GLYCAEMIA",
domain: "cardiometabolic",
domain: SCORE_PILLAR_DOMAINS.GLYCAEMIA,
result: computeGlycaemiaPillar(input.pillars.GLYCAEMIA),
},
ACTIVITY: {
id: "ACTIVITY",
domain: "activity",
domain: SCORE_PILLAR_DOMAINS.ACTIVITY,
result: computeActivityPillar(input.pillars.ACTIVITY),
},
SLEEP: {
id: "SLEEP",
domain: "sleep",
domain: SCORE_PILLAR_DOMAINS.SLEEP,
result: computeSleepPillar(input.pillars.SLEEP),
},
ADIPOSITY: {
id: "ADIPOSITY",
domain: "adiposity",
domain: SCORE_PILLAR_DOMAINS.ADIPOSITY,
result: computeAdiposityPillar(input.pillars.ADIPOSITY),
},
WELLBEING: {
id: "WELLBEING",
domain: "wellbeing",
domain: SCORE_PILLAR_DOMAINS.WELLBEING,
result: computeWellbeingPillar(input.pillars.WELLBEING),
},
FITNESS: {
id: "FITNESS",
domain: "fitness",
domain: SCORE_PILLAR_DOMAINS.FITNESS,
result: computeFitnessPillar(input.pillars.FITNESS),
},
LIPIDS: {
id: "LIPIDS",
domain: "cardiometabolic",
domain: SCORE_PILLAR_DOMAINS.LIPIDS,
result: computeLipidsPillar(input.pillars.LIPIDS),
},
};
@@ -83,6 +90,7 @@ export function computeHealthScore(
pillars,
availablePillars: input.availablePillars,
asOf: input.asOf,
configured: input.configured,
}),
pillars,
delta: input.delta ?? null,
+80
View File
@@ -0,0 +1,80 @@
/**
* v1.35.0 — which modules put data in front of which score pillar.
*
* Until this release the mapping existed only as ternaries inside the
* reader's `availablePillars` expression, where nothing could read it
* and nothing could test it. It is named here because two questions
* that used to have one answer now have two, and confusing them is how
* a person's number moves without them asking for it:
*
* - the CONFIG says which pillars a person counts toward their score;
* - a MODULE says whether that pillar's data is being recorded at all.
*
* The score takes the intersection. A module that is off cannot be
* overridden by a selection, because turning the module off closes the
* data path itself; and a pillar left out of the config stays out
* however much data flows into it.
*
* `GLYCAEMIA` appears under two modules on purpose: it scores off
* fasting glucose OR HbA1c, so either module alone keeps it fed. That
* was the `glucose || labs` ternary, and it is the entry that would be
* lost by assuming one module per pillar.
*
* `BLOOD_PRESSURE`, `ACTIVITY`, `ADIPOSITY` and `FITNESS` are absent
* from the map, which is the same thing the reader has always done:
* they read core measurement domains that carry no module switch, so no
* module can withdraw their data. Absent means ungated, not forgotten —
* the test beside this file pins the four so a silent addition to the
* map cannot quietly start gating one of them.
*/
import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types";
/**
* The modules the score reader is handed. Every key here gates at least
* one pillar; a module that gates nothing has no reason to be passed.
*/
export interface ScoreReaderModules {
glucose: boolean;
labs: boolean;
sleep: boolean;
mentalHealth: boolean;
}
export type ScoreModuleKey = keyof ScoreReaderModules;
/** Which pillars each module feeds. Registry ids, checked by the tests. */
export const MODULE_SCORE_PILLARS: Record<
ScoreModuleKey,
readonly ScorePillarId[]
> = {
glucose: ["GLYCAEMIA"],
labs: ["GLYCAEMIA", "LIPIDS"],
sleep: ["SLEEP"],
mentalHealth: ["WELLBEING"],
};
const MODULE_KEYS = Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[];
/** Pillars whose data any module can withdraw. Derived, never restated. */
export const MODULE_GATED_PILLARS: ReadonlySet<ScorePillarId> = new Set(
MODULE_KEYS.flatMap((key) => MODULE_SCORE_PILLARS[key]),
);
/**
* The registry-ordered pillars whose data is actually being recorded,
* given the account's modules. This is availability, not choice: it says
* nothing about what the person wants counted, only about what there
* could be data for.
*/
export function pillarsWithModuleData(
modules: ScoreReaderModules,
): ScorePillarId[] {
const fed = new Set<ScorePillarId>();
for (const key of MODULE_KEYS) {
if (!modules[key]) continue;
for (const id of MODULE_SCORE_PILLARS[key]) fed.add(id);
}
return SCORE_PILLAR_IDS.filter(
(id) => !MODULE_GATED_PILLARS.has(id) || fed.has(id),
);
}
+59 -21
View File
@@ -21,12 +21,18 @@ 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 { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
import { annotate, getEvent } from "@/lib/logging/context";
import { ACTIVITY_WINDOW_DAYS } from "./activity";
import { SLEEP_WINDOW_DAYS } from "./sleep";
import { attachScoreDelta } from "./composite";
import {
resolveHealthScoreConfig,
resolveScoreConfigured,
scoreConfigBoundary,
} from "./config";
import { pillarsWithModuleData, type ScoreReaderModules } from "./modules";
import { computeHealthScore } from "./index";
import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types";
import { computeWeightGoal } from "./weight-goal";
@@ -45,19 +51,20 @@ export interface ScoreReaderProfile {
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;
/**
* The raw `User.healthScoreConfigJson` blob. Required, and deliberately
* not defaulted: a caller that forgets it would silently score the
* account on someone else's recipe, and a plausible default is exactly
* how that stays invisible. `null` is a real, meaningful value here —
* it means the person never chose, and every pillar counts.
*/
healthScoreConfigJson: unknown;
bpTargets: BpTargets | null;
bpEnvelope: BpInTargetEnvelope | null;
bpEnvelopePriorWeek: BpInTargetEnvelope | null;
@@ -763,18 +770,36 @@ export async function computeUserHealthScore(
),
]);
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] : []),
];
// v1.35.0 — what counts is the person's choice, narrowed to what there
// can be data for. Before this release the module switches decided the
// composition on their own; now they only say whether a pillar's data
// is being recorded, and the stored recipe says what counts. Two
// things deciding one number is the defect this replaces, so the old
// expression is gone rather than kept beside the new one.
//
// The promise on upgrade is that nobody's number moves, and it holds
// by construction rather than by a backfill: an account that never
// chose resolves to every pillar, and every pillar intersected with
// module availability is precisely the set the ternaries built.
//
// A pillar the config selects but whose module is off drops out here
// and therefore produces no row at all — not a score, not a "waiting
// for data" line. Turning a module off is a deliberate act with its
// own meaning, and asking someone for data they chose to stop
// recording would be nagging in the voice of their own settings. The
// place that pillar comes back is the modules screen.
const config = resolveHealthScoreConfig(input.healthScoreConfigJson);
const recordedPillars = pillarsWithModuleData(input.modules);
const recorded = new Set(recordedPillars);
const availablePillars: ScorePillarId[] = config.pillars.filter((id) =>
recorded.has(id),
);
// v1.35.0 — the one place the "this score is configured" boolean is
// decided, from the same config and the same module map the
// composition came from. Resolved here rather than anywhere a client
// can reach, because a client that re-derived it would be a second
// place deciding one thing.
const configured = resolveScoreConfigured({ config, recordedPillars });
const previousAt = new Date(input.now.getTime() - 7 * DAY_MS);
const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS);
const currentBp = scoreBpEnvelope({
@@ -827,6 +852,7 @@ export async function computeUserHealthScore(
const current = computeHealthScore({
asOf: input.now,
availablePillars,
configured,
pillars: scoreInputsFor({
...common,
asOf: input.now,
@@ -837,6 +863,7 @@ export async function computeUserHealthScore(
const previous = computeHealthScore({
asOf: previousAt,
availablePillars,
configured,
pillars: scoreInputsFor({
...common,
asOf: previousAt,
@@ -847,6 +874,7 @@ export async function computeUserHealthScore(
const previousPrevious = computeHealthScore({
asOf: previousPreviousAt,
availablePillars,
configured,
pillars: scoreInputsFor({
...common,
asOf: previousPreviousAt,
@@ -854,15 +882,25 @@ export async function computeUserHealthScore(
}),
weightGoal,
});
// All three windows above were computed under the recipe in force
// right now, which is exactly why the guard needs the recipe's own
// dated identity: nothing in the three composites can show that last
// Tuesday was scored on a different set. Resolved once, from the same
// blob the composition came from, so the number and the boundary that
// judges its movement can never describe two different recipes.
const delta = attachScoreDelta(
current.composite,
previous.composite,
previousPrevious.composite,
input.now,
scoreConfigBoundary(config),
current.pillars,
previous.pillars,
);
const itemKey = healthScoreAlgorithmItemKey(current.scoreVersion);
const itemKey = healthScoreNoticeItemKey(
current.scoreVersion,
config.version,
);
const dismissal = await capture(
db.dismissedPriorityItem.findUnique({
where: { userId_itemKey: { userId: input.userId, itemKey } },
+326
View File
@@ -0,0 +1,326 @@
/**
* Writing the health score down.
*
* The score is computed live, every time, from the rows standing behind it at
* that moment. That is the right thing for today and it is why nothing older
* than today survives: correct a reading from last month, finish a sync that
* had stalled, or simply add the fourteenth night of sleep that made a pillar
* eligible, and the score the person saw back then can no longer be
* reproduced. There is nothing dishonest about that, but it does mean the
* "history" a surface can offer today is two numbers computed seconds apart.
*
* So each local day's score is written down once, the first time it is
* computed, and then left alone. Three properties follow, and all three are
* deliberate:
*
* * **Append-only.** A second computation of the same local day finds the
* row and does nothing. Not an upsert with a cleverer conflict target,
* not a "refresh if newer" — the number the person saw stays the number
* they saw. Restating it would make the stored series agree with whatever
* the data happens to look like now, which is precisely the property that
* makes it worth nothing.
* * **Never authoritative.** No surface reads this instead of computing.
* The record answers questions about past days; today's number always
* comes from today's rows.
* * **Never fatal.** The write hangs off a read path. A score that renders
* must not fail because a bookkeeping insert did, so the failure is
* annotated onto the wide event and swallowed. The integration round trip
* is what proves the write actually happens; a unit test that only
* watches this function return would be watching it succeed at nothing.
*
* Server-only — reaches for the Prisma client.
*/
import { createHash } from "node:crypto";
import { prisma as globalPrisma } from "@/lib/db";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import { userDayKey } from "@/lib/tz/format";
import {
resolveHealthScoreConfig,
scoreConfigBoundary,
type ScoreConfigBoundary,
} from "./config";
import { computeUserHealthScore, type UserHealthScoreInput } from "./reader";
import type {
HealthScoreReport,
ScoreBand,
ScorePillarId,
ScorePillarResult,
} from "./types";
/**
* The bands a stored row may carry, and the same closed set the migration's
* CHECK constraint spells out.
*
* Pinned to {@link ScoreBand} in BOTH directions at compile time: `satisfies`
* rejects a member that is not a band, and `_BandsAreExhaustive` fails to
* compile if a band is ever added to the union without being added here. A
* one-directional check would let a fourth band reach a column that refuses
* it, which is a runtime constraint violation on a write path that is
* supposed to be unable to fail loudly.
*/
export const SCORE_BANDS = [
"green",
"yellow",
"red",
] as const satisfies readonly ScoreBand[];
type _BandsAreExhaustive =
Exclude<ScoreBand, (typeof SCORE_BANDS)[number]> extends never ? true : never;
const _bandsAreExhaustive: _BandsAreExhaustive = true;
void _bandsAreExhaustive;
/** Fingerprint format version. Bump when the covered set below changes. */
const FINGERPRINT_VERSION = 1;
/** The row a computed report turns into, before it reaches the database. */
export interface HealthScoreRecordDraft {
dayKey: string;
timezone: string;
composite: number;
band: ScoreBand;
scoreVersion: number;
composition: ScorePillarId[];
pillarScores: Record<string, number>;
inputFingerprint: string;
/** The recipe version in force. 0 when the account never chose. */
configVersion: number;
configChangedAt: Date | null;
computedAt: Date;
}
/** What the caller knows that the report itself does not carry. */
export interface HealthScoreRecordContext {
/** The account's IANA zone — the clock the local day is cut on. */
timezone: string;
/** The instant the score was computed for. */
now: Date;
/**
* The recipe this day was scored under: the account's own
* configuration version, and when that configuration last changed.
*
* Required, and deliberately not optional. This is what a later
* reader compares one day against the next to find the seam, so a
* caller allowed to stay silent would write a row that claims a
* recipe it does not know. A never-configured account passes
* `{ version: 0, changedAt: null }` — an honest zero, not an absence.
*/
config: ScoreConfigBoundary;
}
/**
* Every pillar that actually counted, in the composition's own order, with
* the score and the scoring-mode identity that produced it.
*
* Reads the composition rather than the pillar list, because the pillar list
* carries pillars that were graded and then not counted (insufficient
* coverage, module off). Storing those would make the fingerprint move for
* reasons the composite never saw.
*/
function countedPillars(
composition: readonly ScorePillarId[],
pillars: readonly ScorePillarResult[],
): Array<{ id: ScorePillarId; score: number; deltaIdentity: string }> {
const byId = new Map(pillars.map((pillar) => [pillar.id, pillar]));
return composition.flatMap((id) => {
const pillar = byId.get(id);
if (!pillar || pillar.result.status !== "ok") return [];
return [
{
id,
score: pillar.result.value.score,
deltaIdentity: pillar.result.value.deltaIdentity,
},
];
});
}
/**
* SHA-256 over exactly what the stored composite and band depend on.
*
* The covered set is the whole point, so it is spelled out rather than
* "hash the report": the algorithm version, and then one ordered triple per
* counted pillar — its id, its score, and the scoring mode that produced that
* score. Drop any one of those and two genuinely different standings collapse
* onto one fingerprint, which is a key missing a dimension its value depends
* on, a defect class this codebase has shipped before.
*
* The composition is not hashed separately. It IS the ordered list of ids in
* those triples, and an earlier draft carried it twice: a key that no input
* could ever make disagree with its neighbour, and therefore a key no test
* could make fail. A hash term nothing can move is not defence in depth, it
* is decoration on a guard.
*
* What is deliberately NOT covered: the account's configuration version. A
* configuration decides the composition, and the composition is already here.
* A configuration change that leaves the composition alone leaves the score
* alone too, and a fingerprint that moved anyway would report a break that
* did not happen.
*/
export function healthScoreInputFingerprint(input: {
scoreVersion: number;
composition: readonly ScorePillarId[];
pillars: readonly ScorePillarResult[];
}): string {
const counted = countedPillars(input.composition, input.pillars);
const canonical = JSON.stringify({
v: FINGERPRINT_VERSION,
scoreVersion: input.scoreVersion,
pillars: counted.map((pillar) => [
pillar.id,
pillar.score,
pillar.deltaIdentity,
]),
});
return createHash("sha256").update(canonical).digest("hex");
}
/**
* Turn a computed report into the row it would be stored as, or `null` when
* there is nothing to record.
*
* `null` means the composite did not resolve — too few domains, no
* physiological pillar, a read that failed. That is honest absence and it is
* NOT a row: a stored zero, or a stored "insufficient" placeholder, would put
* a value on a day that never had one, and every reader downstream would have
* to know to disbelieve it.
*
* Pure, so the fingerprint and the shape can be tested without a database.
*/
export function buildHealthScoreRecord(
report: HealthScoreReport,
context: HealthScoreRecordContext,
): HealthScoreRecordDraft | null {
if (report.composite.status !== "ok") return null;
const composite = report.composite.value;
const counted = countedPillars(composite.composition, report.pillars);
if (counted.length === 0) return null;
return {
dayKey: userDayKey(context.now, context.timezone),
timezone: context.timezone,
composite: composite.score,
band: composite.band,
scoreVersion: composite.scoreVersion,
composition: [...composite.composition],
pillarScores: Object.fromEntries(
counted.map((pillar) => [pillar.id, pillar.score]),
),
inputFingerprint: healthScoreInputFingerprint({
scoreVersion: composite.scoreVersion,
composition: composite.composition,
pillars: report.pillars,
}),
configVersion: context.config.version,
configChangedAt: context.config.changedAt
? new Date(context.config.changedAt)
: null,
computedAt: context.now,
};
}
/** What the write did, so a caller or a test can tell the three apart. */
export type HealthScoreRecordOutcome =
/** A new day was recorded. */
| "written"
/** The day already had a record, which was left exactly as it was. */
| "already_recorded"
/** Nothing to record — the composite did not resolve. */
| "no_score"
/** The insert failed. Annotated, not thrown. */
| "failed";
type RecordDelegate = Pick<PrismaClient, "healthScoreRecord">;
/**
* Record one local day's score, once.
*
* `createMany` with `skipDuplicates` compiles to `ON CONFLICT DO NOTHING`
* against the `(user_id, day_key)` unique index, so two concurrent requests
* on the same day race into the same outcome the second request would have
* reached anyway: the first row stands. An `upsert` would have been the
* obvious call here and would have been wrong — its update branch is exactly
* the restatement this table must not do.
*/
export async function recordHealthScore(
db: RecordDelegate,
userId: string,
report: HealthScoreReport,
context: HealthScoreRecordContext,
): Promise<HealthScoreRecordOutcome> {
const draft = buildHealthScoreRecord(report, context);
if (!draft) return "no_score";
try {
const written = await db.healthScoreRecord.createMany({
data: [
{
userId,
dayKey: draft.dayKey,
timezone: draft.timezone,
composite: draft.composite,
band: draft.band,
scoreVersion: draft.scoreVersion,
composition: draft.composition,
pillarScores: draft.pillarScores as Prisma.InputJsonValue,
inputFingerprint: draft.inputFingerprint,
configVersion: draft.configVersion,
configChangedAt: draft.configChangedAt,
computedAt: draft.computedAt,
},
],
skipDuplicates: true,
});
if (written.count === 0) return "already_recorded";
annotate({
action: { name: "score.record.write" },
meta: {
dayKey: draft.dayKey,
scoreVersion: draft.scoreVersion,
pillars: draft.composition.length,
},
});
return "written";
} catch (err) {
// A failed bookkeeping write must not take a rendered score down with it,
// but it must not pass for silence either: the warning rides the wide
// event so an operator sees the day that went unrecorded.
getEvent()?.addWarning(
`health score record write failed for ${draft.dayKey}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return "failed";
}
}
/**
* Compute the account's health score and record the day it was computed for.
*
* One entry point rather than a write bolted onto each of the surfaces that
* compute a score. Three callers each remembering to record is three places
* that can independently stop remembering, and the one that forgets leaves a
* gap in the series that nothing reports.
*/
export async function computeAndRecordUserHealthScore(
input: UserHealthScoreInput,
): Promise<HealthScoreReport> {
const report = await computeUserHealthScore(input);
// The recipe is resolved here rather than asked of the caller. Three
// routes call this function and every one of them already holds the
// raw blob, so a parameter would have been three chances to pass
// nothing and write a row that names no recipe. `resolveHealthScoreConfig`
// is pure over that same blob, so the version stamped on the row and
// the composition the reader scored are the same recipe by
// construction, not by two callers agreeing.
await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, {
timezone: input.profile.timezone,
now: input.now,
config: scoreConfigBoundary(
resolveHealthScoreConfig(input.healthScoreConfigJson),
),
});
return report;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Reading a run of stored score days as a series, and refusing to draw
* a line through the place where the recipe changed.
*
* Official statistics has one convention for this and it is small: a
* single machine-readable flag on the FIRST observation after the break
* (Eurostat's `b`, "break occurring when there is a change in the
* standards for defining and observing a variable over time"). This
* module is that flag, plus the two consequences that follow from it.
*
* **The composite line breaks.** Two segments, visibly two. A recipe
* change fails the equal-construct requirement for a legitimate
* equating by construction: the numbers on either side are averages of
* different things. The stored day under the old recipe is an overlap
* point of exactly one, which is enough to date the seam and enough to
* refuse a comparison across it, and not nearly enough to compute an
* adjustment factor that would rescale one segment onto the other. So
* no adjustment is offered. There is no "adjusted" variant of
* {@link scoreCompositeSegments} and there should not be one: splicing
* two methods into one continuous line is the failure that produced
* "years of sleep tracking might be no more useful than common sense",
* and it always starts as a helpful-looking option.
*
* **The per-pillar rows do not break.** A pillar's own 0-100 score is
* graded against its own published reference band and means the same
* thing on both sides of the seam. Only the aggregate changes basis.
* That is why the record stores the per-pillar scores beside the
* composite rather than folding them away, and it is what lets someone
* look at their sleep across a period that spans a recipe change
* without a gap.
*
* The seam is drawn on the recipe's VERSION, not on whether the
* composition visibly moved. A person who deselects a pillar that had
* no data that week changes what counts even though this week's number
* did not budge, and the delta guard suppresses that week from less
* information than this module has. Two surfaces disagreeing about
* whether the same act was an event is worse than either answer, so
* both key on the recipe's identity and describe one event.
*
* Pure. No database, no clock. The trend view that renders the segments
* is deliberately not in this release — the records start empty, so
* there is nothing yet to show — but the flag and the refusal ship with
* the machinery that creates the seam rather than after it.
*/
import type { ScoreBand, ScorePillarId } from "./types";
/**
* One stored score day, in the shape `HealthScoreRecord` rows already
* have, so a Prisma `select` feeds this without a mapping layer in
* between to get wrong.
*/
export interface ScoreSeriesRecord {
/** Local calendar day, `YYYY-MM-DD`. */
dayKey: string;
composite: number;
band: ScoreBand;
composition: string[];
pillarScores: Record<string, number>;
/** The recipe version the day was scored under; null on a row written
* before the writer supplied one. */
configVersion: number | null;
}
export interface ScoreSeriesPoint extends ScoreSeriesRecord {
/**
* Eurostat `b`. True on the first observation computed under a
* different recipe than the observation before it. Never true on the
* first observation of the series: a beginning is not a break.
*/
seamBreak: boolean;
}
/** Ascending by local day. Lexicographic order IS chronological here. */
function byDay(left: ScoreSeriesRecord, right: ScoreSeriesRecord): number {
return left.dayKey < right.dayKey ? -1 : left.dayKey > right.dayKey ? 1 : 0;
}
/**
* Whether two consecutive days sit on different bases.
*
* An unknown version on either side counts as a break. The seam's job
* is to refuse a link it cannot justify, and "these two rows were
* scored under the same recipe" is a claim: a row that does not say
* which recipe produced it cannot support it. Only rows predating the
* writer carry null, so in practice this arm is the answer to a
* question production never asks — which is the point of answering it
* deliberately rather than letting `null !== 0` decide by accident.
*/
function breaksFrom(
previous: ScoreSeriesRecord,
current: ScoreSeriesRecord,
): boolean {
if (previous.configVersion === null || current.configVersion === null) {
return true;
}
return previous.configVersion !== current.configVersion;
}
/**
* Mark every stored day with whether it opens a new basis.
*
* Input order does not matter; the result is ascending by day.
*/
export function scoreSeriesSeams(
records: readonly ScoreSeriesRecord[],
): ScoreSeriesPoint[] {
return [...records].sort(byDay).map((record, index, ordered) => ({
...record,
seamBreak: index > 0 && breaksFrom(ordered[index - 1], record),
}));
}
/**
* The composite series, cut into the segments that may honestly be
* drawn as one line each.
*
* Every seam starts a new segment, and nothing here joins two of them:
* no interpolation across the gap, no offset applied to make the ends
* meet, no single array with a hole in it that a chart library would
* happily bridge. A caller that wants one line gets the segments and
* has to decide, visibly, what to do about the fact that there are two.
*/
export function scoreCompositeSegments(
points: readonly ScoreSeriesPoint[],
): ScoreSeriesPoint[][] {
const segments: ScoreSeriesPoint[][] = [];
for (const point of points) {
if (segments.length === 0 || point.seamBreak) {
segments.push([point]);
continue;
}
segments[segments.length - 1].push(point);
}
return segments;
}
/**
* One pillar's own scores across the whole run, seams and all.
*
* Continuous on purpose. The pillar is graded against its own published
* reference band on both sides of a recipe change, so it stays
* comparable when the average of the pillars does not. Days on which
* the pillar did not count are absent rather than zero.
*/
export function scorePillarSeries(
points: readonly ScoreSeriesPoint[],
pillar: ScorePillarId,
): Array<{ dayKey: string; score: number }> {
return points.flatMap((point) => {
const score = point.pillarScores[pillar];
return typeof score === "number" && Number.isFinite(score)
? [{ dayKey: point.dayKey, score }]
: [];
});
}
+51
View File
@@ -20,6 +20,18 @@ export const SCORE_PILLAR_IDS = [
] as const;
export type ScorePillarId = (typeof SCORE_PILLAR_IDS)[number];
/**
* Sanitise a list of pillar ids: registry order, no duplicates, and
* anything outside the catalogue dropped. Everything that narrows the
* score's composition goes through here, so a stored blob, a caller's
* argument and the scorer itself cannot disagree about what a valid
* pillar set looks like.
*/
export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] {
const present = new Set(ids);
return SCORE_PILLAR_IDS.filter((id) => present.has(id));
}
export type ScoreBand = "green" | "yellow" | "red";
export type ScoreDomain =
| "cardiometabolic"
@@ -29,6 +41,27 @@ export type ScoreDomain =
| "wellbeing"
| "fitness";
/**
* Which domain each pillar speaks to. The composite needs three
* distinct domains, so this map is part of the breadth rule and not
* decoration: three of the eight pillars share the cardiometabolic
* domain, which is why a selection of five pillars can still fail.
*
* One map, read by the scorer when it builds a pillar result and by the
* breadth rule when it judges a selection, so the two can never drift
* into disagreeing about what counts as a distinct area.
*/
export const SCORE_PILLAR_DOMAINS: Record<ScorePillarId, ScoreDomain> = {
BLOOD_PRESSURE: "cardiometabolic",
GLYCAEMIA: "cardiometabolic",
ACTIVITY: "activity",
SLEEP: "sleep",
ADIPOSITY: "adiposity",
WELLBEING: "wellbeing",
FITNESS: "fitness",
LIPIDS: "cardiometabolic",
};
export type PillarReferenceKind =
"clinical-threshold" | "population-percentile" | "guideline-band";
@@ -86,6 +119,14 @@ export interface CompositeValue {
bandSetter: ScorePillarId | null;
/** Registry-ordered eligible pillar ids. Part of the number's identity. */
composition: ScorePillarId[];
/**
* v1.35.0 — the resolved "this score is configured" flag: true when the
* account's composition differs from the one its defaults would resolve
* to today. Server-resolved so no client interprets a config blob, and
* never a per-pillar detail. `resolveScoreConfigured` in `./config`
* owns the definition and the reasons behind it.
*/
configured: boolean;
/** Equal-weighted floor across delta-eligible pillars. */
noiseFloor: number;
scoreVersion: typeof SCORE_VERSION;
@@ -93,6 +134,16 @@ export interface CompositeValue {
export type ScoreDeltaReason =
| "algorithm_changed"
/**
* The person changed their own recipe inside the comparison window.
* Distinct from `algorithm_changed` (we changed the method for
* everybody) and from `composition_changed` (the two windows ended up
* with different pillar sets): here both windows are computed under
* the NEW recipe in one request, so the sets agree and the arithmetic
* looks comparable when it is not. Without this reason the settings
* action reads as a health event.
*/
| "config_changed"
| "composition_changed"
| "first_eligibility_window"
| "below_noise_floor"
+6
View File
@@ -105,6 +105,12 @@ export interface DailyDigestScore {
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
composition?: ScorePillarId[];
/**
* v1.35.0 — the resolved "this score is configured" flag, carried
* straight from the snapshot's health-score block. Omitted by older
* cached digests, like its siblings above.
*/
configured?: boolean;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
+1
View File
@@ -431,6 +431,7 @@ export async function loadDailyDigest(
deltaReason: snapshot.healthScore.deltaReason,
scoreVersion: snapshot.healthScore.scoreVersion,
composition: snapshot.healthScore.composition,
configured: snapshot.healthScore.configured,
}
: null;
+33
View File
@@ -60,3 +60,36 @@ export function sameTimeBaselineItemKey(
export function healthScoreAlgorithmItemKey(scoreVersion: number): string {
return `health_score_algorithm:${scoreVersion}`;
}
/**
* The Health Score notice key for one account, folding in the person's
* OWN recipe version beside the global method version.
*
* Two things can change what a score means, and the person needs
* telling once about each: we change the method (`scoreVersion`), or
* they change what counts (`configVersion`). One ledger key carrying
* only the first would leave a recipe change unannounced, and a key
* carrying only the second would swallow the next method change for
* everybody who ever opened the settings page. Both dimensions ride the
* key, so a move in either raises the notice exactly once and a
* dismissal of one never dismisses the other.
*
* An account that never chose keeps the bare
* `health_score_algorithm:<v>` key unchanged. That is not cosmetic:
* lengthening the key for everyone would leave every existing
* acknowledgement pointing at a key nothing looks up, and the method
* notice would reappear for every account on upgrade.
*
* The `health_score_algorithm:` prefix is deliberate — it is the
* dismiss ledger's namespace (`isDismissibleItemKey`, the dismiss
* route's score-cache eviction), not user-facing copy, and reusing it
* is what lets the recipe notice ride machinery that already dismisses
* and evicts correctly instead of growing a second one beside it.
*/
export function healthScoreNoticeItemKey(
scoreVersion: number,
configVersion: number,
): string {
const base = healthScoreAlgorithmItemKey(scoreVersion);
return configVersion >= 1 ? `${base}:config:${configVersion}` : base;
}
@@ -195,6 +195,7 @@ function baseUser(
insightsCachedAt: null,
dashboardWidgetsJson: null,
thresholdsJson: null,
healthScoreConfigJson: null,
...overrides,
};
}
+1
View File
@@ -79,6 +79,7 @@ export async function readDashboardSnapshotCached(
dashboardWidgetsJson: user.dashboardWidgetsJson,
sourcePriorityJson: user.sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
healthScoreConfigJson: user.healthScoreConfigJson,
};
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific
+21 -2
View File
@@ -77,7 +77,7 @@ import {
buildMedsTodayBlock,
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
import {
buildDashboardBands as buildTargetBands,
type DashboardTargetBands,
@@ -329,6 +329,17 @@ export interface DashboardSnapshotHealthScore {
delta: number | null;
confidence?: DerivedConfidence;
composition?: ScorePillarId[];
/**
* v1.35.0 — the resolved "this score is configured" flag: true when the
* person's own recipe narrows the composition below what the account's
* defaults would resolve to today. Server-resolved so the hero, the
* widget and the complication acknowledge an authored recipe without
* interpreting a config blob; the blob itself never rides this wire,
* and neither does any per-pillar config detail. Optional on the type
* (additive contract) so older cached snapshots stay valid; the live
* builder always sets it.
*/
configured?: boolean;
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
bandSetter?: ScorePillarId | null;
@@ -490,6 +501,12 @@ export interface SnapshotUserInput {
* dashboard reference bands.
*/
thresholdsJson: unknown;
/**
* Raw per-user Health Score recipe. Part of the number's identity, so
* it is required rather than optional: a snapshot built without it
* would show a score composed from someone else's rules.
*/
healthScoreConfigJson: unknown;
}
/** Wall-clock hour in `tz`, used for the server-side greeting. */
@@ -736,7 +753,7 @@ async function buildExtras(
// prior-week delta values). Closes the dashboard-vs-insights divergence.
const scoreSourcePriority = user.sourcePriorityJson ?? null;
const scoreResult = await time("healthScore", () =>
computeUserHealthScore({
computeAndRecordUserHealthScore({
prisma,
userId: user.id,
now,
@@ -754,6 +771,7 @@ async function buildExtras(
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
},
healthScoreConfigJson: user.healthScoreConfigJson,
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
@@ -785,6 +803,7 @@ async function buildExtras(
delta: scoreResult.delta,
confidence: scoreResult.composite.confidence,
composition: scoreResult.composite.value.composition,
configured: scoreResult.composite.value.configured,
deltaReason: scoreResult.deltaReason,
scoreVersion: scoreResult.scoreVersion,
bandSetter: scoreResult.composite.value.bandSetter,
+7
View File
@@ -54,6 +54,12 @@ export const WIPE_MODELS = [
// record and nothing else holds it, so a wipe that leaves it behind leaves
// behind an account's activity pattern.
"IntradayCumulativeProfile",
// The health score as it was shown, one row per local day. Not a cache of
// the live computation — nothing can rebuild a past day once the readings
// behind it have moved — so leaving it behind leaves behind a record of how
// the account was doing, day by day, after a confirmation that said
// otherwise.
"HealthScoreRecord",
"MeasurementReminder",
// ── Medication ──────────────────────────────────────────────────────────
@@ -330,6 +336,7 @@ export const USER_RESET = {
moodTagLayoutJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
globalExcludedInjectionSites: [],
moodReminderEnabled: false,
notificationPrefs: Prisma.DbNull,
@@ -204,6 +204,7 @@ function makePrisma() {
},
]),
},
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
+9
View File
@@ -103,6 +103,12 @@ export const BACKED_UP_MODELS = [
"CustomMetric",
"CustomMetricEntry",
"CorrelationPattern",
// Reads like a derived tier and is not one. The live computation always
// answers for today from today's rows, so once the readings behind a past
// day have been corrected, re-synced or simply grown, nothing can reproduce
// what that day said. The row is the only copy of the number the account
// actually saw, which is exactly the test for BACKED_UP rather than DERIVED.
"HealthScoreRecord",
// ── Activity and nutrition ────────────────────────────────────────────────
"Workout",
@@ -157,6 +163,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
"src/lib/export/records-backup.ts",
"src/lib/export/profile-backup.ts",
"src/lib/export/intraday-profile-backup.ts",
"src/lib/export/health-score-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -164,6 +171,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/app/api/admin/backups/[id]/restore/route.ts",
"src/lib/export/profile-backup.ts",
"src/lib/export/intraday-profile-backup.ts",
"src/lib/export/health-score-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -206,6 +214,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [
"CustomMetric",
"CustomMetricEntry",
"CorrelationPattern",
"HealthScoreRecord",
"Workout",
"NutrientIntakeDay",
"InboundDocument",
+19 -1
View File
@@ -31,6 +31,12 @@ import {
type ProfileBackupCounts,
type ProfileBackupSection,
} from "@/lib/export/profile-backup";
import {
buildHealthScoreBackupSection,
countHealthScoreBackupSection,
type HealthScoreBackupCounts,
type HealthScoreBackupSection,
} from "@/lib/export/health-score-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -42,7 +48,8 @@ export interface FullBackupCounts
extends
RecordsBackupCounts,
ProfileBackupCounts,
IntradayProfileBackupCounts {
IntradayProfileBackupCounts,
HealthScoreBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -117,6 +124,7 @@ export async function buildFullBackupPayload(
records,
profile,
intradayProfiles,
healthScoreRecords,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -225,6 +233,13 @@ export async function buildFullBackupPayload(
buildIntradayProfileBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The score as it was SHOWN on each local day. Reads like a derived tier
// and is not one: today's number always comes from today's rows, so once
// the readings behind a past day have moved, nothing can recompute what
// that day said. The row is the only copy.
buildHealthScoreBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -250,6 +265,7 @@ export async function buildFullBackupPayload(
const recordsSection: RecordsBackupSection = records;
const profileSection: ProfileBackupSection = profile;
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
const healthScoreSection: HealthScoreBackupSection = healthScoreRecords;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -396,6 +412,7 @@ export async function buildFullBackupPayload(
...recordsSection,
...profileSection,
...intradaySection,
...healthScoreSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -418,6 +435,7 @@ export async function buildFullBackupPayload(
...countRecordsBackupSection(records),
...countProfileBackupSection(profile),
...countIntradayProfileBackupSection(intradayProfiles),
...countHealthScoreBackupSection(healthScoreRecords),
},
};
}
+206
View File
@@ -0,0 +1,206 @@
/**
* The stored health score days, with both backup ends in one file.
*
* `HealthScoreRecord` looks derived and is not. The live computation answers
* for today from today's rows, so a day whose readings have since been
* corrected, re-synced or simply extended cannot be recomputed as it stood;
* the row is the only copy of the number the account actually saw. Classifying
* it `DERIVED` and leaving it out would produce a restore that comes back with
* a blank history and reports success, which is the shape of defect the
* nutrient day totals, the custom mood tags and the custom cycle symptoms each
* shipped in turn.
*
* The builder and the restore live together deliberately, the way
* `intraday-profile-backup.ts` and `src/lib/cycle/backup.ts` do: a reader
* asking "is this carried at both ends?" answers it in one file, and a reader
* who greps only the restore ROUTE gets a false negative, because the route
* delegates.
*
* Nothing here is encrypted, so there is no portable/disaster-recovery split
* on the values. The only difference between the two purposes is identity: a
* canonical disaster-recovery payload carries the row id and its timestamps so
* the row comes back as itself, a portable export carries the day alone.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { ScoreBand } from "@/lib/analytics/score/types";
export interface HealthScoreBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One local day's score, exactly as it was shown. */
export interface HealthScoreRecordBackupEntry {
/** Present in canonical DR payloads so the row keeps a stable identity. */
id?: string;
/** Local calendar day (`YYYY-MM-DD`) in `timezone`. */
dayKey: string;
/**
* The zone the day was cut on. Carried for the same reason the intraday
* curves carry theirs: an account that moved zones has days of different
* lengths behind it, and a restore that guessed would make them look
* comparable.
*/
timezone: string;
composite: number;
band: ScoreBand;
scoreVersion: number;
composition: string[];
/** Each counted pillar's own score, keyed by pillar id. */
pillarScores: Record<string, number>;
inputFingerprint: string;
configVersion: number | null;
configChangedAt: string | null;
computedAt: string;
createdAt?: string;
}
export interface HealthScoreBackupSection {
healthScoreRecords: HealthScoreRecordBackupEntry[];
}
export interface HealthScoreBackupCounts {
healthScoreRecords: number;
}
/**
* Read the stored score days as JSON-safe entries.
*
* `pillarScores` arrives as an opaque `JsonValue`; it is narrowed here rather
* than cast, so a row holding something other than a flat id-to-number map
* reaches the payload as an empty map instead of as a shape the restore's
* schema would reject at the worst possible moment.
*/
function readPillarScores(value: Prisma.JsonValue): Record<string, number> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
const out: Record<string, number> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "number") out[key] = entry;
}
return out;
}
/**
* Build the stored-score slice of a user's full backup.
*
* Takes the delegate it uses rather than a whole `PrismaClient`, matching the
* other section builders, so the route's global client and the worker's local
* one share one read.
*/
export async function buildHealthScoreBackupSection(
prisma: Pick<PrismaClient, "healthScoreRecord">,
userId: string,
options: HealthScoreBackupOptions = {},
): Promise<HealthScoreBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
// The table carries no tombstone — a day is written once and never deleted
// short of a wipe — so both purposes read the same set.
const rows = await prisma.healthScoreRecord.findMany({
where: { userId },
orderBy: { dayKey: "desc" },
});
return {
healthScoreRecords: rows.map((row) => ({
...(disasterRecovery
? { id: row.id, createdAt: row.createdAt.toISOString() }
: {}),
dayKey: row.dayKey,
timezone: row.timezone,
composite: row.composite,
band: row.band,
scoreVersion: row.scoreVersion,
composition: row.composition,
pillarScores: readPillarScores(row.pillarScores),
inputFingerprint: row.inputFingerprint,
configVersion: row.configVersion,
configChangedAt: row.configChangedAt?.toISOString() ?? null,
computedAt: row.computedAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countHealthScoreBackupSection(
section: HealthScoreBackupSection,
): HealthScoreBackupCounts {
return { healthScoreRecords: section.healthScoreRecords.length };
}
/** Counts the score-record restore wiped, for the audit trail. */
export interface HealthScoreRestoreCleared {
healthScoreRecords: number;
}
/**
* The slice of a parsed backup this restore consumes.
*
* Required rather than optional: `BackupPayload` defaults it, so every caller
* already satisfies this, and a caller that stops satisfying it fails to
* compile instead of passing `undefined` into a loop that iterates zero times
* and reports success.
*/
export interface HealthScoreRestoreInput {
healthScoreRecords: HealthScoreRecordBackupEntry[];
}
/**
* Re-create the account's stored score days.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. Note the asymmetry with the live write path next door: THAT one
* yields on conflict, because a second computation of a day it already holds
* must change nothing. This one does not, because the file is the authority
* here and a day it carries twice is a corrupt file, not a race. Two entries
* claiming the same local day therefore throw with a sentence rather than
* letting `skipDuplicates` pick a winner and report success.
*/
export async function restoreHealthScoreData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: HealthScoreRestoreInput,
): Promise<HealthScoreRestoreCleared> {
const cleared = await tx.healthScoreRecord.deleteMany({
where: { userId: ownerId },
});
const seen = new Set<string>();
for (const record of payload.healthScoreRecords) {
if (seen.has(record.dayKey)) {
throw new Error(
`Duplicate health score record for ${record.dayKey}. One account has ` +
"at most one score per local day, and picking a winner here would " +
"silently discard the other reading of that day.",
);
}
seen.add(record.dayKey);
}
if (payload.healthScoreRecords.length > 0) {
await tx.healthScoreRecord.createMany({
data: payload.healthScoreRecords.map((record) => ({
...(record.id ? { id: record.id } : {}),
userId: ownerId,
dayKey: record.dayKey,
timezone: record.timezone,
composite: record.composite,
band: record.band,
scoreVersion: record.scoreVersion,
composition: record.composition,
pillarScores: record.pillarScores as Prisma.InputJsonValue,
inputFingerprint: record.inputFingerprint,
configVersion: record.configVersion,
configChangedAt: record.configChangedAt
? new Date(record.configChangedAt)
: null,
computedAt: new Date(record.computedAt),
...(record.createdAt ? { createdAt: new Date(record.createdAt) } : {}),
})),
});
}
return { healthScoreRecords: cleared.count };
}
+30 -1
View File
@@ -69,9 +69,38 @@ const dailyDigestResponse = z
value: z.number(),
band: z.string(),
delta: z.number().nullable(),
configured: z
.boolean()
.optional()
.describe(
"True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Resolved on the server and carried straight from the dashboard snapshot; the configuration itself never rides this wire. Optional so older cached digests without the field stay valid.",
),
// The score's identity fields have ridden this wire since the
// digest first carried a score, but the object is
// `additionalProperties: false` and never declared them — a
// generated strict decoder would have rejected the real payload.
// Declared here rather than left standing beside a new field
// that documents itself correctly.
deltaReason: z
.string()
.nullable()
.optional()
.describe("Why a delta was suppressed; null when it was not."),
scoreVersion: z
.number()
.int()
.positive()
.optional()
.describe("Algorithm version that produced the value."),
composition: z
.array(z.string())
.optional()
.describe("Registry-ordered pillar ids the value was composed of."),
})
.nullable()
.describe("Health score + band + week-over-week delta; null when none."),
.describe(
"Health score + band + week-over-week delta, plus whether the account authored the composition; null when none.",
),
topSignal: z
.object({
sourceMetric: z.string(),
@@ -1109,9 +1109,16 @@ export const dashboardSnapshotResponse = z
]),
)
.optional(),
configured: z
.boolean()
.optional()
.describe(
"True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Server-resolved, so a client never interprets a configuration blob: an account that kept every pillar reads false, and so does one whose disabled modules alone narrow the set. The configuration itself is never on this wire. Optional so older cached snapshots without the field stay valid.",
),
deltaReason: z
.enum([
"algorithm_changed",
"config_changed",
"composition_changed",
"first_eligibility_window",
"below_noise_floor",
+31
View File
@@ -577,6 +577,30 @@ const intradayProfileBackupSchema = z
})
.passthrough();
const healthScoreRecordBackupSchema = z
.object({
id: z.string().min(1).optional(),
dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// The zone the day was cut on. An account that moved zones has days of
// different lengths behind it, and a restore that guessed would make them
// look comparable.
timezone: z.string().min(1),
composite: z.number().int().min(0).max(100),
// The same closed set the column's CHECK constraint carries. A band the
// schema let through and the database refused would fail the whole
// restore transaction on one bad row.
band: z.enum(["green", "yellow", "red"]),
scoreVersion: z.number().int(),
composition: z.array(z.string().min(1)).min(1),
pillarScores: z.record(z.string(), z.number()),
inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
configVersion: z.number().int().nullable().default(null),
configChangedAt: isoDateTime.nullable().default(null),
computedAt: isoDateTime,
createdAt: isoDateTime.optional(),
})
.passthrough();
const appSettingsBackupSchema = z
.object({
id: z.string().min(1),
@@ -849,6 +873,10 @@ export const backupPayloadSchema = z
// The hourly shape of a cumulative day. Defaulted for the same reason as
// the pair above: a file written before the table existed carries no key.
intradayProfiles: z.array(intradayProfileBackupSchema).default([]),
// The score as it was shown, day by day. Defaulted for the same reason as
// the sections above: a file written before the table existed carries no
// key, and an account whose score never resolved writes [].
healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough()
@@ -913,6 +941,8 @@ export interface BackupSummary {
correlationPatterns: number;
/** Stored day curves for the cumulative metrics, across every metric. */
intradayProfiles: number;
/** Local days whose health score was written down as it was shown. */
healthScoreRecords: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -947,6 +977,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
),
correlationPatterns: payload.correlationPatterns.length,
intradayProfiles: payload.intradayProfiles.length,
healthScoreRecords: payload.healthScoreRecords.length,
};
}
@@ -0,0 +1,445 @@
/**
* v1.35.0 — the resolved "this score is configured" flag, over the real
* wires.
*
* The flag is one boolean, and the whole point of resolving it on the
* server is that no client ever interprets a configuration blob to know
* it. So the thing worth testing is not the resolver — it has its own
* unit suite — but the pipe: the selection a person saves through the
* real write route, read back by the real score reader, carried by the
* real dashboard-snapshot builder, the real daily-digest builder and the
* real derived-metric dispatcher, out through the three routes an iOS
* widget, a watch complication and the web hero actually call.
*
* Both ends green with a hand-built object between them shipped an inert
* fix in this repo once. Nothing here is hand-built: every scenario
* writes a row, calls the routes, and reads the field off the response
* body.
*
* **The definition under test.** The score is configured when the
* composition it resolves to differs from the composition the account's
* defaults would resolve to today, both narrowed by the same modules.
* Hence the four negatives below, each of which is a way of ending up at
* the default composition without meaning to say "configured":
*
* - an account that never opened the surface;
* - an account that opened it and kept every pillar (it HAS a
* selection — `hasSelection` is true — and is still not configured);
* - an account whose disabled modules alone narrow the set;
* - an account that took out a pillar its modules had already
* withdrawn, so the composition is unchanged.
*/
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The provider read touches an `app_settings` column that trails the
// production schema in this environment, and no surface under test needs
// a provider: the digest lifts a cached briefing, it never generates one.
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
hasAnyConfiguredProvider: vi.fn().mockResolvedValue(false),
}));
const DAY = 24 * 60 * 60 * 1000;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await resetCaches();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function resetCaches() {
const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache");
__resetAllCachesForTests();
}
async function seedSession(username: string, modulePreferences?: unknown) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
heightCm: 178,
dateOfBirth: new Date("1985-07-09"),
...(modulePreferences === undefined
? {}
: { modulePreferencesJson: modulePreferences as object }),
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
/**
* Three domains' worth of data, which is the floor the composite needs
* to exist at all: blood pressure (cardiometabolic), sleep, and waist
* (adiposity). Every scenario seeds the same shape, so the only thing
* that moves between them is the recipe.
*/
async function seedThreeScorableDomains(userId: string, now: number) {
const prisma = getPrismaClient();
for (let i = 0; i < 20; i++) {
const at = new Date(now - i * DAY);
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_DIA",
value: 78,
unit: "mmHg",
measuredAt: at,
},
});
}
for (let i = 0; i < 14; i++) {
const wake = new Date(now - i * DAY);
wake.setUTCHours(6, 0, 0, 0);
await prisma.measurement.create({
data: {
userId,
type: "SLEEP_DURATION",
value: 450,
unit: "min",
measuredAt: wake,
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
},
});
}
await prisma.measurement.create({
data: {
userId,
type: "WAIST_CIRCUMFERENCE",
value: 82,
unit: "cm",
measuredAt: new Date(now),
},
});
// The snapshot's thick phase — the one that carries the health score —
// stays null until the rollup tier is warm for the dense types. Without
// the fold this test would read `healthScore: null` on every scenario
// and prove nothing about the flag.
const { recomputeUserRollups } =
await import("@/lib/rollups/measurement-rollups");
await recomputeUserRollups(userId, { granularities: ["DAY"] });
}
/** Save a selection through the route a person's client actually calls. */
async function saveSelection(pillars: string[]) {
const { PATCH } = await import("@/app/api/auth/me/health-score-config/route");
const res = await (PATCH as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pillars }),
}),
);
expect(res.status).toBe(200);
await resetCaches();
}
interface Wires {
snapshot: boolean | undefined;
digest: boolean | undefined;
derived: boolean | undefined;
}
/**
* The three payloads a client reads, each fetched through its own route,
* each returning the flag it resolved for itself. Nothing is copied
* between them here — if the snapshot builder forgot to carry the field,
* or the digest forgot to lift it off the snapshot, this returns
* `undefined` for that wire and the assertions fail.
*/
async function readWires(): Promise<Wires> {
await resetCaches();
const { GET: snapshotGet } =
await import("@/app/api/dashboard/snapshot/route");
const snapshotRes = await (
snapshotGet as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/dashboard/snapshot"));
expect(snapshotRes.status).toBe(200);
const snapshotBody = (await snapshotRes.json()) as {
data: { healthScore: { configured?: boolean } | null } | null;
};
expect(snapshotBody.data?.healthScore ?? null).not.toBeNull();
const { GET: digestGet } = await import("@/app/api/daily/digest/route");
const digestRes = await (digestGet as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/daily/digest"),
);
expect(digestRes.status).toBe(200);
const digestBody = (await digestRes.json()) as {
data: { score: { configured?: boolean } | null } | null;
};
expect(digestBody.data?.score ?? null).not.toBeNull();
const { GET: derivedGet } = await import("@/app/api/insights/derived/route");
const derivedRes = await (
derivedGet as (req: NextRequest) => Promise<Response>
)(
new NextRequest(
"http://localhost/api/insights/derived?metric=HEALTH_SCORE",
),
);
expect(derivedRes.status).toBe(200);
const derivedBody = (await derivedRes.json()) as {
data: {
status: string;
value: { configured?: boolean } | null;
} | null;
};
expect(derivedBody.data?.status).toBe("ok");
return {
snapshot: snapshotBody.data!.healthScore!.configured,
digest: digestBody.data!.score!.configured,
derived: derivedBody.data!.value!.configured,
};
}
/** Every wire says the same thing, and says it as a boolean. */
function expectEveryWire(wires: Wires, expected: boolean) {
expect(wires.snapshot).toBe(expected);
expect(wires.digest).toBe(expected);
expect(wires.derived).toBe(expected);
}
describe("the resolved configured flag, on every wire that carries it", () => {
it("reads false for an account that never chose", async () => {
const user = await seedSession("cfg-never-chose");
await seedThreeScorableDomains(user.id, Date.now());
expectEveryWire(await readWires(), false);
});
it("reads false for an account that opened the surface and kept every pillar", async () => {
// The distinction this pins: the resolver's `hasSelection` is TRUE
// here, because the person wrote something. The wire flag is false,
// because what they wrote is the default. A client reading
// `hasSelection` would tell them their score is configured when
// nothing about it differs from an untouched account's.
const user = await seedSession("cfg-kept-everything");
await seedThreeScorableDomains(user.id, Date.now());
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]);
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { healthScoreConfigJson: true },
});
// The write really happened — otherwise this whole case would be
// testing the never-chose path again under a different name.
expect(stored.healthScoreConfigJson).toMatchObject({
excludedPillars: [],
version: 1,
});
expectEveryWire(await readWires(), false);
});
it("reads false when disabled modules alone narrow the set", async () => {
// Glucose, labs and mental health off: GLYCAEMIA, LIPIDS and
// WELLBEING can carry no data, so five pillars are eligible instead
// of eight. The person authored nothing, and both sides of the
// comparison narrow identically.
const user = await seedSession("cfg-modules-narrow", {
glucose: false,
labs: false,
mentalHealth: false,
});
await seedThreeScorableDomains(user.id, Date.now());
expectEveryWire(await readWires(), false);
});
it("reads false when a taken-out pillar is one the modules had already withdrawn", async () => {
// The recipe is stored and comes back the moment the module does.
// Today it makes no difference to the composition, so the flag says
// so rather than claiming an authorship the number does not show.
const user = await seedSession("cfg-redundant-exclusion", {
mentalHealth: false,
});
await seedThreeScorableDomains(user.id, Date.now());
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"FITNESS",
"LIPIDS",
]);
expectEveryWire(await readWires(), false);
});
it("reads true once the person's own recipe narrows the composition", async () => {
const user = await seedSession("cfg-authored");
await seedThreeScorableDomains(user.id, Date.now());
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
]);
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { healthScoreConfigJson: true },
});
expect(stored.healthScoreConfigJson).toMatchObject({
excludedPillars: ["FITNESS"],
});
expectEveryWire(await readWires(), true);
});
it("turns true the moment a recipe is saved and false again when it is undone", async () => {
// The same account, read three times across two saves. A flag that
// is computed once and cached under a key that does not include the
// recipe would pass every case above and fail this one.
const user = await seedSession("cfg-round-trip");
await seedThreeScorableDomains(user.id, Date.now());
expectEveryWire(await readWires(), false);
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
]);
expectEveryWire(await readWires(), true);
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"FITNESS",
"LIPIDS",
]);
expectEveryWire(await readWires(), false);
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { healthScoreConfigJson: true },
});
expect(stored.healthScoreConfigJson).toMatchObject({ version: 2 });
});
it("never puts the configuration itself on any of the three wires", async () => {
// iOS consumes the resolved flag and nothing else. A per-pillar
// configuration detail on one of these payloads would invite a
// client to re-derive the answer, which is the boundary this whole
// field exists to hold.
const user = await seedSession("cfg-no-blob");
await seedThreeScorableDomains(user.id, Date.now());
await saveSelection([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
]);
const { GET: snapshotGet } =
await import("@/app/api/dashboard/snapshot/route");
const snapshotRes = await (
snapshotGet as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/dashboard/snapshot"));
const snapshotText = await snapshotRes.text();
const { GET: digestGet } = await import("@/app/api/daily/digest/route");
const digestRes = await (digestGet as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/daily/digest"),
);
const digestText = await digestRes.text();
const { GET: derivedGet } =
await import("@/app/api/insights/derived/route");
const derivedRes = await (
derivedGet as (req: NextRequest) => Promise<Response>
)(
new NextRequest(
"http://localhost/api/insights/derived?metric=HEALTH_SCORE",
),
);
const derivedText = await derivedRes.text();
for (const body of [snapshotText, digestText, derivedText]) {
expect(body).toContain('"configured"');
expect(body).not.toContain("excludedPillars");
expect(body).not.toContain("hasSelection");
expect(body).not.toContain("healthScoreConfig");
}
});
});
@@ -0,0 +1,443 @@
/**
* The persisted health score day, end to end against real Postgres.
*
* A mocked Prisma proves nothing here. Every claim this file makes is about
* what is actually in the table after a real request: that computing a score
* writes the day down, that computing it again under different readings
* leaves the earlier day exactly as it stood, and that the row survives a
* backup and a restore rather than coming back blank while the restore
* reports success.
*
* The three lifecycle paths a new user-scoped table has to join, and where
* each is proved:
*
* - delete-all-data — `tests/integration/settings-data-wipe.test.ts` seeds
* every table from `information_schema` and asserts every model in the
* wipe plan is empty afterwards, so this table is covered there the
* moment it enters the plan. `src/__tests__/data-wipe-completeness.test.ts`
* is what forces it into the plan in the first place.
* - the backup writer — proved below over `buildFullBackupPayload`, which
* is the builder the weekly worker and both export routes share.
* - the restore path — proved below over the real admin restore ROUTE, not
* over the helper it delegates to. That distinction is the whole point:
* the structural guard in `backup-plan-classification.test.ts` reads the
* declared restore FILES, so a helper that exists and is never called
* passes it. Only a round trip through the route catches that.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { caches } from "@/lib/cache/server-cache";
import { encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
invalidateUserHealthScore: vi.fn(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
const DAY = 24 * 60 * 60 * 1000;
interface AnalyticsEnvelope {
data: {
healthScore: {
composite: {
status: "ok" | "insufficient";
value?: { score: number; band: string; composition: string[] };
};
} | null;
} | null;
}
async function seedSession(username: string, role: "USER" | "ADMIN" = "USER") {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role,
heightCm: 178,
dateOfBirth: new Date("1985-07-09"),
// Pinned so the local day the row is filed under is a fact of the
// fixture rather than a property of the machine running the suite.
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
/** BP needs twelve paired readings inside the trailing 90-day window. */
async function seedBp(userId: string, now: number, days: number, sys: number) {
const prisma = getPrismaClient();
for (let i = 0; i < days; i++) {
const at = new Date(now - i * DAY);
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_SYS",
value: sys,
unit: "mmHg",
measuredAt: at,
},
});
await prisma.measurement.create({
data: {
userId,
type: "BLOOD_PRESSURE_DIA",
value: 78,
unit: "mmHg",
measuredAt: at,
},
});
}
}
/** SLEEP needs fourteen distinct wake-day nights. */
async function seedSleep(userId: string, now: number, nights: number) {
const prisma = getPrismaClient();
for (let i = 0; i < nights; i++) {
const wake = new Date(now - i * DAY);
wake.setUTCHours(6, 0, 0, 0);
await prisma.measurement.create({
data: {
userId,
type: "SLEEP_DURATION",
value: 450,
unit: "min",
measuredAt: wake,
sleepStage: "ASLEEP",
source: "APPLE_HEALTH",
},
});
}
}
/** ADIPOSITY needs one waist reading against the seeded height. */
async function seedWaist(userId: string, now: number) {
await getPrismaClient().measurement.create({
data: {
userId,
type: "WAIST_CIRCUMFERENCE",
value: 82,
unit: "cm",
measuredAt: new Date(now),
},
});
}
async function readAnalytics(): Promise<AnalyticsEnvelope> {
const { GET } = await import("@/app/api/analytics/route");
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
expect(res.status).toBe(200);
return (await res.json()) as AnalyticsEnvelope;
}
describe("the score a surface shows is the score that gets written down", () => {
it("persists the composite, band and composition the analytics route returned", async () => {
const user = await seedSession("hsr-roundtrip");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const shown = (await readAnalytics()).data!.healthScore!;
expect(shown.composite.status).toBe("ok");
const rows = await getPrismaClient().healthScoreRecord.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(1);
const row = rows[0];
// The shown value and the stored value are not compared through a fixture
// in the middle — the number asserted here came back over the wire from
// the same request that wrote the row.
expect(row.composite).toBe(shown.composite.value!.score);
expect(row.band).toBe(shown.composite.value!.band);
expect(row.composition).toEqual(shown.composite.value!.composition);
expect(row.pillarScores).toBeTruthy();
// JSONB does not preserve insertion order, so the SET of keys is the
// claim, not their order. The order that matters lives in `composition`,
// which is a text array and asserted above.
expect(
Object.keys(row.pillarScores as Record<string, number>).sort(),
).toEqual([...shown.composite.value!.composition].sort());
expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(row.timezone).toBe("UTC");
expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10));
// The recipe the day was scored under, resolved on the write path from
// the account's own stored configuration. This account never opened the
// settings surface, so the honest answer is version 0 with no change
// date — the thing the first authored recipe will later be a move FROM.
expect(row.configVersion).toBe(0);
expect(row.configChangedAt).toBeNull();
});
it("stamps the account's own recipe on the row, straight from the stored configuration", async () => {
// The supply half of the delta guard, over real Postgres. Nothing in
// the request carries the recipe: the write path resolves it from the
// same column the composition came from, so a version that never
// reached the writer shows up as a row that cannot say which recipe
// produced it — and the seam becomes underivable a release later.
const user = await seedSession("hsr-recipe");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const changedAt = new Date(now - 3 * DAY);
await getPrismaClient().user.update({
where: { id: user.id },
data: {
healthScoreConfigJson: {
excludedPillars: ["FITNESS"],
version: 6,
changedAt: changedAt.toISOString(),
},
},
});
expect((await readAnalytics()).data!.healthScore!.composite.status).toBe(
"ok",
);
const row = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.configVersion).toBe(6);
expect(row.configChangedAt?.toISOString()).toBe(changedAt.toISOString());
});
it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => {
const user = await seedSession("hsr-never-restate");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const first = (await readAnalytics()).data!.healthScore!;
expect(first.composite.status).toBe("ok");
const before = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
where: { userId: user.id },
});
// Change what the score is made of, two ways at once: a fourth pillar
// joins the composition, and blood pressure grades worse. Either alone
// moves the live number.
await getPrismaClient().measurement.deleteMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
});
await getPrismaClient().measurement.deleteMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
});
await seedBp(user.id, now, 20, 168);
for (let i = 0; i < 30; i++) {
await getPrismaClient().measurement.create({
data: {
userId: user.id,
type: "ACTIVITY_STEPS",
value: 11_000,
unit: "steps",
measuredAt: new Date(now - i * DAY),
source: "APPLE_HEALTH",
},
});
}
// `/api/analytics` reads through a 60-second cache, so without this the
// second request would replay the first request's body and nothing would
// recompute. The assertion below is what caught it.
caches.analytics.deleteByPrefix(`${user.id}|`);
caches.insightsDerived.deleteByPrefix(`${user.id}|`);
const second = (await readAnalytics()).data!.healthScore!;
expect(second.composite.status).toBe("ok");
// The live number really did move — without this the test below would
// pass against a recomputation that changed nothing.
expect(second.composite.value!.score).not.toBe(
first.composite.value!.score,
);
const after = await getPrismaClient().healthScoreRecord.findMany({
where: { userId: user.id },
});
expect(after).toHaveLength(1);
expect(after[0]).toEqual(before);
});
});
describe("the recorded day survives a backup and a restore", () => {
it("carries the row out through the shared backup builder", async () => {
const user = await seedSession("hsr-backup");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
await readAnalytics();
const stored = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
where: { userId: user.id },
});
const { payload, counts } = await buildFullBackupPayload(
getPrismaClient(),
user.id,
{ purpose: "disaster-recovery" },
);
expect(counts.healthScoreRecords).toBe(1);
const carried = (payload as { healthScoreRecords: unknown[] })
.healthScoreRecords;
expect(carried).toHaveLength(1);
expect(carried[0]).toMatchObject({
id: stored.id,
dayKey: stored.dayKey,
timezone: stored.timezone,
composite: stored.composite,
band: stored.band,
scoreVersion: stored.scoreVersion,
composition: stored.composition,
inputFingerprint: stored.inputFingerprint,
});
// And the file it produced is one the restore side will accept, rather
// than one that parses everywhere except at the moment it is needed.
expect(
parseBackupPayload(JSON.stringify(payload)).healthScoreRecords,
).toHaveLength(1);
});
it("brings the row back through the admin restore route", async () => {
const prisma = getPrismaClient();
const admin = await seedSession("hsr-restore", "ADMIN");
const now = Date.now();
await seedBp(admin.id, now, 20, 122);
await seedSleep(admin.id, now, 14);
await seedWaist(admin.id, now);
await readAnalytics();
const before = await prisma.healthScoreRecord.findFirstOrThrow({
where: { userId: admin.id },
});
const { payload } = await buildFullBackupPayload(prisma, admin.id, {
purpose: "disaster-recovery",
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "HEALTH_SCORE_RECORD_RESTORE_TEST",
data: encrypt(JSON.stringify(payload)),
},
});
// Wipe the table so a restore that carries nothing cannot pass by leaving
// the row that was already there.
await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } });
expect(
await prisma.healthScoreRecord.count({ where: { userId: admin.id } }),
).toBe(0);
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const res = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
const body = (await res.json()) as {
data: { summary: { healthScoreRecords: number } } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(res.status).toBe(200);
expect(body.data!.summary.healthScoreRecords).toBe(1);
const restored = await prisma.healthScoreRecord.findMany({
where: { userId: admin.id },
});
expect(restored).toHaveLength(1);
expect(restored[0].id).toBe(before.id);
expect(restored[0].dayKey).toBe(before.dayKey);
expect(restored[0].composite).toBe(before.composite);
expect(restored[0].band).toBe(before.band);
expect(restored[0].composition).toEqual(before.composition);
expect(restored[0].pillarScores).toEqual(before.pillarScores);
expect(restored[0].inputFingerprint).toBe(before.inputFingerprint);
expect(restored[0].computedAt.toISOString()).toBe(
before.computedAt.toISOString(),
);
});
});
describe("delete all data", () => {
it("removes the recorded days along with the rest of the record", async () => {
const user = await seedSession("hsr-wipe");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
await readAnalytics();
expect(
await getPrismaClient().healthScoreRecord.count({
where: { userId: user.id },
}),
).toBe(1);
const { DELETE } = await import("@/app/api/settings/data/route");
const res = await (
DELETE as unknown as (req: Request) => Promise<Response>
)(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
}),
);
expect(res.status).toBe(200);
expect(
await getPrismaClient().healthScoreRecord.count({
where: { userId: user.id },
}),
).toBe(0);
});
});