diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index 113ff9b93..a04a0ea17 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -92,3 +92,54 @@ describe(" — lane derivation", () => { 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("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"'); + }); +}); diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index c337e25c5..27b341252 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -209,15 +209,17 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { // 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 end instant: a summary- - // shaped writer (per-stage night totals all stamped on the sleep end) - // reconstructs into bars that all touch the night's right edge — that - // is a breakdown wearing a timeline costume, so paint it as the - // breakdown footer instead. + // 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.) const hasTrack = spans.length > 0 && (spans.length === 1 || - new Set(spans.map((s) => s.x2)).size > 1); + new Set(spans.map((s) => s.x1)).size > 1); const hasBreakdown = breakdown.length > 0; return (