fix(web): paginate sleep agenda newest first with page size selection

This commit is contained in:
fy59 2026-10-02 07:12:02 +02:00
parent 8995843e46
commit f9bb96ae08
5 changed files with 284 additions and 33 deletions

View file

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

View file

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

View file

@ -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(<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)));
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(<SleepDiaryWorkspace period="all" language="fr" />);
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(<SleepDiaryWorkspace period="all" language="en" />);
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(<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();
expect(api.saveSleepEntry).not.toHaveBeenCalled();
});
it("restores the local page-size choice after remounting", async () => {
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");
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);
});
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(<SleepDiaryWorkspace period="30d" language="fr" />);
await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1));
view.rerender(<SleepDiaryWorkspace period="7d" language="fr" />);
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 () => {

View file

@ -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<string | null>(null);
const [agendaPageSize, setAgendaPageSize] = useState<AgendaPageSize>(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" && <p className="sleep-sport-legend">
{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")}
</p>}
<div className="sleep-agenda-pagination" aria-label={t.nightsCount}>
<label>
<span>{t.show} :</span>
<select
aria-label={t.show}
value={agendaPageSize}
onChange={(event) => {
const value = event.target.value;
const next = value === "all" ? "all" : Number(value) as 5 | 10 | 15;
setAgendaPageSize(next);
try {
window.localStorage.setItem(agendaPageSizeKey, String(next));
} catch {
// The current page still works when browser storage is unavailable.
}
setAgendaPage(0);
}}
>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="all">{t.all}</option>
</select>
</label>
<span role="status" aria-live="polite">
{t.nightsCount} {recentEntries.length === 0 ? "0" :
`${firstVisible + 1}–${firstVisible + visibleEntries.length}`} {t.of} {recentEntries.length}
{agendaPageSize !== "all" && pageCount > 1 &&
` · ${t.page} ${visiblePage + 1}/${pageCount}`}
</span>
{agendaPageSize !== "all" && pageCount > 1 && (
<div className="sleep-agenda-page-buttons">
<button type="button" className="quiet-action" disabled={visiblePage === 0}
onClick={() => setAgendaPage(Math.max(0, visiblePage - 1))}>{t.previous}</button>
<button type="button" className="quiet-action" disabled={visiblePage >= pageCount - 1}
onClick={() => setAgendaPage(Math.min(pageCount - 1, visiblePage + 1))}>{t.next}</button>
</div>
)}
</div>
{agendaEntries.length === 0 ? (
<p className="analysis-empty">{t.agendaEmpty}</p>
) : (
@ -864,7 +948,7 @@ export function SleepDiaryWorkspace({
</span>
<span />
</div>
{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({
</div>
</div>
)}
{sportState === "ready" && listedSport.length > 0 && (
<div className="sleep-sport-list">
<strong>{language === "fr" ? "Sport — horaires enregistrés" : "Sport — recorded times"}</strong>
<ul>{listedSport.map((session) => (
<li key={session.identity}>
{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")}
</li>
))}</ul>
</div>
)}
</article>
{selectedEntry && (

View file

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