diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 7fbcdfcff..f7e357a5e 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -278,6 +278,11 @@ export const GET = apiHandler(async () => { * `summaries.SLEEP_DURATION` totals without a stage card in that * case. Returns the sum-per-stage AND the day count covered so the * UI can render an "averaged across N nights" caption truthfully. + * + * v1.4.25 W3f — also returns a `perNight` array: one entry per + * Berlin-tz day inside the window, with minutes-per-stage for the + * stacked-bar chart. Days with zero stage-tagged rows are omitted + * from `perNight` (the chart treats them as gaps). */ async function computeSleepStageBreakdown( userId: string, @@ -287,6 +292,7 @@ async function computeSleepStageBreakdown( nights: number; totalMinutes: number; stages: Record; + perNight: Array<{ dayKey: string; stages: Record }>; } | null> { const DAY_MS = 24 * 60 * 60 * 1000; const since = new Date(Date.now() - 30 * DAY_MS); @@ -305,18 +311,32 @@ async function computeSleepStageBreakdown( const stages: Record = {}; const dayKeys = new Set(); let totalMinutes = 0; + // v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the + // chart's 7/14/30-day slicer can build a left-aligned series. + const perNightMap = new Map>(); for (const row of rows) { if (!row.sleepStage) continue; stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value; totalMinutes += row.value; - dayKeys.add(userDayKey(row.measuredAt, userTz)); + const dayKey = userDayKey(row.measuredAt, userTz); + dayKeys.add(dayKey); + const nightStages = perNightMap.get(dayKey) ?? {}; + nightStages[row.sleepStage] = (nightStages[row.sleepStage] ?? 0) + row.value; + perNightMap.set(dayKey, nightStages); } + // Sort per-night ascending so the chart consumer can slice the + // trailing N entries for the 7d / 14d / 30d toggle. + const perNight = Array.from(perNightMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([dayKey, stages]) => ({ dayKey, stages })); + return { windowDays: 30, nights: dayKeys.size, totalMinutes, stages, + perNight, }; } diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 715fc76cd..41a70be79 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -8,14 +8,22 @@ import { } from "../sleep-stage-stacked-bar"; /** - * v1.4.25 W4c — sleep-stage composition chart unit tests. + * v1.4.25 W4c / W3f — sleep-stage stacked-bar chart unit tests. * * The chart relies on Recharts which uses `ResponsiveContainer` — * `renderToStaticMarkup` produces SSR-only HTML so we assert the - * surrounding card chrome (heading, aria-label) rather than the - * Recharts-rendered `` nodes. That keeps the tests resilient to - * Recharts version bumps while still covering the prose contract the - * sub-page depends on. + * surrounding card chrome (heading, aria-label, window toggle, empty + * state) rather than the Recharts-rendered `` nodes. That keeps + * the tests resilient to Recharts version bumps while still covering + * the prose contract the sub-page depends on. + * + * v1.4.25 W3f migrated the chart from "30-day average composition" to + * "per-night stacked bars with 7/14/30d toggle". The tests now pin: + * - 7-day default window + toggle button rendering + * - Per-night dataset slicing (perNight branch) + * - Empty-state when neither perNight nor aggregate has data + * - Graceful degradation when perNight is absent (legacy aggregate + * path stays render-safe) */ function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -31,6 +39,12 @@ describe("", () => { nights: 23, totalMinutes: 460, stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30, IN_BED: 30 }, + perNight: [ + { + dayKey: "2026-05-08", + stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30, IN_BED: 30 }, + }, + ], }; const html = render(); @@ -44,6 +58,7 @@ describe("", () => { nights: 14, totalMinutes: 100, stages: { CORE: 100 }, + perNight: [{ dayKey: "2026-05-08", stages: { CORE: 100 } }], }; const html = render(); expect(html).toMatch(/aria-label="Sleep stage composition over 14 nights"/); @@ -55,6 +70,7 @@ describe("", () => { nights: 7, totalMinutes: 200, stages: { DEEP: 200 }, + perNight: [{ dayKey: "2026-05-08", stages: { DEEP: 200 } }], }; const html = render(, "de"); expect(html).toContain("Phasen-Verteilung"); @@ -67,9 +83,97 @@ describe("", () => { nights: 3, totalMinutes: 200, stages: { DEEP: 100, UNKNOWN_STAGE: 100 }, + perNight: [ + { dayKey: "2026-05-08", stages: { DEEP: 100, UNKNOWN_STAGE: 100 } }, + ], }; expect(() => render(), ).not.toThrow(); }); + + /** + * v1.4.25 W3f — per-night surface + window toggle. + */ + describe("per-night + window toggle (v1.4.25 W3f)", () => { + it("renders three window-toggle buttons (7 / 14 / 30 days) with 7d active by default", () => { + const breakdown: SleepStageBreakdown = { + windowDays: 30, + nights: 7, + totalMinutes: 3000, + stages: { DEEP: 600, REM: 700, CORE: 1500, AWAKE: 200 }, + perNight: Array.from({ length: 7 }, (_, i) => ({ + dayKey: `2026-05-0${i + 1}`, + stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30 }, + })), + }; + const html = render(); + expect(html).toContain('data-slot="sleep-stage-window-toggle"'); + expect(html).toContain('data-slot="sleep-stage-window-7"'); + expect(html).toContain('data-slot="sleep-stage-window-14"'); + expect(html).toContain('data-slot="sleep-stage-window-30"'); + // The 7d button is default-active. + expect(html).toMatch( + /data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/, + ); + expect(html).toMatch( + /data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/, + ); + }); + + it("renders an empty-state caption when the user has no per-night data", () => { + const breakdown: SleepStageBreakdown = { + windowDays: 30, + nights: 0, + totalMinutes: 0, + stages: {}, + perNight: [], + }; + const html = render(); + expect(html).toContain('data-slot="sleep-stage-empty"'); + // No img-role wrapper when the chart is in empty state. + expect(html).not.toMatch(/role="img"\s+aria-label="Sleep stage/); + }); + + it("degrades gracefully when perNight is absent (legacy payload)", () => { + // Pre-W3f payloads don't carry perNight. The chart should still + // render the aggregate as a single bar so the user isn't left + // staring at an empty card during the rollout. + const breakdown: SleepStageBreakdown = { + windowDays: 30, + nights: 21, + totalMinutes: 9000, + stages: { DEEP: 1500, REM: 2000, CORE: 5000, AWAKE: 500 }, + }; + const html = render(); + // No empty-state surface because the aggregate still has data. + expect(html).not.toContain('data-slot="sleep-stage-empty"'); + // The chart card still mounts. + expect(html).toContain('data-slot="sleep-stage-stacked-bar"'); + }); + + it("handles missing-some-nights gracefully (sparse perNight series)", () => { + // The 7-day window should render even if perNight has gaps — + // each entry stands alone. + const breakdown: SleepStageBreakdown = { + windowDays: 30, + nights: 4, + totalMinutes: 2000, + stages: { DEEP: 400, REM: 500, CORE: 1000, AWAKE: 100 }, + perNight: [ + { dayKey: "2026-05-02", stages: { DEEP: 100, REM: 150, CORE: 300 } }, + { dayKey: "2026-05-04", stages: { DEEP: 100, REM: 150, CORE: 200 } }, + { dayKey: "2026-05-06", stages: { DEEP: 100, REM: 100, CORE: 200 } }, + { dayKey: "2026-05-08", stages: { DEEP: 100, REM: 100, CORE: 300 } }, + ], + }; + expect(() => + render(), + ).not.toThrow(); + const html = render(); + // Chart still mounts + window toggle present. + expect(html).toContain('data-slot="sleep-stage-stacked-bar"'); + expect(html).toContain('data-slot="sleep-stage-window-toggle"'); + }); + }); }); diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 00b271f85..8f0b67efe 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useMemo } from "react"; import { Bar, BarChart, @@ -12,32 +13,36 @@ import { YAxis, } from "recharts"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useTranslations } from "@/lib/i18n/context"; /** - * v1.4.25 W4c — sleep-stage composition chart. + * v1.4.25 W4c → W3f — sleep-stage composition chart. * - * Renders the trailing 30-day stage distribution as a horizontal - * stacked bar (REM / Deep / Core / Awake / In-bed / Asleep). The - * underlying data comes from `/api/analytics`'s `sleepStages` payload - * (added in v1.4.23 W2) which exposes the per-stage minute totals plus - * the number of nights covered, so the legend can read "averaged - * across N nights". + * Marc directive 2026-05-14: switch from "30-day average composition" + * to a per-night stacked column chart so the user sees nightly stage + * variation, not just a rolling average. Apple Health's sleep tab is + * the visual reference — one column per night, stacks for REM / Deep / + * Core / Awake. * - * Why composition over per-night time series? The current - * `/api/analytics` aggregate only carries totals; a per-night stacked - * column chart would need a separate endpoint. The "average night" - * read is the dominant question users ask of stage data — see - * Apple Health's own sleep tab, which shows the same composition - * column above the trend chart. v1.4.26 can add the per-night surface - * if Marc wants it. + * Window toggle: 7 / 14 / 30 days, default 7. The toggle pill above + * the chart matches the per-chart cog pattern in the rest of the app. + * Backed by `/api/analytics`'s `sleepStages.perNight` field (added in + * v1.4.25 W3f); the parent threads the full per-night array and the + * chart slices it down to the active window. * * Accessibility: the Recharts wrapper sets `role="img"` and an * `aria-label` derived from the composition so screen readers hear a * meaningful summary instead of a forest of `` elements. */ +export interface SleepStageNight { + /** Berlin-tz day key (YYYY-MM-DD). */ + dayKey: string; + stages: Record; +} + export interface SleepStageBreakdown { windowDays: number; nights: number; @@ -48,12 +53,22 @@ export interface SleepStageBreakdown { * Values are total minutes across the trailing-30 window. */ stages: Record; + /** + * v1.4.25 W3f — per-night breakdown over the trailing 30 days, + * sorted ascending. The chart slices the trailing N entries based on + * the active window toggle. + */ + perNight?: SleepStageNight[]; } export interface SleepStageStackedBarProps { breakdown: SleepStageBreakdown; } +/** Window toggle values. Default 7. */ +const WINDOW_DAYS = [7, 14, 30] as const; +type WindowSize = (typeof WINDOW_DAYS)[number]; + /** * Order on the stack — deepest restorative stages first so a user * scanning left → right reads quality before context. @@ -85,23 +100,44 @@ function formatMinutes(total: number, locale: string): string { return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; } +/** + * Format a Berlin-tz day key (YYYY-MM-DD) as a short x-axis tick. + * 7-day window → "Mon" / "Tue" / …; 14-day → "M 10" / "T 11" / …; + * 30-day → "May 10" / "May 11" / … Recharts handles overflow via + * interval=preserveStartEnd so we keep the label space tight without + * forced rotation. + */ +function formatDayTick( + dayKey: string, + window: WindowSize, + locale: string, +): string { + const [y, m, d] = dayKey.split("-").map(Number); + if (!y || !m || !d) return dayKey; + const date = new Date(y, m - 1, d); + if (window === 7) { + return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", { + weekday: "short", + }); + } + if (window === 14) { + return `${date.getDate()}.`; + } + return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", { + month: "short", + day: "numeric", + }); +} + export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const { t, locale } = useTranslations(); - const total = breakdown.totalMinutes || 1; // avoid divide-by-zero - // Build a single-row dataset Recharts can stack on. Each stage is its - // own series so the user can hover one stage at a time. - const row: Record = { - name: t("insights.sleep.compositionTitle"), - }; - for (const stage of STAGE_ORDER) { - row[stage] = breakdown.stages[stage] ?? 0; - } - const data = [row]; + + // v1.4.25 W3f — window toggle (7 / 14 / 30). Default 7d so the user + // sees their most recent week with maximal per-bar resolution. + const [windowDays, setWindowDays] = useState(7); // Pull stage names from i18n once so the legend + tooltip share a - // single source of truth. Missing keys fall back to the enum value - // — covers `ASLEEP`/`IN_BED` which we always want to show even when - // no localization exists yet. + // single source of truth. const stageLabels: Record = { DEEP: t("insights.sleep.stages.deep"), REM: t("insights.sleep.stages.rem"), @@ -111,99 +147,195 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { IN_BED: t("insights.sleep.stages.inBed"), }; + // v1.4.25 W3f — per-night dataset. Slice the trailing N nights and + // build a Recharts row per night with one numeric key per stage. + // Empty perNight (legacy clients during the rollout) falls back to + // the aggregate row so the chart still renders something rather + // than going blank. + const data = useMemo(() => { + const perNight = breakdown.perNight ?? []; + if (perNight.length === 0) { + // Legacy fallback: render the 30-day aggregate as a single row + // so the chart degrades gracefully against a pre-W3f payload. + const fallbackRow: Record = { + dayKey: "aggregate", + label: t("insights.sleep.compositionTitle"), + }; + for (const stage of STAGE_ORDER) { + fallbackRow[stage] = breakdown.stages[stage] ?? 0; + } + return [fallbackRow]; + } + const trailing = perNight.slice(-windowDays); + return trailing.map((night) => { + const row: Record = { + dayKey: night.dayKey, + label: formatDayTick(night.dayKey, windowDays, locale), + }; + for (const stage of STAGE_ORDER) { + row[stage] = night.stages[stage] ?? 0; + } + return row; + }); + }, [breakdown, windowDays, locale, t]); + + // Empty-state guard — no perNight rows AND no aggregate. + const hasData = + data.length > 0 && + data.some((row) => + STAGE_ORDER.some( + (stage) => typeof row[stage] === "number" && (row[stage] as number) > 0, + ), + ); + const ariaLabel = t("insights.sleep.compositionAriaLabel", { nights: breakdown.nights, }); return ( - + -
- - {t("insights.sleep.compositionTitle")} - - - {t("insights.sleep.compositionSubtitle", { - nights: breakdown.nights, - })} - +
+
+ + {t("insights.sleep.compositionTitle")} + + + {t("insights.sleep.compositionSubtitle", { + nights: breakdown.nights, + })} + +
+
+ {WINDOW_DAYS.map((w) => ( + + ))} +
-
- - - - `${Math.round(v * 100)}%`} - stroke="hsl(var(--muted-foreground))" - fontSize={11} - /> - - { - if (!active || !payload || payload.length === 0) return null; - return ( -
- {payload.map((entry) => { - const stage = String(entry.dataKey ?? ""); - const minutes = - typeof entry.value === "number" ? entry.value : 0; - if (minutes === 0) return null; - const pct = Math.round((minutes / total) * 100); - return ( -
- - - - {formatMinutes(minutes, locale)} · {pct}% - + {!hasData ? ( +

+ {t("insights.sleep.stages.unavailable")} +

+ ) : ( +
+ + + + + { + // Render the y-axis as hours so 480 min reads as 8h. + if (v <= 0) return "0"; + return `${Math.round(v / 60)}h`; + }} + /> + { + if (!active || !payload || payload.length === 0) return null; + const totalNight = payload.reduce( + (sum, entry) => + sum + + (typeof entry.value === "number" ? entry.value : 0), + 0, + ); + return ( +
+
+ {label} +
+ {payload.map((entry) => { + const stage = String(entry.dataKey ?? ""); + const minutes = + typeof entry.value === "number" ? entry.value : 0; + if (minutes === 0) return null; + const pct = + totalNight > 0 + ? Math.round((minutes / totalNight) * 100) + : 0; + return ( +
+ + + + {formatMinutes(minutes, locale)} · {pct}% + +
+ ); + })} + {totalNight > 0 && ( +
+ {t("insights.sleep.headlineTitle")} + {formatMinutes(totalNight, locale)}
- ); - })} -
- ); - }} - /> - stageLabels[value] ?? value} - /> - {STAGE_ORDER.map((stage) => ( - - - - ))} -
-
-
+ )} +
+ ); + }} + /> + stageLabels[value] ?? value} + /> + {STAGE_ORDER.map((stage) => ( + + + + ))} + + +
+ )} ); diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index b5e8d3ad7..71915e903 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -69,6 +69,38 @@ describe("MEASURE_TYPE_MAP", () => { it("maps Withings meastype 123 (VO2 max) → VO2_MAX", () => { expect(MEASURE_TYPE_MAP[123]).toEqual({ type: "VO2_MAX" }); }); + + // ── v1.4.25 W5d Withings full coverage ── + it("maps Withings meastype 5 (fat free mass) → FAT_FREE_MASS", () => { + expect(MEASURE_TYPE_MAP[5]).toEqual({ type: "FAT_FREE_MASS" }); + }); + + it("maps Withings meastype 8 (fat mass) → FAT_MASS", () => { + expect(MEASURE_TYPE_MAP[8]).toEqual({ type: "FAT_MASS" }); + }); + + it("maps Withings meastype 76 (muscle mass) → MUSCLE_MASS", () => { + expect(MEASURE_TYPE_MAP[76]).toEqual({ type: "MUSCLE_MASS" }); + }); + + it("maps Withings meastype 73 (skin temperature) → SKIN_TEMPERATURE (distinct from BODY_TEMPERATURE)", () => { + expect(MEASURE_TYPE_MAP[73]).toEqual({ type: "SKIN_TEMPERATURE" }); + // Sanity guard — sharing the BODY_TEMPERATURE bucket would corrupt + // the rollup (surface temps 30–34 °C, core ~37 °C). + expect(MEASURE_TYPE_MAP[73].type).not.toBe("BODY_TEMPERATURE"); + }); + + it("maps Withings meastype 91 (pulse wave velocity) → PULSE_WAVE_VELOCITY", () => { + expect(MEASURE_TYPE_MAP[91]).toEqual({ type: "PULSE_WAVE_VELOCITY" }); + }); + + it("maps Withings meastype 155 (vascular age) → VASCULAR_AGE", () => { + expect(MEASURE_TYPE_MAP[155]).toEqual({ type: "VASCULAR_AGE" }); + }); + + it("maps Withings meastype 170 (visceral fat) → VISCERAL_FAT", () => { + expect(MEASURE_TYPE_MAP[170]).toEqual({ type: "VISCERAL_FAT" }); + }); }); describe("WITHINGS_NOTIFY_APPLIS", () => { @@ -166,3 +198,83 @@ describe("fetchMeasurements — temperature (meastype 12)", () => { expect(out[0].value).toBe(36.83); }); }); + +// ── v1.4.25 W5d Withings full coverage ── +describe("fetchMeasurements — body composition expansion", () => { + it("decodes a Body+ fat-free mass reading into FAT_FREE_MASS kg", async () => { + // 65.4 kg of lean body mass. value=65400, unit=-3. + installFetchMock(fakeGetmeasPayload([{ type: 5, value: 65400, unit: -3 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "FAT_FREE_MASS", value: 65.4 }); + }); + + it("decodes a Body+ fat-mass reading into FAT_MASS kg", async () => { + // 14.2 kg of fat. value=14200, unit=-3. + installFetchMock(fakeGetmeasPayload([{ type: 8, value: 14200, unit: -3 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "FAT_MASS", value: 14.2 }); + }); + + it("decodes a Body+ muscle-mass reading into MUSCLE_MASS kg", async () => { + // 58.7 kg of muscle. value=58700, unit=-3. + installFetchMock(fakeGetmeasPayload([{ type: 76, value: 58700, unit: -3 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "MUSCLE_MASS", value: 58.7 }); + }); + + it("decodes a Body Comp visceral-fat rating into VISCERAL_FAT", async () => { + // Withings reports the 1–12 rating directly. value=7, unit=0. + installFetchMock(fakeGetmeasPayload([{ type: 170, value: 7, unit: 0 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "VISCERAL_FAT", value: 7 }); + }); +}); + +describe("fetchMeasurements — ScanWatch skin temperature (meastype 73)", () => { + it("decodes a ScanWatch skin-temp reading into SKIN_TEMPERATURE °C", async () => { + // 32.5 °C surface temp. value=3250, unit=-2. + installFetchMock(fakeGetmeasPayload([{ type: 73, value: 3250, unit: -2 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "SKIN_TEMPERATURE", value: 32.5 }); + }); + + it("co-exists with core BODY_TEMPERATURE (meastype 71) in the same payload", async () => { + // A user with a Thermo + ScanWatch could have both readings on the + // same day. The rollup MUST keep them in distinct enums so the + // dashboard's "body temperature" chart doesn't paint a 32 °C dip. + installFetchMock( + fakeGetmeasPayload([ + { type: 73, value: 3210, unit: -2 }, + { type: 71, value: 3705, unit: -2 }, + ]), + ); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(2); + const types = new Set(out.map((r) => r.type)); + expect(types.has("SKIN_TEMPERATURE")).toBe(true); + expect(types.has("BODY_TEMPERATURE")).toBe(true); + }); +}); + +describe("fetchMeasurements — pulse-wave velocity + vascular age", () => { + it("decodes a Body Cardio PWV reading into PULSE_WAVE_VELOCITY m/s", async () => { + // 7.2 m/s arterial pulse wave velocity. value=72, unit=-1. + installFetchMock(fakeGetmeasPayload([{ type: 91, value: 72, unit: -1 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "PULSE_WAVE_VELOCITY", value: 7.2 }); + }); + + it("decodes a Body Scan vascular-age reading into VASCULAR_AGE years", async () => { + // 42 years biological vascular age. value=42, unit=0. + installFetchMock(fakeGetmeasPayload([{ type: 155, value: 42, unit: 0 }])); + const out = await fetchMeasurements("token"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ type: "VASCULAR_AGE", value: 42 }); + }); +}); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index dc9ab3baf..0a2c3e030 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -145,6 +145,23 @@ export const MEASURE_TYPE_MAP: Record // v1.4.25 — VO2 max from the ScanWatch family. Withings reports // mL/(kg·min) directly, matching the canonical DB unit. 123: { type: "VO2_MAX" }, + // ── v1.4.25 W5d — Withings full coverage ── + // Body composition expansion (Body+, Body Cardio, Body Comp, Body + // Scan ship these values on every measurement). All canonical kg. + 5: { type: "FAT_FREE_MASS" }, // Fat-free mass + 8: { type: "FAT_MASS" }, // Fat mass (kg form of BODY_FAT) + 76: { type: "MUSCLE_MASS" }, // Muscle mass + // Skin temperature (ScanWatch dermal sensor). Distinct from + // BODY_TEMPERATURE — surface temps ~32 °C, core ~37 °C. Same °C + // unit; the SKIN_TEMPERATURE enum value keeps the rollup honest. + 73: { type: "SKIN_TEMPERATURE" }, + // Pulse-wave velocity m/s — Body Cardio / Body Scan exclusive. + 91: { type: "PULSE_WAVE_VELOCITY" }, + // Vascular age in years — Body Scan composite of PWV + age. + 155: { type: "VASCULAR_AGE" }, + // Visceral fat rating (Withings 1–12 scale). Stored under the + // canonical VISCERAL_FAT enum with `rating` as the unit string. + 170: { type: "VISCERAL_FAT" }, }; export interface WithingsMeasure { diff --git a/src/lib/withings/mapping.md b/src/lib/withings/mapping.md index 6149f56bd..478df565c 100644 --- a/src/lib/withings/mapping.md +++ b/src/lib/withings/mapping.md @@ -14,7 +14,9 @@ Audit + scoring of the full surface lives in | meastype | Withings name | DB enum | DB unit | Source devices | |---|---|---|---|---| | 1 | Weight | `WEIGHT` | kg | every Body / Body+ / Body Cardio / Body Comp / Body Scan | +| 5 | Fat Free Mass | `FAT_FREE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | | 6 | Fat Ratio | `BODY_FAT` | % | Body+ family | +| 8 | Fat Mass Weight | `FAT_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | | 9 | Diastolic BP | `BLOOD_PRESSURE_DIA` | mmHg | BPM Connect / Core / Vision | | 10 | Systolic BP | `BLOOD_PRESSURE_SYS` | mmHg | BPM Connect / Core / Vision | | 11 | Heart Pulse | `PULSE` | bpm | BPM cuffs + scales (standing HR) | @@ -22,9 +24,14 @@ Audit + scoring of the full surface lives in | 35 | SpO2 (alt code) | `OXYGEN_SATURATION` | % | older firmware reports 35 instead of 54 | | 54 | SpO2 | `OXYGEN_SATURATION` | % | ScanWatch / BPM Vision | | 71 | Body Temperature (core) | `BODY_TEMPERATURE` | celsius | current-gen Thermo | +| 73 | Skin Temperature | `SKIN_TEMPERATURE` | celsius | ScanWatch dermal sensor. Distinct from `BODY_TEMPERATURE` — surface temps run ~32 °C, core ~37 °C; sharing the bucket would corrupt analytics. | +| 76 | Muscle Mass | `MUSCLE_MASS` | kg | Body+ / Body Cardio / Body Comp / Body Scan | | 77 | Hydration / Water Mass | `TOTAL_BODY_WATER` | kg | Body Comp / Body Scan | | 88 | Bone Mass | `BONE_MASS` | kg | Body Comp / Body Scan | +| 91 | Pulse Wave Velocity | `PULSE_WAVE_VELOCITY` | m/s | Body Cardio / Body Scan exclusive | | 123 | VO2 max | `VO2_MAX` | mL/(kg·min) | ScanWatch family | +| 155 | Vascular Age | `VASCULAR_AGE` | years | Body Scan; composite of PWV + chronological age | +| 170 | Visceral Fat | `VISCERAL_FAT` | rating | Body Comp / Body Scan; Withings' 1–12 scale (not a percent) | ### Unit handling @@ -49,25 +56,19 @@ routines in v1.4.26. ## Deferred — v1.4.26 -| meastype | Withings name | Why deferred | -|---|---|---| -| 5 | Fat Free Mass | needs new `FAT_FREE_MASS` enum | -| 8 | Fat Mass Weight | needs new `FAT_MASS` enum | -| 73 | Skin Temperature | distinct physiology from core; needs `SKIN_TEMPERATURE` enum | -| 76 | Muscle Mass | needs new `MUSCLE_MASS` enum | -| 91 | Pulse Wave Velocity | Body Cardio / Body Scan exclusive; new enum | -| 155 | Vascular Age | new enum, composite-derived years | -| 170 | Visceral Fat | new enum, rating | - -Plus the new sync routines: +The body-composition expansion (meastypes 5, 8, 73, 76, 91, 155, 170) +landed in **v1.4.25 W5d** — see migration `0049_withings_full_metrics`. +Deferred items now: - **Sleep** (`POST /v2/sleep?action=getsummary`) → `SLEEP_DURATION`, `HEART_RATE_VARIABILITY` (sdnn_1), per-stage rows via existing `SleepStage` enum. - **Activity** (`POST /v2/measure?action=getactivity`) → `ACTIVITY_STEPS`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`, `ACTIVE_ENERGY_BURNED`. - **Requires OAuth scope upgrade** to `users.activity` — every existing - Withings connection has to reconnect once. + **Requires OAuth scope upgrade** to `user.activity` — every existing + Withings connection has to reconnect once. v1.4.25 W5d ships the + scope-upgrade plumbing + reconnect banner; the activity sync routine + itself lands in v1.4.26. ## Deferred — v1.5 (with iOS app)