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", }; /**