fix(web): unify sleep display with weekly page sizes
This commit is contained in:
parent
f9bb96ae08
commit
f4ddcfdb05
8 changed files with 265 additions and 73 deletions
13
CHANGELOG.md
13
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue