mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Stop a settings change from reading as a score drop
Changing which pillars count is an act the person performed, not something that happened to their health. Until now the week-over-week comparison could not tell the difference: both windows are computed in one request under whatever recipe is in force right now, so the pillar sets agree, the subtraction looks legitimate, and the dashboard could announce "your score dropped 14 points" to someone who had just come back from the settings page. The recipe now carries its own dated identity, and the delta guard compares against it the same way it already compares against the global method boundary: a recipe that changed between the two windows suppresses the delta with a new reason, config_changed, and the surfaces that gate on an unexplained delta stay quiet. The score record stores the recipe version and its change date on every write, resolved from the same stored blob the composition came from, so a row can say which recipe produced it. A new series module reads those versions back, marks the first day after a change as a break, refuses to join the composite line across it, and keeps the per-pillar rows running through it: a pillar is graded against its own reference band and stays comparable when the average of the pillars does not. The notice key folds the person's recipe version in beside the method version, so a change in either raises the existing dismissible notice once and dismissing one never dismisses the other. An account that never chose keeps its old key untouched.
This commit is contained in:
1 parent
0826bd80e6
commit
087f3f496b
18 files changed
+389
-33
No files matched your search
@@ -28117,6 +28117,7 @@ components:
|
||||
- type: string
|
||||
enum:
|
||||
- algorithm_changed
|
||||
- config_changed
|
||||
- composition_changed
|
||||
- first_eligibility_window
|
||||
- below_noise_floor
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+10
-8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<CompositeValue>,
|
||||
previous: Derived<CompositeValue>,
|
||||
previousPrevious: Derived<CompositeValue>,
|
||||
asOf: Date,
|
||||
config: ScoreConfigBoundary,
|
||||
currentPillars: ScorePillarResult[] = [],
|
||||
previousPillars: ScorePillarResult[] = [],
|
||||
): Pick<HealthScoreReport, "delta" | "deltaReason"> {
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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<ScoreConfigBoundary> =
|
||||
Object.freeze({ version: 0, changedAt: null });
|
||||
|
||||
/** Narrow a resolved config to the identity a comparison reads. */
|
||||
export function scoreConfigBoundary(
|
||||
config: Pick<ResolvedHealthScoreConfig, "version" | "changedAt">,
|
||||
): ScoreConfigBoundary {
|
||||
return { version: config.version, changedAt: config.changedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the positive selection a person made into the blob the column
|
||||
* stores. Unknown ids are dropped on the way in, so the stored
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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<string, number>;
|
||||
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<HealthScoreRecordContext, "configVersion" | "configChangedAt">,
|
||||
): Promise<HealthScoreReport> {
|
||||
const report = await computeUserHealthScore(input);
|
||||
// The recipe is resolved here rather than asked of the caller. Three
|
||||
// routes call this function and every one of them already holds the
|
||||
// raw blob, so a parameter would have been three chances to pass
|
||||
// nothing and write a row that names no recipe. `resolveHealthScoreConfig`
|
||||
// is pure over that same blob, so the version stamped on the row and
|
||||
// the composition the reader scored are the same recipe by
|
||||
// construction, not by two callers agreeing.
|
||||
await recordHealthScore(input.prisma ?? globalPrisma, input.userId, report, {
|
||||
timezone: input.profile.timezone,
|
||||
now: input.now,
|
||||
configVersion: record?.configVersion ?? null,
|
||||
configChangedAt: record?.configChangedAt ?? null,
|
||||
config: scoreConfigBoundary(
|
||||
resolveHealthScoreConfig(input.healthScoreConfigJson),
|
||||
),
|
||||
});
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Reading a run of stored score days as a series, and refusing to draw
|
||||
* a line through the place where the recipe changed.
|
||||
*
|
||||
* Official statistics has one convention for this and it is small: a
|
||||
* single machine-readable flag on the FIRST observation after the break
|
||||
* (Eurostat's `b`, "break occurring when there is a change in the
|
||||
* standards for defining and observing a variable over time"). This
|
||||
* module is that flag, plus the two consequences that follow from it.
|
||||
*
|
||||
* **The composite line breaks.** Two segments, visibly two. A recipe
|
||||
* change fails the equal-construct requirement for a legitimate
|
||||
* equating by construction: the numbers on either side are averages of
|
||||
* different things. The stored day under the old recipe is an overlap
|
||||
* point of exactly one, which is enough to date the seam and enough to
|
||||
* refuse a comparison across it, and not nearly enough to compute an
|
||||
* adjustment factor that would rescale one segment onto the other. So
|
||||
* no adjustment is offered. There is no "adjusted" variant of
|
||||
* {@link scoreCompositeSegments} and there should not be one: splicing
|
||||
* two methods into one continuous line is the failure that produced
|
||||
* "years of sleep tracking might be no more useful than common sense",
|
||||
* and it always starts as a helpful-looking option.
|
||||
*
|
||||
* **The per-pillar rows do not break.** A pillar's own 0-100 score is
|
||||
* graded against its own published reference band and means the same
|
||||
* thing on both sides of the seam. Only the aggregate changes basis.
|
||||
* That is why the record stores the per-pillar scores beside the
|
||||
* composite rather than folding them away, and it is what lets someone
|
||||
* look at their sleep across a period that spans a recipe change
|
||||
* without a gap.
|
||||
*
|
||||
* The seam is drawn on the recipe's VERSION, not on whether the
|
||||
* composition visibly moved. A person who deselects a pillar that had
|
||||
* no data that week changes what counts even though this week's number
|
||||
* did not budge, and the delta guard suppresses that week from less
|
||||
* information than this module has. Two surfaces disagreeing about
|
||||
* whether the same act was an event is worse than either answer, so
|
||||
* both key on the recipe's identity and describe one event.
|
||||
*
|
||||
* Pure. No database, no clock. The trend view that renders the segments
|
||||
* is deliberately not in this release — the records start empty, so
|
||||
* there is nothing yet to show — but the flag and the refusal ship with
|
||||
* the machinery that creates the seam rather than after it.
|
||||
*/
|
||||
import type { ScoreBand, ScorePillarId } from "./types";
|
||||
|
||||
/**
|
||||
* One stored score day, in the shape `HealthScoreRecord` rows already
|
||||
* have, so a Prisma `select` feeds this without a mapping layer in
|
||||
* between to get wrong.
|
||||
*/
|
||||
export interface ScoreSeriesRecord {
|
||||
/** Local calendar day, `YYYY-MM-DD`. */
|
||||
dayKey: string;
|
||||
composite: number;
|
||||
band: ScoreBand;
|
||||
composition: string[];
|
||||
pillarScores: Record<string, number>;
|
||||
/** The recipe version the day was scored under; null on a row written
|
||||
* before the writer supplied one. */
|
||||
configVersion: number | null;
|
||||
}
|
||||
|
||||
export interface ScoreSeriesPoint extends ScoreSeriesRecord {
|
||||
/**
|
||||
* Eurostat `b`. True on the first observation computed under a
|
||||
* different recipe than the observation before it. Never true on the
|
||||
* first observation of the series: a beginning is not a break.
|
||||
*/
|
||||
seamBreak: boolean;
|
||||
}
|
||||
|
||||
/** Ascending by local day. Lexicographic order IS chronological here. */
|
||||
function byDay(left: ScoreSeriesRecord, right: ScoreSeriesRecord): number {
|
||||
return left.dayKey < right.dayKey ? -1 : left.dayKey > right.dayKey ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two consecutive days sit on different bases.
|
||||
*
|
||||
* An unknown version on either side counts as a break. The seam's job
|
||||
* is to refuse a link it cannot justify, and "these two rows were
|
||||
* scored under the same recipe" is a claim: a row that does not say
|
||||
* which recipe produced it cannot support it. Only rows predating the
|
||||
* writer carry null, so in practice this arm is the answer to a
|
||||
* question production never asks — which is the point of answering it
|
||||
* deliberately rather than letting `null !== 0` decide by accident.
|
||||
*/
|
||||
function breaksFrom(
|
||||
previous: ScoreSeriesRecord,
|
||||
current: ScoreSeriesRecord,
|
||||
): boolean {
|
||||
if (previous.configVersion === null || current.configVersion === null) {
|
||||
return true;
|
||||
}
|
||||
return previous.configVersion !== current.configVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every stored day with whether it opens a new basis.
|
||||
*
|
||||
* Input order does not matter; the result is ascending by day.
|
||||
*/
|
||||
export function scoreSeriesSeams(
|
||||
records: readonly ScoreSeriesRecord[],
|
||||
): ScoreSeriesPoint[] {
|
||||
return [...records].sort(byDay).map((record, index, ordered) => ({
|
||||
...record,
|
||||
seamBreak: index > 0 && breaksFrom(ordered[index - 1], record),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The composite series, cut into the segments that may honestly be
|
||||
* drawn as one line each.
|
||||
*
|
||||
* Every seam starts a new segment, and nothing here joins two of them:
|
||||
* no interpolation across the gap, no offset applied to make the ends
|
||||
* meet, no single array with a hole in it that a chart library would
|
||||
* happily bridge. A caller that wants one line gets the segments and
|
||||
* has to decide, visibly, what to do about the fact that there are two.
|
||||
*/
|
||||
export function scoreCompositeSegments(
|
||||
points: readonly ScoreSeriesPoint[],
|
||||
): ScoreSeriesPoint[][] {
|
||||
const segments: ScoreSeriesPoint[][] = [];
|
||||
for (const point of points) {
|
||||
if (segments.length === 0 || point.seamBreak) {
|
||||
segments.push([point]);
|
||||
continue;
|
||||
}
|
||||
segments[segments.length - 1].push(point);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pillar's own scores across the whole run, seams and all.
|
||||
*
|
||||
* Continuous on purpose. The pillar is graded against its own published
|
||||
* reference band on both sides of a recipe change, so it stays
|
||||
* comparable when the average of the pillars does not. Days on which
|
||||
* the pillar did not count are absent rather than zero.
|
||||
*/
|
||||
export function scorePillarSeries(
|
||||
points: readonly ScoreSeriesPoint[],
|
||||
pillar: ScorePillarId,
|
||||
): Array<{ dayKey: string; score: number }> {
|
||||
return points.flatMap((point) => {
|
||||
const score = point.pillarScores[pillar];
|
||||
return typeof score === "number" && Number.isFinite(score)
|
||||
? [{ dayKey: point.dayKey, score }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -60,3 +60,36 @@ export function sameTimeBaselineItemKey(
|
||||
export function healthScoreAlgorithmItemKey(scoreVersion: number): string {
|
||||
return `health_score_algorithm:${scoreVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Health Score notice key for one account, folding in the person's
|
||||
* OWN recipe version beside the global method version.
|
||||
*
|
||||
* Two things can change what a score means, and the person needs
|
||||
* telling once about each: we change the method (`scoreVersion`), or
|
||||
* they change what counts (`configVersion`). One ledger key carrying
|
||||
* only the first would leave a recipe change unannounced, and a key
|
||||
* carrying only the second would swallow the next method change for
|
||||
* everybody who ever opened the settings page. Both dimensions ride the
|
||||
* key, so a move in either raises the notice exactly once and a
|
||||
* dismissal of one never dismisses the other.
|
||||
*
|
||||
* An account that never chose keeps the bare
|
||||
* `health_score_algorithm:<v>` key unchanged. That is not cosmetic:
|
||||
* lengthening the key for everyone would leave every existing
|
||||
* acknowledgement pointing at a key nothing looks up, and the method
|
||||
* notice would reappear for every account on upgrade.
|
||||
*
|
||||
* The `health_score_algorithm:` prefix is deliberate — it is the
|
||||
* dismiss ledger's namespace (`isDismissibleItemKey`, the dismiss
|
||||
* route's score-cache eviction), not user-facing copy, and reusing it
|
||||
* is what lets the recipe notice ride machinery that already dismisses
|
||||
* and evicts correctly instead of growing a second one beside it.
|
||||
*/
|
||||
export function healthScoreNoticeItemKey(
|
||||
scoreVersion: number,
|
||||
configVersion: number,
|
||||
): string {
|
||||
const base = healthScoreAlgorithmItemKey(scoreVersion);
|
||||
return configVersion >= 1 ? `${base}:config:${configVersion}` : base;
|
||||
}
|
||||
@@ -1112,6 +1112,7 @@ export const dashboardSnapshotResponse = z
|
||||
deltaReason: z
|
||||
.enum([
|
||||
"algorithm_changed",
|
||||
"config_changed",
|
||||
"composition_changed",
|
||||
"first_eligibility_window",
|
||||
"below_noise_floor",
|
||||
|
||||
Reference in new issue
Block a user