feat(web): overlay completed sport on Sleep agenda and PDF
This commit is contained in:
parent
3a4ec34eb9
commit
e2c07f1e80
17 changed files with 734 additions and 71 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
39
web/src/api/sportSessions.test.ts
Normal file
39
web/src/api/sportSessions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
57
web/src/api/sportSessions.ts
Normal file
57
web/src/api/sportSessions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue