mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user