Add human durations and body metric graphs

This commit is contained in:
fy59 2026-09-05 21:35:44 +02:00
parent afd6847947
commit 1e1aef86d1
13 changed files with 1689 additions and 263 deletions

View file

@ -37,8 +37,27 @@ Added:
Planned next:
- human duration input such as `1:30`, `1m30`, `2m`, and `45s`;
- automatic minute/second display formatting;
- history graphs for all body measurements;
- left/right asymmetry summaries.
- human duration input such as `1:30`, `1m30`, `2m`, and `45s` (implemented);
- automatic minute/second display formatting (implemented);
- history graphs for all body measurements (implemented);
- left/right asymmetry summaries (implemented).
<!-- TRAINLOG_TUI_V02_CHANGELOG _END -->
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_CHANGELOG -->
### Current TUI checkpoint
Implemented:
- human duration input;
- human minute/second display;
- graphs for every persisted body metric;
- recent values for every body metric;
- left/right asymmetry summaries.
Next:
- normalized global body overlay graph;
- metric legend using both colors and symbols;
- percentage evolution summary;
- richer dashboard weight graph;
- dashboard previous-weight delta and min/max;
- compact body/asymmetry dashboard summary.
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_CHANGELOG _END -->

View file

@ -0,0 +1,85 @@
# Human Duration and Body Graph Checkpoint
## Status
```text
FIRST_USABLE_TUI=PASS
TUI_V0_2_POLISH=IMPLEMENTED
TUI_SESSION_DETAILS=IMPLEMENTED
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
GATE_2=IN_PROGRESS
TRAINLOG_FORMAT_V1=FROZEN
```
## Implemented
### Human duration entry
Accepted examples:
```text
90
90s
1:30
1m30
1m30s
2m
45s
2:05
```
Persistence remains canonical seconds.
Display uses human minute/second formatting.
### Body tracking
`F4 Corps` now provides per-metric history for weight, neck, shoulders, chest,
waist, hips, left/right arm, left/right forearm, left/right thigh and
left/right calf.
Each metric has latest value, first value, change, graph and recent dated
values. Paired limbs expose same-observation asymmetry.
## Next visualization contract
### Global body graph
All available body series are overlaid after normalizing each metric's first
real value to 100. The global graph is display-only.
### Dashboard graph v2
The home screen remains compact but gains:
- current weight;
- first-to-current delta;
- previous-to-current delta;
- min/max;
- richer recent weight graph;
- latest waist;
- compact asymmetry status.
## Validation before push
```bash
meson compile -C build
meson test -C build --print-errorlogs
python tools/validate_json.py
python tools/validate_import_contract.py
git diff --check
git diff --cached --check
```
Sanitizer validation:
```bash
meson compile -C build-asan
meson test -C build-asan --print-errorlogs
```
## Next
```text
TUI_GLOBAL_BODY_OVERLAY
DASHBOARD_GRAPH_V2
```

View file

