diff --git a/CHANGELOG.md b/CHANGELOG.md index e3790b8..95760fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ ## 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. +- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All + recorded-night selector (7 by default). Sleep reads its full available history + without the Analysis period control; other Analysis sections retain their + period choice. The browser-local 5/10/15 preference migrates to 7/14/21, + and a capped full-history read fails explicitly. 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. diff --git a/docs/current_state.md b/docs/current_state.md index 3fc76f3..ed531db 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -235,18 +235,22 @@ parallel implementation of its rules. 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 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 +The active Sleep V2 delta reads the complete available Sleep history independently +of the Analysis period used by other sections. Every returned night remains +available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display +selector: the newest nights first, seven recorded nights per page by default, +with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows. +The browser-local choice survives reloads; old 5/10/15 choices migrate to +7/14/21. At the server's 3660-night read cap, the Web reports that completeness +cannot be established instead of presenting a truncated agenda as All. Paging +changes only the visible rows; PDF selection, statistics, and the current editor +remain based on the complete loaded history. 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, -initialized to the loaded agenda bounds and independent of detail selection. +initialized to the loaded history bounds and independent of detail selection. The completed-sport overlay reads factual `sessions.started_at` and `ended_at` -from desktop history through a read-only, period-bounded paginated Web endpoint. +from desktop history through a read-only, paginated Web endpoint. 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 diff --git a/web/src/api/sleepDiary.test.ts b/web/src/api/sleepDiary.test.ts index d32f2de..1eef4a2 100644 --- a/web/src/api/sleepDiary.test.ts +++ b/web/src/api/sleepDiary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { newSleepId, sleepTimestamp } from "./sleepDiary"; +import { fetchSleepDiary, newSleepId, sleepTimestamp } from "./sleepDiary"; describe("sleepTimestamp", () => { it("serializes browser timestamps at the C17 contract precision", () => { @@ -22,3 +22,37 @@ describe("newSleepId", () => { vi.unstubAllGlobals(); }); }); + +describe("fetchSleepDiary", () => { + it("refuses to call a capped unbounded history complete", async () => { + const entry = { + entry_id: "sd_test", + revision_id: "sdr_test", + night_start_date: "2026-09-20", + night_end_date: "2026-09-21", + created_at: "2026-09-20T18:00:00+02:00", + updated_at: "2026-09-21T08:00:00+02:00", + sleep_quality: null, + wake_quality: null, + day_form: null, + treatment_and_notes: "", + publication_status: "draft", + events: [], + intakes: [], + }; + const entries = Array.from({ length: 3660 }, (_, index) => ({ + ...entry, + entry_id: `sd_${index}`, + })); + const response = { api_version: 1, entries, summary: { nights: 3660 } }; + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => new Response(JSON.stringify(response)), + ); + try { + await expect(fetchSleepDiary()).rejects.toThrow("sleep_diary_history_limit_reached"); + expect(fetch.mock.calls[0][0]).toContain("limit=3660"); + } finally { + fetch.mockRestore(); + } + }); +}); diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index d1eeed9..15d4f2d 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -189,6 +189,11 @@ export async function fetchSleepDiary( !object(value.summary) ) throw new TypeError("sleep_diary_invalid"); + // CONTRACT: the server caps one history read at 3660 nights and does not + // expose a continuation cursor. At the cap, completeness is unknowable; + // fail explicitly instead of labeling a possibly truncated subset "All". + if (!startDate && !endDate && value.entries.length === 3660) + throw new RangeError("sleep_diary_history_limit_reached"); return value as unknown as SleepSnapshot; } diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx index 0ae4538..44e7284 100644 --- a/web/src/routes/AnalysisPage.test.tsx +++ b/web/src/routes/AnalysisPage.test.tsx @@ -44,6 +44,49 @@ describe("AnalysisPage", () => { expect(window.location.search).toContain("section=measurements"); }); + it.each(["7d", "30d"])( + "does not apply legacy global period %s to the Sleep workspace", + async (legacyPeriod) => { + window.history.replaceState(null, "", `/analyse?section=sleep&period=${legacyPeriod}`); + vi.mocked(globalThis.fetch).mockImplementation(async (input) => { + const url = String(input); + if (url.startsWith("/api/v1/sleep-diary?")) + return new Response(JSON.stringify({ api_version: 1, entries: [], summary: {} })); + if (url === "/api/v1/sleep-medications") + return new Response(JSON.stringify({ api_version: 1, medications: [] })); + if (url.startsWith("/api/v1/sessions/sport?")) + return new Response(JSON.stringify({ + api_version: 1, offset: 0, more: false, next_offset: 1, items: [], + })); + return new Response(JSON.stringify(analysisFixture)); + }); + render(); + await screen.findByTestId("sleep-workspace"); + expect(screen.queryByLabelText("Période")).toBeNull(); + expect(screen.getByLabelText("Afficher")).toHaveValue("7"); + expect(screen.getByLabelText("PDF du")).toBeInTheDocument(); + expect(screen.getByLabelText("au")).toBeInTheDocument(); + await waitFor(() => { + const sleepRead = vi.mocked(globalThis.fetch).mock.calls.find(([input]) => + String(input).startsWith("/api/v1/sleep-diary?")); + expect(sleepRead).toBeDefined(); + expect(String(sleepRead?.[0])).not.toContain("start_date"); + expect(String(sleepRead?.[0])).not.toContain("end_date"); + }); + fireEvent.click(screen.getByRole("tab", { name: "Vue d’ensemble" })); + expect(screen.getByLabelText("Période")).toHaveValue(legacyPeriod); + fireEvent.change(screen.getByLabelText("Période"), { target: { value: "90d" } }); + await waitFor(() => expect(window.location.search).toContain("period=90d")); + for (const section of ["Exercices", "Répartition", "Mensurations"]) { + fireEvent.click(screen.getByRole("tab", { name: section })); + expect(screen.getByLabelText("Période")).toHaveValue("90d"); + } + fireEvent.click(screen.getByRole("tab", { name: "Sommeil" })); + expect(screen.queryByLabelText("Période")).toBeNull(); + expect(window.location.search).toContain("period=90d"); + }, + ); + it("supports exercise and BODY ZONE deep-link sections without duplicating the global language control", async () => { window.history.replaceState( null, diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx index 14cdd7d..d688a41 100644 --- a/web/src/routes/AnalysisPage.tsx +++ b/web/src/routes/AnalysisPage.tsx @@ -628,22 +628,24 @@ export function AnalysisPage() { ))} - + {section !== "sleep" && ( + + )} {pending && snapshot === null &&

