Compare commits

..

No commits in common. "f9bb96ae084a29dfbbf60d5776a6816e27717f3c" and "e64b9ddcd3b5c35b1904b822f9d033ce5c6d39de" have entirely different histories.

7 changed files with 51 additions and 426 deletions

View file

@ -2,18 +2,6 @@
## 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,
and an overdue uncompleted session remains eligible. The read-only projection
does not change Program history, preparation priority, or synchronization.
- Aligned the Web front/back BODY ZONES figures with Android's normalized
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
region paths per face, while retaining the existing Catppuccin intensity

View file

@ -236,11 +236,7 @@ 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
night has one row on the shared 18:00-to-18:00 geometry. 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,
@ -251,8 +247,7 @@ 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 with accessible labels and tooltips; the redundant textual Web sport
list is removed. Preview and download wait for the
blue lane and accessible session list. 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

@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
static bool
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
static const char SQL[] =
/* WHY: imported dates are historical after Program rescheduling.
* CONTRACT: select one pending, non-ceded session with its current date.
* INVARIANT: a present planning row with NULL current_for stays undated. */
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
"program_session_id) AS rank FROM eligible) "
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
"next.title,next.program_session_id,next.current_for "
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
"LIMIT 1),"
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
"pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
"DESC,p.program_id "
"DESC "
"LIMIT 1";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *program = yyjson_mut_obj(document);

View file

@ -225,8 +225,7 @@ static bool test_factual_models(void) {
{
yyjson_val *program = yyjson_obj_get(root, "active_program");
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
"2026-09-21") == 0);
}
@ -251,123 +250,6 @@ static bool test_factual_models(void) {
return true;
}
static bool check_next_session(TrainlogDatabase *database,
const char *expected_id,
const char *expected_title,
const char *expected_date) {
yyjson_doc *document = NULL;
yyjson_val *program;
yyjson_val *id;
yyjson_val *title;
yyjson_val *date;
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
CHECK(!yyjson_is_null(program));
id = yyjson_obj_get(program, "next_session_id");
title = yyjson_obj_get(program, "next_session_title");
date = yyjson_obj_get(program, "next_session_planned_for");
CHECK(expected_id == NULL ? yyjson_is_null(id)
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
CHECK(expected_title == NULL
? yyjson_is_null(title)
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
CHECK(expected_date == NULL
? yyjson_is_null(date)
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
yyjson_doc_free(document);
return true;
}
static bool test_current_program_plan(void) {
TrainlogDatabase *database = NULL;
yyjson_doc *document = NULL;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
/* WHY: source dates remain historical; current planning and execution are
* independent facts. These rows exercise their precedence without touching
* any user database or invoking the rescheduling command. */
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
"NULL);"
"INSERT INTO program_sessions VALUES"
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_planning VALUES"
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
"'2026-10-02T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"UPDATE program_session_executions SET state='completed' "
"WHERE program_session_id='ps_s2c';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_undated','pg_current','se_fixture_4','completed',"
"'2026-10-04T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, NULL, NULL, NULL));
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
document = NULL;
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
"WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
trainlog_database_close(database);
return true;
}
int main(void) {
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
}

View file

@ -184,25 +184,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")}`;
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();
@ -266,116 +249,6 @@ 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 = {
@ -437,13 +310,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.findByRole("img", { name: /Current session/ });
await screen.findByText(/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.queryByRole("img", { name: /Stale session/ })).toBeNull());
await waitFor(() => expect(screen.queryByText(/Stale session/)).toBeNull());
});
it("can export sport in a period with no recorded Sleep night", async () => {

View file

@ -38,19 +38,6 @@ 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",
@ -84,13 +71,6 @@ 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",
@ -154,13 +134,6 @@ 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",
@ -345,8 +318,6 @@ 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,
);
@ -421,11 +392,6 @@ 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");
@ -759,22 +725,7 @@ export function SleepDiaryWorkspace({
const selectedEntry =
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
activeEntry ??
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);
agendaEntries[0];
const [pdfStartDate, setPdfStartDate] = useState("");
const [pdfEndDate, setPdfEndDate] = useState("");
const pdfRangeInitialized = useRef(false);
@ -802,6 +753,8 @@ 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 ? {
@ -810,6 +763,7 @@ 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)
@ -879,46 +833,8 @@ 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>
) : (
@ -948,7 +864,7 @@ export function SleepDiaryWorkspace({
</span>
<span />
</div>
{visibleEntries.map((entry) => {
{agendaEntries.map((entry) => {
const facts = sleepEntryFacts(entry);
const projection = projectSleepTimeline(entry);
// WHY: persistence metadata may be UTC while factual night
@ -1164,6 +1080,21 @@ 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,13 +609,8 @@ 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 {
@ -638,9 +633,6 @@ 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;
@ -667,21 +659,6 @@ 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);
@ -873,35 +850,6 @@ 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;
@ -912,6 +860,14 @@ 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;