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:
Marc-André Bombeck committed 2026-08-30 16:16:57 +02:00
1 parent 319a75b02f
commit e4880dcdc1
28 files changed
+1384 -16

No files matched your search

+45
View File
@@ -50111,6 +50111,51 @@ components:
- dismissed
additionalProperties: false
- type: "null"
compositionNotice:
description: "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."
anyOf:
- type: object
properties:
itemKey:
type: string
left:
type: array
items:
type: string
enum:
- BLOOD_PRESSURE
- GLYCAEMIA
- ACTIVITY
- SLEEP
- ADIPOSITY
- WELLBEING
- LIPIDS
joined:
type: array
items:
type: string
enum:
- BLOOD_PRESSURE
- GLYCAEMIA
- ACTIVITY
- SLEEP
- ADIPOSITY
- WELLBEING
- LIPIDS
dismissed:
type: boolean
required:
- itemKey
- left
- joined
- dismissed
additionalProperties: false
- type: "null"
restMode:
description: Attached when an illness episode was active as of the scored instant — the instant the score was computed
for, not a fresh now. The score is NOT penalised; this frames it. Null when no episode was open. On this
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Noch hat nichts genug aktuelle Daten für einen Score. Ein Feld der Gesundheit reicht für den Anfang, und der Score nennt selbst, worauf er beruht.",
"basis": "Beruht auf {count} von {recommended} Feldern der Gesundheit.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "Zählt nicht mehr in den Score: {pillars}.",
"joined": "Zählt jetzt in den Score: {pillars}."
},
"anatomyToggle": "Wie dieser Score zustande kommt",
"notScored": "Noch nicht bewertet ({count})",
"method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.",
@@ -5277,6 +5282,11 @@
"body": "Seit {date} wird dein Score aus einer anderen Zusammenstellung berechnet. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch.",
"bodyUndated": "Dein Score wird aus einer anderen Zusammenstellung berechnet als früher. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch."
},
"composition": {
"title": "Was zählt, hat sich von selbst geändert",
"left": "Zählt nicht mehr in deinen Score: {pillars}. Die Zahl hat sich deshalb bewegt und nicht, weil sich deine Gesundheit geändert hätte.",
"joined": "Zählt jetzt in deinen Score: {pillars}."
},
"dismiss": "Verstanden",
"dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Nothing has enough recent data to score yet. One area of health is enough to start, and the score will say which areas it rests on.",
"basis": "Based on {count} of {recommended} areas of health.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "No longer counting toward the score: {pillars}.",
"joined": "Now counting toward the score: {pillars}."
},
"anatomyToggle": "How this score comes about",
"notScored": "Not scored yet ({count})",
"method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.",
@@ -5277,6 +5282,11 @@
"body": "Since {date} your score is worked out from a different set of pillars. Days before that were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it.",
"bodyUndated": "Your score is worked out from a different set of pillars than it used to be. Days before the change were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it."
},
"composition": {
"title": "What counts changed on its own",
"left": "No longer counting toward your score: {pillars}. The number moved for that reason, not because your health did.",
"joined": "Now counting toward your score: {pillars}."
},
"dismiss": "Got it",
"dismissError": "Couldn't save that. Please try again."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Todavía no hay datos recientes suficientes para puntuar nada. Con un solo ámbito de salud basta para empezar, y la puntuación dirá en qué ámbitos se basa.",
"basis": "Basado en {count} de {recommended} ámbitos de salud.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "Ya no cuenta para la puntuación: {pillars}.",
"joined": "Ahora cuenta para la puntuación: {pillars}."
},
"anatomyToggle": "Cómo se forma esta puntuación",
"notScored": "Aún sin puntuar ({count})",
"method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.",
@@ -5277,6 +5282,11 @@
"body": "Desde el {date} tu puntuación se calcula con otro conjunto de pilares. Los días anteriores salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción.",
"bodyUndated": "Tu puntuación se calcula con un conjunto de pilares distinto al anterior. Los días previos salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción."
},
"composition": {
"title": "Lo que cuenta ha cambiado por su cuenta",
"left": "Ya no cuenta para tu puntuación: {pillars}. El número se movió por eso, no porque haya cambiado tu salud.",
"joined": "Ahora cuenta para tu puntuación: {pillars}."
},
"dismiss": "Entendido",
"dismissError": "No se pudo guardar. Inténtalo de nuevo."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Rien ne dispose encore d'assez de données récentes pour être noté. Un seul domaine de santé suffit pour commencer, et le score indiquera sur quels domaines il repose.",
"basis": "Basé sur {count} domaines de santé sur {recommended}.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "Ne compte plus dans le score : {pillars}.",
"joined": "Compte désormais dans le score : {pillars}."
},
"anatomyToggle": "Comment ce score est établi",
"notScored": "Pas encore évalué ({count})",
"method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.",
@@ -5277,6 +5282,11 @@
"body": "Depuis le {date}, votre score est calculé à partir d'un autre ensemble de piliers. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture.",
"bodyUndated": "Votre score est calculé à partir d'un autre ensemble de piliers qu'auparavant. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture."
},
"composition": {
"title": "Ce qui compte a changé tout seul",
"left": "Ne compte plus dans votre score : {pillars}. Le chiffre a bougé pour cette raison, et non parce que votre santé a changé.",
"joined": "Compte désormais dans votre score : {pillars}."
},
"dismiss": "Compris",
"dismissError": "Enregistrement impossible. Veuillez réessayer."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Non c'è ancora niente con abbastanza dati recenti da valutare. Un solo ambito di salute basta per iniziare, e il punteggio dirà su quali ambiti si basa.",
"basis": "Basato su {count} ambiti di salute su {recommended}.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "Non conta più nel punteggio: {pillars}.",
"joined": "Ora conta nel punteggio: {pillars}."
},
"anatomyToggle": "Come nasce questo punteggio",
"notScored": "Non ancora valutato ({count})",
"method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.",
@@ -5277,6 +5282,11 @@
"body": "Dal {date} il tuo punteggio è calcolato su un insieme di pilastri diverso. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni.",
"bodyUndated": "Il tuo punteggio è calcolato su un insieme di pilastri diverso da prima. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni."
},
"composition": {
"title": "Cosa conta è cambiato da sé",
"left": "Non conta più nel tuo punteggio: {pillars}. Il numero si è mosso per questo, non perché sia cambiata la tua salute.",
"joined": "Ora conta nel tuo punteggio: {pillars}."
},
"dismiss": "Ho capito",
"dismissError": "Non è stato possibile salvare. Riprova."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "아직 점수를 매길 만큼 최근 데이터가 쌓인 곳이 없어요. 건강 영역 하나면 시작할 수 있고, 점수가 어떤 영역을 바탕으로 하는지 알려 줘요.",
"basis": "건강 영역 {recommended}개 중 {count}개를 바탕으로 해요.",
"compositionNotice": {
"item": "{pillar}({reason})",
"left": "점수에 더 이상 반영하지 않아요: {pillars}.",
"joined": "이제 점수에 반영해요: {pillars}."
},
"anatomyToggle": "이 점수가 만들어지는 방식",
"notScored": "아직 반영되지 않음 ({count})",
"method": "점수는 반영 대상 항목을 같은 비중으로 평균 낸 값이고, 0에서 100까지예요. 각 항목은 공개된 참고 구간에 맞춰 채점되고, 가장 약한 항목이 전체 구간을 정할 수 있어요. 최근 데이터가 부족한 항목은 0점으로 세지 않고 빼 둬요. 참고 구간은 공개된 출처에서 가져왔고, 어떤 항목을 반영할지는 직접 고르는 값이라 합쳐진 숫자 자체가 임상 기준은 아니에요.",
@@ -5277,6 +5282,11 @@
"body": "{date}부터는 다른 항목 조합으로 점수를 계산해요. 그 전 날짜는 예전 조합으로 계산해서 서로 비교하지 않고, 비교 구간에서 변경 시점이 빠질 때까지 주간 변화는 멈춰 있어요. 각 항목의 자체 기록은 끊기지 않고 이어져요.",
"bodyUndated": "예전과 다른 항목 조합으로 점수를 계산해요. 변경 전 날짜는 예전 조합으로 계산해서 서로 비교하지 않고, 비교 구간에서 변경 시점이 빠질 때까지 주간 변화는 멈춰 있어요. 각 항목의 자체 기록은 끊기지 않고 이어져요."
},
"composition": {
"title": "반영 항목이 저절로 바뀌었어요",
"left": "점수에 더 이상 반영하지 않아요: {pillars}. 건강이 달라져서가 아니라 이 때문에 숫자가 움직였어요.",
"joined": "이제 점수에 반영해요: {pillars}."
},
"dismiss": "확인",
"dismissError": "저장하지 못했어요. 다시 시도해 주세요."
}
+10
View File
@@ -2455,6 +2455,11 @@
},
"insufficient": "Nic nie ma jeszcze wystarczająco świeżych danych, żeby to ocenić. Na początek wystarczy jeden obszar zdrowia, a wynik sam powie, na których obszarach się opiera.",
"basis": "Opiera się na {count} z {recommended} obszarów zdrowia.",
"compositionNotice": {
"item": "{pillar} ({reason})",
"left": "Nie liczy się już do wyniku: {pillars}.",
"joined": "Teraz liczy się do wyniku: {pillars}."
},
"anatomyToggle": "Jak powstaje ten wynik",
"notScored": "Jeszcze bez oceny ({count})",
"method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.",
@@ -5277,6 +5282,11 @@
"body": "Od {date} Twój wynik liczony jest z innego zestawu filarów. Dni wcześniejsze pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy.",
"bodyUndated": "Twój wynik liczony jest z innego zestawu filarów niż wcześniej. Dni sprzed zmiany pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy."
},
"composition": {
"title": "To, co się liczy, zmieniło się samo",
"left": "Nie liczy się już do Twojego wyniku: {pillars}. Liczba zmieniła się z tego powodu, a nie dlatego, że zmieniło się Twoje zdrowie.",
"joined": "Teraz liczy się do Twojego wyniku: {pillars}."
},
"dismiss": "Rozumiem",
"dismissError": "Nie udało się zapisać. Spróbuj ponownie."
}
@@ -35,6 +35,9 @@ vi.mock("@/lib/db", () => ({
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
labResult: { findMany: vi.fn().mockResolvedValue([]) },
dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
// v1.38 — the composition note reads the account's last stored score
// day. Nothing stored in these fixtures, so no note is raised.
healthScoreRecord: { findFirst: vi.fn().mockResolvedValue(null) },
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. The
// v1.4.36 per-type coverage probe and the v1.4.37 default-slice
// probe also ride `$queryRaw`. Default to an empty coverage map so
+5 -1
View File
@@ -27,6 +27,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { isScoreNoticeItemKey } from "@/lib/daily/priority-item";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
@@ -72,7 +73,10 @@ export const POST = apiHandler(async (req: NextRequest) => {
update: {},
create: { userId: user.id, itemKey },
});
if (itemKey.startsWith("health_score_algorithm:")) {
// Every score notice, not just the method one: the report resolves each
// notice's `dismissed` flag at compute time, so a dismissal the cache
// outlives comes straight back on the next read.
if (isScoreNoticeItemKey(itemKey)) {
invalidateUserHealthScore(user.id);
}
@@ -12,6 +12,7 @@ import {
HealthScoreCard,
HealthScoreCardSkeleton,
markAlgorithmNoticeDismissed,
markCompositionNoticeDismissed,
} from "../health-score-card";
import {
pillarDetailLines,
@@ -1064,3 +1065,154 @@ describe("<HealthScoreCard> basis line", () => {
expect(html).not.toContain('data-slot="health-score-card-progress"');
});
});
/**
* v1.38 — the line that says why the number moved when nobody moved it.
*
* The delta was already suppressed for this case, which is the half of the
* job that stops a lie. This is the other half: the level changed, and
* until now the panel said nothing at all about it.
*
* The reason comes from the departed pillar's own row, so the line and the
* anatomy list can never disagree — asserted by comparing against the
* string the list itself renders rather than against a copy of the copy.
*/
describe("<HealthScoreCard> composition notice", () => {
function withNotice(
notice: HealthScoreReport["compositionNotice"],
pillars = [
BLOOD_PRESSURE,
gatedPillar("SLEEP", "below_night_floor_or_stale"),
],
): HealthScoreReport {
return scoredReport({ pillars, compositionNotice: notice });
}
const SLEEP_LEFT: NonNullable<HealthScoreReport["compositionNotice"]> = {
itemKey: "health-score:v3:composition:abc123abc123",
left: ["SLEEP"],
joined: [],
dismissed: false,
};
it("names the departed pillar and the reason its own row gives", () => {
const html = render(
<HealthScoreCard report={withNotice({ ...SLEEP_LEFT })} />,
);
const line = html.match(
/data-slot="health-score-composition-notice"[^>]*>([^<]*)</,
);
expect(line, "the composition line is missing").not.toBeNull();
expect(line![1]).toContain("Sleep");
// The exact reason the anatomy list uses for that pillar, not a second
// wording of it.
expect(line![1]).toContain("Not enough recent eligible data");
});
it("says a pillar joined without inventing a reason for it", () => {
const html = render(
<HealthScoreCard
report={withNotice({
itemKey: "health-score:v3:composition:def456def456",
left: [],
joined: ["LIPIDS"],
dismissed: false,
})}
/>,
);
const line = html.match(
/data-slot="health-score-composition-notice"[^>]*>([^<]*)</,
);
expect(line![1]).toContain("Lipids");
expect(line![1]).not.toContain("Not enough recent eligible data");
});
it("falls back to the generic verdict for a pillar with no row left at all", () => {
// Switching a module off stops the scorer emitting the pillar. It is
// not waiting for anything, and claiming it is would nag a person in
// the voice of their own settings.
const html = render(
<HealthScoreCard
report={withNotice({ ...SLEEP_LEFT }, [BLOOD_PRESSURE])}
/>,
);
const line = html.match(
/data-slot="health-score-composition-notice"[^>]*>([^<]*)</,
);
expect(line![1]).toContain("Sleep");
expect(line![1]).toContain("This pillar is not currently eligible");
});
it("stays quiet once it has been acknowledged", () => {
const html = render(
<HealthScoreCard
report={withNotice({ ...SLEEP_LEFT, dismissed: true })}
/>,
);
expect(html).not.toContain('data-slot="health-score-composition-notice"');
});
it("stays quiet when the report carries no notice at all", () => {
expect(render(<HealthScoreCard report={scoredReport()} />)).not.toContain(
'data-slot="health-score-composition-notice"',
);
expect(render(<HealthScoreCard report={withNotice(null)} />)).not.toContain(
'data-slot="health-score-composition-notice"',
);
});
it("is a muted line at rest, not a gate and not an alert", () => {
const rest = atRest(
render(<HealthScoreCard report={withNotice({ ...SLEEP_LEFT })} />),
);
expect(rest).toContain('data-slot="health-score-composition-notice"');
expect(rest).toMatch(
/data-slot="health-score-composition-notice"[^>]*class="[^"]*text-muted-foreground/,
);
expect(rest).not.toContain('role="alert"');
expect(rest).not.toContain('data-slot="health-score-insufficient"');
});
});
describe("markCompositionNoticeDismissed", () => {
it("patches the composition notice and leaves the algorithm notice alone", () => {
const payload = {
healthScore: report({
algorithmNotice: {
itemKey: "health_score_algorithm:3",
dismissed: false,
},
compositionNotice: {
itemKey: "health-score:v3:composition:abc123abc123",
left: ["SLEEP"],
joined: [],
dismissed: false,
},
}),
};
const patched = markCompositionNoticeDismissed(
payload,
"health-score:v3:composition:abc123abc123",
) as { healthScore: HealthScoreReport };
expect(patched.healthScore.compositionNotice!.dismissed).toBe(true);
expect(patched.healthScore.algorithmNotice!.dismissed).toBe(false);
});
it("leaves a payload alone when the key names a different set", () => {
const payload = {
healthScore: report({
compositionNotice: {
itemKey: "health-score:v3:composition:abc123abc123",
left: ["SLEEP"],
joined: [],
dismissed: false,
},
}),
};
const patched = markCompositionNoticeDismissed(
payload,
"health-score:v3:composition:999999999999",
) as { healthScore: HealthScoreReport };
expect(patched.healthScore.compositionNotice!.dismissed).toBe(false);
});
});
+93 -3
View File
@@ -159,6 +159,30 @@ const RETURN_METRIC_LABEL_KEY: Record<string, string> = {
export function markAlgorithmNoticeDismissed(
payload: unknown,
itemKey: string,
): unknown {
return markNoticeDismissed(payload, "algorithmNotice", itemKey);
}
/**
* The same patch for the composition note.
*
* Its own entry point rather than a loop over both fields: dismissing one
* notice must never flip the other, and the two keys live in different
* namespaces precisely so they cannot be confused. A shared walker that
* matched "whichever notice carries this key" would work today and would
* quietly do the wrong thing the day the two namespaces collide.
*/
export function markCompositionNoticeDismissed(
payload: unknown,
itemKey: string,
): unknown {
return markNoticeDismissed(payload, "compositionNotice", itemKey);
}
function markNoticeDismissed(
payload: unknown,
field: "algorithmNotice" | "compositionNotice",
itemKey: string,
): unknown {
if (!payload || typeof payload !== "object" || !("healthScore" in payload)) {
return payload;
@@ -167,11 +191,11 @@ export function markAlgorithmNoticeDismissed(
if (
!healthScore ||
typeof healthScore !== "object" ||
!("algorithmNotice" in healthScore)
!(field in healthScore)
) {
return payload;
}
const notice = healthScore.algorithmNotice;
const notice = (healthScore as Record<string, unknown>)[field];
if (
!notice ||
typeof notice !== "object" ||
@@ -184,7 +208,7 @@ export function markAlgorithmNoticeDismissed(
...payload,
healthScore: {
...healthScore,
algorithmNotice: { ...notice, dismissed: true },
[field]: { ...notice, dismissed: true },
},
};
}
@@ -194,6 +218,21 @@ function gateReason(pillar: ScorePillarResult): string | null {
return pillar.result.status === "insufficient" ? pillar.result.reason : null;
}
/**
* Why a pillar that used to count no longer does.
*
* The report's own row for that pillar is the source, so the departure line
* and the anatomy list can never say two different things about one pillar.
* A pillar with NO row is not an error: turning its module off closes the
* data path deliberately and the scorer stops emitting the pillar
* altogether. The empty string falls through `REASON_KEY` to the generic
* "not currently eligible", which is exactly what is true of it.
*/
function reasonOf(report: HealthScoreReport, id: string): string {
const pillar = report.pillars.find((entry) => entry.id === id);
return (pillar ? gateReason(pillar) : null) ?? "";
}
export interface HealthScoreCardProps {
report: HealthScoreReport;
/**
@@ -317,6 +356,49 @@ export function HealthScoreCard({
})
: null;
// v1.38 — why the number moved when nobody moved it. A pillar whose
// window rolled past its floor, whose module went off or whose source
// went quiet changes the set the mean is taken over; the delta is
// already suppressed so the move is not narrated as a health event, and
// until now that was the whole answer — the level changed and the panel
// said nothing.
//
// One muted line, and the reason comes from the pillar's own row rather
// than from a second vocabulary: whatever the anatomy list would say
// about that pillar today is what this says. A pillar with no row at all
// (its module is off, so it is not waiting for anything) falls through
// to the same "not currently eligible" the list uses.
//
// Not a push, not a toast, not a badge. The person is looking at the
// number this explains.
const compositionNotice = report.compositionNotice ?? null;
const compositionLine =
compositionNotice && !compositionNotice.dismissed
? [
compositionNotice.left.length > 0
? t("insights.healthScore.compositionNotice.left", {
pillars: compositionNotice.left
.map((id) =>
t("insights.healthScore.compositionNotice.item", {
pillar: t(PILLAR_LABEL_KEY[id]),
reason: reasonText(reasonOf(report, id)),
}),
)
.join(", "),
})
: null,
compositionNotice.joined.length > 0
? t("insights.healthScore.compositionNotice.joined", {
pillars: compositionNotice.joined
.map((id) => t(PILLAR_LABEL_KEY[id]))
.join(", "),
})
: null,
]
.filter((line): line is string => line !== null)
.join(" ") || null
: null;
return (
<div
data-slot="health-score-card"
@@ -415,6 +497,14 @@ export function HealthScoreCard({
{basisLine}
</p>
) : null}
{compositionLine ? (
<p
data-slot="health-score-composition-notice"
className="text-muted-foreground text-xs"
>
{compositionLine}
</p>
) : null}
{report.delta != null ? (
<p
data-slot="health-score-delta"
@@ -0,0 +1,91 @@
/**
* The settings half of the composition note.
*
* Its subject is the one change on this page nobody made: the person did
* not touch the rows below it, the method did not move, and the number is
* different anyway. So the two things worth pinning are that it names the
* pillars in the reader's own language, and that it never borrows the
* recipe note's sentences — telling someone they changed something they did
* not change is worse than saying nothing, which is what shipped before.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import type { ScorePillarId } from "@/lib/analytics/score/types";
import { ScoreCompositionNotice } from "../score-composition-notice";
function render(
props: { left: ScorePillarId[]; joined: ScorePillarId[] },
locale: Locale = "en",
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ScoreCompositionNotice
left={props.left}
joined={props.joined}
onDismiss={() => {}}
/>
</I18nProvider>,
);
}
describe("a pillar that stopped counting on its own", () => {
it("names the pillar and says the number moved for that reason", () => {
const html = render({ left: ["SLEEP"], joined: [] });
expect(html).toContain('data-slot="score-composition-notice"');
expect(html).toContain("What counts changed on its own");
expect(html).toContain("Sleep");
expect(html).toContain("not because your health did");
});
it("never says the person changed anything", () => {
const html = render({ left: ["SLEEP"], joined: [] });
expect(html).not.toContain("You changed what counts");
expect(html).not.toContain("What counts is now yours to choose");
});
it("says nothing about a pillar that joined when none did", () => {
const html = render({ left: ["SLEEP"], joined: [] });
expect(html).not.toContain("Now counting toward your score");
});
});
describe("both directions at once", () => {
it("names what left and what arrived, each once", () => {
const html = render({ left: ["SLEEP"], joined: ["LIPIDS"] });
expect(html).toContain("Sleep");
expect(html).toContain("Lipids");
expect(html).toContain("Now counting toward your score");
});
});
describe("the reader's own language", () => {
it("carries the German copy with its umlauts", () => {
const html = render({ left: ["SLEEP"], joined: [] }, "de");
expect(html).toContain("Was zählt, hat sich von selbst geändert");
expect(html).not.toContain("What counts changed on its own");
});
it("keeps the Korean register the rest of the page uses", () => {
const html = render({ left: ["SLEEP"], joined: [] }, "ko");
expect(html).toContain("반영 항목이 저절로 바뀌었어요");
expect(html).not.toContain("What counts changed on its own");
});
});
describe("acknowledging it", () => {
it("offers the same dismiss the recipe note offers", () => {
const html = render({ left: ["SLEEP"], joined: [] });
expect(html).toContain('data-slot="score-composition-notice-dismiss"');
expect(html).toContain("Got it");
});
});
@@ -0,0 +1,100 @@
"use client";
/**
* "A pillar stopped counting" — the settings half of the composition note.
*
* It sits beside `<ScoreChangeNotice>` rather than inside it because the two
* answer different questions with different remedies. That one says the
* rules or the recipe moved and the person is one tap from the rows that
* decide the recipe. This one says the DATA moved: nobody changed anything,
* a window rolled past its floor or a source went quiet, and the row for
* that pillar on this page already shows what it is waiting for.
*
* Reasons are deliberately not repeated here. The insights panel states
* them beside the number they explain, and the pillar rows below this card
* carry the server's own verdict per pillar; a third copy on this surface
* would be a third place to drift.
*
* Dismissal is per key and the key is the resulting SET, so acknowledging
* this note holds until the set moves again — not until tomorrow.
*/
import { Info, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
import type { ScorePillarId } from "@/lib/analytics/score/types";
export interface ScoreCompositionNoticeProps {
/** Pillars that counted on the last stored day and do not count now. */
left: ScorePillarId[];
/** Pillars that count now and did not on the last stored day. */
joined: ScorePillarId[];
onDismiss: () => void;
dismissing?: boolean;
}
export function ScoreCompositionNotice({
left,
joined,
onDismiss,
dismissing = false,
}: ScoreCompositionNoticeProps) {
const { t } = useTranslations();
function names(ids: ScorePillarId[]): string {
return ids.map((id) => t(SCORE_PILLAR_LABEL_KEYS[id])).join(", ");
}
// Written out rather than assembled from a discriminator so the i18n
// call-site guard can see every key this component can reach.
const leftLine =
left.length > 0
? t("settings.sections.score.notice.composition.left", {
pillars: names(left),
})
: null;
const joinedLine =
joined.length > 0
? t("settings.sections.score.notice.composition.joined", {
pillars: names(joined),
})
: null;
return (
<SettingsCard
as="section"
data-slot="score-composition-notice"
aria-labelledby="score-composition-notice-title"
>
<SettingsCardHeader
icon={Info}
titleId="score-composition-notice-title"
title={t("settings.sections.score.notice.composition.title")}
/>
<p data-slot="score-composition-notice-body" className="text-sm">
{[leftLine, joinedLine].filter(Boolean).join(" ")}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDismiss}
disabled={dismissing}
data-slot="score-composition-notice-dismiss"
className="min-h-11 sm:min-h-9"
>
{dismissing ? (
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.sections.score.notice.dismiss")}
</Button>
</SettingsCard>
);
}
+47 -1
View File
@@ -60,8 +60,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ScoreChangeNotice } from "@/components/settings/score-change-notice";
import { ScoreCompositionNotice } from "@/components/settings/score-composition-notice";
import { ScorePillarRow } from "@/components/settings/score-pillar-row";
import { markAlgorithmNoticeDismissed } from "@/components/insights/health-score-card";
import {
markAlgorithmNoticeDismissed,
markCompositionNoticeDismissed,
} from "@/components/insights/health-score-card";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
@@ -267,6 +271,26 @@ export function ScoreSection() {
},
});
const dismissCompositionMutation = useMutation({
mutationFn: async (itemKey: string) => {
await apiPost("/api/daily/digest/dismiss", { itemKey });
return itemKey;
},
onSuccess: (itemKey) => {
// Same reasoning as the notice above, against its own field: the
// insights panel renders this note too, and a stale
// `dismissed: false` there would put it back the moment the person
// navigates away from this page.
queryClient.setQueryData(queryKeys.analytics(), (previous: unknown) =>
markCompositionNoticeDismissed(previous, itemKey),
);
void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
},
onError: () => {
toast.error(t("settings.sections.score.notice.dismissError"));
},
});
function toggle(id: ScorePillarId, next: boolean) {
setRefusal(null);
setDraft((current) => {
@@ -281,6 +305,17 @@ export function ScoreSection() {
const showNotice =
notice != null && !notice.dismissed && configQuery.isSuccess;
// The composition note stands on its own read: it says the DATA behind
// the number moved, which is true whether or not the configuration read
// has landed, and it names no configuration value. Gating it on
// `configQuery.isSuccess` the way the notice above is gated would hide it
// for the one account whose settings read is the thing that failed.
const composition = report?.compositionNotice ?? null;
const showComposition =
composition != null &&
!composition.dismissed &&
(composition.left.length > 0 || composition.joined.length > 0);
const busy = saveMutation.isPending;
return (
@@ -294,6 +329,17 @@ export function ScoreSection() {
/>
) : null}
{showComposition && composition ? (
<ScoreCompositionNotice
left={composition.left}
joined={composition.joined}
dismissing={dismissCompositionMutation.isPending}
onDismiss={() =>
dismissCompositionMutation.mutate(composition.itemKey)
}
/>
) : null}
{configQuery.isError ? (
<QueryErrorCard
title={t("settings.sections.score.loadFailed")}
@@ -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]);
}
+90 -9
View File
@@ -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);
}
+41
View File
@@ -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);
});
});
+42
View File
@@ -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}`;
}
+26 -1
View File
@@ -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 (
+12
View File
@@ -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),
@@ -31,6 +31,8 @@ process.env.ENCRYPTION_KEY ??=
import { caches } from "@/lib/cache/server-cache";
import { encrypt } from "@/lib/crypto";
import { SCORE_VERSION } from "@/lib/analytics/score/types";
import { healthScoreCompositionItemKey } from "@/lib/daily/priority-item-key";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -82,6 +84,12 @@ interface AnalyticsEnvelope {
status: "ok" | "insufficient";
value?: { score: number; band: string; composition: string[] };
};
compositionNotice?: {
itemKey: string;
left: string[];
joined: string[];
dismissed: boolean;
} | null;
} | null;
} | null;
}
@@ -441,3 +449,191 @@ describe("delete all data", () => {
).toBe(0);
});
});
/**
* v1.38 — the stored day's first production reader.
*
* Until this release nothing but backup and restore ever read this table.
* The composition note compares today's set against the last stored LOCAL
* DAY, which means a restore that brings the rows back as something subtly
* different does not fail loudly — it produces a note that is silently
* wrong, or no note where one was owed. So the claim proved here is the
* round trip: raise the note, wipe the table, restore it through the real
* admin route, and get the same note back.
*/
describe("the composition note reads the stored day, and survives a restore", () => {
/** Yesterday's day, as the account's own clock cut it. */
async function seedPriorDay(
userId: string,
now: number,
composition: string[],
) {
const dayKey = new Date(now - DAY).toISOString().slice(0, 10);
await getPrismaClient().healthScoreRecord.create({
data: {
userId,
dayKey,
timezone: "UTC",
composite: 80,
band: "green",
scoreVersion: SCORE_VERSION,
composition,
pillarScores: Object.fromEntries(composition.map((id) => [id, 80])),
inputFingerprint: "0".repeat(64),
configVersion: 0,
configChangedAt: null,
computedAt: new Date(now - DAY),
},
});
return dayKey;
}
function evictScoreCaches(userId: string) {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.insightsDerived.deleteByPrefix(`${userId}|`);
}
it("names the pillar that stopped counting since the last stored day", async () => {
const user = await seedSession("hsc-notice");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
// Activity counted yesterday and has no data at all today, which is the
// shape a rolled window leaves behind.
await seedPriorDay(user.id, now, [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
]);
const shown = (await readAnalytics()).data!.healthScore!;
expect(shown.composite.status).toBe("ok");
const notice = shown.compositionNotice;
expect(notice, "no composition notice was raised").toBeTruthy();
expect(notice!.left).toContain("ACTIVITY");
expect(notice!.joined).toEqual([]);
expect(notice!.dismissed).toBe(false);
// The key names the resulting SET, so it is derivable from what the
// same response says the score is made of.
expect(notice!.itemKey).toBe(
healthScoreCompositionItemKey(
SCORE_VERSION,
shown.composite.value!.composition,
),
);
});
it("says nothing when the stored day holds the same set", async () => {
// The counter-case. Without it the assertion above would pass against a
// reader that raised a note on every single request.
const user = await seedSession("hsc-quiet");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
const first = (await readAnalytics()).data!.healthScore!;
expect(first.compositionNotice ?? null).toBeNull();
await seedPriorDay(user.id, now, [...first.composite.value!.composition]);
evictScoreCaches(user.id);
const second = (await readAnalytics()).data!.healthScore!;
expect(second.compositionNotice ?? null).toBeNull();
});
it("raises the same note again after the table has been through a restore", async () => {
const prisma = getPrismaClient();
const admin = await seedSession("hsc-restore", "ADMIN");
const now = Date.now();
await seedBp(admin.id, now, 20, 122);
await seedSleep(admin.id, now, 14);
await seedWaist(admin.id, now);
await seedPriorDay(admin.id, now, [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
]);
const before = (await readAnalytics()).data!.healthScore!.compositionNotice;
expect(before, "the fixture raised no note to round-trip").toBeTruthy();
const { payload } = await buildFullBackupPayload(prisma, admin.id, {
purpose: "disaster-recovery",
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "HEALTH_SCORE_COMPOSITION_RESTORE_TEST",
data: encrypt(JSON.stringify(payload)),
},
});
// Both days go, so a restore that carries nothing cannot pass by
// leaving the row the comparison needs.
await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } });
evictScoreCaches(admin.id);
// And with the stored days gone there is nothing to compare against,
// which is what makes the restore below the thing under test rather
// than a formality.
expect(
(await readAnalytics()).data!.healthScore!.compositionNotice ?? null,
).toBeNull();
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const res = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(res.status).toBe(200);
evictScoreCaches(admin.id);
const after = (await readAnalytics()).data!.healthScore!.compositionNotice;
expect(after).toEqual(before);
});
it("holds the dismissal across the restore, keyed on the set rather than the day", async () => {
const prisma = getPrismaClient();
const user = await seedSession("hsc-dismissed");
const now = Date.now();
await seedBp(user.id, now, 20, 122);
await seedSleep(user.id, now, 14);
await seedWaist(user.id, now);
await seedPriorDay(user.id, now, [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
]);
const raised = (await readAnalytics()).data!.healthScore!.compositionNotice;
expect(raised!.dismissed).toBe(false);
const { POST } = await import("@/app/api/daily/digest/dismiss/route");
const res = await (POST as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/daily/digest/dismiss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ itemKey: raised!.itemKey }),
}),
);
expect(res.status).toBe(200);
expect(
await prisma.dismissedPriorityItem.count({
where: { userId: user.id, itemKey: raised!.itemKey },
}),
).toBe(1);
evictScoreCaches(user.id);
const again = (await readAnalytics()).data!.healthScore!.compositionNotice;
expect(again!.itemKey).toBe(raised!.itemKey);
expect(again!.dismissed).toBe(true);
});
});