feat(dashboard): emit mood and BMI on the summary payload

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:24:07 +02:00
1 parent 8168e60dfd
commit 4ddc08f784
11 files changed
+262 -14

No files matched your search

+6 -2
View File
@@ -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²"
}
}
},
+6 -2
View File
@@ -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²"
}
}
},
+6 -2
View File
@@ -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²"
}
}
},
+6 -2
View File
@@ -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²"
}
}
},
+6 -2
View File
@@ -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²"
}
}
},
+6 -2
View File
@@ -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²"
}
}
},
@@ -81,6 +81,7 @@ const LEGIT_IDENTICAL = new Set<string>([
// 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",
@@ -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.
@@ -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<Record<string, unknown>> {
return (body as { data: { metrics: Array<Record<string, unknown>> } }).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,
);
});
});
+87 -2
View File
@@ -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<MetricKind, string> = {
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<MetricKind, string> = {
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
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.
+12
View File
@@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
// `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<string, string> = {
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",
};
/**