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

+13
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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", () => {
+215
View File
@@ -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<string, string> = {
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: (
<TrendCard
key="hrv"
label={t("measurements.typeHeartRateVariability")}
latest={hrvSummary?.latest ?? null}
unit="ms"
avg7={hrvSummary?.avg7 ?? null}
avg30={hrvSummary?.avg30 ?? null}
slope30={hrvSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(hrvSummary)}
icon={HeartPulse}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(hrvSummary)}
staleDays={tileStaleDays("HEART_RATE_VARIABILITY")}
/>
),
});
}
if (showSpo2Tile) {
trendCards.push({
id: "oxygenSaturation",
order: widgetOrder("oxygenSaturation"),
node: (
<TrendCard
key="oxygenSaturation"
label={t("measurements.typeOxygenSaturation")}
latest={spo2Summary?.latest ?? null}
unit="%"
avg7={spo2Summary?.avg7 ?? null}
avg30={spo2Summary?.avg30 ?? null}
slope30={spo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(spo2Summary)}
icon={Droplets}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(spo2Summary)}
staleDays={tileStaleDays("OXYGEN_SATURATION")}
/>
),
});
}
if (showRespRateTile) {
trendCards.push({
id: "respiratoryRate",
order: widgetOrder("respiratoryRate"),
node: (
<TrendCard
key="respiratoryRate"
label={t("measurements.typeRespiratoryRate")}
latest={respRateSummary?.latest ?? null}
unit="breaths/min"
avg7={respRateSummary?.avg7 ?? null}
avg30={respRateSummary?.avg30 ?? null}
slope30={respRateSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(respRateSummary)}
icon={Wind}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(respRateSummary)}
staleDays={tileStaleDays("RESPIRATORY_RATE")}
/>
),
});
}
if (showWristTempTile) {
trendCards.push({
id: "wristTemperature",
order: widgetOrder("wristTemperature"),
node: (
<TrendCard
key="wristTemperature"
label={t("measurements.typeWristTemperature")}
latest={wristTempSummary?.latest ?? null}
unit="°C"
avg7={wristTempSummary?.avg7 ?? null}
avg30={wristTempSummary?.avg30 ?? null}
slope30={wristTempSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(wristTempSummary)}
icon={Thermometer}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(wristTempSummary)}
staleDays={tileStaleDays("WRIST_TEMPERATURE")}
/>
),
});
}
// 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: (
<TrendCard
key="muscleMass"
label={t("measurements.typeMuscleMass")}
latest={muscleMassSummary?.latest ?? null}
unit="kg"
avg7={muscleMassSummary?.avg7 ?? null}
avg30={muscleMassSummary?.avg30 ?? null}
slope30={muscleMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(muscleMassSummary)}
icon={Dumbbell}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(muscleMassSummary)}
staleDays={tileStaleDays("MUSCLE_MASS")}
/>
),
});
}
if (showBodyWaterTile) {
trendCards.push({
id: "totalBodyWater",
order: widgetOrder("totalBodyWater"),
node: (
<TrendCard
key="totalBodyWater"
label={t("measurements.typeTotalBodyWater")}
latest={bodyWaterSummary?.latest ?? null}
unit="kg"
avg7={bodyWaterSummary?.avg7 ?? null}
avg30={bodyWaterSummary?.avg30 ?? null}
slope30={bodyWaterSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(bodyWaterSummary)}
icon={Droplet}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bodyWaterSummary)}
staleDays={tileStaleDays("TOTAL_BODY_WATER")}
/>
),
});
}
if (showBoneMassTile) {
trendCards.push({
id: "boneMass",
order: widgetOrder("boneMass"),
node: (
<TrendCard
key="boneMass"
label={t("measurements.typeBoneMass")}
latest={boneMassSummary?.latest ?? null}
unit="kg"
avg7={boneMassSummary?.avg7 ?? null}
avg30={boneMassSummary?.avg30 ?? null}
slope30={boneMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(boneMassSummary)}
icon={Bone}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(boneMassSummary)}
staleDays={tileStaleDays("BONE_MASS")}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
@@ -55,6 +55,15 @@ const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
"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",
]);
/**
@@ -383,7 +383,8 @@ describe("<DashboardLayoutSection> — 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",
};
@@ -138,8 +138,13 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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",
+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");