From f9bb96ae084a29dfbbf60d5776a6816e27717f3c Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 2 Oct 2026 07:12:02 +0200 Subject: [PATCH] fix(web): paginate sleep agenda newest first with page size selection --- CHANGELOG.md | 6 + docs/current_state.md | 9 +- web/src/routes/SleepDiaryWorkspace.test.tsx | 131 +++++++++++++++++++- web/src/routes/SleepDiaryWorkspace.tsx | 111 +++++++++++++---- web/src/theme/app.css | 60 +++++++-- 5 files changed, 284 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a51071..e3790b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 0.1.7 +- Show the Web Sleep agenda newest first with a 5, 10, 15, or All night + selector (10 by default). Page changes leave the selected night, editor, + statistics, and inclusive PDF range untouched. Removed only the redundant + textual sport list below the Web agenda; factual blue overlays, the sport + legend, and the complete PDF sport appendix remain. + - Corrected the Dashboard/Analysis active-Program next-session projection to select title, identity, and current date from one pending Program session. Recorded rescheduling dates replace imported dates, ceded slots are excluded, diff --git a/docs/current_state.md b/docs/current_state.md index 2fe7f53..3fc76f3 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -236,7 +236,11 @@ Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. The active Sleep V2 delta keeps the Web agenda period-owned: every returned -night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is +night remains available on the shared 18:00-to-18:00 geometry. The Web shows +the newest nights first, ten per page by default, with 5, 10, 15, and All +choices. The size choice is local to the browser and survives reloads. Paging +changes only the visible rows; PDF selection, statistics, and +the current editor remain based on the complete loaded period. Selecting a row is presentation-only and atomically drives the detail dates, editor content, and full-width heart-rate detail; it performs no database mutation. PDF preview and download instead share an explicit inclusive range over night start dates, @@ -247,7 +251,8 @@ Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals intersect `[start,end)` and are clipped only for display; missing or invalid ends are point markers without invented duration. The Web keeps a separate -blue lane and accessible session list. Preview and download wait for the +blue lane with accessible labels and tooltips; the redundant textual Web sport +list is removed. Preview and download wait for the complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text, width-aware wrapping, content-driven row heights, and dated continuation pages for exceptional notes. Each selected intake and remark is printed once without diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index c4c48a0..d6996ae 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -184,8 +184,25 @@ const dayAEntry = (): SleepEntry => ({ ], }); +const datedEntries = (count: number): SleepEntry[] => + Array.from({ length: count }, (_, index) => { + const date = `2026-09-${String(index + 1).padStart(2, "0")}`; + const next = `2026-09-${String(index + 2).padStart(2, "0")}`; + return { + ...dayAEntry(), + entry_id: `sd_${String(index + 1).padStart(3, "0")}`, + revision_id: `sdr_${String(index + 1).padStart(3, "0")}`, + night_start_date: date, + night_end_date: next, + // A recently edited old night must retain its chronological position. + updated_at: `2026-10-${String(count - index).padStart(2, "0")}T12:00:00+02:00`, + treatment_and_notes: `Observation ${index + 1}`, + }; + }); + describe("SleepDiaryWorkspace", () => { beforeEach(() => { + window.localStorage.removeItem("trainlog.web.sleep.page-size.v1"); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00")); vi.clearAllMocks(); @@ -249,6 +266,116 @@ describe("SleepDiaryWorkspace", () => { ); }); + it.each([0, 1, 5, 6, 10, 11, 15, 16, 23])( + "paginates %i nights newest first without changing the source snapshot", + async (count) => { + const original = datedEntries(count); + const source = original.length === 0 ? [] : + [original[0], ...original.slice(1).reverse()]; + if (source.length > 0) source.push(source[0]); + api.fetchSleepDiary.mockResolvedValue(snapshotWith(source)); + render(); + await waitFor(() => expect(screen.getByLabelText("Afficher")).toHaveValue("10")); + await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(Math.min(count, 10))); + const rows = () => screen.queryAllByTestId(/^sleep-agenda-row-/).map((row) => row.getAttribute("data-testid")); + expect(rows()).toEqual(original.slice().reverse().slice(0, 10).map((entry) => + `sleep-agenda-row-${entry.entry_id}`)); + expect(source[0]).toBe(original[0]); + if (count > 10) { + fireEvent.click(screen.getByRole("button", { name: "Suivant" })); + expect(rows()).toEqual(original.slice().reverse().slice(10, 20).map((entry) => + `sleep-agenda-row-${entry.entry_id}`)); + fireEvent.click(screen.getByRole("button", { name: "Précédent" })); + } + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "all" } }); + expect(rows()).toHaveLength(count); + expect(screen.queryByRole("button", { name: "Suivant" })).toBeNull(); + for (const size of [5, 15, 10]) { + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: String(size) } }); + expect(rows()).toHaveLength(Math.min(count, size)); + } + expect(screen.getByLabelText("Afficher").querySelectorAll("option")).toHaveLength(4); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + }, + ); + + it("keeps PDF range, selected detail and pending form input across pages and sizes", async () => { + const entries = datedEntries(23); + api.fetchSleepDiary.mockResolvedValue(snapshotWith(entries)); + sportApi.fetchSportSessions.mockResolvedValue([{ + identity: "se_old", label: "Old sport", session_type: "training", + started_at: "2026-09-02T20:00:00+02:00", + ended_at: "2026-09-02T21:00:00+02:00", + }]); + const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( + new Blob(["all nights"], { type: "application/pdf" }), + ); + vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); + render(); + await screen.findByTestId("sleep-agenda-row-sd_023"); + await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled()); + expect(screen.queryByTestId("sleep-agenda-sport")).toBeNull(); + expect(screen.queryByText(/Aucune dans la période/)).toBeNull(); + fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: "21:15" } }); + const start = screen.getByLabelText("PDF du") as HTMLInputElement; + const end = screen.getByLabelText("au") as HTMLInputElement; + expect([start.value, end.value]).toEqual(["2026-09-01", "2026-09-23"]); + for (const size of ["10", "5", "15", "all"]) { + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: size } }); + if (size !== "all" && size !== "15") + fireEvent.click(screen.getByRole("button", { name: "Suivant" })); + if (size === "all") + expect(screen.getByTestId("sleep-agenda-sport")).toHaveAttribute("aria-label", expect.stringContaining("Old sport")); + fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); + expect(screen.getByTestId("sleep-event-start")).toHaveValue("21:15"); + expect([start.value, end.value]).toEqual(["2026-09-01", "2026-09-23"]); + } + expect(build).toHaveBeenCalledTimes(4); + for (const [selection, , sport, from, to] of build.mock.calls) { + expect(selection.entries.map((entry) => entry.entry_id)).toEqual(entries.map((entry) => entry.entry_id)); + expect(selection.summary.nights).toBe(23); + expect(selection.summary.intake_count).toBe(23); + expect(sport).toHaveLength(1); + expect([from, to]).toEqual(["2026-09-01", "2026-09-23"]); + } + expect(screen.queryByText(/Sport — horaires enregistrés/)).toBeNull(); + expect(screen.getByText("Bleu : séance de sport effectuée")).toBeInTheDocument(); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + expect(api.deleteSleepEntry).not.toHaveBeenCalled(); + }); + + it("retains the page size but returns to newest on a deliberate period change", async () => { + const many = datedEntries(16); + const fewer = many.slice(0, 6); + api.fetchSleepDiary + .mockResolvedValueOnce(snapshotWith(many)) + .mockResolvedValue(snapshotWith(fewer)); + const view = render(); + await screen.findByTestId("sleep-agenda-row-sd_016"); + fireEvent.change(screen.getByLabelText("Show"), { target: { value: "5" } }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("Nights 6–10 of 16 · Page 2/4")).toBeInTheDocument(); + view.rerender(); + await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(5)); + expect(screen.getByLabelText("Show")).toHaveValue("5"); + expect(screen.getByTestId("sleep-agenda-row-sd_006")).toBeInTheDocument(); + expect(screen.getByText("Nights 1–5 of 6 · Page 1/2")).toBeInTheDocument(); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + }); + + it("restores the local page-size choice after remounting", async () => { + api.fetchSleepDiary.mockResolvedValue(snapshotWith(datedEntries(11))); + const first = render(); + await screen.findByTestId("sleep-agenda-row-sd_011"); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "5" } }); + expect(window.localStorage.getItem("trainlog.web.sleep.page-size.v1")).toBe("5"); + first.unmount(); + render(); + await screen.findByTestId("sleep-agenda-row-sd_011"); + expect(screen.getByLabelText("Afficher")).toHaveValue("5"); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(5); + }); + it("uses the same inclusive date-range subset for PDF preview and export", async () => { const first = dayAEntry(); const second = { @@ -310,13 +437,13 @@ describe("SleepDiaryWorkspace", () => { const view = render(); await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1)); view.rerender(); - await screen.findByText(/Current session/); + await screen.findByRole("img", { name: /Current session/ }); finishOld([{ identity: "se_stale", label: "Stale session", session_type: "training", started_at: "2026-09-21T07:00:00+02:00", ended_at: "2026-09-21T08:00:00+02:00", }]); - await waitFor(() => expect(screen.queryByText(/Stale session/)).toBeNull()); + await waitFor(() => expect(screen.queryByRole("img", { name: /Stale session/ })).toBeNull()); }); it("can export sport in a period with no recorded Sleep night", async () => { diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 23177d2..1c19c82 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -38,6 +38,19 @@ import { } from "./sleepVisualProjection"; type Language = "fr" | "en"; +type AgendaPageSize = 5 | 10 | 15 | "all"; +const agendaPageSizeKey = "trainlog.web.sleep.page-size.v1"; +const initialAgendaPageSize = (): AgendaPageSize => { + try { + const stored = window.localStorage.getItem(agendaPageSizeKey); + if (stored === "all") return "all"; + if (stored === "5" || stored === "10" || stored === "15") + return Number(stored) as 5 | 10 | 15; + } catch { + // A disabled browser storage falls back to the documented first-use size. + } + return 10; +}; const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"]; const pointTypes: readonly SleepEventType[] = [ "bed_time", @@ -71,6 +84,13 @@ const copy = { summary: "Synthèse factuelle", empty: "Aucune nuit enregistrée.", agendaEmpty: "Aucune donnée pour cette nuit.", + show: "Afficher", + all: "Tout", + nightsCount: "Nuits", + of: "sur", + page: "Page", + previous: "Précédent", + next: "Suivant", preview: "Prévisualiser", export: "Exporter PDF", pdfFrom: "PDF du", @@ -134,6 +154,13 @@ const copy = { summary: "Factual summary", empty: "No recorded nights.", agendaEmpty: "No data for this night.", + show: "Show", + all: "All", + nightsCount: "Nights", + of: "of", + page: "Page", + previous: "Previous", + next: "Next", preview: "Preview", export: "Export PDF", pdfFrom: "PDF from", @@ -318,6 +345,8 @@ export function SleepDiaryWorkspace({ const [intakeUnit, setIntakeUnit] = useState("mg"); const [intakeQuantity, setIntakeQuantity] = useState(1); const [selectedEntryId, setSelectedEntryId] = useState(null); + const [agendaPageSize, setAgendaPageSize] = useState(initialAgendaPageSize); + const [agendaPage, setAgendaPage] = useState(0); const identity = useRef<{ entry_id: string; revision_id: string } | null>( null, ); @@ -392,6 +421,11 @@ export function SleepDiaryWorkspace({ useEffect(() => { void reload(); }, [period]); + useEffect(() => { + // CONTRACT: a deliberate period change starts at its newest night while + // keeping the user's page-size choice and all editor/PDF state separate. + setAgendaPage(0); + }, [period]); useEffect(() => { const controller = new AbortController(); setSportState("loading"); @@ -725,7 +759,22 @@ export function SleepDiaryWorkspace({ const selectedEntry = agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ?? activeEntry ?? - agendaEntries[0]; + agendaEntries[agendaEntries.length - 1]; + // INVARIANT: presentation sorting and slicing never mutate or narrow the + // ascending, deduplicated collection used by PDF and sport projections. + const recentEntries = [...agendaEntries].sort((left, right) => + right.night_start_date.localeCompare(left.night_start_date) || + left.entry_id.localeCompare(right.entry_id)); + const pageCount = agendaPageSize === "all" ? 1 : + Math.ceil(recentEntries.length / agendaPageSize); + useEffect(() => { + if (snapshot) setAgendaPage((current) => Math.min(current, Math.max(0, pageCount - 1))); + }, [pageCount, snapshot]); + const visiblePage = Math.min(agendaPage, Math.max(0, pageCount - 1)); + const firstVisible = agendaPageSize === "all" ? 0 : + visiblePage * agendaPageSize; + const visibleEntries = agendaPageSize === "all" ? recentEntries : + recentEntries.slice(firstVisible, firstVisible + agendaPageSize); const [pdfStartDate, setPdfStartDate] = useState(""); const [pdfEndDate, setPdfEndDate] = useState(""); const pdfRangeInitialized = useRef(false); @@ -753,8 +802,6 @@ export function SleepDiaryWorkspace({ // selection. The range concerns the date on which each night begins; detail // selection remains independent and continues to drive the HR/editor view. const pdfWindows = pdfEntries.map(sleepRowWindow); - const agendaSport = sport.filter((session) => agendaEntries.some((entry) => - projectSportSegments([session], sleepRowWindow(entry)).length > 0)); const fallbackStart = Date.parse(`${pdfStartDate}T18:00:00`); const fallbackEnd = Date.parse(`${pdfEndDate}T18:00:00`) + 86400000; const pdfWindow = pdfWindows.length > 0 ? { @@ -763,7 +810,6 @@ export function SleepDiaryWorkspace({ } : { start: fallbackStart, end: fallbackEnd }; const pdfSport = pdfRangeValid ? sport.filter((session) => projectSportSegments([session], pdfWindow).length > 0) : []; - const listedSport = agendaEntries.length === 0 ? pdfSport : agendaSport; const selectedSnapshot = snapshot && sportState === "ready" && pdfRangeValid && (pdfEntries.length > 0 || pdfSport.length > 0) ? sleepSnapshotSelection(snapshot, pdfEntries) @@ -833,8 +879,46 @@ export function SleepDiaryWorkspace({ )} {sportState === "ready" &&

{language === "fr" ? "Bleu : séance de sport effectuée" : "Blue: completed sport session"} - {listedSport.length === 0 && (language === "fr" ? " · Aucune dans la période" : " · None in this period")}

} +
+ + + {t.nightsCount} {recentEntries.length === 0 ? "0" : + `${firstVisible + 1}–${firstVisible + visibleEntries.length}`} {t.of} {recentEntries.length} + {agendaPageSize !== "all" && pageCount > 1 && + ` · ${t.page} ${visiblePage + 1}/${pageCount}`} + + {agendaPageSize !== "all" && pageCount > 1 && ( +
+ + +
+ )} +
{agendaEntries.length === 0 ? (

{t.agendaEmpty}

) : ( @@ -864,7 +948,7 @@ export function SleepDiaryWorkspace({ - {agendaEntries.map((entry) => { + {visibleEntries.map((entry) => { const facts = sleepEntryFacts(entry); const projection = projectSleepTimeline(entry); // WHY: persistence metadata may be UTC while factual night @@ -1080,21 +1164,6 @@ export function SleepDiaryWorkspace({ )} - {sportState === "ready" && listedSport.length > 0 && ( -
- {language === "fr" ? "Sport — horaires enregistrés" : "Sport — recorded times"} -
    {listedSport.map((session) => ( -
  • - {session.label} ({session.session_type}) · {session.started_at.slice(0, 16)} → { - session.ended_at && Date.parse(session.ended_at) > Date.parse(session.started_at) - ? session.ended_at.slice(0, 16) - : (language === "fr" ? "fin non renseignée" : "end not recorded")} - {agendaEntries.length === 0 && (language === "fr" - ? " · sommeil non renseigné" : " · sleep not recorded")} -
  • - ))}
-
- )} {selectedEntry && ( diff --git a/web/src/theme/app.css b/web/src/theme/app.css index d1e30e0..da00701 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -609,8 +609,13 @@ h2 { .sleep-workspace { display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; gap: 1rem; } +.sleep-workspace .analysis-wide { + box-sizing: border-box; +} .sleep-heading, .sleep-actions, .sleep-entry-header { @@ -633,6 +638,9 @@ h2 { font-size: 0.72rem; } .sleep-pdf-controls { + box-sizing: border-box; + max-width: 100%; + min-width: 0; padding: 0.55rem; border: 1px solid var(--surface-1); border-radius: 0.75rem; @@ -659,6 +667,21 @@ h2 { outline-offset: 1px; border-color: var(--lavender); } +@media (max-width: 600px) { + .sleep-heading > .sleep-pdf-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + .sleep-pdf-controls label { + min-width: 0; + } + .sleep-pdf-controls input { + box-sizing: border-box; + width: 100%; + min-width: 0; + } +} .sleep-pdf-range-error { margin: 0.25rem 0 0.6rem; color: var(--peach); @@ -850,6 +873,35 @@ h2 { color: #629bff; font-weight: 700; } +.sleep-agenda-pagination { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem 0.8rem; + margin: 0.5rem 0; + color: var(--subtext-0); + font-size: var(--font-size-sm); +} +.sleep-agenda-pagination label, +.sleep-agenda-page-buttons { + display: flex; + align-items: center; + gap: 0.45rem; +} +.sleep-agenda-pagination select { + min-height: 2.6rem; + padding: 0.4rem 0.6rem; + color: var(--text); + background: var(--base); + border: 1px solid var(--surface-2); + border-radius: 0.5rem; + font: inherit; +} +.sleep-agenda-pagination select:focus-visible, +.sleep-agenda-page-buttons button:focus-visible { + outline: 3px solid var(--lavender); + outline-offset: 2px; +} .sleep-sport { position: absolute; top: 0.1rem; @@ -860,14 +912,6 @@ h2 { background: #3984eb; z-index: 3; } -.sleep-sport-list { - margin-top: 0.6rem; - font-size: var(--font-size-sm); -} -.sleep-sport-list ul { - margin: 0.3rem 0 0; - padding-left: 1.3rem; -} .sleep-point, .sleep-medication { width: 0;