diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index c0608d9a7..a16ec9b41 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -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", () => { diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 73125d354..2f99a5eec 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -12,6 +12,7 @@ import { Dumbbell, Footprints, Gauge, + GlassWater, Heart, HeartPulse, Moon, @@ -173,6 +174,9 @@ const TILE_INSIGHT_HREF: Record = { 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: ( + + ), + }); + } if (showBpInTargetTile) { /* v1.4.22 A2 — feature parity with every other tile. Synthesise a slope from the difference between the 7-day diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index e3f2caaf5..af861d41d 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -64,6 +64,9 @@ const TILE_CAPABLE_WIDGET_IDS = new Set([ "muscleMass", "totalBodyWater", "boneMass", + // v1.29 — fluid intake strip tile: paints one strip card and self-gates + // on having a NUTRIENT_WATER sample. + "waterIntake", ]); /** diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 2837c8ee7..3ee72adf8 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -162,6 +162,8 @@ const WIDGET_LABEL_KEYS: Record = { 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", }; /** diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index bf3a3a3c1..8624620f6 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -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); } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 685d5d222..8c42009ba 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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 }, ], }; diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b237f2646..f267aa23e 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -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, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 57413855d..c1ee08a27 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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(); + 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 + diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 661f45dbc..b03ddd973 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -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> = { 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>` to + * `Partial>` 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> = - { - 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> = { + 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", +};