feat(web): visualize synchronized heart rate
This commit is contained in:
parent
ad493a72aa
commit
246e47ee8f
20 changed files with 1838 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
17
tui/include/trainlog/web_heart_rate.h
Normal file
17
tui/include/trainlog/web_heart_rate.h
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
703
tui/src/web_heart_rate.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
234
tui/tests/test_web_heart_rate.c
Normal file
234
tui/tests/test_web_heart_rate.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
76
web/src/api/heartRate.test.ts
Normal file
76
web/src/api/heartRate.test.ts
Normal 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
196
web/src/api/heartRate.ts
Normal 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())
|
||||
}
|
||||
83
web/src/components/HeartRateTimelinePanel.test.tsx
Normal file
83
web/src/components/HeartRateTimelinePanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
248
web/src/components/HeartRateTimelinePanel.tsx
Normal file
248
web/src/components/HeartRateTimelinePanel.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue