diff --git a/messages/de.json b/messages/de.json index 0b4dc8816..337c3e442 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3785,6 +3785,25 @@ "statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.", "counts": "{withDays} Tage mit, {withoutDays} Tage ohne", "caveat": "Das zeigt einen Zusammenhang, keine Ursache." + }, + "prognosis": { + "title": "Deine Einschätzung und was deine bisherigen Tage nahelegen", + "selfLabel": "Deine Einschätzung", + "expectedLabel": "Auf Basis deiner Muster erwartet", + "band": "Bereich {low} bis {high}", + "statement": "Auf Basis deiner bisherigen Muster wäre für {date} ein Wert um {value} zu erwarten gewesen. Ermittelt aus {n} Tagen, die alles enthalten, was der Vergleich braucht.", + "provisional": "Noch vorläufig: das Muster stützt sich auf eine kurze Strecke von Tagen.", + "notToday": "Das beschreibt {date}, nicht heute.", + "deviationWithin": "Deine eigene Einschätzung liegt in diesem Bereich.", + "deviationAbove": "Deine eigene Einschätzung liegt {amount} Punkte über diesem Bereich.", + "deviationBelow": "Deine eigene Einschätzung liegt {amount} Punkte unter diesem Bereich.", + "weightedTitle": "Was an diesem Tag am stärksten gewichtet wurde", + "weightedUp": "{label} — zählte in Richtung eines höheren erwarteten Werts", + "weightedDown": "{label} — zählte in Richtung eines niedrigeren erwarteten Werts", + "caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.", + "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", + "noPattern": "In den bisher erfassten {entries} Tagen ist nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wer den Kontext eines Tages mit erfasst, macht mehr davon vergleichbar.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/en.json b/messages/en.json index f18373297..074d87158 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3785,6 +3785,25 @@ "statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.", "counts": "{withDays} days with, {withoutDays} days without", "caveat": "That shows a connection, not a cause." + }, + "prognosis": { + "title": "Your rating, and what your own days suggest", + "selfLabel": "Your rating", + "expectedLabel": "Expected on your own pattern", + "band": "Range {low} to {high}", + "statement": "On your own patterns so far, a value around {value} would have been expected for {date}. Built from {n} days that carried everything the comparison needs.", + "provisional": "Still provisional: the pattern rests on a short stretch of days.", + "notToday": "This describes {date}, not today.", + "deviationWithin": "Your own rating sits inside that range.", + "deviationAbove": "Your own rating sits {amount} points above that range.", + "deviationBelow": "Your own rating sits {amount} points below that range.", + "weightedTitle": "What weighed most in this day's comparison", + "weightedUp": "{label} — counted towards a higher expected value", + "weightedDown": "{label} — counted towards a lower expected value", + "caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.", + "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", + "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. Recording a day's context makes more of it comparable.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/es.json b/messages/es.json index 7b3227215..0c7e6f6e0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3785,6 +3785,25 @@ "statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.", "counts": "{withDays} días con, {withoutDays} días sin", "caveat": "Esto muestra una relación, no una causa." + }, + "prognosis": { + "title": "Tu valoración y lo que sugieren tus días anteriores", + "selfLabel": "Tu valoración", + "expectedLabel": "Esperado según tus propios patrones", + "band": "Rango de {low} a {high}", + "statement": "Según tus patrones hasta ahora, para {date} cabría esperar un valor en torno a {value}. Calculado a partir de {n} días que reúnen todo lo que la comparación necesita.", + "provisional": "Todavía provisional: el patrón se apoya en pocos días.", + "notToday": "Esto describe {date}, no hoy.", + "deviationWithin": "Tu propia valoración está dentro de ese rango.", + "deviationAbove": "Tu propia valoración está {amount} puntos por encima de ese rango.", + "deviationBelow": "Tu propia valoración está {amount} puntos por debajo de ese rango.", + "weightedTitle": "Lo que más pesó en la comparación de este día", + "weightedUp": "{label}: contó hacia un valor esperado más alto", + "weightedDown": "{label}: contó hacia un valor esperado más bajo", + "caveat": "Es una comparación con tus propios días anteriores, no una afirmación sobre cómo llegó a transcurrir un día.", + "learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.", + "noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Registrar el contexto del día hace comparable una parte mayor.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/fr.json b/messages/fr.json index 219ed0de9..84706bb84 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3785,6 +3785,25 @@ "statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.", "counts": "{withDays} jours avec, {withoutDays} jours sans", "caveat": "Cela montre un lien, pas une cause." + }, + "prognosis": { + "title": "Ton évaluation et ce que suggèrent tes journées passées", + "selfLabel": "Ton évaluation", + "expectedLabel": "Attendu selon tes propres habitudes", + "band": "Plage de {low} à {high}", + "statement": "D'après tes habitudes jusqu'ici, une valeur autour de {value} aurait été attendue pour {date}. Établi à partir de {n} journées réunissant tout ce dont la comparaison a besoin.", + "provisional": "Encore provisoire : le motif repose sur une courte série de journées.", + "notToday": "Cela décrit {date}, pas aujourd'hui.", + "deviationWithin": "Ta propre évaluation se situe dans cette plage.", + "deviationAbove": "Ta propre évaluation se situe {amount} points au-dessus de cette plage.", + "deviationBelow": "Ta propre évaluation se situe {amount} points en dessous de cette plage.", + "weightedTitle": "Ce qui a le plus pesé dans la comparaison de cette journée", + "weightedUp": "{label} — comptait vers une valeur attendue plus élevée", + "weightedDown": "{label} — comptait vers une valeur attendue plus basse", + "caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.", + "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", + "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Noter le contexte d'une journée en rend davantage comparable.", + "featureValue": "{field} : {value}" } }, "derived": { diff --git a/messages/it.json b/messages/it.json index e3f206ded..73f207207 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3785,6 +3785,25 @@ "statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.", "counts": "{withDays} giorni con, {withoutDays} giorni senza", "caveat": "Questo mostra una relazione, non una causa." + }, + "prognosis": { + "title": "La tua valutazione e ciò che suggeriscono le tue giornate passate", + "selfLabel": "La tua valutazione", + "expectedLabel": "Atteso in base alle tue abitudini", + "band": "Intervallo da {low} a {high}", + "statement": "In base alle tue abitudini finora, per {date} ci si sarebbe attesi un valore intorno a {value}. Ricavato da {n} giornate che contengono tutto ciò che serve al confronto.", + "provisional": "Ancora provvisorio: lo schema si appoggia su poche giornate.", + "notToday": "Questo descrive {date}, non oggi.", + "deviationWithin": "La tua valutazione rientra in questo intervallo.", + "deviationAbove": "La tua valutazione è {amount} punti sopra questo intervallo.", + "deviationBelow": "La tua valutazione è {amount} punti sotto questo intervallo.", + "weightedTitle": "Che cosa ha pesato di più nel confronto di questa giornata", + "weightedUp": "{label} — contava verso un valore atteso più alto", + "weightedDown": "{label} — contava verso un valore atteso più basso", + "caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.", + "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", + "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Registrare il contesto della giornata rende confrontabile una parte maggiore.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/messages/pl.json b/messages/pl.json index 80efa43b8..cd8c54d1a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3785,6 +3785,25 @@ "statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.", "counts": "{withDays} dni z, {withoutDays} dni bez", "caveat": "To pokazuje związek, nie przyczynę." + }, + "prognosis": { + "title": "Twoja ocena i to, co sugerują twoje dotychczasowe dni", + "selfLabel": "Twoja ocena", + "expectedLabel": "Oczekiwane na podstawie twoich wzorców", + "band": "Zakres od {low} do {high}", + "statement": "Na podstawie twoich dotychczasowych wzorców dla {date} należałoby oczekiwać wartości około {value}. Wyliczone z {n} dni, które zawierają wszystko, czego potrzebuje porównanie.", + "provisional": "Jeszcze wstępne: wzorzec opiera się na krótkim odcinku dni.", + "notToday": "To opisuje {date}, nie dzisiaj.", + "deviationWithin": "Twoja własna ocena mieści się w tym zakresie.", + "deviationAbove": "Twoja własna ocena leży {amount} punktów powyżej tego zakresu.", + "deviationBelow": "Twoja własna ocena leży {amount} punktów poniżej tego zakresu.", + "weightedTitle": "Co najbardziej zaważyło w porównaniu tego dnia", + "weightedUp": "{label} — liczyło się w stronę wyższej oczekiwanej wartości", + "weightedDown": "{label} — liczyło się w stronę niższej oczekiwanej wartości", + "caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.", + "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", + "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z tym dzień. Zapisywanie kontekstu dnia sprawia, że więcej da się porównać.", + "featureValue": "{field}: {value}" } }, "derived": { diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts index 345f18467..da87e9644 100644 --- a/src/__tests__/copy-matches-behaviour-guard.test.ts +++ b/src/__tests__/copy-matches-behaviour-guard.test.ts @@ -1,8 +1,11 @@ /** * Copy-matches-behaviour guard. * - * Three classes of copy that drifted away from the code they describe, each - * pinned here so the pair fails loudly if either half moves again: + * Four classes of copy that must not drift away from the code they describe, + * each pinned here so the pair fails loudly if either half moves. The first + * three are repairs — copy that had already drifted. The fourth arrives with + * the feature it governs, because a wording rule that depends on review is a + * wording rule that survives until the fifth locale. * * 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the * consequence. Four dialogs promised permanence on surfaces that render @@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => { expect(copy).toContain("never inferred"); }); }); + +/** + * 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past + * days and it may never be phrased as a cause. "A value around 5.4 would + * have been expected" is what the arithmetic supports; "overtime lowered + * your mood" is not, and the difference is not a matter of taste — the model + * is a correlation over a dozen columns and a causal sentence would be a + * claim it cannot make about somebody's health. + * + * The second half is the count. A forecast built from thirty days and one + * built from three hundred render identically, so every statement that + * quotes a value has to quote what it rests on. Both halves are a test here + * rather than a review habit, because a review habit does not survive the + * fifth locale. + */ +describe("the mood prognosis never states a cause", () => { + /** + * Causal constructions, per locale. Multi-word where a single word would + * over-match — Italian `causa` is a noun in ordinary use and only `a causa + * di` is the construction being banned. + */ + const CAUSAL_PHRASES: Record = { + de: [ + "weil", + "verursacht", + "führt zu", + "sorgt für", + "wegen", + "verbessert deine", + "verschlechtert deine", + "senkt deine", + "hebt deine", + ], + en: [ + "because", + "causes", + "caused", + "leads to", + "due to", + "makes you", + "lowers your", + "raises your", + "improves your", + "worsens your", + ], + es: [ + "porque", + "provoca", + "debido a", + "a causa de", + "mejora tu", + "empeora tu", + ], + fr: [ + "parce que", + "provoque", + "en raison de", + "à cause de", + "améliore ton", + "dégrade ton", + ], + it: [ + "perché", + "provoca", + "a causa di", + "migliora il tuo", + "peggiora il tuo", + ], + pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"], + }; + + /** Every leaf string under `insights.mood.prognosis`, flattened. */ + function prognosisStrings(locale: string): Array<[string, string]> { + const root = ( + bundle(locale).insights as Record> + ).mood.prognosis as Record; + expect( + root, + `${locale} carries no insights.mood.prognosis block`, + ).toBeTypeOf("object"); + return Object.entries(root).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + } + + for (const locale of LOCALES) { + it(`${locale} carries the block, and every string in it`, () => { + const strings = prognosisStrings(locale); + // The positive control. Every assertion below passes on an empty list, + // so the count is the evidence and the empty offender set is only the + // verdict. + expect(strings.length).toBeGreaterThanOrEqual(17); + for (const [key, value] of strings) { + expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0); + } + }); + + it(`${locale} names no cause`, () => { + const offenders = prognosisStrings(locale) + .filter(([, value]) => + CAUSAL_PHRASES[locale].some((phrase) => + value.toLowerCase().includes(phrase), + ), + ) + .map(([key]) => key); + expect( + offenders, + `${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`, + ).toEqual([]); + }); + } + + /** + * Statements that quote a value must quote what it rests on. The list is + * explicit rather than derived, so adding a statement key forces a decision + * here instead of inheriting silence. + */ + const COUNTED_STATEMENTS: Record = { + statement: ["{value}", "{n}"], + learning: ["{entries}", "{threshold}"], + noPattern: ["{entries}"], + band: ["{low}", "{high}"], + }; + + for (const locale of LOCALES) { + it(`${locale} keeps the count beside the claim`, () => { + const strings = new Map(prognosisStrings(locale)); + for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) { + const copy = strings.get(key); + expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string"); + for (const placeholder of placeholders) { + expect( + copy, + `${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`, + ).toContain(placeholder); + } + } + }); + } + + it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => { + const en = resolve(bundle("en"), "insights.mood.prognosis.statement"); + expect(en.toLowerCase()).toContain("would have been expected"); + const de = resolve(bundle("de"), "insights.mood.prognosis.statement"); + expect(de.toLowerCase()).toContain("zu erwarten gewesen"); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 27a40aa5a..a4f0eb3d9 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.", }, + "app/api/mood/prognosis/route.ts": { + domain: "record", + why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".", + }, "app/api/mood-entries/[id]/route.ts": { domain: "mind", why: "One mood entry of the record, fetch-then-guard against the resolved user.", @@ -1812,9 +1816,11 @@ const ACTOR_ROUTES: Record = { * managed-record configuration adds one more: 196 → 197. The bounded * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. - * The mood day's linked figures, read-only, add one: 200 → 201. + * The mood day's linked figures, read-only, add one: 200 → 201. The mood + * prognosis, read-only and admitted on the whole record for the same reason, + * adds one: 201 → 202. */ -const FROZEN_ENTRY_COUNT = 201; +const FROZEN_ENTRY_COUNT = 202; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/mood/prognosis/route.ts b/src/app/api/mood/prognosis/route.ts new file mode 100644 index 000000000..3852d25c8 --- /dev/null +++ b/src/app/api/mood/prognosis/route.ts @@ -0,0 +1,63 @@ +/** + * `GET /api/mood/prognosis` — the day's two readings, side by side. + * + * The person's own rating leads and is returned exactly as it was stored. The + * forecast rides beside it with its band, the number of days it was built + * from, and how old it is. The two are never merged: the distance between them + * is the whole finding, and a single blended number would destroy it. + * + * **This route declares `record`, not `mind`, and that is the security + * decision on it.** The payload is not only mood: the fit reads the day's + * level-B context AND the figures the sleep, activity and recovery modules + * hold, and the contributions it publishes name those features one by one — a + * reader can see that sleep or steps or resting heart rate carried weight on a + * given day, and roughly how much. Declared `mind` it would be a hole with a + * mood-shaped label on it: a delegate holding only the mind section would + * receive a derived view of two sections the owner had declined to share. The + * convention for a read that crosses sections is `record` + * (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest` + * are the precedents) and a scoped grant never reaches one, so a scoped + * delegate loses the forecast entirely rather than receiving a filtered + * version of it. A filtered version would be worse: a contribution list with + * the linked features quietly removed would tell the reader that the model + * weighted only the mood fields, which is false. + * + * Nothing here writes. The rows come from the nightly job and the user cannot + * overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts` + * holds that, and this file is one of the ones it scans. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + // `record`: see the file header. A scoped grant is refused here by + // construction rather than served a narrowed payload. + const { user } = await requireRecordAuth("read", "record"); + + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + + const reading = await readMoodPrognosis(user.id, new Date(), user.timezone); + + annotate({ + action: { name: "mood.prognosis.read" }, + meta: reading.present + ? { + present: true, + day: reading.date, + fit_rows: reading.n, + age_days: reading.ageDays, + current: reading.current, + provisional: reading.provisional, + deviation: reading.deviation, + } + : { present: false, reason: reading.reason, entries: reading.entries }, + }); + + return apiSuccess(reading); +}); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 6573fff92..50886b099 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -36,6 +36,21 @@ const MoodChart = dynamic( { ssr: false, loading: () => }, ); +/** + * The day's two readings. Deferred like every other block on this route that + * is not the first paint: it reads its own endpoint and renders below the + * calendar, so nothing about it belongs in the eager graph of a page whose + * first paint is a heatmap. This route sits close to its bundle ceiling and + * every addition to it goes through the same treatment. + */ +const MoodPrognosisCard = dynamic( + () => + import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ + default: mod.MoodPrognosisCard, + })), + { ssr: false, loading: () => null }, +); + interface ComprehensiveMoodData { moodSummary: { count: number } | null; } @@ -137,6 +152,12 @@ export default function InsightsStimmungPage() { + {/* The day's own rating beside what the account's past days imply. It + sits high because the self-assessment is the leading value on this + page, and directly under the line chart because that is where a + reader has just seen the rating it leads with. */} + + {/* The better-days Einschätzung sits directly under the Ziel card, ahead of the classification tiles and breakdowns. */} diff --git a/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx new file mode 100644 index 000000000..ac0d87f8c --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx @@ -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( + + + , + ); +} + +function present(over: Partial = {}): 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("", () => { + 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"'); + }); +}); diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx new file mode 100644 index 000000000..a4343e2b3 --- /dev/null +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -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("/api/mood/prognosis"), + enabled: isAuthenticated, + staleTime: 60_000, + }); + + if (isLoading) { + return ; + } + if (!data) return null; + return ; +} + +/** + * 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 ( + + + + + + {!data.present ? ( +

+ {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), + })} +

+ ) : ( + <> +
+ {/* The self-assessment leads: it is the larger number, it comes + first, and it is the only one of the two the person gave. */} +
+

+ {t("insights.mood.prognosis.selfLabel")} +

+

+ {data.selfAssessment === null + ? t("mood.dayContext.notRecorded") + : formatValue(data.selfAssessment, locale)} +

+
+
+

+ {t("insights.mood.prognosis.expectedLabel")} +

+

+ {formatValue(data.predicted, locale)} +

+ {/* The band and the count ride with the value, in the block a + screen reader reaches in the same breath. */} +

+ {data.ciLow !== null && data.ciHigh !== null + ? t("insights.mood.prognosis.band", { + low: formatValue(data.ciLow, locale), + high: formatValue(data.ciHigh, locale), + }) + : null} +

+
+
+ +

+ {t("insights.mood.prognosis.statement", { + value: formatValue(data.predicted, locale), + date: data.date, + n: String(data.n), + })} +

+ + {data.provisional ? ( +

+ {t("insights.mood.prognosis.provisional")} +

+ ) : null} + + {!data.current ? ( +

+ {t("insights.mood.prognosis.notToday", { date: data.date })} +

+ ) : null} + + {data.deviation !== null && data.selfAssessment !== null ? ( +

+ {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), + })} +

