feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle)

Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).

Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
  builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
  days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
  the visualisation. Default window = 7d; 14d + 30d toggles in the
  card header (same min-h-11 touch targets as the dashboard range
  tabs). Y-axis renders hours, tooltips show per-stage minutes +
  percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
  screens, sit inline on sm+. Bars use Recharts vertical layout
  (per the directive — no horizontal reflow on mobile, just tighter
  XAxis labels via preserveStartEnd).

i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.

Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 14:08:56 +02:00
1 parent c50931b4e7
commit 7846d68dd5
6 files changed
+515 -129

No files matched your search

+21 -1
View File
@@ -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<string, number>;
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
} | 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<string, number> = {};
const dayKeys = new Set<string>();
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<string, Record<string, number>>();
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,
};
}
@@ -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 `<rect>` 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 `<rect>` 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("<SleepStageStackedBar>", () => {
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(<SleepStageStackedBar breakdown={breakdown} />);
@@ -44,6 +58,7 @@ describe("<SleepStageStackedBar>", () => {
nights: 14,
totalMinutes: 100,
stages: { CORE: 100 },
perNight: [{ dayKey: "2026-05-08", stages: { CORE: 100 } }],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toMatch(/aria-label="Sleep stage composition over 14 nights"/);
@@ -55,6 +70,7 @@ describe("<SleepStageStackedBar>", () => {
nights: 7,
totalMinutes: 200,
stages: { DEEP: 200 },
perNight: [{ dayKey: "2026-05-08", stages: { DEEP: 200 } }],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />, "de");
expect(html).toContain("Phasen-Verteilung");
@@ -67,9 +83,97 @@ describe("<SleepStageStackedBar>", () => {
nights: 3,
totalMinutes: 200,
stages: { DEEP: 100, UNKNOWN_STAGE: 100 },
perNight: [
{ dayKey: "2026-05-08", stages: { DEEP: 100, UNKNOWN_STAGE: 100 } },
],
};
expect(() =>
render(<SleepStageStackedBar breakdown={breakdown} />),
).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(<SleepStageStackedBar breakdown={breakdown} />);
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(<SleepStageStackedBar breakdown={breakdown} />);
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(<SleepStageStackedBar breakdown={breakdown} />);
// 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(<SleepStageStackedBar breakdown={breakdown} />),
).not.toThrow();
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
// Chart still mounts + window toggle present.
expect(html).toContain('data-slot="sleep-stage-stacked-bar"');
expect(html).toContain('data-slot="sleep-stage-window-toggle"');
});
});
});
@@ -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 `<rect>` elements.
*/
export interface SleepStageNight {
/** Berlin-tz day key (YYYY-MM-DD). */
dayKey: string;
stages: Record<string, number>;
}
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<string, number>;
/**
* 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<string, number | string> = {
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<WindowSize>(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<string, string> = {
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<string, number | string> = {
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<string, number | string> = {
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 (
<Card>
<Card data-slot="sleep-stage-stacked-bar">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
})}
</span>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
})}
</span>
</div>
<div
className="flex items-center gap-1 self-end sm:self-auto"
data-slot="sleep-stage-window-toggle"
>
{WINDOW_DAYS.map((w) => (
<Button
key={w}
type="button"
variant={windowDays === w ? "default" : "ghost"}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setWindowDays(w)}
aria-pressed={windowDays === w}
data-slot={`sleep-stage-window-${w}`}
>
{w}d
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={120}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 8, right: 16, left: 16, bottom: 8 }}
stackOffset="expand"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
type="number"
domain={[0, 1]}
tickFormatter={(v: number) => `${Math.round(v * 100)}%`}
stroke="hsl(var(--muted-foreground))"
fontSize={11}
/>
<YAxis type="category" dataKey="name" hide />
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
{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 (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{stageLabels[stage] ?? stage}
</span>
<span className="text-muted-foreground">
{formatMinutes(minutes, locale)} · {pct}%
</span>
{!hasData ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
data-slot="sleep-stage-empty"
>
{t("insights.sleep.stages.unavailable")}
</p>
) : (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={data}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
type="category"
dataKey="label"
stroke="hsl(var(--muted-foreground))"
fontSize={10}
interval="preserveStartEnd"
/>
<YAxis
type="number"
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickFormatter={(v: number) => {
// Render the y-axis as hours so 480 min reads as 8h.
if (v <= 0) return "0";
return `${Math.round(v / 60)}h`;
}}
/>
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0) return null;
const totalNight = payload.reduce(
(sum, entry) =>
sum +
(typeof entry.value === "number" ? entry.value : 0),
0,
);
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
{label}
</div>
{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 (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{stageLabels[stage] ?? stage}
</span>
<span className="text-muted-foreground">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
);
})}
{totalNight > 0 && (
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
<span>{t("insights.sleep.headlineTitle")}</span>
<span>{formatMinutes(totalNight, locale)}</span>
</div>
);
})}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="sleep"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="stages"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
+112
View File
@@ -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 });
});
});
+17
View File
@@ -145,6 +145,23 @@ export const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }>
// 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 {
+14 -13
View File
@@ -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)