fix(web): unify sleep display with weekly page sizes

This commit is contained in:
fy59 2026-10-02 07:44:42 +02:00
parent f9bb96ae08
commit f4ddcfdb05
8 changed files with 265 additions and 73 deletions

View file

@ -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.

View file

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

View file

@ -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();
}
});
});

View file

@ -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;
}

View file

@ -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(<AnalysisPage />);
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,

View file

@ -628,22 +628,24 @@ export function AnalysisPage() {
</button>
))}
</div>
<label>
{t.period}
<select
aria-label={t.period}
value={period}
onChange={(event) =>
choosePeriod(event.target.value as AnalysisPeriod)
}
>
{ANALYSIS_PERIODS.map((value) => (
<option key={value} value={value}>
{t.periods[value]}
</option>
))}
</select>
</label>
{section !== "sleep" && (
<label>
{t.period}
<select
aria-label={t.period}
value={period}
onChange={(event) =>
choosePeriod(event.target.value as AnalysisPeriod)
}
>
{ANALYSIS_PERIODS.map((value) => (
<option key={value} value={value}>
{t.periods[value]}
</option>
))}
</select>
</label>
)}
</div>
{pending && snapshot === null && <p role="status">{t.loading}</p>}
{failed && snapshot === null && (
@ -945,7 +947,9 @@ export function AnalysisPage() {
}
retryLabel={language === "fr" ? "Réessayer" : "Try again"}
>
<SleepDiaryWorkspace period={period} language={language} />
{/* CONTRACT: Sleep owns its complete history; the retained
Analysis period still belongs to the other sections. */}
<SleepDiaryWorkspace period="all" language={language} />
</SectionErrorBoundary>
)}
</>

View file

@ -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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="en" />);
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(<SleepDiaryWorkspace period="7d" language="en" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="fr" />);
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 () => {

View file

@ -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 <p className="error-panel" role="alert">{t.historyLimit}</p>;
return (
<div className="sleep-workspace" data-testid="sleep-workspace">
<article className="analysis-panel analysis-wide">
@ -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);
}}
>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="7">7 {t.days}</option>
<option value="14">14 {t.days}</option>
<option value="21">21 {t.days}</option>
<option value="28">28 {t.days}</option>
<option value="all">{t.all}</option>
</select>
</label>