mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): add tag-by-metric crosstab and deprecate the moodLog webhook
Extend the mood-relations surface from mood to a health metric. For each structured mood tag present on enough days, compare a metric's value on tag-present vs tag-absent days: active energy on workout days (same day), sleep length on nights tagged sleep (same day), and next-day recovery after a tag like alcohol or food (D to D+1 lag). The comparison reuses the existing statistics engine wholesale — the Welch two-sample t-test, the per-group day floors, the confidence band, and the Benjamini-Hochberg FDR control already used by the influence and discovery boards — so no new statistics code lands. Only pairs that clear p < 0.05 and the FDR control surface, ranked by adjusted q then absolute delta and capped at eight rows. The result rides on GET /api/mood/insights as a new additive `tagMetricCrosstab` field and renders as a confidence-gated, observational card on the mood insights page, framed as an association in your own data rather than a cause. The endpoint is server-rendered and not in the OpenAPI contract, so no contract change is required. Deprecate the standalone moodLog integration. Mood is now tracked fully inside HealthLog through entries, structured tags, and rated factors, so the external webhook and reverse-sync bridge no longer add anything. The webhook route, the push and pull helpers, and the validation schemas carry an @deprecated note pointing at the native path; the surface stays functional this release and is slated for removal in a future major. The settings card shows a deprecation hint, localised across all six locales.
This commit is contained in:
1 parent
65b00e3a73
commit
cfb97593a3
19 files changed
+971
-1
No files matched your search
@@ -0,0 +1,78 @@
|
||||
# v1.12.0 — server → iOS: tag × health-metric crosstab on `GET /api/mood/insights`
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**From:** server
|
||||
**Status:** landing in v1.12.0
|
||||
|
||||
## What is changing
|
||||
|
||||
`GET /api/mood/insights` gains one additive top-level field: `tagMetricCrosstab`.
|
||||
It extends the existing mood-relations surface (Daylio's "Activities & Mood") from
|
||||
mood to a health METRIC — for each structured mood tag, the metric's mean on
|
||||
tag-present vs tag-absent days, the delta, and a confidence band. It reuses the
|
||||
same Welch t-test + per-group day floors + Benjamini-Hochberg FDR the
|
||||
`tagInfluence` / `betterDays` fields already use; only FDR-surviving rows surface.
|
||||
|
||||
This endpoint is the server-rendered Insights aggregate (force-dynamic, not in
|
||||
`openapi.yaml`). It is **additive** — every existing field is unchanged, so a
|
||||
client that ignores the new field keeps working. No new `MeasurementType` or
|
||||
`MeasurementSource` values.
|
||||
|
||||
## Response field shape
|
||||
|
||||
`tagMetricCrosstab` is an array (possibly empty) under `data`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
// ... existing fields (summary, heatmap, tagInfluence, betterDays, ...) ...
|
||||
"tagMetricCrosstab": [
|
||||
{
|
||||
"tag": "workout", // stable structured-tag key
|
||||
"labelKey": "mood.tag.workout", // i18n label key (structured tags only)
|
||||
"categoryKey": "activities", // parent category key
|
||||
"icon": "Dumbbell", // Lucide icon name, or null
|
||||
"metricKey": "activeEnergy", // "activeEnergy" | "sleepDuration" | "nextDayRecovery"
|
||||
"display": "kcal", // "hours" | "kcal" | "score" — unit hint for the formatter
|
||||
"mode": "sameDay", // "sameDay" | "nextDay" (D→D+1 lag)
|
||||
"withDays": 18, // tag-present days with a paired metric value
|
||||
"withoutDays": 41, // tag-absent days with a paired metric value
|
||||
"withAvg": 612.4, // mean metric on tag-present days, in `display` unit
|
||||
"withoutAvg": 388.1, // mean metric on tag-absent days, in `display` unit
|
||||
"delta": 224.3, // withAvg − withoutAvg, in `display` unit
|
||||
"pValue": 0.004, // Welch two-sided p-value
|
||||
"qValue": 0.021, // Benjamini-Hochberg adjusted q across the tested family
|
||||
"confidence": "high" // "low" | "medium" | "high"
|
||||
}
|
||||
]
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Field notes
|
||||
|
||||
- **Pairs supported this wave** (the `metricKey` → metric type + lag):
|
||||
- `activeEnergy` → `ACTIVE_ENERGY_BURNED`, same-day. `display: "kcal"`.
|
||||
- `sleepDuration` → `SLEEP_DURATION`, same-day. `display: "hours"` — the server
|
||||
has already converted the stored minutes to hours; `withAvg`/`withoutAvg`/`delta`
|
||||
are in hours.
|
||||
- `nextDayRecovery` → `RECOVERY_SCORE`, next-day (the metric on day D+1 after a
|
||||
tag on day D). `display: "score"` (0..100 points).
|
||||
- **`display` is the unit hint.** Render `hours` as e.g. `7.4 h`, `kcal` as a whole
|
||||
number `612 kcal`, `score` as `+8 pts`. `delta` carries the sign.
|
||||
- **`mode`** lets the client caption next-day rows distinctly ("the day after").
|
||||
- **Structured tags only.** Flat free-text tags are deliberately excluded — the
|
||||
crosstab needs a stable `labelKey`. `labelKey` is always present here (never null,
|
||||
unlike `tagInfluence` where flat-tag rows carry `labelKey: null`).
|
||||
- **Gating.** A row only appears when both sides cleared 5 present + 5 absent days
|
||||
AND it survived `p < 0.05` and the BH-FDR control (`q ≤ 0.10`). Capped at 8 rows.
|
||||
- **Framing is mandatory.** Render the standing observational caption ("associations
|
||||
in your own data, not proof of cause"). Do not phrase any row as causal.
|
||||
|
||||
## What iOS needs to do
|
||||
|
||||
Nothing is required — the field is additive and the web client renders it. If the
|
||||
iOS app wants to surface the crosstab natively, decode `tagMetricCrosstab` with the
|
||||
shape above; treat an absent or empty array as "no card". Pin the `metricKey`,
|
||||
`display`, `mode`, and `confidence` string sets exactly.
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **How your tags line up with your health metrics.** The mood Insights now compares a health metric on the days you tagged an activity against the days you did not — active energy on workout days, sleep length on the nights you tagged sleep, next-day recovery after a tag like alcohol or food. Each row shows the difference, its confidence, and is framed as an association in your own data, not a cause; only relationships that clear the same statistical filter the rest of the relations surface uses are shown.
|
||||
|
||||
### Changed
|
||||
|
||||
- The standalone moodLog integration is deprecated. Mood is now tracked fully inside HealthLog with entries, structured tags, and rated factors, so the external bridge no longer adds anything. It keeps working for existing setups but is slated for removal in a future major release.
|
||||
|
||||
## [1.11.5] — 2026-06-04 — mood relations, sleep depth, and a clear-the-decks polish pass
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen.",
|
||||
"disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Tags und deine Gesundheitswerte",
|
||||
"description": "Wie sich ein Gesundheitswert an Tagen mit einem Tag von Tagen ohne diesen Tag unterscheidet.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Aktive Energie",
|
||||
"metricSleepDuration": "Schlaf",
|
||||
"metricNextDayRecovery": "Erholung am Folgetag",
|
||||
"unitHours": "Std.",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "Pkt.",
|
||||
"detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.",
|
||||
"detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst.",
|
||||
"disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für Ursachen. Muster, die einen Blick wert sind."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Was auffällt",
|
||||
"description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Fehler beim Speichern",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Stimmungseinträge übernehmen.",
|
||||
"moodLogDeprecated": "Veraltet: Stimmung wird jetzt nativ in HealthLog mit Tags und Faktoren erfasst. Diese Integration funktioniert weiterhin, wird aber in einer künftigen Version entfernt.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "API-Key",
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together.",
|
||||
"disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Tags and your health metrics",
|
||||
"description": "How a health metric differs on the days you tagged each activity, compared with the days you did not.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Active energy",
|
||||
"metricSleepDuration": "Sleep",
|
||||
"metricNextDayRecovery": "Next-day recovery",
|
||||
"unitHours": "h",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "pts",
|
||||
"detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.",
|
||||
"detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise.",
|
||||
"disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "What stands out",
|
||||
"description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Error saving",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Import mood entries.",
|
||||
"moodLogDeprecated": "Deprecated: mood is now tracked natively in HealthLog with tags and factors. This integration still works but will be removed in a future release.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "API Key",
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas.",
|
||||
"disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Etiquetas y tus métricas de salud",
|
||||
"description": "Cómo difiere una métrica de salud en los días que etiquetaste cada actividad, frente a los días que no.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Energía activa",
|
||||
"metricSleepDuration": "Sueño",
|
||||
"metricNextDayRecovery": "Recuperación al día siguiente",
|
||||
"unitHours": "h",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "pts",
|
||||
"detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.",
|
||||
"detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto.",
|
||||
"disclaimer": "Son asociaciones en tus propios datos, no prueba de causa. Patrones que vale la pena observar."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Lo que destaca",
|
||||
"description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Error al guardar",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Import mood data from moodLog",
|
||||
"moodLogDeprecated": "Obsoleto: el estado de ánimo ahora se registra de forma nativa en HealthLog con etiquetas y factores. Esta integración sigue funcionando, pero se eliminará en una versión futura.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "Clave de API",
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien.",
|
||||
"disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Tags et vos métriques de santé",
|
||||
"description": "Comment une métrique de santé diffère les jours où vous avez ajouté chaque tag, par rapport aux jours sans.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Énergie active",
|
||||
"metricSleepDuration": "Sommeil",
|
||||
"metricNextDayRecovery": "Récupération le lendemain",
|
||||
"unitHours": "h",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "pts",
|
||||
"detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.",
|
||||
"detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon.",
|
||||
"disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Ce qui ressort",
|
||||
"description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Erreur lors de l’enregistrement",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Import mood data from moodLog",
|
||||
"moodLogDeprecated": "Obsolète : l’humeur est désormais suivie nativement dans HealthLog avec tags et facteurs. Cette intégration fonctionne encore mais sera supprimée dans une version future.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "Clé API",
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme.",
|
||||
"disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Tag e le tue metriche di salute",
|
||||
"description": "Come una metrica di salute cambia nei giorni in cui hai aggiunto un tag rispetto ai giorni senza.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Energia attiva",
|
||||
"metricSleepDuration": "Sonno",
|
||||
"metricNextDayRecovery": "Recupero il giorno dopo",
|
||||
"unitHours": "h",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "pt",
|
||||
"detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.",
|
||||
"detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti.",
|
||||
"disclaimer": "Sono associazioni nei tuoi dati, non prova di causa. Schemi che vale la pena osservare."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Cosa risalta",
|
||||
"description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Errore durante il salvataggio",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Import mood data from moodLog",
|
||||
"moodLogDeprecated": "Obsoleto: l’umore ora è registrato nativamente in HealthLog con tag e fattori. Questa integrazione funziona ancora ma verrà rimossa in una versione futura.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "Chiave API",
|
||||
|
||||
@@ -2604,6 +2604,20 @@
|
||||
"description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania.",
|
||||
"disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz."
|
||||
},
|
||||
"crosstab": {
|
||||
"title": "Tagi a Twoje wskaźniki zdrowia",
|
||||
"description": "Jak wskaźnik zdrowia różni się w dniach z danym tagiem w porównaniu z dniami bez niego.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"metricActiveEnergy": "Aktywna energia",
|
||||
"metricSleepDuration": "Sen",
|
||||
"metricNextDayRecovery": "Regeneracja następnego dnia",
|
||||
"unitHours": "godz.",
|
||||
"unitKcal": "kcal",
|
||||
"unitScore": "pkt",
|
||||
"detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.",
|
||||
"detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni.",
|
||||
"disclaimer": "To powiązania w Twoich danych, nie dowód przyczyny. Wzorce warte obserwacji."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Co się wyróżnia",
|
||||
"description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
|
||||
@@ -3639,6 +3653,7 @@
|
||||
"savingError": "Błąd podczas zapisywania",
|
||||
"moodLogTitle": "moodLog",
|
||||
"moodLogDescription": "Import mood data from moodLog",
|
||||
"moodLogDeprecated": "Wycofane: nastrój jest teraz śledzony natywnie w HealthLog z tagami i czynnikami. Ta integracja nadal działa, ale zostanie usunięta w przyszłej wersji.",
|
||||
"moodLogUrl": "moodLog URL",
|
||||
"moodLogUrlPlaceholder": "https://mood.example.com",
|
||||
"moodLogApiKey": "Klucz API",
|
||||
|
||||
@@ -12,6 +12,13 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* @deprecated The standalone moodLog webhook is superseded by native mood
|
||||
* entries plus structured tags and rated factors — mood is now tracked
|
||||
* fully inside HealthLog, so the external moodLog bridge no longer adds
|
||||
* anything. Kept functional for existing integrations; slated for removal
|
||||
* in a future major release. Do not build new callers against it.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "moodlog.webhook" } });
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
MoodBetterDays,
|
||||
type MoodBetterDayFactor,
|
||||
} from "./mood-better-days";
|
||||
import {
|
||||
MoodTagMetricCrosstab,
|
||||
type MoodTagMetricCrosstabRow,
|
||||
} from "./mood-tag-metric-crosstab";
|
||||
import { MoodDiscoveredRelations } from "./mood-discovered-relations";
|
||||
|
||||
/**
|
||||
@@ -84,6 +88,9 @@ interface MoodInsightsResponse {
|
||||
structured: MoodTagInfluenceRow[];
|
||||
};
|
||||
betterDays?: MoodBetterDayFactor[];
|
||||
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
|
||||
// rollout; the live endpoint always populates it.
|
||||
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
@@ -166,6 +173,8 @@ export function MoodInsightsSections() {
|
||||
const hasInfluence = influenceRows.length > 0;
|
||||
const betterDays = data.betterDays ?? [];
|
||||
const hasBetterDays = betterDays.length > 0;
|
||||
const crosstabRows = data.tagMetricCrosstab ?? [];
|
||||
const hasCrosstab = crosstabRows.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -236,6 +245,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasCrosstab && (
|
||||
<SectionCard title={t("insights.mood.crosstab.title")}>
|
||||
<MoodTagMetricCrosstab rows={crosstabRows} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("insights.mood.correlationsTitle")}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
|
||||
/**
|
||||
* v1.12.0 — tag × health-metric crosstab card.
|
||||
*
|
||||
* Daylio's "Activities & Mood" board, extended from mood to a health
|
||||
* METRIC: for each structured mood tag, a metric's mean on tag-present vs
|
||||
* tag-absent days, the delta, and a confidence chip. The math is
|
||||
* pre-computed in `mood-aggregates.ts` (`computeTagMetricCrosstab`) — the
|
||||
* same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
|
||||
* relations surface uses; only FDR-surviving rows reach here. Observational
|
||||
* only: the "association, not cause" caption renders once below the list.
|
||||
*/
|
||||
|
||||
export type MoodCrosstabDisplay = "hours" | "kcal" | "score";
|
||||
export type MoodCrosstabMode = "sameDay" | "nextDay";
|
||||
|
||||
export interface MoodTagMetricCrosstabRow {
|
||||
tag: string;
|
||||
labelKey: string;
|
||||
categoryKey: string;
|
||||
icon: string | null;
|
||||
metricKey: string;
|
||||
display: MoodCrosstabDisplay;
|
||||
mode: MoodCrosstabMode;
|
||||
withDays: number;
|
||||
withoutDays: number;
|
||||
withAvg: number;
|
||||
withoutAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
confidence: MoodInfluenceConfidence;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
activeEnergy: "insights.mood.crosstab.metricActiveEnergy",
|
||||
sleepDuration: "insights.mood.crosstab.metricSleepDuration",
|
||||
nextDayRecovery: "insights.mood.crosstab.metricNextDayRecovery",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
|
||||
hours: "insights.mood.crosstab.unitHours",
|
||||
kcal: "insights.mood.crosstab.unitKcal",
|
||||
score: "insights.mood.crosstab.unitScore",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "insights.mood.influence.confidenceLow",
|
||||
medium: "insights.mood.influence.confidenceMedium",
|
||||
high: "insights.mood.influence.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
|
||||
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
|
||||
};
|
||||
|
||||
/** One decimal for hours/score, whole numbers for kcal. */
|
||||
function fmt(value: number, display: MoodCrosstabDisplay): string {
|
||||
return display === "kcal" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
export function MoodTagMetricCrosstab({
|
||||
rows,
|
||||
}: {
|
||||
rows: MoodTagMetricCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="mood-tag-metric-crosstab">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.crosstab.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const Icon = moodTagIcon(row.icon);
|
||||
const tagLabel = t(row.labelKey);
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
const deltaColor = up
|
||||
? "var(--dracula-green)"
|
||||
: "var(--dracula-red)";
|
||||
return (
|
||||
<li
|
||||
key={`${row.metricKey}:${row.tag}`}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="mood-crosstab-row"
|
||||
data-metric={row.metricKey}
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${tagLabel} · ${metricLabel}`}
|
||||
>
|
||||
{t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-sm font-semibold tabular-nums"
|
||||
style={{ color: deltaColor }}
|
||||
>
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
row.mode === "nextDay"
|
||||
? "insights.mood.crosstab.detailNextDay"
|
||||
: "insights.mood.crosstab.detailSameDay",
|
||||
{
|
||||
withAvg: fmt(row.withAvg, row.display),
|
||||
withoutAvg: fmt(row.withoutAvg, row.display),
|
||||
unit,
|
||||
withDays: row.withDays,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-[11px]">
|
||||
{t("insights.mood.crosstab.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1688,6 +1688,9 @@ function MoodLogCard({
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.moodLogDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 text-[11px] italic">
|
||||
{t("settings.moodLogDeprecated")}
|
||||
</p>
|
||||
|
||||
{/* v1.4.19 A5 — visual divider matches Withings for consistency
|
||||
(the maintainer explicitly called the asymmetry out). */}
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
computeTagSummary,
|
||||
computeTagInfluence,
|
||||
computeBetterDays,
|
||||
computeTagMetricCrosstab,
|
||||
CROSSTAB_MIN_PRESENT_DAYS,
|
||||
CROSSTAB_MIN_ABSENT_DAYS,
|
||||
CROSSTAB_MAX_ROWS,
|
||||
influenceConfidence,
|
||||
computeTimeOfDayAverages,
|
||||
computeWeekdayAverages,
|
||||
@@ -824,3 +828,160 @@ describe("computeBetterDays", () => {
|
||||
expect(board.length).toBe(BETTER_DAYS_MAX_FACTORS);
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1.12.0 — computeTagMetricCrosstab (tag × HK metric) ─────────────
|
||||
|
||||
describe("computeTagMetricCrosstab", () => {
|
||||
const WORKOUT: StructuredTagRef = {
|
||||
key: "worked_out",
|
||||
categoryKey: "health",
|
||||
labelKey: "mood.tag.workedOut",
|
||||
icon: "Dumbbell",
|
||||
};
|
||||
|
||||
function structuredEntry(
|
||||
offset: number,
|
||||
score: number,
|
||||
structuredTags: StructuredTagRef[],
|
||||
): MoodAggregateEntry {
|
||||
return { ...entry(offset, score, null), structuredTags };
|
||||
}
|
||||
|
||||
/** A measurement at midday on the day `offset` days before NOW. */
|
||||
function meas(offset: number, type: string, value: number): CrossMetricMeasurement {
|
||||
return { type, value, measuredAt: new Date(NOW.getTime() - offset * dayMs) };
|
||||
}
|
||||
|
||||
it("compares a metric on tag-present vs tag-absent days (same-day) and surfaces a significant delta", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
// 12 days WITH the workout tag, high active energy (~600 ± a little).
|
||||
for (let i = 0; i < 12; i++) {
|
||||
entries.push(structuredEntry(i, 4, [WORKOUT]));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600 + (i % 2 === 0 ? 20 : -20)));
|
||||
}
|
||||
// 12 days WITHOUT the tag, low active energy (~350).
|
||||
for (let i = 12; i < 24; i++) {
|
||||
entries.push(structuredEntry(i, 3, []));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350 + (i % 2 === 0 ? 20 : -20)));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const row = rows.find((r) => r.tag === "worked_out" && r.metricKey === "activeEnergy");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.display).toBe("kcal");
|
||||
expect(row!.mode).toBe("sameDay");
|
||||
expect(row!.withDays).toBe(12);
|
||||
expect(row!.withoutDays).toBe(12);
|
||||
expect(row!.withAvg).toBeCloseTo(600, 0);
|
||||
expect(row!.withoutAvg).toBeCloseTo(350, 0);
|
||||
expect(row!.delta).toBeCloseTo(250, 0);
|
||||
// strong clean separation on enough days → high confidence + tight q.
|
||||
expect(row!.confidence).toBe("high");
|
||||
expect(row!.qValue).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
|
||||
it("converts SLEEP_DURATION minutes to display hours", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
const SLEEP: StructuredTagRef = {
|
||||
key: "slept_well",
|
||||
categoryKey: "health",
|
||||
labelKey: "mood.tag.sleptWell",
|
||||
icon: "Moon",
|
||||
};
|
||||
// present: 480 min = 8 h; absent: 360 min = 6 h.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
entries.push(structuredEntry(i, 4, [SLEEP]));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 480 + (i % 2 === 0 ? 6 : -6)));
|
||||
}
|
||||
for (let i = 12; i < 24; i++) {
|
||||
entries.push(structuredEntry(i, 3, []));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6)));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const row = rows.find((r) => r.metricKey === "sleepDuration");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.display).toBe("hours");
|
||||
expect(row!.withAvg).toBeCloseTo(8, 1);
|
||||
expect(row!.withoutAvg).toBeCloseTo(6, 1);
|
||||
expect(row!.delta).toBeCloseTo(2, 1);
|
||||
});
|
||||
|
||||
it("pairs a tag against the NEXT day's recovery (D → D+1 lag)", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
const ALCOHOL: StructuredTagRef = {
|
||||
key: "alcohol",
|
||||
categoryKey: "consumption",
|
||||
labelKey: "mood.tag.alcohol",
|
||||
icon: "Wine",
|
||||
};
|
||||
// Tag on even offsets (present), recovery measured the day AFTER (offset-1).
|
||||
// present days: tag at offset i, recovery low (40) at offset i-1.
|
||||
// absent days: no tag at offset i, recovery high (80) at offset i-1.
|
||||
// Build 24 consecutive days; tag the older half so the +1 day always exists.
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
entries.push(structuredEntry(i, 3, [ALCOHOL]));
|
||||
measurements.push(meas(i - 1, "RECOVERY_SCORE", 40 + (i % 2 === 0 ? 3 : -3)));
|
||||
}
|
||||
for (let i = 13; i <= 24; i++) {
|
||||
entries.push(structuredEntry(i, 4, []));
|
||||
measurements.push(meas(i - 1, "RECOVERY_SCORE", 80 + (i % 2 === 0 ? 3 : -3)));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const row = rows.find((r) => r.metricKey === "nextDayRecovery");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.mode).toBe("nextDay");
|
||||
expect(row!.display).toBe("score");
|
||||
// present-day next-day recovery ~40, absent ~80 → negative delta.
|
||||
expect(row!.withAvg).toBeCloseTo(40, 0);
|
||||
expect(row!.withoutAvg).toBeCloseTo(80, 0);
|
||||
expect(row!.delta).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("drops a tag below the per-side day floors", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
// only 3 present days < CROSSTAB_MIN_PRESENT_DAYS
|
||||
for (let i = 0; i < 3; i++) {
|
||||
entries.push(structuredEntry(i, 4, [WORKOUT]));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600));
|
||||
}
|
||||
for (let i = 3; i < 15; i++) {
|
||||
entries.push(structuredEntry(i, 3, []));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
|
||||
}
|
||||
expect(CROSSTAB_MIN_PRESENT_DAYS).toBeGreaterThan(3);
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
expect(rows.find((r) => r.tag === "worked_out")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes flat free-text tags (structured only)", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
entries.push(entry(i, 4, ["flat_workout"]));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 600));
|
||||
}
|
||||
for (let i = 12; i < 24; i++) {
|
||||
entries.push(entry(i, 3, []));
|
||||
measurements.push(meas(i, "ACTIVE_ENERGY_BURNED", 350));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements, now: NOW });
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no metric data is present", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
entries.push(structuredEntry(i, 4, i < 12 ? [WORKOUT] : []));
|
||||
}
|
||||
const rows = computeTagMetricCrosstab({ entries, measurements: [], now: NOW });
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the surfaced rows at CROSSTAB_MAX_ROWS", () => {
|
||||
expect(CROSSTAB_MAX_ROWS).toBeGreaterThan(0);
|
||||
expect(CROSSTAB_MIN_ABSENT_DAYS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
type MoodNarrative,
|
||||
} from "@/lib/insights/mood-narratives";
|
||||
import { welchTTest } from "@/lib/insights/correlations";
|
||||
import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery";
|
||||
import { toBerlinYmd } from "@/lib/tz/resolver";
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -688,6 +691,275 @@ export function computeBetterDays(
|
||||
.slice(0, BETTER_DAYS_MAX_FACTORS);
|
||||
}
|
||||
|
||||
// --- Tag × health-metric crosstab (v1.12.0) ------------------------------
|
||||
|
||||
/**
|
||||
* v1.12.0 — per-tag × health-metric crosstab.
|
||||
*
|
||||
* Daylio's "Activities & Mood" board answers "how does my mood differ on
|
||||
* days I tagged X?". This extends the same with-vs-without comparison to a
|
||||
* health METRIC: for each structured mood tag present on enough days, the
|
||||
* metric's mean on tag-present days vs tag-absent days, the delta, the
|
||||
* Welch two-sided p, and a confidence band — reusing the EXACT statistics
|
||||
* engine the F1 tag-influence board already runs (`welchTTest` +
|
||||
* `influenceConfidence` + the per-group day floors). FDR-corrected across
|
||||
* every tested (tag × metric) pair via the shared `benjaminiHochberg`
|
||||
* step-up so the surface stays honest as the matrix grows.
|
||||
*
|
||||
* Pairing modes:
|
||||
* - "sameDay" — the metric on the SAME day the tag was logged. Used for
|
||||
* activity (a workout/active tag × ACTIVE_ENERGY_BURNED) and sleep (a
|
||||
* sleep tag × SLEEP_DURATION).
|
||||
* - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the
|
||||
* same lag the correlation-discovery engine uses). Used for an
|
||||
* alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE),
|
||||
* where the plausible direction is "tonight's choice → tomorrow's
|
||||
* recovery".
|
||||
*
|
||||
* Observational only — the UI renders the standing "association, not
|
||||
* cause" caption once. The metric value is shown in its display unit
|
||||
* (SLEEP_DURATION minutes → hours; energy kcal; recovery score 0..100).
|
||||
*/
|
||||
/** Default Benjamini-Hochberg target FDR for the crosstab family. */
|
||||
export const CROSSTAB_FDR_Q = 0.1;
|
||||
|
||||
/**
|
||||
* Per-side day floors for the crosstab — reuse the influence floors so a
|
||||
* tag needs the same defensible present/absent support before a metric
|
||||
* delta surfaces.
|
||||
*/
|
||||
export const CROSSTAB_MIN_PRESENT_DAYS = INFLUENCE_MIN_PRESENT_DAYS;
|
||||
export const CROSSTAB_MIN_ABSENT_DAYS = INFLUENCE_MIN_ABSENT_DAYS;
|
||||
|
||||
/** Max rows surfaced across the whole crosstab so the surface stays scannable. */
|
||||
export const CROSSTAB_MAX_ROWS = 8;
|
||||
|
||||
type CrosstabMode = "sameDay" | "nextDay";
|
||||
|
||||
/**
|
||||
* The metric channels the crosstab pairs each tag against. The `display`
|
||||
* key drives unit formatting on the client; `mode` fixes the pairing lag.
|
||||
* Single-sourced so the fetch filter (`CROSSTAB_METRIC_TYPES`) and the
|
||||
* compute can never drift.
|
||||
*/
|
||||
export const CROSSTAB_METRICS: Record<
|
||||
string,
|
||||
{ type: MeasurementType; mode: CrosstabMode; display: "hours" | "kcal" | "score" }
|
||||
> = {
|
||||
// A workout / active tag × same-day active energy.
|
||||
activeEnergy: { type: "ACTIVE_ENERGY_BURNED", mode: "sameDay", display: "kcal" },
|
||||
// A sleep tag × same-night sleep duration (stored minutes → hours on UI).
|
||||
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
|
||||
// An alcohol / food tag × next-day recovery/readiness (D → D+1 lag).
|
||||
nextDayRecovery: { type: "RECOVERY_SCORE", mode: "nextDay", display: "score" },
|
||||
} as const;
|
||||
|
||||
export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS;
|
||||
|
||||
/** Distinct measurement types the crosstab reads — single-sourced. */
|
||||
export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
|
||||
new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)),
|
||||
);
|
||||
|
||||
export interface TagMetricCrosstabRow {
|
||||
/** Stable structured-tag key. */
|
||||
tag: string;
|
||||
/** i18n label key for the tag (structured tags only — never flat). */
|
||||
labelKey: string;
|
||||
/** Parent category key, for grouping/icon. */
|
||||
categoryKey: string;
|
||||
/** Lucide icon name, or null. */
|
||||
icon: string | null;
|
||||
/** Which metric channel this row compares against. */
|
||||
metricKey: CrosstabMetricKey;
|
||||
/** Display unit hint for the client formatter. */
|
||||
display: "hours" | "kcal" | "score";
|
||||
/** Pairing mode used (echoed so the UI can caption "next-day"). */
|
||||
mode: CrosstabMode;
|
||||
/** Days the tag was present that had a paired metric value. */
|
||||
withDays: number;
|
||||
/** Days the tag was absent that had a paired metric value. */
|
||||
withoutDays: number;
|
||||
/** Mean metric on tag-present days (display unit). */
|
||||
withAvg: number;
|
||||
/** Mean metric on tag-absent days (display unit). */
|
||||
withoutAvg: number;
|
||||
/** withAvg − withoutAvg (display unit). */
|
||||
delta: number;
|
||||
/** Welch two-sided p-value for the difference of means. */
|
||||
pValue: number;
|
||||
/** Benjamini-Hochberg adjusted q-value across the tested family. */
|
||||
qValue: number;
|
||||
/** Discrete confidence band (p + min per-group day count). */
|
||||
confidence: InfluenceConfidence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Berlin-day-keyed metric map with the right aggregation. Energy
|
||||
* and step totals are SUMMED per day (HealthKit `stats:` rows are already
|
||||
* one daily-total row, so the sum is the total either way); sleep duration
|
||||
* is SUMMED across per-stage rows to get the night total; everything else
|
||||
* (a once-daily score) is MEANED. Returns minutes/kcal/raw — the display
|
||||
* conversion happens at row-build time.
|
||||
*/
|
||||
function metricDayMap(
|
||||
measurements: CrossMetricMeasurement[],
|
||||
type: string,
|
||||
): Map<string, number> {
|
||||
const SUM_TYPES = new Set([
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
const summed = SUM_TYPES.has(type);
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of measurements) {
|
||||
if (m.type !== type) continue;
|
||||
const { year, month, day } = toBerlinYmd(m.measuredAt);
|
||||
const key = `${year}-${month}-${day}`;
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += m.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
const out = new Map<string, number>();
|
||||
for (const [key, agg] of byDay) {
|
||||
out.set(key, summed ? agg.sum : agg.sum / agg.count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
|
||||
function shiftDayKey(day: string, lagDays: number): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + lagDays);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Convert a raw metric value to the row's display unit. */
|
||||
function toDisplayUnit(value: number, display: "hours" | "kcal" | "score"): number {
|
||||
return display === "hours" ? value / 60 : value;
|
||||
}
|
||||
|
||||
interface CrosstabCandidate {
|
||||
row: Omit<TagMetricCrosstabRow, "qValue">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the tag × metric crosstab. Pure over already-fetched rows.
|
||||
*
|
||||
* For every structured tag and every configured metric channel, collapse
|
||||
* the window to one observation per tz-anchored day (the tag's daily
|
||||
* membership) joined to the metric's per-day value (same-day or D+1). The
|
||||
* tag-present and tag-absent metric samples feed `welchTTest`; rows that
|
||||
* clear both day floors are tested, FDR-corrected as one family, and the
|
||||
* survivors (p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`) surface ranked by q then
|
||||
* |delta|.
|
||||
*
|
||||
* Structured tags only — flat free-text tags are excluded because the
|
||||
* crosstab needs a stable, localized label and a curated-catalog tag is
|
||||
* the right granularity for a metric comparison.
|
||||
*/
|
||||
export function computeTagMetricCrosstab(args: {
|
||||
entries: MoodAggregateEntry[];
|
||||
measurements: CrossMetricMeasurement[];
|
||||
now: Date;
|
||||
windowDays?: number;
|
||||
}): TagMetricCrosstabRow[] {
|
||||
const { entries, measurements, now } = args;
|
||||
const windowDays = args.windowDays ?? 365;
|
||||
|
||||
const dayMap = collapseToTaggedDays(entries, now, windowDays);
|
||||
if (dayMap.size === 0) return [];
|
||||
|
||||
// The set of structured tags seen in the window, with their meta.
|
||||
const structuredMeta = new Map<string, StructuredTagRef>();
|
||||
for (const day of dayMap.values()) {
|
||||
for (const [key, ref] of day.structuredTags) {
|
||||
if (!structuredMeta.has(key)) structuredMeta.set(key, ref);
|
||||
}
|
||||
}
|
||||
if (structuredMeta.size === 0) return [];
|
||||
|
||||
const candidates: CrosstabCandidate[] = [];
|
||||
|
||||
for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array<
|
||||
[CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]]
|
||||
>) {
|
||||
const metricByDay = metricDayMap(measurements, cfg.type);
|
||||
if (metricByDay.size === 0) continue;
|
||||
|
||||
for (const [tagKey, ref] of structuredMeta) {
|
||||
const withVals: number[] = [];
|
||||
const withoutVals: number[] = [];
|
||||
for (const [dayKey, day] of dayMap) {
|
||||
const metricKeyDay =
|
||||
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
|
||||
const metricValue = metricByDay.get(metricKeyDay);
|
||||
if (metricValue == null || !Number.isFinite(metricValue)) continue;
|
||||
const display = toDisplayUnit(metricValue, cfg.display);
|
||||
if (day.structuredTags.has(tagKey)) withVals.push(display);
|
||||
else withoutVals.push(display);
|
||||
}
|
||||
|
||||
if (
|
||||
withVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
|
||||
withoutVals.length < CROSSTAB_MIN_ABSENT_DAYS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const welch = welchTTest(withVals, withoutVals);
|
||||
const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length;
|
||||
const withoutAvg =
|
||||
withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length;
|
||||
const delta = withAvg - withoutAvg;
|
||||
if (delta === 0) continue;
|
||||
|
||||
const pValue = welch.status === "ok" ? welch.pValue : 1;
|
||||
const minGroupDays = Math.min(withVals.length, withoutVals.length);
|
||||
|
||||
candidates.push({
|
||||
row: {
|
||||
tag: tagKey,
|
||||
labelKey: ref.labelKey,
|
||||
categoryKey: ref.categoryKey,
|
||||
icon: ref.icon,
|
||||
metricKey,
|
||||
display: cfg.display,
|
||||
mode: cfg.mode,
|
||||
withDays: withVals.length,
|
||||
withoutDays: withoutVals.length,
|
||||
withAvg: round(withAvg, 2),
|
||||
withoutAvg: round(withoutAvg, 2),
|
||||
delta: round(delta, 2),
|
||||
pValue,
|
||||
confidence: influenceConfidence(pValue, minGroupDays),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// FDR-correct across the whole tested family (every tag × metric pair) so
|
||||
// the multiple-comparison surface stays honest as the matrix grows. Reuses
|
||||
// the same Benjamini-Hochberg step-up the correlation-discovery engine runs.
|
||||
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
|
||||
|
||||
return candidates
|
||||
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
|
||||
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.qValue - b.qValue ||
|
||||
Math.abs(b.delta) - Math.abs(a.delta) ||
|
||||
a.tag.localeCompare(b.tag),
|
||||
)
|
||||
.slice(0, CROSSTAB_MAX_ROWS);
|
||||
}
|
||||
|
||||
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
|
||||
|
||||
/**
|
||||
@@ -1124,6 +1396,13 @@ export interface MoodAggregates {
|
||||
* into one effect-size-ranked, confidence-gated, observational list.
|
||||
*/
|
||||
betterDays: BetterDayFactor[];
|
||||
/**
|
||||
* v1.12.0 — per-tag × health-metric crosstab: for each structured tag,
|
||||
* a health metric's mean on tag-present vs tag-absent days (same-day or
|
||||
* D→D+1 lag), Welch-tested + FDR-corrected. Observational; the card
|
||||
* renders the standing "association, not cause" caption.
|
||||
*/
|
||||
tagMetricCrosstab: TagMetricCrosstabRow[];
|
||||
/**
|
||||
* v1.8.6 — ranked, threshold-gated narrative takeaways. The "read
|
||||
* this first" layer above the charts; the same array feeds the LLM
|
||||
@@ -1197,6 +1476,11 @@ export function computeMoodAggregates(args: {
|
||||
) as MoodAggregates["correlations"];
|
||||
|
||||
const betterDays = computeBetterDays(tagInfluence, correlations);
|
||||
const tagMetricCrosstab = computeTagMetricCrosstab({
|
||||
entries,
|
||||
measurements,
|
||||
now,
|
||||
});
|
||||
|
||||
// Distinct day keys the user logged on — drives the streak takeaway.
|
||||
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
|
||||
@@ -1227,6 +1511,7 @@ export function computeMoodAggregates(args: {
|
||||
structuredTags,
|
||||
tagInfluence,
|
||||
betterDays,
|
||||
tagMetricCrosstab,
|
||||
narratives: computeMoodNarratives({
|
||||
daily: moodDaily,
|
||||
weekday,
|
||||
@@ -1308,7 +1593,16 @@ export async function fetchMoodAggregates(
|
||||
measuredAt: { gte: windowCutoff },
|
||||
// v1.8.5 — single-sourced from CORRELATION_METRICS so the fetched
|
||||
// channels and the computed correlations never drift apart.
|
||||
type: { in: Object.values(CORRELATION_METRICS) },
|
||||
// v1.12.0 — union the crosstab metric channels so the tag × metric
|
||||
// board has its measurements without a second query.
|
||||
type: {
|
||||
in: Array.from(
|
||||
new Set<MeasurementType>([
|
||||
...(Object.values(CORRELATION_METRICS) as MeasurementType[]),
|
||||
...CROSSTAB_METRIC_TYPES,
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 5000,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.12.0 — moodLog is deprecated (superseded by native mood entries +
|
||||
* structured tags + rated factors), but kept FUNCTIONAL this release. This
|
||||
* test pins the `@deprecated` JSDoc marker on the surface so a future edit
|
||||
* that touches these files can't silently drop the deprecation notice
|
||||
* before the planned removal.
|
||||
*/
|
||||
const ROOT = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
const DEPRECATED_FILES = [
|
||||
"src/app/api/integrations/moodlog/webhook/route.ts",
|
||||
"src/lib/moodlog/push.ts",
|
||||
"src/lib/moodlog/sync.ts",
|
||||
"src/lib/validations/moodlog.ts",
|
||||
];
|
||||
|
||||
describe("moodLog deprecation markers", () => {
|
||||
for (const rel of DEPRECATED_FILES) {
|
||||
it(`marks ${rel} with @deprecated`, () => {
|
||||
const src = readFileSync(join(ROOT, rel), "utf8");
|
||||
expect(src).toContain("@deprecated");
|
||||
// The marker must mention the superseding native path so the note is
|
||||
// actionable, not a bare tag.
|
||||
expect(src.toLowerCase()).toContain("superseded");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -53,6 +53,12 @@ export interface MoodEntryForPush {
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated The moodLog integration is superseded by native mood entries
|
||||
* plus structured tags and rated factors. Mood lives fully inside HealthLog,
|
||||
* so the reverse-sync bridge no longer adds value. Kept functional for
|
||||
* existing setups; slated for removal in a future major release.
|
||||
*/
|
||||
export async function pushMoodEntriesToMoodLog(
|
||||
userId: string,
|
||||
entries: ReadonlyArray<MoodEntryForPush>,
|
||||
|
||||
@@ -13,6 +13,12 @@ import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
/**
|
||||
* Sync mood entries from a user's moodLog instance.
|
||||
* Fetches from the health-log export endpoint and upserts into local DB.
|
||||
*
|
||||
* @deprecated The moodLog integration is superseded by native mood entries
|
||||
* plus structured tags and rated factors. Mood is tracked fully inside
|
||||
* HealthLog now, so the external pull bridge no longer adds value. Kept
|
||||
* functional for existing setups; slated for removal in a future major
|
||||
* release.
|
||||
*/
|
||||
export async function syncMoodLogEntries(
|
||||
userId: string,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
/**
|
||||
* @deprecated The standalone moodLog integration is superseded by native
|
||||
* mood entries plus structured tags and rated factors — mood is tracked
|
||||
* fully inside HealthLog now. These schemas remain only to keep the
|
||||
* existing webhook + sync paths functional; the surface is slated for
|
||||
* removal in a future major release. The native mood schemas
|
||||
* (`createMoodEntrySchema` etc.) below are the supported path.
|
||||
*/
|
||||
export const moodLogCredentialsSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* v1.12.0 — tag × health-metric crosstab over the real DB read path.
|
||||
*
|
||||
* Seeds a user with structured-tagged mood entries and ACTIVE_ENERGY_BURNED
|
||||
* measurements, then asserts `fetchMoodAggregates` (the production read +
|
||||
* orchestration used by `GET /api/mood/insights`) returns a populated
|
||||
* `tagMetricCrosstab` row with the expected delta + display unit + lag mode.
|
||||
* Exercises the widened measurement `type: { in: [...] }` filter and the
|
||||
* structured-tag join end-to-end against Postgres.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { fetchMoodAggregates } from "@/lib/insights/mood-aggregates";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TEST_USER_ID = "user-crosstab";
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
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);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "crosstab",
|
||||
email: "crosstab@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("mood-insights tag × metric crosstab (DB read path)", () => {
|
||||
it("surfaces a structured tag × ACTIVE_ENERGY_BURNED same-day association", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// The structured-tag catalog is seeded by migrations (0118/0119) and is
|
||||
// NOT truncated between tests — use an existing catalog tag rather than
|
||||
// minting one (its `key` is globally unique).
|
||||
const tag = await prisma.moodTag.findFirst({
|
||||
include: { category: true },
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
expect(tag).toBeTruthy();
|
||||
|
||||
// 12 tag-present days with high active energy, 12 tag-absent with low.
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const present = i < 12;
|
||||
const ts = new Date(NOW.getTime() - i * dayMs);
|
||||
const moodEntry = await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
date: dayKey(i),
|
||||
mood: "GUT",
|
||||
score: present ? 4 : 3,
|
||||
source: "WEB",
|
||||
moodLoggedAt: ts,
|
||||
...(present
|
||||
? { tagLinks: { create: [{ moodTagId: tag!.id }] } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
expect(moodEntry.id).toBeTruthy();
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: present ? 600 + (i % 2 === 0 ? 20 : -20) : 350 + (i % 2 === 0 ? 20 : -20),
|
||||
unit: "kcal",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: ts,
|
||||
externalId: `stats:HKQuantityTypeIdentifierActiveEnergyBurned:${dayKey(i)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const aggregates = await fetchMoodAggregates(TEST_USER_ID, NOW);
|
||||
const row = aggregates.tagMetricCrosstab.find(
|
||||
(r) => r.tag === tag!.key && r.metricKey === "activeEnergy",
|
||||
);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.display).toBe("kcal");
|
||||
expect(row!.mode).toBe("sameDay");
|
||||
expect(row!.labelKey).toBe(tag!.labelKey);
|
||||
expect(row!.categoryKey).toBe(tag!.category.key);
|
||||
expect(row!.withDays).toBe(12);
|
||||
expect(row!.withoutDays).toBe(12);
|
||||
expect(row!.withAvg).toBeCloseTo(600, 0);
|
||||
expect(row!.withoutAvg).toBeCloseTo(350, 0);
|
||||
expect(row!.delta).toBeGreaterThan(0);
|
||||
expect(row!.confidence).toBe("high");
|
||||
expect(row!.qValue).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user