fix(web): paginate sleep agenda newest first with page size selection
This commit is contained in:
parent
8995843e46
commit
f9bb96ae08
5 changed files with 284 additions and 33 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.7
|
## 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
|
- Corrected the Dashboard/Analysis active-Program next-session projection to
|
||||||
select title, identity, and current date from one pending Program session.
|
select title, identity, and current date from one pending Program session.
|
||||||
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
|
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,11 @@ Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||||
Trainlog JSON V1 contract.
|
Trainlog JSON V1 contract.
|
||||||
|
|
||||||
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
|
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
|
presentation-only and atomically drives the detail dates, editor content, and
|
||||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||||
and download instead share an explicit inclusive range over night start dates,
|
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
|
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
|
intersect `[start,end)` and are clipped only for display; missing or invalid
|
||||||
ends are point markers without invented duration. The Web keeps a separate
|
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,
|
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
|
width-aware wrapping, content-driven row heights, and dated continuation pages
|
||||||
for exceptional notes. Each selected intake and remark is printed once without
|
for exceptional notes. Each selected intake and remark is printed once without
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("SleepDiaryWorkspace", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
window.localStorage.removeItem("trainlog.web.sleep.page-size.v1");
|
||||||
vi.useFakeTimers({ toFake: ["Date"] });
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
||||||
vi.clearAllMocks();
|
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 () => {
|
it("uses the same inclusive date-range subset for PDF preview and export", async () => {
|
||||||
const first = dayAEntry();
|
const first = dayAEntry();
|
||||||
const second = {
|
const second = {
|
||||||
|
|
@ -310,13 +437,13 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
const view = render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
const view = render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||||
await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1));
|
||||||
view.rerender(<SleepDiaryWorkspace period="7d" language="fr" />);
|
view.rerender(<SleepDiaryWorkspace period="7d" language="fr" />);
|
||||||
await screen.findByText(/Current session/);
|
await screen.findByRole("img", { name: /Current session/ });
|
||||||
finishOld([{
|
finishOld([{
|
||||||
identity: "se_stale", label: "Stale session", session_type: "training",
|
identity: "se_stale", label: "Stale session", session_type: "training",
|
||||||
started_at: "2026-09-21T07:00:00+02:00",
|
started_at: "2026-09-21T07:00:00+02:00",
|
||||||
ended_at: "2026-09-21T08: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 () => {
|
it("can export sport in a period with no recorded Sleep night", async () => {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,19 @@ import {
|
||||||
} from "./sleepVisualProjection";
|
} from "./sleepVisualProjection";
|
||||||
|
|
||||||
type Language = "fr" | "en";
|
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 q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||||
const pointTypes: readonly SleepEventType[] = [
|
const pointTypes: readonly SleepEventType[] = [
|
||||||
"bed_time",
|
"bed_time",
|
||||||
|
|
@ -71,6 +84,13 @@ const copy = {
|
||||||
summary: "Synthèse factuelle",
|
summary: "Synthèse factuelle",
|
||||||
empty: "Aucune nuit enregistrée.",
|
empty: "Aucune nuit enregistrée.",
|
||||||
agendaEmpty: "Aucune donnée pour cette nuit.",
|
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",
|
preview: "Prévisualiser",
|
||||||
export: "Exporter PDF",
|
export: "Exporter PDF",
|
||||||
pdfFrom: "PDF du",
|
pdfFrom: "PDF du",
|
||||||
|
|
@ -134,6 +154,13 @@ const copy = {
|
||||||
summary: "Factual summary",
|
summary: "Factual summary",
|
||||||
empty: "No recorded nights.",
|
empty: "No recorded nights.",
|
||||||
agendaEmpty: "No data for this night.",
|
agendaEmpty: "No data for this night.",
|
||||||
|
show: "Show",
|
||||||
|
all: "All",
|
||||||
|
nightsCount: "Nights",
|
||||||
|
of: "of",
|
||||||
|
page: "Page",
|
||||||
|
previous: "Previous",
|
||||||
|
next: "Next",
|
||||||
preview: "Preview",
|
preview: "Preview",
|
||||||
export: "Export PDF",
|
export: "Export PDF",
|
||||||
pdfFrom: "PDF from",
|
pdfFrom: "PDF from",
|
||||||
|
|
@ -318,6 +345,8 @@ export function SleepDiaryWorkspace({
|
||||||
const [intakeUnit, setIntakeUnit] = useState("mg");
|
const [intakeUnit, setIntakeUnit] = useState("mg");
|
||||||
const [intakeQuantity, setIntakeQuantity] = useState(1);
|
const [intakeQuantity, setIntakeQuantity] = useState(1);
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
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>(
|
const identity = useRef<{ entry_id: string; revision_id: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -392,6 +421,11 @@ export function SleepDiaryWorkspace({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
}, [period]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setSportState("loading");
|
setSportState("loading");
|
||||||
|
|
@ -725,7 +759,22 @@ export function SleepDiaryWorkspace({
|
||||||
const selectedEntry =
|
const selectedEntry =
|
||||||
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
||||||
activeEntry ??
|
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 [pdfStartDate, setPdfStartDate] = useState("");
|
||||||
const [pdfEndDate, setPdfEndDate] = useState("");
|
const [pdfEndDate, setPdfEndDate] = useState("");
|
||||||
const pdfRangeInitialized = useRef(false);
|
const pdfRangeInitialized = useRef(false);
|
||||||
|
|
@ -753,8 +802,6 @@ export function SleepDiaryWorkspace({
|
||||||
// selection. The range concerns the date on which each night begins; detail
|
// selection. The range concerns the date on which each night begins; detail
|
||||||
// selection remains independent and continues to drive the HR/editor view.
|
// selection remains independent and continues to drive the HR/editor view.
|
||||||
const pdfWindows = pdfEntries.map(sleepRowWindow);
|
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 fallbackStart = Date.parse(`${pdfStartDate}T18:00:00`);
|
||||||
const fallbackEnd = Date.parse(`${pdfEndDate}T18:00:00`) + 86400000;
|
const fallbackEnd = Date.parse(`${pdfEndDate}T18:00:00`) + 86400000;
|
||||||
const pdfWindow = pdfWindows.length > 0 ? {
|
const pdfWindow = pdfWindows.length > 0 ? {
|
||||||
|
|
@ -763,7 +810,6 @@ export function SleepDiaryWorkspace({
|
||||||
} : { start: fallbackStart, end: fallbackEnd };
|
} : { start: fallbackStart, end: fallbackEnd };
|
||||||
const pdfSport = pdfRangeValid ? sport.filter((session) =>
|
const pdfSport = pdfRangeValid ? sport.filter((session) =>
|
||||||
projectSportSegments([session], pdfWindow).length > 0) : [];
|
projectSportSegments([session], pdfWindow).length > 0) : [];
|
||||||
const listedSport = agendaEntries.length === 0 ? pdfSport : agendaSport;
|
|
||||||
const selectedSnapshot = snapshot && sportState === "ready" && pdfRangeValid &&
|
const selectedSnapshot = snapshot && sportState === "ready" && pdfRangeValid &&
|
||||||
(pdfEntries.length > 0 || pdfSport.length > 0)
|
(pdfEntries.length > 0 || pdfSport.length > 0)
|
||||||
? sleepSnapshotSelection(snapshot, pdfEntries)
|
? sleepSnapshotSelection(snapshot, pdfEntries)
|
||||||
|
|
@ -833,8 +879,46 @@ export function SleepDiaryWorkspace({
|
||||||
)}
|
)}
|
||||||
{sportState === "ready" && <p className="sleep-sport-legend">
|
{sportState === "ready" && <p className="sleep-sport-legend">
|
||||||
{language === "fr" ? "Bleu : séance de sport effectuée" : "Blue: completed sport session"}
|
{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>}
|
</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 ? (
|
{agendaEntries.length === 0 ? (
|
||||||
<p className="analysis-empty">{t.agendaEmpty}</p>
|
<p className="analysis-empty">{t.agendaEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -864,7 +948,7 @@ export function SleepDiaryWorkspace({
|
||||||
</span>
|
</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
{agendaEntries.map((entry) => {
|
{visibleEntries.map((entry) => {
|
||||||
const facts = sleepEntryFacts(entry);
|
const facts = sleepEntryFacts(entry);
|
||||||
const projection = projectSleepTimeline(entry);
|
const projection = projectSleepTimeline(entry);
|
||||||
// WHY: persistence metadata may be UTC while factual night
|
// WHY: persistence metadata may be UTC while factual night
|
||||||
|
|
@ -1080,21 +1164,6 @@ export function SleepDiaryWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</article>
|
||||||
|
|
||||||
{selectedEntry && (
|
{selectedEntry && (
|
||||||
|
|
|
||||||
|
|
@ -609,8 +609,13 @@ h2 {
|
||||||
|
|
||||||
.sleep-workspace {
|
.sleep-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.sleep-workspace .analysis-wide {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
.sleep-heading,
|
.sleep-heading,
|
||||||
.sleep-actions,
|
.sleep-actions,
|
||||||
.sleep-entry-header {
|
.sleep-entry-header {
|
||||||
|
|
@ -633,6 +638,9 @@ h2 {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
.sleep-pdf-controls {
|
.sleep-pdf-controls {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
border: 1px solid var(--surface-1);
|
border: 1px solid var(--surface-1);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
|
|
@ -659,6 +667,21 @@ h2 {
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
border-color: var(--lavender);
|
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 {
|
.sleep-pdf-range-error {
|
||||||
margin: 0.25rem 0 0.6rem;
|
margin: 0.25rem 0 0.6rem;
|
||||||
color: var(--peach);
|
color: var(--peach);
|
||||||
|
|
@ -850,6 +873,35 @@ h2 {
|
||||||
color: #629bff;
|
color: #629bff;
|
||||||
font-weight: 700;
|
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 {
|
.sleep-sport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.1rem;
|
top: 0.1rem;
|
||||||
|
|
@ -860,14 +912,6 @@ h2 {
|
||||||
background: #3984eb;
|
background: #3984eb;
|
||||||
z-index: 3;
|
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-point,
|
||||||
.sleep-medication {
|
.sleep-medication {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue