mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user