mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user