From f48afed82db2aa5978b91bb134af68bae4436285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:43:58 +0200 Subject: [PATCH] feat(mood): show the day's own rating beside what the account's days imply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/pl.json | 19 ++ .../copy-matches-behaviour-guard.test.ts | 154 ++++++++++- src/__tests__/sharing-surface-guard.test.ts | 10 +- src/app/api/mood/prognosis/route.ts | 63 +++++ src/app/insights/mood/page.tsx | 21 ++ .../__tests__/mood-prognosis-card.test.tsx | 164 ++++++++++++ .../insights/mood/mood-prognosis-card.tsx | 241 ++++++++++++++++++ .../insights/mood/prognosis-feature-label.ts | 71 ++++++ src/lib/analytics/mood-prognosis/read.ts | 207 +++++++++++++++ src/lib/query-keys/mood.ts | 12 + 15 files changed, 1053 insertions(+), 4 deletions(-) create mode 100644 src/app/api/mood/prognosis/route.ts create mode 100644 src/components/insights/mood/__tests__/mood-prognosis-card.test.tsx create mode 100644 src/components/insights/mood/mood-prognosis-card.tsx create mode 100644 src/components/insights/mood/prognosis-feature-label.ts create mode 100644 src/lib/analytics/mood-prognosis/read.ts 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,