mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat: the score says when a pillar stopped counting
A pillar can leave the set behind the number without anybody choosing it: a window rolls past its floor, a module goes off, a source goes quiet. The delta already refuses to narrate that as a health event, which stops a false "you dropped six points" and puts nothing in its place. The level moves, the panel shows a different number, and the cause is nowhere on the surface. Under the adaptive minimum this matters more rather than less, because compositions are smaller now and sit closer to their floors. The report carries `compositionNotice` when today's set differs from the last local day the account has a stored score for. Optional and nullable, the additive shape the score's wire has used six times, so a payload cached before it existed stays valid. It names what left and what joined as pillar ids; the surfaces already own the labels, and the reason a departed pillar stopped counting comes from that pillar's own row in the same report rather than from a second vocabulary. Two changes deliberately do not raise it. A method move and a recipe change each already have their own note, and telling someone twice about one event teaches them to ignore both. The key is `health-score:v<version>:composition:<hash of the sorted set>` and rides the existing dismissed-item ledger, so a dismissal holds until the set moves again rather than until tomorrow. The read excludes today on purpose: the row for today is written by the same request that computed the report, so comparing against the newest row would find today's own composition on the second load of the same page and the note would vanish between two views of it. This gives `HealthScoreRecord` its first production reader. Since it shipped it has been written on every scored read and consumed by nothing but backup and restore, which is the write-only shape the contributor notes call a liability and which nothing in the gate can notice, because every check proves one end. The round trip is proved against real Postgres: raise the note, wipe the table, restore it through the admin route, get the same note back, and hold the dismissal across it. Rendering is one muted line on the score card and a card of its own on the settings page, beside the recipe note rather than inside it. Deliberately not a push. Four fake Prisma clients gain the new read. They are doubles of a client the reader really does call now; leaving them short and guarding the call site instead would have been a shim for a caller that does not exist.
This commit is contained in:
1 parent
319a75b02f
commit
e4880dcdc1
28 files changed
+1384
-16
No files matched your search
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* When a pillar leaving is worth a sentence, and when it is noise.
|
||||
*
|
||||
* The decision is the whole feature. Rendering a note nobody needed is the
|
||||
* failure this repo calls nagging, and staying silent on the one change
|
||||
* nobody chose is the defect the note exists to close, so both arms are
|
||||
* asserted here rather than only the raising one.
|
||||
*
|
||||
* Pure values throughout — the reader's query and the dismissal lookup are
|
||||
* proved against real Postgres in `tests/integration/health-score-record.ts`,
|
||||
* where a stored row is a stored row rather than a fixture agreeing with the
|
||||
* code that made it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCompositionNotice,
|
||||
compositionNoticeKey,
|
||||
type CompositionNoticeInput,
|
||||
} from "@/lib/analytics/score/composition-notice";
|
||||
import { healthScoreCompositionItemKey } from "@/lib/daily/priority-item-key";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
|
||||
const YESTERDAY = ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"];
|
||||
|
||||
function input(over: Partial<CompositionNoticeInput> = {}) {
|
||||
return {
|
||||
current: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
|
||||
previous: {
|
||||
composition: YESTERDAY,
|
||||
scoreVersion: 3,
|
||||
configVersion: 0,
|
||||
},
|
||||
scoreVersion: 3,
|
||||
configVersion: 0,
|
||||
...over,
|
||||
} satisfies CompositionNoticeInput;
|
||||
}
|
||||
|
||||
describe("the composition note only speaks when the set actually moved", () => {
|
||||
it("says nothing while the set is the same", () => {
|
||||
expect(compositionNoticeKey(input())).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing when registry order is the only difference", () => {
|
||||
expect(
|
||||
compositionNoticeKey(
|
||||
input({
|
||||
previous: {
|
||||
composition: ["ADIPOSITY", "SLEEP", "BLOOD_PRESSURE"],
|
||||
scoreVersion: 3,
|
||||
configVersion: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing on a first-ever scored day", () => {
|
||||
expect(compositionNoticeKey(input({ previous: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing when there is no score to explain", () => {
|
||||
expect(compositionNoticeKey(input({ current: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it("raises a key when a pillar drops out on its own", () => {
|
||||
const decision = input({
|
||||
current: ["BLOOD_PRESSURE", "ADIPOSITY"] as ScorePillarId[],
|
||||
});
|
||||
const key = compositionNoticeKey(decision);
|
||||
expect(key).toBe(
|
||||
healthScoreCompositionItemKey(3, ["BLOOD_PRESSURE", "ADIPOSITY"]),
|
||||
);
|
||||
expect(buildCompositionNotice(decision, key!, false)).toEqual({
|
||||
itemKey: key,
|
||||
left: ["SLEEP"],
|
||||
joined: [],
|
||||
dismissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("names both directions when one pillar leaves and another arrives", () => {
|
||||
const decision = input({
|
||||
current: ["BLOOD_PRESSURE", "ADIPOSITY", "LIPIDS"] as ScorePillarId[],
|
||||
});
|
||||
const key = compositionNoticeKey(decision)!;
|
||||
const notice = buildCompositionNotice(decision, key, false);
|
||||
expect(notice.left).toEqual(["SLEEP"]);
|
||||
expect(notice.joined).toEqual(["LIPIDS"]);
|
||||
});
|
||||
|
||||
it("keys on the resulting set, so an unchanged set tomorrow keeps the dismissal", () => {
|
||||
// Day one: sleep leaves. Day two: the same narrower set stands, and the
|
||||
// key resolves to the same string, so a dismissal on day one still
|
||||
// matches. Re-keying per day is what would make a dismissible note
|
||||
// reappear every morning, which is the nagging shape.
|
||||
const dropped = ["BLOOD_PRESSURE", "ADIPOSITY"] as ScorePillarId[];
|
||||
const first = compositionNoticeKey(input({ current: dropped }))!;
|
||||
const second = compositionNoticeKey(
|
||||
input({
|
||||
current: dropped,
|
||||
previous: {
|
||||
composition: [...YESTERDAY],
|
||||
scoreVersion: 3,
|
||||
configVersion: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("stays silent when the method moved — that note is already raised", () => {
|
||||
expect(
|
||||
compositionNoticeKey(
|
||||
input({
|
||||
current: ["BLOOD_PRESSURE", "ADIPOSITY"] as ScorePillarId[],
|
||||
previous: {
|
||||
composition: YESTERDAY,
|
||||
scoreVersion: 2,
|
||||
configVersion: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("stays silent when the person changed their own recipe", () => {
|
||||
expect(
|
||||
compositionNoticeKey(
|
||||
input({
|
||||
current: ["BLOOD_PRESSURE", "ADIPOSITY"] as ScorePillarId[],
|
||||
configVersion: 2,
|
||||
previous: {
|
||||
composition: YESTERDAY,
|
||||
scoreVersion: 3,
|
||||
configVersion: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still speaks for a row written before the recipe columns existed", () => {
|
||||
// `configVersion` is nullable only because the columns predate the
|
||||
// writer by one release. A null there is "unknown", and treating an
|
||||
// unknown as a recipe change would silence the note for every account
|
||||
// carrying one of those rows.
|
||||
expect(
|
||||
compositionNoticeKey(
|
||||
input({
|
||||
current: ["BLOOD_PRESSURE", "ADIPOSITY"] as ScorePillarId[],
|
||||
configVersion: 4,
|
||||
previous: {
|
||||
composition: YESTERDAY,
|
||||
scoreVersion: 3,
|
||||
configVersion: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("treats a retired pillar id on a stored row as the absence it now is", () => {
|
||||
// A row naming FITNESS predates the catalogue that dropped it. Comparing
|
||||
// raw strings would report a change every single day, forever.
|
||||
expect(
|
||||
compositionNoticeKey(
|
||||
input({
|
||||
previous: {
|
||||
composition: [...YESTERDAY, "FITNESS"],
|
||||
scoreVersion: 3,
|
||||
configVersion: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,10 @@ function fakePrisma(): PrismaClient {
|
||||
mentalHealthAssessment: { findMany: async () => ASSESSMENTS },
|
||||
labResult: { findMany: async () => [] },
|
||||
dismissedPriorityItem: { findUnique: async () => null },
|
||||
// v1.38 — no stored day behind this account, so the composition note
|
||||
// has nothing to compare against. These cases are about what the
|
||||
// recipe and the modules compose TODAY.
|
||||
healthScoreRecord: { findFirst: async () => null },
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,11 @@ const fakeDb = vi.hoisted(() => {
|
||||
written.push(...args.data);
|
||||
return { count: args.data.length };
|
||||
},
|
||||
// v1.38 — the composition note's read. These cases are about a
|
||||
// RECIPE change, which that note deliberately stays silent on, and
|
||||
// an account with no stored day has nothing to compare against
|
||||
// either way.
|
||||
findFirst: async () => null,
|
||||
},
|
||||
illnessEpisode: { findMany: none },
|
||||
moodEntry: { findMany: none },
|
||||
|
||||
@@ -121,6 +121,10 @@ describe("score reader bounds and source parity", () => {
|
||||
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
// v1.38 — the reader compares today's composition against the last
|
||||
// stored day. No stored day here, so there is nothing to compare and
|
||||
// no note to raise.
|
||||
healthScoreRecord: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const report = await computeUserHealthScore({
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Telling a person that the set behind their score changed.
|
||||
*
|
||||
* The delta guard already refuses to narrate a composition change as a
|
||||
* health event (`composition_changed`, `./composite.ts`), which is right and
|
||||
* is only half the job: it stops a false sentence and puts nothing in its
|
||||
* place. The number on the panel moves, and the reason it moved — sleep
|
||||
* rolled out of its window, a module went off, a source went quiet — is
|
||||
* nowhere on the surface. This resolves that reason.
|
||||
*
|
||||
* The comparison is against the last LOCAL DAY this account has a stored
|
||||
* score for, which is why `HealthScoreRecord` finally has a production
|
||||
* reader. Until now the table was written on every scored read and consumed
|
||||
* by nothing but backup and restore — precisely the write-only-column shape
|
||||
* the contributor notes call a liability, and it survived two releases
|
||||
* because nothing in the gate can notice a column that is only ever
|
||||
* written.
|
||||
*
|
||||
* Two changes deliberately do NOT raise this note, because they already
|
||||
* have one of their own and a person told twice about one event learns to
|
||||
* ignore both:
|
||||
*
|
||||
* - the method moved (`scoreVersion`), and
|
||||
* - the person changed their own recipe (`configVersion`).
|
||||
*
|
||||
* Either of those can move the composition as a side effect. What is left
|
||||
* after they are excluded is the case nobody chose and nobody was told
|
||||
* about, which is the one worth a sentence.
|
||||
*
|
||||
* Pure. The reader owns the query and the dismissal lookup; every decision
|
||||
* about whether there is anything to say is made here, over values, so it
|
||||
* can be tested without a database.
|
||||
*/
|
||||
import { healthScoreCompositionItemKey } from "@/lib/daily/priority-item-key";
|
||||
|
||||
import {
|
||||
orderedUniquePillars,
|
||||
type ScoreCompositionNotice,
|
||||
type ScorePillarId,
|
||||
} from "./types";
|
||||
|
||||
/** The stored day the current composition is compared against. */
|
||||
export interface StoredCompositionDay {
|
||||
/** The row's own `composition` column, unnarrowed. */
|
||||
composition: readonly string[];
|
||||
scoreVersion: number;
|
||||
/** Null on rows written before the column existed. */
|
||||
configVersion: number | null;
|
||||
}
|
||||
|
||||
export interface CompositionNoticeInput {
|
||||
/** Today's composition, as the composite resolved it. */
|
||||
current: readonly ScorePillarId[];
|
||||
/** The most recent stored day BEFORE today, or null when there is none. */
|
||||
previous: StoredCompositionDay | null;
|
||||
/** The method identity today's score was computed under. */
|
||||
scoreVersion: number;
|
||||
/** The account's recipe version in force now. */
|
||||
configVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The item key the notice would carry, or null when there is nothing to
|
||||
* announce.
|
||||
*
|
||||
* Split out from the notice itself so the reader can look the dismissal up
|
||||
* in the same round trip it would have spent on the algorithm notice, and
|
||||
* so a caller never has to build a notice it is about to discard.
|
||||
*/
|
||||
export function compositionNoticeKey(
|
||||
input: CompositionNoticeInput,
|
||||
): string | null {
|
||||
const { previous } = input;
|
||||
// Nothing to compare against. A first-ever scored day is a beginning, not
|
||||
// a change, and `first_eligibility_window` already narrates the missing
|
||||
// delta beside it.
|
||||
if (!previous) return null;
|
||||
// The method moved, or the person changed what counts. Both already raise
|
||||
// the versioned notice on the settings page.
|
||||
if (previous.scoreVersion !== input.scoreVersion) return null;
|
||||
if (
|
||||
previous.configVersion != null &&
|
||||
previous.configVersion !== input.configVersion
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const current = orderedUniquePillars(input.current);
|
||||
const before = orderedUniquePillars(previous.composition);
|
||||
if (current.length === 0) return null;
|
||||
if (sameSet(before, current)) return null;
|
||||
return healthScoreCompositionItemKey(input.scoreVersion, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notice for a key `compositionNoticeKey` already approved.
|
||||
*
|
||||
* `dismissed` is the caller's lookup against the dismiss ledger, exactly as
|
||||
* the algorithm notice resolves it.
|
||||
*/
|
||||
export function buildCompositionNotice(
|
||||
input: CompositionNoticeInput,
|
||||
itemKey: string,
|
||||
dismissed: boolean,
|
||||
): ScoreCompositionNotice {
|
||||
const current = orderedUniquePillars(input.current);
|
||||
const before = orderedUniquePillars(input.previous?.composition ?? []);
|
||||
const now = new Set<ScorePillarId>(current);
|
||||
const then = new Set<ScorePillarId>(before);
|
||||
return {
|
||||
itemKey,
|
||||
left: before.filter((id) => !now.has(id)),
|
||||
joined: current.filter((id) => !then.has(id)),
|
||||
dismissed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set equality over two registry-ordered lists.
|
||||
*
|
||||
* `orderedUniquePillars` already puts both in registry order and drops
|
||||
* anything outside the catalogue, so a length check plus a positional walk
|
||||
* is exact here — and a stored row naming a pillar the catalogue has since
|
||||
* retired compares as the absence it now is, rather than as a change every
|
||||
* single day forever.
|
||||
*/
|
||||
function sameSet(
|
||||
a: readonly ScorePillarId[],
|
||||
b: readonly ScorePillarId[],
|
||||
): boolean {
|
||||
return a.length === b.length && a.every((id, i) => id === b[i]);
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { ACTIVITY_WINDOW_DAYS } from "./activity";
|
||||
import { SLEEP_WINDOW_DAYS } from "./sleep";
|
||||
import { attachScoreDelta } from "./composite";
|
||||
import {
|
||||
buildCompositionNotice,
|
||||
compositionNoticeKey,
|
||||
type StoredCompositionDay,
|
||||
} from "./composition-notice";
|
||||
import {
|
||||
resolveHealthScoreConfig,
|
||||
resolveScoreConfigured,
|
||||
@@ -109,7 +114,11 @@ async function capture<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
failure?:
|
||||
ScorePillarId | "WEIGHT_GOAL" | "ALGORITHM_NOTICE" | ScorePillarId[],
|
||||
| ScorePillarId
|
||||
| "WEIGHT_GOAL"
|
||||
| "ALGORITHM_NOTICE"
|
||||
| "COMPOSITION_NOTICE"
|
||||
| ScorePillarId[],
|
||||
): Promise<Settled<T>> {
|
||||
try {
|
||||
return { ok: true, value: await promise };
|
||||
@@ -874,14 +883,48 @@ export async function computeUserHealthScore(
|
||||
current.scoreVersion,
|
||||
config.version,
|
||||
);
|
||||
const dismissal = await capture(
|
||||
db.dismissedPriorityItem.findUnique({
|
||||
where: { userId_itemKey: { userId: input.userId, itemKey } },
|
||||
select: { itemKey: true },
|
||||
}),
|
||||
null,
|
||||
"ALGORITHM_NOTICE",
|
||||
);
|
||||
// v1.38 — the composition note's only input from the database, and the
|
||||
// first production read this table has ever had. It is filed under the
|
||||
// account's LOCAL day, and the row for today is excluded on purpose: the
|
||||
// write happens on the same request that computed the report, so
|
||||
// comparing against "the newest row" would find today's own composition
|
||||
// the second time the panel is opened and the note would vanish between
|
||||
// two loads of the same page.
|
||||
const todayKey = userDayKey(input.now, input.profile.timezone);
|
||||
const [dismissal, priorDay] = await Promise.all([
|
||||
capture(
|
||||
db.dismissedPriorityItem.findUnique({
|
||||
where: { userId_itemKey: { userId: input.userId, itemKey } },
|
||||
select: { itemKey: true },
|
||||
}),
|
||||
null,
|
||||
"ALGORITHM_NOTICE",
|
||||
),
|
||||
capture(
|
||||
db.healthScoreRecord.findFirst({
|
||||
where: { userId: input.userId, dayKey: { lt: todayKey } },
|
||||
orderBy: { dayKey: "desc" },
|
||||
select: {
|
||||
composition: true,
|
||||
scoreVersion: true,
|
||||
configVersion: true,
|
||||
},
|
||||
}),
|
||||
null,
|
||||
"COMPOSITION_NOTICE",
|
||||
),
|
||||
]);
|
||||
const compositionNotice = await resolveCompositionNotice({
|
||||
db,
|
||||
userId: input.userId,
|
||||
scoreVersion: current.scoreVersion,
|
||||
configVersion: config.version,
|
||||
composition:
|
||||
current.composite.status === "ok"
|
||||
? current.composite.value.composition
|
||||
: [],
|
||||
priorDay: priorDay.value,
|
||||
});
|
||||
return {
|
||||
...current,
|
||||
...delta,
|
||||
@@ -889,5 +932,43 @@ export async function computeUserHealthScore(
|
||||
itemKey,
|
||||
dismissed: dismissal.value != null,
|
||||
},
|
||||
compositionNotice,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a pillar leaving or joining is worth saying, and look up
|
||||
* whether it has already been said.
|
||||
*
|
||||
* The decision itself is pure and lives in `./composition-notice`; this is
|
||||
* the seam that holds the one extra round trip, and it only spends it when
|
||||
* there is a note to raise. A read failure here resolves to no note rather
|
||||
* than to a thrown request: the score renders, and the wide event carries
|
||||
* the domain that could not be read.
|
||||
*/
|
||||
async function resolveCompositionNotice(args: {
|
||||
db: Pick<PrismaClient, "dismissedPriorityItem">;
|
||||
userId: string;
|
||||
scoreVersion: number;
|
||||
configVersion: number;
|
||||
composition: readonly ScorePillarId[];
|
||||
priorDay: StoredCompositionDay | null;
|
||||
}): Promise<HealthScoreReport["compositionNotice"]> {
|
||||
const decision = {
|
||||
current: args.composition,
|
||||
previous: args.priorDay,
|
||||
scoreVersion: args.scoreVersion,
|
||||
configVersion: args.configVersion,
|
||||
};
|
||||
const key = compositionNoticeKey(decision);
|
||||
if (!key) return null;
|
||||
const seen = await capture(
|
||||
args.db.dismissedPriorityItem.findUnique({
|
||||
where: { userId_itemKey: { userId: args.userId, itemKey: key } },
|
||||
select: { itemKey: true },
|
||||
}),
|
||||
null,
|
||||
"COMPOSITION_NOTICE",
|
||||
);
|
||||
return buildCompositionNotice(decision, key, seen.value != null);
|
||||
}
|
||||
@@ -233,9 +233,50 @@ export interface HealthScoreReport {
|
||||
scoreVersion: typeof SCORE_VERSION;
|
||||
weightGoal: Derived<WeightGoalValue>;
|
||||
algorithmNotice: { itemKey: string; dismissed: boolean } | null;
|
||||
/**
|
||||
* v1.38 — a pillar joined or left the set behind the number since the
|
||||
* last day this account has a stored score for.
|
||||
*
|
||||
* The gap this closes: the delta is already suppressed on a composition
|
||||
* change, so nobody is told they dropped six points they did not drop —
|
||||
* and nobody is told anything at all. The level moves, the panel shows a
|
||||
* different number, and the cause is invisible. Under the adaptive
|
||||
* minimum that matters more rather than less: compositions are smaller
|
||||
* and sit closer to their floors, so a pillar rolling out of its window
|
||||
* is likelier.
|
||||
*
|
||||
* Absent rather than null on a payload written before the field existed,
|
||||
* which is why it is optional — the additive-contract pattern the
|
||||
* snapshot and digest blocks have followed six times. Null when there is
|
||||
* nothing to say: no prior stored day, an unchanged set, or a change the
|
||||
* method and recipe notice already owns.
|
||||
*/
|
||||
compositionNotice?: ScoreCompositionNotice | null;
|
||||
restMode?: RestModeAnnotation | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What left, what joined, and whether the person already said they saw it.
|
||||
*
|
||||
* Both lists are registry-ordered pillar ids, never localised strings: the
|
||||
* surfaces already own the pillar labels, and the reason a departed pillar
|
||||
* stopped counting is on that pillar's own row in the same report.
|
||||
*
|
||||
* The tier can move with the set (full to partial, or back) and this is
|
||||
* the notice for that too — the tier is a function of the composition, so
|
||||
* a second notice saying the same thing in other words would be two
|
||||
* announcements of one event.
|
||||
*/
|
||||
export interface ScoreCompositionNotice {
|
||||
/** `health-score:v<scoreVersion>:composition:<hash of the sorted set>`. */
|
||||
itemKey: string;
|
||||
/** Counted on the last stored day, not counted now. */
|
||||
left: ScorePillarId[];
|
||||
/** Counted now, not counted on the last stored day. */
|
||||
joined: ScorePillarId[];
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
export interface DomainReadState {
|
||||
source: DerivedProvenanceSource;
|
||||
readFailed: boolean;
|
||||
|
||||
@@ -2,10 +2,15 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ecgItemKey,
|
||||
healthScoreAlgorithmItemKey,
|
||||
healthScoreCompositionItemKey,
|
||||
milestoneItemKey,
|
||||
tensionWindowItemKey,
|
||||
} from "@/lib/daily/priority-item-key";
|
||||
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
|
||||
import {
|
||||
isDismissibleItemKey,
|
||||
isScoreNoticeItemKey,
|
||||
} from "@/lib/daily/priority-item";
|
||||
|
||||
describe("priority-item-key — deterministic dismiss identities", () => {
|
||||
it("milestoneItemKey folds kind + metric + reach day, namespaced `milestone:`", () => {
|
||||
@@ -76,4 +81,34 @@ describe("priority-item-key — deterministic dismiss identities", () => {
|
||||
expect(isDismissibleItemKey("sync_issue:withings")).toBe(false);
|
||||
expect(isDismissibleItemKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("healthScoreCompositionItemKey names the set, not the day", () => {
|
||||
const key = healthScoreCompositionItemKey(3, ["SLEEP", "BLOOD_PRESSURE"]);
|
||||
expect(key).toMatch(/^health-score:v3:composition:[0-9a-f]{12}$/);
|
||||
// Order in, same key out. The registry order the composite emits and the
|
||||
// order a stored row happens to hold must not be two identities for one
|
||||
// set, or the note would re-raise on a change that never happened.
|
||||
expect(healthScoreCompositionItemKey(3, ["BLOOD_PRESSURE", "SLEEP"])).toBe(
|
||||
key,
|
||||
);
|
||||
// A different set, and a different method, are both different notes.
|
||||
expect(healthScoreCompositionItemKey(3, ["SLEEP"])).not.toBe(key);
|
||||
expect(
|
||||
healthScoreCompositionItemKey(4, ["SLEEP", "BLOOD_PRESSURE"]),
|
||||
).not.toBe(key);
|
||||
});
|
||||
|
||||
it("the composition key dismisses and evicts, without widening what else does", () => {
|
||||
const composition = healthScoreCompositionItemKey(3, ["SLEEP"]);
|
||||
expect(isDismissibleItemKey(composition)).toBe(true);
|
||||
expect(isScoreNoticeItemKey(composition)).toBe(true);
|
||||
expect(isScoreNoticeItemKey(healthScoreAlgorithmItemKey(3))).toBe(true);
|
||||
// The eviction question is narrower than the dismissal question: an
|
||||
// observational rail item is dismissible and has nothing to do with the
|
||||
// score's cache.
|
||||
expect(
|
||||
isScoreNoticeItemKey(tensionWindowItemKey("2026-07-16", "morning")),
|
||||
).toBe(false);
|
||||
expect(isScoreNoticeItemKey("dose_window:anything")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
* dismissed one — a milestone by its reach day, an ECG recording by its own
|
||||
* timestamp, a tension window by the local day it was detected for.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Milestone } from "@/lib/daily/milestones";
|
||||
|
||||
/** `milestone:<kind>:<metricType>:<sinceDate>` — one key per durable state reached. */
|
||||
@@ -93,3 +95,43 @@ export function healthScoreNoticeItemKey(
|
||||
const base = healthScoreAlgorithmItemKey(scoreVersion);
|
||||
return configVersion >= 1 ? `${base}:config:${configVersion}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* `health-score:v<scoreVersion>:composition:<hash>` — one acknowledgement
|
||||
* per SET of pillars a score rests on.
|
||||
*
|
||||
* The third thing that can change what the number means, after the method
|
||||
* and the person's own recipe: a pillar stops counting on its own. A window
|
||||
* rolls past its floor, a module goes off, a source goes quiet — and the
|
||||
* level moves with nothing on the panel saying why. The delta is already
|
||||
* suppressed for exactly this case, which prevents a false "you dropped six
|
||||
* points" and says nothing about the six points.
|
||||
*
|
||||
* Keyed on the RESULTING set rather than on the day, and that choice is the
|
||||
* whole behaviour: a set that stays the same tomorrow resolves to the same
|
||||
* key, so a dismissal holds instead of the note reappearing every morning,
|
||||
* and the next real change hashes differently and raises it again. The cost
|
||||
* is that a set the person has already acknowledged stays acknowledged if
|
||||
* they later return to it — A → B, dismissed, B → A, B again is silent.
|
||||
* That is the honest reading of the dismissal: they have been told what a
|
||||
* score on set B rests on, and nothing about B has changed since.
|
||||
*
|
||||
* The ids are sorted before hashing, so the same set never produces two
|
||||
* keys through registry order alone, and the hash is truncated because the
|
||||
* key is an identity to compare, not a value to invert.
|
||||
*
|
||||
* A namespace of its own rather than a suffix under
|
||||
* `health_score_algorithm:` — dismissing "the rules moved" must not also
|
||||
* dismiss "a pillar left", and one prefix carrying both would make the two
|
||||
* indistinguishable to the ledger.
|
||||
*/
|
||||
export function healthScoreCompositionItemKey(
|
||||
scoreVersion: number,
|
||||
composition: readonly string[],
|
||||
): string {
|
||||
const hash = createHash("sha256")
|
||||
.update([...composition].sort().join(","))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
return `health-score:v${scoreVersion}:composition:${hash}`;
|
||||
}
|
||||
@@ -82,7 +82,32 @@ export function isDismissibleKind(
|
||||
* builder (which stamps the key) and the dismiss route's Zod schema (which
|
||||
* rejects anything else structurally, before a lookup ever runs).
|
||||
*/
|
||||
const DISMISSIBLE_NOTICE_PREFIXES = ["health_score_algorithm"] as const;
|
||||
const DISMISSIBLE_NOTICE_PREFIXES = [
|
||||
"health_score_algorithm",
|
||||
"health-score",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The notice prefixes whose dismissal changes what the score report says,
|
||||
* and therefore has to evict the cached report.
|
||||
*
|
||||
* Both entries are score notices — the method/recipe note and the
|
||||
* composition note. Named rather than repeated as a literal at the dismiss
|
||||
* route, because the route matched exactly one of them for a release and a
|
||||
* second one added there by hand is a dismissal that appears to work and
|
||||
* comes back on the next read.
|
||||
*/
|
||||
export const SCORE_NOTICE_ITEM_KEY_PREFIXES = [
|
||||
"health_score_algorithm",
|
||||
"health-score",
|
||||
] as const;
|
||||
|
||||
/** Whether dismissing `itemKey` invalidates the cached Health Score report. */
|
||||
export function isScoreNoticeItemKey(itemKey: string): boolean {
|
||||
return SCORE_NOTICE_ITEM_KEY_PREFIXES.some((prefix) =>
|
||||
itemKey.startsWith(`${prefix}:`),
|
||||
);
|
||||
}
|
||||
|
||||
export function isDismissibleItemKey(itemKey: string): boolean {
|
||||
return (
|
||||
|
||||
@@ -405,6 +405,18 @@ const healthScoreReport = z
|
||||
algorithmNotice: z
|
||||
.object({ itemKey: z.string(), dismissed: z.boolean() })
|
||||
.nullable(),
|
||||
compositionNotice: z
|
||||
.object({
|
||||
itemKey: z.string(),
|
||||
left: z.array(scorePillarIdEnum),
|
||||
joined: z.array(scorePillarIdEnum),
|
||||
dismissed: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"A pillar joined or left the set behind the number since the account's last stored local day. Raised only for a change nobody chose: a method move or a recipe change is the algorithm notice's subject instead, and raising both would announce one event twice. Optional so a payload cached before the field existed stays valid; null when there is nothing to say. Dismissed through `POST /api/daily/digest/dismiss` with this `itemKey`, which is keyed on the resulting SET so it re-raises when the set moves again rather than every day.",
|
||||
),
|
||||
restMode: z
|
||||
.object({
|
||||
active: z.literal(true),
|
||||
|
||||
@@ -2068,6 +2068,18 @@ export const healthScoreReport = z
|
||||
.describe(
|
||||
"The once-per-account notice that the scoring rules moved; null when there is nothing to say.",
|
||||
),
|
||||
compositionNotice: z
|
||||
.object({
|
||||
itemKey: z.string(),
|
||||
left: z.array(z.string()),
|
||||
joined: z.array(z.string()),
|
||||
dismissed: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"A pillar joined or left the set behind the number since the account's last stored local day. Raised only for a change nobody chose — a method move or a recipe change is the algorithm notice's subject, and raising both would announce one event twice. Null when there is nothing to say.",
|
||||
),
|
||||
restMode: z
|
||||
.object({
|
||||
active: z.literal(true),
|
||||
|
||||
Reference in new issue
Block a user