From e2c07f1e808d7985181b30c68e37541c03f6ffe4 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 1 Oct 2026 21:06:58 +0200 Subject: [PATCH] feat(web): overlay completed sport on Sleep agenda and PDF --- CHANGELOG.md | 9 ++ docs/current_state.md | 33 +++-- docs/roadmap.md | 12 +- docs/tests.md | 11 ++ tui/include/trainlog/web_sessions.h | 13 ++ tui/src/web_server.c | 8 ++ tui/src/web_sessions.c | 112 +++++++++++++++ tui/tests/test_web_sessions.c | 52 ++++++- web/src/api/sportSessions.test.ts | 39 ++++++ web/src/api/sportSessions.ts | 57 ++++++++ web/src/report/sleepDiaryPdf.test.ts | 31 +++++ web/src/report/sleepDiaryPdf.ts | 98 ++++++++++--- web/src/routes/SleepDiaryWorkspace.test.tsx | 58 ++++++++ web/src/routes/SleepDiaryWorkspace.tsx | 136 ++++++++++++++----- web/src/routes/sleepTimelineGeometry.test.ts | 49 +++++++ web/src/routes/sleepTimelineGeometry.ts | 60 ++++++++ web/src/theme/app.css | 27 +++- 17 files changed, 734 insertions(+), 71 deletions(-) create mode 100644 web/src/api/sportSessions.test.ts create mode 100644 web/src/api/sportSessions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0ca2a..1422ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased — 0.1.7 +- Added a read-only completed-session overlay to the Web Sleep agenda and its + local vector PDF preview/export. Factual session start and end times share + the Sleep row's 18:00 axis in both renderers; blue outlined bands and a + printable paginated listing expose recorded names and times. Missing ends + remain point markers. The report flags sport without a recorded Sleep row, + and export waits for the complete paginated history read. Sleep entries, + session history, database schema, Android, and synchronization contracts + remain unchanged. + The isolated `trainlog-program-recalage-v1` candidate adds an explicit preview-and-confirm cascade for unfinished Program sessions. It retains source and actual dates, stores absolute current dates and ceded-slot state, and diff --git a/docs/current_state.md b/docs/current_state.md index 78c6622..9597cdb 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,15 +1,15 @@ # Current implementation state -## Isolated Program rescheduling candidate +## Private installation and isolated Sleep sport overlay -The `trainlog-program-recalage-v1` worktree contains a local candidate for -explicit ordered Program rescheduling, desktop schema v37, Android schema v34, -and the capability-selected `trainlog-programs` V2 projection. The stable -0.1.7 installation and personal Program have not been migrated or rescheduled. -The candidate passed local native, Web, Android JVM, build, and targeted -sanitizer validation. It still requires coordinated PC/Android deployment -before it is an operational feature. Completed-session calendar -dates in stable 0.1.7 continue to come from their linked factual sessions. +The private daily installation runs the explicit ordered Program rescheduling +rollout from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema +v37, Android schema v34, and the capability-selected `trainlog-programs` V2 +projection. The separate `trainlog-sleep-sport-overlay-v1` worktree starts from +that deployed source and adds the read-only completed-sport Sleep overlay. +Local source and isolated-browser validation do not by themselves establish +that the new overlay is served by the daily installation. Completed-session +calendar dates continue to come from their linked factual sessions. Rollout packaging requires an explicit Meson `-Dweb=enabled` build; the embedded frontend and its native Web tests are part of candidate validation. @@ -224,8 +224,19 @@ 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, -initialized to the loaded agenda bounds and independent of detail selection. Its -rise/fall overlays are descriptive relative measured +initialized to the loaded agenda bounds and independent of detail selection. +The completed-sport overlay reads factual `sessions.started_at` and `ended_at` +from desktop history through a read-only, period-bounded paginated Web endpoint. +Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for +Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals +intersect `[start,end)` and are clipped only for display; missing or invalid +ends are point markers without invented duration. The Web keeps a separate +blue lane and accessible session list. Preview and download wait for the +complete sport read and use the same selected sessions. The PDF prints blue +outlined bands and paginated names, factual times, and durations, marking +sessions without a corresponding Sleep row as `sleep not recorded`. Sport +creates no Sleep event and changes no sleep total, session, schema, or sync +contract. The HR detail's rise/fall overlays are descriptive relative measured variation only (five-minute trailing reference excluding the current 60-second window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They are not sleep events, sleep stages, causes, readiness, or medical inference. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5de7797..49d0f90 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,11 @@ # Roadmap -The isolated Program cascade candidate is implemented in the -`trainlog-program-recalage-v1` worktree and remains outside the stable 0.1.7 -installation. Its next gate is a coordinated PC/Android deployment review, -followed by a newly calculated personal Program preview and separate user -confirmation. No personal reschedule or publication is implied by candidate -tests. +The Program cascade is deployed in the private daily installation from +`trainlog-program-recalage-v1` at `3a4ec34`. The separate +`trainlog-sleep-sport-overlay-v1` source candidate adds the read-only Sleep +sport display and must be verified on the actual Web/PDF installation before +that display is called deployed. No new personal Program rescheduling is +implied by this presentation work. The controlled private rollout is complete for the paired daily installation: physical libmtp, verified backups, signing continuity, migrations, correlated diff --git a/docs/tests.md b/docs/tests.md index ee47bc5..4bf0879 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -398,6 +398,17 @@ Firefox scenario exports a seven-night French PDF from the embedded production bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page with `pdftoppm` for retained visual inspection. +The completed-sport Sleep overlay is covered by C read-only history +intersection/pagination checks, Web pagination and failure-state tests, and +pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and +invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue +bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic +Firefox validation uses an isolated schema-v37 database and the embedded +`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`. +Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized +for page, margin, and monochrome inspection. No personal health artifact is +stored in the repository. + `WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed field and Vitest fixtures confined to test sources. Coverage proves the 90-day activity map and direct totals, all compact/medium/large disclosure levels, diff --git a/tui/include/trainlog/web_sessions.h b/tui/include/trainlog/web_sessions.h index 7d1de54..f106821 100644 --- a/tui/include/trainlog/web_sessions.h +++ b/tui/include/trainlog/web_sessions.h @@ -41,6 +41,19 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, char **output_json, size_t *output_size); +/* WHY: the Sleep agenda needs factual completed sessions without fetching one + * detail per row. CONTRACT: start/end are bounded ISO instants; intervals + * intersect [start,end), and a session with no valid end is a point at start. + * Pages are ordered by factual start and stable ID. No state is mutated. + * INVARIANT: the caller owns output_json and must paginate until more=false. */ +TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database, + const char *start, + const char *end, + size_t offset, + size_t limit, + char **output_json, + size_t *output_size); + /* CONTRACT: kind is one of preparation, proposal, draft, or history. The * returned detail is a factual snapshot and performs no lifecycle transition. */ TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database, diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 204568f..d5dad2b 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -1935,6 +1935,14 @@ static enum MHD_Result handle_request(void *closure, context->database, search, offset, limit, &json, &json_size); return queue_owned_sessions_json(connection, status, json, json_size); } + if (strcmp(url, "/api/v1/sessions/sport") == 0) { + const char *start = + MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start"); + const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end"); + TrainlogStatus status = trainlog_web_sessions_sport_json( + context->database, start, end, offset, limit, &json, &json_size); + return queue_owned_sessions_json(connection, status, json, json_size); + } for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) { if (strcmp(url, collections[index].path) == 0) { TrainlogWebSessionsPageQuery query = { diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index 56d8352..2969a8d 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -10,6 +10,7 @@ #include #include "database_internal.h" +#include "timestamp.h" #include "trainlog/id.h" typedef struct ListDefinition { @@ -95,6 +96,117 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size return TRAINLOG_STATUS_OK; } +TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database, + const char *start, + const char *end, + size_t offset, + size_t limit, + char **output_json, + size_t *output_size) { + static const char SQL[] = + "SELECT s.session_id,s.session_type,s.started_at,s.ended_at," + "COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM " + "session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE " + "se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) " + "FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND " + "unixepoch(s.started_at)unixepoch(s.started_at) " + "THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) " + "END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " + "c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) " + "ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4"; + sqlite3_stmt *statement = NULL; + yyjson_mut_doc *document = NULL; + yyjson_mut_val *root; + yyjson_mut_val *items; + TrainlogStatus status; + TrainlogStatus end_status; + size_t count = 0U; + int step = SQLITE_DONE; + TrainlogTimestampKey start_key; + TrainlogTimestampKey end_key; + + if (database == NULL || start == NULL || end == NULL || output_json == NULL || + output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || + offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U || + !trainlog_timestamp_parse(start, strlen(start), &start_key) || + !trainlog_timestamp_parse(end, strlen(end), &end_key) || + trainlog_timestamp_compare(&start_key, &end_key) >= 0) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_json = NULL; + *output_size = 0U; + status = trainlog_database_read_snapshot_begin(database); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK || + sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finish_sport; + } + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + items = document == NULL ? NULL : yyjson_mut_arr(document); + if (root == NULL || items == NULL) { + status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finish_sport; + } + yyjson_mut_doc_set_root(document, root); + if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) || + !yyjson_mut_obj_add_uint(document, root, "offset", offset) || + !yyjson_mut_obj_add_val(document, root, "items", items)) { + status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finish_sport; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + yyjson_mut_val *item; + if (count == limit) { + ++count; + break; + } + item = yyjson_mut_obj(document); + if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) || + !add_nullable_text(document, item, "session_type", statement, 1) || + !add_nullable_text(document, item, "started_at", statement, 2) || + !add_nullable_text(document, item, "ended_at", statement, 3) || + !add_nullable_text(document, item, "label", statement, 4) || + !yyjson_mut_arr_add_val(items, item)) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finish_sport; + } + ++count; + } + if (step != SQLITE_DONE && count <= limit) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finish_sport; + } + if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) || + !yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) { + status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finish_sport; + } + status = TRAINLOG_STATUS_OK; + +finish_sport: + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && + status == TRAINLOG_STATUS_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + } + end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); + if (status == TRAINLOG_STATUS_OK) { + status = end_status; + } + if (status != TRAINLOG_STATUS_OK) { + yyjson_mut_doc_free(document); + return status; + } + return write_document(document, output_json, output_size); +} + TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, const TrainlogWebSessionsPageQuery *query, char **output_json, diff --git a/tui/tests/test_web_sessions.c b/tui/tests/test_web_sessions.c index f092185..3b3509c 100644 --- a/tui/tests/test_web_sessions.c +++ b/tui/tests/test_web_sessions.c @@ -438,9 +438,59 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) { return true; } +static bool sport_history_intersection_and_pagination(void) { + TrainlogDatabase *database = NULL; + char *page = NULL; + size_t size = 0U; + const char *start = "2026-10-01T00:00:00Z"; + const char *end = "2026-10-02T00:00:00Z"; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO sessions(session_id,session_type,started_at,ended_at) VALUES" + "('se_before','training','2026-09-30T23:00:00Z','2026-10-01T01:00:00Z')," + "('se_inside','training','2026-10-01T04:00:00Z','2026-10-01T05:00:00Z')," + "('se_point','max_test','2026-10-01T08:00:00Z',NULL)," + "('se_after','training','2026-10-02T00:00:00Z','2026-10-02T01:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 1U, &page, &size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(page, "se_before") != NULL); + CHECK(strstr(page, "se_inside") == NULL); + CHECK(strstr(page, "\"more\":true") != NULL); + free(page); + page = NULL; + CHECK(trainlog_web_sessions_sport_json(database, start, end, 1U, 2U, &page, &size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(page, "se_inside") != NULL); + CHECK(strstr(page, "se_point") != NULL); + CHECK(strstr(page, "se_after") == NULL); + CHECK(strstr(page, "\"more\":false") != NULL); + free(page); + page = NULL; + CHECK(sqlite3_exec(database->connection, + "DELETE FROM sessions WHERE session_id='se_inside'", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 64U, &page, &size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(page, "se_inside") == NULL); + CHECK(strstr(page, "se_before") != NULL); + free(page); + page = NULL; + CHECK(trainlog_web_sessions_sport_json(database, "invalid", end, 0U, 64U, &page, &size) == + TRAINLOG_STATUS_INVALID_ARGUMENT); + trainlog_database_close(database); + return true; +} + int main(void) { return creation_replay_revision_and_pagination() && - withdrawal_is_durable_and_preserves_evidence() + withdrawal_is_durable_and_preserves_evidence() && + sport_history_intersection_and_pagination() ? 0 : 1; } diff --git a/web/src/api/sportSessions.test.ts b/web/src/api/sportSessions.test.ts new file mode 100644 index 0000000..151955b --- /dev/null +++ b/web/src/api/sportSessions.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchSportSessions } from "./sportSessions"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("completed sport history read", () => { + const item = (index: number) => ({ + identity: `se_${index}`, + label: `Session ${index}`, + session_type: "training", + started_at: `2026-10-01T0${index}:00:00+02:00`, + ended_at: null, + }); + + it("follows all pages and retains each factual stable identity", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ + api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)], + }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ + api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(2)], + }) }); + vi.stubGlobal("fetch", fetch); + const sessions = await fetchSportSessions("2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"); + expect(sessions.map((session) => session.identity)).toEqual(["se_1", "se_2"]); + expect(String(fetch.mock.calls[1][0])).toContain("offset=64"); + }); + + it("fails closed on stale cursors and duplicate identities", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ + api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)], + }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ + api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(1)], + }) })); + await expect(fetchSportSessions("a", "b")).rejects.toThrow(/Duplicate/); + }); +}); diff --git a/web/src/api/sportSessions.ts b/web/src/api/sportSessions.ts new file mode 100644 index 0000000..ca0e669 --- /dev/null +++ b/web/src/api/sportSessions.ts @@ -0,0 +1,57 @@ +export interface SportSession { + identity: string; + session_type: string; + label: string; + started_at: string; + ended_at: string | null; +} + +interface SportPage { + api_version: 1; + offset: number; + more: boolean; + next_offset: number; + items: SportSession[]; +} + +function validSession(value: unknown): value is SportSession { + if (typeof value !== "object" || value === null) return false; + const item = value as Record; + return typeof item.identity === "string" && + typeof item.session_type === "string" && + typeof item.label === "string" && + typeof item.started_at === "string" && + (item.ended_at === null || typeof item.ended_at === "string"); +} + +/** CONTRACT: each read is a fresh factual history projection. Pagination must + * complete before callers may present absence or permit a complete PDF. */ +export async function fetchSportSessions( + start: string, + end: string, + signal?: AbortSignal, +): Promise { + const sessions: SportSession[] = []; + const ids = new Set(); + let offset = 0; + for (;;) { + const query = new URLSearchParams({ start, end, offset: String(offset), limit: "64" }); + const response = await fetch(`/api/v1/sessions/sport?${query}`, { signal }); + if (!response.ok) throw new Error(`Sport history HTTP ${response.status}`); + const page: SportPage = await response.json(); + if (page.api_version !== 1 || page.offset !== offset || + !Array.isArray(page.items) || !page.items.every(validSession) || + typeof page.more !== "boolean" || + !Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) { + throw new TypeError("Invalid sport history page"); + } + for (const item of page.items) { + if (ids.has(item.identity)) throw new TypeError("Duplicate sport session identity"); + ids.add(item.identity); + sessions.push(item); + } + if (!page.more) return sessions; + if (page.items.length === 0) throw new TypeError("Empty sport history page"); + offset = page.next_offset; + } +} diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index 3c75c56..9f32e77 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -231,4 +231,35 @@ describe("sleep diary PDF", () => { expect(source).toContain("OBSERVATIONS"); } }); + + it("prints factual sport bands and a separate legible listing without changing sleep totals", async () => { + const sessions = [ + { identity: "se_morning", session_type: "training", label: "Musculation", + started_at: "2026-09-21T05:00:00+02:00", ended_at: "2026-09-21T06:00:00+02:00" }, + { identity: "se_evening", session_type: "training", label: "Étirements", + started_at: "2026-09-21T18:30:00+02:00", ended_at: "2026-09-21T19:00:00+02:00" }, + ]; + const source = await buildSleepDiaryPdf(snapshot(1), "fr", sessions).text(); + const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr", sessions)); + expect(source).toContain("0.10 0.34 0.82 rg 0 G"); + expect(source).toContain(" re B Q"); + expect(content).toContain("Musculation"); + expect(content).toContain("05:00"); + expect(content).toContain("Étirements"); + expect(content).toContain("sommeil non renseigné"); + expect(content).toContain("1 nuit"); + expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2); + }); + + it("prints sport without inventing a Sleep entry", async () => { + const report = buildSleepDiaryPdf(snapshot(0), "fr", [{ + identity: "se_only", session_type: "training", label: "Cardio", + started_at: "2026-09-21T05:00:00+02:00", ended_at: null, + }], "2026-09-20", "2026-09-20"); + const content = await textualContent(report); + expect(content).toContain("0 nuits"); + expect(content).toContain("Cardio"); + expect(content).toContain("sommeil non renseigné"); + expect(content).toContain("fin non renseignée"); + }); }); diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index 081c44f..1ddcb73 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -5,6 +5,8 @@ import type { } from "../api/sleepDiary"; import { formatDose, formatDuration } from "../dashboard/dashboardFormat"; import { projectSleepTimeline } from "../routes/sleepVisualProjection"; +import { projectSportSegments, sleepRowWindow, timelinePosition } from "../routes/sleepTimelineGeometry"; +import type { SportSession } from "../api/sportSessions"; type Language = "fr" | "en"; @@ -85,16 +87,6 @@ const pdfString = (value: string) => const text = (x: number, y: number, size: number, value: string) => `0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`; -const minute = (iso: string, night: string) => - Math.max( - 0, - Math.min( - 1440, - (new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) / - 60000, - ), - ); - const compactDate = (isoDate: string) => { const [year, month, day] = isoDate.split("-"); return `${day}/${month}/${year}`; @@ -124,8 +116,10 @@ function row( entry: SleepEntry, y: number, language: Language, + sport: readonly SportSession[], ) { const projection = projectSleepTimeline(entry); + const window = sleepRowWindow(entry); const timelineX = 112; const width = 500; stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`); @@ -148,14 +142,14 @@ function row( const event = range.event; const x = timelineX + - (minute(new Date(range.start).toISOString(), entry.night_start_date) / 1440) * width; + timelinePosition(range.start, window.start, window.end) * width; // INVARIANT: every projected interval keeps its canonical absolute // start/end. An awakening interrupts rather than moves adjacent sleep; // an estimated band is presentation-only and stays visually distinct. const eventWidth = Math.max( 2, timelineX + - (minute(new Date(range.end).toISOString(), entry.night_start_date) / 1440) * width - + timelinePosition(range.end, window.start, window.end) * width - x, ); const shade = range.kind === "sleep" @@ -181,13 +175,13 @@ function row( }); projection.pointEvents.forEach((event) => { const x = timelineX + - (minute(event.start_at, entry.night_start_date) / 1440) * width; + timelinePosition(event.start_at, window.start, window.end) * width; stream.push(text(x, y - 25, 8, pointMarker(event.type))); }); projection.intakes.forEach((intake) => { const x = timelineX + - (minute(intake.taken_at, entry.night_start_date) / 1440) * width; + timelinePosition(intake.taken_at, window.start, window.end) * width; stream.push(text(x, y - 36, 6, "M")); }); stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-")); @@ -210,6 +204,14 @@ function row( stream.push(text(730, y - 16, 4, notes.slice(40, 80))); stream.push(text(730, y - 25, 4, notes.slice(80, 120))); stream.push(text(730, y - 34, 4, notes.slice(120, 160))); + projectSportSegments(sport, window).forEach((segment) => { + const x = timelineX + segment.left * width; + const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width); + // PDF graphics state contains the blue fill and black outline. It cannot + // tint later sleep marks, grid lines, medication labels, or body text. + stream.push(`q 0.10 0.34 0.82 rg 0 G ${x.toFixed(2)} ${(y - 9).toFixed(2)} ` + + `${segmentWidth.toFixed(2)} 5 re B Q\n`); + }); } function summaryLine(snapshot: SleepSnapshot, language: Language) { @@ -238,6 +240,7 @@ function page( index: number, count: number, language: Language, + sport: readonly SportSession[], ) { const stream = [ "0 G 0 g\n", @@ -272,7 +275,7 @@ function page( stream.push(text(728, 552, 4, "TREATMENT / NOTES")); } entries.forEach((entry, rowIndex) => - row(stream, entry, 538 - rowIndex * 40, language), + row(stream, entry, 538 - rowIndex * 40, language, sport), ); const y = 520 - entries.length * 40; stream.push(text(30, y, 9, "OBSERVATIONS")); @@ -307,6 +310,7 @@ function page( ), ); if (language === "fr") { + stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport).")); stream.push( text( 30, @@ -324,6 +328,7 @@ function page( ), ); } else { + stream.push(text(30, 38, 6, "Blue: completed sport session (recorded times; details at end of report).")); stream.push( text( 30, @@ -344,22 +349,79 @@ function page( return stream.join(""); } +function sportPage( + sessions: readonly SportSession[], + entries: readonly SleepEntry[], + index: number, + count: number, + language: Language, + first: string, + last: string, +): string { + const fr = language === "fr"; + const stream = [ + "0 G 0 g\n", + text(30, 565, 14, fr ? "SÉANCES DE SPORT EFFECTUÉES" : "COMPLETED SPORT SESSIONS"), + text(740, 565, 7, `${index + 1}/${count}`), + text(30, 547, 7, `${fr ? "Période des lignes" : "Row period"} : ${first} 18:00 - ${last} 18:00`), + text(30, 532, 7, fr + ? "Horaires enregistrés ; la durée est celle de la séance. Aucun lien médical déduit." + : "Recorded times and session duration. No medical conclusion is inferred."), + ]; + sessions.forEach((session, rowIndex) => { + const y = 510 - rowIndex * 16; + const start = Date.parse(session.started_at); + const end = session.ended_at === null ? NaN : Date.parse(session.ended_at); + const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start; + const durationText = validEnd ? duration(Math.floor((end - start) / 1000), language) + : (fr ? "fin non renseignée" : "end not recorded"); + const hasRow = entries.some((entry) => + projectSportSegments([session], sleepRowWindow(entry)).length > 0); + const missing = hasRow ? "" : (fr ? " · sommeil non renseigné" : " · sleep not recorded"); + stream.push(`q 0.10 0.34 0.82 rg 0 G 30 ${y - 2} 9 7 re B Q\n`); + stream.push(text(45, y, 7, + `${session.label.slice(0, 48)} (${session.session_type}) · ` + + `${session.started_at.slice(0, 16)} - ${validEnd ? session.ended_at?.slice(0, 16) : "—"}` + + ` · ${durationText}${missing}`)); + }); + stream.push(text(30, 25, 6, fr + ? "Bleu : séance de sport effectuée. Contour noir pour impression monochrome." + : "Blue: completed sport session. Black outline for monochrome printing.")); + return stream.join(""); +} + /** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */ export function buildSleepDiaryPdf( snapshot: SleepSnapshot, language: Language, + sport: readonly SportSession[] = [], + reportStart = snapshot.entries[0]?.night_start_date ?? "—", + reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—", ): Blob { const groups = Array.from( { length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9), ); + const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) }, + (_, index) => sport.slice(index * 28, (index + 1) * 28)); + const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`); + const reportEndExclusive = Number.isFinite(reportEndTime) + ? new Date(reportEndTime + 86400000).toISOString().slice(0, 10) + : reportEnd; + const contents = [ + ...groups.map((entries, index) => page(snapshot, entries, index, + groups.length + sportGroups.length, language, sport)), + ...sportGroups.map((sessions, index) => sportPage(sessions, snapshot.entries, + groups.length + index, groups.length + sportGroups.length, language, + reportStart, + snapshot.entries[snapshot.entries.length - 1]?.night_end_date ?? reportEndExclusive)), + ]; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", - `<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(" ")}] /Count ${groups.length} >>`, + `<< /Type /Pages /Kids [${contents.map((_, index) => `${4 + index * 2} 0 R`).join(" ")}] /Count ${contents.length} >>`, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", ]; - groups.forEach((entries, index) => { - const content = page(snapshot, entries, index, groups.length, language); + contents.forEach((content, index) => { objects.push( `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`, `<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`, diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index 04e0136..c4c48a0 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -28,6 +28,11 @@ const api = vi.hoisted(() => ({ const heartRateApi = vi.hoisted(() => ({ fetchHeartRateTimeline: vi.fn(), })); +const sportApi = vi.hoisted(() => ({ fetchSportSessions: vi.fn() })); + +vi.mock("../api/sportSessions", () => ({ + fetchSportSessions: sportApi.fetchSportSessions, +})); vi.mock("../api/sleepDiary", async (importOriginal) => { const original = await importOriginal(); @@ -187,6 +192,7 @@ describe("SleepDiaryWorkspace", () => { api.nextId = 0; api.fetchSleepDiary.mockResolvedValue(emptySnapshot); api.fetchSleepMedications.mockResolvedValue([]); + sportApi.fetchSportSessions.mockResolvedValue([]); heartRateApi.fetchHeartRateTimeline.mockImplementation(async (contextId) => ({ api_version: 1, context_id: contextId, @@ -267,6 +273,7 @@ describe("SleepDiaryWorkspace", () => { fireEvent.change(screen.getByLabelText("au"), { target: { value: "2026-09-18" }, }); + await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" })); @@ -280,6 +287,56 @@ describe("SleepDiaryWorkspace", () => { expect(api.saveSleepEntry).not.toHaveBeenCalled(); }); + it("blocks a complete PDF when completed-session history is unavailable", async () => { + api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); + sportApi.fetchSportSessions.mockRejectedValue(new Error("history unavailable")); + render(); + await screen.findByText("Séances indisponibles : export complet impossible."); + expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Exporter PDF" })).toBeDisabled(); + expect(screen.queryByText(/Aucune dans la période/)).toBeNull(); + }); + + it("discards a late sport response after the period changes", async () => { + api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); + let finishOld!: (items: unknown[]) => void; + sportApi.fetchSportSessions + .mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; })) + .mockResolvedValueOnce([{ + identity: "se_current", label: "Current session", session_type: "training", + started_at: "2026-09-21T05:00:00+02:00", + ended_at: "2026-09-21T06:00:00+02:00", + }]); + const view = render(); + await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1)); + view.rerender(); + 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.queryByText(/Stale session/)).toBeNull()); + }); + + it("can export sport in a period with no recorded Sleep night", async () => { + sportApi.fetchSportSessions.mockResolvedValue([{ + identity: "se_only", label: "Musculation", session_type: "training", + started_at: "2026-09-21T05:00:00+02:00", + ended_at: "2026-09-21T06:00:00+02:00", + }]); + const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( + new Blob(["sport"], { type: "application/pdf" }), + ); + vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); + render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); + expect(build.mock.calls[0][0].summary.nights).toBe(0); + expect(build.mock.calls[0][2]).toHaveLength(1); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + }); + it("exports every night whose start date is inside the chosen range", async () => { const first = dayAEntry(); const second = { @@ -301,6 +358,7 @@ describe("SleepDiaryWorkspace", () => { expect(screen.getByLabelText("PDF du")).toHaveValue("2026-09-19"); expect(screen.getByLabelText("au")).toHaveValue(first.night_start_date); }); + await screen.findByText(/Bleu : séance de sport effectuée/); fireEvent.change(screen.getByLabelText("PDF du"), { target: { value: "2026-09-21" }, }); diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 0d802ad..23177d2 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -17,6 +17,7 @@ import { type SleepQuality, } from "../api/sleepDiary"; import type { AnalysisPeriod } from "../api/analysis"; +import { fetchSportSessions, type SportSession } from "../api/sportSessions"; import { HeartRateTimelinePanel } from "../components/HeartRateTimelinePanel"; import { buildSleepDiaryPdf, @@ -26,8 +27,10 @@ import { formatDose, formatDuration } from "../dashboard/dashboardFormat"; import { sleepEntryFacts } from "./sleepDiaryFacts"; import { sleepAgendaTicks, - sleepTimelineInterval, - sleepTimelinePosition, + projectSportSegments, + sleepRowWindow, + timelineInterval, + timelinePosition, } from "./sleepTimelineGeometry"; import { projectSleepTimeline, @@ -287,6 +290,8 @@ export function SleepDiaryWorkspace({ const [snapshot, setSnapshot] = useState > | null>(null); + const [sport, setSport] = useState([]); + const [sportState, setSportState] = useState<"loading" | "ready" | "error">("loading"); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState(blank); const [activeNight, setActiveNight] = useState(draft.night_start_date); @@ -387,6 +392,29 @@ export function SleepDiaryWorkspace({ useEffect(() => { void reload(); }, [period]); + useEffect(() => { + const controller = new AbortController(); + setSportState("loading"); + setSport([]); + // The read window covers the displayed period plus the preceding evening. + // A separate selection below trims the PDF to its chosen row windows. + const startDate = range.startDate ?? "1970-01-01"; + const endDate = range.endDate ?? new Date().toISOString().slice(0, 10); + const start = new Date(`${startDate}T00:00:00`); + start.setDate(start.getDate() - 1); + const end = new Date(`${endDate}T00:00:00`); + end.setDate(end.getDate() + 2); + void fetchSportSessions(start.toISOString(), end.toISOString(), controller.signal) + .then((items) => { + if (controller.signal.aborted) return; + setSport(items); + setSportState("ready"); + }) + .catch(() => { + if (!controller.signal.aborted) setSportState("error"); + }); + return () => controller.abort(); + }, [period, range.startDate, range.endDate]); const edit = (entry: SleepEntry) => { ++nightSelectionSequence.current; adoptEntry(entry); @@ -702,15 +730,19 @@ export function SleepDiaryWorkspace({ const [pdfEndDate, setPdfEndDate] = useState(""); const pdfRangeInitialized = useRef(false); useEffect(() => { - if (pdfRangeInitialized.current || agendaEntries.length === 0) return; + if (pdfRangeInitialized.current || !snapshot) return; // WHY: opening the report controls should immediately cover the visible // loaded period while leaving all subsequent range choices user-owned. - setPdfStartDate(agendaEntries[0].night_start_date); - setPdfEndDate(agendaEntries[agendaEntries.length - 1].night_start_date); + setPdfStartDate(agendaEntries[0]?.night_start_date ?? range.startDate ?? + new Date().toLocaleDateString("en-CA")); + setPdfEndDate(agendaEntries[agendaEntries.length - 1]?.night_start_date ?? + range.endDate ?? new Date().toLocaleDateString("en-CA")); pdfRangeInitialized.current = true; - }, [agendaEntries]); + }, [agendaEntries, snapshot, range.startDate, range.endDate]); const pdfRangeValid = pdfStartDate !== "" && pdfEndDate !== "" && - pdfStartDate <= pdfEndDate; + pdfStartDate <= pdfEndDate && + (range.startDate === undefined || pdfStartDate >= range.startDate) && + (range.endDate === undefined || pdfEndDate <= range.endDate); const pdfEntries = pdfRangeValid ? agendaEntries.filter( (entry) => entry.night_start_date >= pdfStartDate && @@ -720,7 +752,20 @@ export function SleepDiaryWorkspace({ // CONTRACT: preview and download share the same inclusive date-range // selection. The range concerns the date on which each night begins; detail // selection remains independent and continues to drive the HR/editor view. - const selectedSnapshot = snapshot && pdfEntries.length > 0 + 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 ? { + start: pdfWindows[0].start, + end: pdfWindows[pdfWindows.length - 1].end, + } : { 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) : null; return ( @@ -754,7 +799,8 @@ export function SleepDiaryWorkspace({ onClick={() => selectedSnapshot && presentSleepDiaryPdf( - buildSleepDiaryPdf(selectedSnapshot, language), + buildSleepDiaryPdf(selectedSnapshot, language, pdfSport, + pdfStartDate, pdfEndDate), false, ) } @@ -768,7 +814,8 @@ export function SleepDiaryWorkspace({ onClick={() => selectedSnapshot && presentSleepDiaryPdf( - buildSleepDiaryPdf(selectedSnapshot, language), + buildSleepDiaryPdf(selectedSnapshot, language, pdfSport, + pdfStartDate, pdfEndDate), true, ) } @@ -778,8 +825,16 @@ export function SleepDiaryWorkspace({ {!selectedSnapshot && agendaEntries.length > 0 && ( -

{t.pdfEmpty}

+

{sportState === "loading" + ? (language === "fr" ? "Chargement des séances effectuées…" : "Loading completed sessions…") + : sportState === "error" + ? (language === "fr" ? "Séances indisponibles : export complet impossible." : "Sessions unavailable: complete export disabled.") + : t.pdfEmpty}

)} + {sportState === "ready" &&

+ {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")} +

} {agendaEntries.length === 0 ? (

{t.agendaEmpty}

) : ( @@ -816,10 +871,8 @@ export function SleepDiaryWorkspace({ // events carry the user's local offset. The 18:00 axis must // use one factual local offset for the whole row or every // marker is shifted by the UTC/local difference. - const timelineOffsetSource = entry.events.find( - (event) => event.type === "bed_time", - )?.start_at ?? entry.events[0]?.start_at ?? - entry.intakes[0]?.taken_at ?? entry.created_at; + const window = sleepRowWindow(entry); + const sportSegments = projectSportSegments(sport, window); const estimatedSleep = projection.sleep.filter( (range) => range.estimated, ); @@ -856,12 +909,7 @@ export function SleepDiaryWorkspace({ {estimatedSleep.map((range) => { - const geometry = sleepTimelineInterval( - new Date(range.start).toISOString(), - new Date(range.end).toISOString(), - entry.night_start_date, - timelineOffsetSource, - ); + const geometry = timelineInterval(range.start, range.end, window.start, window.end); const label = `${ language === "fr" ? "Sommeil estimé" : "Estimated sleep" } ${projectedClock(range.start)} → ${projectedClock(range.end)}`; @@ -883,12 +931,8 @@ export function SleepDiaryWorkspace({ {intervalRanges.map((range) => { const event = range.event; if (!event) return null; - const geometry = sleepTimelineInterval( - event.start_at, - event.end_at as string, - entry.night_start_date, - timelineOffsetSource, - ); + const geometry = timelineInterval(event.start_at, event.end_at as string, + window.start, window.end); return ( { - const left = sleepTimelinePosition( - event.start_at, - entry.night_start_date, - timelineOffsetSource, - ); + const left = timelinePosition(event.start_at, window.start, window.end); return ( @@ -935,6 +975,21 @@ export function SleepDiaryWorkspace({ ))} + {sportSegments.map((segment, index) => { + const duration = segment.end === null ? null + : formatDuration(Math.floor((segment.end - segment.start) / 1000)); + const label = `Sport · ${segment.session.label} (${segment.session.session_type}) · ` + + `${segment.session.started_at.slice(0, 16)} → ` + + (segment.end === null + ? (language === "fr" ? "fin non renseignée" : "end not recorded") + : `${segment.session.ended_at?.slice(0, 16)} · ${duration}`); + return ; + })} @@ -1025,6 +1080,21 @@ export function SleepDiaryWorkspace({ )} + {sportState === "ready" && listedSport.length > 0 && ( +
+ {language === "fr" ? "Sport — horaires enregistrés" : "Sport — recorded times"} +
    {listedSport.map((session) => ( +
  • + {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")} +
  • + ))}
+
+ )} {selectedEntry && ( diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts index af851f2..001ef92 100644 --- a/web/src/routes/sleepTimelineGeometry.test.ts +++ b/web/src/routes/sleepTimelineGeometry.test.ts @@ -6,6 +6,7 @@ import { sleepTimelinePosition, timelineInterval, timelinePosition, + projectSportSegments, } from "./sleepTimelineGeometry"; describe("sleep timeline geometry", () => { @@ -94,3 +95,51 @@ describe("sleep timeline geometry", () => { ]); }); }); + +describe("completed sport projection", () => { + const window = { + start: Date.parse("2026-09-30T18:00:00+02:00"), + end: Date.parse("2026-10-01T18:00:00+02:00"), + }; + const session = (identity: string, started_at: string, ended_at: string | null) => ({ + identity, label: identity, session_type: "training", started_at, ended_at, + }); + + it("places an actual 05:00–06:00 session on the preceding 18:00 row", () => { + const segments = projectSportSegments([ + session("morning", "2026-10-01T05:00:00+02:00", "2026-10-01T06:00:00+02:00"), + ], window); + expect(segments).toHaveLength(1); + expect(segments[0].left).toBeCloseTo(11 / 24, 12); + expect(segments[0].width).toBeCloseTo(1 / 24, 12); + }); + + it("clips crossings without retaining out-of-window strokes or splitting identity", () => { + const sessions = [ + session("midnight", "2026-09-30T23:30:00+02:00", "2026-10-01T00:30:00+02:00"), + session("boundary", "2026-10-01T17:30:00+02:00", "2026-10-01T18:30:00+02:00"), + session("before", "2026-09-30T16:00:00+02:00", "2026-09-30T17:00:00+02:00"), + session("point", "2026-10-01T13:00:00+02:00", null), + session("bad", "2026-10-01T14:00:00+02:00", "2026-10-01T13:00:00+02:00"), + ]; + const segments = projectSportSegments(sessions, window); + expect(segments.map(({ session }) => session.identity)).toEqual([ + "midnight", "boundary", "point", "bad", + ]); + expect(segments[1].left + segments[1].width).toBe(1); + expect(segments[2].end).toBeNull(); + expect(segments[3].invalidEnd).toBe(true); + }); + + it("retains one row offset through the Europe/Paris fall transition", () => { + const autumn = { + start: Date.parse("2026-10-24T18:00:00+02:00"), + end: Date.parse("2026-10-24T18:00:00+02:00") + 86400000, + }; + const segment = projectSportSegments([ + session("after-shift", "2026-10-25T05:00:00+01:00", "2026-10-25T06:00:00+01:00"), + ], autumn)[0]; + expect(segment.left).toBeCloseTo(12 / 24, 12); + expect(segment.width).toBeCloseTo(1 / 24, 12); + }); +}); diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts index 1518e16..e21e989 100644 --- a/web/src/routes/sleepTimelineGeometry.ts +++ b/web/src/routes/sleepTimelineGeometry.ts @@ -1,5 +1,65 @@ export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000; +import type { SleepEntry } from "../api/sleepDiary"; +import type { SportSession } from "../api/sportSessions"; + +export interface SleepRowWindow { + start: number; + end: number; +} + +/** WHY: event-specific offsets shift otherwise identical instants on the + * agenda. CONTRACT: one factual Sleep offset owns each 18:00 row, including + * its sport overlay and PDF. The existing axis spans 24 elapsed hours even + * across a daylight-saving transition. */ +export function sleepRowWindow(entry: SleepEntry): SleepRowWindow { + const source = entry.events.find((event) => event.type === "bed_time")?.start_at ?? + entry.events[0]?.start_at ?? entry.intakes[0]?.taken_at ?? entry.created_at; + const offset = source.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? ""; + const start = Date.parse(`${entry.night_start_date}T18:00:00${offset}`); + return { start, end: start + SLEEP_TIMELINE_DURATION_MS }; +} + +export interface SportSegment { + session: SportSession; + start: number; + end: number | null; + left: number; + width: number; + invalidEnd: boolean; +} + +/** INVARIANT: intersection precedes clipping; [start,end) prevents a session + * ending at 18:00 from appearing in the next row. Invalid/missing ends + * produce a point only and never a fabricated duration. */ +export function projectSportSegments( + sessions: readonly SportSession[], + window: SleepRowWindow, +): SportSegment[] { + if (!Number.isFinite(window.start) || !Number.isFinite(window.end) || + window.end <= window.start) return []; + return sessions.flatMap((session) => { + const start = Date.parse(session.started_at); + if (!Number.isFinite(start)) return []; + const parsedEnd = session.ended_at === null ? NaN : Date.parse(session.ended_at); + const validEnd = Number.isFinite(parsedEnd) && parsedEnd > start; + if (validEnd ? start >= window.end || parsedEnd <= window.start + : start < window.start || start >= window.end) return []; + const left = timelinePosition(Math.max(start, window.start), window.start, window.end); + const right = validEnd + ? timelinePosition(Math.min(parsedEnd, window.end), window.start, window.end) + : left; + return [{ + session, + start, + end: validEnd ? parsedEnd : null, + left, + width: Math.max(0, right - left), + invalidEnd: session.ended_at !== null && !validEnd, + }]; + }); +} + export interface TimelineTick { timestamp: number; position: number; diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 4b064e4..7273f2f 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -820,7 +820,7 @@ h2 { } .sleep-track { position: relative; - height: 1.75rem; + height: 2.4rem; /* CONTRACT: the 24 equal columns share the exact 18:00-to-18:00 geometry * used by the positioned events and by the hour labels above them. */ background-color: var(--surface-0); @@ -837,7 +837,7 @@ h2 { .sleep-event { position: absolute; display: block; - top: 0.15rem; + top: 0.78rem; bottom: 0.15rem; min-width: 0.3rem; overflow: visible; @@ -845,6 +845,29 @@ h2 { text-align: center; color: var(--text); } +.sleep-sport-legend { + margin: 0.35rem 0; + color: #629bff; + font-weight: 700; +} +.sleep-sport { + position: absolute; + top: 0.1rem; + height: 0.36rem; + min-width: 3px; + border: 1px solid #183f83; + border-radius: 2px; + 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;