feat(insights): enrich the mood page with calendar, distribution, weekday, and correlation views

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 19:54:26 +02:00
1 parent 1fcc5ba96b
commit 84e592b17e
20 files changed
+2003 -96

No files matched your search

+43 -2
View File
@@ -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",
+43 -2
View File
@@ -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",
+43 -2
View File
@@ -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",
+43 -2
View File
@@ -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",
+43 -2
View File
@@ -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",
+43 -2
View File
@@ -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",
+43
View File
@@ -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<MoodAggregates>,
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);
});
+3
View File
@@ -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() {
<MetricTargetSummary slug="mood" />
<MoodInsightsSections />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
+316
View File
@@ -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<string, MoodHeatmapCell>;
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<HTMLDivElement | null>(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 (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full sm:w-full overflow-x-auto sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{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 (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
onPointerEnter={(e) => {
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,
});
}}
/>
);
})}
</svg>
</div>
{tooltip && (
<div
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
>
{tooltip.text}
</div>
)}
<div
className="text-muted-foreground mt-2 flex flex-wrap items-center gap-3 text-xs"
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ 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 }) => (
<span key={label} className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color }}
/>
{label}
</span>
))}
</div>
</div>
);
}
@@ -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 `<ScatterCorrelationChart>` (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: () => (
<div className="bg-muted/40 aspect-square min-h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none sm:aspect-[3/2] sm:h-auto" />
),
},
);
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<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
};
const Y_LABEL_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepAxis",
steps: "insights.mood.correlation.stepsAxis",
pulse: "insights.mood.correlation.pulseAxis",
};
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
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 (
<Card data-slot="mood-correlation-card" data-kind={kind}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold">
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{hasResult && data.result ? (
<>
<ScatterCorrelationChart
data={data.points}
fill={FILL_BY_KIND[kind]}
xAxis={{
dataKey: "x",
name: t("insights.moodSectionTitle"),
domain: [1, 5],
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
/>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
</p>
</>
) : (
<EmptyState
variant="plain"
size="compact"
icon={<Activity className="size-5" />}
title={t("insights.mood.correlation.emptyTitle")}
description={t("insights.mood.correlation.emptyDescription")}
/>
)}
</CardContent>
</Card>
);
}
export function MoodCorrelationCards({
sleep,
steps,
pulse,
}: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
}) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MoodCorrelationCard kind="sleep" data={sleep} />
<MoodCorrelationCard kind="steps" data={steps} />
<MoodCorrelationCard kind="pulse" data={pulse} />
</div>
);
}
@@ -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<number, string> = {
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 (
<div className="aspect-[3/2] min-h-[180px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={28}
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(value) =>
[
`${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`,
t("insights.mood.distributionTitle"),
] as [string, string]
}
/>
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
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 <Skeleton className="h-48 w-full rounded-lg" />;
}
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 (
<div className="space-y-4">
<SectionCard title={t("insights.mood.heatmapTitle")}>
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
stretch
/>
</SectionCard>
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title={t("insights.mood.distributionTitle")}>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard title={t("insights.mood.weekdayTitle")}>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
</h3>
<p className="text-muted-foreground text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
/>
</div>
</div>
);
}
@@ -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 (
<ul className="space-y-2" data-slot="mood-tag-breakdown">
{tags.map((row) => (
<li key={row.tag} className="flex items-center gap-2 text-sm">
<span className="text-foreground w-28 shrink-0 truncate" title={row.tag}>
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
}}
/>
</div>
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
{row.count}
</span>
<span
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
</span>
</li>
))}
</ul>
);
}
@@ -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 (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine y={3.5} stroke="var(--dracula-green)" strokeDasharray="3 3" />
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(_value, _name, item) => {
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"),
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.weekday}
fill={
row.avgScore == null
? "var(--secondary)"
: colorForScore(row.avgScore)
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{best && worst && (
<p className="text-muted-foreground text-xs">
{t("insights.mood.weekdayBestWorst", {
best: t(WEEKDAY_LABEL_KEYS[best.weekday]),
worst: t(WEEKDAY_LABEL_KEYS[worst.weekday]),
})}
</p>
)}
</div>
);
}
+3
View File
@@ -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);
}
/**
+12
View File
@@ -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<unknown>({
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
@@ -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);
});
});
+503
View File
@@ -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<DailyBucket, "dayOffset" | "value">;
/** 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<PairedPoint & { dayKey: string }> {
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<string, { count: number; scoreSum: number }>();
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<number, number>();
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<number, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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<string, number>` 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 `<ScatterCorrelationChart>` 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<MoodAggregates> {
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 });
}
+18 -84
View File
@@ -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<PairedPoint & { dayKey: string }> {
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<string, { count: number; scoreSum: number }>();
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,
+8
View File
@@ -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(),