From 7c45c876a05730b3a4d73534f9600a376b1ac0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 15:21:03 +0200 Subject: [PATCH] feat(dashboard): tiles for HRV, blood oxygen, breathing rate, temperature, and body composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several readings were synced and charted but had no place on the dashboard strip: heart-rate variability, blood oxygen, breathing rate, wrist temperature, and — for body-composition scale users — muscle mass, body water, and bone mass. Each now has a tile. The tiles are pure client additions: the dashboard snapshot already ships a summary for every measurement type, so no backend, query, or migration changed. Each tile is data-availability gated (renders only when the account has that reading) and is toggleable and reorderable under Settings like the rest. hrv and respiratoryRate graduate from iOS-only to web-writable widget ids, and muscleMass is a new writable id (catalogue 36 to 37, within the 40-id PUT cap); wristTemperature moves from pin-only to a web-rendered tile. All additive — the widget enum only widens, which the iOS contract treats as non-breaking. --- CHANGELOG.md | 13 ++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../dashboard-tile-strip-skeleton.test.ts | 7 +- src/app/page-client.tsx | 215 ++++++++++++++++++ src/components/dashboard/dashboard-gates.ts | 9 + .../dashboard-layout-section.test.tsx | 3 +- .../settings/dashboard-layout-section.tsx | 5 + src/lib/__tests__/dashboard-layout.test.ts | 94 ++++++-- src/lib/dashboard-layout.ts | 79 ++++--- src/lib/dashboard/__tests__/snapshot.test.ts | 16 +- 12 files changed, 389 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9c8b5da..c9f88dd99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.28.52] — 2026-07-16 — Dashboard tiles for more of what you already track + +The dashboard gains tiles for readings that were synced and charted but never +had a place on the home strip: heart-rate variability, blood oxygen, breathing +rate, wrist temperature, and — for anyone with a body-composition scale — muscle +mass, body water, and bone mass. + +Each tile appears only once you have that reading, so nothing new shows up on an +account that doesn't track it, and every tile can be toggled or reordered under +Settings → Dashboard like the rest. Tapping one opens its detail view. Nothing +changed in how the readings are stored or synced — this only surfaces what was +already there. + ## [1.28.51] — 2026-07-16 — Chatting about a document lives in the Coach now Asking the Coach about a document used to open a separate, half-finished chat diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08bc59afa..ca8d24167 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.51 + version: 1.28.52 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 307b3b6d9..e3340ba20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.51", + "version": "1.28.52", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 6de42b312..e55614a3e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.51"; + /* @sw-version-fallback */ "v1.28.52"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 2c9828023..c0608d9a7 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -108,8 +108,11 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => { // plus the v1.20.0 sleep / steps / glucose flips are tile-visible AND // tile-capable; medications + recentWorkouts are tile-visible in the // stored layout but paint NO strip tile, so they must not inflate the - // silhouette. bp paints sys + dia = 2. - expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(11); + // 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); }); 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 cbf182f33..facf03b70 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -6,16 +6,22 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { Activity, + Bone, Droplet, + Droplets, + Dumbbell, Footprints, Gauge, Heart, + HeartPulse, Moon, Percent, Plus, Smile, Target, + Thermometer, TrendingUp, + Wind, } from "lucide-react"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { cn } from "@/lib/utils"; @@ -156,6 +162,15 @@ const TILE_INSIGHT_HREF: Record = { sleep: "/insights/sleep", steps: "/insights/steps", vo2Max: "/insights/cardio-fitness", + // v1.28.52 — vitals + body-composition strip tiles link to their + // existing /insights detail pages (bands, history, correlations). + hrv: "/insights/hrv", + oxygenSaturation: "/insights/oxygen", + respiratoryRate: "/insights/respiratory-rate", + wristTemperature: "/insights/wrist-temperature", + muscleMass: "/insights/muscle-mass", + totalBodyWater: "/insights/body-water", + boneMass: "/insights/bone-mass", }; function tileInsightHref(id: string): string | null { @@ -354,6 +369,17 @@ export default function DashboardPageClient() { // auto-populates this summary because the route iterates over the // full measurementTypeEnum.options list; no backend change needed. const vo2Summary = data?.summaries?.VO2_MAX; + // v1.28.52 — additional vitals + body-composition summaries. Each rides + // the same summaries slice (`computeSummariesSlice` iterates the full + // measurement-type enum), so no backend change was needed; the strip + // tiles below self-gate on `count > 0`. + const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY; + const spo2Summary = data?.summaries?.OXYGEN_SATURATION; + const respRateSummary = data?.summaries?.RESPIRATORY_RATE; + const wristTempSummary = data?.summaries?.WRIST_TEMPERATURE; + const muscleMassSummary = data?.summaries?.MUSCLE_MASS; + const bodyWaterSummary = data?.summaries?.TOTAL_BODY_WATER; + const boneMassSummary = data?.summaries?.BONE_MASS; const moodSummary = moodData?.summary; // Resolve full dashboard layout — controls visibility + order of every widget @@ -430,6 +456,14 @@ export default function DashboardPageClient() { const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; const hasVo2 = (vo2Summary?.count ?? 0) > 0; + // v1.28.52 — data floors for the new vitals + body-composition tiles. + const hasHrv = (hrvSummary?.count ?? 0) > 0; + const hasSpo2 = (spo2Summary?.count ?? 0) > 0; + const hasRespRate = (respRateSummary?.count ?? 0) > 0; + const hasWristTemp = (wristTempSummary?.count ?? 0) > 0; + const hasMuscleMass = (muscleMassSummary?.count ?? 0) > 0; + const hasBodyWater = (bodyWaterSummary?.count ?? 0) > 0; + const hasBoneMass = (boneMassSummary?.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 @@ -468,6 +502,16 @@ export default function DashboardPageClient() { const showStepsTile = isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false; const showVo2Tile = isTileVisible("vo2Max") && hasVo2; + // v1.28.52 — strip-tile gates for the new vitals + body-composition + // metrics. Each follows the VO2max precedent: layout toggle AND a + // non-empty summary, so an account without that metric sees no tile. + const showHrvTile = isTileVisible("hrv") && hasHrv; + const showSpo2Tile = isTileVisible("oxygenSaturation") && hasSpo2; + const showRespRateTile = isTileVisible("respiratoryRate") && hasRespRate; + const showWristTempTile = isTileVisible("wristTemperature") && hasWristTemp; + const showMuscleMassTile = isTileVisible("muscleMass") && hasMuscleMass; + const showBodyWaterTile = isTileVisible("totalBodyWater") && hasBodyWater; + const showBoneMassTile = isTileVisible("boneMass") && hasBoneMass; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; // Chart (lower row) gates — controlled by the legacy `visible` flag. @@ -1059,6 +1103,177 @@ export default function DashboardPageClient() { ), }); } + // v1.28.52 — vitals strip tiles (HRV / SpO2 / respiratory rate / + // wrist temperature). Each self-gates on the layout toggle AND a + // non-empty summary (see the `showXTile` gates above), links to its + // /insights detail page, and forwards the per-type freshness caption. + // Units + sentiments mirror the matching /insights sub-page: + // higher HRV and higher SpO2 are better (up-good); respiratory rate + // and wrist temperature read as neutral (stability is the signal). + if (showHrvTile) { + trendCards.push({ + id: "hrv", + order: widgetOrder("hrv"), + node: ( + + ), + }); + } + if (showSpo2Tile) { + trendCards.push({ + id: "oxygenSaturation", + order: widgetOrder("oxygenSaturation"), + node: ( + + ), + }); + } + if (showRespRateTile) { + trendCards.push({ + id: "respiratoryRate", + order: widgetOrder("respiratoryRate"), + node: ( + + ), + }); + } + if (showWristTempTile) { + trendCards.push({ + id: "wristTemperature", + order: widgetOrder("wristTemperature"), + node: ( + + ), + }); + } + // v1.28.52 — body-composition strip tiles (muscle mass / total body + // water / bone mass). Higher muscle mass reads as up-good; body water + // and bone mass are neutral (stability, not direction, is the signal). + if (showMuscleMassTile) { + trendCards.push({ + id: "muscleMass", + order: widgetOrder("muscleMass"), + node: ( + + ), + }); + } + if (showBodyWaterTile) { + trendCards.push({ + id: "totalBodyWater", + order: widgetOrder("totalBodyWater"), + node: ( + + ), + }); + } + if (showBoneMassTile) { + trendCards.push({ + id: "boneMass", + order: widgetOrder("boneMass"), + 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 3b74ff113..e3f2caaf5 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -55,6 +55,15 @@ const TILE_CAPABLE_WIDGET_IDS = new Set([ "glucose", "bpInTarget", "vo2Max", + // v1.28.52 — vitals + body-composition strip tiles: each paints one + // strip card and self-gates on having a sample of its MeasurementType. + "hrv", + "oxygenSaturation", + "respiratoryRate", + "wristTemperature", + "muscleMass", + "totalBodyWater", + "boneMass", ]); /** diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index d6ed649af..12de28600 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -383,7 +383,8 @@ describe(" — iOS-pin-only ids hidden from web (v1.11.2 stairAscentSpeed: "Stair ascent speed", stairDescentSpeed: "Stair descent speed", breathingDisturbances: "Breathing disturbances", - wristTemperature: "Wrist temperature", + // v1.28.52 — `wristTemperature` is no longer pin-only; it now renders + // a web row, so it left both IOS_PIN_ONLY_WIDGET_IDS and this map. falls: "Falls", walkingSteadiness: "Walking steadiness", }; diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 674af8433..78815f821 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -138,8 +138,13 @@ const WIDGET_LABEL_KEYS: Record = { glucose: "measurements.typeBloodGlucose", totalBodyWater: "measurements.typeTotalBodyWater", boneMass: "measurements.typeBoneMass", + // v1.28.52 — muscle mass joins the body-composition strip tiles. + muscleMass: "measurements.typeMuscleMass", bpInTarget: "dashboard.bpInTarget", oxygenSaturation: "measurements.typeOxygenSaturation", + // v1.28.52 — HRV + respiratory rate graduate to web-writable widgets. + hrv: "measurements.typeHeartRateVariability", + respiratoryRate: "measurements.typeRespiratoryRate", achievements: "achievements.title", // v1.4.25 W8d — VO2 max secondary-metric tile (opt-in). vo2Max: "dashboard.vo2Max", diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 1fd6f6275..079135fb0 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -460,15 +460,19 @@ describe("resolveDashboardLayout() — heroVisible", () => { * iOS-only ids extend it without touching the writable PUT enum. */ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { - it("carries exactly 36 distinct ids (25 server-known + 11 iOS-only)", () => { + it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => { // v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so // the server-known set grew 16 → 24 and the catalogue 27 → 35. // v1.18.2 — the Vorsorge summary widget added one server-known id // (24 → 25), so the catalogue grew 35 → 36. - expect(DASHBOARD_WIDGET_IDS).toHaveLength(25); - expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(11); - expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(36); - expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(36); + // v1.28.52 — HRV + respiratory rate graduated from the iOS-only set to + // 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); + 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); }); it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => { @@ -511,22 +515,81 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { }); it("carries the locked iOS-only ids verbatim", () => { + // v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable + // DASHBOARD_WIDGET_IDS (they gained a web strip tile). expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([ "restingHeartRate", - "hrv", "walkingSpeed", "walkingAsymmetry", "walkingStepLength", "bmi", "bodyTemperature", "walkingDoubleSupport", - "respiratoryRate", "audioExposureEnvironment", "audioExposureHeadphone", ]); }); }); +/** + * v1.28.52 — vitals + body-composition strip tiles. Seven metrics + * HealthLog already collects (HRV, SpO2, respiratory rate, wrist + * temperature, muscle mass, total body water, bone mass) gain a + * dashboard strip tile. Each must be a WRITABLE widget id (so the PUT + * enum + Settings toggle accept it) with a default layout row that turns + * the strip tile on and keeps the chart row off (the tile self-gates on + * data in page.tsx; the chart lives on the /insights sub-page). + */ +describe("v1.28.52 — vitals + body-composition strip tiles", () => { + const NEW_TILE_IDS = [ + "hrv", + "oxygenSaturation", + "respiratoryRate", + "wristTemperature", + "muscleMass", + "totalBodyWater", + "boneMass", + ] as const; + + it("registers every new tile id as a writable widget (Settings enum + PUT accept it)", () => { + const writable = new Set(DASHBOARD_WIDGET_IDS); + for (const id of NEW_TILE_IDS) { + expect(writable.has(id)).toBe(true); + } + }); + + it("carries a default layout row for every new tile: strip tile on, chart row off", () => { + for (const id of NEW_TILE_IDS) { + const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find((w) => w.id === id); + expect(widget, `default row for ${id}`).toBeDefined(); + // Strip tile on so accounts that sync the metric discover it on /; + // it self-gates on `count > 0` in page.tsx. + expect(widget?.tileVisible, `${id} tileVisible`).toBe(true); + // Chart row off — those charts live on the /insights sub-pages. + expect(widget?.visible, `${id} visible`).toBe(false); + } + }); + + it("keeps the promoted ids out of the iOS-only + pin-only sets (no double-book)", () => { + const iosOnly = new Set(DASHBOARD_IOS_ONLY_WIDGET_IDS); + const pinOnly = new Set(IOS_PIN_ONLY_WIDGET_IDS); + for (const id of NEW_TILE_IDS) { + expect(iosOnly.has(id), `${id} not iOS-only`).toBe(false); + expect(pinOnly.has(id), `${id} not pin-only`).toBe(false); + } + }); + + it("round-trips every new tile id through serialize → resolve", () => { + const resolved = resolveDashboardLayout( + serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + ); + const ids = new Set(resolved.widgets.map((w) => w.id)); + for (const id of NEW_TILE_IDS) { + expect(ids.has(id), `${id} survives round-trip`).toBe(true); + } + }); +}); + /** * v1.11.2 HIGH-1 — the 8 B5 metrics are WRITABLE (members of * `DASHBOARD_WIDGET_IDS`, so the widgets PUT enum — derived from the @@ -536,14 +599,15 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { * `dashboard-layout-section.test.tsx`). */ describe("IOS_PIN_ONLY_WIDGET_IDS — writable but not web-rendered", () => { - it("is the 8 B5 ids verbatim", () => { + it("is the 7 pin-only ids verbatim", () => { + // v1.28.52 — `wristTemperature` graduated to a web-rendered strip tile, + // so it is no longer pin-only (the Settings list now offers its toggle). expect([...IOS_PIN_ONLY_WIDGET_IDS]).toEqual([ "cardioRecovery", "sixMinuteWalk", "stairAscentSpeed", "stairDescentSpeed", "breathingDisturbances", - "wristTemperature", "falls", "walkingSteadiness", ]); @@ -623,7 +687,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { version: 1, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, - { id: "hrv", visible: true, tileVisible: true, order: 1 }, // iOS-only + { id: "hrv", visible: true, tileVisible: true, order: 1 }, // catalogue id { id: "glp1", visible: true, tileVisible: true, order: 2 }, // retired { id: "totally-made-up", visible: true, tileVisible: true, order: 3 }, ], @@ -635,17 +699,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { expect(ids).not.toContain("totally-made-up"); }); - it("keeps the default layout at the 25 web tiles (no iOS-only seeded)", () => { + it("keeps the default layout at the 28 web tiles (no iOS-only seeded)", () => { const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); - expect(ids).toHaveLength(25); + expect(ids).toHaveLength(28); 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 25 - // web defaults — never to the 36 catalogue. iOS-only ids appear only + // 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 // once a native client has explicitly sent them. const partial = { version: 1, @@ -653,7 +717,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(25); + expect(ids).toHaveLength(28); 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 8aa782eeb..fc2330cfc 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -27,8 +27,18 @@ export const DASHBOARD_WIDGET_IDS = [ "glucose", "totalBodyWater", "boneMass", + // v1.28.52 — muscle mass joins the body-composition strip tiles + // (totalBodyWater / boneMass). Maps 1:1 to the MUSCLE_MASS + // MeasurementType the server already stores; self-gates on data. + "muscleMass", "bpInTarget", "oxygenSaturation", + // v1.28.52 — HRV + respiratory rate graduate from the iOS-only + // catalogue to first-class web-writable widgets with a strip tile. + // Each maps 1:1 to a MeasurementType the server already surfaces via + // the summaries slice; both self-gate on having a sample. + "hrv", + "respiratoryRate", "achievements", // v1.4.25 W8d — VO2 max trend tile (secondary-metric pattern). The // strip tile is on by default and self-gates on having any VO2_MAX @@ -90,7 +100,8 @@ export const IOS_PIN_ONLY_WIDGET_IDS = [ "stairAscentSpeed", "stairDescentSpeed", "breathingDisturbances", - "wristTemperature", + // v1.28.52 — `wristTemperature` graduated to a web-rendered strip tile, + // so it is no longer pin-only (the Settings list now offers its toggle). "falls", "walkingSteadiness", ] as const satisfies readonly DashboardWidgetId[]; @@ -113,14 +124,15 @@ export const IOS_PIN_ONLY_WIDGET_IDS = [ */ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [ "restingHeartRate", - "hrv", + // v1.28.52 — `hrv` + `respiratoryRate` graduated to web-writable widgets + // (now members of DASHBOARD_WIDGET_IDS), so they leave the iOS-only set to + // avoid double-booking the catalogue. "walkingSpeed", "walkingAsymmetry", "walkingStepLength", "bmi", "bodyTemperature", "walkingDoubleSupport", - "respiratoryRate", "audioExposureEnvironment", "audioExposureHeadphone", ] as const; @@ -541,10 +553,26 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, { id: "pulse", visible: true, tileVisible: true, order: 2 }, - { id: "bodyFat", visible: true, tileVisible: true, order: 3 }, - { id: "mood", visible: true, tileVisible: true, order: 4 }, - { id: "bpInTarget", visible: true, tileVisible: true, order: 5 }, - { id: "medications", visible: true, tileVisible: true, order: 6 }, + // v1.28.52 — vitals strip cluster (HRV / SpO2 / respiratory rate / + // wrist temperature) sits next to pulse. Each strip tile is on by + // default (`tileVisible: true`) and self-gates on having a sample of + // that MeasurementType (see the `showXTile` gates in page.tsx), so an + // empty account still gets a clean dashboard. The chart row stays off + // (`visible: false`) — those charts live on the /insights sub-pages. + { id: "hrv", visible: false, tileVisible: true, order: 3 }, + { id: "oxygenSaturation", visible: false, tileVisible: true, order: 4 }, + { id: "respiratoryRate", visible: false, tileVisible: true, order: 5 }, + { id: "wristTemperature", visible: false, tileVisible: true, order: 6 }, + { id: "bodyFat", visible: true, tileVisible: true, order: 7 }, + // v1.28.52 — body-composition strip cluster (muscle mass / total body + // water / bone mass) next to body fat. Same self-gating tile contract + // as the vitals cluster above; charts live on the /insights sub-pages. + { id: "muscleMass", visible: false, tileVisible: true, order: 8 }, + { id: "totalBodyWater", visible: false, tileVisible: true, order: 9 }, + { id: "boneMass", visible: false, tileVisible: true, order: 10 }, + { id: "mood", visible: true, tileVisible: true, order: 11 }, + { id: "bpInTarget", visible: true, tileVisible: true, order: 12 }, + { id: "medications", visible: true, tileVisible: true, order: 13 }, // v1.20.0 — sleep / steps / glucose strip tiles default-on so accounts // that sync these signals discover them on / without hunting through // Settings → Dashboard. Each follows the VO2max precedent: the strip @@ -552,24 +580,16 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // (`hasSleep` / `hasSteps` / `showGlucoseCards` in page.tsx), so an // empty account still gets a clean dashboard. The chart row stays off // (`visible: false`) — those charts live on the /insights sub-pages. - { id: "sleep", visible: false, tileVisible: true, order: 7 }, - { id: "steps", visible: false, tileVisible: true, order: 8 }, - { id: "glucose", visible: false, tileVisible: true, order: 9 }, - // totalBodyWater / boneMass / oxygenSaturation carry as catalogue ids - // for the iOS layout round-trip only — they have no web render block or - // data-floor flag in page.tsx, so flipping their defaults would surface - // nothing on web. Their data IS discoverable via the /insights sub-pages. - // Kept false until a web render path lands (see .planning/v1200-backlog.md). - { id: "totalBodyWater", visible: false, tileVisible: false, order: 10 }, - { id: "boneMass", visible: false, tileVisible: false, order: 11 }, - { id: "oxygenSaturation", visible: false, tileVisible: false, order: 12 }, + { id: "sleep", visible: false, tileVisible: true, order: 14 }, + { id: "steps", visible: false, tileVisible: true, order: 15 }, + { id: "glucose", visible: false, tileVisible: true, order: 16 }, // v1.4.15 phase-B4 — recent-unlocks card (dashboard surface for the // gamification feature). Visible by default — the card is small, // self-gates on having any achievements (otherwise it shows a brief // "no achievements yet" + link), and maintainer-asked-for-it explicitly. // `tileVisible` is forced false because there is no tile surface // for this widget; only the chart-row card. - { id: "achievements", visible: true, tileVisible: false, order: 13 }, + { id: "achievements", visible: true, tileVisible: false, order: 17 }, // v1.4.25 W8d — VO2 max trend tile. The strip tile is on by default // (`tileVisible: true`) so accounts that already sync a VO2max sample // see cardio fitness without hunting through Settings → Dashboard; the @@ -577,34 +597,33 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // page.tsx), so accounts with no sample still get a clean dashboard. // The chart row stays off (`visible: false`) — VO2max charts live on // the /insights/cardio-fitness and /insights/pulse sub-pages. - { id: "vo2Max", visible: false, tileVisible: true, order: 14 }, + { id: "vo2Max", visible: false, tileVisible: true, order: 18 }, // v1.4.32 — Recent workouts dashboard tile. Default-visible so // brand-new accounts discover the workout surface without // hunting through Settings; the tile self-gates on a non-empty // workouts list and renders an Apple-Health onboarding hint // otherwise. - { id: "recentWorkouts", visible: true, tileVisible: true, order: 15 }, + { id: "recentWorkouts", visible: true, tileVisible: true, order: 19 }, // v1.11.2 B5 — v1.10 additive HealthKit signals, pinnable but // default-invisible on both surfaces. The user opts in via // Settings → Dashboard; each tile self-gates on having any sample. - { id: "cardioRecovery", visible: false, tileVisible: false, order: 16 }, - { id: "sixMinuteWalk", visible: false, tileVisible: false, order: 17 }, - { id: "stairAscentSpeed", visible: false, tileVisible: false, order: 18 }, - { id: "stairDescentSpeed", visible: false, tileVisible: false, order: 19 }, + { id: "cardioRecovery", visible: false, tileVisible: false, order: 20 }, + { id: "sixMinuteWalk", visible: false, tileVisible: false, order: 21 }, + { id: "stairAscentSpeed", visible: false, tileVisible: false, order: 22 }, + { id: "stairDescentSpeed", visible: false, tileVisible: false, order: 23 }, { id: "breathingDisturbances", visible: false, tileVisible: false, - order: 20, + order: 24, }, - { id: "wristTemperature", visible: false, tileVisible: false, order: 21 }, - { id: "falls", visible: false, tileVisible: false, order: 22 }, - { id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 }, + { id: "falls", visible: false, tileVisible: false, order: 25 }, + { id: "walkingSteadiness", visible: false, tileVisible: false, order: 26 }, // v1.18.2 — Vorsorge summary card. Chart-row only (no strip tile), so // `tileVisible` is forced false. v1.20.0 — flipped `visible: true` so // 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: 24 }, + { id: "vorsorge", visible: true, tileVisible: false, order: 27 }, ], }; diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 15be5091f..b237f2646 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -749,15 +749,17 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () => isFullyCovered.mockReturnValue(false); }); - it("emits all 36 catalogue ids with visibility + order", async () => { + it("emits all 37 catalogue ids with visibility + order", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.layoutCatalogue).toHaveLength(36); + expect(snap.layoutCatalogue).toHaveLength(37); const ids = new Set(snap.layoutCatalogue.map((w) => w.id)); - expect(ids.size).toBe(36); + expect(ids.size).toBe(37); // iOS-only ids appended default-invisible. - const hrv = snap.layoutCatalogue.find((w) => w.id === "hrv"); - expect(hrv).toBeDefined(); - expect(hrv!.visible).toBe(false); + const walkingSpeed = snap.layoutCatalogue.find( + (w) => w.id === "walkingSpeed", + ); + expect(walkingSpeed).toBeDefined(); + expect(walkingSpeed!.visible).toBe(false); for (const w of snap.layoutCatalogue) { expect(typeof w.visible).toBe("boolean"); expect(typeof w.order).toBe("number"); @@ -824,7 +826,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(25); + expect(snap.layout.widgets.length).toBeLessThanOrEqual(28); // tiles shape unchanged. expect(snap.tiles).toHaveProperty("summaries");