From 246e47ee8f2c61f17cbcbf6232255670bb1aa550 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 15:24:16 +0200 Subject: [PATCH] feat(web): visualize synchronized heart rate --- CHANGELOG.md | 2 + README.md | 2 +- docs/current_state.md | 3 +- docs/roadmap.md | 2 +- docs/tests.md | 25 + tools/cardio_guidance_exchange.py | 10 +- tui/include/trainlog/web_heart_rate.h | 17 + tui/meson.build | 10 + tui/src/database.c | 2 +- tui/src/web_heart_rate.c | 703 ++++++++++++++++++ tui/src/web_server.c | 82 ++ tui/tests/test_web_heart_rate.c | 234 ++++++ tui/tests/test_web_server.c | 23 + web/src/api/heartRate.test.ts | 76 ++ web/src/api/heartRate.ts | 196 +++++ .../HeartRateTimelinePanel.test.tsx | 83 +++ web/src/components/HeartRateTimelinePanel.tsx | 248 ++++++ web/src/routes/SessionsPage.tsx | 2 + web/src/routes/SleepDiaryWorkspace.tsx | 3 + web/src/theme/app.css | 121 +++ 20 files changed, 1838 insertions(+), 6 deletions(-) create mode 100644 tui/include/trainlog/web_heart_rate.h create mode 100644 tui/src/web_heart_rate.c create mode 100644 tui/tests/test_web_heart_rate.c create mode 100644 web/src/api/heartRate.test.ts create mode 100644 web/src/api/heartRate.ts create mode 100644 web/src/components/HeartRateTimelinePanel.test.tsx create mode 100644 web/src/components/HeartRateTimelinePanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index dac0460..1834b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ timeline, calibration provenance and exact raw HR evidence for instruction events carrying BPM before accepting them. +- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added. + Development opened after stable v0.1.4 release. ## 0.1.4 — 2026-09-22 diff --git a/README.md b/README.md index bfca54c..320f99a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import, list, detail, archive, and delete surface. Web Exercises administration, the factual Dashboard/Analyse read model, and the synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version -0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, dedicated synchronized `cardio` sessions, versioned measured Cardio calibration and fresh-signal BPM-guided phases; factual Web cardio/timeline visualization is the current implementation cursor. +0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, dedicated synchronized `cardio` sessions, versioned measured Cardio calibration, fresh-signal BPM-guided phases and factual Web heart-rate timelines in History/Sleep; complete 0.1.5 validation is the current implementation cursor. ## Releases diff --git a/docs/current_state.md b/docs/current_state.md index acb185a..230c419 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -155,7 +155,8 @@ parallel implementation of its rules. | Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently | | Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion | | Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync | -| Current operational cursor | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1`: factual Web timelines/curves for training, cardio guidance/calibration and Sleep without inventing measurements or sleep states | +| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference | +| Current operational cursor | `TRAINLOG_0_1_5_VALIDATION_V1`: complete Android/native/Web regression, migration, package, sync/replay and private-device smoke validation before any stable release decision | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 7088e1d..1b13003 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog Bluetooth synchronization transport contract. ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_WEB_VISUALIZATION_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_VALIDATION_V1 ``` The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this diff --git a/docs/tests.md b/docs/tests.md index 0c8cfc9..f444d73 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1837,3 +1837,28 @@ calibration provenance and exact raw BPM evidence for non-suspended events. The 2026-09-23 closeout passes the complete native Meson inventory **110/110**, and the complete Android JVM inventory reports **300 tests: 295 passed, five skipped, zero failures/errors**. `assembleDebug` succeeds. + +## Cardio Web visualization V1 validation + +`TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS` adds one bounded read-only C17 +heart-rate timeline projection and one loopback GET endpoint keyed only by the +stable session/cardio `se_` or Sleep Diary `sl_` identity. The projection +returns synchronized raw BPM samples with native RR arrays, canonical +session/exercise or Sleep/medication markers, guided-cardio phase snapshots and +instruction changes, and factual calibration peak/recovery data when present. +It performs no sleep-stage, return-to-sleep, HRV, readiness or medical +inference. + +Web History and the Sleep workspace reuse the same React timeline component. +Min/max/mean facts are calculated over every returned BPM sample. Only the SVG +projection is bounded for large captures by retaining bucket extrema; the +source samples and statistics are not downsampled. A large synchronized night +therefore remains exact at the data boundary without forcing a 100,000-point +SVG path. + +The backend test covers cardio BPM/RR, exercise intervals, guidance, calibration, +Sleep events, medication points, empty contexts and invalid identities. The HTTP +test covers valid empty GET, bad context rejection and GET-only semantics. The +2026-09-23 closeout passes the complete native inventory **111/111** and the +complete Web inventory **29 files / 183 tests**; Web typecheck and production +build also succeed. diff --git a/tools/cardio_guidance_exchange.py b/tools/cardio_guidance_exchange.py index 37c259a..d8a7fbc 100644 --- a/tools/cardio_guidance_exchange.py +++ b/tools/cardio_guidance_exchange.py @@ -128,6 +128,7 @@ def validate(root: object) -> dict: if not isinstance(runs, list) or len(runs) > MAX_RUNS: fail("cardio guidance run bound exceeded") seen_runs = set() + seen_sessions = set() seen_phases = set() for run in runs: if not isinstance(run, dict) or set(run) != { @@ -140,8 +141,13 @@ def validate(root: object) -> dict: ): fail("invalid or duplicate cardio guidance run identity") seen_runs.add(run["run_id"]) - if not isinstance(run["session_id"], str) or not SESSION_ID.fullmatch(run["session_id"]): - fail("invalid cardio guidance session identity") + if ( + not isinstance(run["session_id"], str) + or not SESSION_ID.fullmatch(run["session_id"]) + or run["session_id"] in seen_sessions + ): + fail("invalid or duplicate cardio guidance session identity") + seen_sessions.add(run["session_id"]) _, run_start = timestamp(run["started_at"]) _, run_end = timestamp(run["ended_at"]) if run_end < run_start: diff --git a/tui/include/trainlog/web_heart_rate.h b/tui/include/trainlog/web_heart_rate.h new file mode 100644 index 0000000..a7d7cdb --- /dev/null +++ b/tui/include/trainlog/web_heart_rate.h @@ -0,0 +1,17 @@ +#ifndef TRAINLOG_WEB_HEART_RATE_H +#define TRAINLOG_WEB_HEART_RATE_H + +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +#define TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY (16U * 1024U * 1024U) + +TrainlogStatus trainlog_web_heart_rate_timeline_json(TrainlogDatabase *database, + const char *context_id, + char *output, + size_t capacity, + size_t *output_size); + +#endif diff --git a/tui/meson.build b/tui/meson.build index e1a0493..38a4b75 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -218,6 +218,7 @@ trainlog_core_sources = files( 'src/web_dashboard.c', 'src/web_dashboard_json.c', 'src/web_analysis.c', + 'src/web_heart_rate.c', 'src/web_prepared_items.c', 'src/web_prepared_items_json.c', 'src/web_sessions.c', @@ -381,6 +382,15 @@ test_web_analysis = executable( ) test('web_analysis', test_web_analysis) +test_web_heart_rate = executable( + 'test_web_heart_rate', + 'tests/test_web_heart_rate.c', + dependencies: trainlog_core_dep, + include_directories: trainlog_private_include, + c_args: strict_c_args, +) +test('web_heart_rate', test_web_heart_rate) + test_web_prepared_items = executable( 'test_web_prepared_items', 'tests/test_web_prepared_items.c', diff --git a/tui/src/database.c b/tui/src/database.c index 77d53bb..349d624 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1433,7 +1433,7 @@ static const char *const MIGRATE_V32_TO_V33_SQL = static const char *const MIGRATE_V33_TO_V34_SQL = "BEGIN IMMEDIATE;" "CREATE TABLE IF NOT EXISTS cardio_guidance_runs(" - "run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE RESTRICT," + "run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL UNIQUE REFERENCES sessions(session_id) ON DELETE RESTRICT," "started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL," "CHECK(run_id GLOB 'cgr_*'),CHECK(session_id GLOB 'se_*'));" "CREATE TABLE IF NOT EXISTS cardio_guidance_phases(" diff --git a/tui/src/web_heart_rate.c b/tui/src/web_heart_rate.c new file mode 100644 index 0000000..c3d2f24 --- /dev/null +++ b/tui/src/web_heart_rate.c @@ -0,0 +1,703 @@ +#include "trainlog/web_heart_rate.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "database_internal.h" + +#define HEART_RATE_SAMPLE_MAX 100000U +#define TIMELINE_EVENT_MAX 2048U +#define GUIDANCE_PHASE_MAX 128U +#define GUIDANCE_EVENT_MAX 4096U + +static bool prepare(TrainlogDatabase *database, const char *sql, sqlite3_stmt **statement) { + return sqlite3_prepare_v2(database->connection, sql, -1, statement, NULL) == SQLITE_OK; +} + +static bool valid_context_id(const char *value) { + size_t length; + if (value == NULL) { + return false; + } + length = strlen(value); + if (length < 4U || length >= 64U) { + return false; + } + return strncmp(value, "se_", 3U) == 0 || strncmp(value, "sl_", 3U) == 0; +} + +static bool add_nullable_string(yyjson_mut_doc *document, + yyjson_mut_val *object, + const char *key, + const char *value) { + return value == NULL ? yyjson_mut_obj_add_null(document, object, key) + : yyjson_mut_obj_add_strcpy(document, object, key, value); +} + +static bool add_nullable_column_string(yyjson_mut_doc *document, + yyjson_mut_val *object, + const char *key, + sqlite3_stmt *statement, + int column) { + return sqlite3_column_type(statement, column) == SQLITE_NULL + ? yyjson_mut_obj_add_null(document, object, key) + : yyjson_mut_obj_add_strcpy( + document, object, key, (const char *)sqlite3_column_text(statement, column)); +} + +static bool add_nullable_column_int(yyjson_mut_doc *document, + yyjson_mut_val *object, + const char *key, + sqlite3_stmt *statement, + int column) { + return sqlite3_column_type(statement, column) == SQLITE_NULL + ? yyjson_mut_obj_add_null(document, object, key) + : yyjson_mut_obj_add_sint( + document, object, key, sqlite3_column_int64(statement, column)); +} + +static bool append_event(yyjson_mut_doc *document, + yyjson_mut_val *events, + const char *type, + const char *at, + const char *end_at, + const char *label, + const char *entry_id, + const char *exercise_id) { + yyjson_mut_val *event = yyjson_mut_obj(document); + if (event == NULL || + !yyjson_mut_obj_add_strcpy(document, event, "type", type) || + !yyjson_mut_obj_add_strcpy(document, event, "at", at) || + !add_nullable_string(document, event, "end_at", end_at) || + !add_nullable_string(document, event, "label", label) || + !add_nullable_string(document, event, "entry_id", entry_id) || + !add_nullable_string(document, event, "exercise_id", exercise_id) || + !yyjson_mut_arr_append(events, event)) { + return false; + } + return true; +} + +static bool add_samples(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *capture, + const char *capture_id) { + static const char COUNT_SQL[] = + "SELECT COUNT(*) FROM heart_rate_samples WHERE capture_id=?;"; + static const char SAMPLE_SQL[] = + "SELECT s.sequence,s.observed_at,s.bpm,r.rr_index,r.value_1024 " + "FROM heart_rate_samples s LEFT JOIN heart_rate_rr_intervals r " + "ON r.capture_id=s.capture_id AND r.sample_sequence=s.sequence " + "WHERE s.capture_id=? ORDER BY s.sequence,r.rr_index;"; + sqlite3_stmt *count_statement = NULL; + sqlite3_stmt *statement = NULL; + yyjson_mut_val *samples = yyjson_mut_arr(document); + yyjson_mut_val *sample = NULL; + yyjson_mut_val *rr = NULL; + sqlite3_int64 current_sequence = -1; + sqlite3_int64 count; + int step; + + if (samples == NULL || !prepare(database, COUNT_SQL, &count_statement) || + sqlite3_bind_text(count_statement, 1, capture_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(count_statement) != SQLITE_ROW) { + if (count_statement != NULL) { + (void)sqlite3_finalize(count_statement); + } + return false; + } + count = sqlite3_column_int64(count_statement, 0); + if (sqlite3_step(count_statement) != SQLITE_DONE || + sqlite3_finalize(count_statement) != SQLITE_OK || + count < 0 || (uint64_t)count > HEART_RATE_SAMPLE_MAX || + !yyjson_mut_obj_add_sint(document, capture, "sample_count", count) || + !prepare(database, SAMPLE_SQL, &statement) || + sqlite3_bind_text(statement, 1, capture_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + sqlite3_int64 sequence = sqlite3_column_int64(statement, 0); + if (sequence != current_sequence) { + sample = yyjson_mut_obj(document); + rr = yyjson_mut_arr(document); + if (sample == NULL || rr == NULL || + !yyjson_mut_obj_add_sint(document, sample, "sequence", sequence) || + !yyjson_mut_obj_add_strcpy(document, + sample, + "observed_at", + (const char *)sqlite3_column_text(statement, 1)) || + !yyjson_mut_obj_add_sint( + document, sample, "bpm", sqlite3_column_int64(statement, 2)) || + !yyjson_mut_obj_add_val(document, sample, "rr_1024", rr) || + !yyjson_mut_arr_append(samples, sample)) { + (void)sqlite3_finalize(statement); + return false; + } + current_sequence = sequence; + } + if (sqlite3_column_type(statement, 4) != SQLITE_NULL && + !yyjson_mut_arr_add_sint(document, rr, sqlite3_column_int64(statement, 4))) { + (void)sqlite3_finalize(statement); + return false; + } + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || + !yyjson_mut_obj_add_val(document, capture, "samples", samples)) { + return false; + } + return true; +} + +static bool add_session_events(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *events, + const char *session_id) { + static const char SESSION_SQL[] = + "SELECT started_at,ended_at FROM sessions WHERE session_id=?;"; + static const char EXERCISE_SQL[] = + "SELECT t.entry_id,t.exercise_id,e.name,t.started_at,t.ended_at " + "FROM session_exercise_timeline t " + "JOIN sessions s ON s.session_id=t.session_id " + "JOIN session_exercises se ON se.session_row_id=s.id AND se.entry_id=t.entry_id " + "JOIN exercises e ON e.id=se.exercise_row_id AND e.exercise_id=t.exercise_id " + "WHERE t.session_id=? ORDER BY t.started_at,t.entry_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int step; + + if (!prepare(database, SESSION_SQL, &statement) || + sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW || + !append_event(document, + events, + "session", + (const char *)sqlite3_column_text(statement, 0), + sqlite3_column_type(statement, 1) == SQLITE_NULL + ? NULL + : (const char *)sqlite3_column_text(statement, 1), + NULL, + NULL, + NULL) || + sqlite3_step(statement) != SQLITE_DONE || + sqlite3_finalize(statement) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + + statement = NULL; + if (!prepare(database, EXERCISE_SQL, &statement) || + sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + if (++count > TIMELINE_EVENT_MAX || + !append_event(document, + events, + "exercise", + (const char *)sqlite3_column_text(statement, 3), + sqlite3_column_type(statement, 4) == SQLITE_NULL + ? NULL + : (const char *)sqlite3_column_text(statement, 4), + (const char *)sqlite3_column_text(statement, 2), + (const char *)sqlite3_column_text(statement, 0), + (const char *)sqlite3_column_text(statement, 1))) { + (void)sqlite3_finalize(statement); + return false; + } + } + return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK; +} + +static bool add_sleep_events(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *events, + const char *entry_id) { + static const char EVENT_SQL[] = + "SELECT e.event_type,e.start_at,e.end_at " + "FROM sleep_diary_entries d JOIN sleep_diary_events e " + "ON e.revision_id=d.current_revision_id " + "WHERE d.entry_id=? AND d.deleted=0 ORDER BY e.start_at,e.event_id;"; + static const char INTAKE_SQL[] = + "SELECT i.taken_at,i.medication_name " + "FROM sleep_diary_entries d JOIN sleep_medication_intakes i " + "ON i.revision_id=d.current_revision_id " + "WHERE d.entry_id=? AND d.deleted=0 ORDER BY i.taken_at,i.intake_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int step; + + if (!prepare(database, EVENT_SQL, &statement) || + sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + if (++count > TIMELINE_EVENT_MAX || + !append_event(document, + events, + (const char *)sqlite3_column_text(statement, 0), + (const char *)sqlite3_column_text(statement, 1), + sqlite3_column_type(statement, 2) == SQLITE_NULL + ? NULL + : (const char *)sqlite3_column_text(statement, 2), + NULL, + NULL, + NULL)) { + (void)sqlite3_finalize(statement); + return false; + } + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + return false; + } + + statement = NULL; + if (!prepare(database, INTAKE_SQL, &statement) || + sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + if (++count > TIMELINE_EVENT_MAX || + !append_event(document, + events, + "medication", + (const char *)sqlite3_column_text(statement, 0), + NULL, + (const char *)sqlite3_column_text(statement, 1), + NULL, + NULL)) { + (void)sqlite3_finalize(statement); + return false; + } + } + return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK; +} + +static bool add_guidance(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *root, + const char *session_id) { + static const char RUN_SQL[] = + "SELECT run_id,started_at,ended_at FROM cardio_guidance_runs " + "WHERE session_id=? ORDER BY ended_at DESC,run_id DESC LIMIT 1;"; + static const char PHASE_SQL[] = + "SELECT phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm," + "calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," + "exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction " + "FROM cardio_guidance_phases WHERE run_id=? ORDER BY position,phase_id;"; + static const char EVENT_SQL[] = + "SELECT sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm " + "FROM cardio_guidance_events WHERE run_id=? AND phase_id=? ORDER BY sequence;"; + sqlite3_stmt *run_statement = NULL; + sqlite3_stmt *phase_statement = NULL; + const char *run_id; + yyjson_mut_val *guidance; + yyjson_mut_val *phases; + size_t phase_count = 0U; + int step; + + if (!prepare(database, RUN_SQL, &run_statement) || + sqlite3_bind_text(run_statement, 1, session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (run_statement != NULL) { + (void)sqlite3_finalize(run_statement); + } + return false; + } + step = sqlite3_step(run_statement); + if (step == SQLITE_DONE) { + (void)sqlite3_finalize(run_statement); + return yyjson_mut_obj_add_null(document, root, "guidance"); + } + if (step != SQLITE_ROW) { + (void)sqlite3_finalize(run_statement); + return false; + } + + run_id = (const char *)sqlite3_column_text(run_statement, 0); + guidance = yyjson_mut_obj(document); + phases = yyjson_mut_arr(document); + if (guidance == NULL || phases == NULL || + !yyjson_mut_obj_add_strcpy(document, guidance, "run_id", run_id) || + !yyjson_mut_obj_add_strcpy(document, + guidance, + "started_at", + (const char *)sqlite3_column_text(run_statement, 1)) || + !yyjson_mut_obj_add_strcpy(document, + guidance, + "ended_at", + (const char *)sqlite3_column_text(run_statement, 2))) { + (void)sqlite3_finalize(run_statement); + return false; + } + { + char stable_run_id[64]; + if (snprintf(stable_run_id, sizeof(stable_run_id), "%s", run_id) < 0 || + strlen(run_id) >= sizeof(stable_run_id) || + sqlite3_step(run_statement) != SQLITE_DONE || + sqlite3_finalize(run_statement) != SQLITE_OK || + !prepare(database, PHASE_SQL, &phase_statement) || + sqlite3_bind_text(phase_statement, 1, stable_run_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (phase_statement != NULL) { + (void)sqlite3_finalize(phase_statement); + } + return false; + } + + while ((step = sqlite3_step(phase_statement)) == SQLITE_ROW) { + yyjson_mut_val *phase = yyjson_mut_obj(document); + yyjson_mut_val *target = NULL; + yyjson_mut_val *exit_condition = yyjson_mut_obj(document); + yyjson_mut_val *instruction_events = yyjson_mut_arr(document); + sqlite3_stmt *event_statement = NULL; + const char *phase_id = (const char *)sqlite3_column_text(phase_statement, 0); + char stable_phase_id[64]; + size_t event_count = 0U; + int event_step; + + if (++phase_count > GUIDANCE_PHASE_MAX || phase == NULL || + exit_condition == NULL || instruction_events == NULL || + strlen(phase_id) >= sizeof(stable_phase_id) || + snprintf(stable_phase_id, sizeof(stable_phase_id), "%s", phase_id) < 0 || + !yyjson_mut_obj_add_strcpy(document, phase, "phase_id", phase_id) || + !yyjson_mut_obj_add_strcpy(document, + phase, + "entry_id", + (const char *)sqlite3_column_text(phase_statement, 1)) || + !yyjson_mut_obj_add_sint( + document, phase, "position", sqlite3_column_int64(phase_statement, 2)) || + !yyjson_mut_obj_add_strcpy(document, + phase, + "kind", + (const char *)sqlite3_column_text(phase_statement, 3))) { + (void)sqlite3_finalize(phase_statement); + return false; + } + + if (sqlite3_column_type(phase_statement, 4) == SQLITE_NULL) { + if (!yyjson_mut_obj_add_null(document, phase, "target")) { + (void)sqlite3_finalize(phase_statement); + return false; + } + } else { + target = yyjson_mut_obj(document); + if (target == NULL || + !yyjson_mut_obj_add_sint( + document, target, "minimum_bpm", sqlite3_column_int64(phase_statement, 4)) || + !yyjson_mut_obj_add_sint( + document, target, "maximum_bpm", sqlite3_column_int64(phase_statement, 5)) || + !add_nullable_column_string( + document, target, "calibration_id", phase_statement, 6) || + !add_nullable_column_int( + document, target, "calibration_observed_peak_bpm", phase_statement, 7) || + !add_nullable_column_int( + document, target, "minimum_percent", phase_statement, 8) || + !add_nullable_column_int( + document, target, "maximum_percent", phase_statement, 9) || + !yyjson_mut_obj_add_val(document, phase, "target", target)) { + (void)sqlite3_finalize(phase_statement); + return false; + } + } + + if (!yyjson_mut_obj_add_strcpy(document, + exit_condition, + "kind", + (const char *)sqlite3_column_text(phase_statement, 10)) || + !add_nullable_column_int( + document, exit_condition, "seconds", phase_statement, 11) || + !add_nullable_column_int( + document, exit_condition, "bpm", phase_statement, 12) || + !yyjson_mut_obj_add_val(document, phase, "exit_condition", exit_condition) || + !yyjson_mut_obj_add_strcpy(document, + phase, + "started_at", + (const char *)sqlite3_column_text(phase_statement, 13)) || + !yyjson_mut_obj_add_strcpy(document, + phase, + "ended_at", + (const char *)sqlite3_column_text(phase_statement, 14)) || + !yyjson_mut_obj_add_strcpy(document, + phase, + "final_instruction", + (const char *)sqlite3_column_text(phase_statement, 15)) || + !prepare(database, EVENT_SQL, &event_statement) || + sqlite3_bind_text(event_statement, 1, stable_run_id, -1, SQLITE_TRANSIENT) != + SQLITE_OK || + sqlite3_bind_text(event_statement, 2, stable_phase_id, -1, SQLITE_TRANSIENT) != + SQLITE_OK) { + if (event_statement != NULL) { + (void)sqlite3_finalize(event_statement); + } + (void)sqlite3_finalize(phase_statement); + return false; + } + + while ((event_step = sqlite3_step(event_statement)) == SQLITE_ROW) { + yyjson_mut_val *event = yyjson_mut_obj(document); + if (++event_count > GUIDANCE_EVENT_MAX || event == NULL || + !yyjson_mut_obj_add_sint( + document, event, "sequence", sqlite3_column_int64(event_statement, 0)) || + !yyjson_mut_obj_add_strcpy(document, + event, + "observed_at", + (const char *)sqlite3_column_text(event_statement, 1)) || + !yyjson_mut_obj_add_strcpy(document, + event, + "instruction", + (const char *)sqlite3_column_text(event_statement, 2)) || + !add_nullable_column_int(document, event, "bpm", event_statement, 3) || + !add_nullable_column_int( + document, event, "target_minimum_bpm", event_statement, 4) || + !add_nullable_column_int( + document, event, "target_maximum_bpm", event_statement, 5) || + !yyjson_mut_arr_append(instruction_events, event)) { + (void)sqlite3_finalize(event_statement); + (void)sqlite3_finalize(phase_statement); + return false; + } + } + if (event_step != SQLITE_DONE || sqlite3_finalize(event_statement) != SQLITE_OK || + !yyjson_mut_obj_add_val(document, phase, "events", instruction_events) || + !yyjson_mut_arr_append(phases, phase)) { + (void)sqlite3_finalize(phase_statement); + return false; + } + } + if (step != SQLITE_DONE || sqlite3_finalize(phase_statement) != SQLITE_OK) { + return false; + } + } + + return yyjson_mut_obj_add_val(document, guidance, "phases", phases) && + yyjson_mut_obj_add_val(document, root, "guidance", guidance); +} + +static bool add_calibration(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *root, + const char *session_id) { + static const char SQL[] = + "SELECT calibration_id,observed_peak_bpm,effort_end_at " + "FROM cardio_calibrations WHERE session_id=? ORDER BY ended_at DESC LIMIT 1;"; + static const char RECOVERY_SQL[] = + "SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery " + "WHERE calibration_id=? ORDER BY target_offset_seconds;"; + sqlite3_stmt *statement = NULL; + sqlite3_stmt *recovery_statement = NULL; + yyjson_mut_val *calibration; + yyjson_mut_val *recovery; + int step; + char calibration_id[64]; + + if (!prepare(database, SQL, &statement) || + sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return false; + } + step = sqlite3_step(statement); + if (step == SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return yyjson_mut_obj_add_null(document, root, "calibration"); + } + if (step != SQLITE_ROW || + strlen((const char *)sqlite3_column_text(statement, 0)) >= sizeof(calibration_id) || + snprintf(calibration_id, + sizeof(calibration_id), + "%s", + (const char *)sqlite3_column_text(statement, 0)) < 0) { + (void)sqlite3_finalize(statement); + return false; + } + + calibration = yyjson_mut_obj(document); + recovery = yyjson_mut_arr(document); + if (calibration == NULL || recovery == NULL || + !yyjson_mut_obj_add_strcpy(document, calibration, "calibration_id", calibration_id) || + !yyjson_mut_obj_add_sint( + document, calibration, "observed_peak_bpm", sqlite3_column_int64(statement, 1)) || + !yyjson_mut_obj_add_strcpy(document, + calibration, + "effort_end_at", + (const char *)sqlite3_column_text(statement, 2)) || + sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || + !prepare(database, RECOVERY_SQL, &recovery_statement) || + sqlite3_bind_text(recovery_statement, 1, calibration_id, -1, SQLITE_TRANSIENT) != + SQLITE_OK) { + if (recovery_statement != NULL) { + (void)sqlite3_finalize(recovery_statement); + } + return false; + } + while ((step = sqlite3_step(recovery_statement)) == SQLITE_ROW) { + yyjson_mut_val *point = yyjson_mut_obj(document); + if (point == NULL || + !yyjson_mut_obj_add_sint( + document, point, "offset_seconds", sqlite3_column_int64(recovery_statement, 0)) || + !yyjson_mut_obj_add_strcpy(document, + point, + "observed_at", + (const char *)sqlite3_column_text(recovery_statement, 1)) || + !yyjson_mut_obj_add_sint( + document, point, "bpm", sqlite3_column_int64(recovery_statement, 2)) || + !yyjson_mut_arr_append(recovery, point)) { + (void)sqlite3_finalize(recovery_statement); + return false; + } + } + return step == SQLITE_DONE && sqlite3_finalize(recovery_statement) == SQLITE_OK && + yyjson_mut_obj_add_val(document, calibration, "recovery", recovery) && + yyjson_mut_obj_add_val(document, root, "calibration", calibration); +} + +TrainlogStatus trainlog_web_heart_rate_timeline_json(TrainlogDatabase *database, + const char *context_id, + char *output, + size_t capacity, + size_t *output_size) { + static const char CAPTURE_SQL[] = + "SELECT capture_id,context_kind,started_at,ended_at,sensor_name " + "FROM heart_rate_captures WHERE context_id=? AND ended_at IS NOT NULL " + "ORDER BY ended_at DESC,capture_id DESC LIMIT 1;"; + sqlite3_stmt *statement = NULL; + yyjson_mut_doc *document; + yyjson_mut_val *root; + yyjson_mut_val *capture; + yyjson_mut_val *events; + const char *context_kind; + char capture_id[64]; + char kind[16]; + char *json; + size_t json_size; + yyjson_write_err error; + int step; + bool ok; + + if (database == NULL || !valid_context_id(context_id) || output == NULL || capacity < 2U || + output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output_size = 0U; + output[0] = '\0'; + + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + events = document == NULL ? NULL : yyjson_mut_arr(document); + if (document == NULL || root == NULL || events == NULL) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + yyjson_mut_doc_set_root(document, root); + if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) || + !yyjson_mut_obj_add_strcpy(document, root, "context_id", context_id) || + !prepare(database, CAPTURE_SQL, &statement) || + sqlite3_bind_text(statement, 1, context_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + step = sqlite3_step(statement); + if (step == SQLITE_DONE) { + (void)sqlite3_finalize(statement); + if (!yyjson_mut_obj_add_bool(document, root, "available", false) || + !yyjson_mut_obj_add_null(document, root, "capture") || + !yyjson_mut_obj_add_val(document, root, "events", events) || + !yyjson_mut_obj_add_null(document, root, "guidance") || + !yyjson_mut_obj_add_null(document, root, "calibration")) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + } else if (step == SQLITE_ROW) { + const char *capture_text = (const char *)sqlite3_column_text(statement, 0); + const char *kind_text = (const char *)sqlite3_column_text(statement, 1); + if (strlen(capture_text) >= sizeof(capture_id) || strlen(kind_text) >= sizeof(kind) || + snprintf(capture_id, sizeof(capture_id), "%s", capture_text) < 0 || + snprintf(kind, sizeof(kind), "%s", kind_text) < 0) { + (void)sqlite3_finalize(statement); + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + context_kind = kind; + capture = yyjson_mut_obj(document); + if (capture == NULL || + !yyjson_mut_obj_add_bool(document, root, "available", true) || + !yyjson_mut_obj_add_strcpy(document, capture, "capture_id", capture_id) || + !yyjson_mut_obj_add_strcpy(document, capture, "context_kind", context_kind) || + !yyjson_mut_obj_add_strcpy(document, + capture, + "started_at", + (const char *)sqlite3_column_text(statement, 2)) || + !yyjson_mut_obj_add_strcpy(document, + capture, + "ended_at", + (const char *)sqlite3_column_text(statement, 3)) || + !add_nullable_column_string(document, capture, "sensor_name", statement, 4) || + sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || + !add_samples(database, document, capture, capture_id) || + !yyjson_mut_obj_add_val(document, root, "capture", capture)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (strcmp(context_kind, "sleep") == 0) { + ok = add_sleep_events(database, document, events, context_id) && + yyjson_mut_obj_add_null(document, root, "guidance") && + yyjson_mut_obj_add_null(document, root, "calibration"); + } else if (strcmp(context_kind, "session") == 0 || strcmp(context_kind, "cardio") == 0) { + ok = add_session_events(database, document, events, context_id) && + (strcmp(context_kind, "cardio") != 0 || + (add_guidance(database, document, root, context_id) && + add_calibration(database, document, root, context_id))) && + (strcmp(context_kind, "cardio") == 0 || + (yyjson_mut_obj_add_null(document, root, "guidance") && + yyjson_mut_obj_add_null(document, root, "calibration"))); + } else { + ok = false; + } + if (!ok || !yyjson_mut_obj_add_val(document, root, "events", events)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + } else { + (void)sqlite3_finalize(statement); + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, &json_size, &error); + yyjson_mut_doc_free(document); + if (json == NULL || json_size + 2U > capacity) { + free(json); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + (void)memcpy(output, json, json_size); + output[json_size] = '\n'; + output[json_size + 1U] = '\0'; + *output_size = json_size + 1U; + free(json); + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 25bf8a1..64100ef 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -22,6 +22,7 @@ #include "trainlog/web_dashboard.h" #include "trainlog/web_analysis.h" +#include "trainlog/web_heart_rate.h" #include "trainlog/web_exercises.h" #include "trainlog/web_prepared_items.h" #include "trainlog/web_programs.h" @@ -71,6 +72,36 @@ static bool valid_exercise_id(const char *value) { uuid_parse(value + 3U, (unsigned char[16]){0}) == 0; } +static bool valid_heart_rate_context_id(const char *value) { + return value != NULL && strlen(value) == 39U && + (strncmp(value, "se_", 3U) == 0 || strncmp(value, "sl_", 3U) == 0) && + uuid_parse(value + 3U, (unsigned char[16]){0}) == 0; +} + +typedef struct TrainlogHeartRateArguments { + bool valid; + unsigned int context_count; +} TrainlogHeartRateArguments; + +static enum MHD_Result validate_heart_rate_argument(void *context, + enum MHD_ValueKind kind, + const char *key, + const char *value) { + TrainlogHeartRateArguments *arguments = context; + (void)kind; + (void)value; + if (strcmp(key, "context_id") != 0) { + arguments->valid = false; + return MHD_NO; + } + ++arguments->context_count; + if (arguments->context_count > 1U) { + arguments->valid = false; + return MHD_NO; + } + return MHD_YES; +} + typedef struct TrainlogAnalysisArguments { bool valid; unsigned int period_count; @@ -1358,6 +1389,57 @@ static enum MHD_Result handle_request(void *closure, context->csrf_token, "/api/v1/sync/status"); } + if (strcmp(url, "/api/v1/heart-rate-timeline") == 0) { + TrainlogHeartRateArguments arguments = {.valid = true}; + const char *context_id; + char *json; + size_t json_size = 0U; + TrainlogStatus status; + enum MHD_Result queued; + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } + (void)MHD_get_connection_values( + connection, MHD_GET_ARGUMENT_KIND, validate_heart_rate_argument, &arguments); + context_id = + MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "context_id"); + if (!arguments.valid || arguments.context_count != 1U || + !valid_heart_rate_context_id(context_id)) { + return queue_json(connection, + MHD_HTTP_BAD_REQUEST, + "{\"error\":\"invalid_heart_rate_context\"}\n", + NULL); + } + json = malloc(TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY); + if (json == NULL) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"heart_rate_timeline_unavailable\"}\n", + NULL); + } + status = trainlog_web_heart_rate_timeline_json(context->database, + context_id, + json, + TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, + &json_size); + if (status != TRAINLOG_STATUS_OK || json_size == 0U) { + free(json); + return queue_json(connection, + status == TRAINLOG_STATUS_INVALID_ARGUMENT + ? MHD_HTTP_BAD_REQUEST + : MHD_HTTP_INTERNAL_SERVER_ERROR, + status == TRAINLOG_STATUS_INVALID_ARGUMENT + ? "{\"error\":\"invalid_heart_rate_context\"}\n" + : "{\"error\":\"heart_rate_timeline_unavailable\"}\n", + NULL); + } + queued = queue_json(connection, MHD_HTTP_OK, json, NULL); + free(json); + return queued; + } if (strcmp(url, "/api/v1/analysis") == 0) { TrainlogAnalysisArguments arguments = {.valid = true}; TrainlogWebAnalysisQuery query; diff --git a/tui/tests/test_web_heart_rate.c b/tui/tests/test_web_heart_rate.c new file mode 100644 index 0000000..a259a18 --- /dev/null +++ b/tui/tests/test_web_heart_rate.c @@ -0,0 +1,234 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include + +#include "trainlog/database.h" +#include "trainlog/web_heart_rate.h" +#include "database_internal.h" + +#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) + +static const char *const SESSION_ID = "se_11111111-1111-4111-8111-111111111111"; +static const char *const ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222222"; +static const char *const RUN_ID = "cgr_55555555-5555-4555-8555-555555555555"; +static const char *const CALIBRATION_ID = "cal_77777777-7777-4777-8777-777777777777"; +static const char *const SLEEP_ID = "sl_88888888-8888-4888-8888-888888888888"; + +static bool execute_sql(sqlite3 *database, const char *sql) { + int rc = sqlite3_exec(database, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) { + fprintf(stderr, "seed SQLite rc=%d: %s\n", rc, sqlite3_errmsg(database)); + return false; + } + return true; +} + +static bool seed(sqlite3 *database) { + const char *const statements[] = { + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields)" + " VALUES('ex_33333333-3333-4333-8333-333333333333','Vélo guidé','velo guide'," + "'continuous','duration',0);" + "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" + " VALUES('se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:00+02:00'," + "'2026-09-23T10:10:00+02:00','training','cardio');", + + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode," + "tracking_mode,data_fields,position,load_mode,rest_seconds)" + " SELECT 'sxe_22222222-2222-4222-8222-222222222222',s.id,e.id,'continuous','duration'," + "0,0,'none',0 FROM sessions s,exercises e WHERE " + "s.session_id='se_11111111-1111-4111-8111-111111111111' AND " + "e.exercise_id='ex_33333333-3333-4333-8333-333333333333';" + "INSERT INTO session_exercise_timeline(session_id,entry_id,exercise_id,started_at,ended_at,imported_at)" + " VALUES('se_11111111-1111-4111-8111-111111111111'," + "'sxe_22222222-2222-4222-8222-222222222222'," + "'ex_33333333-3333-4333-8333-333333333333','2026-09-23T10:00:10+02:00'," + "'2026-09-23T10:09:50+02:00','2026-09-23T10:11:00+02:00');", + + "INSERT INTO heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at)" + " VALUES('hrc_44444444-4444-4444-8444-444444444444','cardio'," + "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:00+02:00'," + "'2026-09-23T10:10:00+02:00','CYCPLUS H2','2026-09-23T10:11:00+02:00');" + "INSERT INTO heart_rate_samples(capture_id,sequence,observed_at,bpm,exercise_entry_id," + "sensor_contact_detected,energy_expended) VALUES" + "('hrc_44444444-4444-4444-8444-444444444444',0,'2026-09-23T10:00:20+02:00',110," + "'sxe_22222222-2222-4222-8222-222222222222',NULL,NULL)," + "('hrc_44444444-4444-4444-8444-444444444444',1,'2026-09-23T10:00:40+02:00',130," + "'sxe_22222222-2222-4222-8222-222222222222',NULL,NULL);" + "INSERT INTO heart_rate_rr_intervals(capture_id,sample_sequence,rr_index,value_1024)" + " VALUES('hrc_44444444-4444-4444-8444-444444444444',0,0,1024);", + + "INSERT INTO cardio_calibrations(calibration_id,protocol_version,session_id,entry_id," + "started_at,effort_end_at,ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at)" + " VALUES('cal_77777777-7777-4777-8777-777777777777',1," + "'se_11111111-1111-4111-8111-111111111111','sxe_22222222-2222-4222-8222-222222222222'," + "'2026-09-23T10:00:00+02:00','2026-09-23T10:05:00+02:00','2026-09-23T10:10:00+02:00'," + "'hrc_44444444-4444-4444-8444-444444444444',160,'2026-09-23T10:11:00+02:00');" + "INSERT INTO cardio_calibration_recovery(calibration_id,target_offset_seconds,observed_at,bpm)" + " VALUES('cal_77777777-7777-4777-8777-777777777777',60,'2026-09-23T10:06:00+02:00',140);", + + "INSERT INTO cardio_guidance_runs(run_id,session_id,started_at,ended_at,imported_at)" + " VALUES('cgr_55555555-5555-4555-8555-555555555555'," + "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:20+02:00'," + "'2026-09-23T10:03:20+02:00','2026-09-23T10:11:00+02:00');" + "INSERT INTO cardio_guidance_phases(run_id,phase_id,entry_id,position,kind,target_min_bpm," + "target_max_bpm,calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," + "exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction)" + " VALUES('cgr_55555555-5555-4555-8555-555555555555'," + "'cgp_66666666-6666-4666-8666-666666666666','sxe_22222222-2222-4222-8222-222222222222'," + "0,'work',120,140,'cal_77777777-7777-4777-8777-777777777777',160,75,88," + "'fixed_duration',180,NULL,'2026-09-23T10:00:20+02:00','2026-09-23T10:03:20+02:00','maintain');", + + "INSERT INTO cardio_guidance_events(run_id,phase_id,sequence,observed_at,instruction,bpm," + "target_min_bpm,target_max_bpm) VALUES" + "('cgr_55555555-5555-4555-8555-555555555555','cgp_66666666-6666-4666-8666-666666666666'," + "0,'2026-09-23T10:00:20+02:00','accelerate',110,120,140)," + "('cgr_55555555-5555-4555-8555-555555555555','cgp_66666666-6666-4666-8666-666666666666'," + "1,'2026-09-23T10:00:40+02:00','maintain',130,120,140);", + + "INSERT INTO sleep_diary_entries(entry_id,night_start_date,night_end_date,created_at,updated_at," + "current_revision_id,deleted) VALUES('sl_88888888-8888-4888-8888-888888888888','2026-09-22'," + "'2026-09-23','2026-09-22T22:00:00+02:00','2026-09-23T06:30:00+02:00'," + "'slr_99999999-9999-4999-8999-999999999999',0);" + "INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at," + "sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(" + "'slr_99999999-9999-4999-8999-999999999999','sl_88888888-8888-4888-8888-888888888888'," + "NULL,'2026-09-23T06:30:00+02:00',NULL,NULL,NULL,'');", + + "INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) VALUES" + "('slr_99999999-9999-4999-8999-999999999999','sle_1','bed_time'," + "'2026-09-22T22:30:00+02:00',NULL)," + "('slr_99999999-9999-4999-8999-999999999999','sle_2','night_get_up'," + "'2026-09-23T02:10:00+02:00',NULL)," + "('slr_99999999-9999-4999-8999-999999999999','sle_3','final_get_up'," + "'2026-09-23T06:20:00+02:00',NULL);" + "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name," + "taken_at,dose_value,dose_unit,note,created_at) VALUES(" + "'slr_99999999-9999-4999-8999-999999999999','mdi_1','med_1','Médicament test'," + "'2026-09-22T22:40:00+02:00',NULL,NULL,NULL,'2026-09-22T22:40:00+02:00');", + + "INSERT INTO heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at)" + " VALUES('hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','sleep'," + "'sl_88888888-8888-4888-8888-888888888888','2026-09-22T22:30:00+02:00'," + "'2026-09-23T06:20:00+02:00','CYCPLUS H2','2026-09-23T07:00:00+02:00');" + "INSERT INTO heart_rate_samples(capture_id,sequence,observed_at,bpm,exercise_entry_id," + "sensor_contact_detected,energy_expended) VALUES(" + "'hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',0,'2026-09-23T02:10:00+02:00',75,NULL,NULL,NULL);", + }; + size_t index; + + for (index = 0U; index < sizeof(statements) / sizeof(statements[0]); ++index) { + if (!execute_sql(database, statements[index])) { + return false; + } + } + return true; +} + +static yyjson_doc *timeline(TrainlogDatabase *database, const char *context_id) { + char *json = malloc(TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY); + size_t size = 0U; + yyjson_doc *document = NULL; + if (json == NULL) { + return NULL; + } + if (trainlog_web_heart_rate_timeline_json(database, + context_id, + json, + TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, + &size) == TRAINLOG_STATUS_OK && + size > 0U) { + document = yyjson_read(json, size, 0); + } + free(json); + return document; +} + +int main(void) { + char path[] = "/tmp/trainlog-web-heart-rate-XXXXXX"; + int descriptor = mkstemp(path); + TrainlogDatabase *database = NULL; + yyjson_doc *document = NULL; + yyjson_val *root; + yyjson_val *capture; + yyjson_val *samples; + yyjson_val *events; + yyjson_val *guidance; + yyjson_val *phase; + yyjson_val *calibration; + int result = EXIT_FAILURE; + + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(seed(database->connection)); + + document = timeline(database, SESSION_ID); + CHECK(document != NULL); + root = yyjson_doc_get_root(document); + CHECK(yyjson_get_bool(yyjson_obj_get(root, "available"))); + capture = yyjson_obj_get(root, "capture"); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(capture, "context_kind")), "cardio") == 0); + CHECK(yyjson_get_int(yyjson_obj_get(capture, "sample_count")) == 2); + samples = yyjson_obj_get(capture, "samples"); + CHECK(yyjson_arr_size(samples) == 2U); + CHECK(yyjson_arr_size(yyjson_obj_get(yyjson_arr_get(samples, 0U), "rr_1024")) == 1U); + events = yyjson_obj_get(root, "events"); + CHECK(yyjson_arr_size(events) == 2U); + guidance = yyjson_obj_get(root, "guidance"); + CHECK(yyjson_is_obj(guidance)); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(guidance, "run_id")), RUN_ID) == 0); + phase = yyjson_arr_get(yyjson_obj_get(guidance, "phases"), 0U); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(phase, "entry_id")), ENTRY_ID) == 0); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(phase, "target"), "minimum_bpm")) == 120); + CHECK(yyjson_arr_size(yyjson_obj_get(phase, "events")) == 2U); + calibration = yyjson_obj_get(root, "calibration"); + CHECK(yyjson_is_obj(calibration)); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(calibration, "calibration_id")), CALIBRATION_ID) == 0); + CHECK(yyjson_get_int(yyjson_obj_get(calibration, "observed_peak_bpm")) == 160); + CHECK(yyjson_arr_size(yyjson_obj_get(calibration, "recovery")) == 1U); + yyjson_doc_free(document); + document = NULL; + + document = timeline(database, SLEEP_ID); + CHECK(document != NULL); + root = yyjson_doc_get_root(document); + CHECK(yyjson_get_bool(yyjson_obj_get(root, "available"))); + capture = yyjson_obj_get(root, "capture"); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(capture, "context_kind")), "sleep") == 0); + CHECK(yyjson_get_int(yyjson_obj_get(capture, "sample_count")) == 1); + events = yyjson_obj_get(root, "events"); + CHECK(yyjson_arr_size(events) == 4U); + CHECK(yyjson_is_null(yyjson_obj_get(root, "guidance"))); + CHECK(yyjson_is_null(yyjson_obj_get(root, "calibration"))); + yyjson_doc_free(document); + document = NULL; + + document = timeline(database, "se_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + CHECK(document != NULL); + root = yyjson_doc_get_root(document); + CHECK(!yyjson_get_bool(yyjson_obj_get(root, "available"))); + CHECK(yyjson_is_null(yyjson_obj_get(root, "capture"))); + yyjson_doc_free(document); + document = NULL; + + { + char buffer[64]; + size_t size = 123U; + CHECK(trainlog_web_heart_rate_timeline_json( + database, "bad", buffer, sizeof(buffer), &size) == TRAINLOG_STATUS_INVALID_ARGUMENT); + } + + result = EXIT_SUCCESS; + +cleanup: + if (document != NULL) yyjson_doc_free(document); + if (database != NULL) trainlog_database_close(database); + (void)unlink(path); + if (result == EXIT_SUCCESS) puts("PASS factual Web heart-rate timeline"); + return result; +} diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 70e9d78..4dcf582 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -516,6 +516,29 @@ static bool test_http_contract(TrainlogDatabase *database) { strstr(response, "HTTP/1.1 200") != NULL && strstr(response, "\"period\":\"30d\"") != NULL && strstr(response, "\"selected_metric\":\"waist\"") != NULL); + CHECK(exchange(port, + "GET /api/v1/heart-rate-timeline?context_id=" + "se_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"available\":false") != NULL && + strstr(response, "\"capture\":null") != NULL); + CHECK(exchange(port, + "GET /api/v1/heart-rate-timeline?context_id=bad HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 400") != NULL && + strstr(response, "invalid_heart_rate_context") != NULL); + CHECK(exchange(port, + "POST /api/v1/heart-rate-timeline?context_id=" + "se_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb HTTP/1.1\r\n" + "Host: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 405") != NULL); CHECK(exchange(port, "GET /api/v1/analysis?period=invalid HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, diff --git a/web/src/api/heartRate.test.ts b/web/src/api/heartRate.test.ts new file mode 100644 index 0000000..3e48bbe --- /dev/null +++ b/web/src/api/heartRate.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchHeartRateTimeline, parseHeartRateTimeline, type HeartRateTimeline } from './heartRate' + +const fixture: HeartRateTimeline = { + api_version: 1, + context_id: 'se_11111111-1111-4111-8111-111111111111', + available: true, + capture: { + capture_id: 'hrc_22222222-2222-4222-8222-222222222222', + context_kind: 'cardio', + started_at: '2026-09-23T10:00:00+02:00', + ended_at: '2026-09-23T10:10:00+02:00', + sensor_name: 'CYCPLUS H2', + sample_count: 2, + samples: [ + { sequence: 0, observed_at: '2026-09-23T10:00:20+02:00', bpm: 110, rr_1024: [1024] }, + { sequence: 1, observed_at: '2026-09-23T10:00:40+02:00', bpm: 130, rr_1024: [] }, + ], + }, + events: [{ + type: 'exercise', + at: '2026-09-23T10:00:10+02:00', + end_at: '2026-09-23T10:09:50+02:00', + label: 'Vélo', + entry_id: 'sxe_33333333-3333-4333-8333-333333333333', + exercise_id: 'ex_44444444-4444-4444-8444-444444444444', + }], + guidance: { + run_id: 'cgr_55555555-5555-4555-8555-555555555555', + started_at: '2026-09-23T10:00:20+02:00', + ended_at: '2026-09-23T10:03:20+02:00', + phases: [{ + phase_id: 'cgp_66666666-6666-4666-8666-666666666666', + entry_id: 'sxe_33333333-3333-4333-8333-333333333333', + position: 0, + kind: 'work', + target: { + minimum_bpm: 120, maximum_bpm: 140, calibration_id: null, + calibration_observed_peak_bpm: null, minimum_percent: null, maximum_percent: null, + }, + exit_condition: { kind: 'fixed_duration', seconds: 180, bpm: null }, + started_at: '2026-09-23T10:00:20+02:00', + ended_at: '2026-09-23T10:03:20+02:00', + final_instruction: 'maintain', + events: [], + }], + }, + calibration: null, +} + +afterEach(() => vi.restoreAllMocks()) + +describe('heart-rate timeline API', () => { + it('accepts factual BPM and raw RR without interpreting them', () => { + const parsed = parseHeartRateTimeline(fixture) + expect(parsed.capture?.samples[0].rr_1024).toEqual([1024]) + expect(parsed.guidance?.phases[0].target?.minimum_bpm).toBe(120) + }) + + it('rejects inconsistent sample counts', () => { + expect(() => parseHeartRateTimeline({ + ...fixture, + capture: { ...fixture.capture!, sample_count: 3 }, + })).toThrow('heart_rate_capture_invalid') + }) + + it('uses only the stable context id in the request', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(fixture), { status: 200 })) + await fetchHeartRateTimeline(fixture.context_id) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('context_id=se_11111111-1111-4111-8111-111111111111'), + expect.anything(), + ) + }) +}) diff --git a/web/src/api/heartRate.ts b/web/src/api/heartRate.ts new file mode 100644 index 0000000..617ec14 --- /dev/null +++ b/web/src/api/heartRate.ts @@ -0,0 +1,196 @@ +export interface HeartRateSample { + sequence: number + observed_at: string + bpm: number + rr_1024: number[] +} + +export interface HeartRateCapture { + capture_id: string + context_kind: 'session' | 'cardio' | 'sleep' + started_at: string + ended_at: string + sensor_name: string | null + sample_count: number + samples: HeartRateSample[] +} + +export interface HeartRateTimelineEvent { + type: string + at: string + end_at: string | null + label: string | null + entry_id: string | null + exercise_id: string | null +} + +export interface HeartRateGuidanceEvent { + sequence: number + observed_at: string + instruction: 'accelerate' | 'maintain' | 'slow_down' | 'suspended' + bpm: number | null + target_minimum_bpm: number | null + target_maximum_bpm: number | null +} + +export interface HeartRateGuidancePhase { + phase_id: string + entry_id: string + position: number + kind: 'warmup' | 'work' | 'recovery' | 'cooldown' + target: null | { + minimum_bpm: number + maximum_bpm: number + calibration_id: string | null + calibration_observed_peak_bpm: number | null + minimum_percent: number | null + maximum_percent: number | null + } + exit_condition: { kind: string; seconds: number | null; bpm: number | null } + started_at: string + ended_at: string + final_instruction: 'accelerate' | 'maintain' | 'slow_down' | 'suspended' + events: HeartRateGuidanceEvent[] +} + +export interface HeartRateTimeline { + api_version: 1 + context_id: string + available: boolean + capture: HeartRateCapture | null + events: HeartRateTimelineEvent[] + guidance: null | { + run_id: string + started_at: string + ended_at: string + phases: HeartRateGuidancePhase[] + } + calibration: null | { + calibration_id: string + observed_peak_bpm: number + effort_end_at: string + recovery: Array<{ offset_seconds: number; observed_at: string; bpm: number }> + } +} + +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) +const integer = (value: unknown): value is number => Number.isSafeInteger(value) +const bpm = (value: unknown): value is number => + integer(value) && Number(value) >= 0 && Number(value) <= 65535 +const nullableString = (value: unknown): value is string | null => + value === null || typeof value === 'string' +const instruction = (value: unknown): boolean => + value === 'accelerate' || value === 'maintain' || + value === 'slow_down' || value === 'suspended' + +function validSample(value: unknown): boolean { + if (!object(value) || !integer(value.sequence) || Number(value.sequence) < 0 || + typeof value.observed_at !== 'string' || !bpm(value.bpm) || + !Array.isArray(value.rr_1024) || value.rr_1024.length > 64) return false + return value.rr_1024.every((item) => + integer(item) && Number(item) >= 0 && Number(item) <= 65535) +} + +function validEvent(value: unknown): boolean { + return object(value) && typeof value.type === 'string' && + typeof value.at === 'string' && nullableString(value.end_at) && + nullableString(value.label) && nullableString(value.entry_id) && + nullableString(value.exercise_id) +} + +function validGuidanceEvent(value: unknown): boolean { + return object(value) && integer(value.sequence) && Number(value.sequence) >= 0 && + typeof value.observed_at === 'string' && instruction(value.instruction) && + (value.bpm === null || bpm(value.bpm)) && + (value.target_minimum_bpm === null || bpm(value.target_minimum_bpm)) && + (value.target_maximum_bpm === null || bpm(value.target_maximum_bpm)) +} + +function validTarget(value: unknown): boolean { + if (value === null) return true + return object(value) && bpm(value.minimum_bpm) && bpm(value.maximum_bpm) && + Number(value.minimum_bpm) <= Number(value.maximum_bpm) && + nullableString(value.calibration_id) && + (value.calibration_observed_peak_bpm === null || bpm(value.calibration_observed_peak_bpm)) && + (value.minimum_percent === null || integer(value.minimum_percent)) && + (value.maximum_percent === null || integer(value.maximum_percent)) +} + +function validPhase(value: unknown): boolean { + if (!object(value) || typeof value.phase_id !== 'string' || + typeof value.entry_id !== 'string' || !integer(value.position) || + !['warmup', 'work', 'recovery', 'cooldown'].includes(String(value.kind)) || + !validTarget(value.target) || !object(value.exit_condition) || + typeof value.exit_condition.kind !== 'string' || + !(value.exit_condition.seconds === null || integer(value.exit_condition.seconds)) || + !(value.exit_condition.bpm === null || bpm(value.exit_condition.bpm)) || + typeof value.started_at !== 'string' || typeof value.ended_at !== 'string' || + !instruction(value.final_instruction) || !Array.isArray(value.events) || + value.events.length > 4096) return false + return value.events.every(validGuidanceEvent) +} + +export function parseHeartRateTimeline(value: unknown): HeartRateTimeline { + if (!object(value) || value.api_version !== 1 || typeof value.context_id !== 'string' || + typeof value.available !== 'boolean' || !Array.isArray(value.events) || + value.events.length > 2048 || !value.events.every(validEvent)) { + throw new TypeError('heart_rate_timeline_invalid') + } + if (!value.available) { + if (value.capture !== null || value.guidance !== null || value.calibration !== null) { + throw new TypeError('heart_rate_timeline_unavailable_invalid') + } + return value as unknown as HeartRateTimeline + } + if (!object(value.capture) || + !['session', 'cardio', 'sleep'].includes(String(value.capture.context_kind)) || + typeof value.capture.capture_id !== 'string' || + typeof value.capture.started_at !== 'string' || + typeof value.capture.ended_at !== 'string' || + !nullableString(value.capture.sensor_name) || + !integer(value.capture.sample_count) || + !Array.isArray(value.capture.samples) || + value.capture.samples.length > 100000 || + value.capture.samples.length !== Number(value.capture.sample_count) || + !value.capture.samples.every(validSample)) { + throw new TypeError('heart_rate_capture_invalid') + } + if (value.guidance !== null) { + if (!object(value.guidance) || typeof value.guidance.run_id !== 'string' || + typeof value.guidance.started_at !== 'string' || + typeof value.guidance.ended_at !== 'string' || + !Array.isArray(value.guidance.phases) || + value.guidance.phases.length > 128 || + !value.guidance.phases.every(validPhase)) { + throw new TypeError('heart_rate_guidance_invalid') + } + } + if (value.calibration !== null) { + if (!object(value.calibration) || + typeof value.calibration.calibration_id !== 'string' || + !bpm(value.calibration.observed_peak_bpm) || + typeof value.calibration.effort_end_at !== 'string' || + !Array.isArray(value.calibration.recovery) || + value.calibration.recovery.length > 3 || + !value.calibration.recovery.every((point) => + object(point) && integer(point.offset_seconds) && + typeof point.observed_at === 'string' && bpm(point.bpm))) { + throw new TypeError('heart_rate_calibration_invalid') + } + } + return value as unknown as HeartRateTimeline +} + +export async function fetchHeartRateTimeline( + contextId: string, + signal?: AbortSignal, +): Promise { + const query = new URLSearchParams({ context_id: contextId }) + const response = await fetch('/api/v1/heart-rate-timeline?' + query.toString(), { + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) throw new Error('heart-rate timeline HTTP ' + response.status) + return parseHeartRateTimeline(await response.json()) +} diff --git a/web/src/components/HeartRateTimelinePanel.test.tsx b/web/src/components/HeartRateTimelinePanel.test.tsx new file mode 100644 index 0000000..c28b703 --- /dev/null +++ b/web/src/components/HeartRateTimelinePanel.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HeartRateTimelinePanel } from './HeartRateTimelinePanel' + +afterEach(() => vi.restoreAllMocks()) + +describe('HeartRateTimelinePanel', () => { + it('renders factual BPM statistics, RR, markers and guided target', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + api_version: 1, + context_id: 'se_11111111-1111-4111-8111-111111111111', + available: true, + capture: { + capture_id: 'hrc_22222222-2222-4222-8222-222222222222', + context_kind: 'cardio', + started_at: '2026-09-23T10:00:00+02:00', + ended_at: '2026-09-23T10:05:00+02:00', + sensor_name: 'CYCPLUS H2', + sample_count: 3, + samples: [ + { sequence: 0, observed_at: '2026-09-23T10:00:10+02:00', bpm: 100, rr_1024: [1024] }, + { sequence: 1, observed_at: '2026-09-23T10:01:10+02:00', bpm: 130, rr_1024: [1000] }, + { sequence: 2, observed_at: '2026-09-23T10:02:10+02:00', bpm: 160, rr_1024: [] }, + ], + }, + events: [{ + type: 'exercise', + at: '2026-09-23T10:00:05+02:00', + end_at: '2026-09-23T10:04:50+02:00', + label: 'Vélo guidé', + entry_id: 'sxe_33333333-3333-4333-8333-333333333333', + exercise_id: 'ex_44444444-4444-4444-8444-444444444444', + }], + guidance: { + run_id: 'cgr_55555555-5555-4555-8555-555555555555', + started_at: '2026-09-23T10:00:10+02:00', + ended_at: '2026-09-23T10:03:10+02:00', + phases: [{ + phase_id: 'cgp_66666666-6666-4666-8666-666666666666', + entry_id: 'sxe_33333333-3333-4333-8333-333333333333', + position: 0, + kind: 'work', + target: { + minimum_bpm: 120, maximum_bpm: 140, calibration_id: null, + calibration_observed_peak_bpm: null, minimum_percent: null, maximum_percent: null, + }, + exit_condition: { kind: 'fixed_duration', seconds: 180, bpm: null }, + started_at: '2026-09-23T10:00:10+02:00', + ended_at: '2026-09-23T10:03:10+02:00', + final_instruction: 'maintain', + events: [], + }], + }, + calibration: null, + }), { status: 200 })) + + render() + + await waitFor(() => expect(screen.getByTestId('heart-rate-timeline')).toBeInTheDocument()) + expect(screen.getByText('100 BPM')).toBeInTheDocument() + expect(screen.getByText('130.0 BPM')).toBeInTheDocument() + expect(screen.getByText('160 BPM')).toBeInTheDocument() + expect(screen.getByText('Vélo guidé')).toBeInTheDocument() + expect(screen.getByText('120–140 BPM')).toBeInTheDocument() + expect(screen.getByText('Maintiens')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('renders a factual empty state when no synchronized capture exists', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + api_version: 1, + context_id: 'sl_11111111-1111-4111-8111-111111111111', + available: false, + capture: null, + events: [], + guidance: null, + calibration: null, + }), { status: 200 })) + + render() + expect(await screen.findByText(/Aucune capture cardio synchronisée/)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/HeartRateTimelinePanel.tsx b/web/src/components/HeartRateTimelinePanel.tsx new file mode 100644 index 0000000..db0207b --- /dev/null +++ b/web/src/components/HeartRateTimelinePanel.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useState } from 'react' +import { + fetchHeartRateTimeline, + type HeartRateSample, + type HeartRateTimeline, + type HeartRateTimelineEvent, +} from '../api/heartRate' + +const WIDTH = 1000 +const HEIGHT = 280 +const LEFT = 48 +const RIGHT = 18 +const TOP = 22 +const BOTTOM = 38 +const DISPLAY_BUCKETS = 1000 + +function project(samples: HeartRateSample[]): HeartRateSample[] { + if (samples.length <= DISPLAY_BUCKETS * 2) return samples + const size = Math.ceil(samples.length / DISPLAY_BUCKETS) + const selected: HeartRateSample[] = [] + for (let start = 0; start < samples.length; start += size) { + const bucket = samples.slice(start, Math.min(samples.length, start + size)) + if (bucket.length === 0) continue + let minimum = bucket[0] + let maximum = bucket[0] + for (const sample of bucket) { + if (sample.bpm < minimum.bpm) minimum = sample + if (sample.bpm > maximum.bpm) maximum = sample + } + if (minimum.observed_at <= maximum.observed_at) { + selected.push(minimum) + if (maximum !== minimum) selected.push(maximum) + } else { + selected.push(maximum) + if (maximum !== minimum) selected.push(minimum) + } + } + return selected +} + +function eventLabel(event: HeartRateTimelineEvent, french: boolean): string { + if (event.label) return event.label + const labels: Record = { + session: ['Séance', 'Session'], + bed_time: ['Couché', 'Bedtime'], + final_get_up: ['Levé', 'Final get-up'], + night_get_up: ['Réveil', 'Awakening'], + medication: ['Médicament', 'Medication'], + sleep: ['Sommeil déclaré', 'Declared sleep'], + nap: ['Sieste', 'Nap'], + long_awake: ['Éveil long', 'Long awakening'], + half_sleep: ['Demi-sommeil', 'Half-sleep'], + daytime_sleepiness: ['Somnolence', 'Sleepiness'], + } + return labels[event.type]?.[french ? 0 : 1] ?? event.type +} + +function instructionLabel(value: string, french: boolean): string { + const labels: Record = { + accelerate: ['Accélère', 'Speed up'], + maintain: ['Maintiens', 'Maintain'], + slow_down: ['Ralentis', 'Slow down'], + suspended: ['Signal indisponible', 'Signal unavailable'], + } + return labels[value]?.[french ? 0 : 1] ?? value +} + +function phaseLabel(value: string, french: boolean): string { + const labels: Record = { + warmup: ['Échauffement', 'Warm-up'], + work: ['Travail', 'Work'], + recovery: ['Récupération', 'Recovery'], + cooldown: ['Retour au calme', 'Cool-down'], + } + return labels[value]?.[french ? 0 : 1] ?? value +} + +export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { + const french = !document.documentElement.lang.toLowerCase().startsWith('en') + const [timeline, setTimeline] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + const controller = new AbortController() + setTimeline(null) + setFailed(false) + fetchHeartRateTimeline(contextId, controller.signal) + .then((value) => { + if (!controller.signal.aborted) setTimeline(value) + }) + .catch(() => { + if (!controller.signal.aborted) setFailed(true) + }) + return () => controller.abort() + }, [contextId]) + + const calculated = useMemo(() => { + const samples = timeline?.capture?.samples ?? [] + if (samples.length === 0) return null + let minimum = samples[0].bpm + let maximum = samples[0].bpm + let total = 0 + let rrCount = 0 + for (const sample of samples) { + if (sample.bpm < minimum) minimum = sample.bpm + if (sample.bpm > maximum) maximum = sample.bpm + total += sample.bpm + rrCount += sample.rr_1024.length + } + const average = total / samples.length + return { minimum, maximum, average, rrCount, display: project(samples) } + }, [timeline]) + + if (failed) { + return
+ {french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'} +
+ } + if (!timeline) { + return
+ {french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'} +
+ } + if (!timeline.available || !timeline.capture || !calculated) { + return
+

{french ? 'Rythme cardiaque' : 'Heart rate'}

+

+ {french ? 'Aucune capture cardio synchronisée pour cette période.' : 'No synchronized heart-rate capture for this period.'} +

+
+ } + + const capture = timeline.capture + const start = Date.parse(capture.started_at) + const end = Date.parse(capture.ended_at) + const duration = Math.max(1, end - start) + const bpmFloor = Math.max(0, calculated.minimum - 8) + const bpmCeiling = calculated.maximum + 8 + const bpmSpan = Math.max(1, bpmCeiling - bpmFloor) + const x = (timestamp: string) => + LEFT + ((Date.parse(timestamp) - start) / duration) * (WIDTH - LEFT - RIGHT) + const y = (bpm: number) => + TOP + ((bpmCeiling - bpm) / bpmSpan) * (HEIGHT - TOP - BOTTOM) + const points = calculated.display + .map((sample) => String(x(sample.observed_at)) + ',' + String(y(sample.bpm))) + .join(' ') + const visibleEvents = timeline.events.slice(0, 24) + + return
+
+
+

{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}

+

{french ? 'Rythme cardiaque' : 'Heart rate'}

+
+
+
Min
{calculated.minimum} BPM
+
{french ? 'Moy.' : 'Avg.'}
{calculated.average.toFixed(1)} BPM
+
Max
{calculated.maximum} BPM
+
RR
{calculated.rrCount}
+
+
+ +
+ + {timeline.guidance?.phases.map((phase) => { + if (!phase.target) return null + const phaseX = x(phase.started_at) + const phaseWidth = Math.max(1, x(phase.ended_at) - phaseX) + const targetTop = y(phase.target.maximum_bpm) + const targetHeight = Math.max(1, y(phase.target.minimum_bpm) - targetTop) + return + })} + {timeline.events.filter((event) => event.end_at).map((event, index) => { + const eventX = x(event.at) + const width = Math.max(1, x(event.end_at as string) - eventX) + return + })} + + + {visibleEvents.map((event, index) => { + const eventX = x(event.at) + return + + {eventLabel(event, french)} + + })} + + {new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + + {new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + + {calculated.maximum} + + + {calculated.minimum} + + + {capture.samples.length > calculated.display.length && +
+ {french + ? 'Projection graphique bornée conservant les minima/maxima ; statistiques calculées sur toutes les mesures.' + : 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'} +
} +
+ + {timeline.guidance &&
+

{french ? 'Phases guidées' : 'Guided phases'}

+
    + {timeline.guidance.phases.map((phase) =>
  1. + {phaseLabel(phase.kind, french)} + {phase.target + ? String(phase.target.minimum_bpm) + '–' + String(phase.target.maximum_bpm) + ' BPM' + : '—'} + {instructionLabel(phase.final_instruction, french)} +
  2. )} +
+
} + + {timeline.calibration &&
+

{french ? 'Calibration utilisée / observée' : 'Observed calibration'}

+

{french ? 'Pic observé' : 'Observed peak'} : + {timeline.calibration.observed_peak_bpm} BPM

+ {timeline.calibration.recovery.length > 0 &&

+ {timeline.calibration.recovery.map((point) => + '+' + String(Math.round(point.offset_seconds / 60)) + ' min: ' + + String(point.bpm) + ' BPM').join(' · ')} +

} +
} + + {timeline.events.length > 0 &&
+ {french ? 'Marqueurs factuels' : 'Factual markers'} ({timeline.events.length}) +
    {timeline.events.map((event, index) =>
  • + + {' · '}{eventLabel(event, french)} +
  • )}
+
} +
+} diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index dd4b3c7..790f3a1 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -16,6 +16,7 @@ import { type SessionListItem, } from '../api/sessions' import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog' +import { HeartRateTimelinePanel } from '../components/HeartRateTimelinePanel' import { useDatePreferences } from '../presentation/DatePreferences' import { formatCivilDate, @@ -478,6 +479,7 @@ function ReadonlyDetail({ kind, identity, onBack }: { return `${String(values.reps ?? values.duration_seconds ?? '—')}${values.weight_kg ? ` × ${String(values.weight_kg)} kg` : ''}` }).join(' · ') : occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'} )}} + {kind === 'history' && } {kind === 'draft' && payload &&

État confirmé

{JSON.stringify(payload, null, 2)}
} diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 3de4f79..41f64f2 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -17,6 +17,7 @@ import { type SleepQuality, } from "../api/sleepDiary"; import type { AnalysisPeriod } from "../api/analysis"; +import { HeartRateTimelinePanel } from "../components/HeartRateTimelinePanel"; import { buildSleepDiaryPdf, presentSleepDiaryPdf, @@ -891,6 +892,8 @@ export function SleepDiaryWorkspace({ )} + {activeEntry && } +
diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 6cf62f1..7ff439f 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -2943,3 +2943,124 @@ h2 { transition: none !important; } } + +.heart-rate-panel { + margin-top: 1rem; + padding: 1rem; + background: var(--mantle); + border: 1px solid var(--surface-1); + border-radius: 0.7rem; +} +.heart-rate-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; +} +.heart-rate-heading h3, +.heart-rate-phases h4, +.heart-rate-calibration h4 { + margin: 0; +} +.heart-rate-facts { + margin: 0; + display: grid; + grid-template-columns: repeat(4, minmax(4.5rem, 1fr)); + gap: 0.4rem; +} +.heart-rate-facts div { + padding: 0.45rem 0.55rem; + background: var(--surface-0); + border-radius: 0.45rem; +} +.heart-rate-facts dt { + color: var(--overlay-1); + font-size: var(--font-size-xs); +} +.heart-rate-facts dd { + margin: 0.15rem 0 0; + color: var(--text); + font: 700 var(--font-size-sm) var(--font-mono); +} +.heart-rate-chart { + margin: 0.85rem 0 0; +} +.heart-rate-chart svg { + width: 100%; + min-height: 15rem; + overflow: visible; +} +.heart-rate-line { + stroke: var(--red); + stroke-width: 1.6; + vector-effect: non-scaling-stroke; +} +.heart-rate-target { + fill: rgb(166 227 161 / 14%); + stroke: rgb(166 227 161 / 42%); + stroke-width: 0.7; + vector-effect: non-scaling-stroke; +} +.heart-rate-event-band { + fill: rgb(180 190 254 / 6%); +} +.heart-rate-marker { + stroke: var(--surface-2); + stroke-width: 0.7; + stroke-dasharray: 3 3; + vector-effect: non-scaling-stroke; +} +.heart-rate-marker-label, +.chart-axis-label { + fill: var(--subtext-0); + font-size: 10px; +} +.heart-rate-chart figcaption { + margin-top: 0.45rem; + color: var(--subtext-0); + font-size: var(--font-size-xs); +} +.heart-rate-phases, +.heart-rate-calibration, +.heart-rate-events { + margin-top: 0.9rem; + padding-top: 0.8rem; + border-top: 1px solid var(--surface-1); +} +.heart-rate-phases ol { + margin: 0.55rem 0 0; + padding: 0; + display: grid; + gap: 0.4rem; + list-style: none; +} +.heart-rate-phases li { + display: grid; + grid-template-columns: minmax(8rem, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.6rem; + background: var(--surface-0); + border-radius: 0.45rem; +} +.heart-rate-phases small, +.heart-rate-events { + color: var(--subtext-0); +} +.heart-rate-events ul { + max-height: 12rem; + overflow: auto; +} +@media (max-width: 720px) { + .heart-rate-heading { + display: block; + } + .heart-rate-facts { + margin-top: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .heart-rate-phases li { + grid-template-columns: 1fr; + gap: 0.2rem; + } +}