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

View file

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

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

View file

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

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

View file

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

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_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',

View file

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

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

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, "\"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,

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,
} 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'}</span>
</li>)}</ol></section>}
{kind === 'history' && <HeartRateTimelinePanel contextId={identity} />}
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
</article>

View file

@ -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({
)}
</article>
{activeEntry && <HeartRateTimelinePanel contextId={activeEntry.entry_id} />}
<article className="analysis-panel analysis-wide sleep-entry-card">
<header className="sleep-entry-header">
<div className="sleep-night-block">

View file

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