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
|
## Unreleased — 0.1.7
|
||||||
|
|
||||||
- Show the Web Sleep agenda newest first with a 5, 10, 15, or All night
|
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
|
||||||
selector (10 by default). Page changes leave the selected night, editor,
|
recorded-night selector (7 by default). Sleep reads its full available history
|
||||||
statistics, and inclusive PDF range untouched. Removed only the redundant
|
without the Analysis period control; other Analysis sections retain their
|
||||||
textual sport list below the Web agenda; factual blue overlays, the sport
|
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
|
||||||
legend, and the complete PDF sport appendix remain.
|
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
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -235,18 +235,22 @@ parallel implementation of its rules.
|
||||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
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 reads the complete available Sleep history independently
|
||||||
night remains available on the shared 18:00-to-18:00 geometry. The Web shows
|
of the Analysis period used by other sections. Every returned night remains
|
||||||
the newest nights first, ten per page by default, with 5, 10, 15, and All
|
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
|
||||||
choices. The size choice is local to the browser and survives reloads. Paging
|
selector: the newest nights first, seven recorded nights per page by default,
|
||||||
changes only the visible rows; PDF selection, statistics, and
|
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
|
||||||
the current editor remain based on the complete loaded period. Selecting a row is
|
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
|
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,
|
||||||
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`
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { newSleepId, sleepTimestamp } from "./sleepDiary";
|
import { fetchSleepDiary, newSleepId, sleepTimestamp } from "./sleepDiary";
|
||||||
|
|
||||||
describe("sleepTimestamp", () => {
|
describe("sleepTimestamp", () => {
|
||||||
it("serializes browser timestamps at the C17 contract precision", () => {
|
it("serializes browser timestamps at the C17 contract precision", () => {
|
||||||
|
|
@ -22,3 +22,37 @@ describe("newSleepId", () => {
|
||||||
vi.unstubAllGlobals();
|
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)
|
!object(value.summary)
|
||||||
)
|
)
|
||||||
throw new TypeError("sleep_diary_invalid");
|
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;
|
return value as unknown as SleepSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,49 @@ describe("AnalysisPage", () => {
|
||||||
expect(window.location.search).toContain("section=measurements");
|
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 () => {
|
it("supports exercise and BODY ZONE deep-link sections without duplicating the global language control", async () => {
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -628,22 +628,24 @@ export function AnalysisPage() {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label>
|
{section !== "sleep" && (
|
||||||
{t.period}
|
<label>
|
||||||
<select
|
{t.period}
|
||||||
aria-label={t.period}
|
<select
|
||||||
value={period}
|
aria-label={t.period}
|
||||||
onChange={(event) =>
|
value={period}
|
||||||
choosePeriod(event.target.value as AnalysisPeriod)
|
onChange={(event) =>
|
||||||
}
|
choosePeriod(event.target.value as AnalysisPeriod)
|
||||||
>
|
}
|
||||||
{ANALYSIS_PERIODS.map((value) => (
|
>
|
||||||
<option key={value} value={value}>
|
{ANALYSIS_PERIODS.map((value) => (
|
||||||
{t.periods[value]}
|
<option key={value} value={value}>
|
||||||
</option>
|
{t.periods[value]}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</label>
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pending && snapshot === null && <p role="status">{t.loading}</p>}
|
{pending && snapshot === null && <p role="status">{t.loading}</p>}
|
||||||
{failed && snapshot === null && (
|
{failed && snapshot === null && (
|
||||||
|
|
@ -945,7 +947,9 @@ export function AnalysisPage() {
|
||||||
}
|
}
|
||||||
retryLabel={language === "fr" ? "Réessayer" : "Try again"}
|
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>
|
</SectionErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,8 @@ const dayAEntry = (): SleepEntry => ({
|
||||||
|
|
||||||
const datedEntries = (count: number): SleepEntry[] =>
|
const datedEntries = (count: number): SleepEntry[] =>
|
||||||
Array.from({ length: count }, (_, index) => {
|
Array.from({ length: count }, (_, index) => {
|
||||||
const date = `2026-09-${String(index + 1).padStart(2, "0")}`;
|
const date = new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10);
|
||||||
const next = `2026-09-${String(index + 2).padStart(2, "0")}`;
|
const next = new Date(Date.UTC(2026, 8, index + 2)).toISOString().slice(0, 10);
|
||||||
return {
|
return {
|
||||||
...dayAEntry(),
|
...dayAEntry(),
|
||||||
entry_id: `sd_${String(index + 1).padStart(3, "0")}`,
|
entry_id: `sd_${String(index + 1).padStart(3, "0")}`,
|
||||||
|
|
@ -195,7 +195,7 @@ const datedEntries = (count: number): SleepEntry[] =>
|
||||||
night_start_date: date,
|
night_start_date: date,
|
||||||
night_end_date: next,
|
night_end_date: next,
|
||||||
// A recently edited old night must retain its chronological position.
|
// 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}`,
|
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",
|
"paginates %i nights newest first without changing the source snapshot",
|
||||||
async (count) => {
|
async (count) => {
|
||||||
const original = datedEntries(count);
|
const original = datedEntries(count);
|
||||||
|
|
@ -275,32 +275,113 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
if (source.length > 0) source.push(source[0]);
|
if (source.length > 0) source.push(source[0]);
|
||||||
api.fetchSleepDiary.mockResolvedValue(snapshotWith(source));
|
api.fetchSleepDiary.mockResolvedValue(snapshotWith(source));
|
||||||
render(<SleepDiaryWorkspace period="all" language="fr" />);
|
render(<SleepDiaryWorkspace period="all" language="fr" />);
|
||||||
await waitFor(() => expect(screen.getByLabelText("Afficher")).toHaveValue("10"));
|
await waitFor(() => expect(screen.getByLabelText("Afficher")).toHaveValue("7"));
|
||||||
await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(Math.min(count, 10)));
|
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"));
|
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}`));
|
`sleep-agenda-row-${entry.entry_id}`));
|
||||||
expect(source[0]).toBe(original[0]);
|
expect(source[0]).toBe(original[0]);
|
||||||
if (count > 10) {
|
if (count > 7) {
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Suivant" }));
|
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}`));
|
`sleep-agenda-row-${entry.entry_id}`));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Précédent" }));
|
fireEvent.click(screen.getByRole("button", { name: "Précédent" }));
|
||||||
}
|
}
|
||||||
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "all" } });
|
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "all" } });
|
||||||
expect(rows()).toHaveLength(count);
|
expect(rows()).toHaveLength(count);
|
||||||
expect(screen.queryByRole("button", { name: "Suivant" })).toBeNull();
|
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) } });
|
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: String(size) } });
|
||||||
expect(rows()).toHaveLength(Math.min(count, 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();
|
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 () => {
|
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));
|
api.fetchSleepDiary.mockResolvedValue(snapshotWith(entries));
|
||||||
sportApi.fetchSportSessions.mockResolvedValue([{
|
sportApi.fetchSportSessions.mockResolvedValue([{
|
||||||
identity: "se_old", label: "Old sport", session_type: "training",
|
identity: "se_old", label: "Old sport", session_type: "training",
|
||||||
|
|
@ -312,31 +393,32 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
);
|
);
|
||||||
vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {});
|
vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {});
|
||||||
render(<SleepDiaryWorkspace period="all" language="fr" />);
|
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());
|
await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled());
|
||||||
expect(screen.queryByTestId("sleep-agenda-sport")).toBeNull();
|
expect(screen.queryByTestId("sleep-agenda-sport")).toBeNull();
|
||||||
expect(screen.queryByText(/Aucune dans la période/)).toBeNull();
|
expect(screen.queryByText(/Aucune dans la période/)).toBeNull();
|
||||||
fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: "21:15" } });
|
fireEvent.change(screen.getByTestId("sleep-event-start"), { target: { value: "21:15" } });
|
||||||
const start = screen.getByLabelText("PDF du") as HTMLInputElement;
|
const start = screen.getByLabelText("PDF du") as HTMLInputElement;
|
||||||
const end = screen.getByLabelText("au") as HTMLInputElement;
|
const end = screen.getByLabelText("au") as HTMLInputElement;
|
||||||
expect([start.value, end.value]).toEqual(["2026-09-01", "2026-09-23"]);
|
expect([start.value, end.value]).toEqual(["2026-09-01", "2026-09-28"]);
|
||||||
for (const size of ["10", "5", "15", "all"]) {
|
for (const size of ["7", "14", "21", "28", "all"]) {
|
||||||
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: size } });
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Suivant" }));
|
||||||
if (size === "all")
|
if (size === "all")
|
||||||
expect(screen.getByTestId("sleep-agenda-sport")).toHaveAttribute("aria-label", expect.stringContaining("Old sport"));
|
expect(screen.getByTestId("sleep-agenda-sport")).toHaveAttribute("aria-label", expect.stringContaining("Old sport"));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" }));
|
fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" }));
|
||||||
expect(screen.getByTestId("sleep-event-start")).toHaveValue("21:15");
|
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) {
|
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.entries.map((entry) => entry.entry_id)).toEqual(entries.map((entry) => entry.entry_id));
|
||||||
expect(selection.summary.nights).toBe(23);
|
expect(selection.summary.nights).toBe(28);
|
||||||
expect(selection.summary.intake_count).toBe(23);
|
expect(selection.summary.intake_count).toBe(28);
|
||||||
expect(sport).toHaveLength(1);
|
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.queryByText(/Sport — horaires enregistrés/)).toBeNull();
|
||||||
expect(screen.getByText("Bleu : séance de sport effectuée")).toBeInTheDocument();
|
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 () => {
|
it("retains the page size but returns to newest on a deliberate period change", async () => {
|
||||||
const many = datedEntries(16);
|
const many = datedEntries(16);
|
||||||
const fewer = many.slice(0, 6);
|
const fewer = many.slice(0, 10);
|
||||||
api.fetchSleepDiary
|
api.fetchSleepDiary
|
||||||
.mockResolvedValueOnce(snapshotWith(many))
|
.mockResolvedValueOnce(snapshotWith(many))
|
||||||
.mockResolvedValue(snapshotWith(fewer));
|
.mockResolvedValue(snapshotWith(fewer));
|
||||||
const view = render(<SleepDiaryWorkspace period="all" language="en" />);
|
const view = render(<SleepDiaryWorkspace period="all" language="en" />);
|
||||||
await screen.findByTestId("sleep-agenda-row-sd_016");
|
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" }));
|
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" />);
|
view.rerender(<SleepDiaryWorkspace period="7d" language="en" />);
|
||||||
await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(5));
|
await waitFor(() => expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(7));
|
||||||
expect(screen.getByLabelText("Show")).toHaveValue("5");
|
expect(screen.getByLabelText("Show")).toHaveValue("7");
|
||||||
expect(screen.getByTestId("sleep-agenda-row-sd_006")).toBeInTheDocument();
|
expect(screen.getByTestId("sleep-agenda-row-sd_010")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Nights 1–5 of 6 · Page 1/2")).toBeInTheDocument();
|
expect(screen.getByText("Nights 1–7 of 10 · Page 1/2")).toBeInTheDocument();
|
||||||
expect(api.saveSleepEntry).not.toHaveBeenCalled();
|
expect(api.saveSleepEntry).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -367,13 +449,13 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
api.fetchSleepDiary.mockResolvedValue(snapshotWith(datedEntries(11)));
|
api.fetchSleepDiary.mockResolvedValue(snapshotWith(datedEntries(11)));
|
||||||
const first = render(<SleepDiaryWorkspace period="all" language="fr" />);
|
const first = render(<SleepDiaryWorkspace period="all" language="fr" />);
|
||||||
await screen.findByTestId("sleep-agenda-row-sd_011");
|
await screen.findByTestId("sleep-agenda-row-sd_011");
|
||||||
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "5" } });
|
fireEvent.change(screen.getByLabelText("Afficher"), { target: { value: "14" } });
|
||||||
expect(window.localStorage.getItem("trainlog.web.sleep.page-size.v1")).toBe("5");
|
expect(window.localStorage.getItem("trainlog.web.sleep.page-size.v1")).toBe("14");
|
||||||
first.unmount();
|
first.unmount();
|
||||||
render(<SleepDiaryWorkspace period="all" language="fr" />);
|
render(<SleepDiaryWorkspace period="all" language="fr" />);
|
||||||
await screen.findByTestId("sleep-agenda-row-sd_011");
|
await screen.findByTestId("sleep-agenda-row-sd_011");
|
||||||
expect(screen.getByLabelText("Afficher")).toHaveValue("5");
|
expect(screen.getByLabelText("Afficher")).toHaveValue("14");
|
||||||
expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(5);
|
expect(screen.queryAllByTestId(/^sleep-agenda-row-/)).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,26 @@ import {
|
||||||
} from "./sleepVisualProjection";
|
} from "./sleepVisualProjection";
|
||||||
|
|
||||||
type Language = "fr" | "en";
|
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 agendaPageSizeKey = "trainlog.web.sleep.page-size.v1";
|
||||||
const initialAgendaPageSize = (): AgendaPageSize => {
|
const initialAgendaPageSize = (): AgendaPageSize => {
|
||||||
try {
|
try {
|
||||||
const stored = window.localStorage.getItem(agendaPageSizeKey);
|
const stored = window.localStorage.getItem(agendaPageSizeKey);
|
||||||
if (stored === "all") return "all";
|
if (stored === "all") return "all";
|
||||||
if (stored === "5" || stored === "10" || stored === "15")
|
if (stored === "7" || stored === "14" || stored === "21" || stored === "28")
|
||||||
return Number(stored) as 5 | 10 | 15;
|
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 {
|
} catch {
|
||||||
// A disabled browser storage falls back to the documented first-use size.
|
// 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 q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||||
const pointTypes: readonly SleepEventType[] = [
|
const pointTypes: readonly SleepEventType[] = [
|
||||||
|
|
@ -84,7 +92,9 @@ 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.",
|
||||||
|
historyLimit: "Historique Sommeil trop long pour une lecture complète ; l’agenda et le PDF sont désactivés.",
|
||||||
show: "Afficher",
|
show: "Afficher",
|
||||||
|
days: "jours",
|
||||||
all: "Tout",
|
all: "Tout",
|
||||||
nightsCount: "Nuits",
|
nightsCount: "Nuits",
|
||||||
of: "sur",
|
of: "sur",
|
||||||
|
|
@ -154,7 +164,9 @@ 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.",
|
||||||
|
historyLimit: "Sleep history exceeds the complete-read limit; the agenda and PDF are disabled.",
|
||||||
show: "Show",
|
show: "Show",
|
||||||
|
days: "days",
|
||||||
all: "All",
|
all: "All",
|
||||||
nightsCount: "Nights",
|
nightsCount: "Nights",
|
||||||
of: "of",
|
of: "of",
|
||||||
|
|
@ -414,8 +426,10 @@ export function SleepDiaryWorkspace({
|
||||||
);
|
);
|
||||||
if (exact) adoptEntry(exact);
|
if (exact) adoptEntry(exact);
|
||||||
else resetNight(requestedNight);
|
else resetNight(requestedNight);
|
||||||
} catch {
|
} catch (reason) {
|
||||||
setError("sleep_diary_unavailable");
|
setError(reason instanceof RangeError &&
|
||||||
|
reason.message === "sleep_diary_history_limit_reached"
|
||||||
|
? reason.message : "sleep_diary_unavailable");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -814,6 +828,8 @@ export function SleepDiaryWorkspace({
|
||||||
(pdfEntries.length > 0 || pdfSport.length > 0)
|
(pdfEntries.length > 0 || pdfSport.length > 0)
|
||||||
? sleepSnapshotSelection(snapshot, pdfEntries)
|
? sleepSnapshotSelection(snapshot, pdfEntries)
|
||||||
: null;
|
: null;
|
||||||
|
if (error === "sleep_diary_history_limit_reached")
|
||||||
|
return <p className="error-panel" role="alert">{t.historyLimit}</p>;
|
||||||
return (
|
return (
|
||||||
<div className="sleep-workspace" data-testid="sleep-workspace">
|
<div className="sleep-workspace" data-testid="sleep-workspace">
|
||||||
<article className="analysis-panel analysis-wide">
|
<article className="analysis-panel analysis-wide">
|
||||||
|
|
@ -888,7 +904,7 @@ export function SleepDiaryWorkspace({
|
||||||
value={agendaPageSize}
|
value={agendaPageSize}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value;
|
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);
|
setAgendaPageSize(next);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(agendaPageSizeKey, String(next));
|
window.localStorage.setItem(agendaPageSizeKey, String(next));
|
||||||
|
|
@ -898,9 +914,10 @@ export function SleepDiaryWorkspace({
|
||||||
setAgendaPage(0);
|
setAgendaPage(0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="5">5</option>
|
<option value="7">7 {t.days}</option>
|
||||||
<option value="10">10</option>
|
<option value="14">14 {t.days}</option>
|
||||||
<option value="15">15</option>
|
<option value="21">21 {t.days}</option>
|
||||||
|
<option value="28">28 {t.days}</option>
|
||||||
<option value="all">{t.all}</option>
|
<option value="all">{t.all}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue