mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(mood): show the day's own rating beside what the account's days imply
Two readings, side by side, never merged. The person's own rating leads — larger, first, and returned exactly as it was stored — and the comparison sits beside it with its range and the number of days it was built from. The deviation is measured against the range, not against its midpoint: inside it is a day that went as the account's own past days suggest, and only outside it is worth a sentence. The sentence is a counterfactual and says so. Never "your mood is 5.4", always "a value around 5.4 would have been expected". The explanation names what the comparison weighted for that day, out of what the job stored for that day, in the words the capture sheet already uses — a person reads "Workload" here because that is what the slider they moved was called. The route declares the whole record rather than the mind section, and the reason is written at the declaration: the comparison reads the day's context AND the figures the sleep, activity and recovery modules hold, and it names them, so a delegate holding only the mind section would receive a derived view of sections the owner declined to share. A scoped grant is refused outright rather than served a contribution list with the linked features quietly removed, which would read as "only the mood fields mattered" and be false. The causal-verb ban is a test now rather than a review habit: the copy guard reads all six bundles and fails on a causal construction in any of them, and on a statement that quotes a value without the days behind it. Both halves were watched failing.
This commit is contained in:
1 parent
244e8ffbc7
commit
f48afed82d
15 files changed
+1053
-4
No files matched your search
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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"');
|
||||
expect(stale).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,241 @@
|
||||
"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 { 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 data = reading;
|
||||
|
||||
// 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: data.date,
|
||||
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: data.date })}
|
||||
</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)),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user