diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts new file mode 100644 index 000000000..f13137cff --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -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>); + +/** The dismiss ledger, as a set of item keys the account has acknowledged. */ +const dismissedKeys = vi.hoisted(() => new Set()); + +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> }) => { + 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 { + 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(); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts new file mode 100644 index 000000000..b0794076b --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -0,0 +1,387 @@ +/** + * 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 = { + 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 { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + return buildOk({ + value: { + score, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + 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("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("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", () => { + const points = scoreSeriesSeams([ + day("2026-08-19", null), + day("2026-08-20", 1), + ]); + expect(points[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 }, + ]); + }); +}); diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts index b17ecbe6f..3d5f8d5a5 100644 --- a/tests/integration/health-score-record.test.ts +++ b/tests/integration/health-score-record.test.ts @@ -207,12 +207,48 @@ describe("the score a surface shows is the score that gets written down", () => expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(row.timezone).toBe("UTC"); expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10)); - // Nothing supplies these yet, and an invented default would be worse than - // an honest null the day the delta guard starts reading them. - expect(row.configVersion).toBeNull(); + // 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();