diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb6544..f23a782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,9 +72,11 @@ also refined. default. A repeated tap inside the window extends its end to 30 minutes after the latest tap, while Levé clamps it to the factual final-get-up timestamp. The policy is revisioned, undoable, and independent of BPM/RR inference. -- Aligned the Sleep agenda's hour labels with a shared 24-column visual grid +- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid and added an inclusive night-start date range for PDF preview and export. - Detail/HR night selection remains independent from the report range. + The duplicate terminal `18` is removed; detail/HR night selection remains + independent from the report range, whose date fields now use a grouped + Trainlog form treatment. ## 0.1.5 — 2026-09-23 diff --git a/docs/current_state.md b/docs/current_state.md index 46360ce..314657f 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -205,7 +205,8 @@ sleep intervals, every structured long-awake interval, point events, medication intakes, and bounds. The agenda renders an estimated interval with distinct hatching, never substitutes it for a factual interval, and retains the exact shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by -the labels and every row. Displayed sleep duration is the sum of factual +every row, with each hour label centered inside the cell it names. Displayed +sleep duration is the sum of factual sleep intervals or, in their absence, the displayed estimate minus overlapping structured awakenings. The overlays remain available without an HR capture and do not fabricate BPM. diff --git a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md index 0a1be6c..3aaff02 100644 --- a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md +++ b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md @@ -15,8 +15,9 @@ editor snapshot, and HR context in memory without issuing a write. PDF preview and download receive the same explicit inclusive range over each entry's `night_start_date`, and all observations are recomputed from that subset. The range initially spans the loaded agenda period; later detail/HR selection does -not alter it. The floating hour axis is reinforced by the same 24 equal visual -columns used beneath every 18:00-to-18:00 agenda row. +not alter it. Each of the 24 hour labels is centered in the equal visual cell +it names, using the same grid beneath every 18:00-to-18:00 agenda row; there is +no ambiguous duplicate `18` at the right edge. ## Automated validation diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index c04ea4b..b50d871 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -721,26 +721,21 @@ describe("SleepDiaryWorkspace", () => { ); expect(intake).toHaveStyle({ left: "18.75%" }); const ticks = Array.from(document.querySelectorAll(".sleep-hour-axis > span")); - expect(ticks).toHaveLength(25); + expect(ticks).toHaveLength(24); expect( ticks - .map((tick) => [tick.textContent, tick.style.left]) - .filter((_, index) => index % 2 === 0), + .filter((_, index) => index % 2 === 0) + .map((tick) => tick.textContent), ).toEqual([ - ["18", "0%"], - ["20", `${(2 / 24) * 100}%`], - ["22", `${(4 / 24) * 100}%`], - ["00", "25%"], - ["02", `${(8 / 24) * 100}%`], - ["04", `${(10 / 24) * 100}%`], - ["06", "50%"], - ["08", `${(14 / 24) * 100}%`], - ["10", `${(16 / 24) * 100}%`], - ["12", "75%"], - ["14", `${(20 / 24) * 100}%`], - ["16", `${(22 / 24) * 100}%`], - ["18", "100%"], + "18", "20", "22", "00", "02", "04", + "06", "08", "10", "12", "14", "16", ]); + ticks.forEach((tick, index) => { + expect(Number.parseFloat(tick.style.left)).toBeCloseTo( + ((index + 0.5) / 24) * 100, + 9, + ); + }); expect(ticks[1]).toHaveClass("sleep-hour-tick-odd"); expect(ticks[2]).toHaveClass("sleep-hour-tick-even"); expect(ticks[6]).toHaveClass("sleep-hour-tick-major"); diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts index 862f03a..af851f2 100644 --- a/web/src/routes/sleepTimelineGeometry.test.ts +++ b/web/src/routes/sleepTimelineGeometry.test.ts @@ -74,10 +74,10 @@ describe("sleep timeline geometry", () => { it("derives exact one-hour and two-hour 18:00-to-18:00 ticks", () => { const hourly = sleepAgendaTicks(); - expect(hourly).toHaveLength(25); - expect(hourly[0]).toEqual({ hourOffset: 0, position: 0, label: "18" }); - expect(hourly[6]).toEqual({ hourOffset: 6, position: 0.25, label: "00" }); - expect(hourly[24]).toEqual({ hourOffset: 24, position: 1, label: "18" }); + expect(hourly).toHaveLength(24); + expect(hourly[0]).toEqual({ hourOffset: 0, position: 1 / 48, label: "18" }); + expect(hourly[6]).toEqual({ hourOffset: 6, position: 13 / 48, label: "00" }); + expect(hourly[23]).toEqual({ hourOffset: 23, position: 47 / 48, label: "17" }); expect(sleepAgendaTicks(2).map((tick) => tick.label)).toEqual([ "18", "20", @@ -91,7 +91,6 @@ describe("sleep timeline geometry", () => { "12", "14", "16", - "18", ]); }); }); diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts index 0e33818..1518e16 100644 --- a/web/src/routes/sleepTimelineGeometry.ts +++ b/web/src/routes/sleepTimelineGeometry.ts @@ -15,11 +15,13 @@ export function sleepAgendaTicks(stepHours = 1): SleepAgendaTick[] { if (!Number.isInteger(stepHours) || stepHours < 1 || 24 % stepHours !== 0) { return []; } - return Array.from({ length: 24 / stepHours + 1 }, (_, index) => { + return Array.from({ length: 24 / stepHours }, (_, index) => { const hourOffset = index * stepHours; return { hourOffset, - position: hourOffset / 24, + // CONTRACT: labels name hour cells, so they sit at each cell's center; + // event positions and vertical grid boundaries remain exact instants. + position: (hourOffset + stepHours / 2) / 24, label: String((18 + hourOffset) % 24).padStart(2, "0"), }; }); diff --git a/web/src/theme/app.css b/web/src/theme/app.css index d07c4a3..7099989 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -632,8 +632,32 @@ h2 { color: var(--subtext-0); font-size: 0.72rem; } +.sleep-pdf-controls { + padding: 0.55rem; + border: 1px solid var(--surface-1); + border-radius: 0.75rem; + background: var(--mantle); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%); +} .sleep-pdf-controls input { - min-height: 2.25rem; + min-width: 10.5rem; + min-height: 2.6rem; + padding: 0.55rem 0.7rem; + color: var(--text); + color-scheme: dark; + background: var(--base); + border: 1px solid var(--surface-2); + border-radius: 0.5rem; + font: inherit; + font-variant-numeric: tabular-nums; +} +.sleep-pdf-controls input:hover { + border-color: var(--overlay-0); +} +.sleep-pdf-controls input:focus-visible { + outline: 3px solid var(--lavender); + outline-offset: 1px; + border-color: var(--lavender); } .sleep-pdf-range-error { margin: 0.25rem 0 0.6rem; @@ -731,12 +755,6 @@ h2 { .sleep-hour-tick-odd { display: none; } -.sleep-hour-axis > span:first-child { - transform: none; -} -.sleep-hour-axis > span:last-child { - transform: translateX(-100%); -} .sleep-row { display: grid; grid-template-columns: var(--sleep-label-column) var(--sleep-track-column) var(--sleep-facts-column);