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

@@ -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")}