Merge the persisted score record into the composition swap

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:57:37 +02:00
commit 0826bd80e6
18 files changed
+1614 -7

No files matched your search

@@ -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,
};
},
{
+2 -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: {
@@ -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([]) },