trainlog/tui/src/tui.c
2026-09-16 07:46:01 +02:00

9944 lines
435 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file tui.c
* @brief Trainlog Notcurses terminal interface.
*/
#include "trainlog/tui.h"
#include <ctype.h>
#include <fcntl.h>
#include <locale.h>
#include <limits.h>
#include <math.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <wchar.h>
#include <unistd.h>
#include <utf8proc.h>
#include <uuid/uuid.h>
#include <sqlite3.h>
#include "trainlog/terminal.h"
#include "trainlog/app_shell.h"
#include "trainlog/presentation.h"
#include "trainlog/bodyviz.h"
#include "trainlog/body_analytics.h"
#include "trainlog/chart.h"
#include "trainlog/body_zone_catalog.h"
#include "trainlog/catalog.h"
#include "trainlog/duration.h"
#include "trainlog/equipment_catalog.h"
#include "trainlog/exercise_name_catalog.h"
#include "trainlog/id.h"
#include "trainlog/measured_max.h"
#include "trainlog/mtp.h"
#include "trainlog/sync.h"
#include "trainlog/sync_history.h"
#include "trainlog/sync_screen_action.h"
#include "trainlog/reps.h"
#include "trainlog/session_generation.h"
#include "trainlog/statistics.h"
#include "trainlog/session_generation_policy_internal.h"
#include "trainlog/theme.h"
#include "trainlog/timeutil.h"
#include "trainlog/training_knowledge.h"
#include "trainlog/usb.h"
#include "timestamp.h"
#include "database_internal.h"
static void presentation_surface_printf(TrainlogSurface *surface, int row,
int column, const char *format, ...)
{
va_list arguments;
va_list copy;
char *value;
int count;
va_start(arguments, format);
va_copy(copy, arguments);
count = trainlog_presentation_vformat(NULL, 0U, format, copy);
va_end(copy);
if (count >= 0) {
value = malloc((size_t)count + 1U);
if (value != NULL) {
(void)trainlog_presentation_vformat(value, (size_t)count + 1U,
format, arguments);
trainlog_surface_printf(surface, row, column, "%s", value);
free(value);
}
}
va_end(arguments);
}
static void presentation_terminal_printf(TrainlogTerminal *terminal, int row,
int column, const char *format, ...)
{
va_list arguments;
char value[512];
va_start(arguments, format);
(void)trainlog_presentation_vformat(value, sizeof(value), format, arguments);
va_end(arguments);
trainlog_terminal_printf(terminal, row, column, "%s", value);
}
/* All legacy renderer format literals cross the centralized catalog. Unknown
* strings pass through, preserving persisted names and technical literals. */
#define trainlog_surface_printf presentation_surface_printf
#define trainlog_terminal_printf presentation_terminal_printf
#define MAX_EXERCISES 128U
#define MAX_SESSION_EXERCISES 32U
#define MAX_SETS_PER_EXERCISE 64U
#define MAX_SESSIONS 128U
#define MAX_WEIGHT_POINTS 256U
#define MAX_BODY_ZONES 16U
#define MAX_BODY_OBSERVATIONS 512U
#define MAX_BODY_METRIC_POINTS 256U
#define EXERCISE_GRAPH_POINTS 12U
#define DASHBOARD_BUCKET_COUNT 12U
#define DASHBOARD_FACT_CAPACITY 4096U
#define DASHBOARD_ZONE_BUCKET_CAPACITY (MAX_BODY_ZONES + 1U)
/* TRAINLOG_TUI_V02_POLISH */
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
/* TRAINLOG_SYNC_PC_TO_ANDROID_DIAGNOSTICS */
/* TRAINLOG_SYNC_FULL_MTP_SILENCE */
static bool equipment_text_matches(const char *text, const char *query)
{
size_t index;
size_t query_index;
if (query == NULL || query[0] == '\0') return true;
if (text == NULL) return false;
for (index = 0U; text[index] != '\0'; ++index) {
for (query_index = 0U; query[query_index] != '\0'; ++query_index) {
unsigned char left = (unsigned char)text[index + query_index];
unsigned char right = (unsigned char)query[query_index];
if (left == '\0' || tolower(left) != tolower(right)) break;
}
if (query[query_index] == '\0') return true;
}
return false;
}
static size_t equipment_collect(TrainlogDatabase *database, const char *query,
TrainlogResolvedEquipment *output, size_t capacity, bool *truncated,
bool *failed)
{
TrainlogCustomEquipment customs[64];
const TrainlogEquipment **supplied_matches = NULL;
size_t supplied_capacity = trainlog_equipment_catalog_count();
size_t supplied_count;
size_t custom_offset = 0U;
bool custom_more = true;
size_t count = 0U;
size_t index;
if (truncated != NULL) *truncated = false;
if (failed != NULL) *failed = false;
if (supplied_capacity > 0U && query != NULL && query[0] != '\0') {
supplied_matches = calloc(supplied_capacity, sizeof(*supplied_matches));
if (supplied_matches == NULL) {
if (failed != NULL) *failed = true;
return 0U;
}
}
supplied_count = query != NULL && query[0] != '\0'
? trainlog_equipment_catalog_search(query, supplied_matches,
supplied_capacity) : supplied_capacity;
for (index = 0U; index < supplied_count; ++index) {
const TrainlogEquipment *item = query != NULL && query[0] != '\0'
? supplied_matches[index] : trainlog_equipment_catalog_at(index);
if (item == NULL) continue;
if (count >= capacity) {
if (truncated != NULL) *truncated = true;
continue;
}
if (trainlog_database_resolve_equipment(database, item->equipment_id,
&output[count]) == TRAINLOG_STATUS_OK) ++count;
}
free(supplied_matches);
while (custom_more) {
size_t custom_count = 0U;
if (trainlog_database_list_custom_equipment_page(database,
custom_offset, customs, 64U, &custom_count,
&custom_more) != TRAINLOG_STATUS_OK) {
if (failed != NULL) *failed = true;
return count;
}
for (index = 0U; index < custom_count; ++index) {
if (!equipment_text_matches(customs[index].display_name, query) &&
!equipment_text_matches(customs[index].label_name, query) &&
!equipment_text_matches(customs[index].equipment_type, query))
continue;
if (count >= capacity) {
if (truncated != NULL) *truncated = true;
return count;
}
if (trainlog_database_resolve_equipment(database,
customs[index].equipment_id,
&output[count]) == TRAINLOG_STATUS_OK) ++count;
}
if (custom_count > SIZE_MAX - custom_offset ||
(custom_more && custom_count == 0U)) {
if (failed != NULL) *failed = true;
return count;
}
custom_offset += custom_count;
}
return count;
}
static const char *equipment_origin_label(TrainlogEquipmentOrigin origin)
{
if (origin == TRAINLOG_EQUIPMENT_SUPPLIED)
return trainlog_presentation_source("fourni");
if (origin == TRAINLOG_EQUIPMENT_CUSTOM)
return trainlog_presentation_source("personnalisé");
return trainlog_presentation_source("inconnu");
}
static void generate_custom_equipment_uuid(
char output[TRAINLOG_UUID_TEXT_LENGTH + 1U])
{
uuid_t value;
uuid_generate_random(value);
uuid_unparse_lower(value, output);
}
static const char *session_type_label(TrainlogSessionType type)
{
return trainlog_presentation_source(type == TRAINLOG_SESSION_MAX_TEST
? "Test de max" : "Entraînement");
}
static const char *session_type_history_label(TrainlogSessionType type)
{
return type == TRAINLOG_SESSION_MAX_TEST ? "[MAX]" :
trainlog_presentation_text("label.training.upper");
}
static bool parse_double_positive(const char *text, double *output)
{
char *end = NULL;
double value;
if (text == NULL || output == NULL || text[0] == '\0') {
return false;
}
value = strtod(text, &end);
if (end == text || *end != '\0' || !isfinite(value) || value <= 0.0) {
return false;
}
*output = value;
return true;
}
static const char *exercise_load_mode_label(
TrainlogLoadMode mode
)
{
switch (mode) {
case TRAINLOG_LOAD_EXTERNAL:
return trainlog_presentation_source("charge externe");
case TRAINLOG_LOAD_ASSISTANCE:
return trainlog_presentation_source("assistance");
case TRAINLOG_LOAD_NONE:
default:
return trainlog_presentation_source("sans charge");
}
}
static void exercise_short_date(
const char *timestamp,
char output[11]
)
{
if (timestamp == NULL ||
strlen(timestamp) < 10U) {
(void)snprintf(
output,
11U,
"%s",
"----------"
);
return;
}
(void)memcpy(
output,
timestamp,
10U
);
output[10] = '\0';
}
static void format_compact_max_weight(
double value,
char *output,
size_t output_size
)
{
size_t length;
if (output == NULL || output_size == 0U) return;
(void)trainlog_presentation_format(output, output_size, "%.2f", value);
length = strlen(output);
while (length > 0U && output[length - 1U] == '0')
output[--length] = '\0';
if (length > 0U && output[length - 1U] == '.')
output[--length] = '\0';
}
static void exercise_format_performance(
const TrainlogExercisePerformancePoint *point,
char *output,
size_t output_size
)
{
if (point == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (point->has_performance == 0) {
(void)trainlog_presentation_format_key(output, output_size,
"summary.performance.none");
return;
}
if (point->has_explicit_max != 0) {
char weight[32];
format_compact_max_weight(point->weight_kg, weight, sizeof(weight));
(void)snprintf(output, output_size, "%s kg", weight);
return;
}
if (point->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
char duration[64];
if (trainlog_duration_format(
point->metric_value,
duration,
sizeof(duration)
) != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format(
duration,
sizeof(duration),
"%d s",
point->metric_value
);
}
if (point->load_mode ==
TRAINLOG_LOAD_EXTERNAL) {
(void)snprintf(
output,
output_size,
"%.1f kg × %s",
point->weight_kg,
duration
);
} else if (
point->load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)trainlog_presentation_format_key(output, output_size,
"summary.assisted.duration", point->weight_kg, duration);
} else {
(void)snprintf(
output,
output_size,
"%s",
duration
);
}
return;
}
if (point->load_mode ==
TRAINLOG_LOAD_EXTERNAL) {
(void)trainlog_presentation_format(
output,
output_size,
"%.1f kg × %d reps",
point->weight_kg,
point->metric_value
);
} else if (
point->load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)trainlog_presentation_format_key(output, output_size,
"summary.assisted.reps", point->weight_kg, point->metric_value);
} else {
(void)trainlog_presentation_format(
output,
output_size,
"%d reps",
point->metric_value
);
}
}
static bool exercise_point_better(
const TrainlogExercisePerformancePoint *candidate,
const TrainlogExercisePerformancePoint *current
)
{
if (candidate == NULL ||
candidate->has_performance == 0) {
return false;
}
if (current == NULL ||
current->has_performance == 0) {
return true;
}
if (candidate->load_mode !=
current->load_mode) {
return false;
}
switch (candidate->load_mode) {
case TRAINLOG_LOAD_EXTERNAL:
if (candidate->weight_kg >
current->weight_kg) {
return true;
}
return
candidate->weight_kg ==
current->weight_kg &&
candidate->metric_value >
current->metric_value;
case TRAINLOG_LOAD_ASSISTANCE:
if (candidate->weight_kg <
current->weight_kg) {
return true;
}
return
candidate->weight_kg ==
current->weight_kg &&
candidate->metric_value >
current->metric_value;
case TRAINLOG_LOAD_NONE:
default:
return
candidate->metric_value >
current->metric_value;
}
}
static double exercise_graph_value(
const TrainlogExercisePerformancePoint *point
)
{
if (point->load_mode ==
TRAINLOG_LOAD_NONE) {
return (double)point->metric_value;
}
return point->weight_kg;
}
/* TRAINLOG_EXERCISE_FRAMES */
static bool secondary_zone_contains(
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
size_t count,
const char *zone_id,
size_t *output_index
)
{
size_t index;
for (index = 0U; index < count; ++index) {
if (strcmp(secondary[index], zone_id) == 0) {
if (output_index != NULL) *output_index = index;
return true;
}
}
return false;
}
/* WHY: terminal users select translated catalogue rows; accepting raw IDs
* would leak wire identity into product behavior and permit unknown values. */
static bool load_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
char primary[TRAINLOG_ZONE_ID_MAX + 1U],
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
size_t *secondary_count
)
{
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
size_t count = 0U;
size_t index;
primary[0] = '\0';
*secondary_count = 0U;
if (trainlog_database_list_exercise_body_zones(database, exercise_id,
relations, MAX_BODY_ZONES, &count) != TRAINLOG_STATUS_OK) return false;
for (index = 0U; index < count; ++index) {
if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
(void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s",
relations[index].zone_id);
} else if (*secondary_count < MAX_BODY_ZONES) {
(void)snprintf(secondary[*secondary_count], TRAINLOG_ZONE_ID_MAX + 1U,
"%s", relations[index].zone_id);
++*secondary_count;
}
}
return true;
}
/* CONTRACT: knowledge text is read-only catalogue data. The screen stores
* display lines locally so navigation never changes the scientific record. */
#define KNOWLEDGE_LINES_MAX 128U
#define KNOWLEDGE_LINE_MAX 256U
static void knowledge_add_wrapped(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
const char *text, int width)
{
const char *at = text == NULL ? "" : text;
size_t used = 0U;
int cells = 0;
/* WHY: terminal columns count display cells, while French labels are UTF-8;
* utf8proc prevents a wrap from splitting an accented character or from
* writing past the right border for a wide code point. */
while (*at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
utf8proc_int32_t codepoint;
utf8proc_ssize_t bytes = utf8proc_iterate((const utf8proc_uint8_t *)at,
-1, &codepoint);
int codepoint_cells;
if (bytes <= 0) { codepoint = (unsigned char)*at; bytes = 1; }
codepoint_cells = utf8proc_charwidth(codepoint);
if (codepoint_cells < 0) codepoint_cells = 1;
if (cells > 0 && cells + codepoint_cells > width) {
lines[*count][used] = '\0';
++*count;
used = 0U;
cells = 0;
continue;
}
if (used + (size_t)bytes >= KNOWLEDGE_LINE_MAX - 1U) break;
(void)memcpy(lines[*count] + used, at, (size_t)bytes);
used += (size_t)bytes;
cells += codepoint_cells;
at += bytes;
}
/* INVARIANT: every entry is NUL-terminated before rendering, and the fixed
* line capacity bounds catalogue display independently of terminal size. */
if (*count < KNOWLEDGE_LINES_MAX) {
lines[*count][used] = '\0';
++*count;
}
}
static void knowledge_add_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
const char *heading, const char *ids, bool muscles,
int width)
{
const char *at = ids;
knowledge_add_wrapped(lines, count, heading, width);
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
const char *end = strchr(at, '\n');
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
char id[96];
const char *label = NULL;
if (length >= sizeof(id)) break;
(void)memcpy(id, at, length);
id[length] = '\0';
if (muscles) {
const TrainlogKnowledgeMuscle *item = trainlog_knowledge_muscle_lookup(id);
if (item != NULL) label = item->display_name_fr;
} else {
const TrainlogKnowledgeMovementPattern *item =
trainlog_knowledge_movement_pattern_lookup(id);
if (item != NULL) label = item->display_name_fr;
}
knowledge_add_wrapped(lines, count, label == NULL ? id : label, width);
if (end == NULL) break;
at = end + 1;
}
}
static void knowledge_add_plain_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
const char *heading, const char *ids, int width)
{
const char *at = ids;
knowledge_add_wrapped(lines, count, heading, width);
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
const char *end = strchr(at, '\n');
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
char id[96];
if (length >= sizeof(id)) break;
(void)memcpy(id, at, length);
id[length] = '\0';
knowledge_add_wrapped(lines, count, id, width);
if (end == NULL) break;
at = end + 1;
}
}
static void knowledge_add_zones(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
const TrainlogKnowledgeInterpretation *value, int width)
{
const TrainlogBodyZone *zone;
knowledge_add_wrapped(lines, count, "Zones scientifiques :", width);
zone = trainlog_body_zone_catalog_lookup(value->primary_zone_id);
knowledge_add_wrapped(lines, count,
zone == NULL ? value->primary_zone_id : zone->display_name, width);
knowledge_add_plain_ids(lines, count, "Zones secondaires :",
value->secondary_zone_ids, width);
}
/* TRAINLOG_DURATION_BODY_GRAPH_HELPERS */
typedef struct TrainlogBodyMetricView {
TrainlogBodyMetric metric;
const char *label;
const char *unit;
} TrainlogBodyMetricView;
static const TrainlogBodyMetricView BODY_METRICS[] = {
{TRAINLOG_BODY_METRIC_WEIGHT, "Poids", "kg"},
{TRAINLOG_BODY_METRIC_NECK, "Cou", "cm"},
{TRAINLOG_BODY_METRIC_SHOULDERS, "Épaules", "cm"},
{TRAINLOG_BODY_METRIC_CHEST, "Poitrine", "cm"},
{TRAINLOG_BODY_METRIC_WAIST, "Tour de taille", "cm"},
{TRAINLOG_BODY_METRIC_HIPS, "Hanches", "cm"},
{TRAINLOG_BODY_METRIC_LEFT_ARM, "Bras gauche", "cm"},
{TRAINLOG_BODY_METRIC_RIGHT_ARM, "Bras droit", "cm"},
{TRAINLOG_BODY_METRIC_LEFT_FOREARM, "Avant-bras gauche", "cm"},
{TRAINLOG_BODY_METRIC_RIGHT_FOREARM, "Avant-bras droit", "cm"},
{TRAINLOG_BODY_METRIC_LEFT_THIGH, "Cuisse gauche", "cm"},
{TRAINLOG_BODY_METRIC_RIGHT_THIGH, "Cuisse droite", "cm"},
{TRAINLOG_BODY_METRIC_LEFT_CALF, "Mollet gauche", "cm"},
{TRAINLOG_BODY_METRIC_RIGHT_CALF, "Mollet droit", "cm"}
};
static const char *body_metric_label(size_t index)
{
return index < sizeof(BODY_METRICS) / sizeof(BODY_METRICS[0])
? trainlog_presentation_source(BODY_METRICS[index].label)
: trainlog_presentation_source("Mesure");
}
/* TRAINLOG_GLOBAL_BODY_OVERLAY_HELPERS */
#define MAX_GLOBAL_BODY_DATES \
(MAX_BODY_METRIC_POINTS * 14U)
typedef struct TrainlogGlobalBodySeries {
TrainlogBodyMetricPoint points[MAX_BODY_METRIC_POINTS];
size_t count;
double baseline;
double latest_percent;
uint32_t symbol;
TrainlogColorRole role;
} TrainlogGlobalBodySeries;
static const uint32_t GLOBAL_BODY_SYMBOLS[] = {
0x25cfU, 0x25c6U, 0x25b2U, 0x25a0U, 0x25cbU, 0x25bcU, 0x25c7U,
0x25efU, 0x25c8U, 0x25b3U, 0x25a1U, 0x25bdU, 0x25ceU, 0x25c9U
};
static const TrainlogColorRole GLOBAL_BODY_ROLES[] = {
TRAINLOG_COLOR_ACCENT,
TRAINLOG_COLOR_SUCCESS,
TRAINLOG_COLOR_WARNING,
TRAINLOG_COLOR_ERROR,
TRAINLOG_COLOR_GRAPH,
TRAINLOG_COLOR_MUTED,
TRAINLOG_COLOR_ACCENT,
TRAINLOG_COLOR_SUCCESS,
TRAINLOG_COLOR_WARNING,
TRAINLOG_COLOR_ERROR,
TRAINLOG_COLOR_GRAPH,
TRAINLOG_COLOR_MUTED,
TRAINLOG_COLOR_ACCENT,
TRAINLOG_COLOR_SUCCESS
};
static int global_date_compare(
const void *left,
const void *right
)
{
return strcmp(
(const char *)left,
(const char *)right
);
}
static bool global_date_add(
char dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U],
size_t *date_count,
const char *value
)
{
size_t index;
if (dates == NULL ||
date_count == NULL ||
value == NULL) {
return false;
}
for (index = 0U; index < *date_count; ++index) {
if (strcmp(dates[index], value) == 0) {
return true;
}
}
if (*date_count >= MAX_GLOBAL_BODY_DATES) {
return false;
}
(void)snprintf(
dates[*date_count],
sizeof(dates[*date_count]),
"%s",
value
);
++(*date_count);
return true;
}
static size_t global_date_index(
char dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U],
size_t date_count,
const char *value
)
{
size_t low = 0U;
size_t high = date_count;
while (low < high) {
size_t middle =
low + ((high - low) / 2U);
int comparison =
strcmp(dates[middle], value);
if (comparison < 0) {
low = middle + 1U;
} else {
high = middle;
}
}
return low < date_count
? low
: date_count - 1U;
}
static int normalized_graph_row(
double value,
double minimum,
double maximum,
int graph_top,
int graph_height
)
{
double ratio;
int row;
if (maximum <= minimum) {
return graph_top +
(graph_height / 2);
}
ratio =
(value - minimum) /
(maximum - minimum);
row =
graph_top +
graph_height -
1 -
(int)(
ratio *
(double)(graph_height - 1)
);
if (row < graph_top) {
row = graph_top;
}
if (row >
graph_top + graph_height - 1) {
row =
graph_top +
graph_height -
1;
}
return row;
}
/* TRAINLOG_DASHBOARD_GRAPH_ONLY */
/* TRAINLOG_DASHBOARD_12_MONTHS */
#define DASHBOARD_MONTH_COUNT 12U
typedef struct TrainlogDashboardMonth {
int year;
int month;
} TrainlogDashboardMonth;
typedef struct TrainlogDashboardMonthValue {
bool present;
double value;
} TrainlogDashboardMonthValue;
static bool dashboard_parse_year_month(
const char *timestamp,
int *year,
int *month
)
{
int parsed_year;
int parsed_month;
if (timestamp == NULL ||
year == NULL ||
month == NULL ||
strlen(timestamp) < 7U ||
timestamp[4] != '-' ||
timestamp[0] < '0' || timestamp[0] > '9' ||
timestamp[1] < '0' || timestamp[1] > '9' ||
timestamp[2] < '0' || timestamp[2] > '9' ||
timestamp[3] < '0' || timestamp[3] > '9' ||
timestamp[5] < '0' || timestamp[5] > '9' ||
timestamp[6] < '0' || timestamp[6] > '9') {
return false;
}
parsed_year =
((timestamp[0] - '0') * 1000) +
((timestamp[1] - '0') * 100) +
((timestamp[2] - '0') * 10) +
(timestamp[3] - '0');
parsed_month =
((timestamp[5] - '0') * 10) +
(timestamp[6] - '0');
if (parsed_year < 1 ||
parsed_month < 1 ||
parsed_month > 12) {
return false;
}
*year = parsed_year;
*month = parsed_month;
return true;
}
static long dashboard_month_key(
int year,
int month
)
{
return ((long)year * 12L) +
(long)(month - 1);
}
static void dashboard_month_from_key(
long key,
TrainlogDashboardMonth *output
)
{
long year;
long month_zero;
year = key / 12L;
month_zero = key % 12L;
if (month_zero < 0L) {
month_zero += 12L;
--year;
}
output->year = (int)year;
output->month = (int)month_zero + 1;
}
static bool dashboard_current_month_key(
long *output_key
)
{
time_t now;
struct tm local_time;
if (output_key == NULL) {
return false;
}
now = time(NULL);
if (now == (time_t)-1) {
return false;
}
if (localtime_r(&now, &local_time) == NULL) {
return false;
}
*output_key =
dashboard_month_key(
local_time.tm_year + 1900,
local_time.tm_mon + 1
);
return true;
}
typedef struct TrainlogSessionDraftExercise {
TrainlogSessionExerciseInput input;
TrainlogSetInput sets[MAX_SETS_PER_EXERCISE];
char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode;
char notes[TRAINLOG_NOTE_MAX + 1U];
/* Transient provenance only; the persisted plan stores the resulting kg. */
bool target_from_percent_max;
} TrainlogSessionDraftExercise;
static void draft_bind_input(
TrainlogSessionDraftExercise *draft
)
{
if (draft == NULL) {
return;
}
draft->input.sets =
draft->sets;
draft->input.notes =
draft->notes[0] != '\0'
? draft->notes
: NULL;
}
static void draft_format_set_metric(
const TrainlogSessionDraftExercise *draft,
const TrainlogSetInput *set,
char *output,
size_t output_size
)
{
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
if (trainlog_duration_format(
set->duration_seconds,
output,
output_size
) != TRAINLOG_STATUS_OK) {
(void)snprintf(output, output_size, "%d s",
set->duration_seconds);
}
} else {
(void)snprintf(output, output_size, "%d", set->reps);
}
}
static void draft_delete_exercise(
TrainlogSessionDraftExercise *drafts,
size_t *count,
size_t selected
)
{
size_t index;
if (drafts == NULL ||
count == NULL ||
selected >= *count) {
return;
}
for (index = selected;
index + 1U < *count;
++index) {
drafts[index] =
drafts[index + 1U];
draft_bind_input(
&drafts[index]
);
}
--(*count);
if (*count < MAX_SESSION_EXERCISES) {
(void)memset(
&drafts[*count],
0,
sizeof(drafts[*count])
);
}
}
static void draft_set_summary(
const TrainlogSessionDraftExercise *draft,
char *output,
size_t output_size
)
{
size_t index;
size_t used = 0U;
if (draft == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (draft->input.has_max_weight) {
char weight[32];
format_compact_max_weight(
draft->input.max_weight_kg,
weight,
sizeof(weight)
);
(void)snprintf(
output,
output_size,
"Max %s kg",
weight
);
} else if (draft->input.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) {
char duration[64];
(void)trainlog_duration_format(
draft->input.continuous_duration_seconds,
duration,
sizeof(duration)
);
(void)trainlog_presentation_format_key(output, output_size,
"summary.continuous", duration);
} else if (draft->input.set_count == 0U &&
draft->input.target_sets > 0 && draft->input.target_reps > 0) {
if (draft->input.target_has_weight) {
(void)trainlog_presentation_format_key(output, output_size,
"summary.plan.weight",
draft->input.target_sets, draft->input.target_reps,
draft->input.target_weight_kg, draft->input.rest_seconds);
} else {
(void)trainlog_presentation_format_key(output, output_size,
"summary.plan.no_load",
draft->input.target_sets, draft->input.target_reps,
draft->input.rest_seconds);
}
} else {
int written = trainlog_presentation_format_key(output, output_size,
"summary.sets.prefix", draft->input.set_count);
if (written < 0) {
output[0] = '\0';
return;
}
used = (size_t)written < output_size
? (size_t)written : output_size - 1U;
/* CONTRACT: the in-progress summary reflects each actual row. It
* must not collapse heterogeneous loads back to the target weight. */
for (index = 0U; index < draft->input.set_count && used + 1U < output_size;
++index) {
char metric[64];
char fragment[96];
draft_format_set_metric(draft, &draft->sets[index], metric,
sizeof(metric));
if (draft->input.load_mode == TRAINLOG_LOAD_NONE) {
written = snprintf(fragment, sizeof(fragment), "%s%s",
index > 0U ? " / " : "", metric);
} else {
written = snprintf(fragment, sizeof(fragment), "%s%s×%s%s",
index > 0U ? " / " : "", metric,
draft->sets[index].has_weight ? "" : "—",
draft->sets[index].has_weight ? "kg" : "");
if (draft->sets[index].has_weight) {
written = trainlog_presentation_format(fragment, sizeof(fragment),
"%s%s×%.2fkg%s",
index > 0U ? " / " : "", metric,
draft->sets[index].weight_kg,
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
? trainlog_presentation_text("summary.assistance.suffix") : "");
}
}
if (written < 0) {
break;
}
(void)snprintf(output + used, output_size - used, "%s", fragment);
used = strlen(output);
}
}
}
static bool load_persisted_draft(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *session,
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES],
size_t *draft_count
)
{
TrainlogEditableExerciseRecord
records[MAX_SESSION_EXERCISES];
TrainlogSetInput
sets[MAX_SESSION_EXERCISES *
MAX_SETS_PER_EXERCISE];
size_t record_count = 0U;
size_t set_count = 0U;
size_t index;
if (database == NULL ||
session_id == NULL ||
session == NULL ||
drafts == NULL ||
draft_count == NULL) {
return false;
}
if (trainlog_database_load_session_editable(
database,
session_id,
session,
records,
MAX_SESSION_EXERCISES,
&record_count,
sets,
MAX_SESSION_EXERCISES *
MAX_SETS_PER_EXERCISE,
&set_count
) != TRAINLOG_STATUS_OK) {
return false;
}
(void)set_count;
(void)memset(
drafts,
0,
sizeof(*drafts) *
MAX_SESSION_EXERCISES
);
for (index = 0U;
index < record_count;
++index) {
TrainlogSessionDraftExercise *draft =
&drafts[index];
const TrainlogEditableExerciseRecord *record =
&records[index];
if (record->set_count >
MAX_SETS_PER_EXERCISE) {
return false;
}
(void)snprintf(
draft->input.exercise_id,
sizeof(draft->input.exercise_id),
"%s",
record->exercise_id
);
(void)snprintf(
draft->name,
sizeof(draft->name),
"%s",
record->name
);
draft->tracking_mode =
record->tracking_mode;
draft->input.recording_mode =
record->recording_mode;
draft->input.data_fields =
record->data_fields;
draft->input.load_mode =
record->load_mode;
draft->input.rest_seconds =
record->rest_seconds;
draft->input.target_sets =
record->target_sets;
draft->input.target_reps =
record->target_reps;
draft->input.target_duration_seconds =
record->target_duration_seconds;
draft->input.target_has_weight =
record->has_target_weight != 0;
draft->input.target_weight_kg =
record->target_weight_kg;
draft->input.has_max_weight =
record->has_max_weight != 0;
draft->input.max_weight_kg =
record->max_weight_kg;
draft->input.continuous_duration_seconds =
record->continuous_duration_seconds;
draft->input.continuous_has_speed =
record->has_continuous_speed != 0;
draft->input.continuous_speed_kmh =
record->continuous_speed_kmh;
draft->input.continuous_has_distance =
record->has_continuous_distance != 0;
draft->input.continuous_distance_km =
record->continuous_distance_km;
(void)snprintf(
draft->input.entry_id,
sizeof(draft->input.entry_id),
"%s",
record->entry_id
);
(void)snprintf(
draft->input.equipment_id,
sizeof(draft->input.equipment_id),
"%s",
record->equipment_id
);
(void)snprintf(
draft->notes,
sizeof(draft->notes),
"%s",
record->notes
);
if (record->set_count > 0U) {
(void)memcpy(
draft->sets,
&sets[record->set_offset],
record->set_count *
sizeof(draft->sets[0])
);
}
draft->input.set_count =
record->set_count;
draft_bind_input(draft);
}
*draft_count =
record_count;
return true;
}
static bool persist_draft_replacement(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES],
size_t count
)
{
TrainlogSessionExerciseInput
inputs[MAX_SESSION_EXERCISES];
size_t index;
for (index = 0U;
index < count;
++index) {
draft_bind_input(
&drafts[index]
);
inputs[index] =
drafts[index].input;
}
return trainlog_database_replace_session_exercises(
database,
session_id,
inputs,
count
) == TRAINLOG_STATUS_OK;
}
typedef struct TrainlogGeneratorPreviewItem {
char exercise_name[TRAINLOG_NAME_MAX + 1U];
char equipment_name[TRAINLOG_NAME_MAX + 1U];
char zone_name[TRAINLOG_NAME_MAX + 1U];
char movement_name[TRAINLOG_NAME_MAX + 1U];
} TrainlogGeneratorPreviewItem;
static const char *generator_goal_label(const char *goal_id)
{
if (strcmp(goal_id, "general") == 0) return trainlog_presentation_text("goal.general");
if (strcmp(goal_id, "strength") == 0) return trainlog_presentation_text("goal.strength");
if (strcmp(goal_id, "hypertrophy") == 0) return trainlog_presentation_text("goal.hypertrophy");
if (strcmp(goal_id, "endurance") == 0) return trainlog_presentation_text("goal.endurance");
return goal_id;
}
static bool generator_prepare_preview(TrainlogDatabase *database,
const TrainlogGeneratedSession *session, TrainlogGeneratorPreviewItem *items)
{
size_t index;
for (index = 0U; index < session->exercise_count; ++index) {
TrainlogExercise exercise;
TrainlogResolvedEquipment equipment;
const TrainlogBodyZone *zone;
const TrainlogKnowledgeMovementPattern *pattern = NULL;
if (trainlog_database_get_exercise_profile(database,
session->exercises[index].exercise_id, &exercise) != TRAINLOG_STATUS_OK ||
trainlog_database_resolve_equipment(database,
session->exercises[index].equipment_id, &equipment) != TRAINLOG_STATUS_OK) return false;
zone = trainlog_body_zone_catalog_lookup(session->exercises[index].primary_zone_id);
if (session->exercises[index].pattern_count > 0U)
pattern = trainlog_knowledge_movement_pattern_lookup(session->exercises[index].pattern_ids[0]);
(void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "%s", exercise.name);
(void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "%s", equipment.display_name);
(void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "%s",
zone != NULL ? zone->display_name : session->exercises[index].primary_zone_id);
(void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "%s",
pattern != NULL ? pattern->display_name_fr : "mouvement non classé");
}
return true;
}
static bool generator_build_drafts(TrainlogDatabase *database,
const TrainlogGeneratedSession *session, TrainlogSessionDraftExercise *drafts)
{
size_t index;
for (index = 0U; index < session->exercise_count; ++index) {
const TrainlogGeneratedExercise *generated = &session->exercises[index];
TrainlogExercise exercise;
if (trainlog_database_get_exercise_profile(database, generated->exercise_id,
&exercise) != TRAINLOG_STATUS_OK) return false;
(void)memset(&drafts[index], 0, sizeof(drafts[index]));
(void)snprintf(drafts[index].input.exercise_id,
sizeof(drafts[index].input.exercise_id), "%s", generated->exercise_id);
(void)snprintf(drafts[index].input.equipment_id,
sizeof(drafts[index].input.equipment_id), "%s", generated->equipment_id);
(void)snprintf(drafts[index].name, sizeof(drafts[index].name), "%s", exercise.name);
drafts[index].tracking_mode = TRAINLOG_TRACKING_REPS;
drafts[index].input.recording_mode = TRAINLOG_RECORDING_SETS;
drafts[index].input.load_mode = generated->planned_load_mode;
drafts[index].input.target_sets = generated->target_sets;
drafts[index].input.target_reps = generated->target_repetitions;
drafts[index].input.rest_seconds = generated->rest_seconds;
drafts[index].input.target_has_weight = generated->has_target_weight;
drafts[index].input.target_weight_kg = generated->target_weight_kg;
/* INVARIANT: generated dose is a plan. No performed row exists until
* the normal editor records a real metric supplied by the user. */
drafts[index].input.set_count = 0U;
draft_bind_input(&drafts[index]);
}
return true;
}
static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database,
TrainlogSessionDraftExercise *drafts, size_t exercise_count,
const char *started_at, TrainlogSessionType session_type)
{
TrainlogSessionExerciseInput *inputs;
TrainlogSessionInput session;
char session_id[TRAINLOG_GENERATED_ID_CAPACITY];
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogStatus status;
size_t index;
if (database == NULL || drafts == NULL || started_at == NULL ||
exercise_count == 0U ||
exercise_count > MAX_SESSION_EXERCISES) return TRAINLOG_STATUS_INVALID_ARGUMENT;
inputs = calloc(exercise_count, sizeof(*inputs));
if (inputs == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR;
if (trainlog_id_generate("se", session_id, sizeof(session_id)) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(ended_at, sizeof(ended_at)) != TRAINLOG_STATUS_OK) {
free(inputs);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (index = 0U; index < exercise_count; ++index) {
draft_bind_input(&drafts[index]);
inputs[index] = drafts[index].input;
}
(void)memset(&session, 0, sizeof(session));
session.session_type = session_type;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at);
session.exercises = inputs;
session.exercise_count = exercise_count;
status = trainlog_database_insert_session(database, &session);
free(inputs);
return status;
}
static void body_short_date(
const char *timestamp,
char output[9]
)
{
if (timestamp == NULL ||
strlen(timestamp) < 10U) {
(void)snprintf(
output,
9U,
"%s",
"--/--/--"
);
return;
}
if (!trainlog_presentation_compact_date(timestamp, true, output, 9U))
(void)snprintf(output, 9U, "%s", "--/--/--");
}
static size_t body_record_metric_count(
const TrainlogBodyObservationRecord *record
)
{
size_t count = 0U;
if (record == NULL) {
return 0U;
}
#define COUNT_BODY_VALUE(flag_) \
do { \
if ((flag_)) { \
++count; \
} \
} while (0)
COUNT_BODY_VALUE(record->has_body_weight);
COUNT_BODY_VALUE(record->has_neck);
COUNT_BODY_VALUE(record->has_shoulders);
COUNT_BODY_VALUE(record->has_chest);
COUNT_BODY_VALUE(record->has_waist);
COUNT_BODY_VALUE(record->has_hips);
COUNT_BODY_VALUE(record->has_left_arm);
COUNT_BODY_VALUE(record->has_right_arm);
COUNT_BODY_VALUE(record->has_left_forearm);
COUNT_BODY_VALUE(record->has_right_forearm);
COUNT_BODY_VALUE(record->has_left_thigh);
COUNT_BODY_VALUE(record->has_right_thigh);
COUNT_BODY_VALUE(record->has_left_calf);
COUNT_BODY_VALUE(record->has_right_calf);
#undef COUNT_BODY_VALUE
return count;
}
static void body_summary_text(
const TrainlogBodyObservationRecord *record,
char *output,
size_t output_size
)
{
char weight[24];
char waist[24];
char chest[24];
size_t metric_count;
if (record == NULL ||
output == NULL ||
output_size == 0U) {
return;
}
if (record->has_body_weight) {
(void)snprintf(
weight,
sizeof(weight),
"%.1f kg",
record->body_weight_kg
);
} else {
(void)snprintf(
weight,
sizeof(weight),
"%s",
"— kg"
);
}
if (record->has_waist) {
(void)trainlog_presentation_format_key(waist, sizeof(waist),
"summary.waist.value", record->waist_cm);
} else {
(void)trainlog_presentation_format_key(waist, sizeof(waist),
"summary.waist.none");
}
if (record->has_chest) {
(void)trainlog_presentation_format_key(chest, sizeof(chest),
"summary.chest.value", record->chest_cm);
} else {
(void)trainlog_presentation_format_key(chest, sizeof(chest),
"summary.chest.none");
}
metric_count =
body_record_metric_count(record);
(void)trainlog_presentation_format_key(output, output_size,
"summary.body", weight, waist, chest, metric_count);
}
static void body_input_from_record(
const TrainlogBodyObservationRecord *record,
TrainlogBodyObservationInput *input
)
{
if (record == NULL ||
input == NULL) {
return;
}
(void)memset(
input,
0,
sizeof(*input)
);
(void)snprintf(
input->observation_id,
sizeof(input->observation_id),
"%s",
record->observation_id
);
(void)snprintf(
input->observed_at,
sizeof(input->observed_at),
"%s",
record->observed_at
);
input->has_body_weight =
record->has_body_weight;
input->body_weight_kg =
record->body_weight_kg;
input->has_neck =
record->has_neck;
input->neck_cm =
record->neck_cm;
input->has_shoulders =
record->has_shoulders;
input->shoulders_cm =
record->shoulders_cm;
input->has_chest =
record->has_chest;
input->chest_cm =
record->chest_cm;
input->has_waist =
record->has_waist;
input->waist_cm =
record->waist_cm;
input->has_hips =
record->has_hips;
input->hips_cm =
record->hips_cm;
input->has_left_arm =
record->has_left_arm;
input->left_arm_cm =
record->left_arm_cm;
input->has_right_arm =
record->has_right_arm;
input->right_arm_cm =
record->right_arm_cm;
input->has_left_forearm =
record->has_left_forearm;
input->left_forearm_cm =
record->left_forearm_cm;
input->has_right_forearm =
record->has_right_forearm;
input->right_forearm_cm =
record->right_forearm_cm;
input->has_left_thigh =
record->has_left_thigh;
input->left_thigh_cm =
record->left_thigh_cm;
input->has_right_thigh =
record->has_right_thigh;
input->right_thigh_cm =
record->right_thigh_cm;
input->has_left_calf =
record->has_left_calf;
input->left_calf_cm =
record->left_calf_cm;
input->has_right_calf =
record->has_right_calf;
input->right_calf_cm =
record->right_calf_cm;
input->notes =
record->notes[0] != '\0'
? record->notes
: NULL;
}
/* TRAINLOG_BODY_ANALYTICS_TUI_V1 */
static bool body_analytics_profile_path(
char *output,
size_t output_size,
bool ensure_directory
)
{
const char *config_home =
getenv(
"XDG_CONFIG_HOME"
);
const char *home =
getenv(
"HOME"
);
char base[
PATH_MAX + 1U
];
char directory[
PATH_MAX + 1U
];
int written;
if (
output == NULL ||
output_size == 0U
) {
return false;
}
if (
config_home != NULL &&
config_home[0] != '\0'
) {
written =
snprintf(
base,
sizeof(base),
"%s",
config_home
);
} else if (
home != NULL &&
home[0] != '\0'
) {
written =
snprintf(
base,
sizeof(base),
"%s/.config",
home
);
if (
written >= 0 &&
(size_t)written <
sizeof(base) &&
ensure_directory
) {
(void)mkdir(
base,
0700
);
}
} else {
return false;
}
if (
written < 0 ||
(size_t)written >=
sizeof(base)
) {
return false;
}
written =
snprintf(
directory,
sizeof(directory),
"%s/trainlog",
base
);
if (
written < 0 ||
(size_t)written >=
sizeof(directory)
) {
return false;
}
if (ensure_directory) {
(void)mkdir(
directory,
0700
);
}
written =
snprintf(
output,
output_size,
"%s/body_analytics.conf",
directory
);
return
written >= 0 &&
(size_t)written <
output_size;
}
static bool body_analytics_profile_load(
TrainlogBodyAnalyticsProfile *output
)
{
char path[
PATH_MAX + 1U
];
char line[128];
int version = 0;
int formula = -1;
double height_cm = 0.0;
FILE *file;
if (
output == NULL ||
!body_analytics_profile_path(
path,
sizeof(path),
false
)
) {
return false;
}
file =
fopen(
path,
"rb"
);
if (file == NULL) {
return false;
}
while (
fgets(
line,
sizeof(line),
file
) != NULL
) {
if (
sscanf(
line,
"version=%d",
&version
) == 1
) {
continue;
}
if (
strcmp(
line,
"formula=male\n"
) == 0 ||
strcmp(
line,
"formula=male"
) == 0
) {
formula =
(int)
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
continue;
}
if (
strcmp(
line,
"formula=female\n"
) == 0 ||
strcmp(
line,
"formula=female"
) == 0
) {
formula =
(int)
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE;
continue;
}
(void)sscanf(
line,
"height_cm=%lf",
&height_cm
);
}
(void)fclose(
file
);
if (
version != 1 ||
formula < 0
) {
return false;
}
output->formula =
(TrainlogBodyAnalyticsFormula)
formula;
output->height_cm =
height_cm;
return
trainlog_body_analytics_profile_valid(
output
);
}
static bool body_analytics_profile_save(
const TrainlogBodyAnalyticsProfile *profile
)
{
char path[
PATH_MAX + 1U
];
FILE *file;
if (
!trainlog_body_analytics_profile_valid(
profile
) ||
!body_analytics_profile_path(
path,
sizeof(path),
true
)
) {
return false;
}
file =
fopen(
path,
"wb"
);
if (file == NULL) {
return false;
}
if (
fprintf(
file,
"version=1\n"
"formula=%s\n"
"height_cm=%.2f\n",
profile->formula ==
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
? "female"
: "male",
profile->height_cm
) < 0
) {
(void)fclose(
file
);
return false;
}
return
fclose(
file
) == 0;
}
static const TrainlogBodyObservationRecord *
body_analytics_oldest_weight(
const TrainlogBodyObservationRecord *records,
size_t count
)
{
size_t index;
if (
records == NULL ||
count == 0U
) {
return NULL;
}
for (
index = count;
index > 0U;
--index
) {
if (
records[index - 1U]
.has_body_weight
) {
return
&records[index - 1U];
}
}
return NULL;
}
static const TrainlogBodyObservationRecord *
body_analytics_oldest_waist(
const TrainlogBodyObservationRecord *records,
size_t count
)
{
size_t index;
if (
records == NULL ||
count == 0U
) {
return NULL;
}
for (
index = count;
index > 0U;
--index
) {
if (
records[index - 1U]
.has_waist
) {
return
&records[index - 1U];
}
}
return NULL;
}
static bool body_analytics_oldest_estimate(
const TrainlogBodyAnalyticsProfile *profile,
const TrainlogBodyObservationRecord *records,
size_t count,
TrainlogBodyAnalyticsResult *output
)
{
size_t index;
if (
profile == NULL ||
records == NULL ||
output == NULL
) {
return false;
}
for (
index = count;
index > 0U;
--index
) {
TrainlogBodyAnalyticsResult
candidate;
if (
trainlog_body_analytics_calculate(
profile,
&records[index - 1U],
&candidate
) ==
TRAINLOG_STATUS_OK &&
candidate
.has_body_fat_estimate
) {
*output =
candidate;
return true;
}
}
return false;
}
/* TRAINLOG_SYNC_TUI */
/* TRAINLOG_SHARED_SYNC_ENGINE_V1 */
#define SYNC_HISTORY_CAPACITY 64U
#define SYNC_DETAIL_LINE_CAPACITY 160U
#define SYNC_DETAIL_TEXT_CAPACITY 16384U
static void session_history_datetime(
const char *timestamp,
char output[17]
)
{
if (
timestamp == NULL ||
strlen(timestamp) < 16U
) {
(void)snprintf(
output,
17U,
"%s",
"--/--/---- --:--"
);
return;
}
{
char date[9];
if (!trainlog_presentation_compact_date(timestamp, true, date,
sizeof(date))) {
(void)snprintf(output, 17U, "%s", "--/--/---- --:--");
return;
}
(void)snprintf(output, 17U, "%s %c%c:%c%c", date,
timestamp[11], timestamp[12], timestamp[14], timestamp[15]);
}
}
static bool sync_history_path(
char *output,
size_t output_size
)
{
const char *data_home =
getenv(
"XDG_DATA_HOME"
);
const char *home =
getenv(
"HOME"
);
int written;
if (
output == NULL ||
output_size == 0U
) {
return false;
}
if (
data_home != NULL &&
data_home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/trainlog/sync_history.log",
data_home
);
} else if (
home != NULL &&
home[0] != '\0'
) {
written =
snprintf(
output,
output_size,
"%s/.local/share/trainlog/sync_history.log",
home
);
} else {
return false;
}
return
written >= 0 &&
(size_t)written <
output_size;
}
static void sync_history_load(
TrainlogSyncHistoryEntry *output,
size_t capacity,
size_t *output_count
)
{
char path[
PATH_MAX + 1U
];
TrainlogSyncHistoryEntry
ring[SYNC_HISTORY_CAPACITY];
size_t count = 0U;
size_t next = 0U;
size_t copied;
FILE *file;
char line[768];
if (
output_count == NULL ||
(
capacity > 0U &&
output == NULL
)
) {
return;
}
*output_count = 0U;
if (
!sync_history_path(
path,
sizeof(path)
)
) {
return;
}
file =
fopen(
path,
"rb"
);
if (file == NULL) {
return;
}
(void)memset(
ring,
0,
sizeof(ring)
);
while (
fgets(
line,
sizeof(line),
file
) != NULL
) {
TrainlogSyncHistoryEntry *entry;
if (
strchr(line, '\n') == NULL &&
!feof(file)
) {
int discarded;
do {
discarded = fgetc(file);
} while (discarded != '\n' && discarded != EOF);
continue;
}
entry =
&ring[next];
if (
!trainlog_sync_history_parse_line(
line,
entry
)
) {
continue;
}
next =
(
next + 1U
) %
SYNC_HISTORY_CAPACITY;
if (
count <
SYNC_HISTORY_CAPACITY
) {
++count;
}
}
(void)fclose(
file
);
copied =
count < capacity
? count
: capacity;
for (
size_t index = 0U;
index < copied;
++index
) {
size_t source =
(
next +
SYNC_HISTORY_CAPACITY -
1U -
index
) %
SYNC_HISTORY_CAPACITY;
output[index] =
ring[source];
}
*output_count =
copied;
}
static double sync_bytes_to_gib(
uint64_t bytes
)
{
return
(double)bytes /
(
1024.0 *
1024.0 *
1024.0
);
}
typedef enum TrainlogSessionPhase {
TRAINLOG_SESSION_IDLE = 0,
TRAINLOG_SESSION_CHOOSE_TYPE,
TRAINLOG_SESSION_EXERCISE_PICKER,
TRAINLOG_SESSION_EQUIPMENT_PICKER,
TRAINLOG_SESSION_EQUIPMENT_CREATE,
TRAINLOG_SESSION_PLANNING,
TRAINLOG_SESSION_DRAFT,
TRAINLOG_SESSION_ACTUALS,
TRAINLOG_SESSION_GENERATOR_CONFIG,
TRAINLOG_SESSION_GENERATOR_WARNING,
TRAINLOG_SESSION_GENERATOR_PREVIEW,
TRAINLOG_SESSION_CONFIRM_REMOVE,
TRAINLOG_SESSION_CONFIRM_SET_REMOVE,
TRAINLOG_SESSION_CONFIRM_ABANDON,
TRAINLOG_SESSION_CONFIRM_LEAVE,
TRAINLOG_SESSION_MESSAGE
} TrainlogSessionPhase;
typedef enum TrainlogSessionFormPurpose {
TRAINLOG_SESSION_FORM_NONE = 0,
TRAINLOG_SESSION_FORM_SET_METRIC,
TRAINLOG_SESSION_FORM_SET_WEIGHT,
TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION,
TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED,
TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE,
TRAINLOG_SESSION_FORM_TARGET_SETS,
TRAINLOG_SESSION_FORM_TARGET_REPS,
TRAINLOG_SESSION_FORM_TARGET_DURATION,
TRAINLOG_SESSION_FORM_REST,
TRAINLOG_SESSION_FORM_TARGET_WEIGHT,
TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX,
TRAINLOG_SESSION_FORM_EQUIPMENT_NAME,
TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL,
TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE,
TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD,
TRAINLOG_SESSION_FORM_GENERATED_SETS,
TRAINLOG_SESSION_FORM_GENERATED_REPS,
TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX,
TRAINLOG_SESSION_FORM_MAX_WEIGHT
} TrainlogSessionFormPurpose;
typedef struct TrainlogSessionController {
/* CONTRACT: desktop draft durability is exactly one process run. Route
* changes retain this controller; only explicit discard clears it. */
bool has_draft;
bool dirty;
bool generated_preview;
bool generation_warning_acknowledged;
bool correcting;
bool replacing_occurrence;
bool destructive_confirm_selected;
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES];
size_t draft_count;
size_t selected;
size_t set_selected;
size_t set_field;
size_t planning_field;
TrainlogSessionType session_type;
TrainlogSessionPhase phase;
TrainlogSessionPhase return_phase;
TrainlogSessionFormPurpose form_purpose;
TrainlogFormField form;
TrainlogSetInput pending_set;
int pending_generated_sets;
TrainlogExercise picker[MAX_EXERCISES];
size_t picker_count;
size_t picker_selected;
size_t equipment_count;
size_t equipment_selected;
TrainlogCustomEquipment pending_equipment;
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char generation_reference_time[TRAINLOG_TIMESTAMP_MAX + 1U];
char persisted_session_id[TRAINLOG_ID_MAX + 1U];
char message[192];
TrainlogAppRoute pending_route;
TrainlogGeneratedSession generated;
TrainlogGeneratorPreviewItem generated_items[TRAINLOG_GENERATOR_MAX_SELECTED];
const TrainlogBodyZone *generation_zone;
const TrainlogSessionGenerationGoalPolicy *generation_goal;
size_t generation_zone_index;
size_t generation_goal_index;
size_t generation_duration_index;
int generation_duration_minutes;
TrainlogDurabilityState durability;
} TrainlogSessionController;
/* WHY: compiler-visible source buffers are wider than the shell route field.
* CONTRACT: stable identities are copied whole or rejected, never truncated.
* INVARIANT: a failed copy leaves an empty, terminated destination. */
static bool copy_ui_stable_id(char *output, size_t capacity, const char *value)
{
size_t length;
if (output == NULL || capacity == 0U || value == NULL) return false;
length = strlen(value);
if (length >= capacity) { output[0] = '\0'; return false; }
(void)memcpy(output, value, length + 1U);
return true;
}
typedef enum TrainlogEquipmentPhase {
TRAINLOG_EQUIPMENT_IDLE = 0,
TRAINLOG_EQUIPMENT_NAME,
TRAINLOG_EQUIPMENT_LABEL,
TRAINLOG_EQUIPMENT_TYPE,
TRAINLOG_EQUIPMENT_LOAD,
TRAINLOG_EQUIPMENT_CONFIRM_DISCARD,
TRAINLOG_EQUIPMENT_MESSAGE
} TrainlogEquipmentPhase;
typedef struct TrainlogEquipmentController {
/* INVARIANT: this controller is the sole transient owner of a catalogue
* equipment definition. Invalid submissions retain every raw field. */
TrainlogEquipmentPhase phase;
TrainlogEquipmentPhase return_phase;
TrainlogFormField form;
TrainlogCustomEquipment pending;
int load_choice;
bool dirty;
bool leaving;
TrainlogAppRoute pending_route;
char message[192];
} TrainlogEquipmentController;
typedef enum TrainlogExercisePhase {
TRAINLOG_EXERCISE_IDLE = 0,
TRAINLOG_EXERCISE_NAME,
TRAINLOG_EXERCISE_TRACKING,
TRAINLOG_EXERCISE_RECORDING,
TRAINLOG_EXERCISE_SPEED,
TRAINLOG_EXERCISE_DISTANCE,
TRAINLOG_EXERCISE_ZONES,
TRAINLOG_EXERCISE_MERGE_PICKER,
TRAINLOG_EXERCISE_MERGE_CONFIRM,
TRAINLOG_EXERCISE_MERGE_RESULT,
TRAINLOG_EXERCISE_CONFIRM_PROFILE,
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
TRAINLOG_EXERCISE_MESSAGE
} TrainlogExercisePhase;
typedef struct TrainlogExerciseController {
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
* exercise_id; incompatible future-profile changes require confirmation
* and never mutate occurrence-owned historical snapshots. */
TrainlogExercisePhase phase;
TrainlogExercisePhase return_phase;
TrainlogFormField form;
TrainlogExercise pending;
TrainlogExercise original;
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
size_t secondary_count;
size_t zone_selected;
bool editing;
bool inline_session;
bool dirty;
bool profile_confirmed;
bool leaving;
TrainlogAppRoute pending_route;
char message[192];
TrainlogExercise merge_candidates[MAX_EXERCISES];
size_t merge_count;
size_t merge_selected;
TrainlogExercise merge_source;
TrainlogExercise merge_target;
TrainlogExerciseMergePreview merge_preview;
} TrainlogExerciseController;
typedef enum TrainlogBodyPhase {
TRAINLOG_BODY_IDLE = 0,
TRAINLOG_BODY_FORM,
TRAINLOG_BODY_CONFIRM_DISCARD,
TRAINLOG_BODY_MESSAGE
} TrainlogBodyPhase;
typedef struct TrainlogBodyController {
/* CONTRACT: one controller owns all fourteen raw metric fields until one
* insert/update succeeds. Editing copies the immutable record identity,
* timestamp and session link and never permits those fields to drift. */
TrainlogBodyPhase phase;
TrainlogFormField form;
TrainlogBodyObservationInput pending;
char session_id[TRAINLOG_ID_MAX + 1U];
char notes[TRAINLOG_NOTE_MAX + 1U];
size_t field;
bool editing;
bool dirty;
bool leaving;
TrainlogAppRoute pending_route;
char message[192];
} TrainlogBodyController;
typedef enum TrainlogProfilePhase {
TRAINLOG_PROFILE_IDLE = 0,
TRAINLOG_PROFILE_FORMULA,
TRAINLOG_PROFILE_HEIGHT,
TRAINLOG_PROFILE_CONFIRM_DISCARD,
TRAINLOG_PROFILE_MESSAGE
} TrainlogProfilePhase;
typedef struct TrainlogProfileController {
TrainlogProfilePhase phase;
TrainlogProfilePhase return_phase;
TrainlogFormField form;
TrainlogBodyAnalyticsProfile pending;
TrainlogAppRoute caller;
bool dirty;
bool leaving;
TrainlogAppRoute pending_route;
char message[192];
} TrainlogProfileController;
typedef TrainlogStatus (*TrainlogAppSyncProbe)(TrainlogSyncDeviceInfo *output);
typedef TrainlogStatus (*TrainlogAppSyncRun)(TrainlogSyncTrigger trigger,
bool require_request, TrainlogSyncDirection direction,
TrainlogSyncReport *output);
typedef struct TrainlogSyncController {
/* Navigation only reads cached local journal state. Device discovery and
* the synchronous engine are invoked solely by explicit controller keys. */
TrainlogSyncScreenState action;
TrainlogSyncDeviceInfo device;
TrainlogStatus probe_status;
bool probe_known;
TrainlogSyncReport report;
/* WHY: report summary/error are durable diagnostic payloads, not UI copy.
* CONTRACT: the latest engine status is retained separately so the
* presenter can classify failures without parsing or rewriting payloads.
* INVARIANT: current-result rendering depends only on this status and the
* report's typed fields/counters. */
TrainlogStatus latest_status;
bool has_report;
bool running;
TrainlogSyncHistoryEntry history[SYNC_HISTORY_CAPACITY];
size_t history_count;
size_t selected;
char detail_text[SYNC_DETAIL_TEXT_CAPACITY];
char *detail_lines[SYNC_DETAIL_LINE_CAPACITY];
size_t detail_line_count;
size_t detail_scroll;
bool showing_detail;
TrainlogAppSyncProbe probe;
TrainlogAppSyncRun run;
} TrainlogSyncController;
typedef struct TrainlogDashboardWeek {
int64_t start_day;
int64_t span_days;
char label[12];
size_t sessions;
size_t maxima;
size_t working_improvements;
size_t max_improvements;
} TrainlogDashboardWeek;
typedef struct TrainlogDashboardZoneBucket {
char label[TRAINLOG_NAME_MAX + 1U];
size_t count;
} TrainlogDashboardZoneBucket;
typedef enum TrainlogStatisticsPeriod {
TRAINLOG_STATS_30_DAYS = 0,
TRAINLOG_STATS_7_DAYS,
TRAINLOG_STATS_90_DAYS,
TRAINLOG_STATS_YEAR,
TRAINLOG_STATS_ALL
} TrainlogStatisticsPeriod;
typedef struct TrainlogDashboardSnapshot {
bool error;
bool partial;
bool invalid_data;
bool has_performance;
TrainlogExercise exercise;
TrainlogLoadMode performance_load_mode;
TrainlogTrackingMode performance_tracking_mode;
int performance_dose;
char performance_equipment_id[TRAINLOG_ID_MAX + 1U];
char performance_equipment_label[TRAINLOG_NAME_MAX + 1U];
TrainlogExercisePerformancePoint performance[MAX_SESSIONS];
size_t performance_count;
bool has_explicit_max;
TrainlogExercise max_exercise;
double max_weight_kg;
char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
bool has_body;
size_t body_metric;
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
size_t body_count;
TrainlogDashboardWeek weeks[DASHBOARD_BUCKET_COUNT];
size_t week_count;
size_t completed_session_count;
size_t performed_set_count;
size_t distinct_exercise_count;
size_t explicit_max_count;
/* WHY: this is a catalogue dashboard, not a history-derived muscle
* claim. CONTRACT: every active canonical exercise contributes once to
* its persisted primary zone, or to the explicit unclassified bucket.
* INVARIANT: aliases and secondary zones never affect these counts. */
TrainlogDashboardZoneBucket zones[DASHBOARD_ZONE_BUCKET_CAPACITY];
size_t zone_count;
TrainlogStatisticsPeriod period;
} TrainlogDashboardSnapshot;
typedef enum TrainlogDashboardFactKind {
TRAINLOG_DASHBOARD_WORK,
TRAINLOG_DASHBOARD_MAX,
TRAINLOG_DASHBOARD_CONTINUOUS
} TrainlogDashboardFactKind;
typedef struct TrainlogDashboardFact {
TrainlogDashboardFactKind kind;
TrainlogTimestampKey time;
char session_id[TRAINLOG_ID_MAX + 1U];
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode;
size_t set_position;
int dose;
bool has_weight;
double weight_kg;
} TrainlogDashboardFact;
typedef struct TrainlogAppContext {
TrainlogDatabase *database;
TrainlogTerminal *terminal;
TrainlogNavigationState navigation;
TrainlogSessionController session;
TrainlogEquipmentController equipment_controller;
TrainlogExerciseController exercise_controller;
TrainlogBodyController body_controller;
TrainlogProfileController profile_controller;
TrainlogSyncController sync_controller;
TrainlogDashboardSnapshot dashboard;
TrainlogStatisticsSummary statistics;
TrainlogZoneStatistics statistics_zones[TRAINLOG_STATISTICS_ZONE_MAX];
size_t statistics_zone_count;
TrainlogExerciseStatistics exercise_statistics;
TrainlogMaxListItem max_list_items[MAX_EXERCISES];
size_t max_list_item_count;
bool max_list_partial;
bool max_show_all;
TrainlogExerciseListStatistic exercise_list_statistics[MAX_EXERCISES];
size_t exercise_list_statistics_count;
bool exercise_list_statistics_partial;
TrainlogStatisticsWindow statistics_window;
int64_t statistics_now;
size_t statistics_graph;
size_t statistics_zone_selected;
TrainlogExerciseSeriesKind exercise_statistics_graph;
bool statistics_error;
bool statistics_zones_partial;
TrainlogFocusTarget focus;
TrainlogActionModel actions;
TrainlogOverlayStack overlays;
TrainlogShellLayout layout;
TrainlogSurface *header;
TrainlogSurface *sidebar;
TrainlogSurface *content;
TrainlogSurface *footer;
size_t navigation_selected;
TrainlogFocusTarget navigation_restore_focus;
size_t content_selected;
size_t content_scroll;
bool settings_language_save_failed;
bool settings_language_durability_uncertain;
TrainlogListState list;
TrainlogSearchState search;
TrainlogListState saved_lists[TRAINLOG_ROUTE_COUNT];
TrainlogSearchState saved_searches[TRAINLOG_ROUTE_COUNT];
TrainlogExercise exercises[MAX_EXERCISES];
TrainlogResolvedEquipment equipment[256];
TrainlogSessionSummary sessions[MAX_SESSIONS];
const char *stable_ids[MAX_BODY_OBSERVATIONS];
size_t loaded_count;
int exercise_zone_filter;
bool list_error;
TrainlogExercise exercise_detail;
TrainlogExerciseBodyZone exercise_zones[MAX_BODY_ZONES];
size_t exercise_zone_count;
TrainlogResolvedEquipment exercise_explicit_equipment[64];
size_t exercise_explicit_equipment_count;
TrainlogResolvedEquipment exercise_historic_equipment[128];
size_t exercise_historic_equipment_count;
size_t exercise_equipment_selected;
bool exercise_detail_metadata_error;
TrainlogResolvedEquipment equipment_detail;
TrainlogExercisePerformancePoint performance_points[MAX_SESSIONS];
size_t performance_count;
TrainlogMeasuredMaxSummary max_summary;
bool performance_error;
TrainlogSessionSummary session_detail;
TrainlogPersistedExerciseDetail session_entries[MAX_SESSION_EXERCISES];
size_t session_entry_count;
size_t session_entry_selected;
size_t session_set_scroll;
bool session_detail_error;
TrainlogFeedbackView *exercise_feedback;
TrainlogFeedbackView *session_followups;
size_t exercise_feedback_count;
size_t session_followup_count;
TrainlogBodyObservationRecord body_records[MAX_BODY_OBSERVATIONS];
TrainlogBodyObservationRecord body_detail;
TrainlogBodyMetricPoint body_metric_points[MAX_BODY_METRIC_POINTS];
size_t body_metric_count;
bool body_metric_partial;
size_t body_metric_selected;
TrainlogGlobalBodySeries body_global_series[14];
bool body_global_enabled[14];
bool body_global_initialized;
size_t body_global_selected;
char body_global_dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U];
size_t body_global_date_count;
bool body_global_partial;
bool body_snapshot_error;
TrainlogBodyAnalyticsProfile body_profile;
bool body_has_profile;
bool quit_confirmation;
bool running;
} TrainlogAppContext;
static void app_shell_open_route(TrainlogAppContext *app,
TrainlogAppRoute route);
static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app);
static void app_shell_refresh_list(TrainlogAppContext *app);
static void app_shell_load_body_global(TrainlogAppContext *app);
static void app_shell_select_available_body_metric(TrainlogAppContext *app);
static void app_shell_load_statistics(TrainlogAppContext *app)
{
time_t now = time(NULL);
app->statistics_now = now == (time_t)-1 ? 0 : (int64_t)now;
app->statistics_error = now == (time_t)-1 ||
trainlog_statistics_load_summary(app->database, app->statistics_window,
(int64_t)now, &app->statistics) != TRAINLOG_STATUS_OK;
}
static void app_shell_load_statistics_zones(TrainlogAppContext *app)
{
time_t now = time(NULL);
app->statistics_zone_count = 0U;
app->statistics_zones_partial = false;
app->statistics_error = now == (time_t)-1 ||
trainlog_statistics_load_zones(app->database, (int64_t)now,
app->statistics_zones, TRAINLOG_STATISTICS_ZONE_MAX,
&app->statistics_zone_count, &app->statistics_zones_partial) !=
TRAINLOG_STATUS_OK;
if (app->statistics_zone_selected >= app->statistics_zone_count)
app->statistics_zone_selected = app->statistics_zone_count > 0U
? app->statistics_zone_count - 1U : 0U;
}
static void app_shell_load_exercise_statistics(TrainlogAppContext *app)
{
time_t now = time(NULL);
app->statistics_error = now == (time_t)-1 ||
trainlog_statistics_load_exercise(app->database,
app->exercise_detail.exercise_id, (int64_t)now,
&app->exercise_statistics) != TRAINLOG_STATUS_OK;
}
static void body_metric_slot(TrainlogBodyObservationInput *input, size_t field,
bool **present, double **value)
{
*present = NULL; *value = NULL;
#define BODY_SLOT(index_, has_, value_) case index_: *present = &input->has_; \
*value = &input->value_; break
switch (field) {
BODY_SLOT(0U, has_body_weight, body_weight_kg);
BODY_SLOT(1U, has_neck, neck_cm);
BODY_SLOT(2U, has_shoulders, shoulders_cm);
BODY_SLOT(3U, has_chest, chest_cm);
BODY_SLOT(4U, has_waist, waist_cm);
BODY_SLOT(5U, has_hips, hips_cm);
BODY_SLOT(6U, has_left_arm, left_arm_cm);
BODY_SLOT(7U, has_right_arm, right_arm_cm);
BODY_SLOT(8U, has_left_forearm, left_forearm_cm);
BODY_SLOT(9U, has_right_forearm, right_forearm_cm);
BODY_SLOT(10U, has_left_thigh, left_thigh_cm);
BODY_SLOT(11U, has_right_thigh, right_thigh_cm);
BODY_SLOT(12U, has_left_calf, left_calf_cm);
BODY_SLOT(13U, has_right_calf, right_calf_cm);
default: break;
}
#undef BODY_SLOT
}
static void body_controller_open_field(TrainlogBodyController *controller)
{
bool *present;
double *value;
char text[64] = "";
body_metric_slot(&controller->pending, controller->field, &present, &value);
if (controller->editing && present != NULL && *present)
(void)trainlog_presentation_format_input(text, sizeof(text), "%.10g", *value);
trainlog_form_init(&controller->form, text);
controller->phase = TRAINLOG_BODY_FORM;
}
static void body_controller_start_add(TrainlogAppContext *app)
{
TrainlogBodyController *controller = &app->body_controller;
(void)memset(controller, 0, sizeof(*controller));
if (trainlog_id_generate("bo", controller->pending.observation_id,
sizeof(controller->pending.observation_id)) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(controller->pending.observed_at,
sizeof(controller->pending.observed_at)) != TRAINLOG_STATUS_OK) {
controller->phase = TRAINLOG_BODY_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.body.prepare");
return;
}
body_controller_open_field(controller);
app->focus = TRAINLOG_FOCUS_EDITOR;
}
static bool body_controller_start_edit(TrainlogAppContext *app,
const char *observation_id)
{
TrainlogBodyController *controller = &app->body_controller;
TrainlogBodyObservationRecord record;
(void)memset(controller, 0, sizeof(*controller));
if (trainlog_database_get_body_observation(app->database, observation_id,
&record) != TRAINLOG_STATUS_OK) return false;
body_input_from_record(&record, &controller->pending);
(void)snprintf(controller->session_id, sizeof(controller->session_id),
"%s", record.session_id);
(void)snprintf(controller->notes, sizeof(controller->notes), "%s",
record.notes);
controller->pending.session_id = controller->session_id[0] != '\0'
? controller->session_id : NULL;
controller->pending.notes = controller->notes[0] != '\0'
? controller->notes : NULL;
controller->editing = true;
body_controller_open_field(controller);
app->focus = TRAINLOG_FOCUS_EDITOR;
return true;
}
static bool body_controller_persist(TrainlogAppContext *app)
{
TrainlogBodyController *controller = &app->body_controller;
TrainlogStatus status = controller->editing
? trainlog_database_update_body_observation(app->database,
&controller->pending)
: trainlog_database_insert_body_observation(app->database,
&controller->pending);
if (status != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.body.positive");
return false;
}
controller->dirty = false;
controller->form.active = false;
controller->phase = TRAINLOG_BODY_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), controller->editing
? "msg.body.corrected" : "msg.body.saved");
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
app_shell_refresh_list(app);
/* INVARIANT: a successful edit refreshes both halves of the body
* dashboard from the same committed database state. */
app_shell_load_body_global(app);
app_shell_select_available_body_metric(app);
}
if (controller->editing &&
app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL)
(void)trainlog_database_get_body_observation(app->database,
controller->pending.observation_id, &app->body_detail);
return true;
}
static bool app_shell_dispatch_body_controller(TrainlogAppContext *app, int key)
{
TrainlogBodyController *controller = &app->body_controller;
TrainlogFormResult result;
if (controller->phase == TRAINLOG_BODY_IDLE) return false;
if (controller->phase == TRAINLOG_BODY_MESSAGE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
key == TRAINLOG_KEY_ESCAPE) {
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
}
return true;
}
if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
if (key == '1') {
bool leaving = controller->leaving;
TrainlogAppRoute route = controller->pending_route;
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
if (leaving) app_shell_open_route(app, route);
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) {
controller->phase = TRAINLOG_BODY_FORM;
controller->leaving = false;
}
return true;
}
result = trainlog_form_handle(&controller->form, key);
if (result == TRAINLOG_FORM_EDITED) {
controller->dirty = true;
controller->message[0] = '\0';
} else if (result == TRAINLOG_FORM_SUBMIT) {
bool *present;
double *value;
double parsed = 0.0;
body_metric_slot(&controller->pending, controller->field,
&present, &value);
if (present == NULL || value == NULL) return true;
if (controller->form.text[0] == '\0') {
if (!controller->editing) { *present = false; *value = 0.0; }
} else if (controller->editing &&
strcmp(controller->form.text, "-") == 0) {
*present = false; *value = 0.0;
} else if (!parse_double_positive(controller->form.text, &parsed)) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.positive.invalid");
return true;
} else { *present = true; *value = parsed; }
controller->dirty = true;
if (controller->field + 1U < 14U) {
++controller->field;
body_controller_open_field(controller);
} else (void)body_controller_persist(app);
} else if (result == TRAINLOG_FORM_CANCEL) {
if (controller->dirty) controller->phase = TRAINLOG_BODY_CONFIRM_DISCARD;
else { (void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT; }
} else if (result == TRAINLOG_FORM_OPEN_NAVIGATION)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
else if (result == TRAINLOG_FORM_OPEN_ACTIONS)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
static void profile_controller_start(TrainlogAppContext *app,
TrainlogAppRoute caller)
{
TrainlogProfileController *controller = &app->profile_controller;
char text[8];
(void)memset(controller, 0, sizeof(*controller));
controller->caller = caller;
if (!body_analytics_profile_load(&controller->pending)) {
controller->pending.formula = TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
controller->pending.height_cm = 170.0;
}
controller->phase = TRAINLOG_PROFILE_FORMULA;
controller->return_phase = TRAINLOG_PROFILE_FORMULA;
(void)snprintf(text, sizeof(text), "%d",
controller->pending.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
? 2 : 1);
trainlog_form_init(&controller->form, text);
app->focus = TRAINLOG_FOCUS_EDITOR;
}
static bool app_shell_dispatch_profile_controller(TrainlogAppContext *app,
int key)
{
TrainlogProfileController *controller = &app->profile_controller;
TrainlogFormResult result;
if (controller->phase == TRAINLOG_PROFILE_IDLE) return false;
if (controller->phase == TRAINLOG_PROFILE_MESSAGE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == TRAINLOG_KEY_ESCAPE) {
TrainlogAppRoute caller = controller->caller;
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
if (app->navigation.current.route == caller)
app->body_has_profile = body_analytics_profile_load(
&app->body_profile);
}
return true;
}
if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
if (key == '1') {
bool leaving = controller->leaving;
TrainlogAppRoute route = controller->pending_route;
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
if (leaving) app_shell_open_route(app, route);
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
controller->phase = controller->return_phase;
return true;
}
result = trainlog_form_handle(&controller->form, key);
if (result == TRAINLOG_FORM_EDITED) controller->dirty = true;
else if (result == TRAINLOG_FORM_SUBMIT &&
controller->phase == TRAINLOG_PROFILE_FORMULA) {
if (strcmp(controller->form.text, "1") != 0 &&
strcmp(controller->form.text, "2") != 0) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.profile.formula");
return true;
}
controller->pending.formula = strcmp(controller->form.text, "2") == 0
? TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
: TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
controller->phase = TRAINLOG_PROFILE_HEIGHT;
controller->return_phase = TRAINLOG_PROFILE_HEIGHT;
{ char text[64]; (void)trainlog_presentation_format_input(text, sizeof(text), "%.1f",
controller->pending.height_cm); trainlog_form_init(&controller->form, text); }
} else if (result == TRAINLOG_FORM_SUBMIT) {
double height;
if (!parse_double_positive(controller->form.text, &height) ||
height < 100.0 || height > 250.0) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.profile.height");
return true;
}
controller->pending.height_cm = height;
if (!body_analytics_profile_save(&controller->pending)) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.profile.save.fail");
return true;
}
controller->dirty = false;
controller->phase = TRAINLOG_PROFILE_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.profile.saved");
} else if (result == TRAINLOG_FORM_CANCEL) {
if (controller->dirty) {
controller->return_phase = controller->phase;
controller->phase = TRAINLOG_PROFILE_CONFIRM_DISCARD;
}
else { (void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT; }
} else if (result == TRAINLOG_FORM_OPEN_NAVIGATION)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
else if (result == TRAINLOG_FORM_OPEN_ACTIONS)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
static void session_controller_sync_durability(TrainlogSessionController *session)
{
session->durability.session_draft = session->has_draft;
session->durability.session_dirty = session->dirty;
session->durability.generator_configuration_dirty =
session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG &&
(session->generation_zone != NULL || session->generation_goal != NULL);
session->durability.generator_preview = session->generated_preview;
session->durability.generator_preview_dirty = session->generated_preview;
session->durability.transient_form_dirty = session->form.active &&
session->form.bytes > 0U;
}
static void session_controller_clear_draft(TrainlogSessionController *session)
{
/* CONTRACT: this is called only by explicit abandon or a successful save.
* Route changes retain the complete run-memory draft. */
(void)memset(session->drafts, 0, sizeof(session->drafts));
session->has_draft = false;
session->dirty = false;
session->correcting = false;
session->draft_count = 0U;
session->selected = 0U;
session->persisted_session_id[0] = '\0';
session->started_at[0] = '\0';
session_controller_sync_durability(session);
}
static void session_controller_discard_generator(TrainlogSessionController *session)
{
/* INVARIANT: no Back/navigation path calls this helper. Preview edits,
* raw configuration and warning acknowledgement survive keep-and-leave. */
(void)memset(&session->generated, 0, sizeof(session->generated));
(void)memset(session->generated_items, 0, sizeof(session->generated_items));
session->generated_preview = false;
session->generation_warning_acknowledged = false;
session->generation_zone = NULL;
session->generation_goal = NULL;
session->generation_duration_minutes = 0;
session->generation_reference_time[0] = '\0';
session_controller_sync_durability(session);
}
static bool session_controller_load_picker(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
session->picker_count = 0U;
session->picker_selected = 0U;
if (trainlog_database_list_exercises(app->database, session->picker,
MAX_EXERCISES, &session->picker_count) != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.catalog.read");
session->phase = TRAINLOG_SESSION_MESSAGE;
return false;
}
if (session->picker_count == 0U) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.catalog.empty");
session->phase = TRAINLOG_SESSION_MESSAGE;
return false;
}
session->phase = TRAINLOG_SESSION_EXERCISE_PICKER;
return true;
}
static void session_controller_load_equipment(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
size_t index;
session->equipment_count = equipment_collect(app->database, "",
app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]),
NULL, NULL);
session->equipment_selected = 0U; /* zero is the explicit none choice */
if (session->selected < session->draft_count) {
const char *current = session->drafts[session->selected].input.equipment_id;
for (index = 0U; current[0] != '\0' && index < session->equipment_count; ++index)
if (strcmp(current, app->equipment[index].equipment_id) == 0) {
session->equipment_selected = index + 1U;
break;
}
}
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
}
static bool machine_exercise_compatibility(const char *exercise_id,
const char **equipment_id, TrainlogLoadMode *load_mode)
{
static const struct { const char *exercise; const char *equipment; bool assistance; } rows[] = {
{"ex_0e26c06f-a458-40a4-be20-4ed219ede30d", "plate_loaded_leg_press", false},
{"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "leg_extension", false},
{"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "rotary_torso", false},
{"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "diverging_seated_row", false},
{"ex_33f79331-871c-4eed-babe-346e53a99070", "seated_row", false},
{"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "assisted_dip_chin_machine", true},
{"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "back_extension", false},
{"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "rear_delt_pec_fly", false},
{"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "rear_delt_pec_fly", false},
{"ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "assisted_dip_chin_machine", true},
{"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "converging_shoulder_press", false},
{"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "hip_abduction", false},
{"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "seated_leg_curl", false},
{"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "lat_pull", false},
{"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "leg_press", false},
{"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "diverging_lat_pulldown", false},
{"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "prone_leg_curl", false},
{"ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "arm_curl", false}
};
size_t index;
if (strcmp(exercise_id, "ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2") == 0) {
*equipment_id = "treadmill";
*load_mode = TRAINLOG_LOAD_NONE;
return true;
}
for (index = 0U; index < sizeof(rows) / sizeof(rows[0]); ++index) {
if (strcmp(rows[index].exercise, exercise_id) == 0) {
*equipment_id = rows[index].equipment;
*load_mode = rows[index].assistance ? TRAINLOG_LOAD_ASSISTANCE :
TRAINLOG_LOAD_EXTERNAL;
return true;
}
}
return false;
}
static bool session_controller_choose_exercise(TrainlogSessionController *session)
{
TrainlogSessionDraftExercise replacement;
TrainlogSessionDraftExercise *target;
const TrainlogExercise *exercise;
char preserved_id[TRAINLOG_ID_MAX + 1U] = "";
char preserved_notes[TRAINLOG_NOTE_MAX + 1U] = "";
if (session->picker_count == 0U ||
session->picker_selected >= session->picker_count) return false;
if (!session->replacing_occurrence &&
session->draft_count >= MAX_SESSION_EXERCISES) return false;
exercise = &session->picker[session->picker_selected];
(void)memset(&replacement, 0, sizeof(replacement));
if (session->replacing_occurrence && session->selected < session->draft_count) {
target = &session->drafts[session->selected];
(void)snprintf(preserved_id, sizeof(preserved_id), "%s",
target->input.entry_id);
(void)snprintf(preserved_notes, sizeof(preserved_notes), "%s", target->notes);
}
(void)snprintf(replacement.input.entry_id,
sizeof(replacement.input.entry_id), "%s", preserved_id);
(void)snprintf(replacement.input.exercise_id,
sizeof(replacement.input.exercise_id), "%s", exercise->exercise_id);
(void)snprintf(replacement.name, sizeof(replacement.name), "%s", exercise->name);
(void)snprintf(replacement.notes, sizeof(replacement.notes), "%s", preserved_notes);
replacement.tracking_mode = exercise->tracking_mode;
replacement.input.recording_mode = exercise->recording_mode;
replacement.input.data_fields = exercise->data_fields;
replacement.input.load_mode = TRAINLOG_LOAD_NONE;
{
const char *compatibility_equipment = NULL;
TrainlogLoadMode compatibility_load = TRAINLOG_LOAD_NONE;
/* WHY: fixed-machine identity already determines the Phase-1 legacy
* provenance. CONTRACT: custom/unresolved IDs never use name
* inference and retain the explicit compatibility editor. */
if (machine_exercise_compatibility(exercise->exercise_id,
&compatibility_equipment, &compatibility_load)) {
(void)snprintf(replacement.input.equipment_id,
sizeof(replacement.input.equipment_id), "%s", compatibility_equipment);
replacement.input.load_mode = compatibility_load;
}
}
draft_bind_input(&replacement);
if (session->replacing_occurrence) session->drafts[session->selected] = replacement;
else {
session->selected = session->draft_count;
session->drafts[session->draft_count++] = replacement;
}
draft_bind_input(&session->drafts[session->selected]);
session->has_draft = true;
session->dirty = true;
session->replacing_occurrence = false;
session->phase = TRAINLOG_SESSION_DRAFT;
session_controller_sync_durability(session);
return true;
}
static bool session_controller_actuals_valid(TrainlogSessionController *session)
{
size_t index;
if (session->draft_count == 0U) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.session.empty");
return false;
}
for (index = 0U; index < session->draft_count; ++index) {
TrainlogSessionExerciseInput *input = &session->drafts[index].input;
bool valid = input->has_max_weight ||
(input->recording_mode == TRAINLOG_RECORDING_CONTINUOUS
? input->continuous_duration_seconds > 0 : input->set_count > 0U);
if (!valid) {
session->selected = index;
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.actual.required",
session->drafts[index].name);
return false;
}
}
return true;
}
static TrainlogStatus session_controller_save(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
TrainlogStatus status;
if (!session_controller_actuals_valid(session)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
if (session->correcting) status = persist_draft_replacement(app->database,
session->persisted_session_id, session->drafts, session->draft_count)
? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
else status = persist_new_session_drafts(app->database, session->drafts,
session->draft_count, session->started_at, session->session_type);
if (status == TRAINLOG_STATUS_OK) session_controller_clear_draft(session);
return status;
}
static bool session_controller_generate(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
TrainlogGenerationDatabaseRequest request;
char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogStatus status;
if (session->generation_zone == NULL || session->generation_goal == NULL ||
session->generation_duration_minutes <= 0) return false;
if (session->generation_reference_time[0] == '\0' &&
trainlog_time_now_rfc3339(session->generation_reference_time,
sizeof(session->generation_reference_time)) != TRAINLOG_STATUS_OK) return false;
(void)snprintf(reference_time, sizeof(reference_time), "%s",
session->generation_reference_time);
(void)memset(&request, 0, sizeof(request));
request.zone_id = session->generation_zone->zone_id;
request.goal_id = session->generation_goal->id;
request.duration_minutes = session->generation_duration_minutes;
request.reference_time = reference_time;
status = trainlog_session_generate_from_database(app->database, &request,
&session->generated);
if (status != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.generation.rejected", (int)status);
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
session->phase = TRAINLOG_SESSION_MESSAGE;
return false;
}
if (session->generated.exercise_count == 0U) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.generation.empty");
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
session->phase = TRAINLOG_SESSION_MESSAGE;
return false;
}
if (!generator_prepare_preview(app->database, &session->generated,
session->generated_items)) return false;
session->generated_preview = true;
session->selected = 0U;
session->phase = session->generated.exposure.warning_level ==
TRAINLOG_GENERATION_WARNING_NONE || session->generation_warning_acknowledged
? TRAINLOG_SESSION_GENERATOR_PREVIEW : TRAINLOG_SESSION_GENERATOR_WARNING;
session_controller_sync_durability(session);
return true;
}
static TrainlogStatus session_requalify_history(void *context,
const TrainlogGenerationHistoryRow *row)
{
return trainlog_session_generation_analyzer_accept(
(TrainlogSessionGenerationAnalyzer *)context, row);
}
static bool session_controller_requalify_generated(TrainlogAppContext *app,
size_t selected,
int sets, int repetitions)
{
TrainlogSessionController *session = &app->session;
TrainlogGeneratedExercise *current;
TrainlogGenerationCandidate candidate;
TrainlogGenerationRequest request;
TrainlogSessionGenerationAnalyzer *analyzer = NULL;
TrainlogGeneratedSession result;
const char *secondary[TRAINLOG_GENERATOR_MAX_ZONES];
const char *patterns[TRAINLOG_GENERATOR_MAX_PATTERNS];
const char *sources[TRAINLOG_GENERATOR_MAX_SOURCE_REFS];
size_t index;
TrainlogStatus status;
if (selected >= session->generated.exercise_count ||
session->generation_zone == NULL || session->generation_goal == NULL ||
session->generation_reference_time[0] == '\0') return false;
current = &session->generated.exercises[selected];
for (index = 0U; index < current->secondary_zone_count; ++index)
secondary[index] = current->secondary_zone_ids[index];
for (index = 0U; index < current->pattern_count; ++index)
patterns[index] = current->pattern_ids[index];
for (index = 0U; index < current->source_ref_count; ++index)
sources[index] = current->source_ref_ids[index];
(void)memset(&candidate, 0, sizeof(candidate));
candidate.exercise_id = current->exercise_id;
candidate.equipment_id = current->equipment_id;
candidate.primary_zone_id = current->primary_zone_id;
candidate.secondary_zone_ids = secondary;
candidate.secondary_zone_count = current->secondary_zone_count;
candidate.pattern_ids = patterns;
candidate.pattern_count = current->pattern_count;
candidate.source_ref_ids = sources;
candidate.source_ref_count = current->source_ref_count;
candidate.confidence = current->confidence;
candidate.equipment_load_semantics = current->equipment_load_semantics;
(void)memset(&request, 0, sizeof(request));
request.zone_id = session->generation_zone->zone_id;
request.goal_id = session->generation_goal->id;
request.duration_minutes = session->generation_duration_minutes;
request.reference_time = session->generation_reference_time;
request.candidates = &candidate;
request.candidate_count = 1U;
status = trainlog_session_generation_requalify_dose(&request, 0U, sets,
repetitions, &analyzer);
if (status == TRAINLOG_STATUS_OK)
status = trainlog_database_scan_generation_history(app->database,
session_requalify_history, analyzer);
if (status == TRAINLOG_STATUS_OK)
status = trainlog_session_generation_analyzer_finish(analyzer, &result);
trainlog_session_generation_analyzer_destroy(analyzer);
if (status != TRAINLOG_STATUS_OK || result.exercise_count != 1U) return false;
session->generated.estimated_duration_seconds +=
result.exercises[0].estimated_seconds - current->estimated_seconds;
*current = result.exercises[0];
return true;
}
static bool session_controller_accept_generator(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
if (session->has_draft) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.session.exists.preview");
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
session->phase = TRAINLOG_SESSION_MESSAGE;
return false;
}
if (!generator_build_drafts(app->database, &session->generated, session->drafts))
return false;
session->draft_count = session->generated.exercise_count;
session->has_draft = true;
session->dirty = true;
session->session_type = TRAINLOG_SESSION_TRAINING;
session->correcting = false;
if (trainlog_time_now_rfc3339(session->started_at,
sizeof(session->started_at)) != TRAINLOG_STATUS_OK) return false;
/* CONTRACT: acceptance transfers planning metadata into the ordinary
* draft and creates zero performed sets. */
session_controller_discard_generator(session);
session->phase = TRAINLOG_SESSION_DRAFT;
session->selected = 0U;
session_controller_sync_durability(session);
return true;
}
static const TrainlogAppRoute shell_sections[] = {
TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES,
TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, TRAINLOG_ROUTE_SETTINGS
};
static const char *const shell_section_labels[] = {
"nav.home", "nav.sessions", "nav.exercises", "nav.statistics", "nav.sync",
"nav.settings"
};
static void app_shell_destroy_surfaces(TrainlogAppContext *app)
{
/* INVARIANT: each plane has exactly one owner and destruction proceeds
* from transient/inner content toward persistent outer chrome. */
trainlog_surface_destroy(app->content); app->content = NULL;
trainlog_surface_destroy(app->sidebar); app->sidebar = NULL;
trainlog_surface_destroy(app->footer); app->footer = NULL;
trainlog_surface_destroy(app->header); app->header = NULL;
}
static bool app_shell_layout(TrainlogAppContext *app)
{
trainlog_shell_layout_compute(trainlog_terminal_columns(app->terminal),
trainlog_terminal_rows(app->terminal), &app->layout);
if (!app->layout.usable) { app_shell_destroy_surfaces(app); return true; }
if (app->header == NULL)
app->header = trainlog_surface_create(app->terminal, "trainlog.header",
app->layout.header.y, app->layout.header.x,
app->layout.header.height, app->layout.header.width);
else if (!trainlog_surface_set_rect(app->header, app->layout.header.y,
app->layout.header.x, app->layout.header.height, app->layout.header.width))
return false;
if (app->footer == NULL)
app->footer = trainlog_surface_create(app->terminal, "trainlog.footer",
app->layout.footer.y, app->layout.footer.x,
app->layout.footer.height, app->layout.footer.width);
else if (!trainlog_surface_set_rect(app->footer, app->layout.footer.y,
app->layout.footer.x, app->layout.footer.height, app->layout.footer.width))
return false;
if (app->content == NULL)
app->content = trainlog_surface_create(app->terminal, "trainlog.content",
app->layout.content.y, app->layout.content.x,
app->layout.content.height, app->layout.content.width);
else if (!trainlog_surface_set_rect(app->content, app->layout.content.y,
app->layout.content.x, app->layout.content.height, app->layout.content.width))
return false;
if (app->layout.sidebar_visible && app->sidebar == NULL)
app->sidebar = trainlog_surface_create(app->terminal, "trainlog.sidebar",
app->layout.sidebar.y, app->layout.sidebar.x,
app->layout.sidebar.height, app->layout.sidebar.width);
else if (app->layout.sidebar_visible && !trainlog_surface_set_rect(app->sidebar,
app->layout.sidebar.y, app->layout.sidebar.x,
app->layout.sidebar.height, app->layout.sidebar.width)) return false;
else if (!app->layout.sidebar_visible && app->sidebar != NULL) {
trainlog_surface_destroy(app->sidebar); app->sidebar = NULL;
/* CONTRACT: compact Navigation replaces the disappearing sidebar;
* retain both its selected section and its owned focus. */
}
return app->header != NULL && app->footer != NULL && app->content != NULL &&
(!app->layout.sidebar_visible || app->sidebar != NULL);
}
static void app_shell_add_action(TrainlogAppContext *app, const char *identifier,
int key, const char *label, unsigned priority,
TrainlogIntent intent, TrainlogAppRoute route)
{
TrainlogAction action = {identifier, key,
trainlog_presentation_source(label), true, priority, intent, route};
(void)trainlog_actions_add(&app->actions, &action);
}
static bool app_shell_is_list_route(TrainlogAppRoute route)
{
return route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_EQUIPMENT ||
route == TRAINLOG_ROUTE_SESSIONS_COMPLETED ||
route == TRAINLOG_ROUTE_STATS_EXERCISE || route == TRAINLOG_ROUTE_MAX ||
route == TRAINLOG_ROUTE_BODY;
}
static void app_shell_refresh_list(TrainlogAppContext *app)
{
TrainlogAppRoute route = app->navigation.current.route;
size_t index;
size_t count = 0U;
size_t capacity = 0U;
bool source_capped = false;
app->list_error = false;
if (route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_STATS_EXERCISE ||
route == TRAINLOG_ROUTE_MAX) {
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U] = "";
const char *zone_id = NULL;
size_t zone_count = trainlog_body_zone_catalog_count();
bool unclassified = app->exercise_zone_filter == (int)zone_count;
if (app->search.bytes > 0U && trainlog_catalog_normalize_name(app->search.text,
normalized, sizeof(normalized)) != TRAINLOG_STATUS_OK) normalized[0] = '\0';
if (app->exercise_zone_filter >= 0 &&
app->exercise_zone_filter < (int)zone_count) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(
(size_t)app->exercise_zone_filter);
if (zone != NULL) zone_id = zone->zone_id;
}
if (trainlog_database_list_exercises_filtered(app->database, normalized,
zone_id, true, false, unclassified, app->exercises,
MAX_EXERCISES, &count) != TRAINLOG_STATUS_OK) app->list_error = true;
capacity = MAX_EXERCISES;
source_capped = count == capacity;
if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
app->exercise_list_statistics_count = 0U;
app->exercise_list_statistics_partial = false;
if (trainlog_statistics_list_exercises(app->database,
app->exercise_list_statistics, MAX_EXERCISES,
&app->exercise_list_statistics_count,
&app->exercise_list_statistics_partial) != TRAINLOG_STATUS_OK)
app->list_error = true;
source_capped = source_capped || app->exercise_list_statistics_partial;
}
if (route == TRAINLOG_ROUTE_MAX) {
size_t write = 0U;
app->max_list_item_count = 0U;
app->max_list_partial = false;
if (trainlog_statistics_list_maxima(app->database,
app->max_list_items, MAX_EXERCISES,
&app->max_list_item_count, &app->max_list_partial) !=
TRAINLOG_STATUS_OK) app->list_error = true;
for (index = 0U; index < count; ++index) {
bool measured = false;
for (size_t maximum = 0U; maximum < app->max_list_item_count;
++maximum)
if (strcmp(app->exercises[index].exercise_id,
app->max_list_items[maximum].exercise_id) == 0) {
measured = true; break;
}
if ((app->max_show_all && app->exercises[index].recording_mode ==
TRAINLOG_RECORDING_SETS) || measured)
app->exercises[write++] = app->exercises[index];
}
count = write;
source_capped = source_capped || app->max_list_partial;
}
for (index = 0U; index < count; ++index)
app->stable_ids[index] = app->exercises[index].exercise_id;
} else if (route == TRAINLOG_ROUTE_EQUIPMENT) {
bool truncated = false;
bool failed = false;
count = equipment_collect(app->database, app->search.text,
app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]),
&truncated, &failed);
capacity = sizeof(app->equipment) / sizeof(app->equipment[0]);
source_capped = truncated;
app->list_error = failed;
for (index = 0U; index < count; ++index)
app->stable_ids[index] = app->equipment[index].equipment_id;
} else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) {
TrainlogSessionSummary all[MAX_SESSIONS];
size_t all_count = 0U;
if (trainlog_database_list_sessions(app->database, all, MAX_SESSIONS,
&all_count) != TRAINLOG_STATUS_OK) app->list_error = true;
capacity = MAX_SESSIONS;
source_capped = all_count == capacity;
for (index = 0U; index < all_count && count < MAX_SESSIONS; ++index) {
char date[17];
const char *type = session_type_history_label(all[index].session_type);
session_history_datetime(all[index].started_at, date);
if (app->search.bytes == 0U || equipment_text_matches(date, app->search.text) ||
equipment_text_matches(type, app->search.text)) {
app->sessions[count] = all[index];
app->stable_ids[count] = app->sessions[count].session_id;
++count;
}
}
} else if (route == TRAINLOG_ROUTE_BODY) {
if (trainlog_database_list_body_observations(app->database,
app->body_records, MAX_BODY_OBSERVATIONS, &count) != TRAINLOG_STATUS_OK)
app->list_error = true;
capacity = MAX_BODY_OBSERVATIONS;
source_capped = count == capacity;
for (index = 0U; index < count; ++index)
app->stable_ids[index] = app->body_records[index].observation_id;
}
app->loaded_count = count;
trainlog_list_set_items(&app->list, app->stable_ids, count,
app->layout.content.height > 8 ? (size_t)(app->layout.content.height - 8) : 1U,
!source_capped, source_capped);
}
static void app_shell_load_body_metric(TrainlogAppContext *app)
{
app->body_metric_count = 0U;
app->body_snapshot_error =
trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[app->body_metric_selected].metric,
app->body_metric_points, MAX_BODY_METRIC_POINTS,
&app->body_metric_count) != TRAINLOG_STATUS_OK;
app->body_metric_partial = app->body_metric_count == MAX_BODY_METRIC_POINTS;
}
static void app_shell_load_body_global(TrainlogAppContext *app)
{
size_t metric;
app->body_global_date_count = 0U;
app->body_snapshot_error = false;
app->body_global_partial = false;
if (!app->body_global_initialized) {
for (metric = 0U; metric < 14U; ++metric)
app->body_global_enabled[metric] = true;
app->body_global_initialized = true;
}
(void)memset(app->body_global_series, 0,
sizeof(app->body_global_series));
(void)memset(app->body_global_dates, 0, sizeof(app->body_global_dates));
for (metric = 0U; metric < 14U; ++metric) {
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
size_t point;
series->symbol = GLOBAL_BODY_SYMBOLS[metric];
series->role = GLOBAL_BODY_ROLES[metric];
if (trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[metric].metric, series->points,
MAX_BODY_METRIC_POINTS, &series->count) != TRAINLOG_STATUS_OK) {
app->body_snapshot_error = true;
series->count = 0U;
continue;
}
if (series->count == MAX_BODY_METRIC_POINTS)
app->body_global_partial = true;
if (series->count == 0U) continue;
series->baseline = series->points[0].value;
(void)trainlog_body_percent_change(series->baseline,
series->points[series->count - 1U].value,
&series->latest_percent);
for (point = 0U; point < series->count; ++point)
if (!global_date_add(app->body_global_dates,
&app->body_global_date_count,
series->points[point].observed_at))
app->body_snapshot_error = true;
}
if (app->body_global_date_count > 0U)
qsort(app->body_global_dates, app->body_global_date_count,
sizeof(app->body_global_dates[0]), global_date_compare);
}
static void app_shell_select_available_body_metric(TrainlogAppContext *app)
{
size_t metric;
/* CONTRACT: the profile dashboard offers only measurements backed by at
* least one real observation. Weight is the deterministic first choice;
* otherwise BODY_METRICS catalogue order is the stable fallback. */
if (app->body_metric_selected < 14U &&
app->body_global_series[app->body_metric_selected].count > 0U) return;
app->body_metric_selected = 0U;
for (metric = 0U; metric < 14U; ++metric) {
if (app->body_global_series[metric].count > 0U) {
app->body_metric_selected = metric;
return;
}
}
}
static void app_shell_move_available_body_metric(TrainlogAppContext *app,
int direction)
{
size_t step;
for (step = 0U; step < 14U; ++step) {
app->body_metric_selected = direction < 0
? (app->body_metric_selected + 13U) % 14U
: (app->body_metric_selected + 1U) % 14U;
if (app->body_global_series[app->body_metric_selected].count > 0U)
return;
}
}
static void app_shell_load_body_analytics(TrainlogAppContext *app)
{
size_t count = 0U;
app->body_snapshot_error =
trainlog_database_list_body_observations(app->database,
app->body_records, MAX_BODY_OBSERVATIONS,
&count) != TRAINLOG_STATUS_OK;
app->loaded_count = count;
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
}
static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column,
char *output, size_t capacity)
{
const unsigned char *value;
int bytes;
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false;
value = sqlite3_column_text(statement, column);
bytes = sqlite3_column_bytes(statement, column);
if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false;
(void)memcpy(output, value, (size_t)bytes);
output[bytes] = '\0';
return true;
}
/* WHY: the global dashboard compares occurrence-owned facts, while the public
* performance reader deliberately collapses each session to one representative.
* CONTRACT: this private projection emits every performed set, every explicit
* MAX exactly once, and every continuous occurrence exactly once. INVARIANT:
* malformed legacy timestamps are omitted before sorting and reported through
* invalid_data; they can never reach a comparator with uninitialized keys. */
static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
TrainlogDashboardFact *output, size_t capacity, size_t *output_count,
bool *truncated, bool *invalid_data)
{
static const char *const SQL =
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
"ps.weight_kg FROM performed_sets ps "
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0,"
"mr.max_weight_kg FROM max_results mr "
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,"
"ca.duration_seconds,NULL FROM continuous_activity ca "
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (output_count != NULL) *output_count = 0U;
if (truncated != NULL) *truncated = false;
if (invalid_data != NULL) *invalid_data = false;
if (database == NULL || database->connection == NULL || output_count == NULL ||
truncated == NULL || invalid_data == NULL ||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
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) {
TrainlogDashboardFact fact;
const unsigned char *tracking;
const unsigned char *load;
int kind;
(void)memset(&fact, 0, sizeof(fact));
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER ||
!dashboard_copy_sql_text(statement, 1, fact.started_at,
sizeof(fact.started_at)) ||
!dashboard_copy_sql_text(statement, 2, fact.session_id,
sizeof(fact.session_id)) ||
!dashboard_copy_sql_text(statement, 3, fact.entry_id,
sizeof(fact.entry_id)) ||
!dashboard_copy_sql_text(statement, 4, fact.exercise_id,
sizeof(fact.exercise_id)) ||
!dashboard_copy_sql_text(statement, 5, fact.equipment_id,
sizeof(fact.equipment_id)) ||
sqlite3_column_type(statement, 6) != SQLITE_TEXT ||
sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
!trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at),
&fact.time)) {
*invalid_data = true;
continue;
}
kind = sqlite3_column_int(statement, 0);
if (kind < 0 || kind > 2) {
*invalid_data = true;
continue;
}
fact.kind = (TrainlogDashboardFactKind)kind;
tracking = sqlite3_column_text(statement, 6);
load = sqlite3_column_text(statement, 7);
if (strcmp((const char *)tracking, "reps") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_REPS;
else if (strcmp((const char *)tracking, "duration") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_DURATION;
else { *invalid_data = true; continue; }
if (strcmp((const char *)load, "none") == 0)
fact.load_mode = TRAINLOG_LOAD_NONE;
else if (strcmp((const char *)load, "external") == 0)
fact.load_mode = TRAINLOG_LOAD_EXTERNAL;
else if (strcmp((const char *)load, "assistance") == 0)
fact.load_mode = TRAINLOG_LOAD_ASSISTANCE;
else { *invalid_data = true; continue; }
if (fact.kind == TRAINLOG_DASHBOARD_WORK) {
sqlite3_int64 position;
if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER ||
sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
position = sqlite3_column_int64(statement, 8);
if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) {
*invalid_data = true; continue;
}
fact.set_position = (size_t)position;
fact.dose = sqlite3_column_int(statement, 9);
} else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) {
if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
fact.dose = sqlite3_column_int(statement, 9);
}
if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) {
int type = sqlite3_column_type(statement, 10);
if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) {
fact.has_weight = false;
} else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) ||
!isfinite(sqlite3_column_double(statement, 10))) {
*invalid_data = true; continue;
} else {
fact.has_weight = true;
fact.weight_kg = sqlite3_column_double(statement, 10);
}
if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) {
*invalid_data = true; continue;
}
}
if (count == capacity) { *truncated = true; continue; }
output[count++] = fact;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return TRAINLOG_STATUS_OK;
}
static int dashboard_fact_time_compare(const void *left, const void *right)
{
const TrainlogDashboardFact *a = left;
const TrainlogDashboardFact *b = right;
int result = trainlog_timestamp_compare(&a->time, &b->time);
if (result != 0) return result;
result = strcmp(a->session_id, b->session_id);
if (result != 0) return result;
result = strcmp(a->entry_id, b->entry_id);
if (result != 0) return result;
if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1;
return a->set_position == b->set_position ? 0 :
(a->set_position < b->set_position ? -1 : 1);
}
static int dashboard_performance_time_compare(const void *left, const void *right)
{
const TrainlogExercisePerformancePoint *a = left;
const TrainlogExercisePerformancePoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key);
(void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key);
{
int result = trainlog_timestamp_compare(&a_key, &b_key);
return result != 0 ? result : strcmp(a->session_id, b->session_id);
}
}
static int dashboard_body_time_compare(const void *left, const void *right)
{
const TrainlogBodyMetricPoint *a = left;
const TrainlogBodyMetricPoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key);
(void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key);
return trainlog_timestamp_compare(&a_key, &b_key);
}
static int64_t dashboard_floor_div(int64_t value, int64_t divisor)
{
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
}
static int64_t dashboard_period_days(TrainlogStatisticsPeriod period)
{
switch (period) {
case TRAINLOG_STATS_7_DAYS: return 7;
case TRAINLOG_STATS_30_DAYS: return 30;
case TRAINLOG_STATS_90_DAYS: return 90;
case TRAINLOG_STATS_YEAR: return 365;
case TRAINLOG_STATS_ALL: return 0;
}
return 30;
}
static bool dashboard_in_period(const TrainlogTimestampKey *key,
TrainlogStatisticsPeriod period)
{
const int64_t days = dashboard_period_days(period);
/* Unix time and Trainlog's proleptic day zero differ by 719162 days. */
const int64_t now = (int64_t)time(NULL) + INT64_C(719162) * INT64_C(86400);
return key->utc_second <= now &&
(days == 0 || key->utc_second >= now - days * INT64_C(86400));
}
/* WHY: the selected period must change both dashboard charts, while the
* canonical rolling membership test above remains the sole inclusion rule.
* CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day
* and roughly monthly (31-day) presentation buckets; Tout uses at most twelve
* equal day spans covering represented history. INVARIANT: every included
* local date is clamped into exactly one leading-to-current bucket. */
static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard,
int64_t current_day, int64_t earliest_day)
{
size_t count;
int64_t span;
size_t index;
switch (dashboard->period) {
case TRAINLOG_STATS_7_DAYS: count = 7U; span = 1; break;
case TRAINLOG_STATS_30_DAYS: count = 6U; span = 5; break;
case TRAINLOG_STATS_90_DAYS: count = 6U; span = 15; break;
case TRAINLOG_STATS_YEAR: count = 12U; span = 31; break;
case TRAINLOG_STATS_ALL: {
int64_t represented = current_day >= earliest_day
? current_day - earliest_day + 1 : 1;
count = represented < (int64_t)DASHBOARD_BUCKET_COUNT
? (size_t)represented : DASHBOARD_BUCKET_COUNT;
if (count == 0U) count = 1U;
span = (represented + (int64_t)count - 1) / (int64_t)count;
break;
}
}
dashboard->week_count = count;
for (index = 0U; index < count; ++index) {
int64_t distance = (int64_t)(count - 1U - index) * span;
dashboard->weeks[index].start_day = current_day - distance - span + 1;
dashboard->weeks[index].span_days = span;
if (index + 1U == count)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "%s", "actuel");
else if (dashboard->period == TRAINLOG_STATS_7_DAYS)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "J−%zu", count - 1U - index);
else if (dashboard->period == TRAINLOG_STATS_YEAR)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "M−%zu", count - 1U - index);
else
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "P−%zu", count - 1U - index);
}
}
static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard,
int64_t local_day)
{
int64_t first;
int64_t span;
size_t slot;
if (dashboard->week_count == 0U) return 0U;
first = dashboard->weeks[0].start_day;
span = dashboard->weeks[0].span_days;
if (local_day <= first) return 0U;
slot = (size_t)((local_day - first) / span);
return slot < dashboard->week_count ? slot : dashboard->week_count - 1U;
}
static const char *dashboard_period_bucket_summary(TrainlogStatisticsPeriod period)
{
switch (period) {
case TRAINLOG_STATS_7_DAYS: return "7 jours · quotidien";
case TRAINLOG_STATS_30_DAYS: return "30 jours · pas de 5 j";
case TRAINLOG_STATS_90_DAYS: return "90 jours · pas de 15 j";
case TRAINLOG_STATS_YEAR: return "1 an · pas de 31 j";
case TRAINLOG_STATS_ALL: return "Tout · 12 périodes max.";
}
return "Période";
}
static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app,
const TrainlogDashboardFact *fact)
{
TrainlogResolvedEquipment equipment;
if (fact->equipment_id[0] == '\0' ||
trainlog_database_resolve_equipment(app->database, fact->equipment_id,
&equipment) != TRAINLOG_STATUS_OK) return false;
return strcmp(equipment.load_semantics, "external") == 0;
}
static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises,
size_t count, const char *exercise_id)
{
size_t index;
for (index = 0U; index < count; ++index)
if (strcmp(exercises[index].exercise_id, exercise_id) == 0)
return &exercises[index];
return NULL;
}
static int dashboard_zone_bucket_compare(const void *left, const void *right)
{
const TrainlogDashboardZoneBucket *a = left;
const TrainlogDashboardZoneBucket *b = right;
if (a->count != b->count) return a->count > b->count ? -1 : 1;
return strcmp(a->label, b->label);
}
/* WHY: a body-zone dashboard must describe the current catalogue rather than
* infer classifications from history or commercial names. CONTRACT: one
* canonical exercise is counted once, using only its persisted primary body
* zone. INVARIANT: missing primary relations remain visible as "Non classés";
* secondary relations and aliases can neither create nor duplicate a bucket. */
static bool dashboard_load_zone_distribution(TrainlogAppContext *app,
const TrainlogExercise *exercises, size_t exercise_count)
{
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
size_t exercise_index;
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
const char *label = "Non classés";
size_t relation_count = 0U;
size_t relation_index;
size_t bucket;
if (trainlog_database_list_exercise_body_zones(app->database,
exercises[exercise_index].exercise_id, relations, MAX_BODY_ZONES,
&relation_count) != TRAINLOG_STATUS_OK) return false;
for (relation_index = 0U; relation_index < relation_count; ++relation_index) {
if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
relations[relation_index].zone_id);
if (zone != NULL && !zone->is_group) label = zone->display_name;
break;
}
}
for (bucket = 0U; bucket < dashboard->zone_count; ++bucket)
if (strcmp(dashboard->zones[bucket].label, label) == 0) break;
if (bucket == dashboard->zone_count) {
if (bucket == DASHBOARD_ZONE_BUCKET_CAPACITY) return false;
(void)snprintf(dashboard->zones[bucket].label,
sizeof(dashboard->zones[bucket].label), "%s", label);
++dashboard->zone_count;
}
++dashboard->zones[bucket].count;
}
qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]),
dashboard_zone_bucket_compare);
return true;
}
/* WHY: the landing page must remain useful without rendering-time SQL.
* CONTRACT: this bounded controller snapshot contains only actual persisted
* facts. One working-performance series has an exact exercise/equipment/load
* identity; explicit MAX is separate, and assistance/planned targets never
* enter it. All chronology is decided by Trainlog's timestamp parser, not
* SQLite text ordering. */
static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app)
{
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
TrainlogStatisticsPeriod period = dashboard->period;
TrainlogExercise *exercises = NULL;
TrainlogDashboardFact *facts = NULL;
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
TrainlogSessionSummary sessions[MAX_SESSIONS];
TrainlogTimestampKey newest_performance;
char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = "";
size_t exercise_count = 0U;
size_t fact_count = 0U;
size_t session_count = 0U;
size_t exercise_index;
int64_t current_day;
int64_t earliest_day = INT64_MAX;
time_t now;
bool has_newest_performance = false;
bool facts_truncated = false;
bool facts_invalid = false;
(void)memset(dashboard, 0, sizeof(*dashboard));
dashboard->period = period;
facts = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*facts));
if (facts == NULL) {
dashboard->error = true;
return;
}
if (trainlog_database_read_snapshot_begin(app->database) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
free(facts);
return;
}
if (trainlog_database_list_sessions(app->database, sessions, MAX_SESSIONS,
&session_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = session_count == MAX_SESSIONS;
/* CONTRACT: frequency is one count per actual session; a MAX marker is an
* attribute of that count and never a second session. Rolling membership
* uses canonical instants, while buckets use represented local dates.
* Presentation always ends at the canonical current day, so the bucket
* containing that day remains explicit even when it is zero. */
now = time(NULL);
if (now == (time_t)-1) {
dashboard->error = true;
goto finish;
}
current_day = dashboard_floor_div((int64_t)now +
INT64_C(719162) * INT64_C(86400), INT64_C(86400));
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&key, dashboard->period)) continue;
++dashboard->completed_session_count;
if (key.local_day < earliest_day) earliest_day = key.local_day;
}
dashboard_prepare_period_buckets(dashboard, current_day,
earliest_day == INT64_MAX ? current_day : earliest_day);
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
size_t slot;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key) ||
!dashboard_in_period(&key, dashboard->period)) continue;
slot = dashboard_period_bucket(dashboard, key.local_day);
++dashboard->weeks[slot].sessions;
if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST)
++dashboard->weeks[slot].maxima;
}
if (trainlog_database_list_exercises(app->database, NULL, 0U,
&exercise_count) != TRAINLOG_STATUS_OK ||
exercise_count > SIZE_MAX / sizeof(*exercises)) {
dashboard->error = true;
goto finish;
}
if (exercise_count > 0U) {
exercises = calloc(exercise_count, sizeof(*exercises));
if (exercises == NULL || trainlog_database_list_exercises(app->database,
exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
}
if (!dashboard_load_zone_distribution(app, exercises, exercise_count)) {
dashboard->error = true;
goto finish;
}
if (dashboard_database_list_facts(app->database, facts,
DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated,
&facts_invalid) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = dashboard->partial || facts_truncated;
dashboard->invalid_data = dashboard->invalid_data || facts_invalid;
qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare);
for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) {
TrainlogDashboardFact *fact = &facts[exercise_index];
TrainlogExercise *exercise;
bool external;
bool comparable_work;
bool comparable_max;
size_t prior;
bool has_prior = false;
double prior_best = 0.0;
exercise = dashboard_find_exercise(exercises, exercise_count,
fact->exercise_id);
if (!dashboard_in_period(&fact->time, dashboard->period)) continue;
if (fact->kind == TRAINLOG_DASHBOARD_WORK)
++dashboard->performed_set_count;
else if (fact->kind == TRAINLOG_DASHBOARD_MAX)
++dashboard->explicit_max_count;
{
size_t seen;
for (seen = 0U; seen < exercise_index; ++seen)
if (dashboard_in_period(&facts[seen].time, dashboard->period) &&
strcmp(facts[seen].exercise_id, fact->exercise_id) == 0)
break;
if (seen == exercise_index) ++dashboard->distinct_exercise_count;
}
external = dashboard_fact_external_context_qualifies(app, fact);
comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external &&
fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 &&
fact->has_weight && isfinite(fact->weight_kg) &&
fact->weight_kg >= 0.0;
comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external &&
fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0;
/* CONTRACT: Android and TUI compare the same actual observations:
* exact exercise + nonempty resolved external equipment + external
* load semantics + exact performed dose. Only facts at a strictly
* earlier canonical instant establish the prior best; bytewise IDs
* order equal-instant presentation but never manufacture an event. */
if (comparable_work || comparable_max) {
for (prior = 0U; prior < exercise_index; ++prior) {
TrainlogDashboardFact *old = &facts[prior];
bool same = old->kind == fact->kind &&
strcmp(old->exercise_id, fact->exercise_id) == 0 &&
strcmp(old->equipment_id, fact->equipment_id) == 0 &&
old->tracking_mode == fact->tracking_mode &&
old->load_mode == fact->load_mode &&
(fact->kind != TRAINLOG_DASHBOARD_WORK ||
old->dose == fact->dose);
if (!same || trainlog_timestamp_compare(&old->time,
&fact->time) >= 0) continue;
if (!has_prior || old->weight_kg > prior_best)
prior_best = old->weight_kg;
has_prior = true;
}
if (has_prior && fact->weight_kg > prior_best &&
dashboard->week_count > 0U) {
size_t slot = dashboard_period_bucket(dashboard,
fact->time.local_day);
if (comparable_work)
++dashboard->weeks[slot].working_improvements;
else ++dashboard->weeks[slot].max_improvements;
}
}
if (comparable_max && exercise != NULL) {
TrainlogTimestampKey current_max;
int order = 1;
if (dashboard->has_explicit_max &&
trainlog_timestamp_parse(dashboard->max_timestamp,
strlen(dashboard->max_timestamp), &current_max))
order = trainlog_timestamp_compare(&fact->time, &current_max);
if (!dashboard->has_explicit_max || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->max_exercise.exercise_id) < 0)) {
dashboard->has_explicit_max = true;
dashboard->max_exercise = *exercise;
dashboard->max_weight_kg = fact->weight_kg;
(void)snprintf(dashboard->max_timestamp,
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
}
}
if (comparable_work && exercise != NULL) {
int order = has_newest_performance ?
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
if (!has_newest_performance || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->exercise.exercise_id) < 0)) {
dashboard->has_performance = true;
dashboard->exercise = *exercise;
dashboard->performance_load_mode = fact->load_mode;
dashboard->performance_tracking_mode = fact->tracking_mode;
dashboard->performance_dose = fact->dose;
(void)snprintf(dashboard->performance_equipment_id,
sizeof(dashboard->performance_equipment_id), "%s",
fact->equipment_id);
newest_performance = fact->time;
has_newest_performance = true;
}
}
}
if (dashboard->has_performance) {
TrainlogResolvedEquipment equipment;
size_t index;
if (trainlog_database_resolve_equipment(app->database,
dashboard->performance_equipment_id, &equipment) ==
TRAINLOG_STATUS_OK)
(void)snprintf(dashboard->performance_equipment_label,
sizeof(dashboard->performance_equipment_label), "%s",
equipment.display_name);
for (index = 0U; index < fact_count &&
dashboard->performance_count < MAX_SESSIONS; ++index) {
TrainlogDashboardFact *fact = &facts[index];
TrainlogExercisePerformancePoint *point;
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
fact->load_mode != dashboard->performance_load_mode ||
fact->tracking_mode != dashboard->performance_tracking_mode ||
fact->dose != dashboard->performance_dose ||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
strcmp(fact->equipment_id,
dashboard->performance_equipment_id) != 0 ||
!dashboard_in_period(&fact->time, dashboard->period)) continue;
point = &dashboard->performance[dashboard->performance_count++];
(void)memset(point, 0, sizeof(*point));
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
fact->session_id);
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
fact->started_at);
point->tracking_mode = fact->tracking_mode;
point->load_mode = fact->load_mode;
point->actual_set_count = 1U;
point->has_performance = 1;
point->metric_value = fact->dose;
point->has_weight = 1;
point->weight_kg = fact->weight_kg;
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
fact->equipment_id);
}
if (dashboard->performance_count == MAX_SESSIONS)
dashboard->partial = true;
qsort(dashboard->performance, dashboard->performance_count,
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
}
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[exercise_index].metric, body,
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
continue;
}
if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey candidate;
TrainlogTimestampKey current;
int order = 1;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &candidate)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&candidate, dashboard->period)) continue;
if (newest_body[0] != '\0') {
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), &current);
order = trainlog_timestamp_compare(&candidate, &current);
}
if (newest_body[0] == '\0' || order > 0 ||
(order == 0 && exercise_index < dashboard->body_metric)) {
dashboard->has_body = true;
dashboard->body_metric = exercise_index;
(void)snprintf(newest_body, sizeof(newest_body), "%s",
body[point_index].observed_at);
}
}
}
if (dashboard->has_body) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[dashboard->body_metric].metric, body,
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
if (count == MAX_BODY_METRIC_POINTS)
dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (dashboard_in_period(&key, dashboard->period))
dashboard->body[dashboard->body_count++] = body[point_index];
}
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
dashboard_body_time_compare);
}
finish:
if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) !=
TRAINLOG_STATUS_OK) dashboard->error = true;
free(exercises);
free(facts);
}
static void sync_controller_load_history(TrainlogSyncController *sync)
{
char selected_id[TRAINLOG_ID_MAX + 1U] = "";
size_t index;
if (sync->selected < sync->history_count)
(void)snprintf(selected_id, sizeof(selected_id), "%s",
sync->history[sync->selected].sync_id);
sync_history_load(sync->history, SYNC_HISTORY_CAPACITY,
&sync->history_count);
if (sync->history_count == 0U) sync->selected = 0U;
else if (selected_id[0] != '\0') {
for (index = 0U; index < sync->history_count; ++index)
if (strcmp(sync->history[index].sync_id, selected_id) == 0) {
sync->selected = index;
break;
}
if (index == sync->history_count && sync->selected >= sync->history_count)
sync->selected = sync->history_count - 1U;
} else if (sync->selected >= sync->history_count)
sync->selected = sync->history_count - 1U;
}
static void sync_controller_open_detail(TrainlogSyncController *sync)
{
const TrainlogSyncHistoryEntry *entry;
char *cursor;
sync->detail_line_count = 0U;
sync->detail_scroll = 0U;
sync->showing_detail = false;
if (sync->history_count == 0U || sync->selected >= sync->history_count)
return;
entry = &sync->history[sync->selected];
if (entry->sync_id[0] == '\0') return;
/* WHY: history artifacts intentionally retain byte-exact diagnostics for
* logs and support, but arbitrary stored prose is not localized UI copy.
* CONTRACT: normal history presents only parsed metadata and labels old
* directional records neutrally; the files themselves remain untouched.
* INVARIANT: no history summary or run-detail byte reaches this surface. */
(void)trainlog_presentation_format_key(sync->detail_text,
sizeof(sync->detail_text),
entry->direction_known &&
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL
? "sync.history.legacy" : "sync.history.entry",
entry->timestamp,
trainlog_presentation_text(entry->success
? "sync.state.success" : "sync.state.failure"));
cursor = sync->detail_text;
sync->detail_lines[sync->detail_line_count++] = cursor;
while (*cursor != '\0' &&
sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) {
if (*cursor == '\n') {
*cursor = '\0';
if (cursor[1] != '\0')
sync->detail_lines[sync->detail_line_count++] = cursor + 1;
}
++cursor;
}
sync->showing_detail = true;
}
static const char *sync_controller_status_key(TrainlogStatus status)
{
switch (status) {
case TRAINLOG_STATUS_CONFLICT:
return "sync.result.failure.conflict";
case TRAINLOG_STATUS_NOT_FOUND:
return "sync.result.failure.not_found";
case TRAINLOG_STATUS_INVALID_ARGUMENT:
return "sync.result.failure.invalid";
case TRAINLOG_STATUS_DATABASE_ERROR:
return "sync.result.failure.database";
case TRAINLOG_STATUS_SCHEMA_UNSUPPORTED:
return "sync.result.failure.version";
case TRAINLOG_STATUS_SYSTEM_ERROR:
return "sync.result.failure.system";
case TRAINLOG_STATUS_OK:
default:
return "sync.result.failure.no_report";
}
}
static void sync_controller_present_result(const TrainlogSyncController *sync,
char *output, size_t capacity)
{
const TrainlogSyncReport *report;
if (sync == NULL || output == NULL || capacity == 0U) return;
report = &sync->report;
/* CONTRACT: success requires agreement between the typed engine status and
* report flag. No diagnostic string is inspected, translated, or copied. */
if (sync->latest_status != TRAINLOG_STATUS_OK || !report->success) {
(void)snprintf(output, capacity, "%s",
trainlog_presentation_text(
sync_controller_status_key(sync->latest_status)));
return;
}
(void)trainlog_presentation_format_key(output, capacity,
"sync.result.success.counts",
trainlog_sync_screen_direction_label(report->direction),
report->exercises_imported, report->exercises_reconciled,
report->exercises_skipped, report->sessions_imported,
report->sessions_skipped, report->body_imported,
report->body_skipped, report->equipment_definitions_imported,
report->equipment_definitions_skipped, report->catalog_published);
}
static void app_shell_open_exercise_analytics(TrainlogAppContext *app,
TrainlogAppRoute route)
{
app->performance_count = 0U;
app->performance_error = trainlog_database_list_exercise_performance(
app->database, app->exercise_detail.exercise_id,
app->performance_points, MAX_SESSIONS, &app->performance_count) !=
TRAINLOG_STATUS_OK;
if (!app->performance_error && route == TRAINLOG_ROUTE_EXERCISE_MAX)
app->performance_error = trainlog_measured_max_summarize(
app->performance_points, app->performance_count,
&app->max_summary) != TRAINLOG_STATUS_OK;
if (route == TRAINLOG_ROUTE_EXERCISE_MAX)
app_shell_load_exercise_statistics(app);
(void)trainlog_navigation_open(&app->navigation, route,
app->exercise_detail.exercise_id);
app->content_scroll = 0U;
}
static void equipment_controller_start(TrainlogAppContext *app)
{
TrainlogEquipmentController *controller = &app->equipment_controller;
(void)memset(controller, 0, sizeof(*controller));
controller->phase = TRAINLOG_EQUIPMENT_NAME;
controller->return_phase = TRAINLOG_EQUIPMENT_NAME;
controller->load_choice = 2;
trainlog_form_init(&controller->form, "");
app->focus = TRAINLOG_FOCUS_EDITOR;
}
static void equipment_controller_open_field(TrainlogEquipmentController *controller,
TrainlogEquipmentPhase phase,
const char *value)
{
controller->phase = phase;
controller->return_phase = phase;
controller->message[0] = '\0';
trainlog_form_init(&controller->form, value);
}
static bool equipment_controller_save(TrainlogAppContext *app)
{
TrainlogEquipmentController *controller = &app->equipment_controller;
TrainlogStatus status;
generate_custom_equipment_uuid(controller->pending.equipment_id);
(void)snprintf(controller->pending.load_semantics,
sizeof(controller->pending.load_semantics), "%s",
controller->load_choice == 1 ? "none" :
controller->load_choice == 2 ? "external" : "assistance");
status = trainlog_database_create_custom_equipment(app->database,
&controller->pending);
if (status != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT
? "msg.equipment.conflict" : "msg.equipment.invalid");
controller->phase = TRAINLOG_EQUIPMENT_LOAD;
return false;
}
controller->dirty = false;
controller->form.active = false;
controller->phase = TRAINLOG_EQUIPMENT_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.equipment.created",
controller->pending.display_name);
app_shell_refresh_list(app);
if (!copy_ui_stable_id(app->list.selected_id, sizeof(app->list.selected_id),
controller->pending.equipment_id)) return false;
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
app->list.visible_rows, app->list.total_known, app->list.more_available);
return true;
}
static bool app_shell_dispatch_equipment_controller(TrainlogAppContext *app,
int key)
{
TrainlogEquipmentController *controller = &app->equipment_controller;
TrainlogFormResult result;
if (controller->phase == TRAINLOG_EQUIPMENT_IDLE) return false;
if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
key == TRAINLOG_KEY_ESCAPE) {
controller->phase = TRAINLOG_EQUIPMENT_IDLE;
controller->message[0] = '\0';
app->focus = TRAINLOG_FOCUS_CONTENT;
}
return true;
}
if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
if (key == '1') {
bool leaving = controller->leaving;
TrainlogAppRoute pending_route = controller->pending_route;
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
if (leaving) app_shell_open_route(app, pending_route);
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
controller->phase = controller->return_phase;
return true;
}
if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) {
if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_LEFT)
controller->load_choice = controller->load_choice > 1
? controller->load_choice - 1 : 3;
else if (key == TRAINLOG_KEY_DOWN || key == TRAINLOG_KEY_RIGHT)
controller->load_choice = controller->load_choice < 3
? controller->load_choice + 1 : 1;
else if (key >= '1' && key <= '3')
controller->load_choice = key - '0';
else if (key == TRAINLOG_KEY_ENTER || key == '\n')
(void)equipment_controller_save(app);
else if (key == TRAINLOG_KEY_ESCAPE) {
controller->leaving = false;
controller->return_phase = TRAINLOG_EQUIPMENT_LOAD;
controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
} else if (key == TRAINLOG_KEY_F6)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
else if (key == TRAINLOG_KEY_F7)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
result = trainlog_form_handle(&controller->form, key);
if (result == TRAINLOG_FORM_OPEN_NAVIGATION) {
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
return true;
}
if (result == TRAINLOG_FORM_OPEN_ACTIONS) {
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
if (result == TRAINLOG_FORM_CANCEL) {
controller->leaving = false;
controller->return_phase = controller->phase;
controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
return true;
}
if (result == TRAINLOG_FORM_EDITED) {
controller->dirty = true;
controller->message[0] = '\0';
return true;
}
if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT)
return true;
if ((controller->phase == TRAINLOG_EQUIPMENT_NAME ||
controller->phase == TRAINLOG_EQUIPMENT_TYPE) &&
controller->form.text[0] == '\0') {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.field.required");
return true;
}
if (controller->phase == TRAINLOG_EQUIPMENT_NAME) {
(void)snprintf(controller->pending.display_name,
sizeof(controller->pending.display_name), "%s", controller->form.text);
equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_LABEL,
controller->pending.label_name);
} else if (controller->phase == TRAINLOG_EQUIPMENT_LABEL) {
(void)snprintf(controller->pending.label_name,
sizeof(controller->pending.label_name), "%s", controller->form.text);
equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_TYPE,
controller->pending.equipment_type);
} else {
(void)snprintf(controller->pending.equipment_type,
sizeof(controller->pending.equipment_type), "%s", controller->form.text);
controller->form.active = false;
controller->phase = TRAINLOG_EQUIPMENT_LOAD;
}
return true;
}
static void exercise_controller_start_create(TrainlogAppContext *app,
bool inline_session)
{
TrainlogExerciseController *controller = &app->exercise_controller;
(void)memset(controller, 0, sizeof(*controller));
controller->phase = TRAINLOG_EXERCISE_NAME;
controller->return_phase = TRAINLOG_EXERCISE_NAME;
controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS;
controller->pending.recording_mode = TRAINLOG_RECORDING_SETS;
controller->inline_session = inline_session;
trainlog_form_init(&controller->form, "");
app->focus = TRAINLOG_FOCUS_EDITOR;
}
static void exercise_controller_start_edit(TrainlogAppContext *app)
{
TrainlogExerciseController *controller = &app->exercise_controller;
(void)memset(controller, 0, sizeof(*controller));
controller->pending = app->exercise_detail;
controller->original = app->exercise_detail;
controller->editing = true;
controller->phase = TRAINLOG_EXERCISE_NAME;
controller->return_phase = TRAINLOG_EXERCISE_NAME;
if (!load_exercise_body_zones(app->database,
controller->pending.exercise_id, controller->primary_zone,
controller->secondary_zones, &controller->secondary_count)) {
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.zones.read");
} else trainlog_form_init(&controller->form, controller->pending.name);
app->focus = TRAINLOG_FOCUS_EDITOR;
}
static bool exercise_merge_matches(const TrainlogExercise *exercise,
const char *query)
{
char normalized_name[(TRAINLOG_NAME_MAX * 4U) + 1U];
char normalized_query[(TRAINLOG_NAME_MAX * 4U) + 1U];
if (query == NULL || query[0] == '\0') return true;
if (trainlog_catalog_normalize_name(exercise->name, normalized_name,
sizeof(normalized_name)) != TRAINLOG_STATUS_OK ||
trainlog_catalog_normalize_name(query, normalized_query,
sizeof(normalized_query)) != TRAINLOG_STATUS_OK) return false;
return strstr(normalized_name, normalized_query) != NULL;
}
static size_t exercise_merge_match_count(const TrainlogExerciseController *controller)
{
size_t index, count = 0U;
for (index = 0U; index < controller->merge_count; ++index)
if (exercise_merge_matches(&controller->merge_candidates[index],
controller->form.text)) ++count;
return count;
}
static TrainlogExercise *exercise_merge_match_at(TrainlogExerciseController *controller,
size_t selected)
{
size_t index, match = 0U;
for (index = 0U; index < controller->merge_count; ++index) {
if (!exercise_merge_matches(&controller->merge_candidates[index],
controller->form.text)) continue;
if (match == selected) return &controller->merge_candidates[index];
++match;
}
return NULL;
}
static bool exercise_merge_candidate_is_compatible(
TrainlogDatabase *database,
const TrainlogExercise *source,
const TrainlogExercise *target
)
{
TrainlogExerciseBodyZone source_zones[MAX_BODY_ZONES];
TrainlogExerciseBodyZone target_zones[MAX_BODY_ZONES];
size_t source_count = 0U, target_count = 0U, index;
const char *source_primary = NULL, *target_primary = NULL;
if (source->tracking_mode != target->tracking_mode ||
source->recording_mode != target->recording_mode ||
source->data_fields != target->data_fields) return false;
if (trainlog_database_list_exercise_body_zones(database,
source->exercise_id, source_zones, MAX_BODY_ZONES,
&source_count) != TRAINLOG_STATUS_OK ||
trainlog_database_list_exercise_body_zones(database,
target->exercise_id, target_zones, MAX_BODY_ZONES,
&target_count) != TRAINLOG_STATUS_OK) return false;
for (index = 0U; index < source_count; ++index)
if (source_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
source_primary = source_zones[index].zone_id;
for (index = 0U; index < target_count; ++index)
if (target_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
target_primary = target_zones[index].zone_id;
return source_primary == NULL || target_primary == NULL ||
strcmp(source_primary, target_primary) == 0;
}
static void exercise_controller_start_merge(TrainlogAppContext *app)
{
TrainlogExerciseController *controller = &app->exercise_controller;
TrainlogExercise all[MAX_EXERCISES];
size_t index, count = 0U;
TrainlogStatus status = trainlog_database_list_exercises(app->database, all,
MAX_EXERCISES, &count);
(void)memset(controller, 0, sizeof(*controller));
controller->merge_source = app->exercise_detail;
if (status != TRAINLOG_STATUS_OK) {
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.merge.targets");
} else {
for (index = 0U; index < count; ++index)
if (strcmp(all[index].exercise_id,
controller->merge_source.exercise_id) != 0 &&
exercise_merge_candidate_is_compatible(app->database,
&controller->merge_source, &all[index]))
controller->merge_candidates[controller->merge_count++] = all[index];
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
trainlog_form_init(&controller->form, "");
}
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_EXERCISE_MERGE,
TRAINLOG_FOCUS_CONTENT, controller->merge_source.exercise_id);
app->focus = TRAINLOG_FOCUS_OVERLAY;
}
static bool exercise_controller_save(TrainlogAppContext *app)
{
TrainlogExerciseController *controller = &app->exercise_controller;
const char *secondary_ids[MAX_BODY_ZONES];
TrainlogExercise created;
TrainlogStatus status;
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
size_t index;
if (controller->editing && !controller->profile_confirmed &&
(controller->pending.tracking_mode != controller->original.tracking_mode ||
controller->pending.recording_mode != controller->original.recording_mode ||
controller->pending.data_fields != controller->original.data_fields)) {
controller->phase = TRAINLOG_EXERCISE_CONFIRM_PROFILE;
return false;
}
if (!controller->editing &&
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
controller->primary_zone[0] == '\0') {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.zone.required");
return false;
}
for (index = 0U; index < controller->secondary_count; ++index)
secondary_ids[index] = controller->secondary_zones[index];
if (controller->editing) {
status = trainlog_catalog_normalize_name(controller->pending.name,
normalized, sizeof(normalized));
if (status == TRAINLOG_STATUS_OK)
status = trainlog_database_update_exercise_profiled(app->database,
controller->pending.exercise_id, controller->pending.name,
normalized, controller->pending.tracking_mode,
controller->pending.recording_mode,
controller->pending.data_fields,
controller->primary_zone[0] != '\0'
? controller->primary_zone : NULL,
secondary_ids, controller->secondary_count);
created = controller->pending;
} else status = trainlog_catalog_create_exercise_profiled_with_zones(
app->database, controller->pending.name,
controller->pending.tracking_mode,
controller->pending.recording_mode,
controller->pending.data_fields,
controller->primary_zone[0] != '\0' ? controller->primary_zone : NULL,
secondary_ids, controller->secondary_count, &created);
if (status != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT
? "msg.exercise.conflict" : "msg.exercise.save.fail");
return false;
}
controller->dirty = false;
controller->form.active = false;
controller->pending = created;
if (controller->inline_session) {
TrainlogSessionController *session = &app->session;
if (!session_controller_load_picker(app)) {
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.exercise.reload");
return true;
}
for (index = 0U; index < session->picker_count; ++index)
if (strcmp(session->picker[index].exercise_id,
created.exercise_id) == 0) {
session->picker_selected = index;
break;
}
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
return true;
}
if (controller->editing) {
app->exercise_detail = created;
app_shell_load_exercise_detail_metadata(app);
}
else {
app_shell_refresh_list(app);
if (!copy_ui_stable_id(app->list.selected_id,
sizeof(app->list.selected_id), created.exercise_id)) return false;
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
app->list.visible_rows, app->list.total_known,
app->list.more_available);
}
controller->phase = TRAINLOG_EXERCISE_MESSAGE;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.exercise.result",
trainlog_presentation_text(controller->editing
? "msg.exercise.edited" : "msg.exercise.created"), created.name);
return true;
}
static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app,
int key)
{
TrainlogExerciseController *controller = &app->exercise_controller;
TrainlogFormResult result;
size_t zone_count = trainlog_body_zone_catalog_count();
if (controller->phase == TRAINLOG_EXERCISE_IDLE) return false;
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
key == TRAINLOG_KEY_ESCAPE) {
(void)memset(controller, 0, sizeof(*controller));
app->focus = TRAINLOG_FOCUS_CONTENT;
}
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
if (key == '1') {
TrainlogSessionController *session = &app->session;
bool inline_session = controller->inline_session;
bool leaving = controller->leaving;
TrainlogAppRoute pending_route = controller->pending_route;
(void)memset(controller, 0, sizeof(*controller));
if (inline_session && !leaving)
session->phase = TRAINLOG_SESSION_EXERCISE_PICKER;
app->focus = TRAINLOG_FOCUS_CONTENT;
if (leaving) app_shell_open_route(app, pending_route);
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE)
controller->phase = controller->return_phase;
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
if (key == 'y' || key == 'Y' || key == '1') {
controller->profile_confirmed = true;
controller->phase = TRAINLOG_EXERCISE_ZONES;
(void)exercise_controller_save(app);
} else if (key == 'n' || key == 'N' || key == '0' ||
key == TRAINLOG_KEY_ESCAPE || key == 'q' || key == 'Q') {
controller->phase = TRAINLOG_EXERCISE_ZONES;
}
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
const TrainlogBodyZone *zone = zone_count > 0U
? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL;
size_t secondary_index;
if (key == TRAINLOG_KEY_UP && controller->zone_selected > 0U)
--controller->zone_selected;
else if (key == TRAINLOG_KEY_DOWN &&
controller->zone_selected + 1U < zone_count)
++controller->zone_selected;
else if ((key == 'n' || key == 'N')) {
controller->primary_zone[0] = '\0';
controller->secondary_count = 0U;
controller->dirty = true;
} else if ((key == 'p' || key == 'P') && zone != NULL && !zone->is_group) {
(void)snprintf(controller->primary_zone,
sizeof(controller->primary_zone), "%s", zone->zone_id);
if (secondary_zone_contains(controller->secondary_zones,
controller->secondary_count, zone->zone_id,
&secondary_index)) {
for (size_t move = secondary_index;
move + 1U < controller->secondary_count; ++move)
(void)memcpy(controller->secondary_zones[move],
controller->secondary_zones[move + 1U],
sizeof(controller->secondary_zones[move]));
--controller->secondary_count;
}
controller->dirty = true;
} else if (key == ' ' && zone != NULL && !zone->is_group &&
controller->primary_zone[0] != '\0' &&
strcmp(controller->primary_zone, zone->zone_id) != 0) {
if (secondary_zone_contains(controller->secondary_zones,
controller->secondary_count, zone->zone_id,
&secondary_index)) {
for (size_t move = secondary_index;
move + 1U < controller->secondary_count; ++move)
(void)memcpy(controller->secondary_zones[move],
controller->secondary_zones[move + 1U],
sizeof(controller->secondary_zones[move]));
--controller->secondary_count;
} else if (controller->secondary_count < MAX_BODY_ZONES) {
(void)snprintf(controller->secondary_zones[
controller->secondary_count], TRAINLOG_ZONE_ID_MAX + 1U,
"%s", zone->zone_id);
++controller->secondary_count;
}
controller->dirty = true;
} else if (key == TRAINLOG_KEY_ENTER || key == '\n')
(void)exercise_controller_save(app);
else if (key == TRAINLOG_KEY_ESCAPE) {
controller->leaving = false;
controller->return_phase = TRAINLOG_EXERCISE_ZONES;
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
} else if (key == TRAINLOG_KEY_F6)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
else if (key == TRAINLOG_KEY_F7)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
if (controller->phase == TRAINLOG_EXERCISE_TRACKING ||
controller->phase == TRAINLOG_EXERCISE_RECORDING ||
controller->phase == TRAINLOG_EXERCISE_SPEED ||
controller->phase == TRAINLOG_EXERCISE_DISTANCE) {
bool directional = key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN ||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT;
if (directional || key == '0' || key == '1' || key == '2') {
if (controller->phase == TRAINLOG_EXERCISE_TRACKING) {
if (key == '1') controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS;
else if (key == '2') controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
else controller->pending.tracking_mode =
controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS
? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS;
} else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) {
if (key == '1') controller->pending.recording_mode = TRAINLOG_RECORDING_SETS;
else if (key == '2') controller->pending.recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
else controller->pending.recording_mode =
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS
? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS;
} else if (controller->phase == TRAINLOG_EXERCISE_SPEED) {
if (key == '0') controller->pending.data_fields &=
~TRAINLOG_EXERCISE_DATA_SPEED_KMH;
else if (key == '1') controller->pending.data_fields |=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
else controller->pending.data_fields ^=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
} else {
if (key == '0') controller->pending.data_fields &=
~TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
else if (key == '1') controller->pending.data_fields |=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
else controller->pending.data_fields ^=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
}
controller->dirty = true;
} else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
if (controller->phase == TRAINLOG_EXERCISE_TRACKING)
controller->phase = TRAINLOG_EXERCISE_RECORDING;
else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) {
if (controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
controller->phase = TRAINLOG_EXERCISE_SPEED;
} else {
controller->pending.data_fields = 0U;
controller->phase = TRAINLOG_EXERCISE_ZONES;
}
} else if (controller->phase == TRAINLOG_EXERCISE_SPEED)
controller->phase = TRAINLOG_EXERCISE_DISTANCE;
else controller->phase = TRAINLOG_EXERCISE_ZONES;
} else if (key == TRAINLOG_KEY_ESCAPE) {
controller->return_phase = controller->phase;
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
} else if (key == TRAINLOG_KEY_F6)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, app->focus,
app->navigation.current.stable_id);
else if (key == TRAINLOG_KEY_F7)
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_ACTIONS, app->focus,
app->navigation.current.stable_id);
return true;
}
result = trainlog_form_handle(&controller->form, key);
if (result == TRAINLOG_FORM_OPEN_NAVIGATION ||
result == TRAINLOG_FORM_OPEN_ACTIONS) {
(void)trainlog_overlays_push(&app->overlays,
result == TRAINLOG_FORM_OPEN_NAVIGATION
? TRAINLOG_OVERLAY_NAVIGATION : TRAINLOG_OVERLAY_ACTIONS,
app->focus, app->navigation.current.stable_id);
} else if (result == TRAINLOG_FORM_CANCEL) {
controller->leaving = false;
controller->return_phase = TRAINLOG_EXERCISE_NAME;
controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
} else if (result == TRAINLOG_FORM_EDITED) {
controller->dirty = true;
controller->message[0] = '\0';
} else if (result == TRAINLOG_FORM_SUBMIT || result == TRAINLOG_FORM_NEXT) {
if (controller->form.text[0] == '\0')
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.name.required");
else {
(void)snprintf(controller->pending.name,
sizeof(controller->pending.name), "%s", controller->form.text);
controller->form.active = false;
controller->phase = controller->editing
? TRAINLOG_EXERCISE_ZONES : TRAINLOG_EXERCISE_TRACKING;
}
}
return true;
}
static void app_shell_release_session_detail(TrainlogAppContext *app)
{
trainlog_database_free_session_details(app->session_entries,
app->session_entry_count);
(void)memset(app->session_entries, 0, sizeof(app->session_entries));
app->session_entry_count = 0U;
free(app->exercise_feedback);
free(app->session_followups);
app->exercise_feedback = NULL;
app->session_followups = NULL;
app->exercise_feedback_count = 0U;
app->session_followup_count = 0U;
}
static void app_shell_open_session_detail(TrainlogAppContext *app,
const char *session_id)
{
app_shell_release_session_detail(app);
app->session_detail_error = trainlog_database_get_session_details(app->database,
session_id, &app->session_detail, app->session_entries,
MAX_SESSION_EXERCISES, &app->session_entry_count) != TRAINLOG_STATUS_OK;
if (!app->session_detail_error) {
/* Resource bound matches the strict companion cardinality. The cache
* is detail-owned and released on every navigation replacement. */
app->exercise_feedback = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
sizeof(*app->exercise_feedback));
app->session_followups = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
sizeof(*app->session_followups));
if (app->exercise_feedback == NULL || app->session_followups == NULL)
app->session_detail_error = true;
}
if (!app->session_detail_error) {
app->session_detail_error = trainlog_database_list_training_feedback(
app->database, session_id, app->exercise_feedback,
TRAINLOG_FEEDBACK_VIEW_MAX, &app->exercise_feedback_count,
app->session_followups, TRAINLOG_FEEDBACK_VIEW_MAX,
&app->session_followup_count) != TRAINLOG_STATUS_OK;
}
app->session_entry_selected = 0U;
app->session_set_scroll = 0U;
(void)trainlog_navigation_open(&app->navigation, TRAINLOG_ROUTE_SESSION_DETAIL,
session_id);
}
static void app_shell_actions(TrainlogAppContext *app)
{
TrainlogAppRoute route = app->navigation.current.route;
TrainlogSessionController *session = &app->session;
trainlog_actions_clear(&app->actions);
if (app->body_controller.phase != TRAINLOG_BODY_IDLE) {
TrainlogBodyPhase phase = app->body_controller.phase;
if (phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
app_shell_add_action(app, "body.form.discard", '1',
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
app_shell_add_action(app, "body.form.resume", '0',
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
} else if (phase == TRAINLOG_BODY_MESSAGE)
app_shell_add_action(app, "body.form.close", TRAINLOG_KEY_ENTER,
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
else {
app_shell_add_action(app, "body.form.next", TRAINLOG_KEY_ENTER,
app->body_controller.field == 13U ? "Entrée Enregistrer"
: "Entrée Champ suivant",
1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.form.cancel", TRAINLOG_KEY_ESCAPE,
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
}
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
app_shell_add_action(app, "help", '?', "? Aide", 82U,
TRAINLOG_INTENT_OPEN_HELP, route);
return;
}
if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) {
TrainlogProfilePhase phase = app->profile_controller.phase;
if (phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
app_shell_add_action(app, "profile.discard", '1', "1 Abandonner",
1U, TRAINLOG_INTENT_DISCARD, route);
app_shell_add_action(app, "profile.resume", '0', "0 Reprendre",
2U, TRAINLOG_INTENT_BACK, route);
} else if (phase == TRAINLOG_PROFILE_MESSAGE)
app_shell_add_action(app, "profile.close", TRAINLOG_KEY_ENTER,
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
else {
app_shell_add_action(app, "profile.next", TRAINLOG_KEY_ENTER,
phase == TRAINLOG_PROFILE_HEIGHT ? "Entrée Enregistrer"
: "Entrée Continuer",
1U, TRAINLOG_INTENT_SAVE, route);
app_shell_add_action(app, "profile.cancel", TRAINLOG_KEY_ESCAPE,
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
}
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
app_shell_add_action(app, "help", '?', "? Aide", 82U,
TRAINLOG_INTENT_OPEN_HELP, route);
return;
}
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
TrainlogExercisePhase phase = app->exercise_controller.phase;
if (phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
app_shell_add_action(app, "exercise.profile.no", '0',
"0 Non", 1U, TRAINLOG_INTENT_BACK, route);
app_shell_add_action(app, "exercise.profile.yes", '1',
"1 Oui, modifier", 2U, TRAINLOG_INTENT_SAVE, route);
} else if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
app_shell_add_action(app, "exercise.form.discard", '1',
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
app_shell_add_action(app, "exercise.form.resume", '0',
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
} else if (phase == TRAINLOG_EXERCISE_MESSAGE)
app_shell_add_action(app, "exercise.form.close", TRAINLOG_KEY_ENTER,
"Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route);
else {
app_shell_add_action(app, "exercise.form.next", TRAINLOG_KEY_ENTER,
phase == TRAINLOG_EXERCISE_ZONES ? "Entrée Enregistrer"
: "Entrée Continuer",
1U, phase == TRAINLOG_EXERCISE_ZONES ? TRAINLOG_INTENT_SAVE
: TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "exercise.form.cancel", TRAINLOG_KEY_ESCAPE,
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
}
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
app_shell_add_action(app, "help", '?', "? Aide", 82U,
TRAINLOG_INTENT_OPEN_HELP, route);
return;
}
if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
TrainlogEquipmentPhase phase = app->equipment_controller.phase;
if (phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
app_shell_add_action(app, "equipment.create.discard", '1',
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
app_shell_add_action(app, "equipment.create.resume", '0',
"0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route);
} else if (phase == TRAINLOG_EQUIPMENT_MESSAGE)
app_shell_add_action(app, "equipment.create.close", TRAINLOG_KEY_ENTER,
"Entrée Catalogue", 1U, TRAINLOG_INTENT_BACK, route);
else {
app_shell_add_action(app, "equipment.create.next", TRAINLOG_KEY_ENTER,
phase == TRAINLOG_EQUIPMENT_LOAD ? "Entrée Enregistrer"
: "Entrée Champ suivant",
1U, phase == TRAINLOG_EQUIPMENT_LOAD ? TRAINLOG_INTENT_SAVE
: TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "equipment.create.cancel", TRAINLOG_KEY_ESCAPE,
"Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route);
}
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6,
"F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route);
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7,
"F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route);
app_shell_add_action(app, "help", '?', "? Aide", 82U,
TRAINLOG_INTENT_OPEN_HELP, route);
return;
}
if (route == TRAINLOG_ROUTE_HOME)
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
if (route == TRAINLOG_ROUTE_STATS)
app_shell_add_action(app, "statistics.open", TRAINLOG_KEY_ENTER,
"Entrée Ouvrir", 1U, TRAINLOG_INTENT_PRIMARY, route);
if (route == TRAINLOG_ROUTE_STATS_TRAINING) {
app_shell_add_action(app, "statistics.graph.previous", TRAINLOG_KEY_LEFT,
"← Graphe précédent", 1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "statistics.graph.next", TRAINLOG_KEY_RIGHT,
"→ Graphe suivant", 2U, TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) {
app_shell_add_action(app, "statistics.exercise.previous", TRAINLOG_KEY_LEFT,
"← Métrique précédente", 1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "statistics.exercise.next", TRAINLOG_KEY_RIGHT,
"→ Métrique suivante", 2U, TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_STATS_ZONES) {
app_shell_add_action(app, "statistics.zone.previous", TRAINLOG_KEY_UP,
"↑ Zone précédente", 1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "statistics.zone.next", TRAINLOG_KEY_DOWN,
"↓ Zone suivante", 2U, TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_BODY ||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
TRAINLOG_INTENT_PRIMARY, route);
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
session->phase == TRAINLOG_SESSION_DRAFT) {
app_shell_add_action(app, "session.occurrence.add", 'a', "a Ajouter", 1U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.occurrence.edit", 'e', "e Séries/résultat", 2U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.occurrence.replace", 'r', "r Remplacer", 3U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.occurrence.equipment", 'i', "i Équipement", 4U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.occurrence.plan", 'p', "p Objectif prévu", 5U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.occurrence.remove", 'd', "d Retirer", 6U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.finish", 'f', "f Enregistrer", 7U,
TRAINLOG_INTENT_SAVE, route);
app_shell_add_action(app, "session.abandon", 'q', "q Abandonner", 8U,
TRAINLOG_INTENT_DISCARD, route);
}
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
session->phase == TRAINLOG_SESSION_EXERCISE_PICKER)
app_shell_add_action(app, "session.exercise.create", 'n',
"n Créer un exercice", 1U, TRAINLOG_INTENT_NONE, route);
if ((route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT) &&
session->phase == TRAINLOG_SESSION_ACTUALS) {
TrainlogSessionDraftExercise *draft = session->selected < session->draft_count
? &session->drafts[session->selected] : NULL;
app_shell_add_action(app, "session.actual.edit", 'e', "e Modifier", 1U,
TRAINLOG_INTENT_NONE, route);
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_SETS &&
!draft->input.has_max_weight) {
app_shell_add_action(app, "session.set.add", 'a', "a Ajouter série", 2U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.set.remove", 'd', "d Supprimer série", 3U,
TRAINLOG_INTENT_NONE, route);
}
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS &&
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U)
app_shell_add_action(app, "session.continuous.speed", 's', "s Vitesse", 2U,
TRAINLOG_INTENT_NONE, route);
if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS &&
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U)
app_shell_add_action(app, "session.continuous.distance", 'k', "k Distance", 3U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "session.actual.done", 'f', "f Terminer édition", 4U,
TRAINLOG_INTENT_BACK, route);
}
if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG)
app_shell_add_action(app, "generator.generate", 'g', "g Générer", 1U,
TRAINLOG_INTENT_PRIMARY, route);
if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
app_shell_add_action(app, "generator.warning.continue", 'c',
"c Continuer", 1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.warning.zone", 'z',
"z Autre zone", 2U, TRAINLOG_INTENT_NONE, route);
}
if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
app_shell_add_action(app, "generator.accept", 'a', "a Accepter", 1U,
TRAINLOG_INTENT_PRIMARY, route);
app_shell_add_action(app, "generator.remove", 'd', "d Retirer", 2U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.edit.dose", 'e', "e Modifier dose", 3U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.load.percent", '%', "% %MAX", 4U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.load.auto", 'u', "u Charge auto", 5U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.load.none", 'x', "x Sans charge", 6U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.regenerate", 'g', "g Régénérer", 4U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.move.up", '<', "< Monter", 5U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "generator.move.down", '>', "> Descendre", 6U,
TRAINLOG_INTENT_NONE, route);
}
if (session->generated_preview)
app_shell_add_action(app, "generator.discard", 'q',
"q Abandonner la proposition", 6U, TRAINLOG_INTENT_DISCARD, route);
}
if (app_shell_is_list_route(route)) {
app_shell_add_action(app, "list.open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
TRAINLOG_INTENT_PRIMARY, route);
if (route != TRAINLOG_ROUTE_BODY)
app_shell_add_action(app, "list.search", '/', "/ Rechercher", 2U,
TRAINLOG_INTENT_OPEN_SEARCH, route);
}
if (route == TRAINLOG_ROUTE_BODY) {
app_shell_add_action(app, "body.add", 'a', "a Ajouter", 1U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.edit", 'e', "e Modifier", 2U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.analytics", 'v', "v Analyse", 3U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.metric", 'm', "m Historique mesure", 4U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.graph", 'g', "g Vue 12 mois", 5U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.global", 'o', "o Vue globale", 6U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.profile.metric.previous", TRAINLOG_KEY_LEFT,
"← Mesure précédente", 7U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.profile.metric.next", TRAINLOG_KEY_RIGHT,
"→ Mesure suivante", 8U, TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_BODY_DETAIL)
app_shell_add_action(app, "body.detail.edit", 'e', "e Modifier", 1U,
TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_BODY_METRIC) {
app_shell_add_action(app, "body.metric.previous", TRAINLOG_KEY_LEFT,
"← Mesure précédente", 1U, TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "body.metric.next", TRAINLOG_KEY_RIGHT,
"→ Mesure suivante", 2U, TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_BODY_GLOBAL)
app_shell_add_action(app, "body.global.toggle", ' ',
"Espace Afficher/masquer", 1U, TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_BODY_ANALYTICS)
app_shell_add_action(app, "body.profile", 'p', "p Profil estimation",
1U, TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_SYNC) {
/* WHY: one registry entry is the source of truth for both the footer
* and F7 palette, preventing directional or duplicate launch affordances. */
app_shell_add_action(app, "sync.now", 's',
"s Synchroniser maintenant · PC↔Android", 1U,
TRAINLOG_INTENT_NONE, route);
app_shell_add_action(app, "sync.refresh", 'r', "r Actualiser appareil",
2U, TRAINLOG_INTENT_REFRESH, route);
}
if (route == TRAINLOG_ROUTE_EQUIPMENT)
app_shell_add_action(app, "equipment.create", 'n', "n Créer", 1U,
TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
app_shell_add_action(app, "exercise.performance", 'p', "p Performances", 1U,
TRAINLOG_INTENT_PRIMARY, route);
app_shell_add_action(app, "exercise.max", 'm', "m MAX", 2U,
TRAINLOG_INTENT_PRIMARY, route);
app_shell_add_action(app, "exercise.knowledge", 'k', "k Connaissances", 3U,
TRAINLOG_INTENT_PRIMARY, route);
app_shell_add_action(app, "exercise.edit", 'e', "e Modifier", 4U,
TRAINLOG_INTENT_PRIMARY, route);
app_shell_add_action(app, "exercise.merge", 'u', "u Fusionner avec…", 5U,
TRAINLOG_INTENT_NONE, route);
}
if (route == TRAINLOG_ROUTE_EXERCISES)
app_shell_add_action(app, "exercise.create", 'a', "a Créer", 1U,
TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_MAX)
app_shell_add_action(app, "max.filter", 'a', "a MAX mesurés/tous", 1U,
TRAINLOG_INTENT_NONE, route);
if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE)
app_shell_add_action(app, "knowledge.close", 'k', "k Fermer", 1U,
TRAINLOG_INTENT_BACK, TRAINLOG_ROUTE_EXERCISE_DETAIL);
if (route == TRAINLOG_ROUTE_SESSION_DETAIL)
app_shell_add_action(app, "session.edit", 'e', "e Modifier", 1U,
TRAINLOG_INTENT_NONE, route);
/* CONTRACT: destination aliases live in the same model as contextual
* actions. Dispatcher, footer selection and F7 therefore cannot drift. */
app_shell_add_action(app, "home", '0', "0 Accueil", 100U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_HOME);
app_shell_add_action(app, "session.alias", '1', "1 Séance", 101U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
app_shell_add_action(app, "completed.alias", '2', "2 Effectuées", 102U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES);
app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY);
app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SYNC);
app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, "F6 Navigation", 80U,
TRAINLOG_INTENT_OPEN_NAVIGATION, route);
app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, "F7 Actions", 81U,
TRAINLOG_INTENT_OPEN_ACTIONS, route);
app_shell_add_action(app, "help", '?', "? Aide", 82U,
TRAINLOG_INTENT_OPEN_HELP, route);
app_shell_add_action(app, "back", TRAINLOG_KEY_ESCAPE,
route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour", 83U,
route == TRAINLOG_ROUTE_HOME ? TRAINLOG_INTENT_QUIT : TRAINLOG_INTENT_BACK,
route);
}
static void app_shell_render_footer(TrainlogAppContext *app)
{
const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays);
const TrainlogAction *contextual[2] = {NULL, NULL};
size_t position;
size_t contextual_count = 0U;
trainlog_surface_erase(app->footer);
trainlog_surface_set_role(app->footer, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
if (overlay != NULL) {
if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) {
trainlog_surface_printf(app->footer, 0, 2,
"↑↓ choisir Entrée valider");
trainlog_surface_printf(app->footer, 1, 2,
"Annuler conserve l’état de cette exécution");
return;
}
trainlog_surface_printf(app->footer, 0, 2, "↑↓ choisir Entrée valider");
trainlog_surface_printf(app->footer, 1, 2, "Échap fermer F7 Actions ? Aide");
return;
}
/* INVARIANT: stable action identity is deduplicated after every screen,
* focus and shell-wide contributor has been aggregated, before layout.
* Reserved global actions remain in F7 but are rendered once in row two. */
for (position = 0U; position < app->actions.count && contextual_count < 2U;
++position) {
const TrainlogAction *candidate = trainlog_actions_at_priority(
&app->actions, position);
if (candidate == NULL ||
strcmp(candidate->identifier, "navigation") == 0 ||
strcmp(candidate->identifier, "actions") == 0 ||
strcmp(candidate->identifier, "help") == 0 ||
strcmp(candidate->identifier, "back") == 0) continue;
contextual[contextual_count++] = candidate;
}
if (contextual[0] != NULL)
trainlog_surface_printf(app->footer, 0, 2, "%s", contextual[0]->label);
if (contextual[1] != NULL)
trainlog_surface_printf(app->footer, 0, 24, "%s", contextual[1]->label);
trainlog_surface_printf(app->footer, 1, 2,
"F6 Navigation F7 Actions ? Aide %s",
trainlog_presentation_source(
app->navigation.current.route == TRAINLOG_ROUTE_HOME
? "q Quitter" : "Échap Retour"));
}
static void app_shell_render_sidebar(TrainlogAppContext *app)
{
size_t index;
if (app->sidebar == NULL) return;
trainlog_surface_erase(app->sidebar);
trainlog_surface_set_role(app->sidebar, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]); ++index) {
bool active = trainlog_route_section(app->navigation.current.route) == shell_sections[index];
bool focused = app->focus == TRAINLOG_FOCUS_NAVIGATION &&
app->navigation_selected == index;
trainlog_surface_set_role(app->sidebar,
focused || active ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
focused ? TRAINLOG_RGB_SURFACE0 : TRAINLOG_RGB_MANTLE,
focused ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->sidebar, 1 + (int)index * 2, 1,
"%c%c %-17s", focused ? '>' : ' ', active ? '[' : ' ',
trainlog_presentation_text(shell_section_labels[index]));
if (active && app->layout.sidebar_expanded) {
const char *subroute = NULL;
switch (app->navigation.current.route) {
case TRAINLOG_ROUTE_SESSION_CURRENT: subroute = "· Séance en cours"; break;
case TRAINLOG_ROUTE_SESSION_GENERATOR: subroute = "· Programmer"; break;
case TRAINLOG_ROUTE_SESSION_MANUAL: subroute = "· Nouvelle séance"; break;
case TRAINLOG_ROUTE_SESSIONS_COMPLETED: subroute = "· Effectuées"; break;
case TRAINLOG_ROUTE_SESSION_DETAIL: subroute = "· Détail séance"; break;
case TRAINLOG_ROUTE_EXERCISE_DETAIL: subroute = "· Fiche exercice"; break;
case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: subroute = "· Connaissances"; break;
case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: subroute = "· Performances"; break;
case TRAINLOG_ROUTE_EXERCISE_MAX: subroute = "· MAX exercice"; break;
case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: subroute = "· Fiche équipement"; break;
case TRAINLOG_ROUTE_STATS_EXERCISE: subroute = "· Par exercice"; break;
case TRAINLOG_ROUTE_STATS_TRAINING: subroute = "· Vue globale"; break;
case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: subroute = "· Exercice"; break;
case TRAINLOG_ROUTE_STATS_ZONES: subroute = "· Zones"; break;
case TRAINLOG_ROUTE_BODY: subroute = "· Mensurations"; break;
case TRAINLOG_ROUTE_BODY_DETAIL: subroute = "· Relevé corporel"; break;
case TRAINLOG_ROUTE_BODY_METRIC: subroute = "· Historique mesure"; break;
case TRAINLOG_ROUTE_BODY_TRENDS: subroute = "· Évolution 12 mois"; break;
case TRAINLOG_ROUTE_BODY_GLOBAL: subroute = "· Vue globale"; break;
case TRAINLOG_ROUTE_BODY_ANALYTICS: subroute = "· Analyse corporelle"; break;
case TRAINLOG_ROUTE_MAX: subroute = "· Capacités / MAX"; break;
default: break;
}
if (subroute != NULL)
trainlog_surface_printf(app->sidebar,
2 + (int)index * 2, 3, "%s",
trainlog_presentation_source(subroute));
}
}
}
static void app_shell_render_session_controller(TrainlogAppContext *app)
{
TrainlogSessionController *session = &app->session;
int row = 3;
size_t index;
if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) {
trainlog_surface_printf(app->content, row++, 2, "Choisissez le type de séance :");
trainlog_surface_printf(app->content, row++, 4, "%c Entraînement",
session->session_type == TRAINLOG_SESSION_TRAINING ? '>' : ' ');
trainlog_surface_printf(app->content, row, 4, "%c Test max explicite",
session->session_type == TRAINLOG_SESSION_MAX_TEST ? '>' : ' ');
} else if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) {
trainlog_surface_printf(app->content, row++, 2,
"%s — choisir un exercice", session->replacing_occurrence
? "Remplacer l’occurrence" : "Ajouter une occurrence");
for (index = 0U; index < session->picker_count &&
row < app->layout.content.height; ++index)
trainlog_surface_printf(app->content, row++, 4, "%c %.52s",
index == session->picker_selected ? '>' : ' ',
session->picker[index].name);
} else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) {
trainlog_surface_printf(app->content, row++, 2,
"Équipement de l’occurrence — ID stable de l’occurrence inchangé");
trainlog_surface_printf(app->content, row++, 4, "%c Aucun équipement",
session->equipment_selected == 0U ? '>' : ' ');
for (index = 0U; index < session->equipment_count &&
row < app->layout.content.height; ++index)
trainlog_surface_printf(app->content, row++, 4, "%c %.46s %s",
session->equipment_selected == index + 1U ? '>' : ' ',
app->equipment[index].display_name,
equipment_origin_label(app->equipment[index].origin));
trainlog_surface_printf(app->content, app->layout.content.height - 2, 2,
"Entrée choisir · x aucun · n créer puis reprendre (phase catalogue)");
} else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE) {
const char *label = session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME
? "Nom convivial" : session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL
? "Nom étiquette (optionnel)" :
session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE
? "Type" : "Charge : 1 aucune · 2 externe · 3 assistance";
trainlog_surface_printf(app->content, row++, 2,
"Créer un équipement personnel — retour à l’occurrence");
trainlog_surface_printf(app->content, row++, 4, "%s",
trainlog_presentation_source(label));
trainlog_surface_printf(app->content, row++, 4, "> %s", session->form.text);
if (session->message[0] != '\0')
trainlog_surface_printf(app->content, row + 1, 4, "%s",
trainlog_presentation_source(session->message));
} else if (session->phase == TRAINLOG_SESSION_PLANNING &&
session->selected < session->draft_count) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
static const char *const labels[] = {"Séries cibles", "Répétitions cibles",
"Durée cible (s)", "Repos (s)", "Charge/assistance cible (kg)"};
char values[5][48];
(void)snprintf(values[0], sizeof(values[0]), "%d", draft->input.target_sets);
(void)snprintf(values[1], sizeof(values[1]), "%d", draft->input.target_reps);
(void)snprintf(values[2], sizeof(values[2]), "%d",
draft->input.target_duration_seconds);
(void)snprintf(values[3], sizeof(values[3]), "%d", draft->input.rest_seconds);
if (draft->input.target_has_weight)
(void)trainlog_presentation_format(values[4], sizeof(values[4]), "%.2f",
draft->input.target_weight_kg);
else (void)snprintf(values[4], sizeof(values[4]), "—");
if (draft->input.recording_mode != TRAINLOG_RECORDING_SETS) {
(void)snprintf(values[0], sizeof(values[0]), "%s",
trainlog_presentation_text("value.not_applicable"));
(void)snprintf(values[3], sizeof(values[3]), "%s",
trainlog_presentation_text("value.not_applicable"));
}
if (draft->tracking_mode != TRAINLOG_TRACKING_REPS)
(void)snprintf(values[1], sizeof(values[1]), "%s",
trainlog_presentation_text("value.not_applicable"));
if (draft->tracking_mode != TRAINLOG_TRACKING_DURATION)
(void)snprintf(values[2], sizeof(values[2]), "%s",
trainlog_presentation_text("value.not_applicable"));
if (draft->input.load_mode == TRAINLOG_LOAD_NONE)
(void)snprintf(values[4], sizeof(values[4]), "%s",
trainlog_presentation_text("value.not_applicable"));
trainlog_surface_printf(app->content, row++, 2,
"Objectif prévu — séparé des valeurs réalisées");
for (index = 0U; index < 5U; ++index)
trainlog_surface_printf(app->content, row++, 4, "%c %-32s %s",
index == session->planning_field ? '>' : ' ',
trainlog_presentation_source(labels[index]), values[index]);
if (row < app->layout.content.height - 2)
trainlog_surface_printf(app->content, row++, 4,
"%% sur la charge : calcul utilisateur 1..100%% du dernier MAX compatible");
if (session->form.active)
trainlog_surface_printf(app->content, app->layout.content.height - 3, 2,
"> %s", session->form.text);
} else if (session->phase == TRAINLOG_SESSION_ACTUALS &&
session->selected < session->draft_count) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
trainlog_surface_printf(app->content, row++, 2, "%s", draft->name);
if (draft->input.has_max_weight || session->session_type == TRAINLOG_SESSION_MAX_TEST)
trainlog_surface_printf(app->content, row++, 4,
"MAX explicite : %s%.2f kg", draft->input.has_max_weight ? "" : "à saisir · ",
draft->input.has_max_weight ? draft->input.max_weight_kg : 0.0);
else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS)
{
trainlog_surface_printf(app->content, row++, 4,
"Activité continue : %d s · aucune série fictive",
draft->input.continuous_duration_seconds);
if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U)
trainlog_surface_printf(app->content, row++, 4, "Vitesse : %s%.2f km/h",
draft->input.continuous_has_speed ? "" : "— ",
draft->input.continuous_has_speed
? draft->input.continuous_speed_kmh : 0.0);
if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U)
trainlog_surface_printf(app->content, row++, 4, "Distance : %s%.2f km",
draft->input.continuous_has_distance ? "" : "— ",
draft->input.continuous_has_distance
? draft->input.continuous_distance_km : 0.0);
trainlog_surface_printf(app->content, row++, 4,
"e durée · s vitesse · k distance");
}
else {
trainlog_surface_printf(app->content, row++, 2,
"Séries réalisées — cibles séparées : %d×%d, repos %d s",
draft->input.target_sets, draft->input.target_reps,
draft->input.rest_seconds);
for (index = 0U; index < draft->input.set_count &&
row < app->layout.content.height; ++index) {
char metric[64];
draft_format_set_metric(draft, &draft->sets[index], metric,
sizeof(metric));
trainlog_surface_printf(app->content, row++, 4,
"%c %zu. %-16s %s%.2f kg", index == session->set_selected ? '>' : ' ',
index + 1U, metric, draft->sets[index].has_weight ? "" : "— ",
draft->sets[index].has_weight ? draft->sets[index].weight_kg : 0.0);
}
if (draft->input.set_count == 0U)
trainlog_surface_printf(app->content, row++, 4,
"Aucune série réelle. a pour saisir la première.");
}
if (session->form.active) {
trainlog_surface_printf(app->content, app->layout.content.height - 3, 2,
"> %s%s", session->form.text,
session->form.capacity_error ? " [200 octets maximum]" : "");
}
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) {
trainlog_surface_printf(app->content, row++, 2, "Paramètres de la proposition");
trainlog_surface_printf(app->content, row++, 4, "Zone : %s",
session->generation_zone != NULL ? session->generation_zone->display_name : "—");
trainlog_surface_printf(app->content, row++, 4, "Objectif : %s",
session->generation_goal != NULL ?
generator_goal_label(session->generation_goal->id) : "—");
trainlog_surface_printf(app->content, row++, 4, "Durée : %d min",
session->generation_duration_minutes);
trainlog_surface_printf(app->content, row + 1, 2,
"z zone · o objectif · t durée · g générer");
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
TrainlogBodyZoneRecentExposure *exposure = &session->generated.exposure;
trainlog_surface_printf(app->content, row++, 2, "Zone travaillée récemment");
trainlog_surface_printf(app->content, row++, 4,
"24 h : %zu séries principales, %zu secondaires (%zu séances)",
exposure->within_24h.primary_set_count,
exposure->within_24h.secondary_set_count,
exposure->within_24h.session_count);
trainlog_surface_printf(app->content, row++, 4,
"72 h : %zu séries principales, %zu secondaires (%zu séances)",
exposure->within_72h.primary_set_count,
exposure->within_72h.secondary_set_count,
exposure->within_72h.session_count);
trainlog_surface_printf(app->content, row + 1, 2,
"c/Entrée continuer · z changer de zone · Échap conserver et revenir");
} else if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
trainlog_surface_printf(app->content, row++, 2,
"%zu exercice(s) · cible %d min · estimation %d min",
session->generated.exercise_count,
session->generation_duration_minutes,
session->generated.estimated_duration_seconds / 60);
if (session->generation_duration_minutes * 60 -
session->generated.estimated_duration_seconds >= 300)
trainlog_surface_printf(app->content, row++, 2,
"Proposition plus courte : aucun remplissage artificiel.");
trainlog_surface_printf(app->content, row++, 2,
"Échauffement et retour au calme absents en V1.");
for (index = 0U; index < session->generated.exercise_count &&
row + 1 < app->layout.content.height; ++index) {
TrainlogGeneratedExercise *item = &session->generated.exercises[index];
trainlog_surface_printf(app->content, row++, 4, "%c %zu. %.38s",
index == session->selected ? '>' : ' ', index + 1U,
session->generated_items[index].exercise_name);
trainlog_surface_printf(app->content, row++, 7,
"%d×%d · repos %d s · %.30s", item->target_sets,
item->target_repetitions, item->rest_seconds,
session->generated_items[index].equipment_name);
if (index == session->selected && row < app->layout.content.height) {
if (item->has_target_weight)
trainlog_surface_printf(app->content, row++, 7,
"%.2f kg · %s", item->target_weight_kg,
item->rationale_count > 0U && strcmp(item->rationale_codes[0],
"user_selected_max_percentage") == 0
? "pourcentage MAX choisi" : "charge automatique observée");
else trainlog_surface_printf(app->content, row++, 7,
item->rationale_count > 0U && strcmp(item->rationale_codes[0],
"compatible_max_unavailable") == 0
? trainlog_presentation_text("ui.max.compatible.none")
: "Aucune charge numérique qualifiée");
}
}
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
trainlog_surface_printf(app->content, row++, 2,
"Retirer cette occurrence de la séance ?");
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
session->destructive_confirm_selected ? "[ ]" : "[>]",
session->destructive_confirm_selected ? "[>]" : "[ ]");
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
trainlog_surface_printf(app->content, row++, 2, "Supprimer cette série ?");
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
session->destructive_confirm_selected ? "[ ]" : "[>]",
session->destructive_confirm_selected ? "[>]" : "[ ]");
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
trainlog_surface_printf(app->content, row++, 2,
"Abandonner la séance en cours ?");
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, abandonner",
session->destructive_confirm_selected ? "[ ]" : "[>]",
session->destructive_confirm_selected ? "[>]" : "[ ]");
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) {
trainlog_surface_printf(app->content, row++, 2,
"Proposition non acceptée conservée en mémoire.");
trainlog_surface_printf(app->content, row, 4,
"Entrée conserver et revenir · d Abandonner la proposition");
} else if (session->phase == TRAINLOG_SESSION_MESSAGE) {
trainlog_surface_printf(app->content, row++, 2, "%s",
trainlog_presentation_source(session->message));
trainlog_surface_printf(app->content, row, 2, "Entrée pour continuer");
} else {
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
session_type_label(session->session_type));
if (session->draft_count == 0U)
trainlog_surface_printf(app->content, row, 2,
"Aucune occurrence. a pour choisir un exercice.");
for (index = 0U; index < session->draft_count &&
row < app->layout.content.height; ++index) {
char summary[128];
draft_set_summary(&session->drafts[index], summary, sizeof(summary));
trainlog_surface_printf(app->content, row++, 2, "%c %zu. %-28.28s %.38s",
index == session->selected ? '>' : ' ', index + 1U,
session->drafts[index].name, summary);
}
if (session->message[0] != '\0')
trainlog_surface_printf(app->content, app->layout.content.height - 2,
2, "%s", trainlog_presentation_source(session->message));
}
}
static void app_shell_render_equipment_controller(TrainlogAppContext *app)
{
TrainlogEquipmentController *controller = &app->equipment_controller;
const char *label;
int row = 4;
trainlog_surface_printf(app->content, row++, 2,
"Créer un équipement personnel");
if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
trainlog_surface_printf(app->content, row++, 2,
"Abandonner cette définition non enregistrée ?");
trainlog_surface_printf(app->content, row, 4,
"1 abandonner · 0/Échap reprendre");
return;
}
if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) {
trainlog_surface_printf(app->content, row++, 2, "%s",
trainlog_presentation_source(controller->message));
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir au catalogue");
return;
}
trainlog_surface_printf(app->content, row++, 2, "Nom : %s",
controller->pending.display_name[0] != '\0'
? controller->pending.display_name : "—");
trainlog_surface_printf(app->content, row++, 2, "Étiquette : %s",
controller->pending.label_name[0] != '\0'
? controller->pending.label_name : "—");
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
controller->pending.equipment_type[0] != '\0'
? controller->pending.equipment_type : "—");
trainlog_surface_printf(app->content, row++, 2,
"Charge : %s", trainlog_presentation_source(
controller->load_choice == 1 ? "aucune" :
controller->load_choice == 2 ? "externe" : "assistance"));
row += 1;
if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) {
trainlog_surface_printf(app->content, row++, 2,
"↑↓ ou 1–3 choisir · Entrée enregistrer");
} else {
label = controller->phase == TRAINLOG_EQUIPMENT_NAME ? "Nom convivial" :
controller->phase == TRAINLOG_EQUIPMENT_LABEL
? "Nom d’étiquette (optionnel)" : "Type";
trainlog_surface_printf(app->content, row++, 2, "%s",
trainlog_presentation_source(label));
trainlog_surface_printf(app->content, row++, 4, "> %s%s",
controller->form.text,
controller->form.capacity_error ? " [200 octets maximum]" : "");
}
if (controller->message[0] != '\0')
trainlog_surface_printf(app->content, row + 1, 2, "%s",
trainlog_presentation_source(controller->message));
}
static void app_shell_render_exercise_controller(TrainlogAppContext *app)
{
TrainlogExerciseController *controller = &app->exercise_controller;
int row = 4;
trainlog_surface_printf(app->content, row++, 2, "%s un exercice",
controller->editing ? "Modifier" : "Créer");
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
trainlog_surface_printf(app->content, row++, 2,
"Abandonner les modifications non enregistrées ?");
trainlog_surface_printf(app->content, row, 4,
"1 abandonner · 0/Échap reprendre");
return;
}
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
trainlog_surface_printf(app->content, row++, 2,
"Modifier le mode de suivi ?");
trainlog_surface_printf(app->content, row++, 2,
"Les anciennes séances resteront inchangées.");
trainlog_surface_printf(app->content, row++, 2,
"Les prochaines utilisations suivront le nouveau profil.");
trainlog_surface_printf(app->content, row, 4,
"0/Échap/q Non · 1 Oui, modifier");
return;
}
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
trainlog_surface_printf(app->content, row++, 2, "%s",
trainlog_presentation_source(controller->message));
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir");
return;
}
trainlog_surface_printf(app->content, row++, 2, "Nom : %s",
controller->pending.name[0] != '\0' ? controller->pending.name : "—");
trainlog_surface_printf(app->content, row++, 2, "Suivi : %s",
controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS
? "répétitions" : "durée");
trainlog_surface_printf(app->content, row++, 2, "Organisation : %s",
controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS
? trainlog_presentation_source("continue")
: trainlog_presentation_source("séries"));
if (controller->editing)
trainlog_surface_printf(app->content, row++, 2,
"Identifiant stable conservé : %s",
controller->pending.exercise_id);
row += 1;
if (controller->phase == TRAINLOG_EXERCISE_NAME) {
trainlog_surface_printf(app->content, row++, 2, "Nom d’affichage");
trainlog_surface_printf(app->content, row++, 4, "> %s%s",
controller->form.text,
controller->form.capacity_error ? " [200 octets maximum]" : "");
} else if (controller->phase == TRAINLOG_EXERCISE_TRACKING)
trainlog_surface_printf(app->content, row++, 2,
"Suivi : 1 répétitions · 2 durée · ↑↓ choisir · Entrée continuer");
else if (controller->phase == TRAINLOG_EXERCISE_RECORDING)
trainlog_surface_printf(app->content, row++, 2,
"Organisation : 1 séries · 2 continu · ↑↓ choisir · Entrée continuer");
else if (controller->phase == TRAINLOG_EXERCISE_SPEED)
trainlog_surface_printf(app->content, row++, 2,
"Mesurer la vitesse : %s · 0 non · 1 oui · ↑↓ choisir",
(controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH)
!= 0U ? "oui" : "non");
else if (controller->phase == TRAINLOG_EXERCISE_DISTANCE)
trainlog_surface_printf(app->content, row++, 2,
"Mesurer la distance : %s · 0 non · 1 oui · ↑↓ choisir",
(controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM)
!= 0U ? "oui" : "non");
else if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
size_t index;
const TrainlogBodyZone *primary = trainlog_body_zone_catalog_lookup(
controller->primary_zone);
trainlog_surface_printf(app->content, row++, 2,
"Zone principale : %s · secondaires : %zu",
primary != NULL ? primary->display_name : "Non renseignée",
controller->secondary_count);
for (index = controller->zone_selected;
index < trainlog_body_zone_catalog_count() &&
row < app->layout.content.height - 2; ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
trainlog_surface_printf(app->content, row++, 4, "%c %s%s",
index == controller->zone_selected ? '>' : ' ',
zone != NULL ? zone->display_name : "?",
zone != NULL && zone->is_group ? " [groupe]" : "");
}
trainlog_surface_printf(app->content,
app->layout.content.height - 2, 2,
"p principale · Espace secondaire · n effacer · Entrée enregistrer");
}
if (controller->message[0] != '\0')
trainlog_surface_printf(app->content,
app->layout.content.height - 3, 2, "%s",
trainlog_presentation_source(controller->message));
}
static int app_shell_graph_height(const TrainlogAppContext *app, int top)
{
int available = app->layout.content.height - top - 2;
if (available > 7) return 7;
return available;
}
/* WHY: a statistical line must show the relationship between two observations,
* rather than fill horizontal space with decorative ASCII. CONTRACT: callers
* pass distinct x coordinates inside their plot; this helper writes only
* Unicode line glyphs between them. INVARIANT: each interpolated cell stays in
* the caller-owned chart rectangle. */
static void app_shell_draw_chart_segment(TrainlogSurface *surface, int x1, int y1,
int x2, int y2)
{
int x;
if (surface == NULL || x2 <= x1) return;
for (x = x1 + 1; x < x2; ++x) {
int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1);
int delta = y2 - y1;
uint32_t glyph = delta == 0 ? 0x2500U :
(delta < 0 ? 0x2571U : 0x2572U);
trainlog_surface_draw(surface, y, x, glyph);
}
}
static void app_shell_draw_performance_graph(
TrainlogAppContext *app,
const TrainlogExercisePerformancePoint *context,
TrainlogLoadMode mode,
TrainlogTrackingMode tracking_mode,
int top,
int height,
bool measured_max)
{
size_t indices[EXERCISE_GRAPH_POINTS];
size_t selected_count = 0U;
size_t index;
double minimum;
double maximum;
const int left = 11;
int width = app->layout.content.width - left - 2;
/* CONTRACT: graph coordinates are relative to the bounded content plane.
* Header/footer/sidebar ownership is therefore preserved at every valid
* resize, including the minimum 72x20 viewport. */
if (height < 3 || width < 12 || top < 1 ||
top + height >= app->layout.content.height) return;
for (index = 0U; index < app->performance_count &&
selected_count < EXERCISE_GRAPH_POINTS; ++index) {
const TrainlogExercisePerformancePoint *point =
&app->performance_points[index];
if (point->has_performance != 0 && point->load_mode == mode &&
(context == NULL || strcmp(point->equipment_id,
context->equipment_id) == 0) &&
(!measured_max || point->session_type == TRAINLOG_SESSION_MAX_TEST))
indices[selected_count++] = index;
}
if (selected_count == 0U) {
trainlog_surface_printf(app->content, top, 2,
"Aucun point exploitable pour ce contexte.");
return;
}
if (selected_count == 1U) {
char value[128];
exercise_format_performance(&app->performance_points[indices[0]], value,
sizeof(value));
trainlog_surface_printf(app->content, top, 2,
"%s · 1 point · tendance indisponible", value);
return;
}
minimum = exercise_graph_value(&app->performance_points[indices[0]]);
maximum = minimum;
for (index = 1U; index < selected_count; ++index) {
double value = exercise_graph_value(
&app->performance_points[indices[index]]);
if (value < minimum) minimum = value;
if (value > maximum) maximum = value;
}
trainlog_surface_set_role(app->content,
mode == TRAINLOG_LOAD_ASSISTANCE ? TRAINLOG_COLOR_WARNING
: TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, top - 1, left, "%s",
mode == TRAINLOG_LOAD_ASSISTANCE
? (measured_max ? "Assistance mesurée (kg) — moins = mieux"
: "Assistance (kg) — moins = mieux")
: mode == TRAINLOG_LOAD_EXTERNAL
? (measured_max ? "MAX mesuré (kg)" : "Charge du meilleur set (kg)")
: tracking_mode == TRAINLOG_TRACKING_DURATION
? (measured_max ? "Durée MAX mesurée" : "Meilleure durée")
: (measured_max ? "Répétitions MAX mesurées" : "Meilleures répétitions"));
trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum);
trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
{
int previous_x = -1;
int previous_y = -1;
for (int axis_row = top; axis_row < top + height; ++axis_row)
trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U);
size_t order;
for (order = selected_count; order > 0U; --order) {
size_t chronological = selected_count - order;
const TrainlogExercisePerformancePoint *point =
&app->performance_points[indices[order - 1U]];
int x = left +
(int)((chronological * (size_t)(width - 1)) /
(selected_count - 1U));
int y = normalized_graph_row(exercise_graph_value(point), minimum,
maximum, top, height);
if (previous_x >= 0 && x > previous_x) {
app_shell_draw_chart_segment(app->content, previous_x, previous_y,
x, y);
}
trainlog_surface_draw(app->content, y, x,
chronological + 1U == selected_count ? 0x25cfU : 0x25cbU);
previous_x = x;
previous_y = y;
}
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
{
char oldest[11];
char newest[11];
exercise_short_date(app->performance_points[
indices[selected_count - 1U]].started_at, oldest);
exercise_short_date(app->performance_points[indices[0]].started_at, newest);
trainlog_surface_printf(app->content, top + height, left, "%s", oldest);
trainlog_surface_printf(app->content, top + height,
left + width - 10, "%s", newest);
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
}
static void app_shell_draw_body_points(TrainlogAppContext *app,
const TrainlogBodyMetricPoint *points, size_t count, int top, int height,
const char *unit, const char *title)
{
TrainlogChartPoint chart_points[MAX_BODY_METRIC_POINTS];
TrainlogChartRect rect;
size_t index;
if (count > MAX_BODY_METRIC_POINTS) count = MAX_BODY_METRIC_POINTS;
for (index = 0U; index < count; ++index) {
TrainlogTimestampKey timestamp;
if (!trainlog_timestamp_parse(points[index].observed_at,
strlen(points[index].observed_at), &timestamp)) {
count = index;
break;
}
chart_points[index].timestamp = timestamp.utc_second;
chart_points[index].value = points[index].value;
chart_points[index].timestamp_label = points[index].observed_at;
}
rect.top = top;
rect.left = 2;
rect.height = height;
rect.width = app->layout.content.width - 4;
trainlog_chart_render(app->content, rect, chart_points, count, unit, title);
}
static void app_shell_render_body_profile(TrainlogAppContext *app)
{
const TrainlogBodyObservationRecord *record;
const bool *present[13];
const double *values[13];
double maximum = 0.0;
size_t circumference_count = 0U;
size_t shown = 0U;
size_t index;
int evolution_top;
int profile_row = 6;
int bar_left = 23;
int bar_capacity = app->layout.content.width - bar_left - 10;
TrainlogGlobalBodySeries *series;
if (app->body_snapshot_error) {
trainlog_surface_printf(app->content, 3, 2,
"Impossible de lire les mensurations.");
return;
}
if (app->loaded_count == 0U) {
trainlog_surface_printf(app->content, 3, 2,
"Aucun relevé corporel. a pour ajouter un relevé.");
trainlog_surface_printf(app->content, 5, 2,
"ÉVOLUTION DANS LE TEMPS · aucune métrique disponible");
return;
}
if (app->list.selected_index >= app->loaded_count)
app->list.selected_index = 0U;
record = &app->body_records[app->list.selected_index];
{
const bool *const flags[13] = {&record->has_neck,
&record->has_shoulders, &record->has_chest, &record->has_waist,
&record->has_hips, &record->has_left_arm, &record->has_right_arm,
&record->has_left_forearm, &record->has_right_forearm,
&record->has_left_thigh, &record->has_right_thigh,
&record->has_left_calf, &record->has_right_calf};
const double *const measurements[13] = {&record->neck_cm,
&record->shoulders_cm, &record->chest_cm, &record->waist_cm,
&record->hips_cm, &record->left_arm_cm, &record->right_arm_cm,
&record->left_forearm_cm, &record->right_forearm_cm,
&record->left_thigh_cm, &record->right_thigh_cm,
&record->left_calf_cm, &record->right_calf_cm};
(void)memcpy(present, flags, sizeof(present));
(void)memcpy(values, measurements, sizeof(values));
}
for (index = 0U; index < 13U; ++index) {
if (!*present[index]) continue;
++circumference_count;
if (*values[index] > maximum) maximum = *values[index];
}
/* WHY: weight and circumference have unrelated units and must never share
* a visual scale. INVARIANT: absent circumference fields produce no row;
* every rendered bar encodes the selected observation's actual centimetres. */
trainlog_surface_printf(app->content, 3, 2,
"PROFIL ACTUEL · relevé %zu/%zu · %s", app->list.selected_index + 1U,
app->loaded_count, record->observed_at);
if (record->has_body_weight)
trainlog_surface_printf(app->content, 4, 2, "Poids : %.2f kg",
record->body_weight_kg);
else trainlog_surface_printf(app->content, 4, 2,
"Poids : non renseigné");
trainlog_surface_printf(app->content, 5, 2,
"PROFIL CIRCONFÉRENCES · échelle au plus grand cm présent");
/* WHY: sparse observations should donate their unused profile rows to the
* chart. The current content-plane height is the only geometry source, so
* resize redraws cannot retain stale coordinates. */
evolution_top = profile_row + (int)circumference_count + 1;
if (evolution_top < 8) evolution_top = 8;
if (evolution_top > app->layout.content.height - 7)
evolution_top = app->layout.content.height - 7;
if (bar_capacity < 1) bar_capacity = 1;
for (index = 0U; index < 13U && profile_row < evolution_top - 1; ++index) {
int length;
int column;
if (!*present[index]) continue;
length = maximum > 0.0
? (int)((*values[index] / maximum) * (double)bar_capacity) : 1;
if (length < 1) length = 1;
if (length > bar_capacity) length = bar_capacity;
trainlog_surface_printf(app->content, profile_row, 2, "%-19s",
body_metric_label(index + 1U));
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (column = 0; column < length; ++column)
trainlog_surface_draw(app->content, profile_row,
bar_left + column, 0x2586U);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, profile_row,
bar_left + length + 1, "%.1f cm", *values[index]);
++profile_row;
++shown;
}
if (circumference_count == 0U)
trainlog_surface_printf(app->content, profile_row, 2,
"Aucune circonférence dans ce relevé.");
else if (shown < circumference_count)
trainlog_surface_printf(app->content, evolution_top - 1, 2,
"… %zu autre(s) circonférence(s) · ↑↓ change le relevé",
circumference_count - shown);
series = &app->body_global_series[app->body_metric_selected];
if (series->count == 0U) {
trainlog_surface_printf(app->content, evolution_top, 2,
"ÉVOLUTION DANS LE TEMPS · aucune métrique disponible");
return;
}
{
char title[128];
(void)trainlog_presentation_format_key(title, sizeof(title),
"chart.body.metric", trainlog_presentation_source(
body_metric_label(app->body_metric_selected)));
app_shell_draw_body_points(app, series->points, series->count,
evolution_top, app->layout.content.height - evolution_top - 1,
BODY_METRICS[app->body_metric_selected].unit, title);
}
}
static void app_shell_render_body_controller(TrainlogAppContext *app)
{
TrainlogBodyController *controller = &app->body_controller;
const char *label = controller->field < 14U
? body_metric_label(controller->field)
: trainlog_presentation_source("Mesure");
trainlog_surface_printf(app->content, 4, 2, "%s — champ %zu/14",
controller->editing ? "Modifier le relevé" : "Nouveau relevé",
controller->field + 1U);
if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) {
trainlog_surface_printf(app->content, 7, 2,
"Abandonner toutes les modifications ?");
trainlog_surface_printf(app->content, 9, 2,
"1 abandonner · 0/Échap reprendre");
} else if (controller->phase == TRAINLOG_BODY_MESSAGE) {
trainlog_surface_printf(app->content, 7, 2, "%s",
trainlog_presentation_source(controller->message));
trainlog_surface_printf(app->content, 9, 2, "Entrée pour revenir");
} else {
trainlog_surface_printf(app->content, 7, 2, "%s (%s)", label,
BODY_METRICS[controller->field].unit);
trainlog_surface_printf(app->content, 9, 2, "> %s",
controller->form.text);
trainlog_surface_printf(app->content, 11, 2, "%s",
controller->editing
? "vide conserve · - efface · valeur positive remplace"
: "vide signifie mesure non faite · valeur positive ajoute");
if (controller->message[0] != '\0')
trainlog_surface_printf(app->content, 13, 2, "%s",
trainlog_presentation_source(controller->message));
}
}
static void app_shell_render_profile_controller(TrainlogAppContext *app)
{
TrainlogProfileController *controller = &app->profile_controller;
trainlog_surface_printf(app->content, 4, 2,
"Profil d’estimation corporelle");
if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) {
trainlog_surface_printf(app->content, 7, 2,
"Abandonner les modifications du profil ?");
trainlog_surface_printf(app->content, 9, 2,
"1 abandonner · 0/Échap reprendre");
} else if (controller->phase == TRAINLOG_PROFILE_MESSAGE) {
trainlog_surface_printf(app->content, 7, 2, "%s",
trainlog_presentation_source(controller->message));
} else {
trainlog_surface_printf(app->content, 7, 2, "%s",
controller->phase == TRAINLOG_PROFILE_FORMULA
? "Formule : 1 homme · 2 femme"
: "Taille en cm : 100 à 250");
trainlog_surface_printf(app->content, 9, 2, "> %s",
controller->form.text);
if (controller->message[0] != '\0')
trainlog_surface_printf(app->content, 11, 2, "%s",
trainlog_presentation_source(controller->message));
}
}
static void app_shell_render_body_detail(TrainlogAppContext *app)
{
const TrainlogBodyObservationRecord *record = &app->body_detail;
const bool present[14] = {record->has_body_weight, record->has_neck,
record->has_shoulders, record->has_chest, record->has_waist,
record->has_hips, record->has_left_arm, record->has_right_arm,
record->has_left_forearm, record->has_right_forearm,
record->has_left_thigh, record->has_right_thigh,
record->has_left_calf, record->has_right_calf};
const double values[14] = {record->body_weight_kg, record->neck_cm,
record->shoulders_cm, record->chest_cm, record->waist_cm,
record->hips_cm, record->left_arm_cm, record->right_arm_cm,
record->left_forearm_cm, record->right_forearm_cm,
record->left_thigh_cm, record->right_thigh_cm,
record->left_calf_cm, record->right_calf_cm};
size_t index;
int row = 4;
trainlog_surface_printf(app->content, row++, 2, "Date : %s",
record->observed_at);
trainlog_surface_printf(app->content, row++, 2, "Séance liée : %s",
record->session_id[0] != '\0' ? record->session_id
: trainlog_presentation_source("aucune"));
for (index = app->content_scroll; index < 14U &&
row < app->layout.content.height - 1; ++index) {
if (present[index])
trainlog_surface_printf(app->content, row++, 2, "%-22s %.2f %s",
body_metric_label(index), values[index], BODY_METRICS[index].unit);
else trainlog_surface_printf(app->content, row++, 2, "%-22s —",
body_metric_label(index));
}
}
static void app_shell_surface_segment(TrainlogSurface *surface, int x1, int y1,
int x2, int y2, uint32_t symbol)
{
int x;
if (surface == NULL || x2 < x1) return;
if (x1 == x2) { trainlog_surface_draw(surface, y2, x2, symbol); return; }
for (x = x1; x <= x2; ++x) {
int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1);
trainlog_surface_draw(surface, y, x, symbol);
}
}
static void app_shell_dashboard_sparkline(TrainlogAppContext *app,
const double *values, size_t count, int left, int top, int width, int height)
{
double minimum;
double maximum;
size_t start;
size_t index;
int previous_x = -1;
int previous_y = -1;
/* INVARIANT: a line encodes change between actual observations. A single
* point has no trend and must never acquire a synthetic axis or baseline. */
if (count < 2U || width < 4 || height < 2) return;
start = count > (size_t)width ? count - (size_t)width : 0U;
minimum = values[start];
maximum = values[start];
for (index = start + 1U; index < count; ++index) {
if (values[index] < minimum) minimum = values[index];
if (values[index] > maximum) maximum = values[index];
}
if (minimum == maximum) { minimum -= 1.0; maximum += 1.0; }
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (index = start; index < count; ++index) {
size_t relative = index - start;
size_t visible = count - start;
int x = visible == 1U ? left + width / 2 : left +
(int)((relative * (size_t)(width - 1)) / (visible - 1U));
int y = top + height - 1 - (int)(((values[index] - minimum) /
(maximum - minimum)) * (double)(height - 1));
if (previous_x >= 0)
app_shell_surface_segment(app->content, previous_x, previous_y,
x, y, 0x2584U);
trainlog_surface_draw(app->content, y, x,
index + 1U == count ? 0x2588U : 0x2584U);
previous_x = x;
previous_y = y;
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
}
static void app_shell_dashboard_performance_events(TrainlogAppContext *app,
const TrainlogDashboardSnapshot *dashboard, int row, int column)
{
size_t index;
size_t working = 0U;
size_t maxima = 0U;
for (index = 0U; index < dashboard->week_count; ++index) {
working += dashboard->weeks[index].working_improvements;
maxima += dashboard->weeks[index].max_improvements;
}
/* Text totals are the accessible equivalent of the Unicode plot. Empty
* buckets remain dots: a low block would falsely suggest an event. */
trainlog_surface_printf(app->content, row, column,
"%s · travail %zu · MAX %zu%s",
dashboard_period_bucket_summary(dashboard->period), working, maxima,
working + maxima == 0U ? " · données insuffisantes" : "");
for (index = 0U; index < dashboard->week_count &&
column + 2 + (int)index * 3 < app->layout.content.width; ++index) {
size_t level = dashboard->weeks[index].working_improvements +
dashboard->weeks[index].max_improvements;
trainlog_surface_set_role(app->content,
dashboard->weeks[index].max_improvements > 0U ?
TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_draw(app->content, row + 1, column + 2 + (int)index * 3,
level > 0U ? 0x2588U : 0x00b7U);
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
}
static void app_shell_dashboard_wide_performance_events(TrainlogAppContext *app,
const TrainlogDashboardSnapshot *dashboard, int row, int column)
{
size_t index;
size_t working = 0U;
size_t maxima = 0U;
for (index = 0U; index < dashboard->week_count; ++index) {
working += dashboard->weeks[index].working_improvements;
maxima += dashboard->weeks[index].max_improvements;
}
trainlog_surface_printf(app->content, row, column,
"%s · travail %zu · MAX %zu",
dashboard_period_bucket_summary(dashboard->period), working, maxima);
for (index = 0U; index < dashboard->week_count; ++index) {
size_t level = dashboard->weeks[index].working_improvements +
dashboard->weeks[index].max_improvements;
trainlog_surface_set_role(app->content,
dashboard->weeks[index].max_improvements > 0U ?
TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_draw(app->content, row + 1,
column + (int)index * 3, level > 0U ? 0x2588U : 0x00b7U);
}
if (working + maxima == 0U)
trainlog_surface_printf(app->content, row + 2, column,
"Données insuffisantes.");
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
}
static void app_shell_dashboard_decimal_comma(double value, char *output,
size_t output_size)
{
if (output == NULL || output_size == 0U) return;
(void)trainlog_presentation_format(output, output_size, "%.2f", value);
}
/* WHY: the landing page needs a genuinely global catalogue view, while the
* exercise list remains the destination for inspection. CONTRACT: bars encode
* active canonical-exercise counts prepared in the dashboard snapshot only.
* INVARIANT: zero counts never receive invented bars, and a compact viewport
* deterministically retains the count/label-sorted leading buckets. */
static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
const TrainlogDashboardSnapshot *dashboard, int top, int left, int width,
int rows)
{
size_t index;
size_t bucket_rows;
size_t maximum = 0U;
bool separated_rows;
int row_stride;
int label_width;
int bar_width;
if (width < 20 || rows < 1) return;
if (dashboard->zone_count == 0U) {
trainlog_surface_printf(app->content, top, left,
"Aucun exercice actif à répartir.");
return;
}
for (index = 0U; index < dashboard->zone_count; ++index)
if (dashboard->zones[index].count > maximum) maximum = dashboard->zones[index].count;
label_width = width >= 64 ? 20 : width >= 48 ? 16 : 12;
bar_width = width - label_width - 6;
if (bar_width < 4) return;
/* Spacing is a wide-layout enhancement, never a reason to hide catalogue
* buckets which already fit. Thin bars remain independently legible on
* adjacent rows when the available chart height cannot hold every gap. */
separated_rows = app->layout.columns >= 100 &&
dashboard->zone_count <= (size_t)((rows + 1) / 2);
row_stride = separated_rows ? 2 : 1;
bucket_rows = separated_rows ? (size_t)((rows + 1) / 2) : (size_t)rows;
if (dashboard->zone_count < bucket_rows) bucket_rows = dashboard->zone_count;
if (dashboard->zone_count > bucket_rows) {
size_t summary_row = separated_rows
? bucket_rows * (size_t)row_stride - 1U : bucket_rows;
if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows;
}
for (index = 0U; index < bucket_rows; ++index) {
int row = top + (int)index * row_stride;
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
(size_t)bar_width + maximum - 1U) / maximum);
int block;
/* CONTRACT: label, bar and right-edge count own separate columns;
* independently scaled partial-height rows can never form the former
* solid staircase. Wide layouts leave a blank row between buckets. */
trainlog_surface_printf(app->content, row, left,
"%-*.*s", label_width, label_width, dashboard->zones[index].label);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (block = 0; block < blocks; ++block)
trainlog_surface_draw(app->content, row,
left + label_width + 1 + block, 0x2586U);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, row,
left + label_width + 2 + bar_width, "%zu",
dashboard->zones[index].count);
}
if (dashboard->zone_count > bucket_rows) {
size_t summary_row = separated_rows
? bucket_rows * (size_t)row_stride - 1U : bucket_rows;
if (summary_row >= (size_t)rows) return;
trainlog_surface_printf(app->content,
top + (int)summary_row, left,
"+ %zu zones dans le catalogue", dashboard->zone_count - bucket_rows);
}
}
static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app,
const TrainlogDashboardSnapshot *dashboard, int top, int left, int width,
int height)
{
size_t index;
size_t maximum = 0U;
int baseline;
if (width < 12 || height < 1) return;
if (dashboard->week_count == 0U) {
trainlog_surface_printf(app->content, top, left,
"Aucune séance dans cette période.");
return;
}
for (index = 0U; index < dashboard->week_count; ++index)
if (dashboard->weeks[index].sessions > maximum)
maximum = dashboard->weeks[index].sessions;
trainlog_surface_printf(app->content, top, left,
"%s · actuel %zu séance%s · MAX %zu",
dashboard_period_bucket_summary(dashboard->period),
dashboard->weeks[dashboard->week_count - 1U].sessions,
dashboard->weeks[dashboard->week_count - 1U].sessions == 1U ? "" : "s",
dashboard->weeks[dashboard->week_count - 1U].maxima);
baseline = top + height;
for (index = 0U; index < dashboard->week_count; ++index) {
const char *label = index + 1U == dashboard->week_count
? trainlog_presentation_text("dashboard.current")
: dashboard->weeks[index].label;
int label_margin = 3;
int x = dashboard->week_count > 1U
? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) /
(dashboard->week_count - 1U))
: left + width / 2;
int level = maximum == 0U ? 0 : (int)((dashboard->weeks[index].sessions *
(size_t)height + maximum - 1U) / maximum);
int bar_row;
trainlog_surface_set_role(app->content,
dashboard->weeks[index].maxima > 0U ? TRAINLOG_COLOR_WARNING :
(index + 1U == dashboard->week_count ? TRAINLOG_COLOR_ACCENT :
TRAINLOG_COLOR_GRAPH), TRAINLOG_RGB_BASE,
index + 1U == dashboard->week_count ? TRAINLOG_TEXT_BOLD :
TRAINLOG_TEXT_NORMAL);
if (level == 0) trainlog_surface_draw(app->content, baseline, x, 0x00b7U);
else for (bar_row = 0; bar_row < level; ++bar_row)
trainlog_surface_draw(app->content, baseline - bar_row, x, 0x2588U);
/* Each period bucket owns one x coordinate: the count, optional MAX
* marker, bar and label all remain vertically aligned at that point. */
trainlog_surface_printf(app->content, baseline - (level > 0 ? level - 1 : 0),
x, "%zu", dashboard->weeks[index].sessions);
if (dashboard->weeks[index].maxima > 0U) {
/* WHY: the minimum dashboard has one bar row; lifting its marker
* would overwrite the frequency summary. CONTRACT: compact MAX
* markers sit beside the count, taller charts keep them above it. */
int marker_row = height == 1 ? baseline :
baseline - (level > 0 ? level : 0);
int marker_column = height == 1 ? x - 2 : x;
trainlog_surface_draw(app->content, marker_row, marker_column,
0x25c6U);
}
trainlog_surface_printf(app->content, baseline + 1,
x - (index + 1U == dashboard->week_count ? 3 : 1), "%s", label);
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
}
static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContext *app)
{
const TrainlogDashboardSnapshot *dashboard = &app->dashboard;
double body_values[MAX_BODY_METRIC_POINTS];
size_t index;
for (index = 0U; index < dashboard->body_count; ++index)
body_values[index] = dashboard->body[index].value;
trainlog_surface_printf(app->content, 2, 2,
"Période %s %s %s %s %s",
dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j",
dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j",
dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j",
dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an",
trainlog_presentation_source(
dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"));
trainlog_surface_printf(app->content, 3, 2,
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
dashboard->completed_session_count, dashboard->performed_set_count,
dashboard->distinct_exercise_count, dashboard->explicit_max_count);
if (dashboard->error)
trainlog_surface_printf(app->content, 4, 2,
"Certaines données n’ont pas pu être lues.");
else if (dashboard->invalid_data)
trainlog_surface_printf(app->content, 4, 2,
"Données historiques invalides ignorées.");
else if (dashboard->partial)
trainlog_surface_printf(app->content, 4, 2,
"Vue bornée : historique récent partiel.");
if (app->layout.columns >= 100 && app->layout.content.width >= 70) {
int right = app->layout.content.width / 2 + 2;
int body_width = app->layout.content.width - right - 2;
int frequency_top = 10;
int chart_height = app->layout.content.height - frequency_top - 11;
int zones_top;
int zone_rows;
if (chart_height > 5) chart_height = 5;
if (chart_height < 2) chart_height = 2;
trainlog_surface_printf(app->content, 5, 2, "%s Progression globale",
app->content_selected == 0U ? "▶" : " ");
app_shell_dashboard_wide_performance_events(app, dashboard, 6, 4);
trainlog_surface_printf(app->content, 5, right, "%s Mensurations",
app->content_selected == 1U ? "▶" : " ");
if (dashboard->has_body && dashboard->body_count > 0U) {
double latest = dashboard->body[dashboard->body_count - 1U].value;
char latest_text[32];
app_shell_dashboard_decimal_comma(latest, latest_text,
sizeof(latest_text));
if (dashboard->body_count == 1U)
trainlog_surface_printf(app->content, 6, right,
"%s · %s %s · 1 relevé",
body_metric_label(dashboard->body_metric), latest_text,
BODY_METRICS[dashboard->body_metric].unit);
else {
double delta = latest - dashboard->body[
dashboard->body_count - 2U].value;
char delta_text[32];
app_shell_dashboard_decimal_comma(fabs(delta), delta_text,
sizeof(delta_text));
trainlog_surface_printf(app->content, 6, right,
"%s · %s %s · Δ %c%s",
body_metric_label(dashboard->body_metric), latest_text,
BODY_METRICS[dashboard->body_metric].unit,
delta >= 0.0 ? '+' : '-', delta_text);
app_shell_dashboard_sparkline(app, body_values,
dashboard->body_count, right, 7, body_width, 2);
}
} else trainlog_surface_printf(app->content, 6, right,
"Aucune mensuration.");
trainlog_surface_printf(app->content, 10, 2, "%s Fréquence",
app->content_selected == 2U ? "▶" : " ");
app_shell_dashboard_frequency_chart(app, dashboard, 11, 4,
app->layout.content.width - 8, chart_height);
/* INVARIANT: frequency owns summary, bars, MAX markers and labels;
* distribution begins strictly after its final label row. */
zones_top = 14 + chart_height;
zone_rows = app->layout.content.height - zones_top - 2;
trainlog_surface_printf(app->content, zones_top - 1, 2,
"%s Répartition du catalogue",
app->content_selected == 3U ? "▶" : " ");
app_shell_dashboard_zone_chart(app, dashboard, zones_top, 4,
app->layout.content.width - 8, zone_rows);
if (app->layout.content.height > zones_top + 4)
trainlog_surface_printf(app->content,
app->layout.content.height - 2, 2,
"↑↓ région · Entrée détail · chiffres période");
return;
}
/* Compact planes retain each dashboard region. The smaller form omits a
* decorative body sparkline, not data: a real two-point chart belongs in
* the detail view, while the zone bars and frequency bars stay visible. */
trainlog_surface_printf(app->content, 5, 2, "%s Progression globale",
app->content_selected == 0U ? "▶" : " ");
app_shell_dashboard_performance_events(app, dashboard, 5, 25);
trainlog_surface_printf(app->content, 7, 2, "%s Mensurations",
app->content_selected == 1U ? "▶" : " ");
if (dashboard->has_body && dashboard->body_count > 0U) {
double latest = dashboard->body[dashboard->body_count - 1U].value;
char latest_text[32];
app_shell_dashboard_decimal_comma(latest, latest_text, sizeof(latest_text));
if (dashboard->body_count == 1U)
trainlog_surface_printf(app->content, 8, 4,
"%s · %s %s · 1 relevé, tendance indisponible",
body_metric_label(dashboard->body_metric), latest_text,
BODY_METRICS[dashboard->body_metric].unit);
else {
double delta = latest - dashboard->body[dashboard->body_count - 2U].value;
char delta_text[32];
app_shell_dashboard_decimal_comma(fabs(delta), delta_text,
sizeof(delta_text));
trainlog_surface_printf(app->content, 8, 4,
"%s · %s %s · Δ %c%s",
body_metric_label(dashboard->body_metric), latest_text,
BODY_METRICS[dashboard->body_metric].unit,
delta >= 0.0 ? '+' : '-', delta_text);
}
} else trainlog_surface_printf(app->content, 8, 4,
"Aucune mensuration saisie.");
trainlog_surface_printf(app->content, 10, 2, "%s Fréquence",
app->content_selected == 2U ? "▶" : " ");
app_shell_dashboard_frequency_chart(app, dashboard, 11, 4,
app->layout.content.width - 8,
1);
trainlog_surface_printf(app->content, 14, 2, "%s Répartition du catalogue",
app->content_selected == 3U ? "▶" : " ");
if (app->layout.content.height >= 16)
app_shell_dashboard_zone_chart(app, dashboard, 15, 4,
app->layout.content.width - 8,
app->layout.content.height - 15);
}
static void app_shell_render_body_global(TrainlogAppContext *app)
{
size_t metric;
double minimum = 100.0;
double maximum = 100.0;
int graph_top = 4;
int graph_height = app->layout.content.height >= 20 ? 7 : 5;
int left = 8;
int width = app->layout.content.width - left - 3;
for (metric = 0U; metric < 14U; ++metric) {
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
size_t point;
if (!app->body_global_enabled[metric]) continue;
for (point = 0U; point < series->count; ++point) {
double value;
if (trainlog_body_index100(series->baseline,
series->points[point].value, &value) != TRAINLOG_STATUS_OK)
continue;
if (value < minimum) minimum = value;
if (value > maximum) maximum = value;
}
}
if (maximum == minimum) { minimum = 99.0; maximum = 101.0; }
if (width >= 10 && app->body_global_date_count > 0U) {
trainlog_surface_printf(app->content, graph_top, 2, "%.1f", maximum);
trainlog_surface_printf(app->content, graph_top + graph_height - 1, 2,
"%.1f", minimum);
for (metric = 0U; metric < 14U; ++metric) {
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
size_t point;
int previous_x = -1;
int previous_y = -1;
if (!app->body_global_enabled[metric]) continue;
trainlog_surface_set_role(app->content, series->role,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (point = 0U; point < series->count; ++point) {
double value;
size_t date;
int x;
int y;
if (trainlog_body_index100(series->baseline,
series->points[point].value,
&value) != TRAINLOG_STATUS_OK) continue;
date = global_date_index(app->body_global_dates,
app->body_global_date_count,
series->points[point].observed_at);
x = app->body_global_date_count == 1U ? left + width / 2
: left + (int)((date * (size_t)(width - 1)) /
(app->body_global_date_count - 1U));
y = normalized_graph_row(value, minimum, maximum,
graph_top, graph_height);
if (previous_x >= 0)
app_shell_draw_chart_segment(app->content, previous_x,
previous_y, x, y);
trainlog_surface_draw(app->content, y, x, series->symbol);
previous_x = x;
previous_y = y;
}
}
} else trainlog_surface_printf(app->content, graph_top, 2,
"Aucune série corporelle disponible.");
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (metric = 0U; metric < 14U &&
graph_top + graph_height + 2 + (int)(metric % 7U) <
app->layout.content.height; ++metric) {
int row = graph_top + graph_height + 2 + (int)(metric % 7U);
int column = metric < 7U ? 2 : app->layout.content.width / 2;
trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%",
app->body_global_selected == metric ? '>' : ' ',
app->body_global_enabled[metric] ? 'x' : ' ',
body_metric_label(metric),
app->body_global_series[metric].latest_percent);
trainlog_surface_draw(app->content, row, column + 2,
app->body_global_series[metric].symbol);
}
if (app->body_global_partial)
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
"Limite locale atteinte : certaines séries peuvent être partielles.");
}
static void app_shell_render_body_trends(TrainlogAppContext *app)
{
TrainlogDashboardMonthValue monthly[14][DASHBOARD_MONTH_COUNT];
TrainlogDashboardMonth months[DASHBOARD_MONTH_COUNT];
double baseline[14] = {0.0};
double minimum = 0.0;
double maximum = 0.0;
long current_key;
long first_key;
size_t metric;
size_t plotted = 0U;
int top = 5;
int height = app_shell_graph_height(app, top);
int left = 8;
int width = app->layout.content.width - left - 3;
(void)memset(monthly, 0, sizeof(monthly));
trainlog_surface_printf(app->content, 3, 2,
"Évolution corporelle normalisée — 12 mois");
if (!dashboard_current_month_key(&current_key)) {
trainlog_surface_printf(app->content, top, 2,
"Impossible de déterminer le mois courant.");
return;
}
first_key = current_key - (long)(DASHBOARD_MONTH_COUNT - 1U);
for (metric = 0U; metric < DASHBOARD_MONTH_COUNT; ++metric)
dashboard_month_from_key(first_key + (long)metric, &months[metric]);
for (metric = 0U; metric < 14U; ++metric) {
TrainlogGlobalBodySeries *series = &app->body_global_series[metric];
size_t point;
size_t first = 0U;
size_t visible = 0U;
bool have_first = false;
for (point = 0U; point < series->count; ++point) {
int year;
int month;
long offset;
if (!dashboard_parse_year_month(series->points[point].observed_at,
&year, &month)) continue;
offset = dashboard_month_key(year, month) - first_key;
if (offset < 0L || offset >= (long)DASHBOARD_MONTH_COUNT) continue;
monthly[metric][(size_t)offset].present = true;
monthly[metric][(size_t)offset].value = series->points[point].value;
}
for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point)
if (monthly[metric][point].present) {
if (!have_first) { first = point; have_first = true; }
++visible;
}
if (visible < 2U) continue;
baseline[metric] = monthly[metric][first].value;
++plotted;
for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point) {
double percent;
if (monthly[metric][point].present &&
trainlog_body_percent_change(baseline[metric],
monthly[metric][point].value,
&percent) == TRAINLOG_STATUS_OK) {
if (percent < minimum) minimum = percent;
if (percent > maximum) maximum = percent;
}
}
}
if (plotted == 0U || height < 3 || width < 12) {
trainlog_surface_printf(app->content, top, 2,
"Premières courbes après 2 mois relevés pour une même mesure.");
return;
}
if (minimum == maximum) { minimum = -1.0; maximum = 1.0; }
trainlog_surface_printf(app->content, top, 2, "%+.1f%%", maximum);
trainlog_surface_printf(app->content, top + height - 1, 2, "%+.1f%%", minimum);
for (metric = 0U; metric < 14U; ++metric) {
size_t month;
int previous_x = -1;
int previous_y = -1;
long previous_month = -2L;
if (baseline[metric] <= 0.0) continue;
trainlog_surface_set_role(app->content, GLOBAL_BODY_ROLES[metric],
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (month = 0U; month < DASHBOARD_MONTH_COUNT; ++month) {
double percent;
int x;
int y;
if (!monthly[metric][month].present) {
previous_x = -1; previous_y = -1; previous_month = -2L;
continue;
}
if (trainlog_body_percent_change(baseline[metric],
monthly[metric][month].value,
&percent) != TRAINLOG_STATUS_OK) continue;
x = left + (int)((month * (size_t)(width - 1)) /
(DASHBOARD_MONTH_COUNT - 1U));
y = normalized_graph_row(percent, minimum, maximum, top, height);
if (previous_x >= 0 && previous_month + 1L == (long)month)
app_shell_draw_chart_segment(app->content, previous_x, previous_y,
x, y);
trainlog_surface_draw(app->content, y, x, GLOBAL_BODY_SYMBOLS[metric]);
previous_x = x; previous_y = y; previous_month = (long)month;
}
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (top + height < app->layout.content.height)
trainlog_surface_printf(app->content, top + height, left,
"%02d/%02d … %02d/%02d", months[0].month,
months[0].year % 100, months[11].month, months[11].year % 100);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (app->body_global_partial)
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
"Limite locale atteinte : vue 12 mois potentiellement partielle.");
}
static void app_shell_render_body_analytics(TrainlogAppContext *app)
{
const TrainlogBodyObservationRecord *latest = app->loaded_count > 0U
? &app->body_records[0] : NULL;
TrainlogBodyAnalyticsResult current;
TrainlogBodyAnalyticsResult oldest_estimate;
const TrainlogBodyObservationRecord *oldest_weight =
body_analytics_oldest_weight(app->body_records, app->loaded_count);
const TrainlogBodyObservationRecord *oldest_waist =
body_analytics_oldest_waist(app->body_records, app->loaded_count);
bool has_current = latest != NULL && trainlog_body_analytics_calculate(
app->body_has_profile ? &app->body_profile : NULL, latest,
&current) == TRAINLOG_STATUS_OK;
bool has_oldest_estimate = app->body_has_profile &&
body_analytics_oldest_estimate(&app->body_profile, app->body_records,
app->loaded_count, &oldest_estimate);
int row = 4;
if (latest == NULL) {
trainlog_surface_printf(app->content, row, 2, "Aucun relevé corporel.");
return;
}
trainlog_surface_printf(app->content, row++, 2, "Page %zu/2 · relevé %s",
app->content_selected + 1U, latest->observed_at);
trainlog_surface_printf(app->content, row++, 2, "Profil : %s",
app->body_has_profile
? (app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
? "formule femme" : "formule homme")
: "non configuré");
if (app->body_has_profile)
trainlog_surface_printf(app->content, row++, 2, "Taille : %.1f cm",
app->body_profile.height_cm);
#define BODY_ANALYTIC(label_, has_, value_, suffix_) \
trainlog_surface_printf(app->content, row++, 2, "%-27s %s%.2f %s", \
label_, (has_) ? "" : "— ", (has_) ? (value_) : 0.0, suffix_)
row += 1;
if (app->content_selected == 0U) {
BODY_ANALYTIC("Graisse estimée", has_current && current.has_body_fat_estimate,
current.body_fat_percent, "%");
BODY_ANALYTIC("Masse grasse estimée", has_current && current.has_fat_mass_estimate,
current.fat_mass_kg, "kg");
BODY_ANALYTIC("Masse maigre estimée", has_current && current.has_lean_mass_estimate,
current.lean_mass_kg, "kg");
if (latest->has_body_weight)
trainlog_surface_printf(app->content, row++, 2,
"Poids %.1f kg · variation %+.1f kg", latest->body_weight_kg,
oldest_weight != NULL ? latest->body_weight_kg -
oldest_weight->body_weight_kg : 0.0);
if (latest->has_waist)
trainlog_surface_printf(app->content, row++, 2,
"Tour de taille %.1f cm · variation %+.1f cm", latest->waist_cm,
oldest_waist != NULL ? latest->waist_cm -
oldest_waist->waist_cm : 0.0);
if (has_current && current.has_body_fat_estimate && has_oldest_estimate)
trainlog_surface_printf(app->content, row++, 2,
"Variation graisse estimée %+.2f point(s)",
current.body_fat_percent - oldest_estimate.body_fat_percent);
trainlog_surface_printf(app->content, row + 1, 2,
"Estimation anthropométrique : tendance, pas mesure directe.");
} else {
BODY_ANALYTIC("Taille / hanches", has_current && current.has_waist_hip_ratio,
current.waist_hip_ratio, "");
BODY_ANALYTIC("Épaules / taille", has_current && current.has_shoulder_waist_ratio,
current.shoulder_waist_ratio, "");
BODY_ANALYTIC("Poitrine / taille", has_current && current.has_chest_waist_ratio,
current.chest_waist_ratio, "");
BODY_ANALYTIC("Asymétrie bras", has_current && current.has_arm_asymmetry,
current.arm_asymmetry_percent, "%");
BODY_ANALYTIC("Asymétrie avant-bras", has_current && current.has_forearm_asymmetry,
current.forearm_asymmetry_percent, "%");
BODY_ANALYTIC("Asymétrie cuisses", has_current && current.has_thigh_asymmetry,
current.thigh_asymmetry_percent, "%");
BODY_ANALYTIC("Asymétrie mollets", has_current && current.has_calf_asymmetry,
current.calf_asymmetry_percent, "%");
}
#undef BODY_ANALYTIC
}
static void app_shell_render_sync(TrainlogAppContext *app)
{
TrainlogSyncController *sync = &app->sync_controller;
char result[768];
size_t index;
int row = 4;
if (sync->showing_detail) {
trainlog_surface_printf(app->content, row++, 2, "Détail %s",
sync->history[sync->selected].sync_id);
for (index = sync->detail_scroll; index < sync->detail_line_count &&
row < app->layout.content.height; ++index)
trainlog_surface_printf(app->content, row++, 2, "%.*s",
app->layout.content.width - 4, sync->detail_lines[index]);
return;
}
if (!sync->probe_known)
trainlog_surface_printf(app->content, row++, 2,
"État appareil non actualisé · r pour rechercher.");
else if (sync->probe_status == TRAINLOG_STATUS_OK && sync->device.connected) {
trainlog_surface_printf(app->content, row++, 2, "Appareil : %s %s",
sync->device.device.vendor, sync->device.device.model);
trainlog_surface_printf(app->content, row++, 2,
"Stockage : %.2f GiB libres / %.2f GiB",
sync_bytes_to_gib(sync->device.storage.free_space_bytes),
sync_bytes_to_gib(sync->device.storage.max_capacity_bytes));
} else trainlog_surface_printf(app->content, row++, 2,
sync->probe_status == TRAINLOG_STATUS_CONFLICT
? "Service de synchronisation occupé."
: "Aucun appareil MTP Trainlog détecté.");
if (sync->running) {
trainlog_surface_printf(app->content, row + 1, 2,
"Synchronisation en cours… PC↔Android");
trainlog_surface_printf(app->content, row + 3, 2,
"Une seule exécution peut être active.");
return;
}
if (sync->action.confirming) {
trainlog_surface_printf(app->content, row + 1, 2, "%s",
trainlog_sync_screen_confirmation(sync->action.direction));
trainlog_surface_printf(app->content, row + 3, 2,
"Entrée confirme une exécution · Échap annule");
return;
}
if (sync->has_report) {
sync_controller_present_result(sync, result, sizeof(result));
trainlog_surface_printf(app->content, row++, 2, "%s", result);
}
row += 1;
trainlog_surface_printf(app->content, row++, 2,
"Historique des synchronisations");
if (sync->history_count == 0U)
trainlog_surface_printf(app->content, row, 2,
"Aucune synchronisation enregistrée.");
for (index = 0U; index < sync->history_count &&
row < app->layout.content.height; ++index) {
const TrainlogSyncHistoryEntry *entry = &sync->history[index];
/* CONTRACT: journal summary bytes stay available to persistence/log
* consumers, but the normal UI exposes only localized typed state. */
const char *summary = trainlog_presentation_text(
entry->direction_known &&
entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL
? "ui.historical.entry"
: entry->success ? "sync.state.success"
: "sync.state.failure");
trainlog_surface_printf(app->content, row++, 2, "%c %-16s %c %-17.17s %.*s",
index == sync->selected ? '>' : ' ', entry->timestamp,
entry->success ? '+' : '!',
"PC↔Android",
app->layout.content.width > 45 ? app->layout.content.width - 45 : 8,
summary);
}
}
static const char *statistics_window_label(TrainlogStatisticsWindow window)
{
switch (window) {
case TRAINLOG_STATISTICS_7_DAYS:
return trainlog_presentation_source("semaine courante");
case TRAINLOG_STATISTICS_30_DAYS:
return trainlog_presentation_source("mois courant");
case TRAINLOG_STATISTICS_ALL:
return trainlog_presentation_source("Tout l’historique");
}
return trainlog_presentation_source("Période");
}
static void statistics_duration(char *output, size_t capacity, uint64_t seconds)
{
uint64_t hours = seconds / UINT64_C(3600);
uint64_t minutes = (seconds % UINT64_C(3600)) / UINT64_C(60);
(void)snprintf(output, capacity, "%lluh%02llu",
(unsigned long long)hours, (unsigned long long)minutes);
}
static void app_shell_render_statistics_hub(TrainlogAppContext *app)
{
static const char *const labels[] = {
"Corps · poids et mensurations",
"Entraînement · vue globale et régularité",
"Exercices · faits et courbes par identité",
"Zones · travail principal et secondaire",
"MAX · historique des tests enregistrés"
};
size_t index;
trainlog_surface_printf(app->content, 3, 2,
"Centre d’analyse · données enregistrées uniquement");
for (index = 0U; index < 5U; ++index) {
bool selected = app->content_selected == index;
trainlog_surface_set_role(app->content,
selected ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
selected ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 5 + (int)index * 2, 2,
"%s %s", selected ? "▶" : " ",
trainlog_presentation_source(labels[index]));
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (app->layout.content.height > 15)
trainlog_surface_printf(app->content, 15, 2,
"Entrée ouvrir · aucune recommandation ni score global");
}
static void app_shell_render_statistics_chart(TrainlogAppContext *app,
int top, int height)
{
TrainlogChartPoint points[TRAINLOG_STATISTICS_BUCKET_MAX];
size_t count = app->statistics.bucket_count;
size_t index;
const char *unit;
const char *metric;
char title[128];
TrainlogChartOptions options = {0};
if (count > TRAINLOG_STATISTICS_BUCKET_MAX)
count = TRAINLOG_STATISTICS_BUCKET_MAX;
for (index = 0U; index < count; ++index) {
const TrainlogStatisticsBucket *bucket = &app->statistics.buckets[index];
points[index].timestamp = bucket->timestamp;
points[index].timestamp_label = bucket->label;
if (app->statistics_graph == 0U)
points[index].value = bucket->loaded_volume_kg;
else if (app->statistics_graph == 1U)
points[index].value = (double)bucket->sessions;
else if (app->statistics_graph == 2U)
points[index].value = (double)bucket->sets;
else if (app->statistics_graph == 3U)
points[index].value = (double)bucket->repetitions;
else points[index].value = (double)bucket->immediate_feedback_count;
}
if (app->statistics_graph == 0U) { metric = "Volume chargé"; unit = "kg"; }
else if (app->statistics_graph == 1U) { metric = "Séances"; unit = "séances"; }
else if (app->statistics_graph == 2U) { metric = "Séries"; unit = "séries"; }
else if (app->statistics_graph == 3U) { metric = "Répétitions"; unit = "reps"; }
else { metric = "Ressentis immédiats"; unit = "retours"; }
metric = trainlog_presentation_source(metric);
unit = trainlog_presentation_source(unit);
(void)trainlog_presentation_format_key(title, sizeof(title),
"chart.statistics", metric,
app->statistics_window == TRAINLOG_STATISTICS_7_DAYS
? trainlog_presentation_source("semaines calendaires")
: app->statistics_window == TRAINLOG_STATISTICS_30_DAYS
? trainlog_presentation_source("mois calendaires")
: app->statistics.bucket_kind ==
TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH
? trainlog_presentation_source("mois · historique complet")
: trainlog_presentation_source("semaines · historique complet"),
app->statistics_graph + 1U);
options.non_negative = true;
options.zero_baseline = true;
if (count > 0U) {
options.has_time_domain = true;
options.minimum_timestamp = points[0].timestamp;
options.maximum_timestamp =
app->statistics.buckets[count - 1U].end_timestamp;
}
trainlog_chart_render_bars(app->content,
(TrainlogChartRect){top, 2, height, app->layout.content.width - 4},
points, count, unit, title, &options);
}
static void app_shell_render_statistics_training(TrainlogAppContext *app)
{
const TrainlogStatisticsSummary *value = &app->statistics;
char activity[32];
char session_duration[32];
int chart_top = 10;
if (app->statistics_error) {
trainlog_surface_printf(app->content, 4, 2,
"Impossible de calculer les statistiques enregistrées.");
return;
}
statistics_duration(activity, sizeof(activity), value->activity_duration_seconds);
statistics_duration(session_duration, sizeof(session_duration),
value->session_duration_seconds);
if (app->layout.content.width >= 86 && app->layout.content.height >= 24) {
int right = app->layout.content.width / 2 + 1;
trainlog_surface_printf(app->content, 3, 2,
"Période : %s · 1 7j · 2 30j · 3 Tout",
statistics_window_label(app->statistics_window));
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(app->content, 5, 2, "ACTIVITÉ");
trainlog_surface_printf(app->content, 5, right, "TRAVAIL");
trainlog_surface_printf(app->content, 9, 2, "RESSENTIS");
trainlog_surface_printf(app->content, 9, right, "PRÉVU / RÉALISÉ");
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 6, 2,
"Séances %zu · jours %zu · semaines %zu", value->sessions,
value->active_days, value->active_weeks);
if (value->sessions > 0U)
trainlog_surface_printf(app->content, 7, 2,
"Exercices %zu · occurrences %zu · dernière J-%zu",
value->distinct_exercises, value->occurrences,
value->days_since_last_session);
else trainlog_surface_printf(app->content, 7, 2,
"Exercices 0 · occurrences 0 · aucune séance");
trainlog_surface_printf(app->content, 6, right,
"Séries %zu · répétitions %llu", value->sets,
(unsigned long long)value->repetitions);
trainlog_surface_printf(app->content, 7, right,
"Volume %.1f kg · activité %s", value->loaded_volume_kg, activity);
if (value->sessions_with_duration > 0U)
trainlog_surface_printf(app->content, 8, right,
"Séances horodatées %s · moyenne %.0f min",
session_duration,
(double)value->session_duration_seconds / 60.0 /
(double)value->sessions_with_duration);
else trainlog_surface_printf(app->content, 8, right,
"Durée moyenne indisponible");
trainlog_surface_printf(app->content, 10, 2,
"Immédiats %zu · J+1 séance %zu", value->immediate_feedback_count,
value->followup_count);
if (value->top_feedback_count > 0U)
trainlog_surface_printf(app->content, 11, 2, "%s · %zu retour%s",
value->top_feedback_exercise, value->top_feedback_count,
value->top_feedback_count == 1U ? "" : "s");
else trainlog_surface_printf(app->content, 11, 2,
"Texte libre non analysé");
trainlog_surface_printf(app->content, 10, right,
"Occurrences liées %zu · séries %llu/%llu",
value->planned_actual_occurrences,
(unsigned long long)value->planned_sets,
(unsigned long long)value->actual_sets_for_plans);
trainlog_surface_printf(app->content, 11, right,
"Répétitions %llu/%llu",
(unsigned long long)value->planned_repetitions,
(unsigned long long)value->actual_repetitions_for_plans);
app_shell_render_statistics_chart(app, 13,
app->layout.content.height - 14);
return;
}
trainlog_surface_printf(app->content, 3, 2,
"Période : %s · 1 7j · 2 30j · 3 Tout",
statistics_window_label(app->statistics_window));
trainlog_surface_printf(app->content, 4, 2,
"Séances %zu · exercices %zu · occurrences %zu · jours %zu · semaines %zu",
value->sessions, value->distinct_exercises, value->occurrences,
value->active_days, value->active_weeks);
if (value->sessions > 0U && app->layout.content.width > 82)
trainlog_surface_printf(app->content, 4, 73,
"Dernière : J-%zu", value->days_since_last_session);
trainlog_surface_printf(app->content, 5, 2,
"Séries %zu · répétitions %llu · durée d’activité %s",
value->sets, (unsigned long long)value->repetitions, activity);
if (value->sessions_with_duration > 0U)
trainlog_surface_printf(app->content, 6, 2,
"Durée séances horodatées %s · moyenne %.0f min (%zu séance%s)",
session_duration,
(double)value->session_duration_seconds / 60.0 /
(double)value->sessions_with_duration,
value->sessions_with_duration,
value->sessions_with_duration == 1U ? "" : "s");
else trainlog_surface_printf(app->content, 6, 2,
"Durée moyenne : indisponible (fin de séance absente)");
trainlog_surface_printf(app->content, 7, 2,
"Volume chargé valide : %.1f kg", value->loaded_volume_kg);
trainlog_surface_printf(app->content, 8, 2,
"Prévu/réalisé mêmes occurrences : %zu · séries %llu/%llu · reps %llu/%llu",
value->planned_actual_occurrences,
(unsigned long long)value->planned_sets,
(unsigned long long)value->actual_sets_for_plans,
(unsigned long long)value->planned_repetitions,
(unsigned long long)value->actual_repetitions_for_plans);
if (value->top_feedback_count > 0U && app->layout.content.width > 85)
trainlog_surface_printf(app->content, 9, 2,
"Ressentis immédiats %zu · plus fréquent %s (%zu) · J+1 séance %zu · texte libre non analysé",
value->immediate_feedback_count, value->top_feedback_exercise,
value->top_feedback_count, value->followup_count);
else trainlog_surface_printf(app->content, 9, 2,
"Ressentis : immédiats %zu · J+1 séance %zu · texte libre non analysé",
value->immediate_feedback_count, value->followup_count);
if (app->layout.content.height - chart_top >= 3)
app_shell_render_statistics_chart(app, chart_top,
app->layout.content.height - chart_top - 1);
}
static const char *exercise_series_label(TrainlogExerciseSeriesKind kind)
{
switch (kind) {
case TRAINLOG_EXERCISE_SERIES_LOAD:
return trainlog_presentation_source("Charge observée");
case TRAINLOG_EXERCISE_SERIES_VOLUME:
return trainlog_presentation_source("Volume chargé");
case TRAINLOG_EXERCISE_SERIES_REPETITIONS:
return trainlog_presentation_source("Répétitions");
case TRAINLOG_EXERCISE_SERIES_SETS:
return trainlog_presentation_source("Séries");
case TRAINLOG_EXERCISE_SERIES_MAX:
return trainlog_presentation_source("MAX enregistrés");
case TRAINLOG_EXERCISE_SERIES_COUNT: break;
}
return trainlog_presentation_source("Mesure");
}
static const char *exercise_series_unit(TrainlogExerciseSeriesKind kind)
{
if (kind == TRAINLOG_EXERCISE_SERIES_LOAD ||
kind == TRAINLOG_EXERCISE_SERIES_VOLUME ||
kind == TRAINLOG_EXERCISE_SERIES_MAX) return "kg";
return kind == TRAINLOG_EXERCISE_SERIES_REPETITIONS ? "reps" : "séries";
}
static void app_shell_render_exercise_statistics(TrainlogAppContext *app)
{
const TrainlogExerciseStatistics *value = &app->exercise_statistics;
TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX];
TrainlogExerciseSeriesKind kind = app->exercise_statistics_graph;
size_t count = value->series_count[kind];
char title[128];
size_t index;
TrainlogChartOptions options = {0};
if (app->statistics_error) {
trainlog_surface_printf(app->content, 4, 2,
"Impossible de calculer les statistiques de cet exercice.");
return;
}
trainlog_surface_printf(app->content, 3, 2, "%s · %s",
value->name, value->exercise_id);
trainlog_surface_printf(app->content, 4, 2,
"Occurrences %zu · séances %zu · séries %zu · répétitions %llu",
value->occurrences, value->sessions, value->sets,
(unsigned long long)value->repetitions);
trainlog_surface_printf(app->content, 5, 2,
"Première %s · dernière %s · fréquence 7j %zu · 30j %zu",
value->first_at[0] != '\0' ? value->first_at : "—",
value->last_at[0] != '\0' ? value->last_at : "—",
value->frequency_7, value->frequency_30);
if (strcmp(value->load_semantics, "external") == 0)
trainlog_surface_printf(app->content, 6, 2,
"Charge max %.1f kg · volume total %.1f kg · moyen/occ. %.1f kg · /séance %.1f kg",
value->has_highest_load ? value->highest_load_kg : 0.0,
value->loaded_volume_kg,
value->occurrences > 0U ? value->loaded_volume_kg /
(double)value->occurrences : 0.0,
value->sessions > 0U ? value->loaded_volume_kg /
(double)value->sessions : 0.0);
else if (value->duration_seconds > 0U)
trainlog_surface_printf(app->content, 6, 2,
"Durée enregistrée : %llu s · volume chargé non applicable",
(unsigned long long)value->duration_seconds);
else trainlog_surface_printf(app->content, 6, 2,
"Volume chargé non applicable à ce profil.");
trainlog_surface_printf(app->content, 7, 2,
"MAX %zu · ressentis immédiats %zu · prévu/réalisé séries %llu/%llu",
value->max_count, value->immediate_feedback_count,
(unsigned long long)value->planned_sets,
(unsigned long long)value->actual_sets_for_plans);
if (value->has_latest_planned_actual_weight && app->layout.content.width > 86)
trainlog_surface_printf(app->content, 7, 66, "charge %.1f/%.1f kg",
value->latest_planned_weight_kg, value->latest_actual_weight_kg);
for (index = 0U; index < count; ++index) {
points[index].timestamp = value->series[kind][index].timestamp;
points[index].value = value->series[kind][index].value;
points[index].timestamp_label = value->series[kind][index].timestamp_label;
}
(void)trainlog_presentation_format_key(title, sizeof(title),
"chart.exercise",
exercise_series_label(kind), (int)kind + 1);
options.non_negative = true;
trainlog_chart_render_line(app->content,
(TrainlogChartRect){9, 2, app->layout.content.height - 10,
app->layout.content.width - 4},
points, count, exercise_series_unit(kind), title, &options);
}
static void app_shell_render_statistics_zones(TrainlogAppContext *app)
{
size_t index;
size_t maximum = 1U;
int row = 9;
if (app->statistics_error) {
trainlog_surface_printf(app->content, 4, 2,
"Impossible de calculer les statistiques par zone.");
return;
}
if (app->statistics_zone_count == 0U) {
trainlog_surface_printf(app->content, 4, 2,
"Aucune zone associée à un entraînement enregistré.");
return;
}
for (index = 0U; index < app->statistics_zone_count; ++index) {
TrainlogZoneStatistics *zone = &app->statistics_zones[index];
const TrainlogBodyZone *catalog = trainlog_body_zone_catalog_lookup(zone->zone_id);
if (catalog != NULL)
(void)snprintf(zone->label, sizeof(zone->label), "%s", catalog->display_name);
if (zone->primary_30 > maximum) maximum = zone->primary_30;
if (zone->secondary_30 > maximum) maximum = zone->secondary_30;
}
{
const TrainlogZoneStatistics *selected =
&app->statistics_zones[app->statistics_zone_selected];
trainlog_surface_printf(app->content, 3, 2,
"%s · séances %zu · exercices distincts %zu",
selected->label, selected->sessions_any,
selected->distinct_exercises);
trainlog_surface_printf(app->content, 4, 2,
"7j principal %zu · secondaire %zu · 30j principal %zu · secondaire %zu",
selected->primary_7, selected->secondary_7,
selected->primary_30, selected->secondary_30);
trainlog_surface_printf(app->content, 5, 2,
"Dernier principal : %s",
selected->latest_primary[0] != '\0' ? selected->latest_primary : "—");
trainlog_surface_printf(app->content, 6, 2,
"Dernier secondaire : %s",
selected->latest_secondary[0] != '\0' ? selected->latest_secondary : "—");
trainlog_surface_printf(app->content, 7, 2,
"↑/↓ zone · barres = séances distinctes sur 30 jours");
}
for (index = 0U; index < app->statistics_zone_count &&
row + 1 < app->layout.content.height; ++index) {
const TrainlogZoneStatistics *zone = &app->statistics_zones[index];
int capacity = app->layout.content.width - 34;
int primary;
int secondary;
if (capacity < 4) capacity = 4;
primary = (int)((zone->primary_30 * (size_t)capacity) / maximum);
secondary = (int)((zone->secondary_30 * (size_t)capacity) / maximum);
trainlog_surface_printf(app->content, row, 2, "%c %-15.15s P",
index == app->statistics_zone_selected ? '>' : ' ', zone->label);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (int column = 0; column < primary; ++column)
trainlog_surface_draw(app->content, row, 22 + column, 0x2588U);
trainlog_surface_printf(app->content, row, 24 + capacity, "%zu",
zone->primary_30);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, row + 1, 19, "S");
for (int column = 0; column < secondary; ++column)
trainlog_surface_draw(app->content, row + 1, 22 + column, 0x2584U);
trainlog_surface_printf(app->content, row + 1, 24 + capacity, "%zu",
zone->secondary_30);
row += 2;
}
if (app->statistics_zones_partial)
trainlog_surface_printf(app->content, app->layout.content.height - 1, 2,
"Vue partielle : capacité locale de présentation atteinte.");
}
static void app_shell_render_content(TrainlogAppContext *app)
{
TrainlogAppRoute route = app->navigation.current.route;
int width = app->layout.content.width;
/* CONTRACT: renderers consume controller-owned immutable snapshots and
* write coordinates relative to this persistent content plane. SQL,
* device discovery and sync execution stay in explicit controller actions. */
trainlog_surface_erase(app->content);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(app->content, 1, 2, "%s", trainlog_route_title(route));
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (app->body_controller.phase != TRAINLOG_BODY_IDLE) {
app_shell_render_body_controller(app);
} else if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) {
app_shell_render_profile_controller(app);
} else if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
app_shell_render_exercise_controller(app);
} else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
app_shell_render_equipment_controller(app);
} else if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT ||
route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
app_shell_render_session_controller(app);
} else if (route == TRAINLOG_ROUTE_HOME) {
trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit.");
trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance");
trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations");
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
"Séances effectuées"};
size_t index;
for (index = 0U; index < 3U; ++index) {
trainlog_surface_set_role(app->content,
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
app->content_selected == index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 4 + (int)index * 2, 2,
"%s %s", app->content_selected == index ? "▶ [SÉLECTION]" : " ", rows[index]);
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
} else if (route == TRAINLOG_ROUTE_STATS) {
app_shell_render_statistics_hub(app);
} else if (route == TRAINLOG_ROUTE_STATS_TRAINING) {
app_shell_render_statistics_training(app);
} else if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) {
app_shell_render_exercise_statistics(app);
} else if (route == TRAINLOG_ROUTE_STATS_ZONES) {
app_shell_render_statistics_zones(app);
} else if (route == TRAINLOG_ROUTE_SESSION_DETAIL) {
int row = 3;
if (app->session_detail_error) trainlog_surface_printf(app->content, row, 2,
"Impossible de charger la séance.");
else {
TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U
? &app->session_entries[app->session_entry_selected] : NULL;
trainlog_surface_printf(app->content, row++, 2, "Début : %s",
app->session_detail.started_at);
trainlog_surface_printf(app->content, row++, 2, "Fin : %s",
app->session_detail.ended_at[0] != '\0'
? app->session_detail.ended_at : "séance ouverte");
trainlog_surface_printf(app->content, row++, 2, "Type : %s",
session_type_label(app->session_detail.session_type));
row += 1;
if (entry == NULL) trainlog_surface_printf(app->content, row, 2,
"Aucun exercice dans cette séance.");
else {
char duration[64] = "";
trainlog_surface_printf(app->content, row++, 2,
"Exercice %zu/%zu — %s", app->session_entry_selected + 1U,
app->session_entry_count, entry->name);
trainlog_surface_printf(app->content, row++, 2,
"Occurrence : %s", entry->entry_id);
{
size_t feedback_index;
bool heading = false;
for (feedback_index = 0U;
feedback_index < app->exercise_feedback_count &&
row < app->layout.content.height; ++feedback_index) {
TrainlogFeedbackView *feedback = &app->exercise_feedback[feedback_index];
char label[32];
if (strcmp(feedback->entry_id, entry->entry_id) != 0) continue;
if (!heading) {
trainlog_surface_printf(app->content, row++, 2, "RESSENTIS EXERCICES");
heading = true;
}
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
app->session_detail.started_at, feedback->observed_at,
true, label, sizeof(label));
trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(label),
feedback->raw_text);
}
}
if (entry->equipment_id[0] != '\0')
trainlog_surface_printf(app->content, row++, 2,
"Équipement : %s (i pour la fiche)", entry->equipment_id);
if (entry->has_max_weight != 0)
trainlog_surface_printf(app->content, row++, 2,
"MAX réellement mesuré : %.2f kg", entry->max_weight_kg);
else if (entry->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
(void)trainlog_duration_format(entry->continuous_duration_seconds,
duration, sizeof(duration));
trainlog_surface_printf(app->content, row++, 2,
"Réalisé : activité continue · %s", duration);
if (entry->has_continuous_speed != 0)
trainlog_surface_printf(app->content, row++, 2,
"Vitesse : %.1f km/h", entry->continuous_speed_kmh);
if (entry->has_continuous_distance != 0)
trainlog_surface_printf(app->content, row++, 2,
"Distance : %.2f km", entry->continuous_distance_km);
} else {
size_t index;
trainlog_surface_printf(app->content, row++, 2,
"Plan : %d série(s) · repos %d s%s", entry->target_sets,
entry->rest_seconds, entry->has_target_weight != 0
? " · charge/assistance cible définie" : "");
trainlog_surface_printf(app->content, row++, 2,
"Réalisé : %zu série(s)", entry->actual_set_count);
for (index = app->session_set_scroll;
index < entry->actual_set_count && row < app->layout.content.height;
++index) {
char metric[64]; char weight[48];
if (entry->tracking_mode == TRAINLOG_TRACKING_DURATION)
(void)trainlog_duration_format(entry->actual_sets[index].duration_seconds,
metric, sizeof(metric));
else (void)snprintf(metric, sizeof(metric), "%d reps",
entry->actual_sets[index].reps);
if (entry->actual_sets[index].has_weight)
(void)trainlog_presentation_format(weight, sizeof(weight), "%.2f kg",
entry->actual_sets[index].weight_kg);
else (void)snprintf(weight, sizeof(weight), "—");
trainlog_surface_printf(app->content, row++, 4,
"%zu. %-18s %s", index + 1U, metric, weight);
}
}
}
if (app->session_followup_count > 0U && row < app->layout.content.height) {
size_t followup_index;
trainlog_surface_printf(app->content, row++, 2, "SUIVI APRÈS SÉANCE");
for (followup_index = 0U; followup_index < app->session_followup_count &&
row < app->layout.content.height; ++followup_index) {
char label[32];
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
app->session_detail.started_at,
app->session_followups[followup_index].observed_at,
false, label, sizeof(label));
trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(label),
app->session_followups[followup_index].raw_text);
}
}
}
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
const TrainlogResolvedEquipment *item = &app->equipment_detail;
size_t relation_index;
int relation_row = 12;
trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name);
trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id);
trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s",
item->label_name[0] != '\0' ? item->label_name : "—");
trainlog_surface_printf(app->content, 8, 2, "Type : %s",
item->equipment_type[0] != '\0' ? item->equipment_type : "—");
trainlog_surface_printf(app->content, 9, 2, "Charge : %s",
item->load_semantics[0] != '\0' ? item->load_semantics : "—");
trainlog_surface_printf(app->content, 10, 2, "Origine : %s",
equipment_origin_label(item->origin));
trainlog_surface_printf(app->content, relation_row++, 2, "Exercices possibles");
for (relation_index = 0U;
relation_index < trainlog_equipment_exercise_relation_count() &&
relation_row < app->layout.content.height; ++relation_index) {
const TrainlogEquipmentExerciseRelation *relation =
trainlog_equipment_exercise_relation_at(relation_index);
const TrainlogExerciseKnowledge *exercise;
const char *confidence;
if (relation == NULL || strcmp(relation->equipment_id,
item->equipment_id) != 0) continue;
exercise = trainlog_exercise_knowledge_lookup(relation->exercise_id);
if (exercise == NULL) continue;
confidence = strcmp(relation->confidence, "high") == 0
? trainlog_presentation_text("label.confidence.high")
: strcmp(relation->confidence, "moderate") == 0
? trainlog_presentation_text("label.confidence.moderate")
: trainlog_presentation_text("label.confidence.uncertain");
trainlog_surface_printf(app->content, relation_row++, 4,
"> %.38s · confiance %s · sources vérifiées",
exercise->exercise_name, confidence);
}
if (relation_row == 13)
trainlog_surface_printf(app->content, relation_row, 4,
"Aucune association vérifiée; aucune anatomie déduite du nom.");
} else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
const TrainlogExercise *item = &app->exercise_detail;
const TrainlogBodyZone *primary = NULL;
const TrainlogBodyZone *group = NULL;
char secondary[256] = "";
size_t index;
int row = 4;
for (index = 0U; index < app->exercise_zone_count; ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
app->exercise_zones[index].zone_id);
if (app->exercise_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY)
primary = zone;
else if (zone != NULL) {
size_t used = strlen(secondary);
if (used < sizeof(secondary) - 1U)
(void)snprintf(secondary + used, sizeof(secondary) - used,
"%s%s", used > 0U ? ", " : "", zone->display_name);
}
}
if (primary != NULL && primary->parent_zone_id != NULL)
group = trainlog_body_zone_catalog_lookup(primary->parent_zone_id);
trainlog_surface_printf(app->content, row++, 2, "%s", item->name);
trainlog_surface_printf(app->content, row++, 2, "Identifiant : %s", item->exercise_id);
trainlog_surface_printf(app->content, row++, 2, "Suivi : %s · organisation : %s",
item->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée",
item->recording_mode == TRAINLOG_RECORDING_CONTINUOUS
? trainlog_presentation_source("continue")
: trainlog_presentation_source("séries"));
if (app->exercise_detail_metadata_error)
trainlog_surface_printf(app->content, row++, 2,
"Métadonnées de zones/usage partiellement indisponibles.");
else {
trainlog_surface_printf(app->content, row++, 2,
"Zone principale : %s", primary != NULL
? primary->display_name : "Non renseignée");
trainlog_surface_printf(app->content, row++, 2,
"Secondaires : %s", secondary[0] != '\0' ? secondary : "Aucune");
trainlog_surface_printf(app->content, row++, 2,
"Groupe : %s", group != NULL ? group->display_name : "Aucun");
}
trainlog_surface_printf(app->content, row++, 2,
"Compatibles (manifeste) : %zu · utilisés historiquement : %zu",
app->exercise_explicit_equipment_count,
app->exercise_historic_equipment_count);
for (index = 0U; index < app->exercise_explicit_equipment_count &&
row < app->layout.content.height - 2; ++index)
trainlog_surface_printf(app->content, row++, 4, "%c %.48s [compatible]",
app->exercise_equipment_selected == index ? '>' : ' ',
app->exercise_explicit_equipment[index].display_name);
for (index = 0U; index < app->exercise_historic_equipment_count &&
row < app->layout.content.height - 2; ++index) {
size_t absolute = app->exercise_explicit_equipment_count + index;
trainlog_surface_printf(app->content, row++, 4, "%c %.48s [usage]",
app->exercise_equipment_selected == absolute ? '>' : ' ',
app->exercise_historic_equipment[index].display_name);
}
trainlog_surface_printf(app->content, app->layout.content.height - 2, 2,
"Entrée fiche équipement · p Performances · m MAX · k Connaissances · e Modifier");
} else if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) {
const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup(
app->exercise_detail.exercise_id);
const TrainlogKnowledgeInterpretation *value = record == NULL ? NULL
: record->interpretation != NULL ? record->interpretation
: record->conditional_interpretation;
char lines[KNOWLEDGE_LINES_MAX][KNOWLEDGE_LINE_MAX];
size_t count = 0U;
size_t index;
int wrap = app->layout.content.width - 6;
knowledge_add_wrapped(lines, &count, app->exercise_detail.name, wrap);
if (record == NULL || value == NULL)
knowledge_add_wrapped(lines, &count, record == NULL
? "Aucune fiche scientifique pour cet identifiant."
: "Interprétation scientifique non résolue.", wrap);
else {
knowledge_add_wrapped(lines, &count,
record->interpretation != NULL ? "Connaissances validées"
: "Interprétation conditionnelle — à confirmer", wrap);
knowledge_add_ids(lines, &count, "Mouvement :", value->pattern_ids, false, wrap);
knowledge_add_wrapped(lines, &count, "Confiance :", wrap);
knowledge_add_wrapped(lines, &count, value->confidence, wrap);
knowledge_add_ids(lines, &count, "Muscles principaux :",
value->primary_muscle_ids, true, wrap);
knowledge_add_ids(lines, &count, "Secondaires :",
value->secondary_muscle_ids, true, wrap);
knowledge_add_ids(lines, &count, "Stabilisateurs :",
value->stabilizer_muscle_ids, true, wrap);
knowledge_add_zones(lines, &count, value, wrap);
knowledge_add_plain_ids(lines, &count, "Sources :", value->source_refs, wrap);
}
if (app->content_scroll >= count)
app->content_scroll = count > 0U ? count - 1U : 0U;
for (index = app->content_scroll; index < count &&
3 + (int)(index - app->content_scroll) < app->layout.content.height; ++index)
trainlog_surface_printf(app->content,
3 + (int)(index - app->content_scroll), 2, "%s", lines[index]);
} else if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE) {
const TrainlogExercisePerformancePoint *latest = NULL;
const TrainlogExercisePerformancePoint *best = NULL;
TrainlogLoadMode mode = TRAINLOG_LOAD_NONE;
TrainlogResolvedEquipment equipment;
size_t index;
size_t context_points = 0U;
int row = 4;
char latest_text[128];
char best_text[128];
if (app->performance_error) {
trainlog_surface_printf(app->content, row, 2,
"Impossible de lire l’historique.");
} else {
for (index = 0U; index < app->performance_count; ++index)
if (app->performance_points[index].has_performance != 0) {
latest = &app->performance_points[index]; mode = latest->load_mode; break;
}
for (index = 0U; latest != NULL && index < app->performance_count; ++index)
if (app->performance_points[index].has_performance != 0 &&
app->performance_points[index].load_mode == mode &&
strcmp(app->performance_points[index].equipment_id,
latest->equipment_id) == 0 &&
exercise_point_better(&app->performance_points[index], best))
best = &app->performance_points[index];
exercise_format_performance(latest, latest_text, sizeof(latest_text));
exercise_format_performance(best, best_text, sizeof(best_text));
trainlog_surface_printf(app->content, row++, 2, "Performance — %s",
app->exercise_detail.name);
if (latest != NULL) {
const char *equipment_label = "sans équipement";
if (latest->equipment_id[0] != '\0' &&
trainlog_database_resolve_equipment(app->database,
latest->equipment_id, &equipment) == TRAINLOG_STATUS_OK)
equipment_label = equipment.display_name;
for (index = 0U; index < app->performance_count; ++index)
if (app->performance_points[index].has_performance != 0 &&
app->performance_points[index].load_mode == mode &&
strcmp(app->performance_points[index].equipment_id,
latest->equipment_id) == 0)
++context_points;
trainlog_surface_printf(app->content, row++, 2,
"Contexte : %s · %s · %zu point%s",
exercise_load_mode_label(mode), equipment_label, context_points,
context_points == 1U ? "" : "s");
}
trainlog_surface_printf(app->content, row++, 2, "Séances enregistrées : %zu",
app->performance_count);
trainlog_surface_printf(app->content, row++, 2, "Dernier meilleur set : %s",
latest != NULL ? latest_text : "aucun");
trainlog_surface_printf(app->content, row++, 2, "Meilleur set enregistré : %s",
best != NULL ? best_text : "aucun");
if (latest != NULL) {
int graph_top = row + 1;
int graph_height = app_shell_graph_height(app, graph_top);
app_shell_draw_performance_graph(app, latest, mode,
app->exercise_detail.tracking_mode, graph_top,
graph_height, false);
if (graph_height >= 3) row = graph_top + graph_height + 2;
else row += 2;
} else row += 2;
if (row < app->layout.content.height)
trainlog_surface_printf(app->content, row++, 2,
"Points réels du contexte :");
for (index = app->content_scroll; index < app->performance_count &&
row < app->layout.content.height; ++index) {
char date[11]; char summary[128];
if (app->performance_points[index].has_performance == 0 ||
app->performance_points[index].load_mode != mode || latest == NULL ||
strcmp(app->performance_points[index].equipment_id,
latest->equipment_id) != 0) continue;
exercise_short_date(app->performance_points[index].started_at, date);
exercise_format_performance(&app->performance_points[index], summary,
sizeof(summary));
trainlog_surface_printf(app->content, row++, 2, "%s %s", date, summary);
}
}
} else if (route == TRAINLOG_ROUTE_EXERCISE_MAX) {
const TrainlogExerciseStatistics *stats = &app->exercise_statistics;
TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX];
size_t count = stats->series_count[TRAINLOG_EXERCISE_SERIES_MAX];
size_t index;
int row = 4;
trainlog_surface_printf(app->content, row++, 2, "%s", app->exercise_detail.name);
if (app->performance_error || app->statistics_error)
trainlog_surface_printf(app->content, row, 2,
"Impossible de lire les tests de MAX.");
else if (!stats->has_latest_max) {
trainlog_surface_printf(app->content, row, 2, "Aucun MAX enregistré.");
} else {
trainlog_surface_printf(app->content, row++, 2,
"Tests enregistrés : %zu · dernier %.1f kg · meilleur %.1f kg",
stats->max_count, stats->latest_max_kg, stats->best_max_kg);
trainlog_surface_printf(app->content, row++, 2, "Date du dernier : %s",
stats->latest_max_at);
if (stats->has_previous_max) {
double difference = stats->latest_max_kg - stats->previous_max_kg;
trainlog_surface_printf(app->content, row++, 2,
"Précédent %.1f kg · différence %+.1f kg%s",
stats->previous_max_kg, difference,
stats->previous_max_kg != 0.0 ? " · pourcentage calculable" : "");
if (stats->previous_max_kg != 0.0)
trainlog_surface_printf(app->content, row++, 2,
"Différence relative : %+.1f%%",
difference * 100.0 / stats->previous_max_kg);
}
for (index = 0U; index < count; ++index) {
points[index].timestamp = stats->series[
TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp;
points[index].value = stats->series[
TRAINLOG_EXERCISE_SERIES_MAX][index].value;
points[index].timestamp_label = stats->series[
TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp_label;
}
if (app->layout.content.height - row >= 3)
{
TrainlogChartOptions options = {0};
options.non_negative = true;
trainlog_chart_render_line(app->content,
(TrainlogChartRect){row, 2,
app->layout.content.height - row - 1,
app->layout.content.width - 4},
points, count, "kg", "Historique des MAX", &options);
}
}
} else if (route == TRAINLOG_ROUTE_BODY) {
app_shell_render_body_profile(app);
} else if (route == TRAINLOG_ROUTE_BODY_DETAIL) {
app_shell_render_body_detail(app);
} else if (route == TRAINLOG_ROUTE_BODY_METRIC) {
int graph_height = app_shell_graph_height(app, 6);
trainlog_surface_printf(app->content, 4, 2, "%s — ←/→ change la mesure",
body_metric_label(app->body_metric_selected));
if (app->body_snapshot_error)
trainlog_surface_printf(app->content, 6, 2,
"Impossible de lire cet historique.");
else app_shell_draw_body_points(app, app->body_metric_points,
app->body_metric_count, 6, graph_height,
BODY_METRICS[app->body_metric_selected].unit,
"ÉVOLUTION DANS LE TEMPS");
if (app->body_metric_partial)
trainlog_surface_printf(app->content,
app->layout.content.height - 1, 2,
"Limite locale atteinte : historique partiel.");
} else if (route == TRAINLOG_ROUTE_BODY_TRENDS) {
app_shell_render_body_trends(app);
} else if (route == TRAINLOG_ROUTE_BODY_GLOBAL) {
app_shell_render_body_global(app);
} else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) {
app_shell_render_body_analytics(app);
} else if (route == TRAINLOG_ROUTE_SYNC) {
app_shell_render_sync(app);
} else if (app_shell_is_list_route(route)) {
size_t index;
int row = 5;
if (route == TRAINLOG_ROUTE_MAX)
trainlog_surface_printf(app->content, 2, 2,
"Vue : %s · a afficher %s",
trainlog_presentation_source(app->max_show_all
? "tous les exercices compatibles" : "MAX enregistrés"),
trainlog_presentation_source(app->max_show_all
? "seulement les MAX" : "tous les compatibles"));
trainlog_surface_printf(app->content, 3, 2, "Recherche : %s%s",
app->search.bytes > 0U ? app->search.text : "—",
app->search.focused ? " [saisie]" : "");
if (app->list_error) trainlog_surface_printf(app->content, row++, 2,
"Impossible de charger cette liste.");
else if (app->loaded_count == 0U) trainlog_surface_printf(app->content, row++, 2,
"Aucun résultat.");
for (index = app->list.viewport_start; index < app->loaded_count &&
index < app->list.viewport_start + app->list.visible_rows; ++index) {
char secondary[48] = "";
char session_line[80] = "";
char body_line[160] = "";
char clipped[TRAINLOG_NAME_MAX + 8U];
const char *name;
int label_cells = app->layout.content.width - 28;
if (route == TRAINLOG_ROUTE_EQUIPMENT) {
name = app->equipment[index].display_name;
(void)snprintf(secondary, sizeof(secondary), "%s",
equipment_origin_label(app->equipment[index].origin));
} else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) {
char date[17];
session_history_datetime(app->sessions[index].started_at, date);
(void)snprintf(session_line, sizeof(session_line),
"%s %s", date,
session_type_history_label(app->sessions[index].session_type));
name = session_line;
(void)trainlog_presentation_format_key(secondary,
sizeof(secondary), "list.exercise.count",
app->sessions[index].exercise_count);
} else if (route == TRAINLOG_ROUTE_BODY) {
char date[9];
body_short_date(app->body_records[index].observed_at, date);
body_summary_text(&app->body_records[index], body_line,
sizeof(body_line));
name = body_line;
(void)snprintf(secondary, sizeof(secondary), "%s", date);
} else {
name = app->exercises[index].name;
if (route == TRAINLOG_ROUTE_MAX) {
bool measured = false;
for (size_t maximum = 0U; maximum < app->max_list_item_count;
++maximum)
if (strcmp(app->exercises[index].exercise_id,
app->max_list_items[maximum].exercise_id) == 0) {
char date[9];
body_short_date(app->max_list_items[maximum].latest_at, date);
(void)trainlog_presentation_format(secondary, sizeof(secondary),
"%.1f kg %s",
app->max_list_items[maximum].latest_max_kg, date);
measured = true; break;
}
if (!measured) (void)snprintf(secondary,
sizeof(secondary), "—");
} else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
bool found = false;
for (size_t fact = 0U;
fact < app->exercise_list_statistics_count; ++fact)
if (strcmp(app->exercises[index].exercise_id,
app->exercise_list_statistics[fact].exercise_id) == 0) {
char date[9];
body_short_date(app->exercise_list_statistics[fact].latest_at,
date);
(void)trainlog_presentation_format_key(secondary,
sizeof(secondary), "list.occurrences",
app->exercise_list_statistics[fact].occurrences,
date);
found = true; break;
}
if (!found) (void)snprintf(secondary, sizeof(secondary),
"%s", trainlog_presentation_source("aucune donnée"));
} else (void)snprintf(secondary, sizeof(secondary), "%s",
app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS
? "reps" : trainlog_presentation_source("durée"));
}
if (label_cells < 12) label_cells = 12;
if (label_cells > 48) label_cells = 48;
trainlog_shell_format_list_label(name, label_cells, clipped,
sizeof(clipped));
trainlog_surface_set_role(app->content,
index == app->list.selected_index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
index == app->list.selected_index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, row++, 2, "%s %-*s %s",
index == app->list.selected_index ? "> [SÉLECTION]" : " ",
label_cells, clipped, secondary);
}
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (app->loaded_count > 0U) {
if (app->list.total_known)
trainlog_surface_printf(app->content,
app->layout.content.height - 2, 2, "Position %zu/%zu",
app->list.selected_index + 1U, app->loaded_count);
else trainlog_surface_printf(app->content,
app->layout.content.height - 2, 2,
"Position %zu/%zu+ · limite locale atteinte",
app->list.selected_index + 1U, app->loaded_count);
}
} else if (route == TRAINLOG_ROUTE_SETTINGS) {
trainlog_surface_set_role(app->content,
app->content_selected == 0U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
app->content_selected == 0U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 4, 2, "%s %s : %s",
app->content_selected == 0U ? ">" : " ",
trainlog_presentation_text("settings.language"),
trainlog_presentation_language_name(trainlog_presentation_language()));
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 5, 4, "%s",
trainlog_presentation_text("settings.language.help"));
if (app->settings_language_save_failed)
trainlog_surface_printf(app->content, 6, 4, "%s",
trainlog_presentation_text("settings.language.save_failed"));
else if (app->settings_language_durability_uncertain)
trainlog_surface_printf(app->content, 6, 4, "%s",
trainlog_presentation_text(
"settings.language.durability_uncertain"));
trainlog_surface_set_role(app->content,
app->content_selected == 1U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
app->content_selected == 1U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 8, 2, "%s %s",
app->content_selected == 1U ? ">" : " ",
trainlog_presentation_text("settings.body_profile"));
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (app->body_has_profile)
trainlog_surface_printf(app->content, 10, 4, "%s · %.1f cm",
app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
? trainlog_presentation_source("Formule femme")
: trainlog_presentation_source("Formule homme"),
app->body_profile.height_cm);
else trainlog_surface_printf(app->content, 10, 4,
"Profil non configuré.");
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->content, 11, 4, "%s",
trainlog_presentation_text("settings.body_profile.help"));
} else {
trainlog_surface_printf(app->content, 4, 2,
"Entrée ouvre la vue complète existante dans ce contexte.");
if (width < 80) trainlog_surface_printf(app->content, 6, 2,
"Mode compact · F6 ouvre Navigation");
}
}
static void app_shell_render_overlay(TrainlogAppContext *app)
{
const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays);
TrainlogRect rect;
TrainlogSurface *surface;
size_t index;
if (overlay == NULL) return;
rect = trainlog_shell_overlay_rect(&app->layout,
overlay->type == TRAINLOG_OVERLAY_NAVIGATION ? 36 : 72,
overlay->type == TRAINLOG_OVERLAY_HELP ? 14 :
overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE ? 16 : 12);
surface = trainlog_surface_create(app->terminal, "trainlog.overlay",
rect.y, rect.x, rect.height, rect.width);
if (surface == NULL) return;
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_SURFACE0, TRAINLOG_TEXT_NORMAL);
if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) {
trainlog_surface_printf(surface, 1, 2, "Navigation");
for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]) &&
3 + (int)index < rect.height; ++index)
trainlog_surface_printf(surface, 3 + (int)index, 2, "%c %s",
overlay->selected == index ? '>' : ' ',
trainlog_presentation_text(shell_section_labels[index]));
} else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) {
size_t visible = rect.height > 4 ? (size_t)(rect.height - 4) : 1U;
size_t start = overlay->selected >= visible
? overlay->selected - visible + 1U : 0U;
trainlog_surface_printf(surface, 1, 2, "Actions disponibles");
for (index = start; index < app->actions.count &&
index < start + visible; ++index)
trainlog_surface_printf(surface, 3 + (int)(index - start), 2, "%c %s",
overlay->selected == index ? '>' : ' ', app->actions.items[index].label);
if (app->actions.count > visible)
trainlog_surface_printf(surface, rect.height - 1, 2,
"Action %zu/%zu%s%s", overlay->selected + 1U,
app->actions.count, start > 0U ? " · ↑" : "",
start + visible < app->actions.count ? " · ↓" : "");
} else if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) {
TrainlogExerciseController *controller = &app->exercise_controller;
if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) {
size_t matches = exercise_merge_match_count(controller);
size_t visible = rect.height > 7 ? (size_t)(rect.height - 7) : 1U;
size_t start = controller->merge_selected >= visible
? controller->merge_selected - visible + 1U : 0U;
trainlog_surface_printf(surface, 1, 2, "Fusionner la source : %.48s",
controller->merge_source.name);
trainlog_surface_printf(surface, 3, 2, "Recherche cible : %s_",
controller->form.text);
for (index = start; index < matches && index < start + visible; ++index) {
TrainlogExercise *candidate = exercise_merge_match_at(controller, index);
if (candidate != NULL)
trainlog_surface_printf(surface, 5 + (int)(index - start), 2,
"%c %.58s", controller->merge_selected == index ? '>' : ' ',
candidate->name);
}
if (matches == 0U) trainlog_surface_printf(surface, 5, 2,
"Aucune cible correspondante.");
trainlog_surface_printf(surface, rect.height - 1, 2,
"Entrée prévisualiser · Échap annuler");
} else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) {
trainlog_surface_printf(surface, 1, 2, "Confirmer la fusion");
trainlog_surface_printf(surface, 3, 2, "SOURCE : %.54s",
controller->merge_source.name);
trainlog_surface_printf(surface, 4, 2, " vers CANONIQUE : %.45s",
controller->merge_target.name);
trainlog_surface_printf(surface, 6, 2,
"Impact source : %zu occurrence(s), %zu série(s), %zu continu, %zu MAX.",
controller->merge_preview.occurrences,
controller->merge_preview.performed_sets,
controller->merge_preview.continuous_activities,
controller->merge_preview.max_results);
trainlog_surface_printf(surface, 7, 2,
"Associations : %zu équipement(s), %zu zone(s).",
controller->merge_preview.associated_equipment,
controller->merge_preview.body_zones);
trainlog_surface_printf(surface, 9, 2, "%c Annuler",
overlay->selected == 0U ? '>' : ' ');
trainlog_surface_printf(surface, 10, 2, "%c Fusionner source vers canonique",
overlay->selected == 1U ? '>' : ' ');
} else {
trainlog_surface_printf(surface, 1, 2, "Résultat de la fusion");
trainlog_surface_printf(surface, 3, 2, "%.64s",
trainlog_presentation_source(controller->message));
trainlog_surface_printf(surface, 6, 2, "Entrée fermer");
}
} else if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION &&
app->quit_confirmation) {
trainlog_surface_printf(surface, 1, 2, "Quitter TRAINLOG ?");
trainlog_surface_printf(surface, 3, 2,
"Le brouillon et les propositions gardés seulement pour ce run seront perdus.");
trainlog_surface_printf(surface, 5, 2, "%c Annuler",
overlay->selected == 0U ? '>' : ' ');
trainlog_surface_printf(surface, 6, 2, "%c Quitter et perdre cet état",
overlay->selected == 1U ? '>' : ' ');
} else {
trainlog_surface_printf(surface, 1, 2, "Aide contextuelle");
trainlog_surface_printf(surface, 3, 2, "0/Home Accueil · 1/F1 Séance · 2/F2 Effectuées");
trainlog_surface_printf(surface, 5, 2, "3/F3 Exercices · 4/F4 Équipements · 5/F5 Mensurations");
trainlog_surface_printf(surface, 7, 2, "6 Synchronisation · F6 Navigation · F7 Actions");
trainlog_surface_printf(surface, 9, 2, "Tab change le focus · Échap revient");
}
trainlog_surface_move_top(surface);
app_shell_render_footer(app);
trainlog_terminal_render(app->terminal);
trainlog_surface_destroy(surface);
}
static void app_shell_render(TrainlogAppContext *app)
{
if (!app_shell_layout(app)) { app->running = false; return; }
if (!app->layout.usable) {
trainlog_terminal_erase(app->terminal);
trainlog_terminal_printf(app->terminal, 1, 2,
"Terminal trop petit — %dx%d, minimum 72x20.",
app->layout.columns, app->layout.rows);
trainlog_terminal_printf(app->terminal, 3, 2, "q pour quitter");
trainlog_terminal_render(app->terminal); return;
}
trainlog_surface_erase(app->header);
trainlog_surface_set_role(app->header, TRAINLOG_COLOR_ACCENT,
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(app->header, 0, 2, "TRAINLOG");
trainlog_surface_set_role(app->header, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL);
trainlog_surface_printf(app->header, 1, 2, "%s", trainlog_route_title(app->navigation.current.route));
if (!app->layout.sidebar_visible) {
int controls_x = app->layout.columns > 35 ? app->layout.columns - 34 : 2;
trainlog_surface_printf(app->header, 0, controls_x, "%c F6 Navigation",
app->focus == TRAINLOG_FOCUS_NAVIGATION ? '>' : ' ');
trainlog_surface_printf(app->header, 1, controls_x, "%c F7 Actions",
app->focus == TRAINLOG_FOCUS_ACTIONS ? '>' : ' ');
}
app_shell_render_sidebar(app); app_shell_render_content(app);
app_shell_render_footer(app);
trainlog_surface_move_top(app->footer);
trainlog_terminal_render(app->terminal);
if (trainlog_overlays_top(&app->overlays) != NULL) app_shell_render_overlay(app);
}
static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route)
{
TrainlogAppRoute previous = app->navigation.current.route;
TrainlogSessionController *session = &app->session;
if (route != previous &&
app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE &&
app->exercise_controller.phase != TRAINLOG_EXERCISE_MESSAGE &&
app->exercise_controller.phase != TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
/* CONTRACT: selecting a destination never silently replaces the one
* transient exercise editor. Raw fields remain owned until discard. */
app->exercise_controller.return_phase = app->exercise_controller.phase;
app->exercise_controller.pending_route = route;
app->exercise_controller.leaving = true;
app->exercise_controller.phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD;
return;
}
if (route != previous &&
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE &&
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_MESSAGE &&
app->equipment_controller.phase != TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) {
app->equipment_controller.return_phase = app->equipment_controller.phase;
app->equipment_controller.pending_route = route;
app->equipment_controller.leaving = true;
app->equipment_controller.phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD;
return;
}
if (route != previous && app->body_controller.phase == TRAINLOG_BODY_FORM) {
app->body_controller.pending_route = route;
app->body_controller.leaving = true;
app->body_controller.phase = TRAINLOG_BODY_CONFIRM_DISCARD;
return;
}
if (route != previous &&
(app->profile_controller.phase == TRAINLOG_PROFILE_FORMULA ||
app->profile_controller.phase == TRAINLOG_PROFILE_HEIGHT)) {
app->profile_controller.pending_route = route;
app->profile_controller.leaving = true;
app->profile_controller.return_phase = app->profile_controller.phase;
app->profile_controller.phase = TRAINLOG_PROFILE_CONFIRM_DISCARD;
return;
}
if (session->form.active && route != previous) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.route.active.field");
return;
}
if (previous == TRAINLOG_ROUTE_SESSION_GENERATOR &&
route != TRAINLOG_ROUTE_SESSION_GENERATOR &&
(session->generated_preview || session->generation_zone != NULL) &&
session->phase != TRAINLOG_SESSION_CONFIRM_LEAVE) {
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
session->pending_route = route;
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
return;
}
if (app_shell_is_list_route(previous)) {
app->saved_lists[previous] = app->list;
app->saved_searches[previous] = app->search;
app->saved_searches[previous].focused = false;
}
(void)trainlog_navigation_open(&app->navigation, route, NULL);
app->focus = TRAINLOG_FOCUS_CONTENT;
app->content_selected = 0U;
app->content_scroll = 0U;
if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT) {
session->message[0] = '\0';
session->phase = session->has_draft ? TRAINLOG_SESSION_DRAFT
: TRAINLOG_SESSION_CHOOSE_TYPE;
session->session_type = session->has_draft ? session->session_type
: TRAINLOG_SESSION_TRAINING;
} else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
if (session->generated_preview)
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
else {
size_t zone_count = trainlog_body_zone_catalog_count();
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
if (session->generation_zone == NULL && zone_count > 0U)
session->generation_zone = trainlog_body_zone_catalog_at(0U);
if (session->generation_goal == NULL &&
trainlog_session_generation_policy_v1.goal_count > 0U)
session->generation_goal = &trainlog_session_generation_policy_v1.goals[0];
if (session->generation_duration_minutes <= 0 &&
trainlog_session_generation_policy_v1.duration_preset_count > 0U)
session->generation_duration_minutes =
trainlog_session_generation_policy_v1.duration_presets_minutes[0];
}
session_controller_sync_durability(session);
}
if (route == TRAINLOG_ROUTE_SESSION_GENERATOR &&
!session->generated_preview && session->generation_zone == NULL) {
session->generation_zone_index = 0U;
session->generation_goal_index = 0U;
session->generation_duration_index = 0U;
session->generation_zone = trainlog_body_zone_catalog_at(0U);
if (trainlog_session_generation_policy_v1.goal_count > 0U)
session->generation_goal = &trainlog_session_generation_policy_v1.goals[0];
if (trainlog_session_generation_policy_v1.duration_preset_count > 0U)
session->generation_duration_minutes =
trainlog_session_generation_policy_v1.duration_presets_minutes[0];
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
} else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && session->generated_preview &&
session->phase != TRAINLOG_SESSION_GENERATOR_WARNING)
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
if (route == TRAINLOG_ROUTE_STATS_TRAINING)
app_shell_load_statistics(app);
else if (route == TRAINLOG_ROUTE_STATS_ZONES)
app_shell_load_statistics_zones(app);
else if (route == TRAINLOG_ROUTE_BODY_METRIC)
app_shell_load_body_metric(app);
else if (route == TRAINLOG_ROUTE_BODY ||
route == TRAINLOG_ROUTE_BODY_TRENDS ||
route == TRAINLOG_ROUTE_BODY_GLOBAL)
app_shell_load_body_global(app);
else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS)
app_shell_load_body_analytics(app);
else if (route == TRAINLOG_ROUTE_SYNC) {
sync_controller_load_history(&app->sync_controller);
app->sync_controller.showing_detail = false;
} else if (route == TRAINLOG_ROUTE_SETTINGS)
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
if (app_shell_is_list_route(route)) {
app->list = app->saved_lists[route];
app->search = app->saved_searches[route];
app_shell_refresh_list(app);
}
if (route == TRAINLOG_ROUTE_BODY)
app_shell_select_available_body_metric(app);
}
static bool app_shell_back(TrainlogAppContext *app)
{
TrainlogAppRoute previous = app->navigation.current.route;
if (app_shell_is_list_route(previous)) {
app->saved_lists[previous] = app->list;
app->saved_searches[previous] = app->search;
app->saved_searches[previous].focused = false;
}
if (!trainlog_navigation_back(&app->navigation)) return false;
if (app_shell_is_list_route(app->navigation.current.route)) {
app->list = app->saved_lists[app->navigation.current.route];
app->search = app->saved_searches[app->navigation.current.route];
app_shell_refresh_list(app);
}
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING)
app_shell_load_statistics(app);
else if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES)
app_shell_load_statistics_zones(app);
else if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
app_shell_load_body_global(app);
app_shell_select_available_body_metric(app);
}
return true;
}
static bool app_shell_dispatch_session(TrainlogAppContext *app, int key);
static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key);
static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app)
{
size_t index;
app->exercise_zone_count = 0U;
app->exercise_explicit_equipment_count = 0U;
app->exercise_historic_equipment_count = 0U;
app->exercise_equipment_selected = 0U;
app->exercise_detail_metadata_error =
trainlog_database_list_exercise_body_zones(app->database,
app->exercise_detail.exercise_id, app->exercise_zones,
MAX_BODY_ZONES, &app->exercise_zone_count) != TRAINLOG_STATUS_OK;
for (index = 0U; index < trainlog_equipment_catalog_relation_count() &&
app->exercise_explicit_equipment_count < 64U; ++index) {
const TrainlogExerciseEquipmentRelation *relation =
trainlog_equipment_catalog_relation_at(index);
if (relation != NULL && strcmp(relation->exercise_id,
app->exercise_detail.exercise_id) == 0 &&
trainlog_database_resolve_equipment(app->database,
relation->equipment_id,
&app->exercise_explicit_equipment[
app->exercise_explicit_equipment_count]) == TRAINLOG_STATUS_OK)
++app->exercise_explicit_equipment_count;
}
if (trainlog_database_list_exercise_equipment(app->database,
app->exercise_detail.exercise_id,
app->exercise_historic_equipment, 128U,
&app->exercise_historic_equipment_count) != TRAINLOG_STATUS_OK) {
app->exercise_historic_equipment_count = 0U;
app->exercise_detail_metadata_error = true;
}
}
static void app_shell_primary(TrainlogAppContext *app)
{
TrainlogAppRoute route = app->navigation.current.route;
if (route == TRAINLOG_ROUTE_SESSIONS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
app_shell_open_route(app, targets[app->content_selected % 3U]);
} else if (route == TRAINLOG_ROUTE_STATS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_BODY,
TRAINLOG_ROUTE_STATS_TRAINING, TRAINLOG_ROUTE_STATS_EXERCISE,
TRAINLOG_ROUTE_STATS_ZONES, TRAINLOG_ROUTE_MAX};
app_shell_open_route(app, targets[app->content_selected % 5U]);
} else if (route == TRAINLOG_ROUTE_SESSION_MANUAL ||
route == TRAINLOG_ROUTE_SESSION_CURRENT ||
route == TRAINLOG_ROUTE_SESSION_GENERATOR) {
(void)app_shell_dispatch_session(app, TRAINLOG_KEY_ENTER);
} else if (app_shell_is_list_route(route)) {
if (app->loaded_count == 0U) return;
/* INVARIANT: detail routes borrow list selection; they do not replace
* its stable-ID/query/viewport state. Back restores this exact snapshot
* after any intervening resize or catalogue refresh. */
app->saved_lists[route] = app->list;
app->saved_searches[route] = app->search;
app->saved_searches[route].focused = false;
if (route == TRAINLOG_ROUTE_EQUIPMENT) {
app->equipment_detail = app->equipment[app->list.selected_index];
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
app->equipment_detail.equipment_id);
}
else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED)
app_shell_open_session_detail(app,
app->sessions[app->list.selected_index].session_id);
else if (route == TRAINLOG_ROUTE_BODY) {
app->body_detail = app->body_records[app->list.selected_index];
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_BODY_DETAIL,
app->body_detail.observation_id);
}
else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) {
app->exercise_detail = app->exercises[app->list.selected_index];
app_shell_load_exercise_statistics(app);
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL,
app->exercise_detail.exercise_id);
} else if (route == TRAINLOG_ROUTE_MAX) {
app->exercise_detail = app->exercises[app->list.selected_index];
app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX);
}
else {
app->exercise_detail = app->exercises[app->list.selected_index];
app_shell_load_exercise_detail_metadata(app);
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EXERCISE_DETAIL,
app->exercise_detail.exercise_id);
}
if (route != TRAINLOG_ROUTE_EQUIPMENT && route != TRAINLOG_ROUTE_EXERCISES &&
route != TRAINLOG_ROUTE_STATS_EXERCISE && route != TRAINLOG_ROUTE_MAX &&
route != TRAINLOG_ROUTE_SESSIONS_COMPLETED && route != TRAINLOG_ROUTE_BODY) {
trainlog_terminal_erase(app->terminal);
app_shell_refresh_list(app);
}
} else if (route == TRAINLOG_ROUTE_SYNC) {
sync_controller_open_detail(&app->sync_controller);
} else if (route == TRAINLOG_ROUTE_SETTINGS) {
if (app->content_selected == 0U) {
TrainlogPresentationLanguage previous =
trainlog_presentation_language();
TrainlogPresentationLanguage language =
previous == TRAINLOG_PRESENTATION_FRENCH
? TRAINLOG_PRESENTATION_ENGLISH
: TRAINLOG_PRESENTATION_FRENCH;
TrainlogPresentationSaveResult save_result;
/* WHY: a visual toggle that was not persisted is misleading.
* CONTRACT: switching language mutates only the local preference;
* failure rolls back and produces a localized visible diagnostic.
* INVARIANT: no database object is reachable from this operation. */
(void)trainlog_presentation_set_language(language);
save_result = trainlog_presentation_save();
app->settings_language_save_failed = save_result ==
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED;
app->settings_language_durability_uncertain = save_result ==
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN;
trainlog_presentation_resolve_save(previous, save_result);
} else profile_controller_start(app, TRAINLOG_ROUTE_SETTINGS);
}
}
static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key)
{
TrainlogOverlay *overlay = &app->overlays.items[app->overlays.count - 1U];
if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) {
TrainlogExerciseController *controller = &app->exercise_controller;
if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) {
size_t matches = exercise_merge_match_count(controller);
if (key == TRAINLOG_KEY_ESCAPE) {
if (controller->form.text[0] != '\0') {
trainlog_form_init(&controller->form, "");
controller->merge_selected = 0U;
} else {
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
(void)memset(controller, 0, sizeof(*controller));
}
} else if (key == TRAINLOG_KEY_UP && controller->merge_selected > 0U)
--controller->merge_selected;
else if (key == TRAINLOG_KEY_DOWN && controller->merge_selected + 1U < matches)
++controller->merge_selected;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && matches > 0U) {
TrainlogExercise *target = exercise_merge_match_at(controller,
controller->merge_selected);
if (target != NULL) {
controller->merge_target = *target;
if (trainlog_database_preview_exercise_merge(app->database,
controller->merge_source.exercise_id,
&controller->merge_preview) == TRAINLOG_STATUS_OK)
controller->phase = TRAINLOG_EXERCISE_MERGE_CONFIRM;
else {
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
(void)trainlog_presentation_format_key(controller->message,
sizeof(controller->message), "msg.merge.preview.fail");
}
overlay->selected = 0U;
}
} else if (!(key == '/' && controller->form.text[0] == '\0')) {
/* '/' activates the standard search affordance; this overlay
* already owns the shared UTF-8 form editor. */
TrainlogFormResult result = trainlog_form_handle(&controller->form, key);
if (result == TRAINLOG_FORM_EDITED) controller->merge_selected = 0U;
}
} else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) {
if (key == TRAINLOG_KEY_ESCAPE) {
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
overlay->selected = controller->merge_selected;
} else if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)
overlay->selected = overlay->selected == 0U ? 1U : 0U;
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
if (overlay->selected == 0U) {
controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER;
overlay->selected = controller->merge_selected;
} else {
TrainlogStatus status = trainlog_database_merge_exercises(app->database,
controller->merge_source.exercise_id,
controller->merge_target.exercise_id);
if (status == TRAINLOG_STATUS_OK) {
app->exercise_detail = controller->merge_target;
if (!copy_ui_stable_id(app->navigation.current.stable_id,
sizeof(app->navigation.current.stable_id),
controller->merge_target.exercise_id)) {
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
(void)trainlog_presentation_format_key(
controller->message, sizeof(controller->message),
"msg.merge.id.bound");
return;
}
app_shell_load_exercise_detail_metadata(app);
(void)trainlog_presentation_format_key(
controller->message, sizeof(controller->message),
"msg.merge.completed", controller->merge_target.name);
} else (void)trainlog_presentation_format_key(
controller->message, sizeof(controller->message),
status == TRAINLOG_STATUS_CONFLICT
? "msg.merge.conflict" : "msg.merge.fail");
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
}
}
} else if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
key == TRAINLOG_KEY_ESCAPE) {
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
(void)memset(controller, 0, sizeof(*controller));
}
return;
}
size_t count = overlay->type == TRAINLOG_OVERLAY_NAVIGATION
? sizeof(shell_sections) / sizeof(shell_sections[0])
: overlay->type == TRAINLOG_OVERLAY_CONFIRMATION ? 2U : app->actions.count;
if (key == TRAINLOG_KEY_ESCAPE ||
(key == TRAINLOG_KEY_F6 &&
overlay->type == TRAINLOG_OVERLAY_NAVIGATION)) {
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
} else if (key == TRAINLOG_KEY_UP && overlay->selected > 0U) --overlay->selected;
else if (key == TRAINLOG_KEY_DOWN && overlay->selected + 1U < count) ++overlay->selected;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && count > 0U) {
if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) {
bool quit = overlay->selected == 1U;
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
app->quit_confirmation = false;
if (quit) app->running = false;
} else if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) {
TrainlogAppRoute target = shell_sections[overlay->selected];
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
app_shell_open_route(app, target);
} else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) {
TrainlogAction action = app->actions.items[overlay->selected];
char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
(void)trainlog_overlays_pop(&app->overlays, &app->focus, id);
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
(void)app_shell_dispatch_exercise_controller(app, action.key);
} else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) {
(void)app_shell_dispatch_equipment_controller(app, action.key);
} else if (app->session.form.active) {
(void)trainlog_presentation_format_key(app->session.message,
sizeof(app->session.message), "msg.action.active.field");
} else if (strcmp(action.identifier, "generator.discard") == 0) {
session_controller_discard_generator(&app->session);
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
} else if (strcmp(action.identifier, "exercise.merge") == 0) {
exercise_controller_start_merge(app);
} else if (app->navigation.current.route == TRAINLOG_ROUTE_SYNC) {
/* CONTRACT: F7 executes the exact registered key through the
* production sync controller; it is not a palette-only stub. */
(void)app_shell_dispatch_sync(app, action.key);
} else if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_MANUAL ||
app->navigation.current.route == TRAINLOG_ROUTE_SESSION_CURRENT ||
app->navigation.current.route == TRAINLOG_ROUTE_SESSION_GENERATOR)
(void)app_shell_dispatch_session(app, action.key);
else if (action.intent == TRAINLOG_INTENT_PRIMARY) app_shell_primary(app);
else if (action.intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
/* Keep F7 action execution aligned with the registered '/'
* action: it opens the filter and transfers editor focus. */
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
} else if (action.intent == TRAINLOG_INTENT_OPEN_ROUTE)
app_shell_open_route(app, action.route);
else if (action.intent == TRAINLOG_INTENT_BACK)
(void)app_shell_back(app);
}
}
}
static bool session_form_int(const TrainlogFormField *form, int *output)
{
char *end = NULL;
long value;
if (form == NULL || output == NULL || form->bytes == 0U) return false;
value = strtol(form->text, &end, 10);
if (end == form->text || *end != '\0' || value <= 0L || value > INT_MAX)
return false;
*output = (int)value;
return true;
}
static bool session_form_nonnegative_int(const TrainlogFormField *form, int *output)
{
char *end = NULL;
long value;
if (form == NULL || output == NULL || form->bytes == 0U) return false;
value = strtol(form->text, &end, 10);
if (end == form->text || *end != '\0' || value < 0L || value > INT_MAX)
return false;
*output = (int)value;
return true;
}
static bool session_form_double(const TrainlogFormField *form, double *output)
{
char *end = NULL;
double value;
if (form == NULL || output == NULL || form->bytes == 0U) return false;
value = strtod(form->text, &end);
if (end == form->text || *end != '\0' || !isfinite(value) || value < 0.0)
return false;
*output = value;
return true;
}
static void session_controller_start_form(TrainlogSessionController *session,
TrainlogSessionFormPurpose purpose,
const char *initial)
{
session->form_purpose = purpose;
trainlog_form_init(&session->form, initial);
}
static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key)
{
TrainlogSessionController *session = &app->session;
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
TrainlogFormResult result = trainlog_form_handle(&session->form, key);
int integer_value;
double double_value;
if (result == TRAINLOG_FORM_OPEN_NAVIGATION) {
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION,
TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true;
}
if (result == TRAINLOG_FORM_OPEN_ACTIONS) {
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true;
}
if (result == TRAINLOG_FORM_CANCEL) {
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE)
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
return true;
}
if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT)
return true;
if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME) {
if (session->form.bytes == 0U || session->form.bytes >=
sizeof(session->pending_equipment.display_name)) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.equipment.display.required");
return true;
}
(void)snprintf(session->pending_equipment.display_name,
sizeof(session->pending_equipment.display_name), "%s", session->form.text);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL, "");
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL) {
if (session->form.bytes >= sizeof(session->pending_equipment.label_name)) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.equipment.label.long");
return true;
}
(void)snprintf(session->pending_equipment.label_name,
sizeof(session->pending_equipment.label_name), "%s", session->form.text);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE, "");
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE) {
if (session->form.bytes == 0U || session->form.bytes >=
sizeof(session->pending_equipment.equipment_type)) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.equipment.type.required");
return true;
}
(void)snprintf(session->pending_equipment.equipment_type,
sizeof(session->pending_equipment.equipment_type), "%s", session->form.text);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD, "2");
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD &&
session_form_int(&session->form, &integer_value) &&
integer_value <= 3) {
char generated[TRAINLOG_UUID_TEXT_LENGTH + 1U];
size_t index;
(void)snprintf(session->pending_equipment.load_semantics,
sizeof(session->pending_equipment.load_semantics), "%s",
integer_value == 1 ? "none" : integer_value == 2 ? "external" : "assistance");
generate_custom_equipment_uuid(generated);
(void)snprintf(session->pending_equipment.equipment_id,
sizeof(session->pending_equipment.equipment_id), "%s", generated);
if (trainlog_database_create_custom_equipment(app->database,
&session->pending_equipment) != TRAINLOG_STATUS_OK) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.equipment.create.fail");
return true;
}
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
session_controller_load_equipment(app);
for (index = 0U; index < session->equipment_count; ++index)
if (strcmp(app->equipment[index].equipment_id, generated) == 0) {
session->equipment_selected = index + 1U;
break;
}
session->message[0] = '\0';
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_SETS &&
session_form_int(&session->form, &integer_value) && integer_value <= 64 &&
session->selected < session->generated.exercise_count) {
char value[48];
session->pending_generated_sets = integer_value;
(void)snprintf(value, sizeof(value), "%d",
session->generated.exercises[session->selected].target_repetitions);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_GENERATED_REPS, value);
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_REPS &&
session_form_int(&session->form, &integer_value) && integer_value <= 10000 &&
session->selected < session->generated.exercise_count) {
if (!session_controller_requalify_generated(app, session->selected,
session->pending_generated_sets, integer_value)) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.dose.rejected");
return true;
}
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
session_controller_sync_durability(session);
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX &&
session_form_int(&session->form, &integer_value) && integer_value <= 100 &&
session->selected < session->generated.exercise_count) {
TrainlogGeneratedExercise *generated =
&session->generated.exercises[session->selected];
TrainlogLatestExplicitMax latest = {0};
double target = 0.0;
TrainlogStatus status = trainlog_database_latest_explicit_max_equipment_context(
app->database, generated->exercise_id, generated->equipment_id, &latest);
generated->has_target_weight = status == TRAINLOG_STATUS_OK &&
trainlog_measured_max_target_load(&latest, generated->equipment_id,
strcmp(generated->equipment_load_semantics, "external") == 0
? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE,
integer_value, &target) == TRAINLOG_STATUS_OK;
generated->target_weight_kg = generated->has_target_weight ? target : 0.0;
generated->planned_load_mode = generated->has_target_weight
? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE;
generated->rationale_count = 1U;
(void)snprintf(generated->rationale_codes[0],
sizeof(generated->rationale_codes[0]), "%s",
generated->has_target_weight ? "user_selected_max_percentage"
: "compatible_max_unavailable");
generated->load_source_session_id[0] = '\0';
generated->load_source_occurrence_id[0] = '\0';
generated->load_source_started_at[0] = '\0';
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
session->dirty = true;
session_controller_sync_durability(session);
return true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_METRIC &&
session_form_int(&session->form, &integer_value)) {
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION)
session->pending_set.duration_seconds = integer_value;
else session->pending_set.reps = integer_value;
if (draft->input.load_mode != TRAINLOG_LOAD_NONE) {
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_SET_WEIGHT, "");
return true;
}
if (session->set_selected < draft->input.set_count)
draft->sets[session->set_selected] = session->pending_set;
else if (draft->input.set_count < MAX_SETS_PER_EXERCISE)
draft->sets[draft->input.set_count++] = session->pending_set;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_WEIGHT &&
(session->form.bytes == 0U ||
session_form_double(&session->form, &double_value))) {
session->pending_set.has_weight = session->form.bytes > 0U;
session->pending_set.weight_kg = session->form.bytes > 0U ? double_value : 0.0;
if (session->set_selected < draft->input.set_count)
draft->sets[session->set_selected] = session->pending_set;
else if (draft->input.set_count < MAX_SETS_PER_EXERCISE)
draft->sets[draft->input.set_count++] = session->pending_set;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION &&
session_form_int(&session->form, &integer_value)) {
draft->input.continuous_duration_seconds = integer_value;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED &&
(session->form.bytes == 0U || session_form_double(&session->form,
&double_value))) {
draft->input.continuous_has_speed = session->form.bytes > 0U;
draft->input.continuous_speed_kmh = session->form.bytes > 0U ? double_value : 0.0;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE &&
(session->form.bytes == 0U || session_form_double(&session->form,
&double_value))) {
draft->input.continuous_has_distance = session->form.bytes > 0U;
draft->input.continuous_distance_km = session->form.bytes > 0U ? double_value : 0.0;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_SETS &&
session_form_nonnegative_int(&session->form, &integer_value)) {
draft->input.target_sets = integer_value;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_REPS &&
session_form_nonnegative_int(&session->form, &integer_value)) {
draft->input.target_reps = integer_value;
if (integer_value > 0) draft->input.target_duration_seconds = 0;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_DURATION &&
session_form_nonnegative_int(&session->form, &integer_value)) {
draft->input.target_duration_seconds = integer_value;
if (integer_value > 0) draft->input.target_reps = 0;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_REST &&
session_form_nonnegative_int(&session->form, &integer_value)) {
draft->input.rest_seconds = integer_value;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_WEIGHT &&
(session->form.bytes == 0U || session_form_double(&session->form,
&double_value))) {
draft->input.target_has_weight = session->form.bytes > 0U;
draft->input.target_weight_kg = session->form.bytes > 0U ? double_value : 0.0;
draft->target_from_percent_max = false;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX &&
session_form_int(&session->form, &integer_value) &&
integer_value <= 100) {
TrainlogLatestExplicitMax latest = {0};
double target = 0.0;
TrainlogStatus max_status = trainlog_database_latest_explicit_max_equipment_context(
app->database, draft->input.exercise_id, draft->input.equipment_id, &latest);
if (max_status != TRAINLOG_STATUS_OK ||
trainlog_measured_max_target_load(&latest,
draft->input.equipment_id, draft->input.load_mode,
integer_value, &target) !=
TRAINLOG_STATUS_OK) {
draft->input.target_has_weight = false;
draft->input.target_weight_kg = 0.0;
draft->target_from_percent_max = false;
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.max.unavailable");
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
draft_bind_input(draft);
session_controller_sync_durability(session);
return true;
}
draft->input.target_has_weight = true;
draft->input.target_weight_kg = target;
draft->target_from_percent_max = true;
} else if (session->form_purpose == TRAINLOG_SESSION_FORM_MAX_WEIGHT &&
session_form_double(&session->form, &double_value)) {
draft->input.has_max_weight = true;
draft->input.max_weight_kg = double_value;
draft->input.set_count = 0U;
draft->input.continuous_duration_seconds = 0;
} else {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.value.invalid");
return true;
}
draft_bind_input(draft);
session->dirty = true;
session->message[0] = '\0';
session->form.active = false;
session->form_purpose = TRAINLOG_SESSION_FORM_NONE;
session_controller_sync_durability(session);
return true;
}
static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
{
TrainlogSessionController *session = &app->session;
TrainlogAppRoute route = app->navigation.current.route;
if (route != TRAINLOG_ROUTE_SESSION_MANUAL &&
route != TRAINLOG_ROUTE_SESSION_CURRENT &&
route != TRAINLOG_ROUTE_SESSION_GENERATOR) return false;
if (session->form.active) return app_shell_dispatch_session_form(app, key);
if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7 || key == '?') return false;
if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) {
if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN ||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)
session->session_type = session->session_type == TRAINLOG_SESSION_TRAINING
? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING;
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
if (trainlog_time_now_rfc3339(session->started_at,
sizeof(session->started_at)) == TRAINLOG_STATUS_OK) {
session->has_draft = true;
session_controller_load_picker(app);
}
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
return true;
}
if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) {
if (key == TRAINLOG_KEY_UP && session->picker_selected > 0U)
--session->picker_selected;
else if (key == TRAINLOG_KEY_DOWN &&
session->picker_selected + 1U < session->picker_count)
++session->picker_selected;
else if (key == 'n' || key == 'N')
exercise_controller_start_create(app, true);
else if (key == TRAINLOG_KEY_ENTER || key == '\n')
(void)session_controller_choose_exercise(session);
else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
session->replacing_occurrence = false;
session->phase = TRAINLOG_SESSION_DRAFT;
}
return true;
}
if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) {
if (key == TRAINLOG_KEY_UP && session->equipment_selected > 0U)
--session->equipment_selected;
else if (key == TRAINLOG_KEY_DOWN &&
session->equipment_selected < session->equipment_count)
++session->equipment_selected;
else if ((key == 'x' || key == 'X') && session->selected < session->draft_count) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
draft->input.equipment_id[0] = '\0';
draft->input.load_mode = TRAINLOG_LOAD_NONE;
draft->input.target_has_weight = false;
draft->input.target_weight_kg = 0.0;
draft->target_from_percent_max = false;
for (size_t index = 0U; index < draft->input.set_count; ++index) {
draft->sets[index].has_weight = false;
draft->sets[index].weight_kg = 0.0;
}
draft_bind_input(draft);
session->dirty = true;
session->phase = TRAINLOG_SESSION_DRAFT;
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') &&
session->selected < session->draft_count) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
const char *id = session->equipment_selected == 0U ? ""
: app->equipment[session->equipment_selected - 1U].equipment_id;
bool context_changed = strcmp(draft->input.equipment_id, id) != 0;
(void)snprintf(session->drafts[session->selected].input.equipment_id,
sizeof(session->drafts[session->selected].input.equipment_id), "%s", id);
if (session->equipment_selected == 0U)
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE;
else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics,
"assistance") == 0)
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_ASSISTANCE;
else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics,
"external") == 0)
session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_EXTERNAL;
else session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE;
/* INVARIANT: a calculated target cannot survive an equipment
* context change. Direct manual kg remains user-owned. */
if (context_changed && draft->target_from_percent_max) {
draft->input.target_has_weight = false;
draft->input.target_weight_kg = 0.0;
draft->target_from_percent_max = false;
}
if (session->drafts[session->selected].input.load_mode == TRAINLOG_LOAD_NONE) {
session->drafts[session->selected].input.target_has_weight = false;
session->drafts[session->selected].input.target_weight_kg = 0.0;
for (size_t index = 0U; index < draft->input.set_count; ++index) {
draft->sets[index].has_weight = false;
draft->sets[index].weight_kg = 0.0;
}
draft_bind_input(draft);
}
session->dirty = true;
session->phase = TRAINLOG_SESSION_DRAFT;
} else if (key == 'n' || key == 'N') {
(void)memset(&session->pending_equipment, 0,
sizeof(session->pending_equipment));
session->message[0] = '\0';
session->phase = TRAINLOG_SESSION_EQUIPMENT_CREATE;
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_EQUIPMENT_NAME, "");
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
session->phase = TRAINLOG_SESSION_DRAFT;
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_PLANNING &&
session->selected < session->draft_count) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
if (key == TRAINLOG_KEY_UP && session->planning_field > 0U)
--session->planning_field;
else if (key == TRAINLOG_KEY_DOWN && session->planning_field < 4U)
++session->planning_field;
else if (key == TRAINLOG_KEY_TAB)
session->planning_field = (session->planning_field + 1U) % 5U;
else if (key == TRAINLOG_KEY_SHIFT_TAB)
session->planning_field = session->planning_field > 0U
? session->planning_field - 1U : 4U;
else if (key == '%' && session->planning_field == 4U) {
if (draft->input.load_mode != TRAINLOG_LOAD_EXTERNAL ||
draft->input.equipment_id[0] == '\0') {
draft->input.target_has_weight = false;
draft->input.target_weight_kg = 0.0;
draft->target_from_percent_max = false;
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.percent.unavailable");
} else session_controller_start_form(session,
TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX, "");
} else if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
char value[48] = "";
TrainlogSessionFormPurpose purposes[] = {
TRAINLOG_SESSION_FORM_TARGET_SETS,
TRAINLOG_SESSION_FORM_TARGET_REPS,
TRAINLOG_SESSION_FORM_TARGET_DURATION,
TRAINLOG_SESSION_FORM_REST,
TRAINLOG_SESSION_FORM_TARGET_WEIGHT
};
bool applicable = session->planning_field == 0U
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS
: session->planning_field == 1U
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS &&
draft->tracking_mode == TRAINLOG_TRACKING_REPS
: session->planning_field == 2U
? draft->tracking_mode == TRAINLOG_TRACKING_DURATION
: session->planning_field == 3U
? draft->input.recording_mode == TRAINLOG_RECORDING_SETS
: draft->input.load_mode != TRAINLOG_LOAD_NONE;
if (!applicable) {
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.field.not.applicable");
return true;
}
if (session->planning_field == 0U)
(void)snprintf(value, sizeof(value), "%d", draft->input.target_sets);
else if (session->planning_field == 1U)
(void)snprintf(value, sizeof(value), "%d", draft->input.target_reps);
else if (session->planning_field == 2U)
(void)snprintf(value, sizeof(value), "%d", draft->input.target_duration_seconds);
else if (session->planning_field == 3U)
(void)snprintf(value, sizeof(value), "%d", draft->input.rest_seconds);
else if (draft->input.target_has_weight)
(void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.target_weight_kg);
session_controller_start_form(session, purposes[session->planning_field], value);
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27 || key == 'b' || key == 'B')
session->phase = TRAINLOG_SESSION_DRAFT;
return true;
}
if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
session->destructive_confirm_selected = !session->destructive_confirm_selected;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && session->selected < session->draft_count) {
draft_delete_exercise(session->drafts, &session->draft_count,
session->selected);
if (session->selected >= session->draft_count && session->selected > 0U)
--session->selected;
session->dirty = true;
session->phase = TRAINLOG_SESSION_DRAFT;
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
session->phase = TRAINLOG_SESSION_DRAFT;
return true;
}
if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
session->destructive_confirm_selected = !session->destructive_confirm_selected;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && draft->input.set_count > 0U) {
for (size_t index = session->set_selected; index + 1U < draft->input.set_count; ++index)
draft->sets[index] = draft->sets[index + 1U];
--draft->input.set_count;
if (session->set_selected >= draft->input.set_count && session->set_selected > 0U) --session->set_selected;
draft_bind_input(draft); session->dirty = true; session->phase = TRAINLOG_SESSION_ACTUALS;
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
session->phase = TRAINLOG_SESSION_ACTUALS;
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
session->destructive_confirm_selected = !session->destructive_confirm_selected;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected) {
session_controller_clear_draft(session);
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
session->phase = TRAINLOG_SESSION_DRAFT;
return true;
}
if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n') {
TrainlogAppRoute pending = session->pending_route;
app_shell_open_route(app, pending);
} else if (key == 'd' || key == 'D') {
TrainlogAppRoute pending = session->pending_route;
session_controller_discard_generator(session);
app_shell_open_route(app, pending);
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
session->phase = session->return_phase;
return true;
}
if (session->phase == TRAINLOG_SESSION_MESSAGE) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' ||
key == TRAINLOG_KEY_ESCAPE || key == 27) {
session->message[0] = '\0';
session->phase = session->return_phase != TRAINLOG_SESSION_IDLE
? session->return_phase : TRAINLOG_SESSION_DRAFT;
}
return true;
}
if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) {
size_t zone_count = trainlog_body_zone_catalog_count();
if ((key == 'z' || key == 'Z') && zone_count > 0U) {
session->generation_zone_index =
(session->generation_zone_index + 1U) % zone_count;
session->generation_zone = trainlog_body_zone_catalog_at(
session->generation_zone_index);
} else if ((key == 'o' || key == 'O') &&
trainlog_session_generation_policy_v1.goal_count > 0U) {
session->generation_goal_index = (session->generation_goal_index + 1U) %
trainlog_session_generation_policy_v1.goal_count;
session->generation_goal = &trainlog_session_generation_policy_v1.goals[
session->generation_goal_index];
} else if ((key == 't' || key == 'T') &&
trainlog_session_generation_policy_v1.duration_preset_count > 0U) {
session->generation_duration_index =
(session->generation_duration_index + 1U) %
trainlog_session_generation_policy_v1.duration_preset_count;
session->generation_duration_minutes =
trainlog_session_generation_policy_v1.duration_presets_minutes[
session->generation_duration_index];
} else if (key == 'g' || key == 'G' || key == TRAINLOG_KEY_ENTER || key == '\n')
(void)session_controller_generate(app);
else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
}
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) {
if (key == 'c' || key == 'C' || key == TRAINLOG_KEY_ENTER || key == '\n') {
session->generation_warning_acknowledged = true;
session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
} else if (key == 'z' || key == 'Z') {
session->generated_preview = false;
session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG;
} else if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
session->return_phase = TRAINLOG_SESSION_GENERATOR_WARNING;
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
}
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) {
size_t count = session->generated.exercise_count;
if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected;
else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < count)
++session->selected;
else if ((key == '<' || key == ',') && session->selected > 0U) {
TrainlogGeneratedExercise item = session->generated.exercises[session->selected];
TrainlogGeneratorPreviewItem label = session->generated_items[session->selected];
session->generated.exercises[session->selected] =
session->generated.exercises[session->selected - 1U];
session->generated_items[session->selected] =
session->generated_items[session->selected - 1U];
session->generated.exercises[--session->selected] = item;
session->generated_items[session->selected] = label;
} else if ((key == '>' || key == '.') && session->selected + 1U < count) {
TrainlogGeneratedExercise item = session->generated.exercises[session->selected];
TrainlogGeneratorPreviewItem label = session->generated_items[session->selected];
session->generated.exercises[session->selected] =
session->generated.exercises[session->selected + 1U];
session->generated_items[session->selected] =
session->generated_items[session->selected + 1U];
session->generated.exercises[++session->selected] = item;
session->generated_items[session->selected] = label;
} else if ((key == 'd' || key == TRAINLOG_KEY_DELETE) && count > 0U) {
for (size_t index = session->selected; index + 1U < count; ++index) {
session->generated.exercises[index] = session->generated.exercises[index + 1U];
session->generated_items[index] = session->generated_items[index + 1U];
}
--session->generated.exercise_count;
if (session->selected >= session->generated.exercise_count && session->selected > 0U)
--session->selected;
} else if ((key == 'e' || key == 'E') && count > 0U) {
char value[48];
(void)snprintf(value, sizeof(value), "%d",
session->generated.exercises[session->selected].target_sets);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_GENERATED_SETS, value);
} else if (key == '%' && count > 0U) {
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX, "");
} else if ((key == 'x' || key == 'X') && count > 0U) {
TrainlogGeneratedExercise *generated =
&session->generated.exercises[session->selected];
generated->has_target_weight = false;
generated->target_weight_kg = 0.0;
generated->planned_load_mode = TRAINLOG_LOAD_NONE;
generated->rationale_count = 1U;
(void)snprintf(generated->rationale_codes[0],
sizeof(generated->rationale_codes[0]), "%s", "numeric_load_absent");
generated->load_source_session_id[0] = '\0';
generated->load_source_occurrence_id[0] = '\0';
generated->load_source_started_at[0] = '\0';
} else if ((key == 'u' || key == 'U') && count > 0U) {
TrainlogGeneratedExercise *generated =
&session->generated.exercises[session->selected];
(void)session_controller_requalify_generated(app, session->selected,
generated->target_sets, generated->target_repetitions);
} else if (key == 'g' || key == 'G') {
(void)session_controller_generate(app);
} else if (key == 'a' || key == 'A' || key == TRAINLOG_KEY_ENTER || key == '\n') {
if (session_controller_accept_generator(app))
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
} else if (key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) {
session->pending_route = TRAINLOG_ROUTE_SESSIONS;
session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE;
}
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_ACTUALS && session->draft_count > 0U) {
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
if (key == 'f' || key == 'F' || key == 'b' || key == 'B' ||
key == TRAINLOG_KEY_ESCAPE || key == 27) session->phase = TRAINLOG_SESSION_DRAFT;
else if (session->session_type == TRAINLOG_SESSION_MAX_TEST ||
draft->input.has_max_weight) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
char value[48] = "";
if (draft->input.has_max_weight)
(void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.max_weight_kg);
session_controller_start_form(session, TRAINLOG_SESSION_FORM_MAX_WEIGHT, value);
}
} else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') {
char value[48] = "";
if (draft->input.continuous_duration_seconds > 0)
(void)snprintf(value, sizeof(value), "%d",
draft->input.continuous_duration_seconds);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION, value);
} else if ((key == 's' || key == 'S') &&
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) {
char value[48] = "";
if (draft->input.continuous_has_speed)
(void)trainlog_presentation_format_input(value, sizeof(value), "%.2f",
draft->input.continuous_speed_kmh);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED, value);
} else if ((key == 'k' || key == 'K') &&
(draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) {
char value[48] = "";
if (draft->input.continuous_has_distance)
(void)trainlog_presentation_format_input(value, sizeof(value), "%.2f",
draft->input.continuous_distance_km);
session_controller_start_form(session,
TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE, value);
}
} else if (key == TRAINLOG_KEY_UP && session->set_selected > 0U)
--session->set_selected;
else if (key == TRAINLOG_KEY_DOWN &&
session->set_selected + 1U < draft->input.set_count)
++session->set_selected;
else if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB ||
key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)
session->set_field = session->set_field == 0U ? 1U : 0U;
else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
draft->input.set_count > 0U) {
session->destructive_confirm_selected = false;
session->phase = TRAINLOG_SESSION_CONFIRM_SET_REMOVE;
} else if ((key == 'a' || key == 'A') &&
draft->input.set_count < MAX_SETS_PER_EXERCISE) {
(void)memset(&session->pending_set, 0, sizeof(session->pending_set));
session->set_selected = draft->input.set_count;
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, "");
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') &&
session->set_selected < draft->input.set_count) {
char value[48];
session->pending_set = draft->sets[session->set_selected];
if (session->set_field == 1U && draft->input.load_mode != TRAINLOG_LOAD_NONE) {
(void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", session->pending_set.weight_kg);
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_WEIGHT, value);
} else {
(void)snprintf(value, sizeof(value), "%d",
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
? session->pending_set.duration_seconds : session->pending_set.reps);
session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, value);
}
}
session_controller_sync_durability(session);
return true;
}
if (session->phase == TRAINLOG_SESSION_DRAFT) {
session->message[0] = '\0';
if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected;
else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < session->draft_count)
++session->selected;
else if (key == 'a' || key == 'A') {
session->replacing_occurrence = false;
(void)session_controller_load_picker(app);
} else if ((key == 'r' || key == 'R') && session->draft_count > 0U) {
session->replacing_occurrence = true;
(void)session_controller_load_picker(app);
} else if ((key == 'i' || key == 'I') && session->draft_count > 0U)
session_controller_load_equipment(app);
else if ((key == 'p' || key == 'P') && session->draft_count > 0U) {
session->planning_field = 0U;
session->phase = TRAINLOG_SESSION_PLANNING;
} else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
session->draft_count > 0U) {
session->destructive_confirm_selected = false;
session->phase = TRAINLOG_SESSION_CONFIRM_REMOVE;
}
else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') &&
session->draft_count > 0U) {
session->set_selected = 0U;
session->set_field = 0U;
session->phase = TRAINLOG_SESSION_ACTUALS;
} else if (key == 'f' || key == 'F') {
TrainlogStatus status = session_controller_save(app);
if (status == TRAINLOG_STATUS_OK) {
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
return true;
}
if (status != TRAINLOG_STATUS_INVALID_ARGUMENT)
(void)trainlog_presentation_format_key(session->message,
sizeof(session->message), "msg.draft.save.fail");
} else if (key == 'q' || key == 'Q') {
session->destructive_confirm_selected = false;
session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON;
}
else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
session_controller_sync_durability(session);
return true;
}
return true;
}
static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key)
{
TrainlogSyncController *sync = &app->sync_controller;
TrainlogSyncScreenAction action;
bool was_confirming;
if (app->navigation.current.route != TRAINLOG_ROUTE_SYNC) return false;
/* INVARIANT: synchronous execution may be re-entered by terminal or test
* callbacks, but no input can launch/probe/open history during that run. */
if (sync->running) return true;
if (sync->showing_detail) {
size_t visible = app->layout.content.height > 5
? (size_t)(app->layout.content.height - 5) : 1U;
if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B' ||
key == TRAINLOG_KEY_ENTER || key == '\n') {
sync->showing_detail = false;
return true;
}
if (key == TRAINLOG_KEY_UP && sync->detail_scroll > 0U)
--sync->detail_scroll;
else if (key == TRAINLOG_KEY_DOWN &&
sync->detail_scroll + visible < sync->detail_line_count)
++sync->detail_scroll;
else if (key == TRAINLOG_KEY_PAGE_UP)
sync->detail_scroll = sync->detail_scroll > visible
? sync->detail_scroll - visible : 0U;
else if (key == TRAINLOG_KEY_PAGE_DOWN &&
sync->detail_line_count > visible) {
size_t maximum = sync->detail_line_count - visible;
sync->detail_scroll = sync->detail_scroll + visible < maximum
? sync->detail_scroll + visible : maximum;
}
return true;
}
was_confirming = sync->action.confirming;
action = trainlog_sync_screen_dispatch(&sync->action,
key == TRAINLOG_KEY_ESCAPE ? 27 : key);
if (action.effect == TRAINLOG_SYNC_SCREEN_REFRESH) {
(void)memset(&sync->device, 0, sizeof(sync->device));
sync->probe_status = (sync->probe != NULL ? sync->probe
: trainlog_sync_probe)(
&sync->device);
sync->probe_known = true;
return true;
}
if (action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM ||
action.effect == TRAINLOG_SYNC_SCREEN_CANCEL) return true;
if (action.effect == TRAINLOG_SYNC_SCREEN_RUN) {
TrainlogAppSyncRun run = sync->run != NULL ? sync->run : trainlog_sync_run;
TrainlogStatus run_status;
(void)memset(&sync->report, 0, sizeof(sync->report));
sync->has_report = false;
sync->running = true;
/* Render before entering the synchronous shared engine. This provides
* immediate progress without a second scheduler or history system. */
if (app->terminal != NULL) app_shell_render(app);
/* INVARIANT: this is the sole engine call site in the controller and
* every TUI launch has the complete bidirectional intent. */
run_status = run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_BIDIRECTIONAL, &sync->report);
sync->running = false;
sync->latest_status = run_status;
sync->has_report = true;
sync_controller_load_history(sync);
(void)memset(&sync->device, 0, sizeof(sync->device));
sync->probe_status = (sync->probe != NULL ? sync->probe
: trainlog_sync_probe)(
&sync->device);
sync->probe_known = true;
return true;
}
if (was_confirming) return true;
if (key == TRAINLOG_KEY_UP && sync->history_count > 0U)
sync->selected = sync->selected > 0U ? sync->selected - 1U
: sync->history_count - 1U;
else if (key == TRAINLOG_KEY_DOWN && sync->history_count > 0U)
sync->selected = sync->selected + 1U < sync->history_count
? sync->selected + 1U : 0U;
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') &&
sync->history_count > 0U)
sync_controller_open_detail(sync);
else return false;
return true;
}
static void app_shell_dispatch(TrainlogAppContext *app, int key)
{
const TrainlogAction *action;
if (!app->layout.usable) { if (key == 'q' || key == 'Q') app->running = false; return; }
if (trainlog_overlays_top(&app->overlays) != NULL) {
app_shell_dispatch_overlay(app, key); return;
}
/* Local forms own their editor keys before shell aliases or focus controls. */
if (app->focus == TRAINLOG_FOCUS_EDITOR) {
if (app_shell_dispatch_exercise_controller(app, key)) return;
if (app_shell_dispatch_equipment_controller(app, key)) return;
if (app_shell_dispatch_body_controller(app, key)) return;
if (app_shell_dispatch_profile_controller(app, key)) return;
if (app_shell_dispatch_session(app, key)) return;
}
/* Local editor input precedes every route or shell alias. Home/End and
* printable digits therefore remain text editing while search is focused. */
if (app->search.focused && app_shell_is_list_route(app->navigation.current.route)) {
bool changed = false;
if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) {
app->search.focused = false;
app->focus = key == TRAINLOG_KEY_SHIFT_TAB
? TRAINLOG_FOCUS_NAVIGATION : TRAINLOG_FOCUS_CONTENT;
return;
}
if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7) {
/* Let a focused search invoke shell controls; the overlay restores
* TRAINLOG_FOCUS_SEARCH and re-enables this editor on close. */
app->search.focused = false;
} else {
if (key == TRAINLOG_KEY_ENTER || key == '\n') {
app->search.focused = false; app->focus = TRAINLOG_FOCUS_CONTENT; return;
}
if (key == TRAINLOG_KEY_ESCAPE || key == 27) {
bool closed = trainlog_search_escape(&app->search);
if (closed) app->focus = TRAINLOG_FOCUS_CONTENT;
app_shell_refresh_list(app); return;
}
if (key == TRAINLOG_KEY_HOME) trainlog_search_home(&app->search);
else if (key == TRAINLOG_KEY_END) trainlog_search_end(&app->search);
else if (key == TRAINLOG_KEY_LEFT) trainlog_search_left(&app->search);
else if (key == TRAINLOG_KEY_RIGHT) trainlog_search_right(&app->search);
else if (key == TRAINLOG_KEY_BACKSPACE || key == TRAINLOG_KEY_DELETE)
changed = trainlog_search_backspace(&app->search);
else if (key >= 0x20 && key <= 0x10ffff) {
char encoded[5] = "";
utf8proc_ssize_t bytes = utf8proc_encode_char((utf8proc_int32_t)key,
(utf8proc_uint8_t *)encoded);
if (bytes > 0) changed = trainlog_search_insert(&app->search,
encoded, (size_t)bytes);
}
if (changed) app_shell_refresh_list(app);
return;
}
}
if (key == TRAINLOG_KEY_F6) {
if (app->layout.sidebar_visible) {
if (app->focus == TRAINLOG_FOCUS_NAVIGATION)
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
else {
app->navigation_restore_focus = app->focus;
app->focus = TRAINLOG_FOCUS_NAVIGATION;
}
}
else (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION,
app->focus, app->navigation.current.stable_id);
return;
}
if (key == TRAINLOG_KEY_F7) {
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
app->focus, app->navigation.current.stable_id); return;
}
if (key == '?') {
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_HELP,
app->focus, app->navigation.current.stable_id); return;
}
if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) {
if (app->layout.sidebar_visible) {
if (key == TRAINLOG_KEY_SHIFT_TAB)
app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION;
else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION;
} else if (key == TRAINLOG_KEY_SHIFT_TAB)
app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION;
else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH
? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT
? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION;
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
if (app->search.focused) app->search.open = true;
return;
}
if (app->focus == TRAINLOG_FOCUS_NAVIGATION) {
if (!app->layout.sidebar_visible) {
if (key == TRAINLOG_KEY_ENTER || key == '\n')
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_NAVIGATION, TRAINLOG_FOCUS_NAVIGATION,
app->navigation.current.stable_id);
else if (key == TRAINLOG_KEY_ESCAPE)
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
/* INVARIANT: compact Navigation owns its input and cannot fall
* through to the content route controller. */
return;
}
size_t count = sizeof(shell_sections) / sizeof(shell_sections[0]);
if (key == TRAINLOG_KEY_UP && app->navigation_selected > 0U) --app->navigation_selected;
else if (key == TRAINLOG_KEY_DOWN && app->navigation_selected + 1U < count)
++app->navigation_selected;
else if (key == TRAINLOG_KEY_ENTER || key == '\n') {
app_shell_open_route(app, shell_sections[app->navigation_selected]);
app->focus = TRAINLOG_FOCUS_CONTENT;
} else if (key == TRAINLOG_KEY_ESCAPE)
app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION
? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus;
app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH;
/* CONTRACT: a key delivered to persistent navigation is consumed
* exactly once. Escape closes focus without also navigating Back. */
return;
}
if (app->focus == TRAINLOG_FOCUS_ACTIONS) {
if (key == TRAINLOG_KEY_ENTER || key == '\n')
(void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS,
TRAINLOG_FOCUS_ACTIONS, app->navigation.current.stable_id);
else if (key == TRAINLOG_KEY_ESCAPE)
app->focus = TRAINLOG_FOCUS_CONTENT;
/* INVARIANT: the rendered Actions control owns its input. */
return;
}
/* WHY: route controllers may legitimately own printable keys. Search is a
* shell action, so resolve its registered '/' before those controllers.
* CONTRACT: physical '/' and F7 select the same list.search action. */
action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
return;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING &&
key >= '1' && key <= '3') {
static const TrainlogStatisticsWindow periods[] = {
TRAINLOG_STATISTICS_7_DAYS, TRAINLOG_STATISTICS_30_DAYS,
TRAINLOG_STATISTICS_ALL
};
/* CONTRACT: a period switch refreshes one controller-owned snapshot;
* rendering stays SQL-free and all graph coordinates are recomputed. */
app->statistics_window = periods[(size_t)(key - '1')];
app_shell_load_statistics(app);
return;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_SETTINGS &&
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
app->content_selected = app->content_selected == 0U ? 1U : 0U;
return;
}
if (app_shell_dispatch_exercise_controller(app, key)) return;
if (app_shell_dispatch_equipment_controller(app, key)) return;
if (app_shell_dispatch_body_controller(app, key)) return;
if (app_shell_dispatch_profile_controller(app, key)) return;
if (app_shell_dispatch_session(app, key)) return;
if (app_shell_dispatch_sync(app, key)) return;
if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
size_t count = app->navigation.current.route == TRAINLOG_ROUTE_STATS ? 5U : 3U;
if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected;
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
return;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING &&
(key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) {
app->statistics_graph = key == TRAINLOG_KEY_LEFT
? (app->statistics_graph + 4U) % 5U
: (app->statistics_graph + 1U) % 5U;
return;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES &&
app->statistics_zone_count > 0U &&
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
if (key == TRAINLOG_KEY_UP)
app->statistics_zone_selected = app->statistics_zone_selected > 0U
? app->statistics_zone_selected - 1U
: app->statistics_zone_count - 1U;
else
app->statistics_zone_selected =
(app->statistics_zone_selected + 1U) % app->statistics_zone_count;
return;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL &&
(key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) {
size_t step;
for (step = 0U; step < TRAINLOG_EXERCISE_SERIES_COUNT; ++step) {
size_t next = key == TRAINLOG_KEY_LEFT
? ((size_t)app->exercise_statistics_graph +
TRAINLOG_EXERCISE_SERIES_COUNT - 1U) %
TRAINLOG_EXERCISE_SERIES_COUNT
: ((size_t)app->exercise_statistics_graph + 1U) %
TRAINLOG_EXERCISE_SERIES_COUNT;
app->exercise_statistics_graph = (TrainlogExerciseSeriesKind)next;
if (app->exercise_statistics.series_count[next] > 0U) break;
}
return;
}
if (app_shell_is_list_route(app->navigation.current.route)) {
if ((key == 'a' || key == 'A') &&
app->navigation.current.route == TRAINLOG_ROUTE_MAX) {
app->max_show_all = !app->max_show_all;
app_shell_refresh_list(app);
return;
}
if ((key == 'a' || key == 'A') &&
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
exercise_controller_start_create(app, false);
return;
}
if ((key == 'n' || key == 'N') &&
app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT) {
equipment_controller_start(app);
return;
}
if (key == TRAINLOG_KEY_UP) {
trainlog_list_move(&app->list, app->stable_ids, -1); return;
}
if (key == TRAINLOG_KEY_DOWN) {
trainlog_list_move(&app->list, app->stable_ids, 1); return;
}
if (key == TRAINLOG_KEY_PAGE_UP) {
trainlog_list_move(&app->list, app->stable_ids,
-(int)app->list.visible_rows); return;
}
if (key == TRAINLOG_KEY_PAGE_DOWN) {
trainlog_list_move(&app->list, app->stable_ids,
(int)app->list.visible_rows); return;
}
if ((key == 'z' || key == 'Z') &&
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
size_t zone_count = trainlog_body_zone_catalog_count();
++app->exercise_zone_filter;
if (app->exercise_zone_filter > (int)zone_count)
app->exercise_zone_filter = -1;
app_shell_refresh_list(app); return;
}
if ((key == 'x' || key == 'X') &&
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) {
trainlog_search_init(&app->search); app->exercise_zone_filter = -1;
app_shell_refresh_list(app); return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) {
if (key == 'a' || key == 'A') {
body_controller_start_add(app); return;
}
if ((key == 'e' || key == 'E') && app->loaded_count > 0U) {
(void)body_controller_start_edit(app,
app->body_records[app->list.selected_index].observation_id);
return;
}
if (key == 'v' || key == 'V') {
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return;
}
if (key == 'm' || key == 'M') {
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_METRIC); return;
}
if (key == 'g' || key == 'G') {
app->body_metric_selected = 0U;
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_TRENDS); return;
}
if (key == 'o' || key == 'O') {
app_shell_open_route(app, TRAINLOG_ROUTE_BODY_GLOBAL); return;
}
if (key == TRAINLOG_KEY_LEFT) {
app_shell_move_available_body_metric(app, -1); return;
}
if (key == TRAINLOG_KEY_RIGHT) {
app_shell_move_available_body_metric(app, 1); return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) {
if (key == 'e' || key == 'E') {
(void)body_controller_start_edit(app,
app->body_detail.observation_id); return;
}
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) {
--app->content_scroll; return;
}
if (key == TRAINLOG_KEY_DOWN && app->content_scroll + 1U < 14U) {
++app->content_scroll; return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_METRIC) {
if (key == TRAINLOG_KEY_LEFT) {
app->body_metric_selected = app->body_metric_selected > 0U
? app->body_metric_selected - 1U : 13U;
app_shell_load_body_metric(app); return;
}
if (key == TRAINLOG_KEY_RIGHT) {
app->body_metric_selected = (app->body_metric_selected + 1U) % 14U;
app_shell_load_body_metric(app); return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_GLOBAL) {
if (key == TRAINLOG_KEY_UP)
app->body_global_selected = app->body_global_selected > 0U
? app->body_global_selected - 1U : 13U;
else if (key == TRAINLOG_KEY_DOWN)
app->body_global_selected = (app->body_global_selected + 1U) % 14U;
else if (key == ' ')
app->body_global_enabled[app->body_global_selected] =
!app->body_global_enabled[app->body_global_selected];
else goto body_global_unhandled;
return;
body_global_unhandled:;
}
if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_ANALYTICS) {
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT) {
app->content_selected = app->content_selected == 0U ? 1U : 0U;
return;
}
if (key == 'p' || key == 'P') {
profile_controller_start(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
size_t equipment_count = app->exercise_explicit_equipment_count +
app->exercise_historic_equipment_count;
if (key == TRAINLOG_KEY_UP && app->exercise_equipment_selected > 0U) {
--app->exercise_equipment_selected;
return;
}
if (key == TRAINLOG_KEY_DOWN &&
app->exercise_equipment_selected + 1U < equipment_count) {
++app->exercise_equipment_selected;
return;
}
if ((key == TRAINLOG_KEY_ENTER || key == '\n') && equipment_count > 0U) {
size_t selected = app->exercise_equipment_selected;
app->equipment_detail = selected < app->exercise_explicit_equipment_count
? app->exercise_explicit_equipment[selected]
: app->exercise_historic_equipment[
selected - app->exercise_explicit_equipment_count];
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
app->equipment_detail.equipment_id);
return;
}
if (key == 'k' || key == 'K') {
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE,
app->exercise_detail.exercise_id); return;
}
if (key == 'p' || key == 'P') {
app_shell_open_exercise_analytics(app,
TRAINLOG_ROUTE_EXERCISE_PERFORMANCE); return;
}
if (key == 'm' || key == 'M') {
app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX); return;
}
if (key == 'e' || key == 'E') {
exercise_controller_start_edit(app);
return;
}
if (key == 'u' || key == 'U') {
exercise_controller_start_merge(app);
return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) {
size_t page = app->layout.content.height > 4
? (size_t)(app->layout.content.height - 4) : 1U;
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll;
else if (key == TRAINLOG_KEY_DOWN) ++app->content_scroll;
else if (key == TRAINLOG_KEY_PAGE_UP)
app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U;
else if (key == TRAINLOG_KEY_PAGE_DOWN) app->content_scroll += page;
else if (key == 'k' || key == 'K') (void)app_shell_back(app);
else goto shell_nonknowledge_input;
return;
}
shell_nonknowledge_input:
if (app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL &&
(key == TRAINLOG_KEY_ENTER || key == '\n')) {
size_t relation_index;
for (relation_index = 0U;
relation_index < trainlog_equipment_exercise_relation_count();
++relation_index) {
const TrainlogEquipmentExerciseRelation *relation =
trainlog_equipment_exercise_relation_at(relation_index);
if (relation == NULL || strcmp(relation->equipment_id,
app->equipment_detail.equipment_id) != 0) continue;
if (trainlog_database_get_exercise_profile(app->database,
relation->exercise_id, &app->exercise_detail) == TRAINLOG_STATUS_OK) {
app_shell_load_exercise_detail_metadata(app);
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EXERCISE_DETAIL, app->exercise_detail.exercise_id);
}
return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE) {
size_t page = app->layout.content.height > 10
? (size_t)(app->layout.content.height - 10) : 1U;
if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll;
else if (key == TRAINLOG_KEY_DOWN &&
app->content_scroll + 1U < app->performance_count) ++app->content_scroll;
else if (key == TRAINLOG_KEY_PAGE_UP)
app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U;
else if (key == TRAINLOG_KEY_PAGE_DOWN && app->performance_count > 0U)
app->content_scroll = app->content_scroll + page < app->performance_count
? app->content_scroll + page : app->performance_count - 1U;
else goto shell_nonanalytics_input;
return;
}
shell_nonanalytics_input:
if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_DETAIL &&
!app->session_detail_error) {
TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U
? &app->session_entries[app->session_entry_selected] : NULL;
if ((key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_UP) &&
app->session_entry_count > 0U) {
app->session_entry_selected = app->session_entry_selected > 0U
? app->session_entry_selected - 1U : app->session_entry_count - 1U;
app->session_set_scroll = 0U; return;
}
if ((key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_DOWN) &&
app->session_entry_count > 0U) {
app->session_entry_selected = (app->session_entry_selected + 1U) %
app->session_entry_count;
app->session_set_scroll = 0U; return;
}
if (key == TRAINLOG_KEY_PAGE_UP && app->session_set_scroll > 0U) {
--app->session_set_scroll; return;
}
if (key == TRAINLOG_KEY_PAGE_DOWN && entry != NULL &&
app->session_set_scroll + 1U < entry->actual_set_count) {
++app->session_set_scroll; return;
}
if ((key == 'i' || key == 'I') && entry != NULL &&
entry->equipment_id[0] != '\0') {
if (trainlog_database_resolve_equipment(app->database,
entry->equipment_id, &app->equipment_detail) == TRAINLOG_STATUS_OK)
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
app->equipment_detail.equipment_id);
return;
}
if (key == 'e' || key == 'E') {
char session_id[TRAINLOG_ID_MAX + 1U];
(void)snprintf(session_id, sizeof(session_id), "%s",
app->session_detail.session_id);
if (app->session.has_draft) {
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
(void)trainlog_presentation_format_key(app->session.message,
sizeof(app->session.message), "msg.session.exists");
} else {
TrainlogSessionSummary persisted;
if (load_persisted_draft(app->database, session_id, &persisted,
app->session.drafts, &app->session.draft_count)) {
app->session.has_draft = true;
app->session.dirty = false;
app->session.correcting = true;
app->session.session_type = persisted.session_type;
(void)snprintf(app->session.persisted_session_id,
sizeof(app->session.persisted_session_id), "%s", session_id);
app->session.phase = TRAINLOG_SESSION_DRAFT;
session_controller_sync_durability(&app->session);
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT);
}
}
return;
}
}
if (key == TRAINLOG_KEY_ENTER || key == '\n') { app_shell_primary(app); return; }
if (key == TRAINLOG_KEY_HOME) key = '0';
else if (key == TRAINLOG_KEY_F1) key = '1';
else if (key == TRAINLOG_KEY_F2) key = '2';
else if (key == TRAINLOG_KEY_F3) key = '3';
else if (key == TRAINLOG_KEY_F4) key = '4';
else if (key == TRAINLOG_KEY_F5) key = '5';
action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) {
app_shell_open_route(app, action->route); return;
}
else if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B') {
if (!app_shell_back(app)) app_shell_open_route(app, TRAINLOG_ROUTE_HOME);
} else if ((key == 'q' || key == 'Q') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) {
session_controller_sync_durability(&app->session);
if (app->session.has_draft || app->session.generated_preview ||
app->session.generation_zone != NULL ||
app->session.durability.transient_form_dirty) {
app->quit_confirmation = true;
(void)trainlog_overlays_push(&app->overlays,
TRAINLOG_OVERLAY_CONFIRMATION, app->focus,
app->navigation.current.stable_id);
} else app->running = false;
}
}
int trainlog_tui_run(TrainlogDatabase *database)
{
TrainlogAppContext app;
if (database == NULL) {
return 1;
}
(void)setlocale(LC_ALL, "");
/* WHY: presentation preference is loaded before the first surface is
* created so the initial frame cannot flash the default language.
* Invalid/missing local config deterministically remains French. */
trainlog_presentation_init();
(void)trainlog_presentation_load();
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.terminal = trainlog_terminal_create();
if (app.terminal == NULL) {
return 1;
}
trainlog_terminal_cursor_visible(app.terminal, false);
trainlog_navigation_init(&app.navigation);
trainlog_overlays_init(&app.overlays);
trainlog_list_init(&app.list);
trainlog_search_init(&app.search);
trainlog_sync_screen_state_init(&app.sync_controller.action);
app.sync_controller.probe_status = TRAINLOG_STATUS_NOT_FOUND;
app.sync_controller.probe = trainlog_sync_probe;
app.sync_controller.run = trainlog_sync_run;
app.exercise_zone_filter = -1;
app.focus = TRAINLOG_FOCUS_CONTENT;
app.running = true;
/* CONTRACT: this is the single shell event pump. Controllers return to it
* after an explicit action; navigation and resize never write domain data. */
while (app.running) {
int key;
app_shell_actions(&app);
app_shell_render(&app);
if (!app.running) break;
key = trainlog_terminal_get_key(app.terminal);
if (key == TRAINLOG_KEY_NONE) continue;
if (key == TRAINLOG_KEY_RESIZE) {
if (!trainlog_terminal_refresh_geometry(app.terminal)) {
app.running = false;
break;
}
continue;
}
app_shell_dispatch(&app, key);
}
app_shell_destroy_surfaces(&app);
app_shell_release_session_detail(&app);
trainlog_terminal_destroy(app.terminal);
return 0;
}
/* tui.c is included directly by the focused workflow harness; do not rewrite
* the harness's terminal mocks after the production implementation ends. */
#undef trainlog_surface_printf
#undef trainlog_terminal_printf