feat(dashboard): fluid-intake strip tile, nutrients-store-backed

Reuses the TrendCard strip-tile pattern (icon, neutral direction
sentiment, stale-days caption) for a new waterIntake widget, but
sources it from NutrientIntakeDay instead of introducing a parallel
MeasurementType. The dashboard snapshot builder reads the last 30
day-keys for nutrient="water" (summed across sources), folds them
into a synthetic NUTRIENT_WATER summary key alongside the real
MeasurementType summaries, and the existing module-gating machinery
(SUMMARY_TYPE_MODULE, WIDGET_MODULE_BY_ID) strips it when the opt-in
nutrients module is off — no new gating code path, just two new map
entries.

The tile links out to /insights/nutrients instead of a measurement
detail page. Widget catalogue grows 37 -> 38 ids.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 16:08:39 +02:00
1 parent da2de015fe
commit b396b51d35
9 files changed
+200 -33

No files matched your search

@@ -111,8 +111,9 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
// silhouette. bp paints sys + dia = 2. v1.28.52 added seven vitals +
// body-composition strip tiles (hrv, oxygenSaturation, respiratoryRate,
// wristTemperature, muscleMass, totalBodyWater, boneMass), each
// single-count: 11 + 7 = 18.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(18);
// single-count: 11 + 7 = 18. v1.29 added the fluid-intake strip tile
// (waterIntake), single-count: 18 + 1 = 19.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(19);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
+39
View File
@@ -12,6 +12,7 @@ import {
Dumbbell,
Footprints,
Gauge,
GlassWater,
Heart,
HeartPulse,
Moon,
@@ -173,6 +174,9 @@ const TILE_INSIGHT_HREF: Record<string, string> = {
muscleMass: "/insights/muscle-mass",
totalBodyWater: "/insights/body-water",
boneMass: "/insights/bone-mass",
// v1.29 — fluid intake links to the nutrients surface (hydration hero +
// quick-add), not a Measurement-backed detail page.
waterIntake: "/insights/nutrients",
};
function tileInsightHref(id: string): string | null {
@@ -394,6 +398,10 @@ export default function DashboardPageClient() {
const muscleMassSummary = data?.summaries?.MUSCLE_MASS;
const bodyWaterSummary = data?.summaries?.TOTAL_BODY_WATER;
const boneMassSummary = data?.summaries?.BONE_MASS;
// v1.29 — fluid intake strip tile. Synthetic summary key, not a real
// MeasurementType — derived server-side from `NutrientIntakeDay`
// (nutrient="water", summed across sources); see `dashboard/snapshot.ts`.
const waterIntakeSummary = data?.summaries?.NUTRIENT_WATER;
const moodSummary = moodData?.summary;
// Resolve full dashboard layout — controls visibility + order of every widget
@@ -478,6 +486,8 @@ export default function DashboardPageClient() {
const hasMuscleMass = (muscleMassSummary?.count ?? 0) > 0;
const hasBodyWater = (bodyWaterSummary?.count ?? 0) > 0;
const hasBoneMass = (boneMassSummary?.count ?? 0) > 0;
// v1.29 — data floor for the fluid-intake strip tile.
const hasWaterIntake = (waterIntakeSummary?.count ?? 0) > 0;
/**
* v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %"
* placeholder doesn't ride on the dashboard when none of the user's
@@ -526,6 +536,9 @@ export default function DashboardPageClient() {
const showMuscleMassTile = isTileVisible("muscleMass") && hasMuscleMass;
const showBodyWaterTile = isTileVisible("totalBodyWater") && hasBodyWater;
const showBoneMassTile = isTileVisible("boneMass") && hasBoneMass;
// v1.29 — fluid intake strip tile gate: layout toggle AND a non-empty
// summary, same precedent as the vitals/body-composition tiles above.
const showWaterIntakeTile = isTileVisible("waterIntake") && hasWaterIntake;
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
// Chart (lower row) gates — controlled by the legacy `visible` flag.
@@ -1280,6 +1293,32 @@ export default function DashboardPageClient() {
),
});
}
// v1.29 — fluid intake strip tile. Neutral sentiment: more water is
// not unambiguously "better" (over-hydration is a real risk), so no
// up/down framing — same posture as wrist temperature / body water.
if (showWaterIntakeTile) {
trendCards.push({
id: "waterIntake",
order: widgetOrder("waterIntake"),
node: (
<TrendCard
key="waterIntake"
label={t("nutrients.names.water")}
latest={waterIntakeSummary?.latest ?? null}
unit="mL"
avg7={waterIntakeSummary?.avg7 ?? null}
avg30={waterIntakeSummary?.avg30 ?? null}
slope30={waterIntakeSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
icon={GlassWater}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(waterIntakeSummary)}
staleDays={tileStaleDays("NUTRIENT_WATER")}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
@@ -64,6 +64,9 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
"muscleMass",
"totalBodyWater",
"boneMass",
// v1.29 — fluid intake strip tile: paints one strip card and self-gates
// on having a NUTRIENT_WATER sample.
"waterIntake",
]);
/**
@@ -162,6 +162,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
walkingSteadiness: "measurements.typeWalkingSteadiness",
// v1.18.2 — Vorsorge preventive-care summary card.
vorsorge: "measurementReminders.sectionTitle",
// v1.29 — fluid intake strip tile (nutrients-store-backed).
waterIntake: "nutrients.names.water",
};
/**
+11 -8
View File
@@ -447,10 +447,13 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
// web-writable (iOS-only 11 → 9), and muscle mass is a brand-new
// writable id, so the server-known set grew 25 → 28 and the catalogue
// 36 → 37.
expect(DASHBOARD_WIDGET_IDS).toHaveLength(28);
// v1.29 — the nutrients-store-backed fluid-intake strip tile
// (`waterIntake`) added one server-known id, so the catalogue grew
// 37 → 38.
expect(DASHBOARD_WIDGET_IDS).toHaveLength(29);
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9);
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(37);
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(37);
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38);
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38);
});
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
@@ -677,17 +680,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
expect(ids).not.toContain("totally-made-up");
});
it("keeps the default layout at the 28 web tiles (no iOS-only seeded)", () => {
it("keeps the default layout at the 29 web tiles (no iOS-only seeded)", () => {
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
expect(ids).toHaveLength(28);
expect(ids).toHaveLength(29);
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(ids).not.toContain(iosId);
}
});
it("does NOT auto-append iOS-only ids when a web-only layout is read", () => {
// A web account that saved only `weight` must auto-upgrade to the 28
// web defaults — never to the 37 catalogue. iOS-only ids appear only
// A web account that saved only `weight` must auto-upgrade to the 29
// web defaults — never to the 38 catalogue. iOS-only ids appear only
// once a native client has explicitly sent them.
const partial = {
version: 1,
@@ -695,7 +698,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
};
const resolved = resolveDashboardLayout(partial);
const ids = resolved.widgets.map((w) => w.id);
expect(ids).toHaveLength(28);
expect(ids).toHaveLength(29);
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(ids).not.toContain(iosId);
}
+10
View File
@@ -72,6 +72,10 @@ export const DASHBOARD_WIDGET_IDS = [
// user opts in via Settings → Dashboard. Always-on data surface (no
// toggleable module gate), so it behaves as a core opt-in tile.
"vorsorge",
// v1.29 — fluid intake (drinking water). Nutrients-store-backed (see
// `NUTRIENT_WATER` in the dashboard snapshot), gated on the `nutrients`
// module; self-gates on data like the v1.28.52 strip tiles.
"waterIntake",
] as const;
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
@@ -611,6 +615,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// preventive-care reminders surface on / by default; the card self-gates
// (see page.tsx) and shows nothing for accounts without due reminders.
{ id: "vorsorge", visible: true, tileVisible: false, order: 27 },
// v1.29 — fluid intake strip tile. On by default (`tileVisible: true`)
// like the v1.28.52 vitals cluster; self-gates on having water data
// (see `showWaterIntakeTile` in page-client.tsx) AND on the `nutrients`
// module (opt-in, default off), so an account that never enabled
// nutrients or never logged water sees a clean dashboard.
{ id: "waterIntake", visible: false, tileVisible: true, order: 28 },
],
};
+38 -4
View File
@@ -124,6 +124,8 @@ const emptySummary = {
const fakePrisma = {
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
// v1.29 — the fluid-intake dashboard tile block reads this table.
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
@@ -168,6 +170,7 @@ beforeEach(() => {
});
fakePrisma.measurement.findMany.mockResolvedValue([]);
fakePrisma.moodEntry.findMany.mockResolvedValue([]);
fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([]);
hasAnyConfiguredProvider.mockResolvedValue(true);
buildMedsTodayBlock.mockResolvedValue({
activeCount: 1,
@@ -749,11 +752,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () =>
isFullyCovered.mockReturnValue(false);
});
it("emits all 37 catalogue ids with visibility + order", async () => {
it("emits all 38 catalogue ids with visibility + order", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
expect(snap.layoutCatalogue).toHaveLength(37);
expect(snap.layoutCatalogue).toHaveLength(38);
const ids = new Set(snap.layoutCatalogue.map((w) => w.id));
expect(ids.size).toBe(37);
expect(ids.size).toBe(38);
// iOS-only ids appended default-invisible.
const walkingSpeed = snap.layoutCatalogue.find(
(w) => w.id === "walkingSpeed",
@@ -826,7 +829,7 @@ describe("buildDashboardSnapshot — additive proof", () => {
expect(snap.layout).toHaveProperty("version");
expect(Array.isArray(snap.layout.widgets)).toBe(true);
// Resolved layout still carries only server-known widget ids.
expect(snap.layout.widgets.length).toBeLessThanOrEqual(28);
expect(snap.layout.widgets.length).toBeLessThanOrEqual(29);
// tiles shape unchanged.
expect(snap.tiles).toHaveProperty("summaries");
@@ -852,6 +855,8 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18
"stairAscentSpeed",
"stairDescentSpeed",
"breathingDisturbances",
// v1.29 — fluid-intake strip tile, nutrients-store-backed.
"waterIntake",
] as const;
it("every toggleable-domain widget id has a module mapping", () => {
@@ -896,6 +901,11 @@ describe("module gating maps — every toggleable-domain widget is mapped (v1.18
expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep");
expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep");
});
it("fluid-intake widget + synthetic summary type resolve to the nutrients module (v1.29)", () => {
expect(WIDGET_MODULE_BY_ID.waterIntake).toBe("nutrients");
expect(SUMMARY_TYPE_MODULE.NUTRIENT_WATER).toBe("nutrients");
});
});
describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
@@ -953,6 +963,11 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
fakePrisma.moodEntry.findMany.mockResolvedValue([
{ date: new Date(), score: 4 },
]);
// v1.29 — water rows so the fluid-intake tile would otherwise carry a
// summary (proving the nutrients module actually strips it below).
fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([
{ day: new Date().toISOString().slice(0, 10), amount: 1500 },
]);
});
it("all modules on: every toggleable tile + its data is present", async () => {
@@ -1025,6 +1040,25 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
expect(widget(snap, "achievements")!.visible).toBe(false);
});
it("nutrients on: fluid-intake tile widget carries the synthetic NUTRIENT_WATER summary (v1.29)", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap()),
});
expect(snap.tiles.summaries.NUTRIENT_WATER).toBeDefined();
expect(snap.tiles.summaries.NUTRIENT_WATER!.latest).toBe(1500);
});
it("nutrients disabled: waterIntake tile hidden + NUTRIENT_WATER stripped from summaries (v1.29)", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap({ nutrients: false })),
});
expect(widget(snap, "waterIntake")!.visible).toBe(false);
expect(widget(snap, "waterIntake")!.tileVisible).toBe(false);
expect(cat(snap, "waterIntake")!.visible).toBe(false);
expect(snap.tiles.summaries.NUTRIENT_WATER).toBeUndefined();
expect(snap.tiles.lastSeenByType.NUTRIENT_WATER).toBeUndefined();
});
it("insights disabled: Daily Briefing surface goes to disabled state", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
+64
View File
@@ -514,6 +514,50 @@ async function buildMoodBlock(
return { summary: series.summary, entries: series.entries };
}
/**
* v1.29 — fluid-intake dashboard tile block, read from `NutrientIntakeDay`
* (nutrient="water", last 30 day-keys, summed ACROSS SOURCES per day —
* migration 0249 can carry an APPLE_HEALTH row and a MANUAL row for the
* same day). ≤ 60 rows off the existing `(userId, nutrient, day desc)`
* index. Sparse by construction (only days with an actual write become a
* `DataPoint`) — matching the convention every real `MeasurementType`
* summary follows: an unlogged day is absent, not a zero reading. `null`
* when there is no water data in the window; the caller strips the
* synthetic `NUTRIENT_WATER` key entirely in that case (mirrors mood).
* Always fetched (cheap, indexed) and stripped post-hoc by
* `gateSummariesByModules` when the `nutrients` module is off — the same
* fetch-then-gate posture the sleep/glucose/recovery types already use.
*/
async function buildNutrientWaterBlock(
prisma: PrismaClient,
userId: string,
): Promise<{ summary: DataSummary; lastSeenAt: string } | null> {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId, nutrient: "water", day: { gte: since } },
select: { day: true, amount: true },
});
if (rows.length === 0) return null;
const sumByDay = new Map<string, number>();
for (const row of rows) {
sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount);
}
const points: DataPoint[] = [...sumByDay.entries()]
.map(([day, amount]) => ({
date: new Date(`${day}T00:00:00.000Z`),
value: amount,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
return {
summary: summarize(points),
lastSeenAt: points[points.length - 1].date.toISOString(),
};
}
/**
* Thick slice — BD-Zielbereich + per-context glucose + health score.
* Only called by the builder when the rollup tier is warm for the
@@ -1104,6 +1148,7 @@ export async function buildDashboardSnapshot(
medsToday,
modules,
scoreRings,
nutrientWater,
] = await Promise.all([
// A5 — reuse the coverage map already probed above so the slice
// doesn't re-run the identical `probeRollupCoverage` query.
@@ -1131,8 +1176,27 @@ export async function buildDashboardSnapshot(
await medsTodayPromise,
),
).catch(() => [] as DashboardScoreRing[]),
// v1.29 — fluid-intake tile block (see `buildNutrientWaterBlock`).
time("nutrientWater", () => buildNutrientWaterBlock(prisma, user.id)),
]);
// v1.29 — fold the water block into the slim slice under a synthetic
// `NUTRIENT_WATER` key BEFORE the module gate below strips it (like
// every other summary type) when `nutrients` is off. Not a real
// `MeasurementType` — see the widened `SUMMARY_TYPE_MODULE` note.
if (nutrientWater) {
const lastMs = new Date(nutrientWater.lastSeenAt).getTime();
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
slimRaw.summaries = {
...slimRaw.summaries,
NUTRIENT_WATER: nutrientWater.summary,
};
slimRaw.lastSeenByType = {
...slimRaw.lastSeenByType,
NUTRIENT_WATER: { lastSeenAt: nutrientWater.lastSeenAt, daysAgo },
};
}
// v1.18.0 — strip disabled-module data before it leaves the server.
// Mood is blanked when the mood module is off; sleep / glucose summary
// types are dropped from the slim slice; the glucose clinical panel +
+30 -19
View File
@@ -10,10 +10,8 @@
* both for the server call sites and the existing tests.
*
* `ModuleKey` comes from `@/lib/modules/registry` (pure constants, no
* imports) and `MeasurementType` is a type-only import from the generated
* Prisma client, so this file stays browser-bundle-safe.
* imports), so this file stays browser-bundle-safe.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ModuleKey } from "@/lib/modules/registry";
/**
@@ -41,26 +39,39 @@ export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
stairAscentSpeed: "recovery",
stairDescentSpeed: "recovery",
breathingDisturbances: "sleep",
// v1.29 — fluid-intake strip tile, nutrients-store-backed (see
// `SUMMARY_TYPE_MODULE.NUTRIENT_WATER` below).
waterIntake: "nutrients",
};
/**
* `MeasurementType` slim-summary keys that belong to a toggleable module.
* When the module is off the key is stripped from `tiles.summaries` /
* Slim-summary keys that belong to a toggleable module. When the module
* is off the key is stripped from `tiles.summaries` /
* `tiles.lastSeenByType` (so `metricStates` and the client data-floor
* gates also drop it) before the snapshot leaves the server. Core vital
* types are absent here and always pass through.
*
* v1.29 — widened from `Partial<Record<MeasurementType, ModuleKey>>` to
* `Partial<Record<string, ModuleKey>>` so the synthetic `NUTRIENT_WATER`
* key (a `NutrientIntakeDay`-derived summary, not a real
* `MeasurementType` — the abandoned `feat/water` branch's parallel
* `WATER_INTAKE` enum value is deliberately NOT added) can ride the same
* gate without widening the `MeasurementType` enum itself.
*/
export const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, ModuleKey>> =
{
SLEEP_DURATION: "sleep",
BLOOD_GLUCOSE: "glucose",
// v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page +
// its tiles are the recovery module's surface; when it is off these
// device-native signals must drop from the dashboard snapshot too.
CARDIO_RECOVERY: "recovery",
SIX_MINUTE_WALK_DISTANCE: "recovery",
STAIR_ASCENT_SPEED: "recovery",
STAIR_DESCENT_SPEED: "recovery",
// Per-night breathing-disturbance index is a sleep-page signal.
BREATHING_DISTURBANCES: "sleep",
};
export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
SLEEP_DURATION: "sleep",
BLOOD_GLUCOSE: "glucose",
// v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page +
// its tiles are the recovery module's surface; when it is off these
// device-native signals must drop from the dashboard snapshot too.
CARDIO_RECOVERY: "recovery",
SIX_MINUTE_WALK_DISTANCE: "recovery",
STAIR_ASCENT_SPEED: "recovery",
STAIR_DESCENT_SPEED: "recovery",
// Per-night breathing-disturbance index is a sleep-page signal.
BREATHING_DISTURBANCES: "sleep",
// v1.29 — fluid-intake dashboard tile summary, derived server-side from
// `NutrientIntakeDay` (nutrient="water", summed across sources). Gated
// on the `nutrients` module like the rest of that store's surfaces.
NUTRIENT_WATER: "nutrients",
};