@ -0,0 +1,18 @@
# Human Durations and Body Metric Graphs
## Status
```text
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
Durations accept `90`, `90s`, `1:30`, `1m30`, `1m30s`, `2m`, `45s`
and `2:05`, while persistence remains integer seconds.
F4 now browses all frozen body measurements with left/right navigation,
latest/first/change summaries, a graph, recent values, and same-observation
left/right asymmetry for arms, forearms, thighs, and calves.
No database migration and no JSON v1 change are required.

View file

@ -51,8 +51,8 @@ Current state:
FIRST_USABLE_TUI=PASS
TUI_V0_2_POLISH=IMPLEMENTED
TUI_SESSION_DETAILS=IMPLEMENTED
TUI_DURATION_HUMAN_INPUT=NEXT
TUI_BODY_METRIC_GRAPHS=NEXT
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
GATE_2=IN_PROGRESS
TRAINLOG_FORMAT_V1=FROZEN
```
@ -83,3 +83,34 @@ Next implementation slice:
No incompatible change to frozen Trainlog JSON v1 is required.
<!-- TRAINLOG_TUI_V02_ROADMAP _END -->
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_ROADMAP -->
## Next TUI visualization slice
Canonical state after the current checkpoint:
```text
FIRST_USABLE_TUI=PASS
TUI_V0_2_POLISH=IMPLEMENTED
TUI_SESSION_DETAILS=IMPLEMENTED
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
TUI_GLOBAL_BODY_OVERLAY=NEXT
DASHBOARD_GRAPH_V2=NEXT
GATE_2=IN_PROGRESS
TRAINLOG_FORMAT_V1=FROZEN
```
Next deliverables:
1. normalized global body graph in `F4`;
2. color + symbol identity for every overlaid metric;
3. global percent-change summary;
4. richer home weight graph;
5. previous-measurement delta on dashboard;
6. dashboard min/max weight;
7. latest waist summary when available;
8. compact asymmetry warning when relevant.
No database schema migration is expected.
No Trainlog JSON v1 change is expected.
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_ROADMAP _END -->

View file

