Files
HealthLog/src/components/settings/score-section.tsx
T
Marc-André Bombeck bf0a637bab Keep the score's own coverage honest about what it counts
The composite's coverage counts distinct areas of health, not pillars.
The learning line called them pillars, so an account recording blood
pressure, glycaemia and cholesterol read that it had one eligible pillar
while three sat in front of it. The count was right and stays; the noun
was wrong.

The meter beneath the score carried the same confusion further. Its
fraction is the three-area minimum the score requires, and a score only
exists once that floor is met, so it rendered as full coverage for every
account that has one. The arithmetic is untouched. Below the floor the
fraction is a real number and is shown as one; at or above it, the floor
is stated. The dots and the percentage were always honest, they track how
much history is behind the number, and they stay.

Both are held down by an AST guard rather than a text search, with a
counter-test for the neighbouring lines that are legitimately about
pillars and legitimately about a real fraction.
2026-07-31 19:05:20 +02:00

452 lines
17 KiB
TypeScript

"use client";
/**
* `<ScoreSection>` — Settings → Health Score. v1.35.0.
*
* The surface that decides which pillars count toward the Health Score.
* It is its own page rather than a corner of the modules screen, because
* the two answer different questions and conflating them is what made the
* old behaviour invisible: the module switches quietly decided the
* score's composition while claiming to decide what is recorded and shown.
*
* What the page holds to:
*
* - **One concept, one card.** The rows, the starting points and the
* save all sit in the same card. A top half and a bottom half of the
* same decision is the split the settings IA rules forbid.
* - **Grouped by domain, and the grouping is visible.** Blood pressure,
* glycaemia and lipids sit under one heading with a sentence saying
* they are one area of health carrying three pillars. Equal weight per
* pillar is not equal weight per area, and until this page nothing in
* the app said so.
* - **Only the score toggle is writable.** Recording and showing are
* read-outs with a line saying this page leaves them alone.
* - **A pillar whose module is off is not shown.** Turning a module off
* closes the data path deliberately; a pillar behind a closed path is
* not waiting for anything, and saying it waits nags a person in the
* voice of their own settings.
* - **The refusal comes from the server.** The breadth rule lives in
* `evaluateScoreBreadth` and is applied at write time; this page shows
* the refusal in the reader's language and never re-derives the rule.
* A rule copied here would drift the day the server's moved.
*
* Two reads feed it. The configuration (`/api/auth/me/health-score-config`)
* is the primary one and its failure takes the surface down honestly. The
* analytics payload is the secondary one: it carries the server's own
* verdict on each pillar, which is where "selected, waiting for data"
* comes from. When that read has not landed the rows simply make no claim
* about data; when it fails, the failure is named rather than dressed as
* absence.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Gauge, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
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 { ScorePillarRow } from "@/components/settings/score-pillar-row";
import { markAlgorithmNoticeDismissed } from "@/components/insights/health-score-card";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
isConflict,
readUpdatedAtToken,
withBaseToken,
} from "@/lib/api/optimistic-token";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import {
SCORE_DOMAIN_LABEL_KEYS,
SCORE_DOMAIN_NOTE_KEYS,
} from "@/lib/score-config/labels";
import {
buildScoreConfigRows,
selectionToSave,
type ScorePillarVerdict,
} from "@/lib/score-config/rows";
import {
SCORE_PILLAR_IDS,
type ScorePillarId,
} from "@/lib/analytics/score/types";
import type { HealthScoreReport } from "@/lib/analytics/score/types";
import type { ScoreModuleKey } from "@/lib/analytics/score/modules";
import type { ScoreBreadthFailure } from "@/lib/analytics/score/breadth";
/** The resolved configuration `GET /api/auth/me/health-score-config` returns. */
interface HealthScoreConfigPayload {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
/** Optimistic-concurrency token; absent until a selection exists. */
updatedAt?: string;
}
/**
* The two refusals the write can return, in the reader's language.
*
* Keyed on `meta.reason`, which is the machine-readable half of the
* server's answer. The server's own English sentence is not shown: it
* exists for callers without a locale, and rendering it would leave five
* of the six locales reading English at exactly the moment the person
* needs to understand something.
*
* Typed as a total map over `ScoreBreadthFailure` rather than over
* `string`, so a third refusal reason added to the rule cannot ship with
* this surface quietly falling through to a generic "couldn't save". The
* TYPE is imported, never the rule: the breadth question is answered
* server-side and answered once.
*/
export const REFUSAL_KEYS: Record<ScoreBreadthFailure, string> = {
measured_physiological_domain_required:
"settings.sections.score.refusal.physiological",
three_domains_required: "settings.sections.score.refusal.domains",
};
function verdictsFrom(
report: HealthScoreReport | null | undefined,
): Partial<Record<ScorePillarId, ScorePillarVerdict>> | undefined {
if (!report) return undefined;
const out: Partial<Record<ScorePillarId, ScorePillarVerdict>> = {};
for (const pillar of report.pillars) {
out[pillar.id] =
pillar.result.status === "ok"
? { status: "ok" }
: { status: "insufficient", reason: pillar.result.reason };
}
return out;
}
/** Stable fingerprint of the server's selection, for the draft re-seed. */
function selectionSignature(pillars: readonly ScorePillarId[]): string {
return SCORE_PILLAR_IDS.filter((id) => pillars.includes(id)).join(",");
}
export function ScoreSection() {
const { t } = useTranslations();
const { user, isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: queryKeys.healthScoreConfig(),
queryFn: () =>
apiGet<HealthScoreConfigPayload>("/api/auth/me/health-score-config"),
enabled: Boolean(isAuthenticated),
});
// The server's verdict on each pillar. Secondary: the switches work
// without it, so it never gates the surface.
const analyticsQuery = useAnalyticsQuery();
const report = analyticsQuery.data?.healthScore as
HealthScoreReport | null | undefined;
const verdicts = useMemo(() => verdictsFrom(report), [report]);
const serverSelection = useMemo(
() => configQuery.data?.pillars ?? [],
[configQuery.data?.pillars],
);
const serverSignature = selectionSignature(serverSelection);
// Re-seed the draft in render (the sanctioned "adjust state on prop
// change" shape, no effect) whenever the SERVER copy moves — a settled
// save, a refetch, or the first load. An in-flight edit is untouched
// until the server itself advances.
const [seededSignature, setSeededSignature] = useState<string | null>(null);
const [draft, setDraft] = useState<ScorePillarId[]>([]);
const [refusal, setRefusal] = useState<string | null>(null);
if (configQuery.isSuccess && serverSignature !== seededSignature) {
setSeededSignature(serverSignature);
setDraft([...serverSelection]);
setRefusal(null);
}
const modules = (user?.modules ?? {}) as Partial<
Record<ScoreModuleKey, boolean>
>;
const { groups, omitted } = useMemo(
() => buildScoreConfigRows({ selection: draft, modules, verdicts }),
// `modules` is a fresh object each render; its identity is not the
// dependency, its content is.
// eslint-disable-next-line react-hooks/exhaustive-deps
[draft, verdicts, user?.modules],
);
const dirty = selectionSignature(draft) !== serverSignature;
const saveMutation = useMutation({
mutationKey: queryKeys.healthScoreConfig(),
mutationFn: (selection: ScorePillarId[]) =>
apiPatch<HealthScoreConfigPayload>(
"/api/auth/me/health-score-config",
withBaseToken(
{ pillars: selectionToSave(selection) },
readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()),
),
),
onSuccess: (saved) => {
setRefusal(null);
queryClient.setQueryData(queryKeys.healthScoreConfig(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.healthScoreConfig(),
});
// The number now means something else. Every surface that holds a
// computed score has to go, or the change reads as broken.
void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
void queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
toast.success(t("settings.sections.score.saved"));
},
onError: (err) => {
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.healthScoreConfig(),
});
toast.message(t("common.conflictReloaded"));
return;
}
// The write-time breadth refusal. The rule is the server's; this
// only translates the reason it gave.
if (err instanceof ApiError && err.status === 422) {
const reason = err.meta?.reason;
// The wire carries an unknown string until it is checked against
// the map; an unrecognised one falls through to the generic
// failure rather than rendering an empty alert.
const key =
typeof reason === "string" && reason in REFUSAL_KEYS
? REFUSAL_KEYS[reason as ScoreBreadthFailure]
: undefined;
if (key) {
setRefusal(key);
return;
}
}
toast.error(t("settings.sections.score.error"));
},
});
const dismissMutation = useMutation({
mutationFn: async (itemKey: string) => {
await apiPost("/api/daily/digest/dismiss", { itemKey });
return itemKey;
},
onSuccess: (itemKey) => {
// Patch every cached copy of the notice, not just the one this page
// reads: the analytics payload carries the same raised notice and a
// stale `dismissed: false` there would raise it again on the next
// surface that learns to render it.
queryClient.setQueryData(queryKeys.analytics(), (previous: unknown) =>
markAlgorithmNoticeDismissed(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) => {
const set = new Set(current);
if (next) set.add(id);
else set.delete(id);
return SCORE_PILLAR_IDS.filter((pillar) => set.has(pillar));
});
}
const notice = report?.algorithmNotice ?? null;
const showNotice =
notice != null && !notice.dismissed && configQuery.isSuccess;
const busy = saveMutation.isPending;
return (
<div className="space-y-6" data-slot="score-section">
{showNotice && notice ? (
<ScoreChangeNotice
configVersion={configQuery.data?.version ?? 0}
changedAt={configQuery.data?.changedAt ?? null}
dismissing={dismissMutation.isPending}
onDismiss={() => dismissMutation.mutate(notice.itemKey)}
/>
) : null}
{configQuery.isError ? (
<QueryErrorCard
title={t("settings.sections.score.loadFailed")}
onRetry={() => void configQuery.refetch()}
/>
) : (
<SettingsCard
as="section"
data-slot="score-config-card"
aria-labelledby="score-config-title"
className="space-y-4"
>
<SettingsCardHeader
icon={Gauge}
titleId="score-config-title"
title={t("settings.sections.score.card.title")}
description={t("settings.sections.score.card.description")}
status={
<Button
size="sm"
type="button"
onClick={() => saveMutation.mutate(draft)}
disabled={busy || !dirty || configQuery.isLoading}
data-slot="score-config-save"
className="min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("settings.sections.score.save")}
</Button>
}
/>
{/* The sentence the whole feature exists for. Foreground, in the
body, because it is content and not a meta description. */}
<p data-slot="score-config-three-axes" className="text-sm">
{t("settings.sections.score.threeAxes")}
</p>
{/* The starting points. Both name data; neither names a person.
A template that supplies a rationale for dropping a pillar is
the one shape the evidence rules out, so there is none. */}
<div
data-slot="score-config-presets"
className="flex flex-wrap items-center gap-2"
>
<span className="text-muted-foreground text-xs">
{t("settings.sections.score.presets.label")}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
data-slot="score-config-preset-all"
className="min-h-11 sm:min-h-9"
onClick={() => {
setRefusal(null);
setDraft([...SCORE_PILLAR_IDS]);
}}
>
{t("settings.sections.score.presets.all")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy || !dirty}
data-slot="score-config-preset-current"
className="min-h-11 sm:min-h-9"
onClick={() => {
setRefusal(null);
setDraft([...serverSelection]);
}}
>
{t("settings.sections.score.presets.current")}
</Button>
</div>
{refusal ? (
<p
data-slot="score-config-refusal"
role="alert"
className="text-destructive text-sm"
>
{t(refusal)}
</p>
) : null}
{analyticsQuery.isError ? (
<QueryErrorRow
slot="score-config-eligibility-error"
message={t("settings.sections.score.eligibilityFailed")}
onRetry={() => void analyticsQuery.refetch()}
/>
) : null}
{configQuery.isLoading ? (
<div data-slot="score-config-loading" className="space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : (
<div className="space-y-6">
{groups.map((group) => (
<div
key={group.domain}
data-slot="score-config-group"
data-domain={group.domain}
>
<h3
data-slot="score-config-group-heading"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
</h3>
{SCORE_DOMAIN_NOTE_KEYS[group.domain] ? (
<p
data-slot="score-config-group-note"
className="text-muted-foreground mt-1 text-xs"
>
{t(SCORE_DOMAIN_NOTE_KEYS[group.domain] as string)}
</p>
) : null}
<div className="divide-border/60 mt-1 divide-y">
{group.rows.map((row) => (
<ScorePillarRow
key={row.id}
row={row}
pending={busy}
onToggle={(next) => toggle(row.id, next)}
/>
))}
</div>
</div>
))}
</div>
)}
{omitted.length > 0 ? (
<p
data-slot="score-config-omitted"
data-count={omitted.length}
className="text-muted-foreground text-xs"
>
{t("settings.sections.score.omitted", { count: omitted.length })}{" "}
<Link
href="/settings/modules"
data-slot="score-config-omitted-link"
className="text-foreground underline underline-offset-2"
>
{t("settings.sections.score.omittedLink")}
</Link>
</p>
) : null}
</SettingsCard>
)}
</div>
);
}