+ ) : null} + + + +

+ {t("insights.mood.prognosis.caveat")} +

+ + )} +
+
+ ); +} + +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 ( +
+

+ {t("insights.mood.prognosis.weightedTitle")} +

+
    + {named.map((item) => ( +
  • + {item.contribution > 0 + ? t("insights.mood.prognosis.weightedUp", { label: item.label }) + : t("insights.mood.prognosis.weightedDown", { + label: item.label, + })} +
  • + ))} +
+
+ ); +} diff --git a/src/components/insights/mood/prognosis-feature-label.ts b/src/components/insights/mood/prognosis-feature-label.ts new file mode 100644 index 000000000..6c5d912a4 --- /dev/null +++ b/src/components/insights/mood/prognosis-feature-label.ts @@ -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; + +/** + * 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)), + }); +} diff --git a/src/lib/analytics/mood-prognosis/read.ts b/src/lib/analytics/mood-prognosis/read.ts new file mode 100644 index 000000000..6bf0d16ab --- /dev/null +++ b/src/lib/analytics/mood-prognosis/read.ts @@ -0,0 +1,207 @@ +/** + * What the surfaces read: the two values of a day, side by side, never merged. + * + * The self-assessment is the leading value and is returned exactly as it was + * stored. The prognosis is the second reading — computed, banded, counted, and + * not editable. The deviation between them is the finding, and it is measured + * against the BAND rather than against the midpoint: a rating inside the band + * is a day that went as the person's own past days suggest, and only a rating + * outside it is worth a sentence. + * + * One reader for every surface. The route, the Coach snapshot and the daily + * briefing all come through here, so a change to what "the day's forecast" + * means moves all three together instead of two of them. + * + * Absence is explicit and carries its reason and its count. `no-output-yet` + * and `learning-phase` come from the ladder; `no-pattern` is the honest answer + * when there are plenty of days and nothing in them the fit could hold on to. + * None of the three is an error and none of them is a zero. + */ +import { prisma } from "@/lib/db"; +import { + dayKeyAgeInDays, + isCurrentForTodayClaim, +} from "@/lib/insights/measurement-freshness"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +import type { FeatureContribution } from "./forecast"; +import { countQualifyingDays } from "./load"; +import { + forecastGate, + seasonalComparisonsUnlocked, + type PrognosisStage, +} from "./thresholds"; + +/** How the day's own rating sat against the band. */ +export type PrognosisDeviation = "within" | "above" | "below"; + +export interface PrognosisPresent { + present: true; + /** The day the forecast is about, `YYYY-MM-DD`. */ + date: string; + /** The expected pleasantness, 0-10. Never shown without `n` and the band. */ + predicted: number; + ciLow: number | null; + ciHigh: number | null; + /** Complete days the fit used. Displayed wherever `predicted` is. */ + n: number; + modelVersion: string; + computedAt: string; + /** + * Whole days between the forecast's day and the reader's today. A forecast + * about the day before yesterday cannot back a sentence about today, and + * `current` is the same rule every other present-tense claim in the product + * uses — one constant, not a second one. + */ + ageDays: number | null; + current: boolean; + /** Below the regular threshold the forecast is labelled provisional. */ + provisional: boolean; + stage: PrognosisStage; + /** Days of history the ladder was read against. */ + entries: number; + /** Whether weekday and seasonal comparisons have unlocked. */ + seasonalUnlocked: boolean; + /** The person's own rating for that day. `null` when they gave none. */ + selfAssessment: number | null; + /** Where that rating sat against the band. `null` without a rating. */ + deviation: PrognosisDeviation | null; + /** How far outside the band it sat, in scale points. `0` when inside. */ + deviationAmount: number | null; + /** What the model weighted for that day, largest absolute first. */ + contributions: FeatureContribution[]; +} + +export interface PrognosisAbsent { + present: false; + /** + * `no-output-yet` — too few days for anything at all. + * `learning-phase` — enough to describe, not enough to forecast. + * `no-pattern` — enough days, nothing in them the fit could hold on to. + */ + reason: "no-output-yet" | "learning-phase" | "no-pattern"; + /** Days of history carrying a self-rating. */ + entries: number; + /** Days at which the next step unlocks, when a count is what is missing. */ + nextThreshold: number | null; +} + +export type PrognosisReading = PrognosisPresent | PrognosisAbsent; + +/** Parse the stored contributions. A malformed value reads as none. */ +export function parseContributions(stored: string): FeatureContribution[] { + try { + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is FeatureContribution => + typeof item === "object" && + item !== null && + typeof (item as FeatureContribution).feature === "string" && + typeof (item as FeatureContribution).contribution === "number", + ); + } catch { + return []; + } +} + +/** Where a rating sits against a band. */ +export function deviationAgainstBand( + actual: number, + low: number | null, + high: number | null, +): { deviation: PrognosisDeviation; amount: number } | null { + if (low === null || high === null) return null; + if (actual > high) return { deviation: "above", amount: actual - high }; + if (actual < low) return { deviation: "below", amount: low - actual }; + return { deviation: "within", amount: 0 }; +} + +/** + * The newest forecast an account holds, with the day's own rating beside it. + * + * The job writes a trailing window and the newest row in it is normally + * yesterday's — the pass runs before anybody has logged today. That is why + * the age travels with the value instead of being assumed to be zero. + */ +export async function readMoodPrognosis( + userId: string, + now: Date = new Date(), + timezone?: string | null, +): Promise { + const row = await prisma.moodPrediction.findFirst({ + where: { userId }, + orderBy: { date: "desc" }, + select: { + date: true, + predicted: true, + ciLow: true, + ciHigh: true, + n: true, + modelVersion: true, + features: true, + computedAt: true, + }, + }); + + if (row === null) { + // No row is not automatically "too few days": an account can have a year + // of entries and still get no forecast, because nothing in them cleared + // the screening. The count decides which of the three answers is true. + const entries = await countQualifyingDays(userId, now); + const gate = forecastGate(entries); + if (!gate.present) { + return { + present: false, + reason: gate.reason, + entries, + nextThreshold: gate.nextThreshold, + }; + } + return { + present: false, + reason: "no-pattern", + entries, + nextThreshold: null, + }; + } + + const [entries, entry] = await Promise.all([ + countQualifyingDays(userId, now), + prisma.moodEntry.findFirst({ + where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } }, + orderBy: { moodLoggedAt: "desc" }, + select: { moodA1: true }, + }), + ]); + + const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE); + const ageDays = dayKeyAgeInDays(row.date, today); + const gate = forecastGate(entries); + const selfAssessment = entry?.moodA1 ?? null; + const against = + selfAssessment === null + ? null + : deviationAgainstBand(selfAssessment, row.ciLow, row.ciHigh); + + return { + present: true, + date: row.date, + predicted: row.predicted, + ciLow: row.ciLow, + ciHigh: row.ciHigh, + n: row.n, + modelVersion: row.modelVersion, + computedAt: row.computedAt.toISOString(), + ageDays, + current: isCurrentForTodayClaim(ageDays), + provisional: gate.present ? gate.provisional : true, + stage: gate.stage, + entries, + seasonalUnlocked: seasonalComparisonsUnlocked(entries), + selfAssessment, + deviation: against?.deviation ?? null, + deviationAmount: against?.amount ?? null, + contributions: parseContributions(row.features), + }; +} diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts index bde824988..99ac6989e 100644 --- a/src/lib/query-keys/mood.ts +++ b/src/lib/query-keys/mood.ts @@ -58,6 +58,18 @@ export const moodKeys = { moodLinkedContext: (date: string, tz: string) => ["mood-linked-context", date, tz] as const, + /** + * v1.38 — the day's two readings: the person's own rating and what their + * past days imply, with the band and the case count. + * + * Under the `["mood-entries"]` prefix so `moodDependentKeys` reaches it: the + * self-assessment half of the payload comes straight off a mood row, and an + * edit to today's entry changes the deviation the card shows. The forecast + * half does not move until the nightly job runs again, which is why the read + * is cheap enough to refetch on a mood write. + */ + moodPrognosis: () => ["mood-entries", "prognosis"] as const, + moodAnalytics: () => ["mood-analytics"] as const, /** * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,