mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
c50931b4e7
commit
7846d68dd5
6 files changed
+515
-129
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user