@ -267,7 +267,7 @@ Inside one workout, left/right or up/down changes the selected exercise.
Assistance remains direction-aware: more assistance kilograms mean more help,
not greater strength.
## 18. Duration input and display — next implementation slice
## 18. Duration input and display — implemented
Persistent duration and rest units remain **seconds**.
@ -313,7 +313,7 @@ The same parser and formatter are reused for:
Invalid malformed forms are rejected before persistence.
## 19. Body screen and measurement graphs — next implementation slice
## 19. Body screen and measurement graphs — implemented
`F4 Corps` becomes a full history and visualization screen.
@ -388,3 +388,72 @@ Immediate order:
7. Android recorder and JSON import workflow
```
<!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT _END -->
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT -->
## 21. Global body evolution graph — next implementation slice
`F4 Corps` keeps its current per-metric graph and gains a global normalized
overlay view.
The global graph must not overlay raw kilograms and centimeters directly.
Each metric is normalized to its own first real observation:
```text
first recorded value = 100
```
Examples:
```text
waist 100 -> 96 = -4 %
right arm 100 -> 103 = +3 %
weight 100 -> 98 = -2 %
```
This makes unlike units visually comparable without changing persisted data.
Rules:
- no missing observation becomes zero;
- each metric begins only at its first real value;
- original dates remain ordered;
- every series has both a color and a distinct text/symbol identity;
- left/right limb metrics remain separate;
- normalization is display-only;
- canonical SQLite values remain untouched.
The global view is toggled from `F4 Corps` with `g`.
Per-metric navigation remains left/right.
The global view also shows a compact percentage summary from first to latest
recorded value for each available metric.
## 22. Dashboard graph v2 — next implementation slice
The dashboard body-weight graph becomes a richer summary.
It will show:
- current body weight;
- change from first recorded weight;
- change from previous recorded weight;
- minimum recorded weight;
- maximum recorded weight;
- recent weight graph;
- latest waist measurement when available;
- latest left/right asymmetry alert when meaningful.
The dashboard remains intentionally compact.
The complete multi-metric overlay belongs to `F4 Corps`.
## 23. Immediate TUI implementation order
```text
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
TUI_GLOBAL_BODY_OVERLAY=NEXT
DASHBOARD_GRAPH_V2=NEXT
EXERCISE_PERFORMANCE_GRAPHS=AFTER
```
Android remains deferred until these TUI daily-use views are satisfactory.
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT _END -->

View file

@ -124,4 +124,52 @@ TrainlogStatus trainlog_database_get_session_details(
size_t *output_exercise_count
);
/* TRAINLOG_BODY_METRIC_HISTORY_API */
typedef enum TrainlogBodyMetric {
TRAINLOG_BODY_METRIC_WEIGHT = 0,
TRAINLOG_BODY_METRIC_NECK,
TRAINLOG_BODY_METRIC_SHOULDERS,
TRAINLOG_BODY_METRIC_CHEST,
TRAINLOG_BODY_METRIC_WAIST,
TRAINLOG_BODY_METRIC_HIPS,
TRAINLOG_BODY_METRIC_LEFT_ARM,
TRAINLOG_BODY_METRIC_RIGHT_ARM,
TRAINLOG_BODY_METRIC_LEFT_FOREARM,
TRAINLOG_BODY_METRIC_RIGHT_FOREARM,
TRAINLOG_BODY_METRIC_LEFT_THIGH,
TRAINLOG_BODY_METRIC_RIGHT_THIGH,
TRAINLOG_BODY_METRIC_LEFT_CALF,
TRAINLOG_BODY_METRIC_RIGHT_CALF,
TRAINLOG_BODY_METRIC_COUNT
} TrainlogBodyMetric;
typedef struct TrainlogBodyMetricPoint {
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
double value;
} TrainlogBodyMetricPoint;
typedef struct TrainlogBodyPairPoint {
bool found;
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
double left_value;
double right_value;
} TrainlogBodyPairPoint;
TrainlogStatus trainlog_database_list_body_metric_points(
TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_latest_body_pair(
TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output
);
#endif

View file

@ -0,0 +1,24 @@
#ifndef TRAINLOG_DURATION_H
#define TRAINLOG_DURATION_H
/**
* @file duration.h
* @brief Human duration parsing and formatting for Trainlog.
*/
#include <stddef.h>
#include "trainlog/status.h"
TrainlogStatus trainlog_duration_parse(
const char *text,
int *output_seconds
);
TrainlogStatus trainlog_duration_format(
int seconds,
char *output,
size_t output_size
);
#endif

View file

@ -27,6 +27,7 @@ strict_c_args = [
trainlog_core_sources = files(
'src/catalog.c',
'src/database.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
)
@ -107,3 +108,20 @@ test(
'session_detail',
test_session_detail,
)
test_duration = executable(
'test_duration',
'tests/test_duration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test_body_metrics = executable(
'test_body_metrics',
'tests/test_body_metrics.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('duration', test_duration)
test('body_metrics', test_body_metrics)

View file

@ -4,6 +4,7 @@
*/
#include "trainlog/database.h"
#include "trainlog/duration.h"
#include <stdint.h>
#include <stdio.h>
@ -1351,20 +1352,31 @@ static TrainlogStatus detail_fill_sets(
} else if (has_duration != 0) {
int duration = sqlite3_column_int(statement, 1);
char duration_text[64];
if (trainlog_duration_format(
duration,
duration_text,
sizeof(duration_text)
) != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (has_weight != 0) {
written = snprintf(
fragment,
sizeof(fragment),
"%ds@%.1f",
duration,
"%s@%.1f",
duration_text,
sqlite3_column_double(statement, 2)
);
} else {
written = snprintf(
fragment,
sizeof(fragment),
"%ds",
duration
"%s",
duration_text
);
}
} else {
@ -1661,3 +1673,240 @@ TrainlogStatus trainlog_database_get_session_details(
return TRAINLOG_STATUS_OK;
}
/* TRAINLOG_BODY_METRIC_HISTORY_IMPLEMENTATION */
static const char *body_metric_column(TrainlogBodyMetric metric)
{
switch (metric) {
case TRAINLOG_BODY_METRIC_WEIGHT:
return "body_weight_kg";
case TRAINLOG_BODY_METRIC_NECK:
return "neck_cm";
case TRAINLOG_BODY_METRIC_SHOULDERS:
return "shoulders_cm";
case TRAINLOG_BODY_METRIC_CHEST:
return "chest_cm";
case TRAINLOG_BODY_METRIC_WAIST:
return "waist_cm";
case TRAINLOG_BODY_METRIC_HIPS:
return "hips_cm";
case TRAINLOG_BODY_METRIC_LEFT_ARM:
return "left_arm_cm";
case TRAINLOG_BODY_METRIC_RIGHT_ARM:
return "right_arm_cm";
case TRAINLOG_BODY_METRIC_LEFT_FOREARM:
return "left_forearm_cm";
case TRAINLOG_BODY_METRIC_RIGHT_FOREARM:
return "right_forearm_cm";
case TRAINLOG_BODY_METRIC_LEFT_THIGH:
return "left_thigh_cm";
case TRAINLOG_BODY_METRIC_RIGHT_THIGH:
return "right_thigh_cm";
case TRAINLOG_BODY_METRIC_LEFT_CALF:
return "left_calf_cm";
case TRAINLOG_BODY_METRIC_RIGHT_CALF:
return "right_calf_cm";
case TRAINLOG_BODY_METRIC_COUNT:
default:
return NULL;
}
}
TrainlogStatus trainlog_database_list_body_metric_points(
TrainlogDatabase *database,
TrainlogBodyMetric metric,
TrainlogBodyMetricPoint *output,
size_t capacity,
size_t *output_count
)
{
const char *column;
char sql[256];
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int written;
int rc;
if (database == NULL ||
database->connection == NULL ||
output_count == NULL ||
(capacity > 0U && output == NULL)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
column = body_metric_column(metric);
if (column == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
written = snprintf(
sql,
sizeof(sql),
"SELECT observed_at, %s "
"FROM body_observations "
"WHERE %s IS NOT NULL "
"ORDER BY observed_at ASC, id ASC;",
column,
column
);
if (written < 0 ||
(size_t)written >= sizeof(sql)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
rc = sqlite3_prepare_v2(
database->connection,
sql,
-1,
&statement,
NULL
);
if (rc != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
if (count < capacity) {
const unsigned char *observed =
sqlite3_column_text(statement, 0);
if (observed == NULL) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(
output[count].observed_at,
sizeof(output[count].observed_at),
"%s",
(const char *)observed
);
output[count].value =
sqlite3_column_double(statement, 1);
}
++count;
}
if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
*output_count =
count < capacity
? count
: capacity;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_database_latest_body_pair(
TrainlogDatabase *database,
TrainlogBodyMetric left_metric,
TrainlogBodyMetric right_metric,
TrainlogBodyPairPoint *output
)
{
const char *left_column;
const char *right_column;
char sql[384];
sqlite3_stmt *statement = NULL;
int written;
int rc;
if (database == NULL ||
database->connection == NULL ||
output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
left_column = body_metric_column(left_metric);
right_column = body_metric_column(right_metric);
if (left_column == NULL ||
right_column == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
written = snprintf(
sql,
sizeof(sql),
"SELECT observed_at, %s, %s "
"FROM body_observations "
"WHERE %s IS NOT NULL AND %s IS NOT NULL "
"ORDER BY observed_at DESC, id DESC "
"LIMIT 1;",
left_column,
right_column,
left_column,
right_column
);
if (written < 0 ||
(size_t)written >= sizeof(sql)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
rc = sqlite3_prepare_v2(
database->connection,
sql,
-1,
&statement,
NULL
);
if (rc != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_step(statement);
if (rc == SQLITE_DONE) {
(void)sqlite3_finalize(statement);
output->found = false;
return TRAINLOG_STATUS_OK;
}
if (rc != SQLITE_ROW) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
{
const unsigned char *observed =
sqlite3_column_text(statement, 0);
if (observed == NULL) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
output->found = true;
(void)snprintf(
output->observed_at,
sizeof(output->observed_at),
"%s",
(const char *)observed
);
output->left_value =
sqlite3_column_double(statement, 1);
output->right_value =
sqlite3_column_double(statement, 2);
}
return sqlite3_finalize(statement) == SQLITE_OK
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}

270
tui/src/duration.c Normal file
View file

@ -0,0 +1,270 @@
/**
* @file duration.c
* @brief Human duration parser/formatter implementation.
*/
#include "trainlog/duration.h"
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
static bool ascii_space(char value)
{
return value == ' ' ||
value == '\t' ||
value == '\n' ||
value == '\r' ||
value == '\f' ||
value == '\v';
}
static bool ascii_digit(char value)
{
return value >= '0' && value <= '9';
}
static bool parse_decimal_range(
const char *begin,
const char *end,
int *output
)
{
long long value = 0;
const char *cursor;
if (begin == NULL ||
end == NULL ||
output == NULL ||
begin >= end) {
return false;
}
for (cursor = begin; cursor < end; ++cursor) {
int digit;
if (!ascii_digit(*cursor)) {
return false;
}
digit = *cursor - '0';
if (value > ((long long)INT_MAX - digit) / 10LL) {
return false;
}
value = (value * 10LL) + digit;
}
*output = (int)value;
return true;
}
static bool checked_minutes_seconds(
int minutes,
int seconds,
int *output
)
{
long long total;
if (minutes < 0 ||
seconds < 0 ||
seconds >= 60 ||
output == NULL) {
return false;
}
total = ((long long)minutes * 60LL) + seconds;
if (total > (long long)INT_MAX) {
return false;
}
*output = (int)total;
return true;
}
TrainlogStatus trainlog_duration_parse(
const char *text,
int *output_seconds
)
{
const char *begin;
const char *end;
const char *cursor;
const char *colon = NULL;
const char *minute_marker = NULL;
int value;
if (text == NULL || output_seconds == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
begin = text;
while (*begin != '\0' && ascii_space(*begin)) {
++begin;
}
end = text + strlen(text);
while (end > begin && ascii_space(end[-1])) {
--end;
}
if (begin == end ||
(size_t)(end - begin) > 63U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (cursor = begin; cursor < end; ++cursor) {
if (ascii_space(*cursor)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (*cursor == ':') {
if (colon != NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
colon = cursor;
}
if (*cursor == 'm' || *cursor == 'M') {
if (minute_marker != NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
minute_marker = cursor;
}
}
if (colon != NULL) {
int minutes;
int seconds;
if (minute_marker != NULL ||
!parse_decimal_range(begin, colon, &minutes) ||
!parse_decimal_range(colon + 1, end, &seconds) ||
!checked_minutes_seconds(
minutes,
seconds,
output_seconds
)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return TRAINLOG_STATUS_OK;
}
if (minute_marker != NULL) {
int minutes;
int seconds = 0;
const char *seconds_begin = minute_marker + 1;
const char *seconds_end = end;
if (!parse_decimal_range(
begin,
minute_marker,
&minutes
)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (seconds_begin < end &&
(end[-1] == 's' || end[-1] == 'S')) {
seconds_end = end - 1;
}
if (seconds_begin < seconds_end) {
if (!parse_decimal_range(
seconds_begin,
seconds_end,
&seconds
)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
} else if (seconds_begin < end) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (!checked_minutes_seconds(
minutes,
seconds,
output_seconds
)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return TRAINLOG_STATUS_OK;
}
if (end[-1] == 's' || end[-1] == 'S') {
if (!parse_decimal_range(
begin,
end - 1,
&value
)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_seconds = value;
return TRAINLOG_STATUS_OK;
}
if (!parse_decimal_range(begin, end, &value)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_seconds = value;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_duration_format(
int seconds,
char *output,
size_t output_size
)
{
int minutes;
int remainder;
int written;
if (seconds < 0 ||
output == NULL ||
output_size == 0U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
minutes = seconds / 60;
remainder = seconds % 60;
if (minutes == 0) {
written = snprintf(
output,
output_size,
"%d s",
remainder
);
} else if (remainder == 0) {
written = snprintf(
output,
output_size,
"%d min",
minutes
);
} else {
written = snprintf(
output,
output_size,
"%d min %d s",
minutes,
remainder
);
}
if (written < 0 ||
(size_t)written >= output_size) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
/**
* @file test_body_metrics.c
* @brief Generic body-metric history tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); \
return false; \
} \
} while (0)
static bool test_body_metrics(void)
{
TrainlogDatabase *database = NULL;
TrainlogBodyObservationInput observation;
TrainlogBodyMetricPoint points[8];
TrainlogBodyPairPoint pair;
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
(void)memset(&observation, 0, sizeof(observation));
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
"bo_metrics"
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T08:00:00+02:00"
);
observation.has_body_weight = true;
observation.body_weight_kg = 84.1;
observation.has_waist = true;
observation.waist_cm = 96.4;
observation.has_left_arm = true;
observation.left_arm_cm = 34.2;
observation.has_right_arm = true;
observation.right_arm_cm = 34.8;
CHECK(
trainlog_database_insert_body_observation(
database,
&observation
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_list_body_metric_points(
database,
TRAINLOG_BODY_METRIC_WAIST,
points,
8U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].value > 96.39);
CHECK(points[0].value < 96.41);
CHECK(
trainlog_database_latest_body_pair(
database,
TRAINLOG_BODY_METRIC_LEFT_ARM,
TRAINLOG_BODY_METRIC_RIGHT_ARM,
&pair
) == TRAINLOG_STATUS_OK
);
CHECK(pair.found);
CHECK(pair.left_value > 34.19);
CHECK(pair.left_value < 34.21);
CHECK(pair.right_value > 34.79);
CHECK(pair.right_value < 34.81);
trainlog_database_close(database);
return true;
}
int main(void)
{
CHECK(test_body_metrics());
(void)printf("PASS body_metrics\n");
return 0;
}

112
tui/tests/test_duration.c Normal file
View file

@ -0,0 +1,112 @@
/**
* @file test_duration.c
* @brief Human duration parser/formatter tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/duration.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool expect_parse(const char *text, int expected)
{
int seconds = -1;
CHECK(
trainlog_duration_parse(text, &seconds) ==
TRAINLOG_STATUS_OK
);
CHECK(seconds == expected);
return true;
}
static bool test_parsing(void)
{
int scratch = 0;
CHECK(expect_parse("90", 90));
CHECK(expect_parse("90s", 90));
CHECK(expect_parse("1:30", 90));
CHECK(expect_parse("1m30", 90));
CHECK(expect_parse("1m30s", 90));
CHECK(expect_parse("2m", 120));
CHECK(expect_parse("45s", 45));
CHECK(expect_parse("2:05", 125));
CHECK(expect_parse(" 2m ", 120));
CHECK(
trainlog_duration_parse("1:60", &scratch) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_duration_parse("1m90", &scratch) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_duration_parse("1 m 30", &scratch) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_duration_parse("", &scratch) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
return true;
}
static bool test_formatting(void)
{
char buffer[64];
CHECK(
trainlog_duration_format(45, buffer, sizeof(buffer)) ==
TRAINLOG_STATUS_OK
);
CHECK(strcmp(buffer, "45 s") == 0);
CHECK(
trainlog_duration_format(60, buffer, sizeof(buffer)) ==
TRAINLOG_STATUS_OK
);
CHECK(strcmp(buffer, "1 min") == 0);
CHECK(
trainlog_duration_format(90, buffer, sizeof(buffer)) ==
TRAINLOG_STATUS_OK
);
CHECK(strcmp(buffer, "1 min 30 s") == 0);
CHECK(
trainlog_duration_format(125, buffer, sizeof(buffer)) ==
TRAINLOG_STATUS_OK
);
CHECK(strcmp(buffer, "2 min 5 s") == 0);
return true;
}
int main(void)
{
CHECK(test_parsing());
(void)printf("PASS duration_parsing\n");
CHECK(test_formatting());
(void)printf("PASS duration_formatting\n");
return 0;
}