diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 81dec2775..e1c32c136 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -28117,6 +28117,7 @@ components: - type: string enum: - algorithm_changed + - config_changed - composition_changed - first_eligibility_window - below_noise_floor diff --git a/messages/de.json b/messages/de.json index b8e7cd85c..71774346c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/messages/en.json b/messages/en.json index c47ffad22..c8f7ba43e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/messages/es.json b/messages/es.json index ef080d594..dd6ae9b36 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/messages/fr.json b/messages/fr.json index e229906e1..446b1a1af 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/messages/it.json b/messages/it.json index 3974df24a..b176dfa11 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/messages/pl.json b/messages/pl.json index c49b93879..4b60aa32d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2279,6 +2279,7 @@ "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.", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78d9faa1e..bb5745087 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5141,15 +5141,17 @@ model HealthScoreRecord { 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. The write path - /// takes both as parameters and persists whatever it is given; today - /// nothing supplies them, because the per-user configuration itself lands - /// beside this table and the guard that reads them lands after it. They are - /// here now so that guard does not need a second migration over a table - /// that will already hold rows. - /// @pending-consumer(score delta guard) suppresses the week-over-week delta across a configuration change, so a settings action is never narrated as a health event. + /// 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") - /// @pending-consumer(score delta guard) dates the series break on the first record written after a configuration change. configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) /// When the score was computed. Distinct from `dayKey`: the day says which diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index 08c867b69..412d37525 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -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"); @@ -175,7 +176,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 +208,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 +242,7 @@ describe("reference-score composite", () => { beforeBoundary, beforeBoundary, changedAt, + UNCONFIGURED_SCORE_BOUNDARY, ); expect(algorithm.deltaReason).toBe("algorithm_changed"); @@ -256,7 +270,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 +314,7 @@ describe("reference-score composite", () => { previous, stablePrior, NOW, + UNCONFIGURED_SCORE_BOUNDARY, [ pillar("BLOOD_PRESSURE", 90), pillar("ACTIVITY", 90), @@ -339,6 +360,7 @@ describe("reference-score composite", () => { previous, stablePrior, NOW, + UNCONFIGURED_SCORE_BOUNDARY, currentPillars, previousPillars, ); diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index be4572072..8791d125c 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -15,6 +15,7 @@ import type { Derived } from "@/lib/insights/derived/types"; import { computeComposite } from "../composite"; +import { UNCONFIGURED_SCORE_BOUNDARY } from "../config"; import { buildHealthScoreRecord, healthScoreInputFingerprint, @@ -241,6 +242,7 @@ describe("building the row", () => { const draft = buildHealthScoreRecord(built, { timezone: "Europe/Berlin", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }); expect(draft).not.toBeNull(); expect(built.composite.status).toBe("ok"); @@ -265,12 +267,14 @@ describe("building the row", () => { 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"); }); @@ -286,6 +290,7 @@ describe("building the row", () => { 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"); @@ -298,28 +303,36 @@ describe("building the row", () => { const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); expect(thin.composite.status).toBe("insufficient"); expect( - buildHealthScoreRecord(thin, { timezone: "UTC", now: NOW }), + buildHealthScoreRecord(thin, { + timezone: "UTC", + now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, + }), ).toBeNull(); }); it("carries the configuration version and its change date through", () => { - const changedAt = new Date("2026-08-19T09:00:00.000Z"); + const changedAt = "2026-08-19T09:00:00.000Z"; const draft = buildHealthScoreRecord(report(HEALTHY), { timezone: "UTC", now: NOW, - configVersion: 4, - configChangedAt: changedAt, + config: { version: 4, changedAt }, })!; expect(draft.configVersion).toBe(4); - expect(draft.configChangedAt).toEqual(changedAt); + expect(draft.configChangedAt).toEqual(new Date(changedAt)); }); - it("defaults the configuration columns to absent, never to a made-up version", () => { + 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).toBeNull(); + expect(draft.configVersion).toBe(0); expect(draft.configChangedAt).toBeNull(); }); }); @@ -347,6 +360,7 @@ describe("writing the row", () => { 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); @@ -359,6 +373,7 @@ describe("writing the row", () => { await recordHealthScore(db, "user-1", report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).toBe("already_recorded"); }); @@ -370,6 +385,7 @@ describe("writing the row", () => { await recordHealthScore(db, "user-1", thin, { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).toBe("no_score"); expect(createMany).not.toHaveBeenCalled(); @@ -387,6 +403,7 @@ describe("writing the row", () => { recordHealthScore(db, "user-1", report(HEALTHY), { timezone: "UTC", now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, }), ).resolves.toBe("failed"); }); diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 6dde302a5..edc42e7e1 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -16,6 +16,7 @@ import { } 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"; @@ -208,11 +209,50 @@ 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. + * + * `version` is what says a recipe was ever authored. An account that + * never chose has no boundary to straddle, and a stored blob that + * somehow carries no `changedAt` gives nothing to compare, so neither + * suppresses: refusing every delta forever on an unreadable date would + * trade one silent lie for a permanent silence. + */ +export function crossesConfigBoundary( + boundary: ScoreConfigBoundary, + previousAt: Date, + asOf: Date, +): boolean { + if (boundary.version < 1 || boundary.changedAt === null) return false; + const changedAt = new Date(boundary.changedAt); + if (Number.isNaN(changedAt.getTime())) return false; + return asOf >= changedAt && previousAt < 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, previous: Derived, previousPrevious: Derived, asOf: Date, + config: ScoreConfigBoundary, currentPillars: ScorePillarResult[] = [], previousPillars: ScorePillarResult[] = [], ): Pick { @@ -231,6 +271,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" }; } diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index 015796fc0..3a3b81db2 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -136,6 +136,40 @@ export function resolveHealthScoreConfig( }; } +/** + * 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 = + Object.freeze({ version: 0, changedAt: null }); + +/** Narrow a resolved config to the identity a comparison reads. */ +export function scoreConfigBoundary( + config: Pick, +): 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 diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 950f7d39f..0caf068ba 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -21,13 +21,13 @@ 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 } from "./config"; +import { resolveHealthScoreConfig, scoreConfigBoundary } from "./config"; import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; @@ -868,15 +868,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 } }, diff --git a/src/lib/analytics/score/record.ts b/src/lib/analytics/score/record.ts index 930829612..00a3e216e 100644 --- a/src/lib/analytics/score/record.ts +++ b/src/lib/analytics/score/record.ts @@ -37,6 +37,11 @@ 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, @@ -80,7 +85,8 @@ export interface HealthScoreRecordDraft { composition: ScorePillarId[]; pillarScores: Record; inputFingerprint: string; - configVersion: number | null; + /** The recipe version in force. 0 when the account never chose. */ + configVersion: number; configChangedAt: Date | null; computedAt: Date; } @@ -92,13 +98,16 @@ export interface HealthScoreRecordContext { /** 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. + * 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. */ - configVersion?: number | null; - configChangedAt?: Date | null; + config: ScoreConfigBoundary; } /** @@ -204,8 +213,10 @@ export function buildHealthScoreRecord( composition: composite.composition, pillars: report.pillars, }), - configVersion: context.configVersion ?? null, - configChangedAt: context.configChangedAt ?? null, + configVersion: context.config.version, + configChangedAt: context.config.changedAt + ? new Date(context.config.changedAt) + : null, computedAt: context.now, }; } @@ -295,14 +306,21 @@ export async function recordHealthScore( */ export async function computeAndRecordUserHealthScore( input: UserHealthScoreInput, - record?: Pick, ): Promise { 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, - configVersion: record?.configVersion ?? null, - configChangedAt: record?.configChangedAt ?? null, + config: scoreConfigBoundary( + resolveHealthScoreConfig(input.healthScoreConfigJson), + ), }); return report; } diff --git a/src/lib/analytics/score/series.ts b/src/lib/analytics/score/series.ts new file mode 100644 index 000000000..cd05f857e --- /dev/null +++ b/src/lib/analytics/score/series.ts @@ -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; + /** 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 }] + : []; + }); +} diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 6cf094734..c71d0fa1e 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -126,6 +126,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" diff --git a/src/lib/daily/priority-item-key.ts b/src/lib/daily/priority-item-key.ts index 9be276fc7..d483b45f8 100644 --- a/src/lib/daily/priority-item-key.ts +++ b/src/lib/daily/priority-item-key.ts @@ -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:` 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; +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 2e3c51ffd..c6b152647 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1112,6 +1112,7 @@ export const dashboardSnapshotResponse = z deltaReason: z .enum([ "algorithm_changed", + "config_changed", "composition_changed", "first_eligibility_window", "below_noise_floor",