From 84e592b17eea35ffa965abed02793b12047c5e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 19:53:40 +0200 Subject: [PATCH] feat(insights): enrich the mood page with calendar, distribution, weekday, and correlation views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the mood aggregates the AI status generator already computes — tag frequency + per-tag average, in-target share, and the mood × metric Pearson correlations — into a shared `mood-aggregates.ts` so the prose and the new charts read off one implementation. `mood-status.ts` now consumes the shared helpers instead of its own inline copies. Add a cached `/api/mood/insights` endpoint that returns the pre-computed shapes (summary, heatmap, distribution, weekday, tags, correlations) behind a 60-second server cache and a bounded 365-day read — a cheap read-only path, never a synchronous model call. The bucket invalidates alongside the mood-analytics cache on a mood write. Render the shapes on the mood Insights page: - a calendar heatmap forked from the compliance heatmap, coloured by the same mood-score bands as the line chart, with an adaptive window (30 days until ~90 days of history, then 90, then a full year past 180 days) so the grid is never mostly empty; - a mood distribution bar chart (share of days per level); - average mood by weekday with a best/toughest-day callout; - a tag / trigger breakdown (frequency bars + per-tag average); - mood vs. sleep / activity / pulse correlation cards reusing the existing scatter chart. Add the supporting i18n keys across all six locales. --- messages/de.json | 45 +- messages/en.json | 45 +- messages/es.json | 45 +- messages/fr.json | 45 +- messages/it.json | 45 +- messages/pl.json | 45 +- src/app/api/mood/insights/route.ts | 43 ++ src/app/insights/mood/page.tsx | 3 + src/components/charts/mood-heatmap.tsx | 316 +++++++++++ .../insights/mood/mood-correlation-cards.tsx | 151 ++++++ .../insights/mood/mood-distribution-chart.tsx | 100 ++++ .../insights/mood/mood-insights-sections.tsx | 143 +++++ .../insights/mood/mood-tag-breakdown.tsx | 63 +++ .../insights/mood/mood-weekday-chart.tsx | 144 +++++ src/lib/cache/invalidate.ts | 3 + src/lib/cache/server-cache.ts | 12 + .../__tests__/mood-aggregates.test.ts | 238 +++++++++ src/lib/insights/mood-aggregates.ts | 503 ++++++++++++++++++ src/lib/insights/mood-status.ts | 102 +--- src/lib/query-keys.ts | 8 + 20 files changed, 2003 insertions(+), 96 deletions(-) create mode 100644 src/app/api/mood/insights/route.ts create mode 100644 src/components/charts/mood-heatmap.tsx create mode 100644 src/components/insights/mood/mood-correlation-cards.tsx create mode 100644 src/components/insights/mood/mood-distribution-chart.tsx create mode 100644 src/components/insights/mood/mood-insights-sections.tsx create mode 100644 src/components/insights/mood/mood-tag-breakdown.tsx create mode 100644 src/components/insights/mood/mood-weekday-chart.tsx create mode 100644 src/lib/insights/__tests__/mood-aggregates.test.ts create mode 100644 src/lib/insights/mood-aggregates.ts diff --git a/messages/de.json b/messages/de.json index 5c21154dd..f80464152 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1346,7 +1346,16 @@ "personalBaseline": "Dein Mittel", "deltaVsBaseline": "{delta} vs. dein Mittel", "deltaUnchanged": "entspricht deinem Mittel", - "deltaVsTarget": "{delta} vs. Ziel" + "deltaVsTarget": "{delta} vs. Ziel", + "weekdaysFull": { + "mon": "Montag", + "tue": "Dienstag", + "wed": "Mittwoch", + "thu": "Donnerstag", + "fri": "Freitag", + "sat": "Samstag", + "sun": "Sonntag" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." } }, - "navWalkingSpeed": "Gehgeschwindigkeit" + "navWalkingSpeed": "Gehgeschwindigkeit", + "mood": { + "heatmapTitle": "Stimmungskalender", + "heatmapNoEntry": "Kein Eintrag", + "legendGreat": "Super", + "legendGood": "Gut", + "legendOkay": "Okay", + "legendLow": "Niedrig", + "distributionTitle": "Stimmungsverteilung", + "distributionDaysUnit": "Tage", + "weekdayTitle": "Durchschnitt nach Wochentag", + "weekdayNoData": "Keine Daten", + "weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}", + "tagsTitle": "Tags und Auslöser", + "tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag", + "correlationsTitle": "Stimmung und andere Werte", + "correlationsDescription": "Wie sich deine Stimmung am selben Tag mit Schlaf, Aktivität und Puls bewegt.", + "correlation": { + "sleepTitle": "Stimmung vs. Schlaf", + "stepsTitle": "Stimmung vs. Aktivität", + "pulseTitle": "Stimmung vs. Puls", + "sleepAxis": "Schlaf (Min.)", + "stepsAxis": "Schritte", + "pulseAxis": "Puls (bpm)", + "strengthStrong": "Stark", + "strengthModerate": "Moderat", + "strengthWeak": "Schwach", + "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." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index 6af21ada1..a27b5e425 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1346,7 +1346,16 @@ "personalBaseline": "Your normal", "deltaVsBaseline": "{delta} vs. your normal", "deltaUnchanged": "matches your normal", - "deltaVsTarget": "{delta} vs. target" + "deltaVsTarget": "{delta} vs. target", + "weekdaysFull": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Walking-speed readings appear here once Apple Health syncs." } }, - "navWalkingSpeed": "Walking Speed" + "navWalkingSpeed": "Walking Speed", + "mood": { + "heatmapTitle": "Mood calendar", + "heatmapNoEntry": "No entry", + "legendGreat": "Great", + "legendGood": "Good", + "legendOkay": "Okay", + "legendLow": "Low", + "distributionTitle": "Mood distribution", + "distributionDaysUnit": "days", + "weekdayTitle": "Average by weekday", + "weekdayNoData": "No data", + "weekdayBestWorst": "Best day: {best} · Toughest day: {worst}", + "tagsTitle": "Tags and triggers", + "tagAvgMood": "Average mood when tagged", + "correlationsTitle": "Mood and other metrics", + "correlationsDescription": "How your mood moves with sleep, activity, and pulse on the same day.", + "correlation": { + "sleepTitle": "Mood vs. sleep", + "stepsTitle": "Mood vs. activity", + "pulseTitle": "Mood vs. pulse", + "sleepAxis": "Sleep (min)", + "stepsAxis": "Steps", + "pulseAxis": "Pulse (bpm)", + "strengthStrong": "Strong", + "strengthModerate": "Moderate", + "strengthWeak": "Weak", + "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." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index 59eed95f0..5c75fa700 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1346,7 +1346,16 @@ "personalBaseline": "Tu media", "deltaVsBaseline": "{delta} vs. tu media", "deltaUnchanged": "coincide con tu media", - "deltaVsTarget": "{delta} vs. objetivo" + "deltaVsTarget": "{delta} vs. objetivo", + "weekdaysFull": { + "mon": "Lunes", + "tue": "Martes", + "wed": "Miércoles", + "thu": "Jueves", + "fri": "Viernes", + "sat": "Sábado", + "sun": "Domingo" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Las lecturas aparecen aquí cuando Apple Health sincroniza." } }, - "navWalkingSpeed": "Velocidad al andar" + "navWalkingSpeed": "Velocidad al andar", + "mood": { + "heatmapTitle": "Calendario de ánimo", + "heatmapNoEntry": "Sin registro", + "legendGreat": "Genial", + "legendGood": "Bien", + "legendOkay": "Regular", + "legendLow": "Bajo", + "distributionTitle": "Distribución del ánimo", + "distributionDaysUnit": "días", + "weekdayTitle": "Promedio por día de la semana", + "weekdayNoData": "Sin datos", + "weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}", + "tagsTitle": "Etiquetas y desencadenantes", + "tagAvgMood": "Ánimo promedio con esta etiqueta", + "correlationsTitle": "Ánimo y otras métricas", + "correlationsDescription": "Cómo se mueve tu ánimo con el sueño, la actividad y el pulso el mismo día.", + "correlation": { + "sleepTitle": "Ánimo vs. sueño", + "stepsTitle": "Ánimo vs. actividad", + "pulseTitle": "Ánimo vs. pulso", + "sleepAxis": "Sueño (min)", + "stepsAxis": "Pasos", + "pulseAxis": "Pulso (lpm)", + "strengthStrong": "Fuerte", + "strengthModerate": "Moderada", + "strengthWeak": "Débil", + "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." + } + } }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index 029d00f7e..477e1d1df 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1346,7 +1346,16 @@ "personalBaseline": "Ta moyenne", "deltaVsBaseline": "{delta} vs. ta moyenne", "deltaUnchanged": "correspond à ta moyenne", - "deltaVsTarget": "{delta} vs. cible" + "deltaVsTarget": "{delta} vs. cible", + "weekdaysFull": { + "mon": "Lundi", + "tue": "Mardi", + "wed": "Mercredi", + "thu": "Jeudi", + "fri": "Vendredi", + "sat": "Samedi", + "sun": "Dimanche" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise." } }, - "navWalkingSpeed": "Vitesse de marche" + "navWalkingSpeed": "Vitesse de marche", + "mood": { + "heatmapTitle": "Calendrier d'humeur", + "heatmapNoEntry": "Aucune entrée", + "legendGreat": "Excellent", + "legendGood": "Bien", + "legendOkay": "Correct", + "legendLow": "Bas", + "distributionTitle": "Répartition de l'humeur", + "distributionDaysUnit": "jours", + "weekdayTitle": "Moyenne par jour de la semaine", + "weekdayNoData": "Aucune donnée", + "weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}", + "tagsTitle": "Étiquettes et déclencheurs", + "tagAvgMood": "Humeur moyenne avec cette étiquette", + "correlationsTitle": "Humeur et autres mesures", + "correlationsDescription": "Comment votre humeur évolue avec le sommeil, l'activité et le pouls le même jour.", + "correlation": { + "sleepTitle": "Humeur vs. sommeil", + "stepsTitle": "Humeur vs. activité", + "pulseTitle": "Humeur vs. pouls", + "sleepAxis": "Sommeil (min)", + "stepsAxis": "Pas", + "pulseAxis": "Pouls (bpm)", + "strengthStrong": "Forte", + "strengthModerate": "Modérée", + "strengthWeak": "Faible", + "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." + } + } }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index f28a7b9ac..f8dffe04e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1346,7 +1346,16 @@ "personalBaseline": "La tua media", "deltaVsBaseline": "{delta} vs. la tua media", "deltaUnchanged": "corrisponde alla tua media", - "deltaVsTarget": "{delta} vs. obiettivo" + "deltaVsTarget": "{delta} vs. obiettivo", + "weekdaysFull": { + "mon": "Lunedì", + "tue": "Martedì", + "wed": "Mercoledì", + "thu": "Giovedì", + "fri": "Venerdì", + "sat": "Sabato", + "sun": "Domenica" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Le letture appaiono qui quando Apple Health si sincronizza." } }, - "navWalkingSpeed": "Velocità di cammino" + "navWalkingSpeed": "Velocità di cammino", + "mood": { + "heatmapTitle": "Calendario dell'umore", + "heatmapNoEntry": "Nessun dato", + "legendGreat": "Ottimo", + "legendGood": "Buono", + "legendOkay": "Discreto", + "legendLow": "Basso", + "distributionTitle": "Distribuzione dell'umore", + "distributionDaysUnit": "giorni", + "weekdayTitle": "Media per giorno della settimana", + "weekdayNoData": "Nessun dato", + "weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}", + "tagsTitle": "Tag e fattori scatenanti", + "tagAvgMood": "Umore medio con questo tag", + "correlationsTitle": "Umore e altre metriche", + "correlationsDescription": "Come il tuo umore si muove con sonno, attività e battito nello stesso giorno.", + "correlation": { + "sleepTitle": "Umore vs. sonno", + "stepsTitle": "Umore vs. attività", + "pulseTitle": "Umore vs. battito", + "sleepAxis": "Sonno (min)", + "stepsAxis": "Passi", + "pulseAxis": "Battito (bpm)", + "strengthStrong": "Forte", + "strengthModerate": "Moderata", + "strengthWeak": "Debole", + "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." + } + } }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index 8e8098fe5..f61ac8f73 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1346,7 +1346,16 @@ "personalBaseline": "Twoja średnia", "deltaVsBaseline": "{delta} vs. twoja średnia", "deltaUnchanged": "odpowiada twojej średniej", - "deltaVsTarget": "{delta} vs. cel" + "deltaVsTarget": "{delta} vs. cel", + "weekdaysFull": { + "mon": "Poniedziałek", + "tue": "Wtorek", + "wed": "Środa", + "thu": "Czwartek", + "fri": "Piątek", + "sat": "Sobota", + "sun": "Niedziela" + } }, "chart": { "overlay": { @@ -2275,7 +2284,39 @@ "description": "Odczyty pojawią się tutaj po synchronizacji Apple Health." } }, - "navWalkingSpeed": "Prędkość chodu" + "navWalkingSpeed": "Prędkość chodu", + "mood": { + "heatmapTitle": "Kalendarz nastroju", + "heatmapNoEntry": "Brak wpisu", + "legendGreat": "Świetnie", + "legendGood": "Dobrze", + "legendOkay": "W porządku", + "legendLow": "Nisko", + "distributionTitle": "Rozkład nastroju", + "distributionDaysUnit": "dni", + "weekdayTitle": "Średnia wg dnia tygodnia", + "weekdayNoData": "Brak danych", + "weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}", + "tagsTitle": "Tagi i wyzwalacze", + "tagAvgMood": "Średni nastrój z tym tagiem", + "correlationsTitle": "Nastrój a inne wskaźniki", + "correlationsDescription": "Jak Twój nastrój zmienia się wraz ze snem, aktywnością i tętnem tego samego dnia.", + "correlation": { + "sleepTitle": "Nastrój a sen", + "stepsTitle": "Nastrój a aktywność", + "pulseTitle": "Nastrój a tętno", + "sleepAxis": "Sen (min)", + "stepsAxis": "Kroki", + "pulseAxis": "Tętno (bpm)", + "strengthStrong": "Silna", + "strengthModerate": "Umiarkowana", + "strengthWeak": "Słaba", + "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ę." + } + } }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts new file mode 100644 index 000000000..a21037371 --- /dev/null +++ b/src/app/api/mood/insights/route.ts @@ -0,0 +1,43 @@ +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { + fetchMoodAggregates, + type MoodAggregates, +} from "@/lib/insights/mood-aggregates"; + +export const dynamic = "force-dynamic"; + +/** + * v1.8.5 — pre-computed mood-insights aggregates for the Mood Insights + * page (heatmap, distribution, weekday pattern, tag breakdown, + * cross-metric correlations, summary headline). + * + * The page stays a client component for the interactive line chart but + * reads the heavy aggregates from here: the compute runs server-side + * once per 60-s cache window, the browser only ever receives pre-shaped + * data (no raw mood rows). This follows the v1.8.3 anti-freeze posture + * — a cheap cached read, never a synchronous LLM call. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const result = await cached( + caches.moodInsights as ServerCache, + user.id, + () => fetchMoodAggregates(user.id), + annotate, + ); + + annotate({ + action: { name: "mood.insights.read" }, + meta: { + total_entries: result.summary.totalEntries, + heatmap_window_days: result.heatmap.windowDays, + tag_count: result.tags.length, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index cdb451715..ce89442ef 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -16,6 +16,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -107,6 +108,8 @@ export default function InsightsStimmungPage() { + + } diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx new file mode 100644 index 000000000..35bf3fc28 --- /dev/null +++ b/src/components/charts/mood-heatmap.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useState, useMemo, useEffect, useRef } from "react"; +import { useTranslations } from "@/lib/i18n/context"; +import { moodLabelKeyForScore } from "@/lib/mood/labels"; + +/** + * v1.8.5 — mood calendar heatmap. + * + * Forked from `compliance-heatmap.tsx`: same SVG cell-grid, Monday-aligned + * columns, month markers, tap-to-pin tooltip, and `stretch` adaptive cell + * sizing. The one swap is `getColor` — instead of an expected/taken + * compliance map, each cell is coloured by its daily-mean mood score band + * (matching the `mood-chart.tsx` VALUE_BANDS: 1–2 red, 2–3 orange, 3–5 + * green). One cell per calendar day; days with no entry render in the + * neutral `--secondary` fill. + */ + +interface MoodHeatmapCell { + /** YYYY-MM-DD day key. */ + date: string; + /** Daily-mean mood score 1..5. */ + score: number; + /** Number of entries that fed the mean. */ + samples: number; +} + +interface MoodHeatmapProps { + /** Per-day cells keyed by YYYY-MM-DD. */ + cells: Record; + days?: number; + stretch?: boolean; +} + +const CELL_SIZE = 18; +const GAP = 3; +const CELL_FLOOR_PX = 14; + +/** + * Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS + * (1–2 red, 2–3 orange, 3–5 green) and the green/orange thresholds in + * `mood-aggregates.ts`. No score (no entry that day) → neutral fill. + */ +function getColor(score: number | null): string { + if (score == null) return "var(--secondary)"; + 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)"; +} + +function formatDateDE(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00Z"); + return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`; +} + +export function MoodHeatmap({ + cells: cellData, + days = 90, + stretch = false, +}: MoodHeatmapProps) { + const { t } = useTranslations(); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [tooltip, setTooltip] = useState<{ + x: number; + y: number; + text: string; + pinned?: boolean; + } | null>(null); + + useEffect(() => { + if (!tooltip?.pinned) return; + const handlePointer = (event: PointerEvent) => { + const container = containerRef.current; + if (!container) return; + if (!container.contains(event.target as Node)) { + setTooltip(null); + } + }; + document.addEventListener("pointerdown", handlePointer, true); + return () => { + document.removeEventListener("pointerdown", handlePointer, true); + }; + }, [tooltip?.pinned]); + + const WEEKDAY_LABELS = [ + t("charts.weekdays.mon"), + "", + t("charts.weekdays.wed"), + "", + t("charts.weekdays.fri"), + "", + t("charts.weekdays.sun"), + ]; + + useEffect(() => { + if (!stretch) return; + const element = containerRef.current; + if (!element) return; + + const updateWidth = () => { + setContainerWidth(element.clientWidth); + }; + + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [stretch]); + + const { cells, weeks, monthMarkers } = useMemo(() => { + const MONTH_LABELS = [ + t("charts.months.jan"), + t("charts.months.feb"), + t("charts.months.mar"), + t("charts.months.apr"), + t("charts.months.may"), + t("charts.months.jun"), + t("charts.months.jul"), + t("charts.months.aug"), + t("charts.months.sep"), + t("charts.months.oct"), + t("charts.months.nov"), + t("charts.months.dec"), + ]; + const now = new Date(); + const cellList: Array<{ + dateKey: string; + col: number; + row: number; + color: string; + cell: MoodHeatmapCell | null; + }> = []; + + const dates: Date[] = []; + for (let d = days - 1; d >= 0; d--) { + const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); + dates.push(date); + } + + // Monday-align + month markers read off the UTC accessors so the + // grid matches the UTC-anchored dateKey (mirrors compliance-heatmap). + const firstDate = dates[0]; + const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0 + + let col = 0; + const markers: Array<{ col: number; label: string }> = []; + let lastMonth = -1; + + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const dow = (date.getUTCDay() + 6) % 7; // Monday = 0 + const currentCol = Math.floor((i + firstDow) / 7); + const row = dow; + const dateKey = date.toISOString().slice(0, 10); + const cell = cellData[dateKey] ?? null; + + const month = date.getUTCMonth(); + if (month !== lastMonth) { + markers.push({ col: currentCol, label: MONTH_LABELS[month] }); + lastMonth = month; + } + + cellList.push({ + dateKey, + col: currentCol, + row, + color: getColor(cell?.score ?? null), + cell, + }); + + col = Math.max(col, currentCol); + } + + return { cells: cellList, weeks: col + 1, monthMarkers: markers }; + }, [cellData, days, t]); + + const labelWidth = stretch ? 0 : 76; + const headerHeight = 18; + const cellSize = + stretch && containerWidth > 0 + ? Math.max( + CELL_FLOOR_PX, + (containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) / + Math.max(weeks, 1), + ) + : CELL_SIZE; + const step = cellSize + GAP; + const svgWidth = labelWidth + weeks * cellSize + Math.max(0, weeks - 1) * GAP; + const svgHeight = headerHeight + 7 * cellSize + 6 * GAP; + + return ( +
+
+ setTooltip((prev) => (prev?.pinned ? prev : null))} + > + {monthMarkers.map((m, i) => ( + + {m.label} + + ))} + + {!stretch && + WEEKDAY_LABELS.map( + (label, i) => + label && ( + + {label} + + ), + )} + + {cells.map((cell) => { + const buildText = (): string => { + if (!cell.cell) { + return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + } + const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score)); + const moodLabel = labelKey ? t(labelKey) : ""; + return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + }; + return ( + { + if (e.pointerType === "touch") return; + setTooltip({ + x: e.clientX, + y: e.clientY, + text: buildText(), + }); + }} + onPointerLeave={(e) => { + if (e.pointerType === "touch") return; + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} + onPointerDown={(e) => { + if (e.pointerType !== "touch") return; + setTooltip({ + x: e.clientX, + y: e.clientY, + text: buildText(), + pinned: true, + }); + }} + /> + ); + })} + +
+ + {tooltip && ( +
+ {tooltip.text} +
+ )} + +
+ {[ + { color: "var(--dracula-green)", label: t("insights.mood.legendGreat") }, + { color: "var(--dracula-yellow)", label: t("insights.mood.legendGood") }, + { color: "var(--dracula-orange)", label: t("insights.mood.legendOkay") }, + { color: "var(--dracula-red)", label: t("insights.mood.legendLow") }, + ].map(({ color, label }) => ( + +
+ {label} + + ))} +
+
+ ); +} diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx new file mode 100644 index 000000000..cdb3bcc7b --- /dev/null +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -0,0 +1,151 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Activity } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CorrelationResult } from "@/lib/analytics/correlations"; + +/** + * v1.8.5 — mood × metric correlation cards (Apple "Life Factors" parity). + * + * Reuses the shared `` (same wrapper the + * mother-page correlation cards ride) for each mood × (sleep / steps / + * pulse) pair. The Pearson math comes pre-computed from + * `/api/mood/insights` (single source of truth with the LLM snapshot). + * Below-threshold pairs render an EmptyState rather than a misleading + * near-zero scatter. + */ + +const ScatterCorrelationChart = dynamic( + () => + import("@/components/charts/scatter-correlation-chart").then((mod) => ({ + default: mod.ScatterCorrelationChart, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +export interface MoodCorrelationPoint { + x: number; + y: number; + [key: string]: number; +} + +export interface MoodMetricCorrelationData { + result: CorrelationResult | null; + points: MoodCorrelationPoint[]; + n: number; +} + +type MetricKind = "sleep" | "steps" | "pulse"; + +const TITLE_KEY: Record = { + sleep: "insights.mood.correlation.sleepTitle", + steps: "insights.mood.correlation.stepsTitle", + pulse: "insights.mood.correlation.pulseTitle", +}; + +const Y_LABEL_KEY: Record = { + sleep: "insights.mood.correlation.sleepAxis", + steps: "insights.mood.correlation.stepsAxis", + pulse: "insights.mood.correlation.pulseAxis", +}; + +const FILL_BY_KIND: Record = { + sleep: "var(--dracula-purple)", + steps: "var(--dracula-cyan)", + pulse: "var(--dracula-pink)", +}; + +const STRENGTH_KEY: Record = { + stark: "insights.mood.correlation.strengthStrong", + moderat: "insights.mood.correlation.strengthModerate", + schwach: "insights.mood.correlation.strengthWeak", + keine: "insights.mood.correlation.strengthNone", +}; + +function MoodCorrelationCard({ + kind, + data, +}: { + kind: MetricKind; + data: MoodMetricCorrelationData; +}) { + const { t } = useTranslations(); + const hasResult = data.result != null && data.n >= 5; + + return ( + + +
+ + {t(TITLE_KEY[kind])} + + {hasResult && data.result && ( + + {t(STRENGTH_KEY[data.result.strength])} + + )} +
+
+ + {hasResult && data.result ? ( + <> + +

+ {t("insights.mood.correlation.source", { + n: data.n, + r: data.result.r.toFixed(2), + })} +

+ + ) : ( + } + title={t("insights.mood.correlation.emptyTitle")} + description={t("insights.mood.correlation.emptyDescription")} + /> + )} +
+
+ ); +} + +export function MoodCorrelationCards({ + sleep, + steps, + pulse, +}: { + sleep: MoodMetricCorrelationData; + steps: MoodMetricCorrelationData; + pulse: MoodMetricCorrelationData; +}) { + return ( +
+ + + +
+ ); +} diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx new file mode 100644 index 000000000..5e049daff --- /dev/null +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + Bar, + BarChart, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; +import { moodLabelKeyForScore } from "@/lib/mood/labels"; + +/** + * v1.8.5 — mood distribution (share of days per level). + * + * One bar per discrete mood level 1..5, coloured by the same band map + * as the heatmap + line chart. Counts are days (daily means rounded to + * the nearest level) so multi-entry days never over-weight the + * histogram — consistent with the rest of the surface's daily-mean + * convention. + */ + +export interface MoodDistributionRow { + score: number; + count: number; +} + +const BAR_COLOR_BY_SCORE: Record = { + 1: "var(--dracula-red)", + 2: "var(--dracula-orange)", + 3: "var(--dracula-yellow)", + 4: "var(--dracula-green)", + 5: "var(--dracula-green)", +}; + +export function MoodDistributionChart({ + distribution, +}: { + distribution: MoodDistributionRow[]; +}) { + const { t } = useTranslations(); + + const data = distribution.map((row) => { + const labelKey = moodLabelKeyForScore(row.score); + return { + score: row.score, + count: row.count, + label: labelKey ? t(labelKey) : String(row.score), + }; + }); + + return ( +
+ + + + + + [ + `${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`, + t("insights.mood.distributionTitle"), + ] as [string, string] + } + /> + + {data.map((row) => ( + + ))} + + + +
+ ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx new file mode 100644 index 000000000..7e5187919 --- /dev/null +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { MoodHeatmap } from "@/components/charts/mood-heatmap"; +import { + MoodDistributionChart, + type MoodDistributionRow, +} from "./mood-distribution-chart"; +import { + MoodWeekdayChart, + type MoodWeekdayRow, +} from "./mood-weekday-chart"; +import { + MoodTagBreakdown, + type MoodTagRow, +} from "./mood-tag-breakdown"; +import { + MoodCorrelationCards, + type MoodMetricCorrelationData, +} from "./mood-correlation-cards"; + +/** + * v1.8.5 — additional Mood Insights sections. + * + * Reads the pre-computed aggregate bundle from `/api/mood/insights` + * (cheap cached server read, no LLM) and paints the heatmap, + * distribution, weekday pattern, tag breakdown, and mood × metric + * correlation cards. Renders nothing while loading / on error / on an + * empty data set so the page degrades gracefully to the line chart. + */ + +interface MoodInsightsResponse { + summary: { + totalEntries: number; + }; + heatmap: { + windowDays: number; + cells: Array<{ date: string; score: number; samples: number }>; + }; + distribution: MoodDistributionRow[]; + weekday: MoodWeekdayRow[]; + tags: MoodTagRow[]; + correlations: { + sleep: MoodMetricCorrelationData; + steps: MoodMetricCorrelationData; + pulse: MoodMetricCorrelationData; + }; +} + +function SectionCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +export function MoodInsightsSections() { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.moodInsights(), + queryFn: async () => { + const res = await fetch("/api/mood/insights"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as MoodInsightsResponse; + }, + enabled: isAuthenticated, + staleTime: 60_000, + }); + + if (isLoading) { + return ; + } + + if (!data || data.summary.totalEntries === 0) { + return null; + } + + const heatmapCells = Object.fromEntries( + data.heatmap.cells.map((cell) => [cell.date, cell]), + ); + + const hasTags = data.tags.length > 0; + + return ( +
+ + + + +
+ + + + + + +
+ + {hasTags && ( + + + + )} + +
+

+ {t("insights.mood.correlationsTitle")} +

+

+ {t("insights.mood.correlationsDescription")} +

+ +
+
+ ); +} diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx new file mode 100644 index 000000000..f7bb8044f --- /dev/null +++ b/src/components/insights/mood/mood-tag-breakdown.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.8.5 — tag / trigger breakdown. + * + * Ranked horizontal bars over tag frequency, with the per-tag average + * mood ("lift") rendered as a coloured score chip. The aggregation comes + * pre-computed from `/api/mood/insights` (shared with the LLM snapshot's + * tag block, so the prose and the chart agree). Works on the flat + * free-text tag array we hold today — the structured-tag taxonomy with + * icons is a separate later piece. + */ + +export interface MoodTagRow { + tag: string; + count: number; + avgScore: number; +} + +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 MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) { + const { t } = useTranslations(); + const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1; + + return ( +
    + {tags.map((row) => ( +
  • + + {row.tag} + +
    +
    +
    + + {row.count} + + + {row.avgScore.toFixed(1)} + +
  • + ))} +
+ ); +} diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx new file mode 100644 index 000000000..3842a64cc --- /dev/null +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bar, + BarChart, + Cell, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.8.5 — average mood per weekday (Monday-aligned). + * + * One bar per weekday over the average daily-mean mood, with the + * best/worst day called out below the chart. Empty weekdays (no data) + * render as a zero-height bar so the seven-column rhythm stays intact. + */ + +export interface MoodWeekdayRow { + /** 0 = Monday … 6 = Sunday. */ + weekday: number; + avgScore: number | null; + count: number; +} + +const WEEKDAY_LABEL_KEYS = [ + "charts.weekdaysFull.mon", + "charts.weekdaysFull.tue", + "charts.weekdaysFull.wed", + "charts.weekdaysFull.thu", + "charts.weekdaysFull.fri", + "charts.weekdaysFull.sat", + "charts.weekdaysFull.sun", +] as const; + +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 MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { + const { t } = useTranslations(); + + const data = weekday.map((row) => ({ + weekday: row.weekday, + label: t(WEEKDAY_LABEL_KEYS[row.weekday]), + value: row.avgScore ?? 0, + avgScore: row.avgScore, + count: row.count, + })); + + const { best, worst } = useMemo(() => { + const populated = weekday.filter( + (r): r is MoodWeekdayRow & { avgScore: number } => r.avgScore != null, + ); + if (populated.length === 0) return { best: null, worst: null }; + let bestRow = populated[0]; + let worstRow = populated[0]; + for (const row of populated) { + if (row.avgScore > bestRow.avgScore) bestRow = row; + if (row.avgScore < worstRow.avgScore) worstRow = row; + } + return { best: bestRow, worst: worstRow }; + }, [weekday]); + + 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.weekdayTitle"), + ]; + }} + /> + + {data.map((row) => ( + + ))} + + + +
+ {best && worst && ( +

+ {t("insights.mood.weekdayBestWorst", { + best: t(WEEKDAY_LABEL_KEYS[best.weekday]), + worst: t(WEEKDAY_LABEL_KEYS[worst.weekday]), + })} +

+ )} +
+ ); +} diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 9b527c6a2..f8b57b4bd 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -76,6 +76,9 @@ export function invalidateUserMood(userId: string): void { // v1.4.36 W1 — mood writes change the mood target rows on the // insights/targets response (MOOD_SCORE, MOOD_STABILITY). caches.insightsTargets.deleteByPrefix(userId); + // v1.8.5 — mood writes dirty every dimension of the mood-insights + // aggregate (heatmap cell, distribution, weekday, tag breakdown). + caches.moodInsights.deleteByPrefix(userId); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 03fd6a58f..638df3f07 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -260,6 +260,18 @@ export const caches = { maxEntries: 1000, ttlMs: 60_000, }), + /** + * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, + * weekday, tags, cross-metric correlations) for the Mood Insights page. + * Read on every `/insights/mood` mount; bounded 365-day live read behind + * the cache. 60 s TTL matches `moodAnalytics`; invalidated alongside it + * on a mood write via `invalidateUserMood`. + */ + moodInsights: new ServerCache({ + name: "moodInsights", + maxEntries: 1000, + ttlMs: 60_000, + }), /** * v1.4.36 W1 — `/api/insights/targets` is read on every Insights * mount and runs >1 s cold (it pulls 30-day measurements for every diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts new file mode 100644 index 000000000..8c4b3cd1a --- /dev/null +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; + +import { + computeDistribution, + computeHeatmapCells, + computeInTargetPct, + computeMoodAggregates, + computeMoodMetricCorrelation, + computeTagSummary, + computeWeekdayAverages, + selectHeatmapWindow, + type CrossMetricMeasurement, + type MoodAggregateEntry, +} from "../mood-aggregates"; + +const dayMs = 24 * 60 * 60 * 1000; + +/** Anchor "now" at a fixed UTC instant so day-key math is deterministic. */ +const NOW = new Date("2026-06-01T12:00:00.000Z"); + +function dayKey(offset: number): string { + return new Date(NOW.getTime() - offset * dayMs).toISOString().slice(0, 10); +} + +function entry( + offset: number, + score: number, + tags: unknown = null, +): MoodAggregateEntry { + const ts = new Date(NOW.getTime() - offset * dayMs); + return { date: dayKey(offset), score, tags, moodLoggedAt: ts }; +} + +describe("selectHeatmapWindow", () => { + it("uses a 30-day window below 90 days of history", () => { + expect(selectHeatmapWindow(0)).toBe(30); + expect(selectHeatmapWindow(45)).toBe(30); + expect(selectHeatmapWindow(89)).toBe(30); + }); + + it("uses a 90-day window between 90 and 180 days inclusive", () => { + expect(selectHeatmapWindow(90)).toBe(90); + expect(selectHeatmapWindow(150)).toBe(90); + expect(selectHeatmapWindow(180)).toBe(90); + }); + + it("uses a 365-day window beyond 180 days", () => { + expect(selectHeatmapWindow(181)).toBe(365); + expect(selectHeatmapWindow(400)).toBe(365); + }); +}); + +describe("computeTagSummary", () => { + it("ranks tags by frequency, drops singletons, and averages the score", () => { + const entries: MoodAggregateEntry[] = [ + entry(1, 5, ["sport", "work"]), + entry(2, 3, ["work"]), + entry(3, 1, ["work"]), + entry(4, 4, ["sport"]), + entry(5, 2, ["solo-tag"]), // count 1 → dropped + ]; + const tags = computeTagSummary(entries, NOW); + expect(tags).toEqual([ + { tag: "work", count: 3, avgScore: 3 }, + { tag: "sport", count: 2, avgScore: 4.5 }, + ]); + }); + + it("excludes entries older than the window", () => { + const entries: MoodAggregateEntry[] = [ + entry(1, 5, ["recent"]), + entry(2, 5, ["recent"]), + entry(200, 5, ["old"]), + entry(201, 5, ["old"]), + ]; + const tags = computeTagSummary(entries, NOW, 90); + expect(tags.map((t) => t.tag)).toEqual(["recent"]); + }); +}); + +describe("computeInTargetPct", () => { + it("returns the share of the newest 30 daily buckets in the green band", () => { + const daily = [ + { dayOffset: 0, value: 4 }, + { dayOffset: 1, value: 3 }, // below 3.5 → out + { dayOffset: 2, value: 5 }, + { dayOffset: 3, value: 3.5 }, // exactly green min → in + ]; + // 3 of 4 in target → 75 + expect(computeInTargetPct(daily)).toBe(75); + }); + + it("ignores buckets older than 30 days and returns null when empty", () => { + expect(computeInTargetPct([{ dayOffset: 40, value: 5 }])).toBeNull(); + expect(computeInTargetPct([])).toBeNull(); + }); +}); + +describe("computeDistribution", () => { + it("rounds daily means to discrete levels and reports every level 1..5", () => { + const daily = [ + { dayOffset: 0, value: 5 }, + { dayOffset: 1, value: 4.4 }, // → 4 + { dayOffset: 2, value: 3.6 }, // → 4 + { dayOffset: 3, value: 1 }, + ]; + expect(computeDistribution(daily)).toEqual([ + { score: 1, count: 1 }, + { score: 2, count: 0 }, + { score: 3, count: 0 }, + { score: 4, count: 2 }, + { score: 5, count: 1 }, + ]); + }); +}); + +describe("computeWeekdayAverages", () => { + it("groups daily means by Monday-aligned weekday", () => { + // 2026-06-01 is a Monday (UTC). dayOffset 0 = Mon, 1 = Sun, 7 = Mon. + const daily = [ + { dayOffset: 0, value: 4 }, // Mon + { dayOffset: 7, value: 2 }, // Mon + { dayOffset: 1, value: 5 }, // Sun + ]; + const rows = computeWeekdayAverages(daily, NOW); + const mon = rows.find((r) => r.weekday === 0); + const sun = rows.find((r) => r.weekday === 6); + expect(mon).toEqual({ weekday: 0, avgScore: 3, count: 2 }); + expect(sun).toEqual({ weekday: 6, avgScore: 5, count: 1 }); + // every weekday present + expect(rows).toHaveLength(7); + expect(rows.find((r) => r.weekday === 2)).toEqual({ + weekday: 2, + avgScore: null, + count: 0, + }); + }); +}); + +describe("computeHeatmapCells", () => { + it("averages multi-entry days and clips to the window", () => { + const entries: MoodAggregateEntry[] = [ + entry(0, 4), + { ...entry(0, 2), moodLoggedAt: new Date(NOW.getTime() - 0.1 * dayMs) }, + entry(5, 1), + entry(45, 3), // outside a 30-day window + ]; + const cells = computeHeatmapCells(entries, NOW, 30); + const today = cells.find((c) => c.date === dayKey(0)); + expect(today).toEqual({ date: dayKey(0), score: 3, samples: 2 }); + // The 45-day-old entry is clipped. + expect(cells.find((c) => c.date === dayKey(45))).toBeUndefined(); + // Sorted ascending by date. + expect(cells.map((c) => c.date)).toEqual( + [...cells.map((c) => c.date)].sort(), + ); + }); +}); + +describe("computeMoodMetricCorrelation", () => { + it("pairs on dayOffset and returns scatter points + a coefficient", () => { + const moodDaily = [ + { dayOffset: 0, value: 5 }, + { dayOffset: 1, value: 4 }, + { dayOffset: 2, value: 3 }, + { dayOffset: 3, value: 2 }, + { dayOffset: 4, value: 1 }, + ]; + // Perfectly positively correlated metric. + const metricDaily = moodDaily.map((b) => ({ + dayOffset: b.dayOffset, + value: b.value * 10, + })); + const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); + expect(corr.n).toBe(5); + expect(corr.points).toHaveLength(5); + expect(corr.result?.r).toBe(1); + expect(corr.points[0]).toEqual({ x: 5, y: 50 }); + }); + + it("returns a null coefficient below the minimum pair count", () => { + const moodDaily = [ + { dayOffset: 0, value: 5 }, + { dayOffset: 1, value: 4 }, + ]; + const metricDaily = [{ dayOffset: 0, value: 1 }]; + const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); + expect(corr.n).toBe(1); + expect(corr.result).toBeNull(); + }); +}); + +describe("computeMoodAggregates", () => { + it("assembles every dimension and derives the summary headline", () => { + const entries: MoodAggregateEntry[] = []; + for (let day = 0; day < 200; day++) { + entries.push(entry(day, ((day % 5) + 1) as number, ["daily"])); + } + // oldest-first ordering (matches the DB read .reverse()). + entries.reverse(); + + const measurements: CrossMetricMeasurement[] = []; + for (let day = 0; day < 10; day++) { + measurements.push({ + type: "PULSE", + value: 60 + day, + measuredAt: new Date(NOW.getTime() - day * dayMs), + }); + } + + const agg = computeMoodAggregates({ entries, measurements, now: NOW }); + + // 200 days of history → 365-day heatmap window. + expect(agg.heatmap.windowDays).toBe(365); + expect(agg.summary.totalEntries).toBe(200); + expect(agg.summary.totalSpanDays).toBe(199); + expect(agg.distribution).toHaveLength(5); + expect(agg.weekday).toHaveLength(7); + expect(agg.tags.map((t) => t.tag)).toContain("daily"); + expect(agg.correlations.pulse.n).toBeGreaterThanOrEqual(5); + // No sleep / steps rows supplied → empty correlation. + expect(agg.correlations.sleep.n).toBe(0); + expect(agg.correlations.sleep.result).toBeNull(); + }); + + it("handles an empty entry set without throwing", () => { + const agg = computeMoodAggregates({ + entries: [], + measurements: [], + now: NOW, + }); + expect(agg.summary.totalEntries).toBe(0); + expect(agg.summary.mean).toBeNull(); + expect(agg.heatmap.windowDays).toBe(30); + expect(agg.heatmap.cells).toEqual([]); + expect(agg.distribution.every((d) => d.count === 0)).toBe(true); + }); +}); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts new file mode 100644 index 000000000..76de65865 --- /dev/null +++ b/src/lib/insights/mood-aggregates.ts @@ -0,0 +1,503 @@ +/** + * v1.8.5 — shared mood-aggregate computation. + * + * Single source of truth for the analytics the mood surface needs. + * `mood-status.ts` (the LLM snapshot builder) and `/api/mood/insights` + * (the visualization endpoint) both consume these pure functions so the + * numbers the user sees match the numbers the model reasons over — no + * drift between the prose and the charts. + * + * Everything here is a pure function over already-fetched rows. The DB + * read + orchestration lives in `fetchMoodAggregates` at the bottom; + * the per-dimension helpers above it take plain arrays so they unit-test + * without a database. + */ + +import { prisma } from "@/lib/db"; +import { + pearsonCorrelation, + type CorrelationResult, + type PairedPoint, +} from "@/lib/analytics/correlations"; +import { + applyPayloadBudget, + dayOffsetToBerlinDayKey, + type DailyBucket, +} from "@/lib/insights/bucket-series"; +import { round, summarizeSeries } from "@/lib/insights/status-shared"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Minimal daily-bucket shape the aggregates read. `DailyBucket` (from + * `bucket-series`) carries an extra `n` field; the helpers only ever + * touch `dayOffset` + `value`, so accepting the narrow shape keeps them + * trivially testable without synthesising the unused field. + */ +type DailyPoint = Pick; + +/** Raw mood row shape the aggregates operate on. */ +export interface MoodAggregateEntry { + /** TZ-anchored day key (YYYY-MM-DD). */ + date: string; + /** Numeric mood score 1..5. */ + score: number; + /** JSON tag array, or null. */ + tags: unknown; + /** Exact log timestamp — carries time-of-day. */ + moodLoggedAt: Date; +} + +/** Cross-metric row shape (WEIGHT / BLOOD_PRESSURE_SYS / PULSE / …). */ +export interface CrossMetricMeasurement { + type: string; + value: number; + measuredAt: Date; +} + +// --- Target bands (mirror mood-chart VALUE_BANDS + mood-status) --- + +/** + * The mood-status generator grades "in target" as score >= 3.5. The + * mood-chart paints three reference bands (1–2 red, 2–3 orange, 3–5 + * green). We keep both anchored here so the heatmap colour map and the + * in-target headline never drift from the line chart. + */ +export const MOOD_GREEN_MIN = 3.5; +export const MOOD_GREEN_MAX = 5; +export const MOOD_ORANGE_MIN = 2; +export const MOOD_ORANGE_MAX = 3.5; + +// --- Pair two daily-bucket series on dayOffset (lifted from mood-status) --- + +/** + * Pair two daily-bucket series on `dayOffset`. The synthesised `date` + * field is anchored at the UTC midnight of the Berlin calendar day — + * `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries + * don't slip the day-key by one. Each pair also carries `dayKey` + * directly so callers can label points without re-formatting. + */ +export function pairDailyBuckets( + seriesA: DailyPoint[], + seriesB: DailyPoint[], + now: Date, +): Array { + const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); + + return seriesA + .map((entry) => { + const b = mapB.get(entry.dayOffset); + if (b == null) return null; + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const [y, m, d] = dayKey.split("-").map(Number); + return { + a: entry.value, + b, + date: new Date(Date.UTC(y, m - 1, d)), + dayKey, + }; + }) + .filter( + (entry): entry is PairedPoint & { dayKey: string } => entry !== null, + ); +} + +// --- Tag frequency + per-tag avg score (lifted from mood-status) --- + +export interface TagSummaryRow { + tag: string; + count: number; + avgScore: number; +} + +/** + * Tag frequency + per-tag average mood over a recency window. + * + * Keeps the v1.4.5 ~90-day window so the model still gets a recency- + * weighted view of tag patterns; the caller slices by date, not by + * record count. Only tags with count >= 2 survive (a single mention is + * noise), top 10 by frequency. + */ +export function computeTagSummary( + entries: MoodAggregateEntry[], + now: Date, + windowDays = 90, +): TagSummaryRow[] { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const tagCounts = new Map(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + if (entry.tags && Array.isArray(entry.tags)) { + for (const tag of entry.tags as string[]) { + const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 }; + current.count += 1; + current.scoreSum += entry.score; + tagCounts.set(tag, current); + } + } + } + return Array.from(tagCounts.entries()) + .filter(([, stats]) => stats.count >= 2) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 10) + .map(([tag, stats]) => ({ + tag, + count: stats.count, + avgScore: round(stats.scoreSum / stats.count, 2), + })); +} + +// --- In-target % over the last 30 daily points (lifted from mood-status) --- + +/** + * Share (0..100) of the newest 30 daily mood buckets that land in the + * green band (>= 3.5). Returns null when there is no recent data. + */ +export function computeInTargetPct(daily: DailyPoint[]): number | null { + const recent = daily.filter((bucket) => bucket.dayOffset < 30); + if (recent.length === 0) return null; + const inTarget = recent.filter( + (entry) => entry.value >= MOOD_GREEN_MIN && entry.value <= MOOD_GREEN_MAX, + ).length; + return round((inTarget / recent.length) * 100, 1); +} + +// --- Mood distribution (share per discrete level) --- + +export interface DistributionRow { + /** Rounded mood level 1..5. */ + score: number; + count: number; +} + +/** + * Distribution of daily-mean mood across the five discrete levels. + * + * Open product question #2 (design §7) resolves to the daily-mean + * convention used everywhere else on the surface: each day contributes + * one observation, its mean rounded to the nearest level. This keeps + * multi-entry days from over-weighting the histogram and matches the + * heatmap (one cell per day). + */ +export function computeDistribution(daily: DailyPoint[]): DistributionRow[] { + const counts = new Map(); + for (const bucket of daily) { + const level = Math.min(5, Math.max(1, Math.round(bucket.value))); + counts.set(level, (counts.get(level) ?? 0) + 1); + } + const rows: DistributionRow[] = []; + for (let score = 1; score <= 5; score++) { + rows.push({ score, count: counts.get(score) ?? 0 }); + } + return rows; +} + +// --- Average mood by weekday --- + +export interface WeekdayRow { + /** 0 = Monday … 6 = Sunday. */ + weekday: number; + avgScore: number | null; + count: number; +} + +/** + * Average daily-mean mood grouped by weekday (Monday = 0). The weekday + * is read off the day-key in UTC so it matches the heatmap's UTC-anchored + * Monday alignment (`compliance-heatmap` reads `getUTCDay`). + */ +export function computeWeekdayAverages( + daily: DailyPoint[], + now: Date, +): WeekdayRow[] { + const sums = new Map(); + for (const bucket of daily) { + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset); + const d = new Date(dayKey + "T00:00:00Z"); + const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0 + const cur = sums.get(weekday) ?? { sum: 0, count: 0 }; + cur.sum += bucket.value; + cur.count += 1; + sums.set(weekday, cur); + } + const rows: WeekdayRow[] = []; + for (let weekday = 0; weekday < 7; weekday++) { + const agg = sums.get(weekday); + rows.push({ + weekday, + avgScore: agg ? round(agg.sum / agg.count, 2) : null, + count: agg?.count ?? 0, + }); + } + return rows; +} + +// --- Heatmap (one cell per day, score band per cell) --- + +export type HeatmapWindowDays = 30 | 90 | 365; + +/** + * Adaptive heatmap window so the grid is never mostly-empty. + * + * - < 90 days of history → 30-day window (a fresh logger sees a dense + * month, not a sparse year). + * - 90..180 days → 90-day window. + * - > 180 days → 365-day "year in pixels" (Daylio parity). + * + * `historyDays` is the span between the oldest and newest entry. + */ +export function selectHeatmapWindow(historyDays: number): HeatmapWindowDays { + if (historyDays > 180) return 365; + if (historyDays >= 90) return 90; + return 30; +} + +export interface HeatmapCell { + /** YYYY-MM-DD day key. */ + date: string; + /** Daily-mean mood score for the day. */ + score: number; + /** Number of entries that fed the mean. */ + samples: number; +} + +/** + * Build the per-day heatmap cells from raw mood rows. One cell per day + * key carrying the daily mean (multi-entry days averaged), restricted to + * the adaptive window. + */ +export function computeHeatmapCells( + entries: MoodAggregateEntry[], + now: Date, + windowDays: HeatmapWindowDays, +): HeatmapCell[] { + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const byDay = new Map(); + for (const entry of entries) { + if (entry.moodLoggedAt.getTime() < cutoff) continue; + const cur = byDay.get(entry.date) ?? { sum: 0, count: 0 }; + cur.sum += entry.score; + cur.count += 1; + byDay.set(entry.date, cur); + } + return Array.from(byDay.entries()) + .map(([date, stats]) => ({ + date, + score: round(stats.sum / stats.count, 2), + samples: stats.count, + })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +// --- Cross-metric correlation (lifted from mood-status) --- + +export interface CorrelationScatterPoint { + x: number; + y: number; + /** Index signature so the point satisfies the chart's + * `Record` data constraint. */ + [key: string]: number; +} + +export interface MoodMetricCorrelation { + result: CorrelationResult | null; + /** Mood (x) vs metric (y) scatter points, paired by day. */ + points: CorrelationScatterPoint[]; + n: number; +} + +/** + * Pearson correlation between the mood daily series and one metric daily + * series, paired on `dayOffset`. Returns the scatter points so the + * caller can paint a `` from the same pairing + * the coefficient was computed over. + */ +export function computeMoodMetricCorrelation( + moodDaily: DailyPoint[], + metricDaily: DailyPoint[], + now: Date, +): MoodMetricCorrelation { + const pairs = pairDailyBuckets(moodDaily, metricDaily, now); + const result = pearsonCorrelation(pairs); + const points = pairs.map((p) => ({ x: p.a, y: p.b })); + return { result, points, n: pairs.length }; +} + +// --- Orchestrated aggregate shape --- + +export interface MoodAggregates { + summary: { + points: number; + mean: number | null; + min: number | null; + max: number | null; + latest: number | null; + /** latest daily mean minus the previous daily mean. */ + delta: number | null; + inTargetPct: number | null; + totalEntries: number; + totalSpanDays: number; + newestEntryDaysAgo: number | null; + }; + heatmap: { + windowDays: HeatmapWindowDays; + cells: HeatmapCell[]; + }; + distribution: DistributionRow[]; + weekday: WeekdayRow[]; + tags: TagSummaryRow[]; + correlations: { + sleep: MoodMetricCorrelation; + steps: MoodMetricCorrelation; + pulse: MoodMetricCorrelation; + weight: MoodMetricCorrelation; + bloodPressureSystolic: MoodMetricCorrelation; + }; +} + +/** + * Compute every mood aggregate from already-fetched rows. Pure — no DB + * access — so callers (the endpoint and the unit tests) control the + * fetch + the clock. + */ +export function computeMoodAggregates(args: { + entries: MoodAggregateEntry[]; + measurements: CrossMetricMeasurement[]; + now: Date; +}): MoodAggregates { + const { entries, measurements, now } = args; + + const moodPoints = entries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })); + const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodDaily = moodSeries.daily; + + const moodSummary = summarizeSeries( + moodDaily.map((bucket) => ({ value: bucket.value })), + ); + + // daily[0] = newest bucket (lowest dayOffset). + const latest = moodDaily[0] ?? null; + const previous = moodDaily[1] ?? null; + + const oldestEntry = entries.length > 0 ? entries[0].moodLoggedAt : null; + const newestEntry = + entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null; + const totalSpanDays = + oldestEntry && newestEntry + ? Math.round((newestEntry.getTime() - oldestEntry.getTime()) / MS_PER_DAY) + : 0; + const newestEntryDaysAgo = newestEntry + ? Math.round((now.getTime() - newestEntry.getTime()) / MS_PER_DAY) + : null; + + const metricDaily = (type: string) => + applyPayloadBudget( + measurements + .filter((m) => m.type === type) + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + { now }, + ).daily; + + const windowDays = selectHeatmapWindow(totalSpanDays); + + return { + summary: { + points: moodSummary?.points ?? 0, + mean: moodSummary?.mean ?? null, + min: moodSummary?.min ?? null, + max: moodSummary?.max ?? null, + latest: latest?.value ?? null, + delta: + latest && previous ? round(latest.value - previous.value, 2) : null, + inTargetPct: computeInTargetPct(moodDaily), + totalEntries: entries.length, + totalSpanDays, + newestEntryDaysAgo, + }, + heatmap: { + windowDays, + cells: computeHeatmapCells(entries, now, windowDays), + }, + distribution: computeDistribution(moodDaily), + weekday: computeWeekdayAverages(moodDaily, now), + tags: computeTagSummary(entries, now), + correlations: { + sleep: computeMoodMetricCorrelation( + moodDaily, + metricDaily("SLEEP_DURATION"), + now, + ), + steps: computeMoodMetricCorrelation( + moodDaily, + metricDaily("ACTIVITY_STEPS"), + now, + ), + pulse: computeMoodMetricCorrelation( + moodDaily, + metricDaily("PULSE"), + now, + ), + weight: computeMoodMetricCorrelation( + moodDaily, + metricDaily("WEIGHT"), + now, + ), + bloodPressureSystolic: computeMoodMetricCorrelation( + moodDaily, + metricDaily("BLOOD_PRESSURE_SYS"), + now, + ), + }, + }; +} + +/** + * DB read + orchestration for the mood-insights endpoint. + * + * Bounded reads mirror the mood-status path: 365 days of mood rows (the + * heatmap window never exceeds a year) and 365 days × 5 cross-metric + * channels. Tombstoned rows are excluded so the analytics match the + * line chart. + */ +export async function fetchMoodAggregates( + userId: string, + now: Date = new Date(), +): Promise { + const windowCutoff = new Date(now.getTime() - 365 * MS_PER_DAY); + + const entries = await prisma.moodEntry + .findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: windowCutoff } }, + orderBy: { moodLoggedAt: "desc" }, + take: 2000, + select: { date: true, score: true, tags: true, moodLoggedAt: true }, + }) + .then((rows) => rows.reverse()); + + const measurements = await prisma.measurement + .findMany({ + where: { + userId, + deletedAt: null, + measuredAt: { gte: windowCutoff }, + type: { + in: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + ], + }, + }, + orderBy: { measuredAt: "desc" }, + take: 5000, + select: { type: true, value: true, measuredAt: true }, + }) + .then((rows) => rows.reverse()); + + return computeMoodAggregates({ entries, measurements, now }); +} diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 5c762c4aa..5852677fc 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -5,15 +5,17 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { pearsonCorrelation } from "@/lib/analytics/correlations"; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { - pearsonCorrelation, - type PairedPoint, -} from "@/lib/analytics/correlations"; -import { - applyPayloadBudget, - dayOffsetToBerlinDayKey, - type DailyBucket, -} from "@/lib/insights/bucket-series"; + MOOD_GREEN_MAX, + MOOD_GREEN_MIN, + MOOD_ORANGE_MAX, + MOOD_ORANGE_MIN, + computeInTargetPct, + computeTagSummary, + pairDailyBuckets, +} from "@/lib/insights/mood-aggregates"; import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, @@ -36,38 +38,6 @@ import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; -/** - * Pair two daily-bucket series on `dayOffset`. The synthesised `date` - * field is anchored at the UTC midnight of the Berlin calendar day — - * `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries - * don't slip the day-key by one. Each pair also carries `dayKey` - * directly so callers can label points without re-formatting. - */ -function pairDailyBuckets( - seriesA: DailyBucket[], - seriesB: DailyBucket[], - now: Date, -): Array { - const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); - - return seriesA - .map((entry) => { - const b = mapB.get(entry.dayOffset); - if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); - const [y, m, d] = dayKey.split("-").map(Number); - return { - a: entry.value, - b, - date: new Date(Date.UTC(y, m - 1, d)), - dayKey, - }; - }) - .filter( - (entry): entry is PairedPoint & { dayKey: string } => entry !== null, - ); -} - export async function generateMoodStatusForUser( userId: string, options?: { @@ -161,26 +131,13 @@ export async function generateMoodStatusForUser( moodSeries.daily.map((bucket) => ({ value: bucket.value })), ); - const greenMin = 3.5; - const greenMax = 5; - const orangeMin = 2; - const orangeMax = 3.5; + const greenMin = MOOD_GREEN_MIN; + const greenMax = MOOD_GREEN_MAX; + const orangeMin = MOOD_ORANGE_MIN; + const orangeMax = MOOD_ORANGE_MAX; // "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29). - const recentMoodDaily = moodSeries.daily.filter( - (bucket) => bucket.dayOffset < 30, - ); - const inTargetPctLast30DailyPoints = - recentMoodDaily.length === 0 - ? null - : round( - (recentMoodDaily.filter( - (entry) => entry.value >= greenMin && entry.value <= greenMax, - ).length / - recentMoodDaily.length) * - 100, - 1, - ); + const inTargetPctLast30DailyPoints = computeInTargetPct(moodSeries.daily); // daily[0] = newest bucket (lowest dayOffset). const latestMood = moodSeries.daily[0] ?? null; @@ -262,32 +219,9 @@ export async function generateMoodStatusForUser( // Extract tag frequencies from recent entries — keep the v1.4.5 // ~90-day window so the model still gets a recency-weighted view of - // tag patterns. The mood DB query already pulls 3 years (no where - // filter); we slice by date here, not by record count. - const tagWindowCutoff = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); - const recentEntries = entries.filter( - (entry) => entry.moodLoggedAt.getTime() >= tagWindowCutoff.getTime(), - ); - const tagCounts = new Map(); - for (const entry of recentEntries) { - if (entry.tags && Array.isArray(entry.tags)) { - for (const tag of entry.tags as string[]) { - const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 }; - current.count += 1; - current.scoreSum += entry.score; - tagCounts.set(tag, current); - } - } - } - const tagSummary = Array.from(tagCounts.entries()) - .filter(([, stats]) => stats.count >= 2) - .sort((a, b) => b[1].count - a[1].count) - .slice(0, 10) - .map(([tag, stats]) => ({ - tag, - count: stats.count, - avgScore: round(stats.scoreSum / stats.count, 2), - })); + // tag patterns. Shared with the `/api/mood/insights` tag breakdown + // via `computeTagSummary` so the prose and the chart never drift. + const tagSummary = computeTagSummary(entries, now); const snapshot = { locale, diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 440e2a51d..88e9f5986 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -53,6 +53,13 @@ export const queryKeys = { analytics: (slice?: "summaries") => (slice ? (["analytics", slice] as const) : (["analytics"] as const)), moodAnalytics: () => ["mood-analytics"] as const, + /** + * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, + * weekday, tag breakdown, cross-metric correlations) for the Mood + * Insights page. Read-only; invalidated on a mood write through the + * `moodDependentKeys` fan-out below. + */ + moodInsights: () => ["mood-insights"] as const, /** * v1.7.0 W6 — unified dashboard first-paint snapshot. One client cell @@ -385,6 +392,7 @@ export const measurementDependentKeys = [ export const moodDependentKeys = [ queryKeys.moodEntries(), queryKeys.moodAnalytics(), + queryKeys.moodInsights(), queryKeys.insightsRoot(), queryKeys.insightsTargets(), queryKeys.gamificationAchievements(),