Implement Web Sessions Core API and interface

This commit is contained in:
fy59 2026-09-17 20:27:41 +02:00
parent ec17fca45d
commit 47385c7a86
39 changed files with 1876 additions and 43 deletions

View file

@ -23,7 +23,7 @@ CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEX
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT);
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
PRAGMA user_version=22;
PRAGMA user_version=23;
"""
class GenerationTest(unittest.TestCase):

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22):
raise CausalError(f"desktop schema v20-v22 required, found v{version}")
if version not in (20, 21, 22, 23):
raise CausalError(f"desktop schema v20-v23 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22):
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}")
if version not in (19, 20, 21, 22, 23):
raise LifecycleError(f"desktop schema v19-v23 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
raise ValueError("schema desktop v18-v22 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v18-v23 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")

View file

@ -16,8 +16,9 @@ def main():
args = parser.parse_args()
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
raise ValueError("schema desktop v12-v16 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v12-v23 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22):
if version not in (17, 18, 19, 20, 21, 22, 23):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22) and not (args.version == 2 and schema_version == 10):
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22): raise ValueError("schema desktop v15-v22 requis")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23): raise ValueError("schema desktop v15-v23 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
raise ImportFailure("schema desktop v18-v22 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
raise ImportFailure("schema desktop v18-v23 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export
if proof_path is None:

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22): raise ImportFailure("schema desktop v11-v22 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23): raise ImportFailure("schema desktop v11-v23 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -49,7 +49,7 @@ def validate(item):
def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22): fail("schema desktop v17-v22 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): fail("schema desktop v17-v23 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ImportFailure(
f"base desktop schema v5 à v22 attendue, version trouvée: {version}"
f"base desktop schema v5 à v23 attendue, version trouvée: {version}"
)
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v23")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22):fail("schema desktop v15-v22 requis")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23):fail("schema desktop v15-v23 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -95,7 +95,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.2",
"desktop_schema": 22,
"desktop_schema": 23,
"android_schema": 21,
"protocols": [
"mobile-export-v3",

View file

@ -199,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
raise GenerationError("desktop schema v22 required")
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
raise GenerationError("desktop schema v23 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:

View file

@ -39,7 +39,9 @@
* - `se` for sessions;
* - `bo` for body observations.
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences.
* - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
* delivery identities.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -0,0 +1,68 @@
/* Trainlog Web Sessions V1 application service. */
#ifndef TRAINLOG_WEB_SESSIONS_H
#define TRAINLOG_WEB_SESSIONS_H
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_WEB_SESSIONS_PAGE_MAX 64U
#define TRAINLOG_WEB_SESSIONS_JSON_MAX (512U * 1024U)
typedef enum TrainlogWebSessionCollection {
TRAINLOG_WEB_SESSION_PREPARATIONS = 0,
TRAINLOG_WEB_SESSION_PROPOSALS,
TRAINLOG_WEB_SESSION_DRAFTS,
TRAINLOG_WEB_SESSION_HISTORY
} TrainlogWebSessionCollection;
typedef struct TrainlogWebSessionsPageQuery {
TrainlogWebSessionCollection collection;
size_t offset;
size_t limit;
const char *search;
} TrainlogWebSessionsPageQuery;
/*
* CONTRACT: database and query strings are borrowed for the call. On success,
* output_json is heap-owned by the caller and must be released with free().
* A page contains at most TRAINLOG_WEB_SESSIONS_PAGE_MAX rows plus a `more`
* flag derived from one additional row observed in the same read snapshot.
*/
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
const TrainlogWebSessionsPageQuery *query,
char **output_json,
size_t *output_size);
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
* returned detail is a factual snapshot and performs no lifecycle transition. */
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
const char *kind,
const char *identity,
char **output_json,
size_t *output_size);
/* CONTRACT: the catalogue contains only currently selectable exercises. */
TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database,
const char *search,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size);
/*
* CONTRACT: save_body is a complete revision document and request_id is a
* durable idempotency key. expected_revision is empty only for creation.
* Validation and the head comparison occur in one immediate transaction.
*/
TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
const char *save_body,
size_t save_body_size,
char **output_json,
size_t *output_size);
#endif

View file

@ -194,6 +194,7 @@ trainlog_core_sources = files(
'src/web_dashboard_json.c',
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/web_sessions.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -346,6 +347,15 @@ test_web_prepared_items = executable(
)
test('web_prepared_items', test_web_prepared_items)
test_web_sessions = executable(
'test_web_sessions',
'tests/test_web_sessions.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_sessions', test_web_sessions)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',

View file

@ -1618,7 +1618,8 @@ static TrainlogStatus initialize_or_validate_schema(
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21 || version == 22) {
version == 19 || version == 20 || version == 21 || version == 22 ||
version == 23) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {

View file

@ -22,7 +22,11 @@ static bool prefix_is_supported(const char *prefix)
strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 ||
strcmp(prefix, "sxe") == 0;
strcmp(prefix, "sxe") == 0 ||
strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 ||
strcmp(prefix, "spe") == 0 ||
strcmp(prefix, "spd") == 0;
}
TrainlogStatus trainlog_id_generate(

View file

@ -22,6 +22,7 @@
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "web_assets.h"
@ -29,7 +30,8 @@
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
#define TRAINLOG_HTTP_BACKLOG 32U
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
@ -513,6 +515,46 @@ static bool parse_if_match(const char *value, uint64_t *revision) {
return true;
}
static bool parse_page_number(const char *value, size_t default_value, size_t maximum, size_t *out) {
char *end = NULL;
unsigned long long parsed;
if (value == NULL) {
*out = default_value;
return true;
}
errno = 0;
parsed = strtoull(value, &end, 10);
if (errno != 0 || end == value || *end != '\0' || parsed > maximum) {
return false;
}
*out = (size_t)parsed;
return true;
}
static enum MHD_Result queue_owned_sessions_json(struct MHD_Connection *connection,
TrainlogStatus status,
char *json,
size_t json_size) {
enum MHD_Result result;
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
free(json);
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"sessions_unavailable\"}\n",
NULL);
}
(void)json_size;
result = queue_json(connection, MHD_HTTP_OK, json, NULL);
free(json);
return result;
}
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection,
const char *url,
@ -561,7 +603,9 @@ static enum MHD_Result handle_request(void *closure,
"{\"error\":\"headers_too_large\"}\n",
NULL);
}
if (state->body_too_large) {
if (state->body_too_large ||
(strncmp(url, "/api/v1/sessions/", strlen("/api/v1/sessions/")) != 0 &&
state->body_size > TRAINLOG_HTTP_LEGACY_BODY_LIMIT)) {
return queue_json(
connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL);
}
@ -729,6 +773,167 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strncmp(url, "/api/v1/sessions", strlen("/api/v1/sessions")) == 0) {
static const struct {
const char *path;
TrainlogWebSessionCollection collection;
} collections[] = {
{"/api/v1/sessions/preparations", TRAINLOG_WEB_SESSION_PREPARATIONS},
{"/api/v1/sessions/proposals", TRAINLOG_WEB_SESSION_PROPOSALS},
{"/api/v1/sessions/drafts", TRAINLOG_WEB_SESSION_DRAFTS},
{"/api/v1/sessions/history", TRAINLOG_WEB_SESSION_HISTORY},
};
const char *offset_value;
const char *limit_value;
const char *search;
char *json = NULL;
size_t json_size = 0U;
size_t offset;
size_t limit;
size_t index;
if (!is_get &&
(strcmp(url, "/api/v1/sessions/preparations") == 0 ||
strncmp(url,
"/api/v1/sessions/preparation/",
strlen("/api/v1/sessions/preparation/")) == 0)) {
const char *origin =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
const char *csrf = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *content_type = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
const char *request_id = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
const char *identity = NULL;
const char *expected = "";
char expected_buffer[TRAINLOG_ID_MAX + 1U];
TrainlogStatus status;
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(strcmp(url, "/api/v1/sessions/preparations") != 0 &&
strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST, PUT");
}
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(connection,
MHD_HTTP_FORBIDDEN,
"{\"error\":\"mutation_forbidden\"}\n",
NULL);
}
if (content_type == NULL || strcmp(content_type, "application/json") != 0 ||
request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_mutation_headers\"}\n",
NULL);
}
if (strcmp(url, "/api/v1/sessions/preparations") != 0) {
size_t length;
identity = url + strlen("/api/v1/sessions/preparation/");
if (if_match == NULL || if_match[0] != '"') {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
length = strlen(if_match);
if (length < 3U || length - 2U > TRAINLOG_ID_MAX ||
if_match[length - 1U] != '"') {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_revision\"}\n",
NULL);
}
(void)memcpy(expected_buffer, if_match + 1, length - 2U);
expected_buffer[length - 2U] = '\0';
expected = expected_buffer;
}
status = trainlog_web_sessions_save_json(context->database,
identity,
expected,
request_id,
state->body,
state->body_size,
&json,
&json_size);
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(connection,
MHD_HTTP_UNPROCESSABLE_ENTITY,
"{\"error\":\"invalid_preparation\"}\n",
NULL);
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
if (!is_get) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET");
}
offset_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset");
limit_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit");
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
if (!parse_page_number(offset_value, 0U, 1000000U, &offset) ||
!parse_page_number(
limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) ||
limit == 0U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
}
if (strcmp(url, "/api/v1/sessions/catalog") == 0) {
TrainlogStatus status = trainlog_web_sessions_catalog_json(
context->database, search, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
if (strcmp(url, collections[index].path) == 0) {
TrainlogWebSessionsPageQuery query = {
collections[index].collection, offset, limit, search};
TrainlogStatus status = trainlog_web_sessions_list_json(
context->database, &query, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
}
{
static const char DETAIL_PREFIX[] = "/api/v1/sessions/";
const char *kind = url + strlen(DETAIL_PREFIX);
const char *separator = strchr(kind, '/');
char kind_buffer[32];
if (separator != NULL && separator[1] != '\0' &&
(size_t)(separator - kind) < sizeof(kind_buffer)) {
TrainlogStatus status;
(void)memcpy(kind_buffer, kind, (size_t)(separator - kind));
kind_buffer[separator - kind] = '\0';
status = trainlog_web_sessions_detail_json(context->database,
kind_buffer,
separator + 1,
&json,
&json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
}
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;

916
tui/src/web_sessions.c Normal file
View file

@ -0,0 +1,916 @@
#include "trainlog/web_sessions.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/id.h"
typedef struct ListDefinition {
const char *kind;
const char *sql;
} ListDefinition;
static const ListDefinition LISTS[] = {
{"preparation",
"SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at "
"FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
"WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY p.preparation_id ORDER BY COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"},
{"proposal",
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
"WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM ai_session_draft_entries ae JOIN exercises x ON x.id=ae.exercise_row_id WHERE ae.draft_row_id=d.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 OFFSET ?3"},
{"draft",
"SELECT d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
"FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE f.session_id=d.session_id) "
"AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
"AND (?1='' OR d.session_type LIKE '%'||?1||'%' COLLATE NOCASE) ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,COALESCE(d.started_at,''),d.session_id LIMIT ?2 OFFSET ?3"},
{"history",
"SELECT s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s.started_at "
"FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id "
"WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE he.session_row_id=s.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY s.id ORDER BY s.started_at DESC,s.session_id LIMIT ?2 OFFSET ?3"},
};
static bool add_nullable_text(yyjson_mut_doc *document,
yyjson_mut_val *object,
const char *key,
sqlite3_stmt *statement,
int column) {
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
return yyjson_mut_obj_add_null(document, object, key);
}
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
return false;
}
return yyjson_mut_obj_add_strcpy(
document, object, key, (const char *)sqlite3_column_text(statement, column));
}
static TrainlogStatus write_document(yyjson_mut_doc *document,
char **output_json,
size_t *output_size) {
yyjson_write_err error;
char *json;
json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, output_size, &error);
yyjson_mut_doc_free(document);
if (json == NULL || *output_size == 0U || *output_size > TRAINLOG_WEB_SESSIONS_JSON_MAX) {
free(json);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*output_json = json;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
const TrainlogWebSessionsPageQuery *query,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *document = NULL;
yyjson_mut_val *root;
yyjson_mut_val *items;
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
TrainlogStatus end_status;
size_t count = 0U;
int step;
const ListDefinition *definition;
if (database == NULL || query == NULL || output_json == NULL || output_size == NULL ||
query->collection > TRAINLOG_WEB_SESSION_HISTORY || query->limit == 0U ||
query->limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || query->offset > INT64_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
definition = &LISTS[(size_t)query->collection];
status = trainlog_database_read_snapshot_begin(database);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (sqlite3_prepare_v2(database->connection, definition->sql, -1, &statement, NULL) !=
SQLITE_OK ||
sqlite3_bind_text(statement, 1, query->search == NULL ? "" : query->search, -1,
SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 2, (sqlite3_int64)(query->limit + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
items = document == NULL ? NULL : yyjson_mut_arr(document);
if (document == NULL || root == NULL || items == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_strcpy(document, root, "kind", definition->kind) ||
!yyjson_mut_obj_add_uint(document, root, "offset", query->offset) ||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *item;
sqlite3_int64 occurrence_count;
if (count == query->limit) {
++count;
break;
}
occurrence_count = sqlite3_column_int64(statement, 4);
item = yyjson_mut_obj(document);
if (item == NULL || occurrence_count < 0 ||
!add_nullable_text(document, item, "identity", statement, 0) ||
!add_nullable_text(document, item, "title", statement, 1) ||
!add_nullable_text(document, item, "date", statement, 2) ||
!add_nullable_text(document, item, "state", statement, 3) ||
!yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) ||
!add_nullable_text(document, item, "sort_timestamp", statement, 5) ||
!yyjson_mut_arr_add_val(items, item)) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
++count;
}
if (step != SQLITE_DONE && count <= query->limit) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
if (!yyjson_mut_obj_add_bool(document, root, "more", count > query->limit) ||
!yyjson_mut_obj_add_uint(document, root, "next_offset", query->offset + query->limit)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish;
}
status = TRAINLOG_STATUS_OK;
finish:
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
if (status == TRAINLOG_STATUS_OK) {
status = end_status;
}
if (status != TRAINLOG_STATUS_OK) {
yyjson_mut_doc_free(document);
return status;
}
return write_document(document, output_json, output_size);
}
static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
const char *identity,
bool proposal,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
const char *sql = proposal
? "SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN 'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
: "SELECT p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_state FROM session_preparations p JOIN session_preparation_revisions r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1";
const char *entry_sql = proposal
? "SELECT e.entry_id,e.position,x.exercise_id,x.name,e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,NULL FROM ai_session_draft_entries e JOIN exercises x ON x.id=e.exercise_row_id JOIN ai_session_drafts d ON d.id=e.draft_row_id WHERE d.draft_id=?1 ORDER BY e.position,e.entry_id"
: "SELECT e.entry_id,e.position,e.exercise_id,COALESCE(x.name,e.exercise_id),e.equipment_id,e.recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e.target_weight_kg,e.rest_seconds,e.notes FROM session_preparation_entries e LEFT JOIN exercises x ON x.exercise_id=e.exercise_id JOIN session_preparations p ON p.current_revision_id=e.revision_id WHERE p.preparation_id=?1 ORDER BY e.position,e.entry_id";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *entries;
int step;
int column;
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_NOT_FOUND;
}
for (column = 0; column < 8; ++column) {
static const char *const keys[] = {"identity", "title", "planned_for", "session_type",
"notes", "source_fingerprint", "revision_id", "state"};
if (!add_nullable_text(document, root, keys[column], statement, column)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
entries = yyjson_mut_arr(document);
if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) ||
sqlite3_prepare_v2(database->connection, entry_sql, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name",
"equipment_id", "recording_mode", "tracking_mode",
"load_mode"};
yyjson_mut_val *entry = yyjson_mut_obj(document);
int index;
if (entry == NULL || !yyjson_mut_obj_add_sint(
document, entry, "position", sqlite3_column_int64(statement, 1))) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (index = 0; index < 8; ++index) {
if (text_keys[index] != NULL &&
!add_nullable_text(document, entry, text_keys[index], statement, index)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
for (index = 8; index <= 12; ++index) {
static const char *const number_keys[] = {"target_sets", "target_reps",
"target_duration_seconds", "target_weight_kg",
"rest_seconds"};
if (sqlite3_column_type(statement, index) == SQLITE_NULL) {
(void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]);
} else if (index == 11) {
(void)yyjson_mut_obj_add_real(document, entry, number_keys[index - 8],
sqlite3_column_double(statement, index));
} else {
(void)yyjson_mut_obj_add_sint(document, entry, number_keys[index - 8],
sqlite3_column_int64(statement, index));
}
}
if (!add_nullable_text(document, entry, "notes", statement, 13) ||
!yyjson_mut_arr_add_val(entries, entry)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus detail_for_history(TrainlogDatabase *database,
const char *identity,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char SESSION_SQL[] =
"SELECT session_id,session_type,started_at,ended_at,notes FROM sessions WHERE session_id=?1";
static const char ENTRY_SQL[] =
"SELECT se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se.tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.rest_seconds,se.notes,se.max_weight_kg FROM session_exercises se JOIN exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?1 ORDER BY se.position,se.entry_id";
static const char SET_SQL[] =
"SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 ORDER BY ps.position";
sqlite3_stmt *statement = NULL;
sqlite3_stmt *sets_statement = NULL;
yyjson_mut_val *entries;
int step;
if (sqlite3_prepare_v2(database->connection, SESSION_SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!add_nullable_text(document, root, "identity", statement, 0) ||
!add_nullable_text(document, root, "session_type", statement, 1) ||
!add_nullable_text(document, root, "started_at", statement, 2) ||
!add_nullable_text(document, root, "ended_at", statement, 3) ||
!add_nullable_text(document, root, "notes", statement, 4)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
entries = yyjson_mut_arr(document);
if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) ||
sqlite3_prepare_v2(database->connection, ENTRY_SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *entry = yyjson_mut_obj(document);
yyjson_mut_val *sets = yyjson_mut_arr(document);
const char *entry_id = (const char *)sqlite3_column_text(statement, 0);
int set_step;
int column;
static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name",
"equipment_id", "recording_mode", "tracking_mode",
"load_mode"};
static const char *const numeric_keys[] = {"target_sets", "target_reps",
"target_duration_seconds", "target_weight_kg",
"rest_seconds", "notes", "max_weight_kg"};
if (entry == NULL || sets == NULL ||
!yyjson_mut_obj_add_sint(document, entry, "position", sqlite3_column_int64(statement, 1))) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (column = 0; column < 8; ++column) {
if (text_keys[column] != NULL &&
!add_nullable_text(document, entry, text_keys[column], statement, column)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
for (column = 8; column <= 14; ++column) {
if (column == 13) {
(void)add_nullable_text(document, entry, numeric_keys[column - 8], statement, column);
} else if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
(void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]);
} else if (column == 11 || column == 14) {
(void)yyjson_mut_obj_add_real(document,
entry,
numeric_keys[column - 8],
sqlite3_column_double(statement, column));
} else {
(void)yyjson_mut_obj_add_sint(document,
entry,
numeric_keys[column - 8],
sqlite3_column_int64(statement, column));
}
}
if (!yyjson_mut_obj_add_val(document, entry, "performed_sets", sets) ||
sqlite3_prepare_v2(database->connection, SET_SQL, -1, &sets_statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(sets_statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
(void)sqlite3_finalize(statement);
if (sets_statement != NULL) {
(void)sqlite3_finalize(sets_statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) {
yyjson_mut_val *set = yyjson_mut_obj(document);
if (set == NULL ||
!yyjson_mut_obj_add_sint(document, set, "position", sqlite3_column_int64(sets_statement, 0))) {
(void)sqlite3_finalize(sets_statement);
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (column = 1; column <= 3; ++column) {
static const char *const keys[] = {"reps", "duration_seconds", "weight_kg"};
if (sqlite3_column_type(sets_statement, column) == SQLITE_NULL) {
(void)yyjson_mut_obj_add_null(document, set, keys[column - 1]);
} else if (column == 3) {
(void)yyjson_mut_obj_add_real(document,
set,
keys[column - 1],
sqlite3_column_double(sets_statement, column));
} else {
(void)yyjson_mut_obj_add_sint(document,
set,
keys[column - 1],
sqlite3_column_int64(sets_statement, column));
}
}
(void)yyjson_mut_arr_add_val(sets, set);
}
if (set_step != SQLITE_DONE || sqlite3_finalize(sets_statement) != SQLITE_OK ||
!yyjson_mut_arr_add_val(entries, entry)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
sets_statement = NULL;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus detail_for_draft(TrainlogDatabase *database,
const char *identity,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
sqlite3_stmt *statement = NULL;
yyjson_doc *payload_document = NULL;
yyjson_val *payload;
yyjson_mut_val *copied_payload;
if (sqlite3_prepare_v2(database->connection,
"SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM execution_drafts WHERE session_id=?1",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!add_nullable_text(document, root, "identity", statement, 0) ||
!add_nullable_text(document, root, "session_type", statement, 1) ||
!add_nullable_text(document, root, "started_at", statement, 2) ||
!add_nullable_text(document, root, "state", statement, 3) ||
!add_nullable_text(document, root, "revision_id", statement, 4)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
payload_document = yyjson_read((const char *)sqlite3_column_text(statement, 5),
(size_t)sqlite3_column_bytes(statement, 5),
YYJSON_READ_NOFLAG);
payload = payload_document == NULL ? NULL : yyjson_doc_get_root(payload_document);
copied_payload = payload == NULL ? NULL : yyjson_val_mut_copy(document, payload);
if (copied_payload == NULL ||
!yyjson_mut_obj_add_val(document, root, "payload", copied_payload) ||
sqlite3_finalize(statement) != SQLITE_OK) {
yyjson_doc_free(payload_document);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
yyjson_doc_free(payload_document);
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
const char *kind,
const char *identity,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *document;
yyjson_mut_val *root;
TrainlogStatus status;
TrainlogStatus end_status;
if (database == NULL || kind == NULL || identity == NULL || identity[0] == '\0' ||
output_json == NULL || output_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
if (strcmp(kind, "preparation") != 0 && strcmp(kind, "proposal") != 0 &&
strcmp(kind, "history") != 0 && strcmp(kind, "draft") != 0) {
return TRAINLOG_STATUS_NOT_FOUND;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(document, root, "kind", kind);
status = trainlog_database_read_snapshot_begin(database);
if (status == TRAINLOG_STATUS_OK) {
if (strcmp(kind, "history") == 0) {
status = detail_for_history(database, identity, document, root);
} else if (strcmp(kind, "draft") == 0) {
status = detail_for_draft(database, identity, document, root);
} else {
status = detail_for_revision(
database, identity, strcmp(kind, "proposal") == 0, document, root);
}
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
if (status == TRAINLOG_STATUS_OK) {
status = end_status;
}
if (status != TRAINLOG_STATUS_OK) {
yyjson_mut_doc_free(document);
return status;
}
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database,
const char *search,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size) {
static const char SQL[] =
"SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises "
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' "
"AND c.target_id=exercises.exercise_id AND c.deleted=1) "
"AND (?1='' OR name LIKE '%'||?1||'%' COLLATE NOCASE) "
"ORDER BY name COLLATE NOCASE,exercise_id LIMIT ?2 OFFSET ?3";
sqlite3_stmt *statement = NULL;
yyjson_mut_doc *document;
yyjson_mut_val *root;
yyjson_mut_val *items;
size_t count = 0U;
int step;
if (database == NULL || output_json == NULL || output_size == NULL || limit == 0U ||
limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || offset > INT64_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
items = document == NULL ? NULL : yyjson_mut_arr(document);
if (document == NULL || root == NULL || items == NULL ||
sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, search == NULL ? "" : search, -1, SQLITE_TRANSIENT) !=
SQLITE_OK ||
sqlite3_bind_int64(statement, 2, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)offset) != SQLITE_OK) {
yyjson_mut_doc_free(document);
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
yyjson_mut_doc_set_root(document, root);
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
(void)yyjson_mut_obj_add_val(document, root, "items", items);
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *item;
if (count == limit) {
++count;
break;
}
item = yyjson_mut_obj(document);
if (item == NULL || !add_nullable_text(document, item, "exercise_id", statement, 0) ||
!add_nullable_text(document, item, "name", statement, 1) ||
!add_nullable_text(document, item, "recording_mode", statement, 2) ||
!add_nullable_text(document, item, "tracking_mode", statement, 3) ||
!yyjson_mut_obj_add_sint(
document, item, "data_fields", sqlite3_column_int64(statement, 4)) ||
!yyjson_mut_arr_add_val(items, item)) {
(void)sqlite3_finalize(statement);
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
++count;
}
if ((step != SQLITE_DONE && count <= limit) || sqlite3_finalize(statement) != SQLITE_OK ||
!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return write_document(document, output_json, output_size);
}
static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
time_t now = time(NULL);
struct tm utc;
return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static bool json_object_has_only(yyjson_val *object,
const char *const *allowed,
size_t allowed_count) {
yyjson_obj_iter iterator = yyjson_obj_iter_with(object);
yyjson_val *key;
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
size_t index;
bool found = false;
const char *name = yyjson_get_str(key);
for (index = 0U; index < allowed_count; ++index) {
if (name != NULL && strcmp(name, allowed[index]) == 0) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
static bool bounded_json_string(yyjson_val *value, size_t capacity, const char **output) {
const char *text;
size_t length;
if (!yyjson_is_str(value)) {
return false;
}
text = yyjson_get_str(value);
length = yyjson_get_len(value);
if (text == NULL || length >= capacity || memchr(text, '\0', length) != NULL) {
return false;
}
*output = text;
return true;
}
static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *value) {
if (value == NULL || yyjson_is_null(value)) {
return sqlite3_bind_null(statement, index) == SQLITE_OK;
}
return yyjson_is_str(value) &&
sqlite3_bind_text(statement, index, yyjson_get_str(value),
(int)yyjson_get_len(value), SQLITE_TRANSIENT) == SQLITE_OK;
}
static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
const char *revision_id,
yyjson_val *entry,
size_t position) {
static const char *const ALLOWED[] = {
"entry_id", "exercise_id", "equipment_id", "load_mode", "rest_seconds",
"target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "notes"};
static const char INSERT_SQL[] =
"INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) "
"SELECT ?1,?2,?3,e.exercise_id,?5,e.recording_mode,e.tracking_mode,e.data_fields,?6,?7,?8,?9,?10,?11,?12 FROM exercises e "
"WHERE e.exercise_id=?4 AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1)";
sqlite3_stmt *statement = NULL;
const char *entry_id;
const char *exercise_id;
const char *load_mode;
char generated_id[TRAINLOG_GENERATED_ID_CAPACITY];
yyjson_val *value;
int64_t rest_seconds;
int rc;
if (!yyjson_is_obj(entry) ||
!json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) ||
!bounded_json_string(yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U,
&exercise_id) ||
!bounded_json_string(yyjson_obj_get(entry, "load_mode"), 16U, &load_mode) ||
(strcmp(load_mode, "none") != 0 && strcmp(load_mode, "external") != 0 &&
strcmp(load_mode, "assistance") != 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
value = yyjson_obj_get(entry, "entry_id");
if (value == NULL) {
if (trainlog_id_generate("spe", generated_id, sizeof(generated_id)) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
entry_id = generated_id;
} else if (!bounded_json_string(value, TRAINLOG_ID_MAX + 1U, &entry_id) ||
strncmp(entry_id, "spe_", 4U) != 0) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
value = yyjson_obj_get(entry, "rest_seconds");
if (!yyjson_is_int(value)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
rest_seconds = yyjson_get_sint(value);
if (rest_seconds < 0 || rest_seconds > 86400 || position > INT64_MAX ||
sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)position) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
!bind_nullable_text(statement, 5, yyjson_obj_get(entry, "equipment_id")) ||
sqlite3_bind_text(statement, 6, load_mode, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 7, rest_seconds) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
value = yyjson_obj_get(entry, "target_sets");
if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 8);
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 99 ||
sqlite3_bind_int64(statement, 8, yyjson_get_sint(value)) != SQLITE_OK) {
goto invalid;
}
value = yyjson_obj_get(entry, "target_reps");
if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 9);
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 10000 ||
sqlite3_bind_int64(statement, 9, yyjson_get_sint(value)) != SQLITE_OK) {
goto invalid;
}
value = yyjson_obj_get(entry, "target_duration_seconds");
if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 10);
} else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 604800 ||
sqlite3_bind_int64(statement, 10, yyjson_get_sint(value)) != SQLITE_OK) {
goto invalid;
}
value = yyjson_obj_get(entry, "target_weight_kg");
if (value == NULL || yyjson_is_null(value)) {
(void)sqlite3_bind_null(statement, 11);
} else if (!yyjson_is_num(value) || !isfinite(yyjson_get_real(value)) ||
yyjson_get_real(value) <= 0.0 || yyjson_get_real(value) > 2000.0 ||
sqlite3_bind_double(statement, 11, yyjson_get_real(value)) != SQLITE_OK) {
goto invalid;
}
if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) {
goto invalid;
}
rc = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
return rc == SQLITE_DONE && sqlite3_changes(database->connection) == 1
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
invalid:
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
const char *save_body,
size_t save_body_size,
char **output_json,
size_t *output_size) {
static const char *const ALLOWED[] = {
"title", "session_type", "planned_for", "notes", "editing_state", "occurrences"};
yyjson_doc *input = NULL;
yyjson_val *root;
yyjson_val *occurrences;
sqlite3_stmt *statement = NULL;
char new_preparation[TRAINLOG_GENERATED_ID_CAPACITY];
char revision[TRAINLOG_GENERATED_ID_CAPACITY];
char now[TRAINLOG_TIMESTAMP_MAX + 1U];
const char *actual_preparation = preparation_id;
const char *title;
const char *session_type;
const char *editing_state;
size_t index;
TrainlogStatus status = TRAINLOG_STATUS_OK;
yyjson_mut_doc *response;
yyjson_mut_val *response_root;
if (database == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' ||
save_body == NULL || save_body_size == 0U || output_json == NULL || output_size == NULL ||
!timestamp_now(now)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
input = yyjson_read(save_body, save_body_size, YYJSON_READ_NOFLAG);
root = input == NULL ? NULL : yyjson_doc_get_root(input);
if (!yyjson_is_obj(root) ||
!json_object_has_only(root, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) ||
!bounded_json_string(yyjson_obj_get(root, "title"), TRAINLOG_NAME_MAX + 1U, &title) ||
!bounded_json_string(yyjson_obj_get(root, "session_type"), 16U, &session_type) ||
!bounded_json_string(yyjson_obj_get(root, "editing_state"), 16U, &editing_state) ||
(strcmp(session_type, "training") != 0 && strcmp(session_type, "max_test") != 0) ||
(strcmp(editing_state, "draft") != 0 && strcmp(editing_state, "ready") != 0)) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
occurrences = yyjson_obj_get(root, "occurrences");
if (!yyjson_is_arr(occurrences) || yyjson_arr_size(occurrences) > 64U ||
(strcmp(editing_state, "ready") == 0 && (title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (actual_preparation == NULL || actual_preparation[0] == '\0') {
if (expected_revision[0] != '\0' ||
trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != TRAINLOG_STATUS_OK) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
actual_preparation = new_preparation;
}
if (trainlog_id_generate("spr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK ||
sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
yyjson_doc_free(input);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* INVARIANT: a replayed request returns its committed identity and does
* not evaluate the now-stale expected revision again. */
if (sqlite3_prepare_v2(database->connection,
"SELECT response_json FROM session_preparation_requests WHERE request_id=?1",
-1, &statement, NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
const char *saved = (const char *)sqlite3_column_text(statement, 0);
*output_size = saved == NULL ? 0U : strlen(saved);
*output_json = saved == NULL ? NULL : strdup(saved);
(void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
yyjson_doc_free(input);
return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (preparation_id != NULL && preparation_id[0] != '\0') {
const char *current = NULL;
if (sqlite3_prepare_v2(database->connection,
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_NOT_FOUND;
goto rollback;
}
current = (const char *)sqlite3_column_text(statement, 0);
if (current == NULL || strcmp(current, expected_revision) != 0) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
(void)sqlite3_finalize(statement);
statement = NULL;
} else if (sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
(expected_revision[0] == '\0' ? sqlite3_bind_null(statement, 3)
: sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, title, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, session_type, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
!bind_nullable_text(statement, 6, yyjson_obj_get(root, "planned_for")) ||
!bind_nullable_text(statement, 7, yyjson_obj_get(root, "notes")) ||
sqlite3_bind_text(statement, 8, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
goto rollback;
}
(void)sqlite3_finalize(statement);
statement = NULL;
for (index = 0U; index < yyjson_arr_size(occurrences); ++index) {
status = insert_preparation_entry(
database, revision, yyjson_arr_get(occurrences, index), index);
if (status != TRAINLOG_STATUS_OK) {
goto rollback;
}
}
if (preparation_id != NULL && preparation_id[0] != '\0') {
if (sqlite3_prepare_v2(database->connection,
"UPDATE session_preparations SET current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' END WHERE preparation_id=?4 AND current_revision_id=?5",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
(void)sqlite3_finalize(statement);
statement = NULL;
}
response = yyjson_mut_doc_new(NULL);
response_root = response == NULL ? NULL : yyjson_mut_obj(response);
if (response == NULL || response_root == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto rollback;
}
yyjson_mut_doc_set_root(response, response_root);
(void)yyjson_mut_obj_add_uint(response, response_root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(response, response_root, "preparation_id", actual_preparation);
(void)yyjson_mut_obj_add_strcpy(response, response_root, "revision_id", revision);
(void)yyjson_mut_obj_add_strcpy(response, response_root, "editing_state", editing_state);
status = write_document(response, output_json, output_size);
if (status != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
yyjson_doc_free(input);
return TRAINLOG_STATUS_OK;
rollback:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
free(*output_json);
*output_json = NULL;
*output_size = 0U;
yyjson_doc_free(input);
return status;
}

View file

@ -27,13 +27,13 @@ int main(void)
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==22);
CHECK(scalar(raw,"PRAGMA user_version")==23);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);

View file

@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=23;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=24;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 23 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 24 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -0,0 +1,149 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/web_sessions.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \
} \
} while (0)
static int scalar(TrainlogDatabase *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int result = -1;
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
result = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return result;
}
static bool creation_replay_revision_and_pagination(void) {
static const char CREATE_BODY[] =
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-20\",\"notes\":\"Dos & épaules\","
"\"editing_state\":\"ready\",\"occurrences\":["
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"load_mode\":\"external\",\"rest_seconds\":90,\"target_sets\":3,"
"\"target_reps\":8,\"target_duration_seconds\":null,\"target_weight_kg\":42.5},"
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target_sets\":1,"
"\"target_reps\":12,\"target_duration_seconds\":null,\"target_weight_kg\":null}]}";
static const char UPDATE_BODY[] =
"{\"title\":\"Réordonnée\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}";
TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL;
char *replayed = NULL;
char *detail = NULL;
char *page = NULL;
char *updated = NULL;
size_t created_size = 0U;
size_t replayed_size = 0U;
size_t detail_size = 0U;
size_t page_size = 0U;
size_t updated_size = 0U;
char preparation_id[128];
char revision_id[128];
const char *preparation_start;
const char *revision_start;
const char *end;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Développé test",
"developpe test",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-create-1",
CREATE_BODY,
strlen(CREATE_BODY),
&created,
&created_size) == TRAINLOG_STATUS_OK);
CHECK(created_size > 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_entries") == 2);
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-create-1",
CREATE_BODY,
strlen(CREATE_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(created_size == replayed_size && strcmp(created, replayed) == 0);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
preparation_start = strstr(created, "\"preparation_id\":\"");
revision_start = strstr(created, "\"revision_id\":\"");
CHECK(preparation_start != NULL && revision_start != NULL);
preparation_start += strlen("\"preparation_id\":\"");
end = strchr(preparation_start, '"');
CHECK(end != NULL && (size_t)(end - preparation_start) < sizeof(preparation_id));
(void)memcpy(preparation_id, preparation_start, (size_t)(end - preparation_start));
preparation_id[end - preparation_start] = '\0';
revision_start += strlen("\"revision_id\":\"");
end = strchr(revision_start, '"');
CHECK(end != NULL && (size_t)(end - revision_start) < sizeof(revision_id));
(void)memcpy(revision_id, revision_start, (size_t)(end - revision_start));
revision_id[end - revision_start] = '\0';
CHECK(trainlog_web_sessions_detail_json(
database, "preparation", preparation_id, &detail, &detail_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(detail, "Séance échappée") != NULL);
CHECK(strstr(detail, "Développé test") != NULL);
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, preparation_id) != NULL);
CHECK(trainlog_web_sessions_save_json(database,
preparation_id,
revision_id,
"request-update-1",
UPDATE_BODY,
strlen(UPDATE_BODY),
&updated,
&updated_size) == TRAINLOG_STATUS_OK);
CHECK(updated_size > 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
free(detail);
detail = NULL;
detail_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
preparation_id,
revision_id,
"request-stale",
UPDATE_BODY,
strlen(UPDATE_BODY),
&detail,
&detail_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
free(created);
free(replayed);
free(detail);
free(page);
free(updated);
trainlog_database_close(database);
return true;
}
int main(void) {
return creation_replay_revision_and_pagination() ? 0 : 1;
}

175
web/src/api/sessions.ts Normal file
View file

@ -0,0 +1,175 @@
import { mutationCsrfToken } from './sync'
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
export interface SessionListItem {
identity: string
title: string
date: string | null
state: string
occurrence_count: number
sort_timestamp: string
}
export interface SessionPage {
api_version: 1
kind: string
offset: number
more: boolean
next_offset: number
items: SessionListItem[]
}
export interface PreparationOccurrence {
entry_id?: string
exercise_id: string
exercise_name?: string
equipment_id: string | null
recording_mode?: string
tracking_mode?: string
load_mode: 'none' | 'external' | 'assistance'
rest_seconds: number
target_sets: number | null
target_reps: number | null
target_duration_seconds: number | null
target_weight_kg: number | null
notes: string | null
}
export interface SessionDetail {
api_version: 1
kind: 'preparation' | 'proposal'
identity: string
revision_id: string
title: string
planned_for: string | null
session_type: 'training' | 'max_test'
notes: string | null
source_fingerprint: string | null
state: string
occurrences: PreparationOccurrence[]
}
export interface CatalogChoice {
exercise_id: string
name: string
recording_mode: 'sets' | 'continuous'
tracking_mode: 'reps' | 'duration'
data_fields: number
}
export interface PreparationInput {
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
notes: string | null
editing_state: 'draft' | 'ready'
occurrences: PreparationOccurrence[]
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validListItem(value: unknown): value is SessionListItem {
if (!object(value)) return false
return typeof value.identity === 'string' && typeof value.title === 'string' &&
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
typeof value.sort_timestamp === 'string'
}
export async function fetchSessionPage(
collection: SessionCollection,
offset = 0,
search = '',
signal?: AbortSignal,
): Promise<SessionPage> {
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
if (search.trim()) query.set('search', search.trim())
const response = await fetch(`/api/v1/sessions/${collection}?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`liste HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
!value.items.every(validListItem) || typeof value.more !== 'boolean' ||
!Number.isSafeInteger(value.next_offset)) throw new TypeError('liste de séances invalide')
return value as unknown as SessionPage
}
export async function fetchSessionDetail(
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
): Promise<SessionDetail> {
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.kind !== kind ||
value.identity !== identity || typeof value.revision_id !== 'string' ||
typeof value.title !== 'string' || !Array.isArray(value.occurrences)) {
throw new TypeError('fiche de séance invalide')
}
return value as unknown as SessionDetail
}
export async function fetchReadonlySessionDetail(
kind: 'draft' | 'history', identity: string, signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.kind !== kind || value.identity !== identity) {
throw new TypeError('fiche de séance invalide')
}
return value
}
export async function fetchCatalog(search = '', signal?: AbortSignal): Promise<CatalogChoice[]> {
const query = new URLSearchParams({ limit: '64', search })
const response = await fetch(`/api/v1/sessions/catalog?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`catalogue HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || !Array.isArray(value.items)) throw new TypeError('catalogue invalide')
return value.items as CatalogChoice[]
}
function requestId(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export async function savePreparation(
input: PreparationInput,
identity?: string,
revision?: string,
): Promise<{ preparation_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken()
const response = await fetch(identity
? `/api/v1/sessions/preparation/${encodeURIComponent(identity)}`
: '/api/v1/sessions/preparations', {
method: identity ? 'PUT' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId(),
...(identity && revision ? { 'If-Match': `"${revision}"` } : {}),
},
body: JSON.stringify(input),
})
const value: unknown = await response.json()
if (!response.ok) {
const reason = object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
throw new Error(reason)
}
if (!object(value) || typeof value.preparation_id !== 'string' ||
typeof value.revision_id !== 'string') throw new TypeError('réponse de sauvegarde invalide')
return { preparation_id: value.preparation_id, revision_id: value.revision_id }
}

View file

@ -40,6 +40,12 @@ export interface SyncStatus {
}
let csrfToken = ''
export async function mutationCsrfToken(): Promise<string> {
if (csrfToken.length !== 64) await fetchSyncStatus()
if (csrfToken.length !== 64) throw new Error('jeton CSRF absent')
return csrfToken
}
const phases = new Set<SyncPhase>([
'idle',
'requested',

View file

@ -6,6 +6,7 @@ import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage'
import { PlaceholderPage } from '../routes/PlaceholderPage'
import { SessionsPage } from '../routes/SessionsPage'
import type { RouteId } from './routes'
import { useRoute } from './useRoute'
@ -72,7 +73,7 @@ export function App() {
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
) :
) : route.id === 'sessions' ? <SessionsPage /> :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />

View file

@ -15,5 +15,6 @@ export const routes: readonly AppRoute[] = [
]
export function routeFromPath(pathname: string): AppRoute {
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3]
return routes.find((route) => route.path === pathname) ?? routes[0]
}

View file

@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from 'react'
import {
fetchCatalog,
fetchSessionDetail,
fetchReadonlySessionDetail,
fetchSessionPage,
savePreparation,
type CatalogChoice,
type PreparationInput,
type PreparationOccurrence,
type SessionCollection,
type SessionDetail,
type SessionListItem,
} from '../api/sessions'
type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
}
function parseDecimal(value: string): number | null {
if (!value.trim()) return null
const normalized = value.replace(',', '.')
const number = Number(normalized)
return Number.isFinite(number) ? number : null
}
function emptyInput(): PreparationInput {
return { title: '', session_type: 'training', planned_for: null, notes: null,
editing_state: 'draft', occurrences: [] }
}
function Editor({ initial, onSaved, onCancel }: {
initial?: SessionDetail
onSaved: (identity: string) => void
onCancel: () => void
}) {
const [input, setInput] = useState<PreparationInput>(() => initial ? {
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences,
} : emptyInput())
const [catalog, setCatalog] = useState<CatalogChoice[]>([])
const [catalogSearch, setCatalogSearch] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
const controller = new AbortController()
fetchCatalog(catalogSearch, controller.signal).then(setCatalog).catch(() => setCatalog([]))
return () => controller.abort()
}, [catalogSearch])
const updateOccurrence = (index: number, next: PreparationOccurrence) => {
setInput((current) => ({ ...current,
occurrences: current.occurrences.map((value, position) => position === index ? next : value) }))
}
const add = (choice: CatalogChoice) => setInput((current) => ({ ...current, occurrences: [
...current.occurrences,
{ exercise_id: choice.exercise_id, exercise_name: choice.name, equipment_id: null,
recording_mode: choice.recording_mode, tracking_mode: choice.tracking_mode,
load_mode: 'none', rest_seconds: 0, target_sets: choice.recording_mode === 'sets' ? 1 : null,
target_reps: choice.tracking_mode === 'reps' ? 1 : null,
target_duration_seconds: choice.tracking_mode === 'duration' ? 60 : null,
target_weight_kg: null, notes: null },
] }))
const move = (index: number, delta: number) => setInput((current) => {
const target = index + delta
if (target < 0 || target >= current.occurrences.length) return current
const occurrences = [...current.occurrences]
;[occurrences[index], occurrences[target]] = [occurrences[target], occurrences[index]]
return { ...current, occurrences }
})
const submit = async (ready: boolean) => {
setSaving(true); setError('')
try {
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft' },
initial?.identity, initial?.revision_id)
onSaved(result.preparation_id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible')
} finally { setSaving(false) }
}
return <section className="sessions-editor" aria-labelledby="editor-title">
<div className="sessions-toolbar"><div><p className="eyebrow">PRÉPARATION</p>
<h2 id="editor-title">{initial ? 'Modifier la séance' : 'Nouvelle séance'}</h2></div>
<button type="button" className="quiet-action" onClick={onCancel}>Annuler les modifications</button></div>
{error && <p role="alert" className="form-error">{error}</p>}
<div className="form-grid">
<label>Titre<input value={input.title} maxLength={200}
onChange={(event) => setInput({ ...input, title: event.target.value })} /></label>
<label>Type<select value={input.session_type}
onChange={(event) => setInput({ ...input, session_type: event.target.value as 'training' | 'max_test' })}>
<option value="training">Entraînement</option><option value="max_test">Test MAX</option></select></label>
<label>Date planifiée<input type="date" value={input.planned_for ?? ''}
onChange={(event) => setInput({ ...input, planned_for: event.target.value || null })} /></label>
<label className="wide-field">Note<textarea value={input.notes ?? ''} maxLength={4000}
onChange={(event) => setInput({ ...input, notes: event.target.value || null })} /></label>
</div>
<div className="catalog-picker"><label>Rechercher un exercice<input value={catalogSearch}
onChange={(event) => setCatalogSearch(event.target.value)} /></label>
<div className="catalog-results">{catalog.map((choice) => <button type="button"
key={choice.exercise_id} onClick={() => add(choice)}>+ {choice.name}</button>)}</div></div>
<ol className="occurrence-editor-list">{input.occurrences.map((occurrence, index) =>
<li key={occurrence.entry_id ?? `${occurrence.exercise_id}-${index}`}>
<div className="occurrence-heading"><span className="drag-handle" aria-hidden="true">⋮⋮</span>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span className="occurrence-actions"><button type="button" disabled={index === 0}
aria-label="Monter" onClick={() => move(index, -1)}>↑</button>
<button type="button" disabled={index + 1 === input.occurrences.length}
aria-label="Descendre" onClick={() => move(index, 1)}>↓</button>
<button type="button" onClick={() => setInput({ ...input,
occurrences: input.occurrences.filter((_, position) => position !== index) })}>Retirer</button></span></div>
<div className="occurrence-fields"><label>Charge<select value={occurrence.load_mode}
onChange={(event) => updateOccurrence(index, { ...occurrence,
load_mode: event.target.value as PreparationOccurrence['load_mode'],
target_weight_kg: event.target.value === 'none' ? null : occurrence.target_weight_kg })}>
<option value="none">Aucune</option><option value="external">Externe</option>
<option value="assistance">Assistance</option></select></label>
{occurrence.recording_mode === 'sets' && <label>Séries<input inputMode="numeric"
value={occurrence.target_sets ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_sets: parseDecimal(event.target.value) })} /></label>}
{occurrence.tracking_mode === 'reps' ? <label>Répétitions<input inputMode="numeric"
value={occurrence.target_reps ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_reps: parseDecimal(event.target.value) })} /></label> :
<label>Durée (s)<input inputMode="numeric" value={occurrence.target_duration_seconds ?? ''}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_duration_seconds: parseDecimal(event.target.value) })} /></label>}
{occurrence.load_mode !== 'none' && <label>Charge (kg)<input inputMode="decimal"
value={occurrence.target_weight_kg ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_weight_kg: parseDecimal(event.target.value) })} /></label>}
<label>Repos (s)<input inputMode="numeric" value={occurrence.rest_seconds}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, rest_seconds: parseDecimal(event.target.value) ?? 0 })} /></label></div>
</li>)}</ol>
{input.occurrences.length === 0 && <p className="empty-inline">Ajoutez des exercices pour rendre la préparation exécutable.</p>}
<div className="editor-actions"><button type="button" disabled={saving}
onClick={() => void submit(false)}>Enregistrer</button>
<button type="button" className="primary-action" disabled={saving || !input.title || input.occurrences.length === 0}
onClick={() => void submit(true)}>Marquer prête / Préparer pour Android</button></div>
</section>
}
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
const [detail, setDetail] = useState<SessionDetail | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
{kind === 'preparation' && <button type="button" className="primary-action" onClick={() => onEdit(detail)}>Modifier</button>}</div>
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}</p></header>
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span>Prévu : {occurrence.target_sets ?? '—'} séries · {occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—'}
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
</li>)}</ol></section>
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3>
<code>{detail.source_fingerprint}</code></section>}
</article>
}
function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
const payload = typeof detail.payload === 'object' && detail.payload !== null
? detail.payload as Record<string, unknown> : null
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history'
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue'
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header>
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
<strong>{String(occurrence.exercise_name ?? occurrence.exercise_id)}</strong>
<span>Objectifs : {String(occurrence.target_sets ?? '—')} séries · {String(occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—')}</span>
<span>Réalisé : {Array.isArray(occurrence.performed_sets) && occurrence.performed_sets.length > 0
? occurrence.performed_sets.map((set) => { const values = set as Record<string, unknown>
return `${String(values.reps ?? values.duration_seconds ?? '—')}${values.weight_kg ? ` × ${String(values.weight_kg)} kg` : ''}` }).join(' · ')
: occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'}</span>
</li>)}</ol></section>}
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
</article>
}
export function SessionsPage() {
const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
const [search, setSearch] = useState('')
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchSessionPage(collection, 0, search, controller.signal)
.then((page) => page.items.map((item) => ({ ...item, collection })))))
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
window.addEventListener('popstate', pop); return () => window.removeEventListener('popstate', pop) }, [])
const open = (collection: SessionCollection, identity: string) => {
const kind = collection === 'preparations' ? 'preparation' : collection === 'proposals' ? 'proposal'
: collection === 'drafts' ? 'draft' : 'history'
const path = `/seances/${kind}/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
return <section className="page"><Detail kind={detailMatch[1]}
identity={decodeURIComponent(detailMatch[2])} onBack={back} onEdit={setEditor} /></section>
}
if (detailMatch) return <section className="page"><ReadonlyDetail
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
onBack={back} /></section>
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
{(['preparation', 'resume', 'history'] as const).map((candidate) => <button type="button" role="tab"
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && items.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{items.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div>
</section>
}

View file

@ -58,6 +58,46 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
.quiet-action { border-color: var(--surface-2); }
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
.session-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
.session-row:hover { border-color: var(--lavender); background: var(--base); }
.session-row span:first-child { display: grid; gap: .2rem; }
.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
.form-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; }
.form-grid label, .catalog-picker label, .occurrence-fields label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.wide-field { grid-column: 1 / -1; }
.form-grid textarea { min-height: 7rem; resize: vertical; }
.catalog-picker { margin: 1.5rem 0; }
.catalog-results { margin-top: .6rem; display: flex; gap: .45rem; flex-wrap: wrap; }
.catalog-results button, .occurrence-actions button { padding: .45rem .65rem; border: 1px solid var(--surface-2); border-radius: .45rem; color: var(--text); background: var(--base); cursor: pointer; }
.occurrence-editor-list, .detail-occurrences { padding: 0; display: grid; gap: .75rem; list-style: none; }
.occurrence-editor-list > li, .detail-occurrences > li { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
.occurrence-heading { display: flex; align-items: center; gap: .65rem; }
.drag-handle { color: var(--overlay-1); }
.occurrence-actions { margin-left: auto; display: flex; gap: .35rem; }
.occurrence-fields { margin-top: 1rem; display: grid; grid-template-columns: repeat(5, minmax(7rem, 1fr)); gap: .75rem; }
.editor-actions { margin-top: 1.5rem; display: flex; justify-content: flex-end; gap: .75rem; }
.editor-actions button { border-color: var(--surface-2); }
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
@media (max-width: 52rem) { .session-row { grid-template-columns: 1fr 1fr; } .form-grid { grid-template-columns: 1fr 1fr; } .occurrence-fields { grid-template-columns: 1fr 1fr; } }
@media (max-width: 30rem) { .session-row, .form-grid, .occurrence-fields { grid-template-columns: 1fr; } .session-tabs { width: 100%; overflow-x: auto; } .session-tabs button { white-space: nowrap; } .editor-actions { align-items: stretch; flex-direction: column; } }
.dashboard-layout { min-width: 0; }
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 var(--font-size-xs) var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }