feat(web): visualize synchronized heart rate

This commit is contained in:
fy59 2026-09-23 15:24:16 +02:00
parent ad493a72aa
commit 246e47ee8f
20 changed files with 1838 additions and 6 deletions

View file

@ -61,6 +61,8 @@
timeline, calibration provenance and exact raw HR evidence for instruction timeline, calibration provenance and exact raw HR evidence for instruction
events carrying BPM before accepting them. 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. Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22 ## 0.1.4 — 2026-09-22

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface. list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version 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 ## Releases

View file

@ -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 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 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 | | 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 | | 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` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract. Bluetooth synchronization transport contract.
```text ```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 The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -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 The 2026-09-23 closeout passes the complete native Meson inventory
**110/110**, and the complete Android JVM inventory reports **300 tests: **110/110**, and the complete Android JVM inventory reports **300 tests:
295 passed, five skipped, zero failures/errors**. `assembleDebug` succeeds. 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.

View file

@ -128,6 +128,7 @@ def validate(root: object) -> dict:
if not isinstance(runs, list) or len(runs) > MAX_RUNS: if not isinstance(runs, list) or len(runs) > MAX_RUNS:
fail("cardio guidance run bound exceeded") fail("cardio guidance run bound exceeded")
seen_runs = set() seen_runs = set()
seen_sessions = set()
seen_phases = set() seen_phases = set()
for run in runs: for run in runs:
if not isinstance(run, dict) or set(run) != { 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") fail("invalid or duplicate cardio guidance run identity")
seen_runs.add(run["run_id"]) seen_runs.add(run["run_id"])
if not isinstance(run["session_id"], str) or not SESSION_ID.fullmatch(run["session_id"]): if (
fail("invalid cardio guidance session identity") 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_start = timestamp(run["started_at"])
_, run_end = timestamp(run["ended_at"]) _, run_end = timestamp(run["ended_at"])
if run_end < run_start: if run_end < run_start:

View file

@ -0,0 +1,17 @@
#ifndef TRAINLOG_WEB_HEART_RATE_H
#define TRAINLOG_WEB_HEART_RATE_H
#include <stddef.h>
#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

View file

@ -218,6 +218,7 @@ trainlog_core_sources = files(
'src/web_dashboard.c', 'src/web_dashboard.c',
'src/web_dashboard_json.c', 'src/web_dashboard_json.c',
'src/web_analysis.c', 'src/web_analysis.c',
'src/web_heart_rate.c',
'src/web_prepared_items.c', 'src/web_prepared_items.c',
'src/web_prepared_items_json.c', 'src/web_prepared_items_json.c',
'src/web_sessions.c', 'src/web_sessions.c',
@ -381,6 +382,15 @@ test_web_analysis = executable(
) )
test('web_analysis', test_web_analysis) 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 = executable(
'test_web_prepared_items', 'test_web_prepared_items',
'tests/test_web_prepared_items.c', 'tests/test_web_prepared_items.c',

View file

@ -1433,7 +1433,7 @@ static const char *const MIGRATE_V32_TO_V33_SQL =
static const char *const MIGRATE_V33_TO_V34_SQL = static const char *const MIGRATE_V33_TO_V34_SQL =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS cardio_guidance_runs(" "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," "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_*'));" "CHECK(run_id GLOB 'cgr_*'),CHECK(session_id GLOB 'se_*'));"
"CREATE TABLE IF NOT EXISTS cardio_guidance_phases(" "CREATE TABLE IF NOT EXISTS cardio_guidance_phases("

703
tui/src/web_heart_rate.c Normal file
View file

@ -0,0 +1,703 @@
#include "trainlog/web_heart_rate.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include <yyjson.h>
#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;
}

View file

@ -22,6 +22,7 @@
#include "trainlog/web_dashboard.h" #include "trainlog/web_dashboard.h"
#include "trainlog/web_analysis.h" #include "trainlog/web_analysis.h"
#include "trainlog/web_heart_rate.h"
#include "trainlog/web_exercises.h" #include "trainlog/web_exercises.h"
#include "trainlog/web_prepared_items.h" #include "trainlog/web_prepared_items.h"
#include "trainlog/web_programs.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; 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 { typedef struct TrainlogAnalysisArguments {
bool valid; bool valid;
unsigned int period_count; unsigned int period_count;
@ -1358,6 +1389,57 @@ static enum MHD_Result handle_request(void *closure,
context->csrf_token, context->csrf_token,
"/api/v1/sync/status"); "/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) { if (strcmp(url, "/api/v1/analysis") == 0) {
TrainlogAnalysisArguments arguments = {.valid = true}; TrainlogAnalysisArguments arguments = {.valid = true};
TrainlogWebAnalysisQuery query; TrainlogWebAnalysisQuery query;

View file

@ -0,0 +1,234 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <yyjson.h>
#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;
}

View file

@ -516,6 +516,29 @@ static bool test_http_contract(TrainlogDatabase *database) {
strstr(response, "HTTP/1.1 200") != NULL && strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"period\":\"30d\"") != NULL && strstr(response, "\"period\":\"30d\"") != NULL &&
strstr(response, "\"selected_metric\":\"waist\"") != 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, CHECK(exchange(port,
"GET /api/v1/analysis?period=invalid HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", "GET /api/v1/analysis?period=invalid HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, response,

View file

@ -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(),
)
})
})

196
web/src/api/heartRate.ts Normal file
View file

@ -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<string, unknown> =>
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<HeartRateTimeline> {
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())
}

View file

@ -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(<HeartRateTimelinePanel contextId="se_11111111-1111-4111-8111-111111111111" />)
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(<HeartRateTimelinePanel contextId="sl_11111111-1111-4111-8111-111111111111" />)
expect(await screen.findByText(/Aucune capture cardio synchronisée/)).toBeInTheDocument()
})
})

View file

@ -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<string, [string, string]> = {
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<string, [string, string]> = {
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<string, [string, string]> = {
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<HeartRateTimeline | null>(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 <section className="heart-rate-panel" role="alert">
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
</section>
}
if (!timeline) {
return <section className="heart-rate-panel" aria-live="polite">
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
</section>
}
if (!timeline.available || !timeline.capture || !calculated) {
return <section className="heart-rate-panel">
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
<p className="analysis-empty">
{french ? 'Aucune capture cardio synchronisée pour cette période.' : 'No synchronized heart-rate capture for this period.'}
</p>
</section>
}
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 <section className="heart-rate-panel" data-testid="heart-rate-timeline">
<div className="heart-rate-heading">
<div>
<p className="eyebrow">{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}</p>
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
</div>
<dl className="heart-rate-facts">
<div><dt>Min</dt><dd>{calculated.minimum} BPM</dd></div>
<div><dt>{french ? 'Moy.' : 'Avg.'}</dt><dd>{calculated.average.toFixed(1)} BPM</dd></div>
<div><dt>Max</dt><dd>{calculated.maximum} BPM</dd></div>
<div><dt>RR</dt><dd>{calculated.rrCount}</dd></div>
</dl>
</div>
<figure className="heart-rate-chart">
<svg viewBox={'0 0 ' + WIDTH + ' ' + HEIGHT} role="img"
aria-label={french ? 'Courbe du rythme cardiaque mesuré' : 'Measured heart-rate curve'}>
{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 <rect key={phase.phase_id} className="heart-rate-target"
x={phaseX} y={targetTop} width={phaseWidth} height={targetHeight} />
})}
{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 <rect key={'interval-' + index} className="heart-rate-event-band"
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
})}
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
x2={WIDTH - RIGHT} y2={HEIGHT - BOTTOM} />
<polyline className="heart-rate-line" points={points} fill="none" />
{visibleEvents.map((event, index) => {
const eventX = x(event.at)
return <g key={'marker-' + index}>
<line className="heart-rate-marker" x1={eventX} y1={TOP}
x2={eventX} y2={HEIGHT - BOTTOM} />
<text className="heart-rate-marker-label" x={eventX + 4}
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
</g>
})}
<text className="chart-axis-label" x={LEFT} y={HEIGHT - 10}>
{new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</text>
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
{new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</text>
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
{calculated.maximum}
</text>
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
{calculated.minimum}
</text>
</svg>
{capture.samples.length > calculated.display.length &&
<figcaption>
{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.'}
</figcaption>}
</figure>
{timeline.guidance && <div className="heart-rate-phases">
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>
<ol>
{timeline.guidance.phases.map((phase) => <li key={phase.phase_id}>
<strong>{phaseLabel(phase.kind, french)}</strong>
<span>{phase.target
? String(phase.target.minimum_bpm) + '–' + String(phase.target.maximum_bpm) + ' BPM'
: '—'}</span>
<small>{instructionLabel(phase.final_instruction, french)}</small>
</li>)}
</ol>
</div>}
{timeline.calibration && <div className="heart-rate-calibration">
<h4>{french ? 'Calibration utilisée / observée' : 'Observed calibration'}</h4>
<p>{french ? 'Pic observé' : 'Observed peak'} : <strong>
{timeline.calibration.observed_peak_bpm} BPM</strong></p>
{timeline.calibration.recovery.length > 0 && <p>
{timeline.calibration.recovery.map((point) =>
'+' + String(Math.round(point.offset_seconds / 60)) + ' min: ' +
String(point.bpm) + ' BPM').join(' · ')}
</p>}
</div>}
{timeline.events.length > 0 && <details className="heart-rate-events">
<summary>{french ? 'Marqueurs factuels' : 'Factual markers'} ({timeline.events.length})</summary>
<ul>{timeline.events.map((event, index) => <li key={String(index) + event.at}>
<time dateTime={event.at}>
{new Date(event.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
{' · '}{eventLabel(event, french)}
</li>)}</ul>
</details>}
</section>
}

View file

@ -16,6 +16,7 @@ import {
type SessionListItem, type SessionListItem,
} from '../api/sessions' } from '../api/sessions'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog' import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
import { HeartRateTimelinePanel } from '../components/HeartRateTimelinePanel'
import { useDatePreferences } from '../presentation/DatePreferences' import { useDatePreferences } from '../presentation/DatePreferences'
import { import {
formatCivilDate, 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(' · ') 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'}</span> : occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'}</span>
</li>)}</ol></section>} </li>)}</ol></section>}
{kind === 'history' && <HeartRateTimelinePanel contextId={identity} />}
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3> {kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>} <pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
</article> </article>

View file

@ -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 { HeartRateTimelinePanel } from "../components/HeartRateTimelinePanel";
import { import {
buildSleepDiaryPdf, buildSleepDiaryPdf,
presentSleepDiaryPdf, presentSleepDiaryPdf,
@ -891,6 +892,8 @@ export function SleepDiaryWorkspace({
)} )}
</article> </article>
{activeEntry && <HeartRateTimelinePanel contextId={activeEntry.entry_id} />}
<article className="analysis-panel analysis-wide sleep-entry-card"> <article className="analysis-panel analysis-wide sleep-entry-card">
<header className="sleep-entry-header"> <header className="sleep-entry-header">
<div className="sleep-night-block"> <div className="sleep-night-block">

View file

@ -2943,3 +2943,124 @@ h2 {
transition: none !important; 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;
}
}