mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
1fcc5ba96b
commit
84e592b17e
20 files changed
+2003
-96
No files matched your search
+43
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+12
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user