From 2565d0eafa869db68e1b84b13360ab3ca4dee937 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 21 Sep 2026 06:23:58 +0200 Subject: [PATCH] feat(core): add sleep diary domain --- tools/causal_delete_exchange.py | 4 +- tools/execution_draft_exchange.py | 4 +- tools/export_ai_history.py | 4 +- tools/export_ai_session_drafts.py | 4 +- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_aliases.py | 4 +- tools/export_exercise_body_zones.py | 2 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 4 +- tools/export_programs.py | 4 +- tools/export_session_preparations.py | 4 +- tools/export_training_feedback.py | 4 +- tools/import_ai_session_draft.py | 4 +- tools/import_equipment_associations.py | 2 +- tools/import_equipment_definitions.py | 2 +- tools/import_exercise_aliases.py | 2 +- tools/import_exercise_body_zones.py | 4 +- tools/import_exercise_profile_state.py | 4 +- tools/import_mobile_export.py | 8 +- tools/import_training_feedback.py | 4 +- tools/program_execution_exchange.py | 4 +- tui/include/trainlog/database.h | 2 +- tui/include/trainlog/id.h | 1 + tui/include/trainlog/sleep_diary.h | 100 +++++ tui/meson.build | 27 ++ tui/src/database.c | 42 +- tui/src/id.c | 3 +- tui/src/sleep_diary.c | 586 +++++++++++++++++++++++++ tui/tests/test_database.c | 318 ++++---------- tui/tests/test_schema_v26_migration.c | 4 +- tui/tests/test_schema_v28_migration.c | 2 +- tui/tests/test_schema_v29_migration.c | 73 +++ tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_sleep_diary.c | 125 ++++++ tui/tests/test_web_programs.c | 2 +- 38 files changed, 1099 insertions(+), 280 deletions(-) create mode 100644 tui/include/trainlog/sleep_diary.h create mode 100644 tui/src/sleep_diary.c create mode 100644 tui/tests/test_schema_v29_migration.c create mode 100644 tui/tests/test_sleep_diary.c diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 7e7a543..7a0c097 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -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, 23, 24, 25, 26, 27, 28): - raise CausalError(f"desktop schema v20-v28 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + raise CausalError(f"desktop schema v20-v29 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 04b3f43..402ae71 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -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, 23, 24, 25, 26, 27, 28): - raise LifecycleError(f"desktop schema v19-v28 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + raise LifecycleError(f"desktop schema v19-v29 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 1f9032b..5deaaa6 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -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, 23, 24, 25, 26, 27, 28) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v28 requis") + raise ValueError("schema desktop v15-v29 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index d125488..d163619 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v28 requis") + raise ValueError("schema desktop v18-v29 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 33ca994..2d9de78 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 29)) + supported_versions = tuple(range(8, 30)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v28 requis") + raise ValueError("schema desktop v8 à v29 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 65c71eb..13f8dc1 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 29)) + supported_versions = tuple(range(8, 30)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v28 requis") + raise ValueError("schema desktop v8 à v29 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") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index a92df91..0253d48 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): 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, 23, 24, 25, 26, 27, 28): - raise ValueError("schema desktop v12-v28 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + raise ValueError("schema desktop v12-v29 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index e2e98c6..c1bb3c1 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 29)) + supported_versions = tuple(range(11, 30)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: 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(): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index 612468d..22cddcf 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -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, 23, 24, 25, 26, 27, 28): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): 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, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 2f31a5e..fca91a3 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -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 range(8, 29): + if version not in range(8, 30): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 5f18f83..bcebc83 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -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, 23, 24, 25, 26, 27, 28): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 29) + supported_versions = range(11, 30) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") diff --git a/tools/export_programs.py b/tools/export_programs.py index cdb34f4..f3312a0 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,8 +16,8 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28): - raise ValueError("desktop schema v26, v27 or v28 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29): + raise ValueError("desktop schema v26, v27, v28 or v29 required") programs = [] for row in db.execute( "SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at," diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 1cfae2a..fe847d3 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28) + supported_versions = (24, 25, 26, 27, 28, 29) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("desktop schema v24, v25, v26, v27 or v28 required") + raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required") deliveries = [] rows = connection.execute( "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 640bfd4..fa171a8 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ 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: - supported_versions = range(15, 29) + supported_versions = range(15, 30) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v28 requis") + raise ValueError("schema desktop v15-v29 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() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 41bc286..807cd70 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ 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. """ - supported_versions = range(18, 29) + supported_versions = range(18, 30) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v28 requis") + raise ImportFailure("schema desktop v18-v29 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") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index eabe941..582f709 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,7 +206,7 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 29) + supported_versions = range(8, 30) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: fail("schema desktop v8 à v21 requis") proof_path = args.mobile_export diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 4ae102c..c1cd7a2 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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.""" - supported_versions = range(8, 29) + supported_versions = range(8, 30) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: 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(): diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 0e43f05..87a4c4c 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,7 +56,7 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 29) + supported_versions = range(12, 30) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v12-v21 requis") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 6a014be..dcb2d44 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ 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.""" - supported_versions = range(11, 29) + supported_versions = range(11, 30) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v28 requis") + raise ImportFailure("schema desktop v11-v29 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 diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index a15d670..bae7d7c 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ 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, 23, 24, 25, 26, 27, 28): - fail("schema desktop v17-v28 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + fail("schema desktop v17-v29 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 4a86b58..ea51a3a 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,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 range(5, 29): + if version not in range(5, 30): raise ImportFailure( - f"base desktop schema v5 à v28 attendue, version trouvée: {version}" + f"base desktop schema v5 à v29 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en 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, 23, 24, 25, 26, 27, 28): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29") 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")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 61f351a..943ee9a 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ 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") - supported_versions = range(15, 29) + supported_versions = range(15, 30) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v28 requis") + fail("schema desktop v15-v29 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"]): diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index 293cc3f..d5a52ca 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,8 +87,8 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28): - fail("desktop schema v27 or v28 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29): + fail("desktop schema v27, v28 or v29 required") added = 0 advanced = 0 skipped = 0 diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 074883c..f1285ec 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 28 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 29 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h index 0fa0668..8709285 100644 --- a/tui/include/trainlog/id.h +++ b/tui/include/trainlog/id.h @@ -44,6 +44,7 @@ * occurrence, delivery, and withdrawal identities; * - `pgr` for desktop Program V1 revision identities; * - `del` for immutable causal deletion operations. + * - `sl`, `slr`, and `sle` for sleep diary entries, revisions, and events. * * Imported v1 documents may contain other schema-valid opaque identifiers; * this API defines creation policy, not import validation. diff --git a/tui/include/trainlog/sleep_diary.h b/tui/include/trainlog/sleep_diary.h new file mode 100644 index 0000000..62ccf70 --- /dev/null +++ b/tui/include/trainlog/sleep_diary.h @@ -0,0 +1,100 @@ +/* Trainlog Sleep Diary V1 domain API. */ +#ifndef TRAINLOG_SLEEP_DIARY_H +#define TRAINLOG_SLEEP_DIARY_H + +#include +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +#define TRAINLOG_SLEEP_EVENTS_MAX 64U +#define TRAINLOG_SLEEP_NOTES_MAX 16384U +#define TRAINLOG_SLEEP_ENTRY_ID_CAPACITY 40U +#define TRAINLOG_SLEEP_REVISION_ID_CAPACITY 41U +#define TRAINLOG_SLEEP_EVENT_ID_CAPACITY 41U +#define TRAINLOG_SLEEP_TIMESTAMP_CAPACITY 64U + +typedef enum TrainlogSleepQuality { + TRAINLOG_SLEEP_QUALITY_UNSET = 0, + TRAINLOG_SLEEP_QUALITY_TB, + TRAINLOG_SLEEP_QUALITY_B, + TRAINLOG_SLEEP_QUALITY_MOY, + TRAINLOG_SLEEP_QUALITY_M, + TRAINLOG_SLEEP_QUALITY_TM +} TrainlogSleepQuality; + +typedef enum TrainlogSleepEventType { + TRAINLOG_SLEEP_EVENT_BED_TIME = 0, + TRAINLOG_SLEEP_EVENT_FINAL_GET_UP, + TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP, + TRAINLOG_SLEEP_EVENT_SLEEP, + TRAINLOG_SLEEP_EVENT_NAP, + TRAINLOG_SLEEP_EVENT_LONG_AWAKE, + TRAINLOG_SLEEP_EVENT_HALF_SLEEP, + TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS +} TrainlogSleepEventType; + +typedef struct TrainlogSleepEvent { + char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY]; + TrainlogSleepEventType type; + char start_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY]; + char end_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY]; +} TrainlogSleepEvent; + +typedef struct TrainlogSleepDiaryEntry { + char entry_id[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY]; + char night_start_date[11]; + char night_end_date[11]; + char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY]; + char updated_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY]; + char revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + char parent_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + TrainlogSleepQuality sleep_quality; + TrainlogSleepQuality wake_quality; + TrainlogSleepQuality day_form; + char treatment_and_notes[TRAINLOG_SLEEP_NOTES_MAX + 1U]; + TrainlogSleepEvent events[TRAINLOG_SLEEP_EVENTS_MAX]; + size_t event_count; + bool deleted; +} TrainlogSleepDiaryEntry; + +typedef TrainlogStatus (*TrainlogSleepDiaryVisitor)(void *context, + const TrainlogSleepDiaryEntry *entry); + +/* CONTRACT: timestamps are ISO-8601 instants carrying Z or a numeric offset. + * Intervals are ordered by absolute time, never lexically or by HH:MM. */ +bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry); + +/* Creates one stable identity and initial immutable revision transactionally. */ +TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database, + TrainlogSleepDiaryEntry *entry); + +/* Replaces the complete current snapshot iff expected_revision is current. + * Exact replay of an already-current resulting revision is idempotent. */ +TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database, + const char *expected_revision, + TrainlogSleepDiaryEntry *entry); + +TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database, + const char *entry_id, + bool include_deleted, + TrainlogSleepDiaryEntry *output); + +/* Dates are inclusive YYYY-MM-DD bounds; NULL means unbounded. */ +TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database, + const char *start_date, + const char *end_date, + size_t limit, + TrainlogSleepDiaryVisitor visitor, + void *context); + +/* Logical deletion is revision guarded and retained for sync replay safety. */ +TrainlogStatus +trainlog_sleep_diary_delete(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *deleted_at, + char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 80efe89..1da1d54 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -48,6 +48,7 @@ if web_assets_enabled '../web/src/api/health.ts', '../web/src/api/dashboard.ts', '../web/src/api/analysis.ts', + '../web/src/api/sleepDiary.ts', '../web/src/api/analysis.test.ts', '../web/src/analysis/messages.ts', '../web/src/api/dashboardLayout.ts', @@ -77,6 +78,9 @@ if web_assets_enabled '../web/src/presentation/dateFormat.ts', '../web/src/routes/DashboardPage.tsx', '../web/src/routes/AnalysisPage.tsx', + '../web/src/routes/SleepDiaryWorkspace.tsx', + '../web/src/report/sleepDiaryPdf.ts', + '../web/src/report/sleepDiaryPdf.test.ts', '../web/src/routes/AnalysisPage.test.tsx', '../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/SessionsPage.tsx', @@ -219,6 +223,7 @@ trainlog_core_sources = files( 'src/web_sessions.c', 'src/web_session_deletions.c', 'src/web_programs.c', + 'src/web_sleep.c', 'src/web_exercises.c', 'src/duration.c', 'src/id.c', @@ -229,6 +234,7 @@ trainlog_core_sources = files( 'src/generation_mtp.c', 'src/reps.c', 'src/session_generation.c', + 'src/sleep_diary.c', 'src/statistics.c', 'src/measured_max.c', 'src/sync.c', @@ -801,6 +807,22 @@ test_schema_v28_migration = executable( test('schema_v28_migration', test_schema_v28_migration) +test_sleep_diary = executable( + 'test_sleep_diary', + 'tests/test_sleep_diary.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('sleep_diary', test_sleep_diary) + +test_schema_v29_migration = executable( + 'test_schema_v29_migration', + 'tests/test_schema_v29_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v29_migration', test_schema_v29_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -875,6 +897,11 @@ test( args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'], timeout: 120, ) +test( + 'sleep_diary_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'], +) test( 'session_preparation_export', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index 60768f9..1503804 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1274,6 +1274,41 @@ static const char *const MIGRATE_V27_TO_V28_SQL = "ON program_session_executions(program_id,state);" "PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;"; +/* WHY: sleep observations need minute-precise, timezone-aware facts that can + * later be correlated with physiological samples; a rendered 18:00 timeline + * is not a persistence model. CONTRACT: one stable diary identity owns a + * bounded causal revision chain and zero or more typed points/intervals. + * INVARIANT: wall-clock timestamps never order concurrent peer revisions and + * logical deletion retains the last revision for causal replay protection. */ +static const char *const MIGRATE_V28_TO_V29_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS sleep_diary_entries(" + "entry_id TEXT PRIMARY KEY,night_start_date TEXT NOT NULL,night_end_date TEXT NOT NULL," + "created_at TEXT NOT NULL,updated_at TEXT NOT NULL,current_revision_id TEXT NOT NULL," + "deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1))," + "CHECK(entry_id GLOB 'sl_*'),CHECK(night_start_dateconnection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/src/id.c b/tui/src/id.c index b657005..3599c6a 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -20,7 +20,8 @@ static bool prefix_is_supported(const char *prefix) { return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 || strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 || strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 || - strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0; + strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 || + strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0; } TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) { diff --git a/tui/src/sleep_diary.c b/tui/src/sleep_diary.c new file mode 100644 index 0000000..5e478b4 --- /dev/null +++ b/tui/src/sleep_diary.c @@ -0,0 +1,586 @@ +#include "trainlog/sleep_diary.h" + +#include +#include + +#include + +#include "database_internal.h" +#include "timestamp.h" +#include "trainlog/id.h" + +static bool date_valid(const char *value) { + TrainlogTimestampKey key; + char timestamp[32]; + int written; + if (value == NULL || strlen(value) != 10U) { + return false; + } + written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", value); + return written == 20 && trainlog_timestamp_parse(timestamp, 20U, &key); +} + +static bool id_valid(const char *value, const char *prefix, size_t capacity) { + size_t prefix_size = strlen(prefix); + return value != NULL && strlen(value) < capacity && strncmp(value, prefix, prefix_size) == 0 && + value[prefix_size] == '_' && + strlen(value + prefix_size + 1U) == TRAINLOG_UUID_TEXT_LENGTH; +} + +static const char *quality_text(TrainlogSleepQuality value) { + static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"}; + return value >= TRAINLOG_SLEEP_QUALITY_UNSET && value <= TRAINLOG_SLEEP_QUALITY_TM + ? values[value] + : NULL; +} + +static bool quality_parse(const unsigned char *value, TrainlogSleepQuality *output) { + size_t index; + if (value == NULL) { + *output = TRAINLOG_SLEEP_QUALITY_UNSET; + return true; + } + for (index = 1U; index <= (size_t)TRAINLOG_SLEEP_QUALITY_TM; ++index) { + if (strcmp((const char *)value, quality_text((TrainlogSleepQuality)index)) == 0) { + *output = (TrainlogSleepQuality)index; + return true; + } + } + return false; +} + +static const char *event_text(TrainlogSleepEventType type) { + static const char *const values[] = {"bed_time", + "final_get_up", + "night_get_up", + "sleep", + "nap", + "long_awake", + "half_sleep", + "daytime_sleepiness"}; + return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS + ? values[type] + : NULL; +} + +static bool event_parse(const unsigned char *value, TrainlogSleepEventType *output) { + int index; + if (value == NULL) { + return false; + } + for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) { + if (strcmp((const char *)value, event_text((TrainlogSleepEventType)index)) == 0) { + *output = (TrainlogSleepEventType)index; + return true; + } + } + return false; +} + +static bool point_type(TrainlogSleepEventType type) { + return type == TRAINLOG_SLEEP_EVENT_BED_TIME || type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP || + type == TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP || + type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; +} + +static bool timestamp_valid(const char *value, TrainlogTimestampKey *key) { + size_t length = value == NULL ? 0U : strlen(value); + return length > 0U && length < TRAINLOG_SLEEP_TIMESTAMP_CAPACITY && + trainlog_timestamp_parse(value, length, key); +} + +bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) { + size_t index, other; + TrainlogTimestampKey created, updated; + if (entry == NULL || !date_valid(entry->night_start_date) || + !date_valid(entry->night_end_date) || + strcmp(entry->night_start_date, entry->night_end_date) >= 0 || + !timestamp_valid(entry->created_at, &created) || + !timestamp_valid(entry->updated_at, &updated) || + trainlog_timestamp_compare(&updated, &created) < 0 || + entry->sleep_quality > TRAINLOG_SLEEP_QUALITY_TM || + entry->wake_quality > TRAINLOG_SLEEP_QUALITY_TM || + entry->day_form > TRAINLOG_SLEEP_QUALITY_TM || + strlen(entry->treatment_and_notes) > TRAINLOG_SLEEP_NOTES_MAX || + entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX) { + return false; + } + for (index = 0U; index < entry->event_count; ++index) { + const TrainlogSleepEvent *event = &entry->events[index]; + TrainlogTimestampKey start, end; + bool point = point_type(event->type); + if (!id_valid(event->event_id, "sle", sizeof(event->event_id)) || + event_text(event->type) == NULL || !timestamp_valid(event->start_at, &start) || + (point ? event->end_at[0] != '\0' + : !timestamp_valid(event->end_at, &end) || + trainlog_timestamp_compare(&end, &start) <= 0)) { + return false; + } + for (other = index + 1U; other < entry->event_count; ++other) { + if (strcmp(event->event_id, entry->events[other].event_id) == 0) { + return false; + } + } + } + return true; +} + +static void rollback(sqlite3 *database) { + (void)sqlite3_exec(database, "ROLLBACK", NULL, NULL, NULL); +} + +static TrainlogStatus insert_revision(TrainlogDatabase *database, + const TrainlogSleepDiaryEntry *entry) { + static const char revision_sql[] = + "INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at," + "sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)"; + static const char event_sql[] = + "INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) " + "VALUES(?1,?2,?3,?4,?5)"; + sqlite3_stmt *statement = NULL; + size_t index; + int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry->entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = + entry->parent_revision_id[0] == '\0' + ? sqlite3_bind_null(statement, 3) + : sqlite3_bind_text(statement, 3, entry->parent_revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 4, entry->updated_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = entry->sleep_quality == TRAINLOG_SLEEP_QUALITY_UNSET + ? sqlite3_bind_null(statement, 5) + : sqlite3_bind_text( + statement, 5, quality_text(entry->sleep_quality), -1, SQLITE_STATIC); + } + if (result == SQLITE_OK) { + result = entry->wake_quality == TRAINLOG_SLEEP_QUALITY_UNSET + ? sqlite3_bind_null(statement, 6) + : sqlite3_bind_text( + statement, 6, quality_text(entry->wake_quality), -1, SQLITE_STATIC); + } + if (result == SQLITE_OK) { + result = + entry->day_form == TRAINLOG_SLEEP_QUALITY_UNSET + ? sqlite3_bind_null(statement, 7) + : sqlite3_bind_text(statement, 7, quality_text(entry->day_form), -1, SQLITE_STATIC); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 8, entry->treatment_and_notes, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + if (result != SQLITE_OK) { + return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT + : TRAINLOG_STATUS_DATABASE_ERROR; + } + for (index = 0U; index < entry->event_count; ++index) { + const TrainlogSleepEvent *event = &entry->events[index]; + statement = NULL; + result = sqlite3_prepare_v2(database->connection, event_sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, event->event_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, event_text(event->type), -1, SQLITE_STATIC); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 4, event->start_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = point_type(event->type) + ? sqlite3_bind_null(statement, 5) + : sqlite3_bind_text(statement, 5, event->end_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + if (result != SQLITE_OK) { + return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT + : TRAINLOG_STATUS_DATABASE_ERROR; + } + } + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database, + TrainlogSleepDiaryEntry *entry) { + static const char sql[] = "INSERT INTO sleep_diary_entries VALUES(?1,?2,?3,?4,?5,?6,0)"; + sqlite3_stmt *statement = NULL; + TrainlogStatus status; + int result; + if (database == NULL || entry == NULL || entry->deleted || entry->entry_id[0] != '\0' || + entry->revision_id[0] != '\0') { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (trainlog_id_generate("sl", entry->entry_id, sizeof(entry->entry_id)) != + TRAINLOG_STATUS_OK || + trainlog_id_generate("slr", entry->revision_id, sizeof(entry->revision_id)) != + TRAINLOG_STATUS_OK || + !trainlog_sleep_diary_validate(entry)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry->entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry->night_start_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, entry->night_end_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 4, entry->created_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 5, entry->updated_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 6, entry->revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + status = result == SQLITE_OK ? insert_revision(database, entry) + : (result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT + : TRAINLOG_STATUS_DATABASE_ERROR); + if (status == TRAINLOG_STATUS_OK && + sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { + return status; + } + rollback(database->connection); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status; +} + +TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database, + const char *expected_revision, + TrainlogSleepDiaryEntry *entry) { + static const char sql[] = + "UPDATE sleep_diary_entries SET " + "night_start_date=?1,night_end_date=?2,updated_at=?3,current_revision_id=?4 WHERE " + "entry_id=?5 AND current_revision_id=?6 AND deleted=0"; + sqlite3_stmt *statement = NULL; + TrainlogStatus status; + char next[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + int result; + if (database == NULL || entry == NULL || + !id_valid(entry->entry_id, "sl", sizeof(entry->entry_id)) || + !id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) || + entry->deleted) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (strcmp(entry->revision_id, expected_revision) != 0 || + trainlog_id_generate("slr", next, sizeof(next)) != TRAINLOG_STATUS_OK) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + (void)snprintf( + entry->parent_revision_id, sizeof(entry->parent_revision_id), "%s", expected_revision); + (void)snprintf(entry->revision_id, sizeof(entry->revision_id), "%s", next); + if (!trainlog_sleep_diary_validate(entry)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + status = insert_revision(database, entry); + if (status == TRAINLOG_STATUS_OK) { + result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry->night_start_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry->night_end_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, entry->updated_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 4, entry->revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 5, entry->entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 6, expected_revision, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR + : sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_CONFLICT; + } + if (status == TRAINLOG_STATUS_OK && + sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { + return status; + } + rollback(database->connection); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status; +} + +static TrainlogStatus load_entry(TrainlogDatabase *database, + const char *entry_id, + bool include_deleted, + TrainlogSleepDiaryEntry *output) { + static const char root_sql[] = + "SELECT " + "e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_" + "revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r." + "treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON " + "r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)"; + static const char events_sql[] = + "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 " + "ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY"; + sqlite3_stmt *statement = NULL; + int result; + memset(output, 0, sizeof(*output)); + result = sqlite3_prepare_v2(database->connection, root_sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_int(statement, 2, include_deleted ? 1 : 0); + } + if (result != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { + if (statement != NULL) { + (void)sqlite3_finalize(statement); + } + return result == SQLITE_OK ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR; + } +#define COPY_COLUMN(field, column) \ + (void)snprintf(output->field, \ + sizeof(output->field), \ + "%s", \ + sqlite3_column_text(statement, column) == NULL \ + ? "" \ + : (const char *)sqlite3_column_text(statement, column)) + COPY_COLUMN(entry_id, 0); + COPY_COLUMN(night_start_date, 1); + COPY_COLUMN(night_end_date, 2); + COPY_COLUMN(created_at, 3); + COPY_COLUMN(updated_at, 4); + COPY_COLUMN(revision_id, 5); + output->deleted = sqlite3_column_int(statement, 6) != 0; + COPY_COLUMN(parent_revision_id, 7); +#undef COPY_COLUMN + if (!quality_parse(sqlite3_column_text(statement, 8), &output->sleep_quality) || + !quality_parse(sqlite3_column_text(statement, 9), &output->wake_quality) || + !quality_parse(sqlite3_column_text(statement, 10), &output->day_form)) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + (void)snprintf(output->treatment_and_notes, + sizeof(output->treatment_and_notes), + "%s", + sqlite3_column_text(statement, 11) == NULL + ? "" + : (const char *)sqlite3_column_text(statement, 11)); + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + result = sqlite3_prepare_v2(database->connection, events_sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, output->revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement); + } + while (result == SQLITE_ROW) { + TrainlogSleepEvent *event; + if (output->event_count == TRAINLOG_SLEEP_EVENTS_MAX) { + result = SQLITE_TOOBIG; + break; + } + event = &output->events[output->event_count++]; + (void)snprintf( + event->event_id, sizeof(event->event_id), "%s", sqlite3_column_text(statement, 0)); + if (!event_parse(sqlite3_column_text(statement, 1), &event->type)) { + result = SQLITE_MISMATCH; + break; + } + (void)snprintf( + event->start_at, sizeof(event->start_at), "%s", sqlite3_column_text(statement, 2)); + (void)snprintf(event->end_at, + sizeof(event->end_at), + "%s", + sqlite3_column_text(statement, 3) == NULL + ? "" + : (const char *)sqlite3_column_text(statement, 3)); + result = sqlite3_step(statement); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) { + result = SQLITE_ERROR; + } + return result == SQLITE_DONE && trainlog_sleep_diary_validate(output) + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database, + const char *entry_id, + bool include_deleted, + TrainlogSleepDiaryEntry *output) { + if (database == NULL || output == NULL || + !id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + return load_entry(database, entry_id, include_deleted, output); +} + +TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database, + const char *start_date, + const char *end_date, + size_t limit, + TrainlogSleepDiaryVisitor visitor, + void *context) { + static const char sql[] = + "SELECT entry_id FROM sleep_diary_entries WHERE deleted=0 AND (?1 IS NULL OR " + "night_start_date>=?1) AND (?2 IS NULL OR night_start_date<=?2) ORDER BY night_start_date " + "DESC,entry_id COLLATE BINARY LIMIT ?3"; + sqlite3_stmt *statement = NULL; + int result; + if (database == NULL || visitor == NULL || limit == 0U || limit > 3660U || + (start_date != NULL && !date_valid(start_date)) || + (end_date != NULL && !date_valid(end_date)) || + (start_date != NULL && end_date != NULL && strcmp(start_date, end_date) > 0)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = start_date == NULL + ? sqlite3_bind_null(statement, 1) + : sqlite3_bind_text(statement, 1, start_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = end_date == NULL ? sqlite3_bind_null(statement, 2) + : sqlite3_bind_text(statement, 2, end_date, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_int64(statement, 3, (sqlite3_int64)limit); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement); + } + while (result == SQLITE_ROW) { + TrainlogSleepDiaryEntry entry; + TrainlogStatus status = + load_entry(database, (const char *)sqlite3_column_text(statement, 0), false, &entry); + if (status == TRAINLOG_STATUS_OK) { + status = visitor(context, &entry); + } + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(statement); + return status; + } + result = sqlite3_step(statement); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) { + result = SQLITE_ERROR; + } + return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus +trainlog_sleep_diary_delete(TrainlogDatabase *database, + const char *entry_id, + const char *expected_revision, + const char *deleted_at, + char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) { + static const char sql[] = + "UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE " + "entry_id=?3 AND current_revision_id=?4 AND deleted=0"; + sqlite3_stmt *statement = NULL; + TrainlogSleepDiaryEntry entry; + TrainlogStatus status; + int result; + if (database == NULL || output_revision == NULL || + !id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) || + !id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + status = load_entry(database, entry_id, false, &entry); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + if (strcmp(entry.revision_id, expected_revision) != 0) { + return TRAINLOG_STATUS_CONFLICT; + } + (void)snprintf( + entry.parent_revision_id, sizeof(entry.parent_revision_id), "%s", expected_revision); + if (trainlog_id_generate("slr", entry.revision_id, sizeof(entry.revision_id)) != + TRAINLOG_STATUS_OK || + !timestamp_valid(deleted_at, &(TrainlogTimestampKey){0})) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + (void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", deleted_at); + entry.deleted = true; + if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + status = insert_revision(database, &entry); + if (status == TRAINLOG_STATUS_OK) { + result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, deleted_at, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 3, entry_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK + : sqlite3_errcode(database->connection); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) { + result = SQLITE_ERROR; + } + status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR + : sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_CONFLICT; + } + if (status == TRAINLOG_STATUS_OK && + sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { + (void)snprintf( + output_revision, TRAINLOG_SLEEP_REVISION_ID_CAPACITY, "%s", entry.revision_id); + return status; + } + rollback(database->connection); + return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status; +} diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index fe92997..a9a1446 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -11,125 +11,69 @@ #include "trainlog/database.h" #include "trainlog/id.h" -#define CHECK(condition) \ - do { \ - if (!(condition)) { \ - (void)fprintf( \ - stderr, \ - "CHECK failed at %s:%d: %s\n", \ - __FILE__, \ - __LINE__, \ - #condition \ - ); \ - return false; \ - } \ +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return false; \ + } \ } while (0) -static bool test_database_open_and_schema(void) -{ +static bool test_database_open_and_schema(void) { TrainlogDatabase *database = NULL; int schema_version = 0; int foreign_keys = 0; - CHECK( - trainlog_database_open(":memory:", &database) == - TRAINLOG_STATUS_OK - ); - CHECK( - trainlog_database_schema_version(database, &schema_version) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK); CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION); - CHECK( - trainlog_database_foreign_keys_enabled(database, &foreign_keys) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK); CHECK(foreign_keys == 1); trainlog_database_close(database); return true; } -static bool test_database_open_diagnostic(void) -{ +static bool test_database_open_diagnostic(void) { TrainlogDatabase *database = NULL; char diagnostic[256]; /* CONTRACT: a launcher must retain the stable status code while showing * the SQLite operation that blocked access to the user's real database. */ - CHECK( - trainlog_database_open_with_diagnostic( - "/", - &database, - diagnostic, - sizeof(diagnostic) - ) == TRAINLOG_STATUS_DATABASE_ERROR - ); + CHECK(trainlog_database_open_with_diagnostic("/", &database, diagnostic, sizeof(diagnostic)) == + TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strstr(diagnostic, "open database:") != NULL); return true; } -static bool test_generated_ids(void) -{ +static bool test_generated_ids(void) { char first[TRAINLOG_GENERATED_ID_CAPACITY]; char second[TRAINLOG_GENERATED_ID_CAPACITY]; char sync_id[TRAINLOG_GENERATED_ID_CAPACITY]; char entry_id[TRAINLOG_GENERATED_ID_CAPACITY]; char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U]; - CHECK( - trainlog_id_generate("ex", first, sizeof(first)) == - TRAINLOG_STATUS_OK - ); - CHECK( - trainlog_id_generate("ex", second, sizeof(second)) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK); + CHECK(trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK); CHECK(strncmp(first, "ex_", 3U) == 0); CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH); CHECK(strcmp(first, second) != 0); CHECK(first[3U + 14U] == '4'); /* ABI behavior: the new longer occurrence prefix must not invalidate the * exact buffer size historically sufficient for two-character prefixes. */ - CHECK( - trainlog_id_generate( - "se", - v1_exact_capacity, - sizeof(v1_exact_capacity) - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_id_generate("se", v1_exact_capacity, sizeof(v1_exact_capacity)) == + TRAINLOG_STATUS_OK); /* TRAINLOG_SYNC_ID_PREFIX_TEST */ - CHECK( - trainlog_id_generate( - "sy", - sync_id, - sizeof(sync_id) - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_id_generate("sy", sync_id, sizeof(sync_id)) == TRAINLOG_STATUS_OK); - CHECK( - strncmp( - sync_id, - "sy_", - 3U - ) == 0 - ); + CHECK(strncmp(sync_id, "sy_", 3U) == 0); - CHECK( - sync_id[3U + 14U] == - '4' - ); + CHECK(sync_id[3U + 14U] == '4'); - CHECK( - trainlog_id_generate( - "sxe", - entry_id, - sizeof(entry_id) - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_id_generate("sxe", entry_id, sizeof(entry_id)) == TRAINLOG_STATUS_OK); CHECK(strncmp(entry_id, "sxe_", 4U) == 0); CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U); CHECK(entry_id[4U + 14U] == '4'); @@ -137,19 +81,15 @@ static bool test_generated_ids(void) return true; } -static bool seed_exercise(TrainlogDatabase *database) -{ - return trainlog_database_insert_exercise( - database, - "ex_test", - "Presse à cuisses", - "presse à cuisses", - TRAINLOG_TRACKING_REPS - ) == TRAINLOG_STATUS_OK; +static bool seed_exercise(TrainlogDatabase *database) { + return trainlog_database_insert_exercise(database, + "ex_test", + "Presse à cuisses", + "presse à cuisses", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK; } -static bool test_session_insert(void) -{ +static bool test_session_insert(void) { TrainlogDatabase *database = NULL; TrainlogSetInput sets[3]; TrainlogSessionExerciseInput exercise; @@ -158,10 +98,7 @@ static bool test_session_insert(void) TrainlogPersistedExerciseDetail persisted_exercises[1]; size_t count = 0U; - CHECK( - trainlog_database_open(":memory:", &database) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(seed_exercise(database)); (void)memset(sets, 0, sizeof(sets)); @@ -175,12 +112,7 @@ static bool test_session_insert(void) sets[2].weight_kg = 0.0; (void)memset(&exercise, 0, sizeof(exercise)); - (void)snprintf( - exercise.exercise_id, - sizeof(exercise.exercise_id), - "%s", - "ex_test" - ); + (void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test"); exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; exercise.rest_seconds = 60; exercise.target_sets = 3; @@ -191,50 +123,20 @@ static bool test_session_insert(void) exercise.set_count = 3U; (void)memset(&session, 0, sizeof(session)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_test"); (void)snprintf( - session.session_id, - sizeof(session.session_id), - "%s", - "se_test" - ); - (void)snprintf( - session.started_at, - sizeof(session.started_at), - "%s", - "2026-09-05T18:00:00+02:00" - ); - (void)snprintf( - session.ended_at, - sizeof(session.ended_at), - "%s", - "2026-09-05T19:00:00+02:00" - ); + session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00"); session.exercises = &exercise; session.exercise_count = 1U; - CHECK( - trainlog_database_insert_session(database, &session) == - TRAINLOG_STATUS_OK - ); - CHECK( - trainlog_database_insert_session(database, &session) == - TRAINLOG_STATUS_CONFLICT - ); - CHECK( - trainlog_database_session_count(database, &count) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT); + CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); - CHECK( - trainlog_database_get_session_details( - database, - "se_test", - &persisted_session, - persisted_exercises, - 1U, - &count - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_get_session_details( + database, "se_test", &persisted_session, persisted_exercises, 1U, &count) == + TRAINLOG_STATUS_OK); CHECK(count == 1U); /* Regression: local occurrences are `sxe`, never synchronization runs. */ CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0); @@ -247,67 +149,39 @@ static bool test_session_insert(void) trainlog_database_free_session_details(persisted_exercises, count); - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_negative_weight"); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight"); sets[0].weight_kg = -0.5; - CHECK(trainlog_database_insert_session(database, &session) == - TRAINLOG_STATUS_INVALID_ARGUMENT); - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_nonfinite_weight"); + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight"); sets[0].weight_kg = INFINITY; - CHECK(trainlog_database_insert_session(database, &session) == - TRAINLOG_STATUS_INVALID_ARGUMENT); - CHECK(trainlog_database_session_count(database, &count) == - TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); trainlog_database_close(database); return true; } -static bool test_body_weight_history(void) -{ +static bool test_body_weight_history(void) { TrainlogDatabase *database = NULL; TrainlogBodyObservationInput observation; TrainlogWeightPoint points[4]; size_t count = 0U; - CHECK( - trainlog_database_open(":memory:", &database) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); (void)memset(&observation, 0, sizeof(observation)); - (void)snprintf( - observation.observation_id, - sizeof(observation.observation_id), - "%s", - "bo_test" - ); - (void)snprintf( - observation.observed_at, - sizeof(observation.observed_at), - "%s", - "2026-09-05T07:00:00+02:00" - ); + (void)snprintf(observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test"); + (void)snprintf(observation.observed_at, + sizeof(observation.observed_at), + "%s", + "2026-09-05T07:00:00+02:00"); observation.has_body_weight = true; observation.body_weight_kg = 82.4; - CHECK( - trainlog_database_insert_body_observation( - database, - &observation - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK); - CHECK( - trainlog_database_list_weight_points( - database, - points, - 4U, - &count - ) == TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_list_weight_points(database, points, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); CHECK(points[0].body_weight_kg > 82.39); CHECK(points[0].body_weight_kg < 82.41); @@ -316,22 +190,15 @@ static bool test_body_weight_history(void) return true; } -static bool test_transaction_rollback(void) -{ +static bool test_transaction_rollback(void) { TrainlogDatabase *database = NULL; size_t count = 0U; - CHECK( - trainlog_database_open(":memory:", &database) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK); CHECK(seed_exercise(database)); CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK); - CHECK( - trainlog_database_exercise_count(database, &count) == - TRAINLOG_STATUS_OK - ); + CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK); /* Schema v13 seeds the five frozen supplied machine identities. The * rolled-back custom row must not change that baseline. */ CHECK(count == 5U); @@ -340,8 +207,7 @@ static bool test_transaction_rollback(void) return true; } -static bool test_exercise_merge_aliases_and_conflicts(void) -{ +static bool test_exercise_merge_aliases_and_conflicts(void) { TrainlogDatabase *database = NULL; TrainlogExerciseBodyZone zones[4]; const char *source_secondary[] = {"shoulders"}; @@ -350,50 +216,53 @@ static bool test_exercise_merge_aliases_and_conflicts(void) TrainlogExerciseMergePreview preview; size_t count = 0U; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A", - "curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B", - "curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C", - "curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold", - "hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source", - "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target", - "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", - &preview) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise( + database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise( + database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise( + database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise( + database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones( + database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones( + database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) == + TRAINLOG_STATUS_OK); CHECK(preview.occurrences == 0U && preview.performed_sets == 0U && - preview.continuous_activities == 0U && preview.max_results == 0U && - preview.associated_equipment == 0U && preview.body_zones == 2U); + preview.continuous_activities == 0U && preview.max_results == 0U && + preview.associated_equipment == 0U && preview.body_zones == 2U); CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") == - TRAINLOG_STATUS_OK); - CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical, - sizeof(canonical)) == TRAINLOG_STATUS_OK); + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_resolve_exercise_id( + database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_target") == 0); - CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, - 4U, &count) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) == + TRAINLOG_STATUS_OK); CHECK(count == 3U); - CHECK(strcmp(zones[0].zone_id, "arms") == 0 && - zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY); + CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY); /* A second merge must collapse every old source directly to the newest * canonical ID; resolution never depends on an unbounded alias walk. */ CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") == - TRAINLOG_STATUS_OK); - CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical, - sizeof(canonical)) == TRAINLOG_STATUS_OK); + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_resolve_exercise_id( + database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); - CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical, - sizeof(canonical)) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_resolve_exercise_id( + database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") == - TRAINLOG_STATUS_CONFLICT); - CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical, - sizeof(canonical)) == TRAINLOG_STATUS_OK); + TRAINLOG_STATUS_CONFLICT); + CHECK(trainlog_database_resolve_exercise_id( + database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK); CHECK(strcmp(canonical, "ex_final") == 0); CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK); CHECK(count == 7U); @@ -406,8 +275,7 @@ struct TestCase { bool (*function)(void); }; -int main(void) -{ +int main(void) { static const struct TestCase tests[] = { {"database_open_and_schema", test_database_open_and_schema}, {"database_open_diagnostic", test_database_open_diagnostic}, diff --git a/tui/tests/test_schema_v26_migration.c b/tui/tests/test_schema_v26_migration.c index 34c1b44..bc568d9 100644 --- a/tui/tests/test_schema_v26_migration.c +++ b/tui/tests/test_schema_v26_migration.c @@ -432,12 +432,12 @@ static int run_test(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 28); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(database); database = NULL; CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 28); + CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(capture_projection(raw, BUSINESS_PROJECTIONS, sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), diff --git a/tui/tests/test_schema_v28_migration.c b/tui/tests/test_schema_v28_migration.c index f15f098..d7276a3 100644 --- a/tui/tests/test_schema_v28_migration.c +++ b/tui/tests/test_schema_v28_migration.c @@ -119,7 +119,7 @@ static int run_test(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 28); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c new file mode 100644 index 0000000..750e83c --- /dev/null +++ b/tui/tests/test_schema_v29_migration.c @@ -0,0 +1,73 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + int value = -1; + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + value = sqlite3_column_int(statement, 0); + } + (void)sqlite3_finalize(statement); + return value; +} + +int main(void) { + char path[] = "/tmp/trainlog-schema-v29-XXXXXX"; + TrainlogDatabase *production = NULL; + sqlite3 *raw = NULL; + int descriptor = mkstemp(path); + int result = EXIT_FAILURE; + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;" + "DROP TABLE sleep_diary_entries;PRAGMA user_version=28;", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, "PRAGMA user_version") == 29); + CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == + 1); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + result = EXIT_SUCCESS; +cleanup: + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } + (void)unlink(path); + if (result == EXIT_SUCCESS) { + puts("PASS schema v28 to v29 migration and reopen"); + } + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 9501f64..af044dc 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v28: SQLite rc=", - strlen("migrate database to schema v28: SQLite rc=")) == 0); + "migrate database to schema v29: SQLite rc=", + strlen("migrate database to schema v29: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,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=29;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", 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 29 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 430dfb6..7c108f2 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_sleep_diary.c b/tui/tests/test_sleep_diary.c new file mode 100644 index 0000000..3310e22 --- /dev/null +++ b/tui/tests/test_sleep_diary.c @@ -0,0 +1,125 @@ +#include +#include +#include + +#include "trainlog/database.h" +#include "trainlog/id.h" +#include "trainlog/sleep_diary.h" +#include "trainlog/web_sleep.h" + +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + return 1; \ + } \ + } while (0) + +static TrainlogSleepDiaryEntry sample(void) { + TrainlogSleepDiaryEntry entry = {0}; + (void)snprintf(entry.night_start_date, sizeof(entry.night_start_date), "2026-10-24"); + (void)snprintf(entry.night_end_date, sizeof(entry.night_end_date), "2026-10-25"); + (void)snprintf(entry.created_at, sizeof(entry.created_at), "2026-10-25T08:10:00+01:00"); + (void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", entry.created_at); + entry.sleep_quality = TRAINLOG_SLEEP_QUALITY_B; + entry.wake_quality = TRAINLOG_SLEEP_QUALITY_MOY; + (void)snprintf( + entry.treatment_and_notes, sizeof(entry.treatment_and_notes), "Synthetic fixture"); + entry.event_count = 4U; + (void)trainlog_id_generate("sle", entry.events[0].event_id, sizeof(entry.events[0].event_id)); + entry.events[0].type = TRAINLOG_SLEEP_EVENT_BED_TIME; + (void)snprintf( + entry.events[0].start_at, sizeof(entry.events[0].start_at), "2026-10-24T23:30:00+02:00"); + (void)trainlog_id_generate("sle", entry.events[1].event_id, sizeof(entry.events[1].event_id)); + entry.events[1].type = TRAINLOG_SLEEP_EVENT_SLEEP; + (void)snprintf( + entry.events[1].start_at, sizeof(entry.events[1].start_at), "2026-10-25T00:30:00+02:00"); + (void)snprintf( + entry.events[1].end_at, sizeof(entry.events[1].end_at), "2026-10-25T02:30:00+02:00"); + (void)trainlog_id_generate("sle", entry.events[2].event_id, sizeof(entry.events[2].event_id)); + entry.events[2].type = TRAINLOG_SLEEP_EVENT_LONG_AWAKE; + (void)snprintf( + entry.events[2].start_at, sizeof(entry.events[2].start_at), "2026-10-25T02:30:00+02:00"); + (void)snprintf( + entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:30:00+01:00"); + (void)trainlog_id_generate("sle", entry.events[3].event_id, sizeof(entry.events[3].event_id)); + entry.events[3].type = TRAINLOG_SLEEP_EVENT_FINAL_GET_UP; + (void)snprintf( + entry.events[3].start_at, sizeof(entry.events[3].start_at), "2026-10-25T08:00:00+01:00"); + return entry; +} + +static TrainlogStatus count_visitor(void *context, const TrainlogSleepDiaryEntry *entry) { + size_t *count = context; + if (entry == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + ++*count; + return TRAINLOG_STATUS_OK; +} + +int main(void) { + TrainlogDatabase *database = NULL; + TrainlogSleepDiaryEntry entry = sample(); + TrainlogSleepDiaryEntry loaded; + TrainlogSleepDiaryEntry stale; + TrainlogSleepDiaryEntry second; + char *json = NULL; + size_t json_size = 0U; + char first_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + char deleted_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]; + size_t count = 0U; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_sleep_diary_validate(&entry)); + CHECK(trainlog_sleep_diary_create(database, &entry) == TRAINLOG_STATUS_OK); + second = sample(); + (void)snprintf(second.night_start_date, sizeof(second.night_start_date), "2026-10-25"); + (void)snprintf(second.night_end_date, sizeof(second.night_end_date), "2026-10-26"); + (void)snprintf( + second.events[0].start_at, sizeof(second.events[0].start_at), "2026-10-26T00:30:00+01:00"); + CHECK(trainlog_sleep_diary_create(database, &second) == TRAINLOG_STATUS_OK); + CHECK(trainlog_web_sleep_list_json(database, NULL, NULL, 7U, &json, &json_size) == + TRAINLOG_STATUS_OK); + CHECK(json_size > 0U && strstr(json, "\"average_bed_minute\":360") != NULL); + free(json); + json = NULL; + CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK); + CHECK(loaded.event_count == 4U && loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B); + (void)snprintf(first_revision, sizeof(first_revision), "%s", loaded.revision_id); + stale = loaded; + loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB; + (void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00"); + CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK); + stale.day_form = TRAINLOG_SLEEP_QUALITY_TM; + (void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00"); + CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) == + TRAINLOG_STATUS_CONFLICT); + CHECK(trainlog_sleep_diary_list( + database, "2026-10-24", "2026-10-24", 7U, count_visitor, &count) == + TRAINLOG_STATUS_OK && + count == 1U); + CHECK(trainlog_sleep_diary_delete(database, + entry.entry_id, + loaded.revision_id, + "2026-10-26T09:00:00+01:00", + deleted_revision) == TRAINLOG_STATUS_OK); + CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == + TRAINLOG_STATUS_NOT_FOUND); + CHECK(trainlog_sleep_diary_get(database, entry.entry_id, true, &loaded) == TRAINLOG_STATUS_OK && + loaded.deleted); + CHECK(trainlog_sleep_diary_delete(database, + entry.entry_id, + first_revision, + "2026-10-26T10:00:00+01:00", + deleted_revision) != TRAINLOG_STATUS_OK); + + /* Same local clock across the DST fallback is a positive absolute interval. */ + entry = sample(); + (void)snprintf( + entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:15:00+02:00"); + CHECK(!trainlog_sleep_diary_validate(&entry)); + trainlog_database_close(database); + puts("PASS sleep diary domain"); + return 0; +} diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index a3c527a..b863fcb 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -219,7 +219,7 @@ static bool import_archive_and_prepare(void) { char *second_entry; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(scalar(database, "PRAGMA user_version") == 28); + CHECK(scalar(database, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(scalar(database, "SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE " "name IN('generation_id','acknowledged_at')") == 2);