mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Record each local day's score on the path that computes it
Every surface that shows a score now goes through one entry point that computes it and writes the day down, rather than three surfaces each remembering to. The write is an insert that yields on conflict, so a second computation of the same day leaves the first row alone, and a failure is annotated rather than thrown: a score that renders must not disappear because a bookkeeping insert did. The fingerprint covers exactly what the stored composite and band depend on - the algorithm version, the composition, and each counted pillar's score with its scoring-mode identity. A pillar that was graded but not counted stays out of both the fingerprint and the row, because it did not contribute to the number being stored.
This commit is contained in:
1 parent
cb75986eb5
commit
860a1fa299
5 files changed
+664
-6
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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 { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
|
||||
import { computeComposite } from "../composite";
|
||||
|
||||
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,
|
||||
});
|
||||
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("lists every band the composite can produce, and nothing else", () => {
|
||||
// The migration's CHECK constraint spells out the same three. A band the
|
||||
// composite can reach and the column refuses is a runtime constraint
|
||||
// violation on a write path that swallows its own failures, so the two
|
||||
// lists have to be the same list.
|
||||
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 moves", () => {
|
||||
const narrowed = {
|
||||
...base,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP"] as ScorePillarId[],
|
||||
};
|
||||
expect(healthScoreInputFingerprint(narrowed)).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
})!.dayKey,
|
||||
).toBe("2026-08-21");
|
||||
expect(
|
||||
buildHealthScoreRecord(built, {
|
||||
timezone: "UTC",
|
||||
now: lateEvening,
|
||||
})!.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,
|
||||
})!;
|
||||
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 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the configuration version and its change date through", () => {
|
||||
const changedAt = new Date("2026-08-19T09:00:00.000Z");
|
||||
const draft = buildHealthScoreRecord(report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
configVersion: 4,
|
||||
configChangedAt: changedAt,
|
||||
})!;
|
||||
expect(draft.configVersion).toBe(4);
|
||||
expect(draft.configChangedAt).toEqual(changedAt);
|
||||
});
|
||||
|
||||
it("defaults the configuration columns to absent, never to a made-up version", () => {
|
||||
const draft = buildHealthScoreRecord(report(HEALTHY), {
|
||||
timezone: "UTC",
|
||||
now: NOW,
|
||||
})!;
|
||||
expect(draft.configVersion).toBeNull();
|
||||
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 () => ({ 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,
|
||||
});
|
||||
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,
|
||||
}),
|
||||
).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,
|
||||
}),
|
||||
).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,
|
||||
}),
|
||||
).resolves.toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -50,7 +50,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: {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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 { 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;
|
||||
configVersion: number | null;
|
||||
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 account's health-score configuration version in force for this
|
||||
* computation, and when it last changed. Both stay `null` until the
|
||||
* per-user configuration exists; the parameters are here so threading them
|
||||
* through later is a caller change and not a migration.
|
||||
*/
|
||||
configVersion?: number | null;
|
||||
configChangedAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, the composition, and each counted
|
||||
* pillar's score together with its scoring-mode identity. Drop any one of
|
||||
* them and two genuinely different standings collapse onto one fingerprint —
|
||||
* a key missing a dimension its value depends on, which is a defect class
|
||||
* this codebase has shipped before.
|
||||
*
|
||||
* 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,
|
||||
composition: [...input.composition],
|
||||
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.configVersion ?? null,
|
||||
configChangedAt: context.configChangedAt ?? 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,
|
||||
record?: Pick<HealthScoreRecordContext, "configVersion" | "configChangedAt">,
|
||||
): Promise<HealthScoreReport> {
|
||||
const report = await computeUserHealthScore(input);
|
||||
await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, {
|
||||
timezone: input.profile.timezone,
|
||||
now: input.now,
|
||||
configVersion: record?.configVersion ?? null,
|
||||
configChangedAt: record?.configChangedAt ?? null,
|
||||
});
|
||||
return report;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -736,7 +736,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,
|
||||
|
||||
Reference in new issue
Block a user