feat(dashboard): tiles for HRV, blood oxygen, breathing rate, temperature, and body composition

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 15:21:03 +02:00
1 parent e883b64b1f
commit 7c45c876a0
12 files changed
+389 -58

No files matched your search

+79 -15
View File
@@ -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<string>(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<string>(DASHBOARD_IOS_ONLY_WIDGET_IDS);
const pinOnly = new Set<string>(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);
}
+49 -30
View File
@@ -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 },
],
};
+9 -7
View File
@@ -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");