Merge branch 'main' into feat/encounter-phase-2

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:11:50 +02:00
commit f28c6df861
43 files changed
+5333 -7

No files matched your search

@@ -0,0 +1,170 @@
/**
* What reaches the screen, not what the strings say.
*
* The copy guard reads the bundles; this reads the render. Between them they
* cover the two halves of the same rule — that the two readings of a day are
* shown side by side and never merged, that the forecast never appears without
* its band and its count, and that the deviation is measured against the band
* rather than against the midpoint.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { MoodPrognosisView } from "../mood-prognosis-card";
function render(reading: PrognosisReading): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodPrognosisView reading={reading} />
</I18nProvider>,
);
}
function present(over: Partial<PrognosisReading> = {}): PrognosisReading {
return {
present: true,
date: "2026-08-07",
predicted: 5.4,
ciLow: 4.1,
ciHigh: 6.8,
n: 42,
modelVersion: "mood-ridge-1",
computedAt: "2026-08-08T02:35:00.000Z",
ageDays: 1,
current: true,
provisional: false,
stage: "regular",
entries: 64,
seasonalUnlocked: false,
selfAssessment: 6,
deviation: "within",
deviationAmount: 0,
contributions: [
{ feature: "dimension:a2", contribution: 0.9 },
{ feature: "linked:steps", contribution: -0.4 },
],
...over,
} as PrognosisReading;
}
describe("<MoodPrognosisView>", () => {
it("shows both readings, and shows the person's own one first", () => {
const html = render(present());
expect(html).toContain('data-slot="mood-prognosis-self"');
expect(html).toContain('data-slot="mood-prognosis-expected"');
// The self block precedes the expected block in the document, which is
// what "the self-assessment leads" means to a screen reader.
expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan(
html.indexOf('data-slot="mood-prognosis-expected"'),
);
// Both values are on screen as themselves. A merged single figure is the
// failure this whole feature is shaped to avoid.
expect(html).toContain("6.0");
expect(html).toContain("5.4");
});
it("never shows the value without its band and its count", () => {
const html = render(present());
expect(html).toContain("4.1");
expect(html).toContain("6.8");
expect(html).toContain("42");
});
it("phrases the forecast as a counterfactual", () => {
const html = render(present());
expect(html).toContain("would have been expected");
expect(html).not.toContain("your mood is");
});
it("measures the deviation against the band, not the midpoint", () => {
// 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not
// a finding. A midpoint comparison would have called it one.
expect(render(present())).toContain("inside that range");
const above = render(
present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }),
);
expect(above).toContain("1.2 points above");
});
it("labels a provisional forecast as provisional", () => {
expect(render(present({ provisional: true }))).toContain(
'data-slot="mood-prognosis-provisional"',
);
expect(render(present())).not.toContain(
'data-slot="mood-prognosis-provisional"',
);
});
it("says which day it is about when that day is not today", () => {
const stale = render(present({ current: false, ageDays: 4 }));
expect(stale).toContain('data-slot="mood-prognosis-age"');
// Rendered through the account's own date-order preference rather than as
// the raw day key — under this render's `en` locale and the default AUTO
// preference that is month-first. The assertion is on the formatted form
// precisely because a raw `2026-08-07` on the screen would pass a looser
// one.
expect(stale).toContain("08/07/2026");
expect(stale).not.toContain("2026-08-07");
});
it("names what the model weighted, in the reader's own words", () => {
const html = render(present());
// The dimension's own label, the one the slider carries.
expect(html).toContain("Stress");
expect(html).toContain("Steps");
expect(html).toContain("counted towards a higher expected value");
expect(html).toContain("counted towards a lower expected value");
});
it("skips a feature key this build no longer knows", () => {
const html = render(
present({
contributions: [
{ feature: "retired:whatever", contribution: 2 },
{ feature: "dimension:a3", contribution: 1 },
],
}),
);
expect(html).not.toContain("retired:whatever");
expect(html).toContain('data-slot="mood-prognosis-explanation"');
});
it("renders nothing at all below the first rung", () => {
expect(
render({
present: false,
reason: "no-output-yet",
entries: 4,
nextThreshold: 15,
}),
).toBe("");
});
it("explains the learning phase with the count and the next step", () => {
const html = render({
present: false,
reason: "learning-phase",
entries: 22,
nextThreshold: 30,
});
expect(html).toContain("22");
expect(html).toContain("30");
// Absence, not an error and not a zero.
expect(html).not.toContain('data-slot="mood-prognosis-expected"');
});
it("refuses honestly when there are days but no pattern", () => {
const html = render({
present: false,
reason: "no-pattern",
entries: 140,
nextThreshold: null,
});
expect(html).toContain("140");
expect(html).not.toContain('data-slot="mood-prognosis-statement"');
});
});
@@ -0,0 +1,248 @@
"use client";
/**
* The day's two readings, side by side, with the person's own one leading.
*
* The copy rules this component exists to hold, stated once so nobody has to
* infer them from the strings:
*
* * **No sentence names a cause.** Not "overtime lowered your mood", not
* "poor sleep is why today was hard". What the data supports is "on your
* own past days, a value around X would have been expected", and that is
* what it says. The ban is a test, not a habit:
* `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six
* bundles and fails on a causal construction in any of them.
* * **The forecast is a counterfactual and the sentence says so.** Never
* "your mood is 5.4" — always "a value around 5.4 would have been
* expected". The difference is the whole distinction between the two
* readings.
* * **The count is on the same surface as the number**, not in a tooltip. A
* forecast built from thirty days and one built from three hundred look
* identical otherwise, and a reader who cannot see which one they are
* looking at cannot weigh it.
* * **The deviation is measured against the band, never against the
* midpoint.** Inside the band is "as expected"; outside it is the finding.
*
* The explanation is generated from the contributions the job stored for that
* day, so it names what the model actually weighted rather than a fresh
* calculation against a model that may since have been re-fitted.
*/
import { useQuery } from "@tanstack/react-query";
import { Compass } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { prognosisFeatureLabel } from "./prognosis-feature-label";
/** How many weighted features the explanation names. */
const EXPLAINED_FEATURES = 3;
function formatValue(value: number, locale: string): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
}
export function MoodPrognosisCard() {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodPrognosis(),
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="min-h-24 w-full rounded-xl" />;
}
if (!data) return null;
return <MoodPrognosisView reading={data} />;
}
/**
* The card itself, over a resolved reading.
*
* Split from the query so the copy rules above can be asserted against real
* renders rather than against the strings alone: "the two values are never
* merged" is a property of what reaches the screen, and a bundle check cannot
* see it.
*/
export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
const { t, locale } = useTranslations();
const dateFormat = useDateFormatPreference();
const data = reading;
// The day key is a plain calendar date and is rendered as one, through the
// account's own date-order preference and pinned to UTC — the same treatment
// every other day key in the app gets, so this card cannot show a different
// day from the list beside it.
const day = data.present ? formatDate(data.date, dateFormat, locale) : "";
// Below the first rung there is nothing to say and the surface says nothing
// rather than showing an empty frame with an apology in it.
if (!data.present && data.reason === "no-output-yet") return null;
return (
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
</CardHeader>
<CardContent className="space-y-3">
{!data.present ? (
<p
className="text-muted-foreground text-sm"
data-slot="mood-prognosis-absent"
>
{data.reason === "learning-phase"
? t("insights.mood.prognosis.learning", {
entries: String(data.entries),
threshold: String(data.nextThreshold ?? 0),
})
: t("insights.mood.prognosis.noPattern", {
entries: String(data.entries),
})}
</p>
) : (
<>
<div className="flex flex-wrap items-end gap-x-8 gap-y-3">
{/* The self-assessment leads: it is the larger number, it comes
first, and it is the only one of the two the person gave. */}
<div data-slot="mood-prognosis-self">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.selfLabel")}
</p>
<p className="text-foreground text-3xl font-semibold tabular-nums">
{data.selfAssessment === null
? t("mood.dayContext.notRecorded")
: formatValue(data.selfAssessment, locale)}
</p>
</div>
<div data-slot="mood-prognosis-expected">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.expectedLabel")}
</p>
<p className="text-foreground text-xl font-medium tabular-nums">
{formatValue(data.predicted, locale)}
</p>
{/* The band and the count ride with the value, in the block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{data.ciLow !== null && data.ciHigh !== null
? t("insights.mood.prognosis.band", {
low: formatValue(data.ciLow, locale),
high: formatValue(data.ciHigh, locale),
})
: null}
</p>
</div>
</div>
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-statement"
>
{t("insights.mood.prognosis.statement", {
value: formatValue(data.predicted, locale),
date: day,
n: String(data.n),
})}
</p>
{data.provisional ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-provisional"
>
{t("insights.mood.prognosis.provisional")}
</p>
) : null}
{!data.current ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-age"
>
{t("insights.mood.prognosis.notToday", { date: day })}
</p>
) : null}
{data.deviation !== null && data.selfAssessment !== null ? (
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-deviation"
>
{data.deviation === "within"
? t("insights.mood.prognosis.deviationWithin")
: data.deviation === "above"
? t("insights.mood.prognosis.deviationAbove", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})
: t("insights.mood.prognosis.deviationBelow", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})}
</p>
) : null}
<PrognosisExplanation contributions={data.contributions} />
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.caveat")}
</p>
</>
)}
</CardContent>
</Card>
);
}
function PrognosisExplanation({
contributions,
}: {
contributions: Array<{ feature: string; contribution: number }>;
}) {
const { t } = useTranslations();
const named = contributions
.map((item) => ({
...item,
label: prognosisFeatureLabel(item.feature, t),
}))
.filter(
(item): item is typeof item & { label: string } =>
item.label !== null && item.contribution !== 0,
)
.slice(0, EXPLAINED_FEATURES);
if (named.length === 0) return null;
return (
<div className="space-y-1" data-slot="mood-prognosis-explanation">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.weightedTitle")}
</p>
<ul className="space-y-0.5">
{named.map((item) => (
<li
key={item.feature}
className="text-foreground text-sm"
data-slot="mood-prognosis-weighted-item"
>
{item.contribution > 0
? t("insights.mood.prognosis.weightedUp", { label: item.label })
: t("insights.mood.prognosis.weightedDown", {
label: item.label,
})}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,71 @@
/**
* A stored feature key, in the reader's own language.
*
* The keys in `MoodPrediction.features` are stable identifiers, not copy. This
* turns one into a label, and it does so out of the strings the capture
* surfaces already use: a person reads "Workload" here because that is what
* the slider they moved was called, not because a second wording was invented
* for the explanation.
*
* Every key it cannot place returns `null`, and that path is real rather than
* defensive: a row written by an older `modelVersion` can name a feature this
* build has dropped, and skipping it is better than rendering an identifier at
* somebody.
*/
import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import { getMoodDimension } from "@/lib/mood/dimensions";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The five linked figures, resolved through literal keys.
*
* A switch rather than a computed key so the call-site coverage guard can see
* every string this file can ask for. The keys are the capture sheet's own
* (`mood.dayContext.linked*`), which is the point: the label a figure carries
* in the explanation is the label it carries where it was read.
*/
function linkedLabel(metric: string, t: Translate): string | null {
switch (metric) {
case "sleepAsleep":
return t("mood.dayContext.linkedSleep");
case "steps":
return t("mood.dayContext.linkedSteps");
case "activeEnergy":
return t("mood.dayContext.linkedActiveEnergy");
case "restingHeartRate":
return t("mood.dayContext.linkedRestingHeartRate");
case "heartRateVariability":
return t("mood.dayContext.linkedHrv");
default:
return null;
}
}
export function prognosisFeatureLabel(
key: string,
t: Translate,
): string | null {
const parsed = parseFeatureKey(key);
if (parsed === null) return null;
if (parsed.family === "dimension") {
const dimension = getMoodDimension(parsed.dimension);
return dimension ? t(dimension.labelKey) : null;
}
if (parsed.family === "linked") return linkedLabel(parsed.metric, t);
const field = t(`mood.context.field.${parsed.field}`);
if (parsed.value === null) return field;
// "Working day: longer than usual" — the field and the value the person
// picked, joined by a pattern rather than by a hard-coded colon, because the
// punctuation between them is a locale's business.
return t("insights.mood.prognosis.featureValue", {
field,
value: t(contextValueLabelKey(parsed.field, parsed.value)),
});
}