diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md b/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md new file mode 100644 index 000000000..482f1dfd9 --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d2ddc4a..a54b09abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/messages/de.json b/messages/de.json index ad0c3b1b5..87074c454 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 34d45d3bc..2880d77a3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c4b0034d4..bf2fed0c9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 80e56762d..9803c8c9c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 5d43ed82d..bb94e0f5d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 22322c137..bd9eb85cb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index d9c7fd2b5..ac2dec883 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -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" } }); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 073d53f60..5f34a0f52 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -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 (
+ {t("insights.mood.crosstab.description")} +
++ {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, + }, + )} +
++ {t("insights.mood.crosstab.disclaimer")} +
+{t("settings.moodLogDescription")}
++ {t("settings.moodLogDeprecated")} +
{/* v1.4.19 A5 — visual divider matches Withings for consistency (the maintainer explicitly called the asymmetry out). */} diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 234fb9c30..31b5bd56a 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index e0be00212..0ff61743e 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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