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 &&