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
|
## 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
|
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
|
||||||
preview-and-confirm cascade for unfinished Program sessions. It retains source
|
preview-and-confirm cascade for unfinished Program sessions. It retains source
|
||||||
and actual dates, stores absolute current dates and ceded-slot state, and
|
and actual dates, stores absolute current dates and ceded-slot state, and
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
# Current implementation state
|
# 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
|
The private daily installation runs the explicit ordered Program rescheduling
|
||||||
explicit ordered Program rescheduling, desktop schema v37, Android schema v34,
|
rollout from `trainlog-program-recalage-v1` at `3a4ec34`, with desktop schema
|
||||||
and the capability-selected `trainlog-programs` V2 projection. The stable
|
v37, Android schema v34, and the capability-selected `trainlog-programs` V2
|
||||||
0.1.7 installation and personal Program have not been migrated or rescheduled.
|
projection. The separate `trainlog-sleep-sport-overlay-v1` worktree starts from
|
||||||
The candidate passed local native, Web, Android JVM, build, and targeted
|
that deployed source and adds the read-only completed-sport Sleep overlay.
|
||||||
sanitizer validation. It still requires coordinated PC/Android deployment
|
Local source and isolated-browser validation do not by themselves establish
|
||||||
before it is an operational feature. Completed-session calendar
|
that the new overlay is served by the daily installation. Completed-session
|
||||||
dates in stable 0.1.7 continue to come from their linked factual sessions.
|
calendar dates continue to come from their linked factual sessions.
|
||||||
Rollout packaging requires an explicit Meson `-Dweb=enabled` build; the
|
Rollout packaging requires an explicit Meson `-Dweb=enabled` build; the
|
||||||
embedded frontend and its native Web tests are part of candidate validation.
|
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
|
presentation-only and atomically drives the detail dates, editor content, and
|
||||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||||
and download instead share an explicit inclusive range over night start dates,
|
and download instead share an explicit inclusive range over night start dates,
|
||||||
initialized to the loaded agenda bounds and independent of detail selection. Its
|
initialized to the loaded agenda bounds and independent of detail selection.
|
||||||
rise/fall overlays are descriptive relative measured
|
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
|
variation only (five-minute trailing reference excluding the current 60-second
|
||||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
The isolated Program cascade candidate is implemented in the
|
The Program cascade is deployed in the private daily installation from
|
||||||
`trainlog-program-recalage-v1` worktree and remains outside the stable 0.1.7
|
`trainlog-program-recalage-v1` at `3a4ec34`. The separate
|
||||||
installation. Its next gate is a coordinated PC/Android deployment review,
|
`trainlog-sleep-sport-overlay-v1` source candidate adds the read-only Sleep
|
||||||
followed by a newly calculated personal Program preview and separate user
|
sport display and must be verified on the actual Web/PDF installation before
|
||||||
confirmation. No personal reschedule or publication is implied by candidate
|
that display is called deployed. No new personal Program rescheduling is
|
||||||
tests.
|
implied by this presentation work.
|
||||||
|
|
||||||
The controlled private rollout is complete for the paired daily installation:
|
The controlled private rollout is complete for the paired daily installation:
|
||||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
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
|
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
||||||
with `pdftoppm` for retained visual inspection.
|
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
|
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||||
activity map and direct totals, all compact/medium/large disclosure levels,
|
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,
|
char **output_json,
|
||||||
size_t *output_size);
|
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
|
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
|
||||||
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
* returned detail is a factual snapshot and performs no lifecycle transition. */
|
||||||
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
|
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);
|
context->database, search, offset, limit, &json, &json_size);
|
||||||
return queue_owned_sessions_json(connection, status, 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) {
|
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
|
||||||
if (strcmp(url, collections[index].path) == 0) {
|
if (strcmp(url, collections[index].path) == 0) {
|
||||||
TrainlogWebSessionsPageQuery query = {
|
TrainlogWebSessionsPageQuery query = {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include <yyjson.h>
|
#include <yyjson.h>
|
||||||
|
|
||||||
#include "database_internal.h"
|
#include "database_internal.h"
|
||||||
|
#include "timestamp.h"
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
|
|
||||||
typedef struct ListDefinition {
|
typedef struct ListDefinition {
|
||||||
|
|
@ -95,6 +96,117 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
|
||||||
return TRAINLOG_STATUS_OK;
|
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,
|
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
|
||||||
const TrainlogWebSessionsPageQuery *query,
|
const TrainlogWebSessionsPageQuery *query,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
|
|
|
||||||
|
|
@ -438,9 +438,59 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
|
||||||
return true;
|
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) {
|
int main(void) {
|
||||||
return creation_replay_revision_and_pagination() &&
|
return creation_replay_revision_and_pagination() &&
|
||||||
withdrawal_is_durable_and_preserves_evidence()
|
withdrawal_is_durable_and_preserves_evidence() &&
|
||||||
|
sport_history_intersection_and_pagination()
|
||||||
? 0
|
? 0
|
||||||
: 1;
|
: 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");
|
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";
|
} from "../api/sleepDiary";
|
||||||
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||||
import { projectSleepTimeline } from "../routes/sleepVisualProjection";
|
import { projectSleepTimeline } from "../routes/sleepVisualProjection";
|
||||||
|
import { projectSportSegments, sleepRowWindow, timelinePosition } from "../routes/sleepTimelineGeometry";
|
||||||
|
import type { SportSession } from "../api/sportSessions";
|
||||||
|
|
||||||
type Language = "fr" | "en";
|
type Language = "fr" | "en";
|
||||||
|
|
||||||
|
|
@ -85,16 +87,6 @@ const pdfString = (value: string) =>
|
||||||
const text = (x: number, y: number, size: number, 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`;
|
`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 compactDate = (isoDate: string) => {
|
||||||
const [year, month, day] = isoDate.split("-");
|
const [year, month, day] = isoDate.split("-");
|
||||||
return `${day}/${month}/${year}`;
|
return `${day}/${month}/${year}`;
|
||||||
|
|
@ -124,8 +116,10 @@ function row(
|
||||||
entry: SleepEntry,
|
entry: SleepEntry,
|
||||||
y: number,
|
y: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
|
sport: readonly SportSession[],
|
||||||
) {
|
) {
|
||||||
const projection = projectSleepTimeline(entry);
|
const projection = projectSleepTimeline(entry);
|
||||||
|
const window = sleepRowWindow(entry);
|
||||||
const timelineX = 112;
|
const timelineX = 112;
|
||||||
const width = 500;
|
const width = 500;
|
||||||
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`);
|
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`);
|
||||||
|
|
@ -148,14 +142,14 @@ function row(
|
||||||
const event = range.event;
|
const event = range.event;
|
||||||
const x =
|
const x =
|
||||||
timelineX +
|
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
|
// INVARIANT: every projected interval keeps its canonical absolute
|
||||||
// start/end. An awakening interrupts rather than moves adjacent sleep;
|
// start/end. An awakening interrupts rather than moves adjacent sleep;
|
||||||
// an estimated band is presentation-only and stays visually distinct.
|
// an estimated band is presentation-only and stays visually distinct.
|
||||||
const eventWidth = Math.max(
|
const eventWidth = Math.max(
|
||||||
2,
|
2,
|
||||||
timelineX +
|
timelineX +
|
||||||
(minute(new Date(range.end).toISOString(), entry.night_start_date) / 1440) * width -
|
timelinePosition(range.end, window.start, window.end) * width -
|
||||||
x,
|
x,
|
||||||
);
|
);
|
||||||
const shade = range.kind === "sleep"
|
const shade = range.kind === "sleep"
|
||||||
|
|
@ -181,13 +175,13 @@ function row(
|
||||||
});
|
});
|
||||||
projection.pointEvents.forEach((event) => {
|
projection.pointEvents.forEach((event) => {
|
||||||
const x = timelineX +
|
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)));
|
stream.push(text(x, y - 25, 8, pointMarker(event.type)));
|
||||||
});
|
});
|
||||||
projection.intakes.forEach((intake) => {
|
projection.intakes.forEach((intake) => {
|
||||||
const x =
|
const x =
|
||||||
timelineX +
|
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(x, y - 36, 6, "M"));
|
||||||
});
|
});
|
||||||
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-"));
|
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 - 16, 4, notes.slice(40, 80)));
|
||||||
stream.push(text(730, y - 25, 4, notes.slice(80, 120)));
|
stream.push(text(730, y - 25, 4, notes.slice(80, 120)));
|
||||||
stream.push(text(730, y - 34, 4, notes.slice(120, 160)));
|
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) {
|
function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
||||||
|
|
@ -238,6 +240,7 @@ function page(
|
||||||
index: number,
|
index: number,
|
||||||
count: number,
|
count: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
|
sport: readonly SportSession[],
|
||||||
) {
|
) {
|
||||||
const stream = [
|
const stream = [
|
||||||
"0 G 0 g\n",
|
"0 G 0 g\n",
|
||||||
|
|
@ -272,7 +275,7 @@ function page(
|
||||||
stream.push(text(728, 552, 4, "TREATMENT / NOTES"));
|
stream.push(text(728, 552, 4, "TREATMENT / NOTES"));
|
||||||
}
|
}
|
||||||
entries.forEach((entry, rowIndex) =>
|
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;
|
const y = 520 - entries.length * 40;
|
||||||
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
||||||
|
|
@ -307,6 +310,7 @@ function page(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (language === "fr") {
|
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(
|
stream.push(
|
||||||
text(
|
text(
|
||||||
30,
|
30,
|
||||||
|
|
@ -324,6 +328,7 @@ function page(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
stream.push(text(30, 38, 6, "Blue: completed sport session (recorded times; details at end of report)."));
|
||||||
stream.push(
|
stream.push(
|
||||||
text(
|
text(
|
||||||
30,
|
30,
|
||||||
|
|
@ -344,22 +349,79 @@ function page(
|
||||||
return stream.join("");
|
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. */
|
/** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */
|
||||||
export function buildSleepDiaryPdf(
|
export function buildSleepDiaryPdf(
|
||||||
snapshot: SleepSnapshot,
|
snapshot: SleepSnapshot,
|
||||||
language: Language,
|
language: Language,
|
||||||
|
sport: readonly SportSession[] = [],
|
||||||
|
reportStart = snapshot.entries[0]?.night_start_date ?? "—",
|
||||||
|
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
|
||||||
): Blob {
|
): Blob {
|
||||||
const groups = Array.from(
|
const groups = Array.from(
|
||||||
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
|
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
|
||||||
(_, index) => snapshot.entries.slice(index * 9, (index + 1) * 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 = [
|
const objects = [
|
||||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
"<< /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 >>",
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||||
];
|
];
|
||||||
groups.forEach((entries, index) => {
|
contents.forEach((content, index) => {
|
||||||
const content = page(snapshot, entries, index, groups.length, language);
|
|
||||||
objects.push(
|
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 >>`,
|
`<< /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`,
|
`<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ const api = vi.hoisted(() => ({
|
||||||
const heartRateApi = vi.hoisted(() => ({
|
const heartRateApi = vi.hoisted(() => ({
|
||||||
fetchHeartRateTimeline: vi.fn(),
|
fetchHeartRateTimeline: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const sportApi = vi.hoisted(() => ({ fetchSportSessions: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("../api/sportSessions", () => ({
|
||||||
|
fetchSportSessions: sportApi.fetchSportSessions,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../api/sleepDiary", async (importOriginal) => {
|
vi.mock("../api/sleepDiary", async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import("../api/sleepDiary")>();
|
const original = await importOriginal<typeof import("../api/sleepDiary")>();
|
||||||
|
|
@ -187,6 +192,7 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
api.nextId = 0;
|
api.nextId = 0;
|
||||||
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
||||||
api.fetchSleepMedications.mockResolvedValue([]);
|
api.fetchSleepMedications.mockResolvedValue([]);
|
||||||
|
sportApi.fetchSportSessions.mockResolvedValue([]);
|
||||||
heartRateApi.fetchHeartRateTimeline.mockImplementation(async (contextId) => ({
|
heartRateApi.fetchHeartRateTimeline.mockImplementation(async (contextId) => ({
|
||||||
api_version: 1,
|
api_version: 1,
|
||||||
context_id: contextId,
|
context_id: contextId,
|
||||||
|
|
@ -267,6 +273,7 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
fireEvent.change(screen.getByLabelText("au"), {
|
fireEvent.change(screen.getByLabelText("au"), {
|
||||||
target: { value: "2026-09-18" },
|
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: "Prévisualiser" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" }));
|
fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" }));
|
||||||
|
|
||||||
|
|
@ -280,6 +287,56 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
expect(api.saveSleepEntry).not.toHaveBeenCalled();
|
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 () => {
|
it("exports every night whose start date is inside the chosen range", async () => {
|
||||||
const first = dayAEntry();
|
const first = dayAEntry();
|
||||||
const second = {
|
const second = {
|
||||||
|
|
@ -301,6 +358,7 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
expect(screen.getByLabelText("PDF du")).toHaveValue("2026-09-19");
|
expect(screen.getByLabelText("PDF du")).toHaveValue("2026-09-19");
|
||||||
expect(screen.getByLabelText("au")).toHaveValue(first.night_start_date);
|
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"), {
|
fireEvent.change(screen.getByLabelText("PDF du"), {
|
||||||
target: { value: "2026-09-21" },
|
target: { value: "2026-09-21" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type SleepQuality,
|
type SleepQuality,
|
||||||
} from "../api/sleepDiary";
|
} from "../api/sleepDiary";
|
||||||
import type { AnalysisPeriod } from "../api/analysis";
|
import type { AnalysisPeriod } from "../api/analysis";
|
||||||
|
import { fetchSportSessions, type SportSession } from "../api/sportSessions";
|
||||||
import { HeartRateTimelinePanel } from "../components/HeartRateTimelinePanel";
|
import { HeartRateTimelinePanel } from "../components/HeartRateTimelinePanel";
|
||||||
import {
|
import {
|
||||||
buildSleepDiaryPdf,
|
buildSleepDiaryPdf,
|
||||||
|
|
@ -26,8 +27,10 @@ import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||||
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||||
import {
|
import {
|
||||||
sleepAgendaTicks,
|
sleepAgendaTicks,
|
||||||
sleepTimelineInterval,
|
projectSportSegments,
|
||||||
sleepTimelinePosition,
|
sleepRowWindow,
|
||||||
|
timelineInterval,
|
||||||
|
timelinePosition,
|
||||||
} from "./sleepTimelineGeometry";
|
} from "./sleepTimelineGeometry";
|
||||||
import {
|
import {
|
||||||
projectSleepTimeline,
|
projectSleepTimeline,
|
||||||
|
|
@ -287,6 +290,8 @@ export function SleepDiaryWorkspace({
|
||||||
const [snapshot, setSnapshot] = useState<Awaited<
|
const [snapshot, setSnapshot] = useState<Awaited<
|
||||||
ReturnType<typeof fetchSleepDiary>
|
ReturnType<typeof fetchSleepDiary>
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
const [sport, setSport] = useState<SportSession[]>([]);
|
||||||
|
const [sportState, setSportState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const entries = snapshot?.entries ?? [];
|
const entries = snapshot?.entries ?? [];
|
||||||
const [draft, setDraft] = useState<SleepEntryInput>(blank);
|
const [draft, setDraft] = useState<SleepEntryInput>(blank);
|
||||||
const [activeNight, setActiveNight] = useState(draft.night_start_date);
|
const [activeNight, setActiveNight] = useState(draft.night_start_date);
|
||||||
|
|
@ -387,6 +392,29 @@ export function SleepDiaryWorkspace({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
}, [period]);
|
}, [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) => {
|
const edit = (entry: SleepEntry) => {
|
||||||
++nightSelectionSequence.current;
|
++nightSelectionSequence.current;
|
||||||
adoptEntry(entry);
|
adoptEntry(entry);
|
||||||
|
|
@ -702,15 +730,19 @@ export function SleepDiaryWorkspace({
|
||||||
const [pdfEndDate, setPdfEndDate] = useState("");
|
const [pdfEndDate, setPdfEndDate] = useState("");
|
||||||
const pdfRangeInitialized = useRef(false);
|
const pdfRangeInitialized = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pdfRangeInitialized.current || agendaEntries.length === 0) return;
|
if (pdfRangeInitialized.current || !snapshot) return;
|
||||||
// WHY: opening the report controls should immediately cover the visible
|
// WHY: opening the report controls should immediately cover the visible
|
||||||
// loaded period while leaving all subsequent range choices user-owned.
|
// loaded period while leaving all subsequent range choices user-owned.
|
||||||
setPdfStartDate(agendaEntries[0].night_start_date);
|
setPdfStartDate(agendaEntries[0]?.night_start_date ?? range.startDate ??
|
||||||
setPdfEndDate(agendaEntries[agendaEntries.length - 1].night_start_date);
|
new Date().toLocaleDateString("en-CA"));
|
||||||
|
setPdfEndDate(agendaEntries[agendaEntries.length - 1]?.night_start_date ??
|
||||||
|
range.endDate ?? new Date().toLocaleDateString("en-CA"));
|
||||||
pdfRangeInitialized.current = true;
|
pdfRangeInitialized.current = true;
|
||||||
}, [agendaEntries]);
|
}, [agendaEntries, snapshot, range.startDate, range.endDate]);
|
||||||
const pdfRangeValid = pdfStartDate !== "" && pdfEndDate !== "" &&
|
const pdfRangeValid = pdfStartDate !== "" && pdfEndDate !== "" &&
|
||||||
pdfStartDate <= pdfEndDate;
|
pdfStartDate <= pdfEndDate &&
|
||||||
|
(range.startDate === undefined || pdfStartDate >= range.startDate) &&
|
||||||
|
(range.endDate === undefined || pdfEndDate <= range.endDate);
|
||||||
const pdfEntries = pdfRangeValid
|
const pdfEntries = pdfRangeValid
|
||||||
? agendaEntries.filter(
|
? agendaEntries.filter(
|
||||||
(entry) => entry.night_start_date >= pdfStartDate &&
|
(entry) => entry.night_start_date >= pdfStartDate &&
|
||||||
|
|
@ -720,7 +752,20 @@ export function SleepDiaryWorkspace({
|
||||||
// CONTRACT: preview and download share the same inclusive date-range
|
// CONTRACT: preview and download share the same inclusive date-range
|
||||||
// selection. The range concerns the date on which each night begins; detail
|
// selection. The range concerns the date on which each night begins; detail
|
||||||
// selection remains independent and continues to drive the HR/editor view.
|
// 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)
|
? sleepSnapshotSelection(snapshot, pdfEntries)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
|
|
@ -754,7 +799,8 @@ export function SleepDiaryWorkspace({
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectedSnapshot &&
|
selectedSnapshot &&
|
||||||
presentSleepDiaryPdf(
|
presentSleepDiaryPdf(
|
||||||
buildSleepDiaryPdf(selectedSnapshot, language),
|
buildSleepDiaryPdf(selectedSnapshot, language, pdfSport,
|
||||||
|
pdfStartDate, pdfEndDate),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +814,8 @@ export function SleepDiaryWorkspace({
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectedSnapshot &&
|
selectedSnapshot &&
|
||||||
presentSleepDiaryPdf(
|
presentSleepDiaryPdf(
|
||||||
buildSleepDiaryPdf(selectedSnapshot, language),
|
buildSleepDiaryPdf(selectedSnapshot, language, pdfSport,
|
||||||
|
pdfStartDate, pdfEndDate),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -778,8 +825,16 @@ export function SleepDiaryWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedSnapshot && agendaEntries.length > 0 && (
|
{!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 ? (
|
{agendaEntries.length === 0 ? (
|
||||||
<p className="analysis-empty">{t.agendaEmpty}</p>
|
<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
|
// events carry the user's local offset. The 18:00 axis must
|
||||||
// use one factual local offset for the whole row or every
|
// use one factual local offset for the whole row or every
|
||||||
// marker is shifted by the UTC/local difference.
|
// marker is shifted by the UTC/local difference.
|
||||||
const timelineOffsetSource = entry.events.find(
|
const window = sleepRowWindow(entry);
|
||||||
(event) => event.type === "bed_time",
|
const sportSegments = projectSportSegments(sport, window);
|
||||||
)?.start_at ?? entry.events[0]?.start_at ??
|
|
||||||
entry.intakes[0]?.taken_at ?? entry.created_at;
|
|
||||||
const estimatedSleep = projection.sleep.filter(
|
const estimatedSleep = projection.sleep.filter(
|
||||||
(range) => range.estimated,
|
(range) => range.estimated,
|
||||||
);
|
);
|
||||||
|
|
@ -856,12 +909,7 @@ export function SleepDiaryWorkspace({
|
||||||
</strong>
|
</strong>
|
||||||
<span className="sleep-track">
|
<span className="sleep-track">
|
||||||
{estimatedSleep.map((range) => {
|
{estimatedSleep.map((range) => {
|
||||||
const geometry = sleepTimelineInterval(
|
const geometry = timelineInterval(range.start, range.end, window.start, window.end);
|
||||||
new Date(range.start).toISOString(),
|
|
||||||
new Date(range.end).toISOString(),
|
|
||||||
entry.night_start_date,
|
|
||||||
timelineOffsetSource,
|
|
||||||
);
|
|
||||||
const label = `${
|
const label = `${
|
||||||
language === "fr" ? "Sommeil estimé" : "Estimated sleep"
|
language === "fr" ? "Sommeil estimé" : "Estimated sleep"
|
||||||
} ${projectedClock(range.start)} → ${projectedClock(range.end)}`;
|
} ${projectedClock(range.start)} → ${projectedClock(range.end)}`;
|
||||||
|
|
@ -883,12 +931,8 @@ export function SleepDiaryWorkspace({
|
||||||
{intervalRanges.map((range) => {
|
{intervalRanges.map((range) => {
|
||||||
const event = range.event;
|
const event = range.event;
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
const geometry = sleepTimelineInterval(
|
const geometry = timelineInterval(event.start_at, event.end_at as string,
|
||||||
event.start_at,
|
window.start, window.end);
|
||||||
event.end_at as string,
|
|
||||||
entry.night_start_date,
|
|
||||||
timelineOffsetSource,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<i
|
<i
|
||||||
key={event.event_id}
|
key={event.event_id}
|
||||||
|
|
@ -903,11 +947,7 @@ export function SleepDiaryWorkspace({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{projection.pointEvents.map((event) => {
|
{projection.pointEvents.map((event) => {
|
||||||
const left = sleepTimelinePosition(
|
const left = timelinePosition(event.start_at, window.start, window.end);
|
||||||
event.start_at,
|
|
||||||
entry.night_start_date,
|
|
||||||
timelineOffsetSource,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<i key={event.event_id}
|
<i key={event.event_id}
|
||||||
className={`sleep-event sleep-${event.type} sleep-point`}
|
className={`sleep-event sleep-${event.type} sleep-point`}
|
||||||
|
|
@ -926,7 +966,7 @@ export function SleepDiaryWorkspace({
|
||||||
className="sleep-event sleep-medication"
|
className="sleep-event sleep-medication"
|
||||||
data-testid="sleep-agenda-medication"
|
data-testid="sleep-agenda-medication"
|
||||||
style={{
|
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")}`}
|
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
|
||||||
>
|
>
|
||||||
|
|
@ -935,6 +975,21 @@ export function SleepDiaryWorkspace({
|
||||||
</span>
|
</span>
|
||||||
</i>
|
</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>
|
||||||
<span className="sleep-row-summary">
|
<span className="sleep-row-summary">
|
||||||
<span className="sleep-row-facts">
|
<span className="sleep-row-facts">
|
||||||
|
|
@ -1025,6 +1080,21 @@ export function SleepDiaryWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</article>
|
||||||
|
|
||||||
{selectedEntry && (
|
{selectedEntry && (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
timelineInterval,
|
timelineInterval,
|
||||||
timelinePosition,
|
timelinePosition,
|
||||||
|
projectSportSegments,
|
||||||
} from "./sleepTimelineGeometry";
|
} from "./sleepTimelineGeometry";
|
||||||
|
|
||||||
describe("sleep timeline geometry", () => {
|
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;
|
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 {
|
export interface TimelineTick {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
position: number;
|
position: number;
|
||||||
|
|
|
||||||
|
|
@ -820,7 +820,7 @@ h2 {
|
||||||
}
|
}
|
||||||
.sleep-track {
|
.sleep-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 1.75rem;
|
height: 2.4rem;
|
||||||
/* CONTRACT: the 24 equal columns share the exact 18:00-to-18:00 geometry
|
/* 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. */
|
* used by the positioned events and by the hour labels above them. */
|
||||||
background-color: var(--surface-0);
|
background-color: var(--surface-0);
|
||||||
|
|
@ -837,7 +837,7 @@ h2 {
|
||||||
.sleep-event {
|
.sleep-event {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
top: 0.15rem;
|
top: 0.78rem;
|
||||||
bottom: 0.15rem;
|
bottom: 0.15rem;
|
||||||
min-width: 0.3rem;
|
min-width: 0.3rem;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
@ -845,6 +845,29 @@ h2 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text);
|
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-point,
|
||||||
.sleep-medication {
|
.sleep-medication {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue