diff --git a/src/components/charts/__tests__/touch-action-guard.test.ts b/src/components/charts/__tests__/touch-action-guard.test.ts index 0104bda2b..6e00f2525 100644 --- a/src/components/charts/__tests__/touch-action-guard.test.ts +++ b/src/components/charts/__tests__/touch-action-guard.test.ts @@ -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", ]; diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx deleted file mode 100644 index 5a1fc8dbe..000000000 --- a/src/components/charts/compliance-line-chart.tsx +++ /dev/null @@ -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; - 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 ( -
- {showRangeControls ? ( -
- {TIME_RANGES.map((r) => ( - - ))} -
- ) : null} - - {chartData.length === 0 ? ( -
- Keine Daten im gewählten Zeitraum -
- ) : ( -
- - - - - - [`${value}%`, "Compliance"]} - /> - - - - -
- )} -
- ); -} diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx deleted file mode 100644 index 4bd96d7b6..000000000 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ /dev/null @@ -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( - - - , - ); -} - -describe(" — 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(" — 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(" — 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(" — 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"', - ); - }); -}); diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx deleted file mode 100644 index ca6f6f353..000000000 --- a/src/components/insights/sleep-hypnogram.tsx +++ /dev/null @@ -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; - 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 = { - 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(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 = 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. - - -
- - - {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`. */} - - - {/* 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 ? ( - - {t("insights.sleep.hypnogram.source", { - source: session.source, - })} - - ) : 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 ? ( - - {t("insights.sleep.hypnogram.estimateNote")} - - ) : null} -
-
- - {!hasTrack && !hasBreakdown ? ( -

- {t("insights.sleep.stages.unavailable")} -

- ) : ( - <> - {hasTrack && ( -
- - - - timeFmt.format(new Date(v))} - /> - 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) => ( - - ))} - - -
- )} - - {hasBreakdown && ( - <> -
- {breakdown.map(({ stage, minutes, pct }) => ( -
- - - - {formatMinutes(minutes, locale)} · {pct}% - -
- ))} -
- -
- - {t("insights.sleep.hypnogram.asleepTotal", { - value: formatMinutes(session.asleepMinutes, locale), - })} - - {session.inBedMinutes != null && ( - - {t("insights.sleep.hypnogram.inBedTotal", { - value: formatMinutes(session.inBedMinutes, locale), - })} - - )} - - {t("insights.sleep.hypnogram.awakenings", { - count: String(session.awakenings), - })} - -
- - )} - - )} -
-
- ); -}