feat(web): overlay completed sport on Sleep agenda and PDF

This commit is contained in:
fy59 2026-10-01 21:06:58 +02:00
parent 3a4ec34eb9
commit e2c07f1e80
17 changed files with 734 additions and 71 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@
#include <yyjson.h>
#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(?2) AND "
"(CASE WHEN unixepoch(s.ended_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,

View file

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

View file

@ -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/);
});
});

View file

@ -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<string, unknown>;
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<SportSession[]> {
const sessions: SportSession[] = [];
const ids = new Set<string>();
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;
}
}

View file

@ -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");
});
});

View file

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

View file

@ -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<typeof import("../api/sleepDiary")>();
@ -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(<SleepDiaryWorkspace period="30d" language="fr" />);
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(<SleepDiaryWorkspace period="30d" language="fr" />);
await waitFor(() => expect(sportApi.fetchSportSessions).toHaveBeenCalledTimes(1));
view.rerender(<SleepDiaryWorkspace period="7d" language="fr" />);
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(<SleepDiaryWorkspace period="30d" language="fr" />);
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" },
});

View file

@ -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<Awaited<
ReturnType<typeof fetchSleepDiary>
> | null>(null);
const [sport, setSport] = useState<SportSession[]>([]);
const [sportState, setSportState] = useState<"loading" | "ready" | "error">("loading");
const entries = snapshot?.entries ?? [];
const [draft, setDraft] = useState<SleepEntryInput>(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({
</div>
</div>
{!selectedSnapshot && agendaEntries.length > 0 && (
<p className="sleep-pdf-range-error" role="status">{t.pdfEmpty}</p>
<p className="sleep-pdf-range-error" role="status">{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}</p>
)}
{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>}
{agendaEntries.length === 0 ? (
<p className="analysis-empty">{t.agendaEmpty}</p>
) : (
@ -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({
</strong>
<span className="sleep-track">
{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 (
<i
key={event.event_id}
@ -903,11 +947,7 @@ export function SleepDiaryWorkspace({
);
})}
{projection.pointEvents.map((event) => {
const left = sleepTimelinePosition(
event.start_at,
entry.night_start_date,
timelineOffsetSource,
);
const left = timelinePosition(event.start_at, window.start, window.end);
return (
<i key={event.event_id}
className={`sleep-event sleep-${event.type} sleep-point`}
@ -926,7 +966,7 @@ export function SleepDiaryWorkspace({
className="sleep-event sleep-medication"
data-testid="sleep-agenda-medication"
style={{
left: `${sleepTimelinePosition(takenAt, entry.night_start_date, timelineOffsetSource) * 100}%`,
left: `${timelinePosition(takenAt, window.start, window.end) * 100}%`,
}}
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
>
@ -935,6 +975,21 @@ export function SleepDiaryWorkspace({
</span>
</i>
))}
{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 <i key={segment.session.identity} className="sleep-sport"
role="img" aria-label={label} title={label}
data-testid="sleep-agenda-sport"
style={{ top: `${0.1 + (index % 3) * 0.2}rem`,
left: `${segment.left * 100}%`,
width: segment.end === null ? "3px" : `${segment.width * 100}%` }} />;
})}
</span>
<span className="sleep-row-summary">
<span className="sleep-row-facts">
@ -1025,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

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

View file

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

View file

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