From f65181bbf278e93f46a697188e223f498b433643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:38:18 +0200 Subject: [PATCH] feat(insights): surface mood time-of-day, stability, and weight/BP correlations Render the new mood analyses on the insights surface: - a four-bar time-of-day chart (morning / afternoon / evening / night), shown only when the server marks the pattern reliable - a stability tile beside the in-target tile, with the descriptive band - the time-of-day takeaway in the narrative feed (new Clock icon, daypart label resolved from the bundle) Also wire the mood x weight and mood x systolic-blood-pressure scatter cards. Their Pearson coefficients and points were already computed and returned by the endpoint but never rendered; they now sit alongside the sleep / activity / pulse cards, each gated on the same minimum-pairs threshold so a thin pairing renders an empty state rather than a noisy r. New i18n keys land EN-first and propagate across de / es / fr / it / pl. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f) --- messages/de.json | 24 ++- messages/en.json | 24 ++- messages/es.json | 24 ++- messages/fr.json | 24 ++- messages/it.json | 24 ++- messages/pl.json | 24 ++- .../mood-insights-sections-in-target.test.tsx | 13 ++ .../insights/mood/mood-correlation-cards.tsx | 14 +- .../insights/mood/mood-insights-sections.tsx | 30 +++- .../insights/mood/mood-narrative-feed.tsx | 3 + .../insights/mood/mood-stability-tile.tsx | 66 ++++++++ .../insights/mood/mood-time-of-day-chart.tsx | 149 ++++++++++++++++++ 12 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 src/components/insights/mood/mood-stability-tile.tsx create mode 100644 src/components/insights/mood/mood-time-of-day-chart.tsx diff --git a/messages/de.json b/messages/de.json index 0b4fce63a..f958e2f11 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2412,7 +2412,11 @@ "strengthNone": "Keine", "source": "{n} gepaarte Tage · r = {r}", "emptyTitle": "Noch nicht genug gepaarte Daten", - "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen." + "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.", + "weightTitle": "Stimmung vs. Gewicht", + "bloodPressureTitle": "Stimmung vs. Blutdruck", + "weightAxis": "Gewicht", + "bloodPressureAxis": "Systolisch (mmHg)" }, "structuredTagsTitle": "Tag-Auswertung", "inTargetValue": "{pct} %", @@ -2428,7 +2432,23 @@ "tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.", "tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.", "inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.", - "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so." + "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.", + "timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}." + }, + "timeOfDay": { + "title": "Stimmung nach Tageszeit", + "morning": "Morgen", + "afternoon": "Nachmittag", + "evening": "Abend", + "night": "Nacht", + "bestCaption": "Am {bucket} fühlst du dich tendenziell am besten." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Deine Stimmung war sehr gleichmäßig", + "bandSteady": "Deine Stimmung war recht gleichmäßig", + "bandVariable": "Deine Stimmung war merklich schwankend", + "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" } } }, diff --git a/messages/en.json b/messages/en.json index 2d8dd0c2f..c445fc30d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2412,7 +2412,11 @@ "strengthNone": "None", "source": "{n} paired days · r = {r}", "emptyTitle": "Not enough paired data yet", - "emptyDescription": "Log mood and this metric on the same days to see a correlation." + "emptyDescription": "Log mood and this metric on the same days to see a correlation.", + "weightTitle": "Mood vs. weight", + "bloodPressureTitle": "Mood vs. blood pressure", + "weightAxis": "Weight", + "bloodPressureAxis": "Systolic (mmHg)" }, "structuredTagsTitle": "Tag breakdown", "inTargetValue": "{pct}%", @@ -2428,7 +2432,23 @@ "tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.", "tagDrop": "Days tagged \"{tag}\" run {delta} below your average.", "inTarget": "You've been in your good-mood range {pct}% of the last 30 days.", - "streak": "You've logged your mood {days} days in a row — keep it up." + "streak": "You've logged your mood {days} days in a row — keep it up.", + "timeOfDay": "You feel best in the {bucket}, averaging {value}." + }, + "timeOfDay": { + "title": "Mood by time of day", + "morning": "Morning", + "afternoon": "Afternoon", + "evening": "Evening", + "night": "Night", + "bestCaption": "You tend to feel best in the {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Your mood has been very steady", + "bandSteady": "Your mood has been fairly steady", + "bandVariable": "Your mood has been noticeably variable", + "bandVeryVariable": "Your mood has swung a lot lately" } } }, diff --git a/messages/es.json b/messages/es.json index 81e7c7f83..f1e7155ef 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2412,7 +2412,11 @@ "strengthNone": "Ninguna", "source": "{n} días emparejados · r = {r}", "emptyTitle": "Aún no hay suficientes datos emparejados", - "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación." + "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.", + "weightTitle": "Ánimo vs. peso", + "bloodPressureTitle": "Ánimo vs. presión arterial", + "weightAxis": "Peso", + "bloodPressureAxis": "Sistólica (mmHg)" }, "structuredTagsTitle": "Desglose de etiquetas", "inTargetValue": "{pct} %", @@ -2428,7 +2432,23 @@ "tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.", "tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.", "inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.", - "streak": "Has registrado tu ánimo {days} días seguidos: sigue así." + "streak": "Has registrado tu ánimo {days} días seguidos: sigue así.", + "timeOfDay": "Te sientes mejor por la {bucket}, con una media de {value}." + }, + "timeOfDay": { + "title": "Estado de ánimo por hora del día", + "morning": "Mañana", + "afternoon": "Tarde", + "evening": "Noche", + "night": "Madrugada", + "bestCaption": "Sueles sentirte mejor por la {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Tu estado de ánimo ha sido muy estable", + "bandSteady": "Tu estado de ánimo ha sido bastante estable", + "bandVariable": "Tu estado de ánimo ha sido notablemente variable", + "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" } } }, diff --git a/messages/fr.json b/messages/fr.json index 69ba52e45..20ff9269a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2412,7 +2412,11 @@ "strengthNone": "Aucune", "source": "{n} jours appariés · r = {r}", "emptyTitle": "Pas encore assez de données appariées", - "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation." + "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.", + "weightTitle": "Humeur vs. poids", + "bloodPressureTitle": "Humeur vs. tension artérielle", + "weightAxis": "Poids", + "bloodPressureAxis": "Systolique (mmHg)" }, "structuredTagsTitle": "Répartition des étiquettes", "inTargetValue": "{pct} %", @@ -2428,7 +2432,23 @@ "tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.", "tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.", "inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.", - "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez." + "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.", + "timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}." + }, + "timeOfDay": { + "title": "Humeur selon l'heure de la journée", + "morning": "Matin", + "afternoon": "Après-midi", + "evening": "Soir", + "night": "Nuit", + "bestCaption": "Vous vous sentez généralement mieux le {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Votre humeur a été très stable", + "bandSteady": "Votre humeur a été plutôt stable", + "bandVariable": "Votre humeur a été nettement variable", + "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" } } }, diff --git a/messages/it.json b/messages/it.json index 360007928..fb830e1ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2412,7 +2412,11 @@ "strengthNone": "Nessuna", "source": "{n} giorni appaiati · r = {r}", "emptyTitle": "Non ci sono ancora abbastanza dati appaiati", - "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione." + "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.", + "weightTitle": "Umore vs. peso", + "bloodPressureTitle": "Umore vs. pressione sanguigna", + "weightAxis": "Peso", + "bloodPressureAxis": "Sistolica (mmHg)" }, "structuredTagsTitle": "Analisi etichette", "inTargetValue": "{pct} %", @@ -2428,7 +2432,23 @@ "tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.", "tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.", "inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.", - "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così." + "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.", + "timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}." + }, + "timeOfDay": { + "title": "Umore per fascia oraria", + "morning": "Mattina", + "afternoon": "Pomeriggio", + "evening": "Sera", + "night": "Notte", + "bestCaption": "Tendi a sentirti meglio di {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Il tuo umore è stato molto stabile", + "bandSteady": "Il tuo umore è stato abbastanza stabile", + "bandVariable": "Il tuo umore è stato notevolmente variabile", + "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" } } }, diff --git a/messages/pl.json b/messages/pl.json index 2e37e134e..e198df4ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2412,7 +2412,11 @@ "strengthNone": "Brak", "source": "{n} sparowanych dni · r = {r}", "emptyTitle": "Za mało sparowanych danych", - "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację." + "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.", + "weightTitle": "Nastrój vs. waga", + "bloodPressureTitle": "Nastrój vs. ciśnienie krwi", + "weightAxis": "Waga", + "bloodPressureAxis": "Skurczowe (mmHg)" }, "structuredTagsTitle": "Podział tagów", "inTargetValue": "{pct}%", @@ -2428,7 +2432,23 @@ "tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.", "tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.", "inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.", - "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj." + "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.", + "timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}." + }, + "timeOfDay": { + "title": "Nastrój według pory dnia", + "morning": "Rano", + "afternoon": "Popołudnie", + "evening": "Wieczór", + "night": "Noc", + "bestCaption": "Zwykle najlepiej czujesz się {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Twój nastrój był bardzo stabilny", + "bandSteady": "Twój nastrój był dość stabilny", + "bandVariable": "Twój nastrój był zauważalnie zmienny", + "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" } } }, diff --git a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx index 807bed63e..714f74fa4 100644 --- a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx +++ b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx @@ -32,6 +32,13 @@ type Response = { heatmap: { windowDays: number; cells: [] }; distribution: []; weekday: []; + timeOfDay: { + buckets: []; + reliable: boolean; + best: null; + worst: null; + }; + stability: null; tags: []; structuredTags: []; narratives: Array<{ @@ -43,6 +50,8 @@ type Response = { sleep: EmptyCorrelation; steps: EmptyCorrelation; pulse: EmptyCorrelation; + weight: EmptyCorrelation; + bloodPressureSystolic: EmptyCorrelation; }; }; @@ -70,6 +79,8 @@ function baseData(inTargetPct: number | null): Response { heatmap: { windowDays: 30, cells: [] }, distribution: [], weekday: [], + timeOfDay: { buckets: [], reliable: false, best: null, worst: null }, + stability: null, tags: [], structuredTags: [], narratives: [ @@ -88,6 +99,8 @@ function baseData(inTargetPct: number | null): Response { sleep: emptyCorrelation, steps: emptyCorrelation, pulse: emptyCorrelation, + weight: emptyCorrelation, + bloodPressureSystolic: emptyCorrelation, }, }; } diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index cdb3bcc7b..1f5983161 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -45,24 +45,30 @@ export interface MoodMetricCorrelationData { n: number; } -type MetricKind = "sleep" | "steps" | "pulse"; +type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure"; const TITLE_KEY: Record = { sleep: "insights.mood.correlation.sleepTitle", steps: "insights.mood.correlation.stepsTitle", pulse: "insights.mood.correlation.pulseTitle", + weight: "insights.mood.correlation.weightTitle", + bloodPressure: "insights.mood.correlation.bloodPressureTitle", }; const Y_LABEL_KEY: Record = { sleep: "insights.mood.correlation.sleepAxis", steps: "insights.mood.correlation.stepsAxis", pulse: "insights.mood.correlation.pulseAxis", + weight: "insights.mood.correlation.weightAxis", + bloodPressure: "insights.mood.correlation.bloodPressureAxis", }; const FILL_BY_KIND: Record = { sleep: "var(--dracula-purple)", steps: "var(--dracula-cyan)", pulse: "var(--dracula-pink)", + weight: "var(--dracula-green)", + bloodPressure: "var(--dracula-orange)", }; const STRENGTH_KEY: Record = { @@ -136,16 +142,22 @@ export function MoodCorrelationCards({ sleep, steps, pulse, + weight, + bloodPressureSystolic, }: { sleep: MoodMetricCorrelationData; steps: MoodMetricCorrelationData; pulse: MoodMetricCorrelationData; + weight: MoodMetricCorrelationData; + bloodPressureSystolic: MoodMetricCorrelationData; }) { return (
+ +
); } diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index e65225593..256b79573 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -33,6 +33,14 @@ import { type MoodNarrativeItem, } from "./mood-narrative-feed"; import { MoodInTargetTile } from "./mood-in-target-tile"; +import { + MoodTimeOfDayChart, + type MoodTimeOfDayPattern, +} from "./mood-time-of-day-chart"; +import { + MoodStabilityTile, + type MoodStabilityData, +} from "./mood-stability-tile"; /** * v1.8.5 — additional Mood Insights sections. @@ -55,6 +63,8 @@ interface MoodInsightsResponse { }; distribution: MoodDistributionRow[]; weekday: MoodWeekdayRow[]; + timeOfDay: MoodTimeOfDayPattern; + stability: MoodStabilityData | null; tags: MoodTagRow[]; structuredTags: MoodStructuredTagRow[]; narratives: MoodNarrativeItem[]; @@ -62,6 +72,8 @@ interface MoodInsightsResponse { sleep: MoodMetricCorrelationData; steps: MoodMetricCorrelationData; pulse: MoodMetricCorrelationData; + weight: MoodMetricCorrelationData; + bloodPressureSystolic: MoodMetricCorrelationData; }; } @@ -122,9 +134,17 @@ export function MoodInsightsSections() { ? data.narratives.filter((item) => item.kind !== "in-target") : data.narratives; + const hasStabilityTile = data.stability != null; + const hasInTargetTile = data.summary.inTargetPct != null; + return (
- + {(hasInTargetTile || hasStabilityTile) && ( +
+ + +
+ )} @@ -145,6 +165,12 @@ export function MoodInsightsSections() {
+ {data.timeOfDay.reliable && ( + + + + )} + {hasStructuredTags && ( @@ -168,6 +194,8 @@ export function MoodInsightsSections() { sleep={data.correlations.sleep} steps={data.correlations.steps} pulse={data.correlations.pulse} + weight={data.correlations.weight} + bloodPressureSystolic={data.correlations.bloodPressureSystolic} /> diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index a528cd69f..ce2f2444c 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -4,6 +4,7 @@ import { ArrowDownRight, ArrowUpRight, CalendarDays, + Clock, Flame, Tag, Target, @@ -42,6 +43,7 @@ const ICONS: Record> = { "tag-drop": ArrowDownRight, "in-target": Target, streak: Flame, + "time-of-day": Clock, }; function pickIcon(item: MoodNarrativeItem): ComponentType<{ @@ -76,6 +78,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { const vars: Record = { ...item.vars }; if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey); if (vars.tagKey) vars.tag = t(vars.tagKey); + if (vars.bucketKey) vars.bucket = t(vars.bucketKey); return (
  • = { + verySteady: "insights.mood.stability.bandVerySteady", + steady: "insights.mood.stability.bandSteady", + variable: "insights.mood.stability.bandVariable", + veryVariable: "insights.mood.stability.bandVeryVariable", +}; + +export function MoodStabilityTile({ + stability, +}: { + stability: MoodStabilityData | null; +}) { + const { t } = useTranslations(); + + if (stability == null) return null; + + return ( + + +
    + +
    +
    +

    + {t("insights.mood.stability.value", { + score: String(stability.score), + })} +

    +

    + {t(BAND_KEY[stability.band])} +

    +
    +
    +
    + ); +} diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx new file mode 100644 index 000000000..7478ead1f --- /dev/null +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { + Bar, + BarChart, + Cell, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.9.0 — average mood per part of day. + * + * Four bars (morning / afternoon / evening / night) over the average + * mood logged in each part of the user's local day. The buckets are + * computed server-side in each entry's own timezone and gated on a + * spread floor (`reliable`); the parent only mounts this chart when the + * pattern is trustworthy, so a once-a-day logger never sees a misleading + * single bar dressed up as a daypart preference. Empty buckets render as + * a muted zero-height bar so the four-column rhythm stays intact. + */ + +export type MoodTimeOfDayBucket = + | "morning" + | "afternoon" + | "evening" + | "night"; + +export interface MoodTimeOfDayRow { + bucket: MoodTimeOfDayBucket; + avgScore: number | null; + count: number; +} + +export interface MoodTimeOfDayPattern { + buckets: MoodTimeOfDayRow[]; + reliable: boolean; + best: MoodTimeOfDayBucket | null; + worst: MoodTimeOfDayBucket | null; +} + +const LABEL_KEYS: Record = { + morning: "insights.mood.timeOfDay.morning", + afternoon: "insights.mood.timeOfDay.afternoon", + evening: "insights.mood.timeOfDay.evening", + night: "insights.mood.timeOfDay.night", +}; + +function colorForScore(score: number): string { + if (score < 2) return "var(--dracula-red)"; + if (score < 3) return "var(--dracula-orange)"; + if (score < 3.5) return "var(--dracula-yellow)"; + return "var(--dracula-green)"; +} + +export function MoodTimeOfDayChart({ + pattern, +}: { + pattern: MoodTimeOfDayPattern; +}) { + const { t } = useTranslations(); + + const data = pattern.buckets.map((row) => ({ + bucket: row.bucket, + label: t(LABEL_KEYS[row.bucket]), + value: row.avgScore ?? 0, + avgScore: row.avgScore, + count: row.count, + })); + + const bestLabel = pattern.best ? t(LABEL_KEYS[pattern.best]) : null; + + return ( +
    +
    + + + + + + { + const payload = item?.payload as + | { avgScore: number | null; count: number } + | undefined; + if (!payload || payload.avgScore == null) { + return [t("insights.mood.weekdayNoData"), ""]; + } + return [ + `${payload.avgScore.toFixed(1)} (${payload.count})`, + t("insights.mood.timeOfDay.title"), + ]; + }} + /> + + {data.map((row) => ( + + ))} + + + +
    + {bestLabel && ( +

    + {t("insights.mood.timeOfDay.bestCaption", { bucket: bestLabel })} +

    + )} +
    + ); +}