mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
e883b64b1f
commit
7c45c876a0
12 files changed
+389
-58
No files matched your search
@@ -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,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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user