{t.loading}

} {failed && snapshot === null && ( @@ -945,7 +947,9 @@ export function AnalysisPage() { } retryLabel={language === "fr" ? "Réessayer" : "Try again"} > - + {/* CONTRACT: Sleep owns its complete history; the retained + Analysis period still belongs to the other sections. */} + )} diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index d6996ae..54305b0 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -186,8 +186,8 @@ 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")}`; + const date = new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10); + const next = new Date(Date.UTC(2026, 8, index + 2)).toISOString().slice(0, 10); return { ...dayAEntry(), entry_id: `sd_${String(index + 1).padStart(3, "0")}`, @@ -195,7 +195,7 @@ const datedEntries = (count: number): SleepEntry[] => 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`, + updated_at: new Date(Date.UTC(2026, 9, count - index)).toISOString(), treatment_and_notes: `Observation ${index + 1}`, }; }); @@ -266,7 +266,7 @@ describe("SleepDiaryWorkspace", () => { ); }); - it.each([0, 1, 5, 6, 10, 11, 15, 16, 23])( + it.each([0, 1, 7, 8, 14, 15, 21, 22, 28, 29, 35])( "paginates %i nights newest first without changing the source snapshot", async (count) => { const original = datedEntries(count); @@ -275,32 +275,113 @@ describe("SleepDiaryWorkspace", () => { 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))); + await waitFor(() => expect(screen.getByLabelText("Afficher")).toHaveValue("7")); + await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(Math.min(count, 7))); const rows = () => screen.queryAllByTestId(/^sleep-agenda-row-/).map((row) => row.getAttribute("data-testid")); - expect(rows()).toEqual(original.slice().reverse().slice(0, 10).map((entry) => + expect(rows()).toEqual(original.slice().reverse().slice(0, 7).map((entry) => `sleep-agenda-row-${entry.entry_id}`)); expect(source[0]).toBe(original[0]); - if (count > 10) { + if (count > 7) { fireEvent.click(screen.getByRole("button", { name: "Suivant" })); - expect(rows()).toEqual(original.slice().reverse().slice(10, 20).map((entry) => + expect(rows()).toEqual(original.slice().reverse().slice(7, 14).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]) { + for (const size of [14, 21, 28, 7]) { 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(Array.from(screen.getByLabelText("Afficher").querySelectorAll("option"), + (option) => [option.value, option.textContent])).toEqual([ + ["7", "7 jours"], ["14", "14 jours"], ["21", "21 jours"], + ["28", "28 jours"], ["all", "Tout"], + ]); expect(api.saveSleepEntry).not.toHaveBeenCalled(); }, ); + it("bounds all pages of 35 nights and keeps partial last pages accurate", async () => { + api.fetchSleepDiary.mockResolvedValue(snapshotWith(datedEntries(35))); + render(); + await screen.findByTestId("sleep-agenda-row-sd_035"); + const next = () => fireEvent.click(screen.getByRole("button", { name: "Suivant" })); + for (let page = 1; page <= 5; page++) { + expect(screen.getByText(`Nuits ${(page - 1) * 7 + 1}–${page * 7} sur 35 · Page ${page}/5`)).toBeInTheDocument(); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7); + if (page < 5) next(); + } + expect(screen.getByRole("button", { name: "Suivant" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "14" } }); + expect(screen.getByText("Nuits 1–14 sur 35 · Page 1/3")).toBeInTheDocument(); + next(); + next(); + expect(screen.getByText("Nuits 29–35 sur 35 · Page 3/3")).toBeInTheDocument(); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "21" } }); + next(); + expect(screen.getByText("Nuits 22–35 sur 35 · Page 2/2")).toBeInTheDocument(); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(14); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "28" } }); + expect(screen.getByText("Nuits 1–28 sur 35 · Page 1/2")).toBeInTheDocument(); + next(); + expect(screen.getByText("Nuits 29–35 sur 35 · Page 2/2")).toBeInTheDocument(); + }); + + it("counts recorded nights rather than assuming consecutive calendar days", async () => { + const gaps = datedEntries(14).filter((_, index) => index % 2 === 0); + api.fetchSleepDiary.mockResolvedValue(snapshotWith(gaps)); + render(); + await screen.findByTestId(`sleep-agenda-row-${gaps[gaps.length - 1].entry_id}`); + expect(screen.getByText("Nuits 1–7 sur 7")).toBeInTheDocument(); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7); + expect(screen.queryByTestId("sleep-agenda-row-sd_014")).toBeNull(); + }); + + it.each([ + ["5", "7"], ["10", "14"], ["15", "21"], + ["7", "7"], ["14", "14"], ["21", "21"], ["28", "28"], + ["all", "all"], ["invalid", "7"], + ])("uses or migrates local page size %s to %s", async (stored, selected) => { + window.localStorage.setItem("trainlog.web.sleep.page-size.v1", stored); + render(); + expect(screen.getByLabelText("Afficher")).toHaveValue(selected); + if (["5", "10", "15"].includes(stored)) + expect(window.localStorage.getItem("trainlog.web.sleep.page-size.v1")).toBe(selected); + }); + + it("uses seven nights when local browser storage is unavailable", () => { + const read = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + try { + render(); + expect(screen.getByLabelText("Afficher")).toHaveValue("7"); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "14" } }); + expect(screen.getByLabelText("Afficher")).toHaveValue("14"); + } finally { + read.mockRestore(); + write.mockRestore(); + } + }); + + it("shows an explicit error instead of an incomplete all-history agenda", async () => { + api.fetchSleepDiary.mockRejectedValue(new RangeError("sleep_diary_history_limit_reached")); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Historique Sommeil trop long pour une lecture complète", + ); + expect(screen.queryByLabelText("Afficher")).toBeNull(); + expect(screen.queryByRole("button", { name: "Exporter PDF" })).toBeNull(); + }); + it("keeps PDF range, selected detail and pending form input across pages and sizes", async () => { - const entries = datedEntries(23); + const entries = datedEntries(28); api.fetchSleepDiary.mockResolvedValue(snapshotWith(entries)); sportApi.fetchSportSessions.mockResolvedValue([{ identity: "se_old", label: "Old sport", session_type: "training", @@ -312,31 +393,32 @@ describe("SleepDiaryWorkspace", () => { ); vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); render(); - await screen.findByTestId("sleep-agenda-row-sd_023"); + await screen.findByTestId("sleep-agenda-row-sd_028"); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7); 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"]) { + expect([start.value, end.value]).toEqual(["2026-09-01", "2026-09-28"]); + for (const size of ["7", "14", "21", "28", "all"]) { fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: size } }); - if (size !== "all" && size !== "15") + if (size !== "all" && size !== "28") 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([start.value, end.value]).toEqual(["2026-09-01", "2026-09-28"]); } - expect(build).toHaveBeenCalledTimes(4); + expect(build).toHaveBeenCalledTimes(5); 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(selection.summary.nights).toBe(28); + expect(selection.summary.intake_count).toBe(28); expect(sport).toHaveLength(1); - expect([from, to]).toEqual(["2026-09-01", "2026-09-23"]); + expect([from, to]).toEqual(["2026-09-01", "2026-09-28"]); } expect(screen.queryByText(/Sport — horaires enregistrés/)).toBeNull(); expect(screen.getByText("Bleu : séance de sport effectuée")).toBeInTheDocument(); @@ -346,20 +428,20 @@ describe("SleepDiaryWorkspace", () => { 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); + const fewer = many.slice(0, 10); 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.change(screen.getByLabelText("Show"), { target: { value: "7" } }); fireEvent.click(screen.getByRole("button", { name: "Next" })); - expect(screen.getByText("Nights 6–10 of 16 · Page 2/4")).toBeInTheDocument(); + expect(screen.getByText("Nights 8–14 of 16 · Page 2/3")).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(); + await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7)); + expect(screen.getByLabelText("Show")).toHaveValue("7"); + expect(screen.getByTestId("sleep-agenda-row-sd_010")).toBeInTheDocument(); + expect(screen.getByText("Nights 1–7 of 10 · Page 1/2")).toBeInTheDocument(); expect(api.saveSleepEntry).not.toHaveBeenCalled(); }); @@ -367,13 +449,13 @@ describe("SleepDiaryWorkspace", () => { 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"); + fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "14" } }); + expect(window.localStorage.getItem("trainlog.web.sleep.page-size.v1")).toBe("14"); 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); + expect(screen.getByLabelText("Afficher")).toHaveValue("14"); + expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(11); }); it("uses the same inclusive date-range subset for PDF preview and export", async () => { diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 1c19c82..52279e3 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -38,18 +38,26 @@ import { } from "./sleepVisualProjection"; type Language = "fr" | "en"; -type AgendaPageSize = 5 | 10 | 15 | "all"; +type AgendaPageSize = 7 | 14 | 21 | 28 | "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; + if (stored === "7" || stored === "14" || stored === "21" || stored === "28") + return Number(stored) as 7 | 14 | 21 | 28; + // CONTRACT: migrate only this browser-local choice from the previous + // 5/10/15 agenda; no synchronized preference or other key is changed. + const migrated = stored === "5" ? 7 : stored === "10" ? 14 : + stored === "15" ? 21 : null; + if (migrated !== null) { + window.localStorage.setItem(agendaPageSizeKey, String(migrated)); + return migrated; + } } catch { // A disabled browser storage falls back to the documented first-use size. } - return 10; + return 7; }; const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"]; const pointTypes: readonly SleepEventType[] = [ @@ -84,7 +92,9 @@ const copy = { summary: "Synthèse factuelle", empty: "Aucune nuit enregistrée.", agendaEmpty: "Aucune donnée pour cette nuit.", + historyLimit: "Historique Sommeil trop long pour une lecture complète ; l’agenda et le PDF sont désactivés.", show: "Afficher", + days: "jours", all: "Tout", nightsCount: "Nuits", of: "sur", @@ -154,7 +164,9 @@ const copy = { summary: "Factual summary", empty: "No recorded nights.", agendaEmpty: "No data for this night.", + historyLimit: "Sleep history exceeds the complete-read limit; the agenda and PDF are disabled.", show: "Show", + days: "days", all: "All", nightsCount: "Nights", of: "of", @@ -414,8 +426,10 @@ export function SleepDiaryWorkspace({ ); if (exact) adoptEntry(exact); else resetNight(requestedNight); - } catch { - setError("sleep_diary_unavailable"); + } catch (reason) { + setError(reason instanceof RangeError && + reason.message === "sleep_diary_history_limit_reached" + ? reason.message : "sleep_diary_unavailable"); } }; useEffect(() => { @@ -814,6 +828,8 @@ export function SleepDiaryWorkspace({ (pdfEntries.length > 0 || pdfSport.length > 0) ? sleepSnapshotSelection(snapshot, pdfEntries) : null; + if (error === "sleep_diary_history_limit_reached") + return

{t.historyLimit}

; return (
@@ -888,7 +904,7 @@ export function SleepDiaryWorkspace({ value={agendaPageSize} onChange={(event) => { const value = event.target.value; - const next = value === "all" ? "all" : Number(value) as 5 | 10 | 15; + const next = value === "all" ? "all" : Number(value) as 7 | 14 | 21 | 28; setAgendaPageSize(next); try { window.localStorage.setItem(agendaPageSizeKey, String(next)); @@ -898,9 +914,10 @@ export function SleepDiaryWorkspace({ setAgendaPage(0); }} > - - - + + + +