chore(charts): drop two unused recharts chart components

sleep-hypnogram.tsx and compliance-line-chart.tsx are not imported
anywhere; a future import would mint a second recharts chunk and blow
the bundle budget. Delete both plus the dedicated hypnogram test file,
and drop the now-missing filename from the touch-action guard's file
list.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 00:37:24 +02:00
1 parent 1417137162
commit 95ab0514b9
4 files changed
-779

No files matched your search

@@ -24,7 +24,6 @@ const CHART_FILES = [
"health-chart.tsx",
"mood-chart.tsx",
"medication-compliance-chart.tsx",
"compliance-line-chart.tsx",
"scatter-correlation-chart.tsx",
];
@@ -1,169 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
} from "recharts";
import { Button } from "@/components/ui/button";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
interface DailyData {
expected: number;
taken: number;
skipped: number;
onTime?: number;
late?: number;
veryLate?: number;
}
interface ComplianceLineChartProps {
dailyCompliance: Record<string, DailyData>;
rangePoints?: 30 | 90 | 0;
onRangePointsChange?: (value: 30 | 90 | 0) => void;
showRangeControls?: boolean;
/**
* v1.4.25 W7b — per-user display timezone for the X-axis labels.
* Defaults to "Europe/Berlin" so older callers keep their previous
* rendering bit-for-bit.
*/
userTimezone?: string;
}
const TIME_RANGES = [
{ label: "30M", points: 30, title: "Die dreissig letzten Messpunkte" },
{ label: "90M", points: 90, title: "Die neunzig letzten Messpunkte" },
{ label: "Alle", points: 0, title: "Alle verfuegbaren Messpunkte" },
] as const;
export function ComplianceLineChart({
dailyCompliance,
rangePoints,
onRangePointsChange,
showRangeControls = true,
userTimezone = "Europe/Berlin",
}: ComplianceLineChartProps) {
const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>(
30,
);
const activeRangePoints = rangePoints ?? internalRangePoints;
const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints;
const viewportWidth = useViewportWidth();
const { locale } = useTranslations();
// v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to
// legacy Europe/Berlin when the prop is omitted.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
const chartData = useMemo(() => {
const points = Object.entries(dailyCompliance)
.filter(([, data]) => data.expected > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, data]) => {
const date = new Date(`${dateKey}T12:00:00.000Z`);
return {
date: tzFmt.date(date),
rate: Math.min(100, Math.round((data.taken / data.expected) * 100)),
timestamp: date.getTime(),
};
});
return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points;
}, [dailyCompliance, activeRangePoints, tzFmt]);
return (
<div>
{showRangeControls ? (
<div className="mb-3 flex justify-end gap-1">
{TIME_RANGES.map((r) => (
<Button
key={r.label}
variant={activeRangePoints === r.points ? "default" : "ghost"}
aria-pressed={activeRangePoints === r.points}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setActiveRangePoints(r.points)}
title={r.title}
>
{r.label}
</Button>
))}
</div>
) : null}
{chartData.length === 0 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center rounded-lg border border-dashed text-sm">
Keine Daten im gewählten Zeitraum
</div>
) : (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit="%"
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
formatter={(value) => [`${value}%`, "Compliance"]}
/>
<ReferenceLine
y={80}
stroke="var(--dracula-green)"
strokeDasharray="5 5"
strokeOpacity={0.7}
label={{
value: "Ziel 80%",
position: "right",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
<Line
type="monotone"
dataKey="rate"
name="Compliance"
stroke="var(--dracula-purple)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--dracula-purple)" }}
activeDot={{ r: 4 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -1,192 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram";
/**
* v1.16.8 — hypnogram polish: the night's measuring source rides the
* header as a muted caption, and the depth lanes derive from the stages
* actually present in the night's spans (in `LANE_ORDER` order) instead
* of always painting all six — an Apple night (AWAKE/REM/CORE/DEEP)
* used to carry two permanently empty rows.
*
* Project convention: SSR-only tests. Recharts' `ResponsiveContainer`
* paints nothing without measured dimensions, so the lane derivation is
* pinned structurally against the source.
*/
const baseSession: SleepHypnogramSession = {
night: "2026-06-09",
source: "Apple Health",
start: "2026-06-08T22:30:00.000Z",
end: "2026-06-09T06:00:00.000Z",
asleepMinutes: 420,
inBedMinutes: 450,
awakeMinutes: 30,
awakenings: 2,
stages: { DEEP: 90, REM: 100, CORE: 230, AWAKE: 30 },
segments: [
{
stage: "AWAKE",
start: "2026-06-08T22:30:00.000Z",
end: "2026-06-08T22:45:00.000Z",
minutes: 15,
},
{
stage: "CORE",
start: "2026-06-08T22:45:00.000Z",
end: "2026-06-09T01:00:00.000Z",
minutes: 135,
},
{
stage: "DEEP",
start: "2026-06-09T01:00:00.000Z",
end: "2026-06-09T02:30:00.000Z",
minutes: 90,
},
],
};
function render(session: SleepHypnogramSession, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<SleepHypnogram session={session} />
</I18nProvider>,
);
}
describe("<SleepHypnogram> — source caption", () => {
it("surfaces the night's measuring source as a muted header caption", () => {
const html = render(baseSession);
expect(html).toContain('data-slot="sleep-hypnogram-source"');
expect(html).toContain("Source: Apple Health");
});
it("localises the caption label", () => {
const html = render(baseSession, "de");
expect(html).toContain("Quelle: Apple Health");
});
it("omits the caption when the session carries no source", () => {
const html = render({ ...baseSession, source: null });
expect(html).not.toContain('data-slot="sleep-hypnogram-source"');
});
});
describe("<SleepHypnogram> — lane derivation", () => {
it("derives the Y lanes from the stages present in the spans", () => {
const src = readFileSync(
join(process.cwd(), "src/components/insights/sleep-hypnogram.tsx"),
"utf8",
);
// Lanes come from the present stages, preserving LANE_ORDER…
expect(src).toContain("LANE_ORDER.filter((stage) => present.has(stage))");
// …and the axis reads the derived lane list, not the full constant.
expect(src).toContain("domain={[-0.5, lanes.length - 0.5]}");
expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}");
});
});
describe("<SleepHypnogram> — timeline bar visibility", () => {
const sharedEnd = "2026-06-09T06:00:00.000Z";
it("draws the timeline bar for a multi-stage night whose segments share a right edge", () => {
// Distinct STARTS but a shared END instant — the shape a summary-derived
// writer reconstructs to. The earlier end-instant guard hid the bar here;
// gating on the start instant restores it. (Regression: bar vanished,
// leaving only the breakdown numbers.)
const session: SleepHypnogramSession = {
...baseSession,
segments: [
{
stage: "CORE",
start: "2026-06-08T22:30:00.000Z",
end: sharedEnd,
minutes: 200,
},
{
stage: "DEEP",
start: "2026-06-09T01:00:00.000Z",
end: sharedEnd,
minutes: 100,
},
{
stage: "REM",
start: "2026-06-09T03:00:00.000Z",
end: sharedEnd,
minutes: 120,
},
],
};
expect(render(session)).toContain('role="img"');
});
it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => {
// WHOOP / Polar report per-stage TOTALS and the server lays the segments
// out in a fixed physiological order — the timeline would invent a phase
// progression that was never measured. So even with distinct, well-timed
// spans, a `reconstructed` night drops the bar and keeps the breakdown.
const session: SleepHypnogramSession = {
...baseSession,
source: "WHOOP",
reconstructed: true,
};
const html = render(session);
expect(html).not.toContain('role="img"');
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"');
});
it("keeps the timeline bar for a measured (non-reconstructed) night", () => {
// An Apple Health night carries real per-stage onsets, so the stepped bar
// is genuine and stays. The estimate note never appears.
const html = render({ ...baseSession, reconstructed: false });
expect(html).toContain('role="img"');
expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"');
});
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
// Every segment stamped on ONE instant — no real timeline, so only the
// breakdown footer, no bar.
const instant = "2026-06-09T06:00:00.000Z";
const session: SleepHypnogramSession = {
...baseSession,
segments: [
{ stage: "CORE", start: instant, end: instant, minutes: 200 },
{ stage: "DEEP", start: instant, end: instant, minutes: 100 },
],
};
const html = render(session);
expect(html).not.toContain('role="img"');
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
});
});
describe("<SleepHypnogram> — source discrepancy marker", () => {
const discrepancy = {
deltaMinutes: 160,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{ source: "GOOGLE_HEALTH", deviceType: null, asleepMinutes: 455 },
],
};
it("renders the discreet marker when the server flags disagreeing sources", () => {
const html = render({ ...baseSession, sourceDiscrepancy: discrepancy });
expect(html).toContain('data-slot="sleep-source-discrepancy"');
// Muted marker, no colour wash — the trigger stays on the muted tier.
expect(html).toContain("Sources disagree");
});
it("omits the marker when the night carries no discrepancy", () => {
expect(render(baseSession)).not.toContain(
'data-slot="sleep-source-discrepancy"',
);
expect(render({ ...baseSession, sourceDiscrepancy: null })).not.toContain(
'data-slot="sleep-source-discrepancy"',
);
});
});
-417
View File
@@ -1,417 +0,0 @@
"use client";
import { useMemo } from "react";
import {
CartesianGrid,
ComposedChart,
ReferenceArea,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import {
SleepSourceDiscrepancyMarker,
formatMinutes,
type SleepSourceDiscrepancyDto,
} from "@/components/insights/sleep-source-discrepancy-marker";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
/**
* v1.11.5 — last-night HYPNOGRAM (the maintainer directive: the phase-progression
* view). Renders one night's stage timeline as a stepped depth track —
* Awake at the top, Deep at the base — over clock time, in the Dracula
* stage palette reused from `sleep-stage-stacked-bar.tsx` (no token
* reshuffle, per the charts-visual-identity rule). Below the chart: the
* per-stage breakdown (time + %), total asleep, time in bed, and the
* mid-sleep awakenings count.
*
* One canonical source per night (the endpoint already collapsed it), so
* exactly one timeline renders — Apple ↔ WHOOP are never overlaid.
*
* Recharts stays (project rule). Each stage segment is drawn as its own
* `ReferenceArea` rectangle spanning the segment's [start, end] clock range
* at the stage's depth lane, filled with the segment's `STAGE_COLORS` token
* (the same palette the breakdown legend below uses) — so the chart reads as
* a coloured "cityscape" rather than one flat single-colour line. Depth still
* increases downward (Awake top, Deep base).
*
* v1.11.5 — the chart renders only when per-segment `segments` exist
* (`hasTrack`); the per-stage breakdown + totals footer render whenever
* `session.stages` carries any positive value, so a legacy stage-summary
* night (no segment spans) still shows its breakdown instead of collapsing
* the whole card to "unavailable".
*/
export interface SleepHypnogramSegment {
stage: string | null;
start: string;
end: string;
minutes: number;
}
export interface SleepHypnogramSession {
night: string;
source: string | null;
start: string;
end: string;
asleepMinutes: number;
inBedMinutes: number | null;
awakeMinutes: number | null;
awakenings: number;
stages: Record<string, number>;
segments: SleepHypnogramSegment[];
/**
* `true` when the winning source has no measured per-stage onset
* timestamps and the server synthesised a contiguous timeline in a fixed
* physiological order (WHOOP / Polar). The "timeline" of such a night is
* not a real hypnogram — it would read as one long Core block followed by
* one Deep block, never the phase-to-phase progression a measured night
* shows — so we never draw the misleading stepped bar for it and fall back
* to the per-stage breakdown alone. A real-series source (Apple Health,
* Withings, Fitbit) is `false` and keeps the timeline.
*/
reconstructed?: boolean;
/**
* Non-null when two writer buckets reported clearly different asleep
* totals for this night (server-computed, observational only — the
* headline total stays the winning writer's). Renders as a discreet
* marker with a per-source breakdown in a tooltip.
*/
sourceDiscrepancy?: SleepSourceDiscrepancyDto | null;
}
export interface SleepHypnogramProps {
session: SleepHypnogramSession;
}
/**
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
* downward, matching Apple Health / Oura's "cityscape" convention. Only
* the stages actually present in the night's spans get a lane (the
* order is preserved) — painting all six lanes left an Apple night
* (AWAKE/REM/CORE/DEEP) with two permanently empty rows.
*/
const LANE_ORDER = [
"AWAKE",
"IN_BED",
"ASLEEP",
"REM",
"CORE",
"DEEP",
] as const;
/** Breakdown order — deepest restorative first, matching the stacked bar. */
const BREAKDOWN_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
// v1.28.x — `formatMinutes`, the source/device label maps, and the
// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx`
// so the dashboard sleep tile renders the identical hint.
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const stageLabels: Record<string, string> = {
DEEP: t("insights.sleep.stages.deep"),
REM: t("insights.sleep.stages.rem"),
CORE: t("insights.sleep.stages.core"),
ASLEEP: t("insights.sleep.stages.asleep"),
AWAKE: t("insights.sleep.stages.awake"),
IN_BED: t("insights.sleep.stages.inBed"),
};
// Build one coloured span per stage segment: its [start, end] clock range
// at the stage's depth lane, in the stage's own palette token. Each span
// renders as a `ReferenceArea` so the chart is multi-coloured (a single
// Recharts line can only carry one stroke). Lanes derive from the stages
// PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night
// paints four lanes, not six.
const { spans, lanes } = useMemo(() => {
const known = new Set<string>(LANE_ORDER);
const segments = [...session.segments]
.filter((seg) => seg.stage != null && known.has(seg.stage))
.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const present = new Set(segments.map((seg) => seg.stage as string));
const lanes = LANE_ORDER.filter((stage) => present.has(stage));
const laneOf: Record<string, number> = Object.fromEntries(
// Reverse-index so the first present stage sits at the highest y.
lanes.map((s, i) => [s, lanes.length - 1 - i]),
);
const spans = segments.map((seg) => {
const stage = seg.stage as string;
return {
stage,
x1: new Date(seg.start).getTime(),
x2: new Date(seg.end).getTime(),
lane: laneOf[stage],
fill: STAGE_COLORS[stage],
};
});
return { spans, lanes };
}, [session.segments]);
const domain = useMemo<[number, number]>(
() => [new Date(session.start).getTime(), new Date(session.end).getTime()],
[session.start, session.end],
);
// v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts
// has no points to derive ticks from and would otherwise label the domain
// endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole
// clock hours and pick a 1–3 h step from the night's span so the axis reads
// 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks).
const xTicks = useMemo(() => {
const [from, to] = domain;
const spanHours = (to - from) / 3_600_000;
if (!Number.isFinite(spanHours) || spanHours <= 0) return [];
// Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h.
const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3;
const stepMs = stepHours * 3_600_000;
// First whole hour at or after `from`, aligned to the step grid.
const firstHour = new Date(from);
firstHour.setMinutes(0, 0, 0);
if (firstHour.getTime() < from)
firstHour.setTime(firstHour.getTime() + 3_600_000);
// Align the first tick to a multiple of the step (relative to midnight).
while (firstHour.getHours() % stepHours !== 0) {
firstHour.setTime(firstHour.getTime() + 3_600_000);
}
const ticks: number[] = [];
for (let t = firstHour.getTime(); t <= to; t += stepMs) {
ticks.push(t);
}
return ticks;
}, [domain]);
const timeFmt = useMemo(
() =>
new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
}),
[locale, timeFormat],
);
// Per-stage breakdown with percentage of the night's total tracked time.
const breakdown = useMemo(() => {
const total = Object.values(session.stages).reduce((s, v) => s + v, 0);
return BREAKDOWN_ORDER.filter(
(stage) => (session.stages[stage] ?? 0) > 0,
).map((stage) => {
const mins = session.stages[stage] ?? 0;
return {
stage,
minutes: mins,
pct: total > 0 ? Math.round((mins / total) * 100) : 0,
};
});
}, [session.stages]);
const ariaLabel = t("insights.sleep.hypnogram.ariaLabel", {
asleep: formatMinutes(session.asleepMinutes, locale),
awakenings: String(session.awakenings),
});
// The CHART needs per-segment spans; the BREAKDOWN + totals footer only
// need any positive per-stage minutes. A legacy stage-summary night (no
// segment spans) still shows its breakdown rather than collapsing the
// whole card. "Unavailable" is reserved for a truly empty session.
// Spans must also carry more than one distinct START instant: a real
// night with timed stages steps through the clock, so its segments begin
// at different instants and earn the timeline bar. Only a degenerate
// session whose every segment starts on a single instant collapses to the
// breakdown footer. (Gating on the END instant instead hid the bar for an
// ordinary multi-stage night whose segments happened to share a right
// edge — the regression this restores.)
// A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
// server laid its segments out in a fixed physiological order, so drawing
// the stepped bar would invent a phase progression that was never recorded
// (the "Core block then Deep block, no real breakdown" the bar reads as).
// For those we keep the per-stage breakdown + totals, which ARE real, and
// drop the misleading timeline entirely. Real-series sources keep the bar.
const hasTrack =
session.reconstructed !== true &&
spans.length > 0 &&
(spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
// v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to
// `gap-3` so the header sits closer to the timeline, reclaiming the empty
// band the maintainer flagged. The chart keeps its 200 px footprint.
<Card data-slot="sleep-hypnogram" className="gap-2">
<CardHeader>
<div className="flex flex-col gap-0.5">
<TileHeader title={t("insights.sleep.hypnogram.title")} />
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{t("insights.sleep.hypnogram.subtitle", {
asleep: formatMinutes(session.asleepMinutes, locale),
})}
{/* Discreet source-discrepancy marker: two writers reported
clearly different totals for this night. Observational — the
headline stays the winning writer's number; the tooltip lists
each source's own total. Shared with the dashboard sleep
tile via `sleep-source-discrepancy-marker.tsx`. */}
<SleepSourceDiscrepancyMarker
discrepancy={session.sourceDiscrepancy}
/>
</span>
{/* The night's measuring source rides the header as a muted
caption — it is already on the wire, and "which device
tracked this" is the first question a multi-tracker user
asks about a surprising night. */}
{session.source ? (
<span
data-slot="sleep-hypnogram-source"
className="text-muted-foreground text-xs"
>
{t("insights.sleep.hypnogram.source", {
source: session.source,
})}
</span>
) : null}
{/* When the source reports stage TOTALS but no measured onsets
(WHOOP / Polar) the stepped timeline is suppressed — drawing one
would invent a phase order that was never recorded. Surface a calm
note so the breakdown below doesn't read as a missing chart. */}
{session.reconstructed === true && hasBreakdown ? (
<span
data-slot="sleep-hypnogram-estimate-note"
className="text-muted-foreground text-xs"
>
{t("insights.sleep.hypnogram.estimateNote")}
</span>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{!hasTrack && !hasBreakdown ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
data-slot="sleep-hypnogram-empty"
>
{t("insights.sleep.stages.unavailable")}
</p>
) : (
<>
{hasTrack && (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart
data={[]}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="number"
dataKey="t"
domain={domain}
scale="time"
ticks={xTicks}
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
/>
<YAxis
type="number"
dataKey="lane"
domain={[-0.5, lanes.length - 0.5]}
ticks={lanes.map((_, i) => lanes.length - 1 - i)}
stroke="var(--muted-foreground)"
fontSize={10}
width={56}
tickFormatter={(v: number) => {
const stage = lanes[lanes.length - 1 - v];
return stage ? (stageLabels[stage] ?? "") : "";
}}
/>
{spans.map((span, i) => (
<ReferenceArea
key={`${span.stage}-${i}`}
x1={span.x1}
x2={span.x2}
y1={span.lane - 0.4}
y2={span.lane + 0.4}
fill={span.fill}
fillOpacity={0.9}
stroke="none"
ifOverflow="extendDomain"
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{hasBreakdown && (
<>
<div
className="space-y-1.5"
data-slot="sleep-hypnogram-breakdown"
>
{breakdown.map(({ stage, minutes, pct }) => (
<div
key={stage}
className="flex items-center justify-between gap-3 text-xs"
>
<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 tabular-nums">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
))}
</div>
<div className="border-border flex flex-wrap items-center gap-x-4 gap-y-1 border-t pt-2 text-xs">
<span className="font-medium">
{t("insights.sleep.hypnogram.asleepTotal", {
value: formatMinutes(session.asleepMinutes, locale),
})}
</span>
{session.inBedMinutes != null && (
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.inBedTotal", {
value: formatMinutes(session.inBedMinutes, locale),
})}
</span>
)}
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.awakenings", {
count: String(session.awakenings),
})}
</span>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
);
}