From 4ddc08f784b943cc163923fdc0662a273c8a1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:24:07 +0200 Subject: [PATCH] feat(dashboard): emit mood and BMI on the summary payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both metrics exist in the record but the summary route emitted neither, so a native client could render every home-screen tile except those two. Mood reads the canonical daily series (`buildMoodDailySeries`) — the same rollup-backed aggregation the web mood analytics and the dashboard snapshot render — and emits the newest day's score with a trailing seven-day sparkline. BMI comes from `computeBmi`, the one implementation the web vitals dashboard already renders through the derived-metric dispatch; the route resolves the figure so no client re-derives weight over height squared. Mood gates on the mood module through a synthetic `MOOD_ENTRY` key, the same route `NUTRIENT_WATER` takes, because mood is not a measurement type; without the join the card would ship past a disabled module. BMI belongs to no module and rides through with the other core vitals. The BMI card is omitted rather than emitted empty when the profile has no height or no weight in the window: the card shape carries no honest "unknown" state, and a null value would read as a logging gap rather than a missing profile field. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- src/__tests__/i18n-english-leak-guard.test.ts | 1 + .../summary/__tests__/module-gate.test.ts | 19 ++++ .../dashboard/summary/__tests__/route.test.ts | 107 ++++++++++++++++++ src/app/api/dashboard/summary/route.ts | 89 ++++++++++++++- src/lib/dashboard/widget-modules.ts | 12 ++ 11 files changed, 262 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index ad9268086..fd027918c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -425,7 +425,9 @@ "steps": "Schritte", "totalBodyWater": "Gesamtkörperwasser", "boneMass": "Knochenmasse", - "oxygenSaturation": "Sauerstoffsättigung" + "oxygenSaturation": "Sauerstoffsättigung", + "mood": "Stimmung", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "Schritte", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/en.json b/messages/en.json index c78f6c78c..1460664f9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -425,7 +425,9 @@ "steps": "Steps", "totalBodyWater": "Total Body Water", "boneMass": "Bone Mass", - "oxygenSaturation": "Oxygen Saturation" + "oxygenSaturation": "Oxygen Saturation", + "mood": "Mood", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "steps", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/es.json b/messages/es.json index 7e77e5ed2..08e6a0a86 100644 --- a/messages/es.json +++ b/messages/es.json @@ -425,7 +425,9 @@ "steps": "Pasos", "totalBodyWater": "Agua corporal total", "boneMass": "Masa ósea", - "oxygenSaturation": "Saturación de oxígeno" + "oxygenSaturation": "Saturación de oxígeno", + "mood": "Estado de ánimo", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "pasos", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/fr.json b/messages/fr.json index a390a4c5f..f8e967d66 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -425,7 +425,9 @@ "steps": "Pas", "totalBodyWater": "Eau corporelle totale", "boneMass": "Masse osseuse", - "oxygenSaturation": "Saturation en oxygène" + "oxygenSaturation": "Saturation en oxygène", + "mood": "Humeur", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "pas", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/it.json b/messages/it.json index 827c43c5f..bee89c07c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -425,7 +425,9 @@ "steps": "Passi", "totalBodyWater": "Acqua corporea totale", "boneMass": "Massa ossea", - "oxygenSaturation": "Saturazione di ossigeno" + "oxygenSaturation": "Saturazione di ossigeno", + "mood": "Umore", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "passi", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/pl.json b/messages/pl.json index a43e94b7f..70027bd7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -425,7 +425,9 @@ "steps": "Kroki", "totalBodyWater": "Całkowita woda w organizmie", "boneMass": "Masa kostna", - "oxygenSaturation": "Saturacja tlenem" + "oxygenSaturation": "Saturacja tlenem", + "mood": "Nastrój", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "kroki", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 0dd958db5..ec7d7962e 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -81,6 +81,7 @@ const LEGIT_IDENTICAL = new Set([ // Clinical abbreviations / SI units — identical across locales. "charts.vo2Max", "cycle.insights.crosstab.unitGlucose", + "dashboard.metric.unit.bmi", "dashboard.metric.unit.glucose", "dashboard.vo2Max", "dashboard.vo2MaxShort", diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts index 577966bf1..dfb98886d 100644 --- a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts +++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts @@ -87,6 +87,8 @@ const ALL_KINDS = [ "totalBodyWater", "boneMass", "oxygenSaturation", + "mood", + "bmi", ] as const; function card(kind: string) { @@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => { "totalBodyWater", "boneMass", "oxygenSaturation", + // BMI is derived from weight + the profile height and belongs to no + // module, so it rides through with the other core vitals. + "bmi", ]) { expect(kinds).toContain(core); } }); + it("drops the mood card when the mood module is off", async () => { + // Mood is not a MeasurementType, so it gates through the synthetic + // `MOOD_ENTRY` key rather than a measurement enum value. Without that + // join the card would be unmapped and silently ship past a disabled + // module. + setDisabledModules(["mood"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("mood"); + expect(kinds).toContain("weight"); + expect(kinds).toContain("bmi"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + it("gates the cached body, so a toggle takes effect on the next request", async () => { // The analytics LRU is not evicted by a module toggle. The filter must // therefore run on the way out, not inside the cached build. diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 3bf6040e0..1005fd8ea 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// The mood card reads the canonical daily series; its own aggregation is +// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an +// empty series by default so the legacy assertions keep their "no mood" +// expectation, and overridden per test below. +vi.mock("@/lib/analytics/mood-series", () => ({ + buildMoodDailySeries: vi.fn(async () => ({ + entries: [], + summary: null, + entryCount: 0, + source: "live" as const, + })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -85,6 +98,7 @@ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => { expect(body.data.compliance.takenToday).toBe(1); }); }); + +/** + * Mood + BMI cards. + * + * Both metrics existed in the record but the summary payload emitted + * neither, so the native home screen could not render those two tiles while + * every other tile worked. Both are resolved server-side from the canonical + * source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the + * client consumes a finished figure instead of re-deriving one. + */ +describe("GET /api/dashboard/summary — mood + BMI cards", () => { + function cardsOf(body: unknown): Array> { + return (body as { data: { metrics: Array> } }).data + .metrics; + } + + it("emits a mood card from the canonical daily series", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [ + { date: "2026-07-01", score: 2, samples: 1 }, + { date: "2026-07-02", score: 3, samples: 2 }, + { date: "2026-07-03", score: 4.5, samples: 1 }, + ], + summary: null, + entryCount: 3, + source: "rollup", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const mood = cardsOf(await res.json()).find((c) => c.kind === "mood"); + + expect(mood).toBeDefined(); + // Headline is the newest day's score, verbatim from the series — the + // route must not re-average or re-round what the helper resolved. + expect(mood?.latestValue).toBe(4.5); + expect(mood?.sparkline).toEqual([2, 3, 4.5]); + expect(mood?.trend).toBe("up"); + expect(mood?.allTimeCount).toBe(3); + expect(mood?.titleKey).toBe("dashboard.metric.title.mood"); + expect(mood?.unitKey).toBe("dashboard.metric.unit.mood"); + expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z"); + }); + + it("omits the mood card when the account has never logged a mood", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe( + undefined, + ); + }); + + it("emits a BMI card computed from the profile height and latest weight", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // 180 cm profile. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: null, + heightCm: 180, + } as never); + // `computeBmi` reads recent weight rows through `measurement.findMany`. + // 81 kg at 1.80 m => 25.0. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") }, + ] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi"); + + expect(bmi).toBeDefined(); + // The canonical helper's rounding (1 dp), not a second formula. + expect(bmi?.latestValue).toBe(25); + expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi"); + expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi"); + }); + + it("omits the BMI card when the profile carries no height", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Default profile has `heightCm: null`; weight alone cannot yield a BMI. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") }, + ] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe( + undefined, + ); + }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 00b399f98..5dd8a4195 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -74,6 +74,9 @@ import { import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { SleepStage } from "@/generated/prisma/client"; import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { computeBmi } from "@/lib/insights/derived/bmi"; +import { isDerivedOk } from "@/lib/insights/derived/types"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { reconstructNights, sleepNeedMinutes, @@ -96,7 +99,9 @@ type MetricKind = | "steps" | "totalBodyWater" | "boneMass" - | "oxygenSaturation"; + | "oxygenSaturation" + | "mood" + | "bmi"; interface MetricCard { id: string; @@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record = { totalBodyWater: "dashboard.metric.title.totalBodyWater", boneMass: "dashboard.metric.title.boneMass", oxygenSaturation: "dashboard.metric.title.oxygenSaturation", + mood: "dashboard.metric.title.mood", + bmi: "dashboard.metric.title.bmi", }; const METRIC_UNIT_KEYS: Record = { @@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record = { totalBodyWater: "dashboard.metric.unit.totalBodyWater", boneMass: "dashboard.metric.unit.boneMass", oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", + mood: "dashboard.metric.unit.mood", + bmi: "dashboard.metric.unit.bmi", }; /** @@ -615,11 +624,24 @@ async function buildDashboardSummary( // before the per-night reconstruction sums it. v1.17.0 — the baseline // profile rides along (one read) so the sleep-rhythm block resolves the // age-based sleep need the cumulative-debt deficit uses. - const [sleepPriorityJson, sleepProfile] = await Promise.all([ + const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([ loadUserSourcePriority(userId), loadBaselineProfile(prisma, userId), + // The mood card reads the SAME canonical daily series the web mood + // analytics and the dashboard snapshot read (`buildMoodDailySeries`), + // rollup fast-path included — never a second aggregation. + time("moodSeries", () => buildMoodDailySeries(userId, prisma)), ]); + // BMI is derived, not measured: it needs the profile height loaded above, + // so it cannot join the round before it. `computeBmi` is the one canonical + // implementation (the same one the web vitals dashboard renders through + // the derived-metric dispatch) — the route resolves the figure and the + // client consumes it, rather than every client re-deriving weight / m². + const bmiDerived = await time("bmi", () => + computeBmi(userId, sleepProfile, { now }), + ); + // Per-type metadata lookup — typed Map so a metric with no readings // at all falls through `metaForType` to the `{ allTimeCount: 0, // lastSeenAt: null }` default. The aggregate row's `_count._all` is @@ -954,6 +976,69 @@ async function buildDashboardSummary( }); } + // Mood — the one metric on this payload that is not a `Measurement`. The + // daily series is the canonical `buildMoodDailySeries` output (rollup + // fast-path, 1..5 scale, one entry per day with an average score), the + // same series the web mood analytics and the dashboard snapshot render. + // Emitted only when the account has ever logged a mood, matching the + // `allTimeCount === 0` gate every optional card above uses. + if (moodSeries.entryCount > 0) { + const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); + const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; + // The series carries UTC day labels, not timestamps — the entry's day is + // the only instant the source actually pins, so it is emitted as the + // day's start rather than inventing a reading time. + const latestAt = latestEntry + ? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString() + : null; + metrics.push({ + id: "mood", + kind: "mood", + titleKey: METRIC_TITLE_KEYS.mood, + latestValue: latestEntry?.score ?? null, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS.mood, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latestAt, + allTimeCount: moodSeries.entryCount, + lastSeenAt: latestAt, + }); + } + + // BMI — derived from the latest weight at the profile height by + // `computeBmi`, the single implementation the web vitals dashboard also + // renders. The card is omitted when the derivation is insufficient + // (no height on the profile, or no weight inside its window): the card + // shape has no "unknown" state to express honestly, and a card with a + // null value would read as a logging gap rather than a missing profile + // field. Clients that want to prompt for a height should do so from the + // profile surface, not from an empty dashboard tile. + if (isDerivedOk(bmiDerived)) { + const spark = bmiDerived.value.series.slice(-SPARK_DAYS); + metrics.push({ + id: "bmi", + kind: "bmi", + titleKey: METRIC_TITLE_KEYS.bmi, + latestValue: bmiDerived.value.bmi, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS.bmi, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: trendOf(spark), + sparkline: spark, + // BMI moves exactly when weight does, so the weight metadata is the + // honest recency signal — there is no separate BMI reading. + updatedAt: metaForType("WEIGHT").lastSeenAt, + allTimeCount: metaForType("WEIGHT").allTimeCount, + lastSeenAt: metaForType("WEIGHT").lastSeenAt, + }); + } + // Shared builder carries the exact tally semantics this tile always // had (scheduled = every today-window row; taken = `takenAt` set and // not skipped). The wire shape of `compliance` is locked for iOS. diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index ee12a8dfa..3bddac9d8 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial> = { // `NutrientIntakeDay` (nutrient="water", summed across sources). Gated // on the `nutrients` module like the rest of that store's surfaces. NUTRIENT_WATER: "nutrients", + // Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it + // rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the + // `mood` module: turning the module off must drop the dashboard card on + // every client, not just hide the web widget. + MOOD_ENTRY: "mood", }; /** @@ -123,6 +128,13 @@ export const SUMMARY_METRIC_TYPE_BY_KIND: Record = { totalBodyWater: "TOTAL_BODY_WATER", boneMass: "BONE_MASS", oxygenSaturation: "OXYGEN_SATURATION", + // Synthetic key — see `MOOD_ENTRY` in `SUMMARY_TYPE_MODULE`. + mood: "MOOD_ENTRY", + // BMI is derived from weight + the profile height and belongs to no + // module, exactly like weight and body fat. Listed so the full emitted + // set stays readable and a future card cannot be added without deciding + // what backs it. + bmi: "WEIGHT", }; /**