diff --git a/lib/data/db.dart b/lib/data/db.dart index 69c2eca0e..3c5ee4f3b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -58,6 +58,8 @@ import 'live_coverage_policy.dart'; import '../compute/step_calibration_fit.dart'; import 'step_calibration.dart'; import '../sync/reset_gate.dart'; +import 'hyrox_store.dart'; +import 'triathlon_store.dart'; import 'med_store.dart'; import 'models.dart'; import 'nutrition_store.dart'; @@ -321,6 +323,14 @@ class LocalDb { 'imported_workout', 'workout_route', 'workout_split', + // A HYROX race is hand-marked at the band: the round boundaries exist + // nowhere else, and the plan was typed by the athlete. + 'hyrox_session', + 'hyrox_segment', + // A triathlon is hand-marked at the band: the boundaries exist nowhere + // else, and the plan (incl. indoor/outdoor) was typed by the athlete. + 'triathlon_session', + 'triathlon_segment', // The only copy of what a paired sensor measured during a session. 'external_hr', 'imported_measurement', @@ -664,7 +674,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 58; + static const int schemaVersion = 60; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -672,6 +682,32 @@ class LocalDb { /// `decoded_onehz` is 86 400 counters. Same reason `commitSyncBatch` chunks. static const int _maxSqlVars = 500; + /// HYROX day-window predicate, shared by day export and day delete so the + /// two paths cannot drift apart. A race belongs to a day when it STARTS that + /// day, or when one of its segments STARTS that day or OVERLAPS it. The + /// segment-start branch matters for a finalized DNF whose open cut segment + /// starts on the later day: its session start and closed-segment ends are + /// all on the earlier day, so without it the race would survive a privacy + /// delete of the later day. An open segment of a LIVE session only overlaps + /// the window while the session was written at or after the window start + /// (updated_at is epoch MILLISECONDS, window bounds are seconds); a + /// FINALIZED race (incl. an abandoned DNF) keeps the end-time bound + /// COALESCE(session.end_ts, session.start_ts) for the open branch. + /// Args: [startSec, endSec, endSec, startSec, startSec, endSec, startSec]. + static const String _hyroxInWindowSql = '(SELECT id FROM hyrox_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM hyrox_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( start_ts >= ? ' + ' OR (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL ' + ' AND EXISTS (SELECT 1 FROM hyrox_session s ' + ' WHERE s.id = hyrox_segment.session_id ' + ' AND ( (s.status = \'finalized\' ' + ' AND COALESCE(s.end_ts, s.start_ts, 0) > ?) ' + ' OR (s.status <> \'finalized\' ' + ' AND s.updated_at >= ? * 1000))))) )'; + /// THE PRIMARY BAND'S `device_id`, and it is reserved permanently. /// /// Not a migration default — a standing rule, load-bearing twice over: @@ -1434,6 +1470,17 @@ class LocalDb { // A band's own hypnogram (`vendor_staged`). New table only. await _createVendorSleepEpoch(db); } + if (oldV < 59) { + // Triathlon/multisport: parent session + per-segment boundaries. + // New tables only, no backfill — there is no older data to move. + await createTriathlonTables(db); + } + if (oldV < 60) { + // HYROX: parent session + per-round boundaries. New tables only, + // no backfill — there is no older data to move. The reconcile + // exclusion column is part of the table from the start. + await createHyroxTables(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -5735,6 +5782,12 @@ class LocalDb { // DDL belongs next to the code that reads it, not two thousand lines away. await createNutritionTables(db); await createMedTables(db); + // HYROX sessions and their round segments: hand-recorded race data, so + // the creator lives here where onCreate, the onOpen repair pass and the + // export database all run it. CREATE IF NOT EXISTS, so re-running is free. + await createHyroxTables(db); + // Triathlon sessions and their segments, same reasoning. + await createTriathlonTables(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -11177,6 +11230,45 @@ class LocalDb { where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); + // Same midnight rule as deleteDays (shared predicate _hyroxInWindowSql): + // a race is exported for a day when it starts that day OR one of its + // segments starts that day or overlaps it, and always whole. + await copyRows( + 'hyrox_session', + where: 'id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], + ); + await copyRows( + 'hyrox_segment', + where: 'session_id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], + ); + // Same midnight rule as deleteDays: a race is exported for a day when + // it starts that day OR one of its segments does, and always whole. + // An open segment only overlaps later days while the session is still + // live; a FINALIZED race (incl. an abandoned DNF with an open cut + // segment) is bounded by COALESCE(session.end_ts, session.start_ts), + // so it does not match every later day. + const triInWindow = '(SELECT id FROM triathlon_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM triathlon_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL AND start_ts < ? ' + ' AND EXISTS (SELECT 1 FROM triathlon_session s ' + ' WHERE s.id = triathlon_segment.session_id ' + ' AND (s.status <> \'finalized\' ' + ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + await copyRows( + 'triathlon_session', + where: 'id IN $triInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + ); + await copyRows( + 'triathlon_segment', + where: 'session_id IN $triInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + ); await copyRows( 'live_coverage', where: 'end_ts > ? AND start_ts < ?', @@ -11359,6 +11451,142 @@ class LocalDb { [startSec, endSec], ); } + // HYROX rows are NOT children of `sessions`: their key is the hyrox + // session id, and a segment's `workout_id` merely points at one. + // Treating them as session children matched nothing (a privacy leak + // on delete) and could delete a race segment whose string id collided + // with a workout id. + // + // A race belongs to a day when it STARTS that day, or when one of its + // segments OVERLAPS it. Overlap is not "starts inside": a segment that + // began yesterday and is still open, or ends today, was recorded on + // this day too. An open segment has no end, so it overlaps every later + // window. Deleting either day takes the WHOLE race — half a race is a + // record nobody can read. + // + // The ids are read first and deleted second: deleting the segments + // before the parent statement would empty the very subquery the parent + // delete is built from, which is how a cross-midnight race used to + // survive its own deletion. + // An open segment only overlaps later days while the session is still + // live; a FINALIZED race (incl. an abandoned DNF with an open cut + // segment) is bounded by COALESCE(session.end_ts, session.start_ts). + // The predicate is the shared _hyroxInWindowSql so export and delete + // cannot drift apart. + final hyroxIds = [ + for (final r in await txn.query( + 'hyrox_session', + columns: ['id'], + where: 'id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], + )) + r['id'] as String, + ]; + for (final chunk in _sqlVarChunks(hyroxIds)) { + final marks = List.filled(chunk.length, '?').join(','); + // Collect the linked workouts BEFORE the segment rows go: a segment + // deleted with its `sessions` row left behind would orphan the + // workout's route and splits on a day the user asked to delete. + final linked = await txn.query( + 'hyrox_segment', + columns: ['workout_id'], + where: 'session_id IN ($marks) AND workout_id IS NOT NULL', + whereArgs: chunk, + ); + for (final w in linked) { + final wid = w['workout_id'] as String; + await txn.delete('workout_route', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('workout_split', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('session_sensor', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('strength_set', + where: 'session_id = ?', whereArgs: [wid]); + deleted += await txn.delete('sessions', + where: 'id = ?', whereArgs: [wid]); + } + deleted += await txn.rawDelete( + 'DELETE FROM hyrox_segment WHERE session_id IN ($marks)', + chunk, + ); + deleted += await txn.rawDelete( + 'DELETE FROM hyrox_session WHERE id IN ($marks)', + chunk, + ); + } + // Triathlon rows are NOT children of `sessions`: their key is the + // triathlon session id, and a segment's `workout_id` merely points at + // one. Treating them as session children matched nothing (a privacy + // leak on delete) and could delete a race segment whose string id + // collided with a workout id. + // + // A race belongs to a day when it STARTS that day, or when one of its + // segments OVERLAPS it. Overlap is not "starts inside": a segment that + // began yesterday and is still open, or ends today, was recorded on + // this day too. An open segment has no end, so it overlaps every later + // window. Deleting either day takes the WHOLE race — half a race is a + // record nobody can read. + // + // The ids are read first and deleted second: deleting the segments + // before the parent statement would empty the very subquery the parent + // delete is built from, which is how a cross-midnight race used to + // survive its own deletion. + // An open segment only overlaps later days while the session is still + // live; a FINALIZED race (incl. an abandoned DNF with an open cut + // segment) is bounded by COALESCE(session.end_ts, session.start_ts). + const triInWindow = '(SELECT id FROM triathlon_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM triathlon_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL AND start_ts < ? ' + ' AND EXISTS (SELECT 1 FROM triathlon_session s ' + ' WHERE s.id = triathlon_segment.session_id ' + ' AND (s.status <> \'finalized\' ' + ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + final triIds = [ + for (final r in await txn.query( + 'triathlon_session', + columns: ['id'], + where: 'id IN $triInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + )) + r['id'] as String, + ]; + for (final chunk in _sqlVarChunks(triIds)) { + final marks = List.filled(chunk.length, '?').join(','); + // Collect the linked workouts BEFORE the segment rows go: a segment + // deleted with its `sessions` row left behind would orphan the + // workout's route and splits on a day the user asked to delete. + final linked = await txn.query( + 'triathlon_segment', + columns: ['workout_id'], + where: 'session_id IN ($marks) AND workout_id IS NOT NULL', + whereArgs: chunk, + ); + for (final w in linked) { + final wid = w['workout_id'] as String; + await txn.delete('workout_route', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('workout_split', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('session_sensor', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('strength_set', + where: 'session_id = ?', whereArgs: [wid]); + deleted += await txn.delete('sessions', + where: 'id = ?', whereArgs: [wid]); + } + deleted += await txn.rawDelete( + 'DELETE FROM triathlon_segment WHERE session_id IN ($marks)', + chunk, + ); + deleted += await txn.rawDelete( + 'DELETE FROM triathlon_session WHERE id IN ($marks)', + chunk, + ); + } deleted += await txn.delete( 'sessions', where: 'start_ts >= ? AND start_ts < ?', @@ -11802,6 +12030,11 @@ class LocalDb { // Keep the decision merge after both kinds of accepted activity. 'sessions', 'session_sensor', + // The recorded round boundaries and their plans cannot be rebuilt. + 'hyrox_session', + 'hyrox_segment', + 'triathlon_session', + 'triathlon_segment', 'activity_suggestions', 'activity_review_meta', 'activity_review_days', @@ -12032,6 +12265,139 @@ class LocalDb { // get the same protection, or trends and the rolling baselines would be // rebuilt from the other export's numbers while day detail serves ours. final finalizedDays = {}; + // A LOCAL race wins over an OLDER snapshot, but a NEWER backup must be + // able to advance it (e.g. the final boundaries recorded on another + // phone). `discarded` is an explicit local decision and always wins. + // Segments follow their parent's verdict, because they arrive as separate + // rows and would otherwise rewrite the boundaries of a skipped race. + final protectedHyrox = {}; + final hyroxLastEdit = {}; + // Session ids whose imported hyrox_session row the merge skipped (locally + // discarded or newer). Their segment rows must be skipped too: segments + // follow their parent's verdict, and hyrox_session is merged before + // hyrox_segment in _restoreTables, so the set is complete before the + // segment pass starts. + final skippedHyrox = {}; + try { + // A race arrives as a parent row plus its segment rows, and a replaced + // parent has its local segments cleared first. A restore cut off between + // the two leaves a parent with none or only some of its segments; if that + // parent counted as "local and current" the next restore would skip the + // segments for good. So a parent only earns the newer-than-snapshot + // protection once every segment its plan calls for is on disk. + final haveHyroxSegments = { + for (final r in await db.rawQuery( + 'SELECT session_id, COUNT(*) AS n FROM hyrox_segment ' + 'GROUP BY session_id', + )) + '${r['session_id']}': (r['n'] as num?)?.toInt() ?? 0, + }; + int? plannedHyroxSegments(Object? planJson) { + try { + final j = jsonDecode('$planJson'); + final segs = j is Map ? j['segments'] : null; + return segs is List ? segs.length : null; + } catch (_) { + return null; + } + } + + for (final r in await db.query( + 'hyrox_session', + columns: ['id', 'status', 'updated_at', 'finalized_at', 'plan_json'], + distinct: true, + )) { + final id = '${r['id']}'; + // Finalization is one-way: a finalized local race is a record and is + // never replaced by an import, not even a newer backup's unfinalized + // copy. Discarded is an explicit local decision and always wins too. + if (r['status'] == 'discarded' || r['finalized_at'] != null) { + protectedHyrox.add(id); + } + final planned = plannedHyroxSegments(r['plan_json']); + if (planned == null || (haveHyroxSegments[id] ?? 0) < planned) { + continue; // an interrupted import: let the next one finish it + } + hyroxLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; + } + } on DatabaseException { + /* table absent in an older build of this file — nothing to protect */ + } + // A local race is protected from an import when the athlete explicitly + // discarded it, or when the local row was edited no earlier than the + // imported snapshot (a newer backup may still advance the race). [row] is + // the imported hyrox_session row, null for segment rows. + bool isHyroxProtected(String id, Map? row) { + if (protectedHyrox.contains(id)) return true; + if (row == null) return false; + final local = hyroxLastEdit[id]; + if (local == null) return false; + final theirs = (row['updated_at'] as num?)?.toInt() ?? 0; + return local >= theirs; + } + // A LOCAL race wins over an OLDER snapshot, but a NEWER backup must be + // able to advance it (e.g. the final boundaries recorded on another + // phone). `discarded` is an explicit local decision and always wins. + // Segments follow their parent's verdict, because they arrive as separate + // rows and would otherwise rewrite the boundaries of a skipped race. + final protectedTriathlons = {}; + final triLastEdit = {}; + final skippedTriathlons = {}; + // A local race is protected from an import when the athlete explicitly + // discarded it, or when the local row was edited no earlier than the + // imported snapshot (a newer backup may still advance the race). [row] is + // the imported triathlon_session row, null for segment rows. + bool triProtected(String id, Map? row) { + if (protectedTriathlons.contains(id)) return true; + if (row == null) return false; + final local = triLastEdit[id]; + if (local == null) return false; + final theirs = (row['updated_at'] as num?)?.toInt() ?? 0; + return local >= theirs; + } + + try { + // A race arrives as a parent row and its segment rows, in separate + // statements. A restore cut off between the two leaves a parent with + // none or only some of its segments; if that parent counted as "local + // and current" the next restore would skip its segments for good. So a + // parent only earns the newer-than-snapshot protection once every + // segment its plan calls for is on disk. + final haveSegments = { + for (final r in await db.rawQuery( + 'SELECT session_id, COUNT(*) AS n FROM triathlon_segment ' + 'GROUP BY session_id', + )) + '${r['session_id']}': (r['n'] as num?)?.toInt() ?? 0, + }; + int? plannedSegments(Object? planJson) { + try { + final j = jsonDecode('$planJson'); + final segs = j is Map ? j['segments'] : null; + return segs is List ? segs.length : null; + } catch (_) { + return null; + } + } + + for (final r in await db.query( + 'triathlon_session', + columns: ['id', 'status', 'updated_at', 'plan_json'], + distinct: true, + )) { + final id = '${r['id']}'; + if (r['status'] == 'discarded') { + protectedTriathlons.add(id); + } + final planned = plannedSegments(r['plan_json']); + if (planned == null || (haveSegments[id] ?? 0) < planned) { + continue; // an interrupted import: let the next one finish it + } + triLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; + } + } on DatabaseException { + /* table absent in an older build of this file — nothing to protect */ + } var archiveSkipped = 0; int? archiveEdgeSec; var impAttached = false; @@ -12764,6 +13130,43 @@ class LocalDb { } } } + if (t == 'hyrox_session' && + isHyroxProtected('${row['id']}', row)) { + skippedHyrox.add('${row['id']}'); + continue; // locally discarded/finalized, or newer than the snapshot + } + if (t == 'hyrox_session') { + // The session pass runs before the segment pass and FKs are + // off: a replaced parent would keep local segment rows the + // backup does not carry. Delete them so the restored race + // shows exactly the backup's segments. Skipped (protected) + // sessions never reach here, so their segments stay protected. + // MUST run on the transaction (txn), not db — calling db.* + // inside db.transaction deadlocks. + await txn.delete( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: ['${row['id']}'], + ); + } + if (t == 'hyrox_segment' && + (isHyroxProtected('${row['session_id']}', null) || + skippedHyrox.contains('${row['session_id']}'))) { + continue; // parent skipped — the segments follow its verdict + } + if (t == 'triathlon_session' && + triProtected('${row['id']}', row)) { + // Locally discarded, or newer than the snapshot. Its + // segments are skipped with it: the parent table is merged + // first, so the verdict is already known when they arrive. + skippedTriathlons.add('${row['id']}'); + continue; + } + if (t == 'triathlon_segment' && + (protectedTriathlons.contains('${row['session_id']}') || + skippedTriathlons.contains('${row['session_id']}'))) { + continue; + } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', @@ -13157,6 +13560,10 @@ class LocalDb { 'signal_priority', 'vendor_sleep_epoch', 'substrate_archive', + 'hyrox_session', + 'hyrox_segment', + 'triathlon_session', + 'triathlon_segment', ]; final missingTables = []; @@ -15608,15 +16015,31 @@ class LocalDb { // on the three real exports these four ids are 87, 22 and 12 rows across // 8 to 11 days — a few thousand a year. Event 33 alone is ~1900 A DAY, // which is why the exemption is four ids and not the whole table. + // + // The double-taps (event 14) that mark a triathlon's or a HYROX race's + // boundaries are also kept while a race is still armed or active: the + // reconcilers rebuild the boundaries from them, and a race left + // unreconciled past the cutoff would otherwise lose the only record of + // where its segments change. deleted += await txn.delete( 'band_events', - where: 'ts < ? AND event_id NOT IN (?, ?, ?, ?)', + where: 'ts < ? AND event_id NOT IN (?, ?, ?, ?) ' + 'AND NOT (event_id = ? AND ts >= COALESCE(' + '(SELECT MIN(c) FROM (' + 'SELECT MIN(created_at) / 1000 - ? AS c FROM triathlon_session ' + "WHERE status IN ('armed', 'active') " + 'UNION ALL ' + 'SELECT MIN(created_at) / 1000 - ? FROM hyrox_session ' + "WHERE status IN ('armed', 'active'))), 9223372036854775807))", whereArgs: [ cutoffSec, proto.EventId.chargingOn, proto.EventId.chargingOff, proto.EventId.wristOn, proto.EventId.wristOff, + kTriathlonDoubleTapEventId, + kTriathlonClockDriftSec, + kTriathlonClockDriftSec, ], ); // THE UNDECODABLE ARCHIVE IS THINNED, NOT KEPT WHOLE. See diff --git a/lib/data/hyrox_plan.dart b/lib/data/hyrox_plan.dart new file mode 100644 index 000000000..45cfb604a --- /dev/null +++ b/lib/data/hyrox_plan.dart @@ -0,0 +1,321 @@ +// HYROX PLAN — pure Dart, no Flutter, no sqflite. +// +// A plan is what the athlete chose BEFORE arming: the ordered rounds, the +// station of each round, the planned targets and the optional goal times. It +// is frozen into `hyrox_session.plan_json` at arm time and never edited +// afterwards, so a finished race still says what was actually attempted +// rather than what the preset means today. +// +// Domain decision, made once and documented here: ONE SEGMENT PER ROUND. A +// HYROX round is 1 km run + one station, and this app models the round as a +// single `hyrox_round` segment (kind + `station` + `round_index` 1..8) rather +// than a run/station split. The athlete taps at round transitions — after +// each station, before the next run — which is exactly eight boundaries for +// eight rounds plus the start. The final 1 km run after round 8 is part of +// round 8's segment: segment 8 runs from the start of round 8's run to the +// finish line. The interleaved 1 km runs are NOT separate segments in this +// model; if a future PR wants run/station granularity that is a new segment +// kind, not a reinterpretation of `hyrox_round`. +// +// Two rules shape this file: +// +// * A PLANNED distance/reps/calorie target is not a MEASURED distance. The +// preset table below is a target the user typed or accepted, never +// something the band measured, so it lives here and not in +// `hyrox_segment.distance_m`. +// * HYROX is always an indoor arena event, so there is no per-segment +// indoor/outdoor choice (unlike triathlon): the runs happen on an indoor +// track and no segment arms GPS. + +/// Current serialization version of [HyroxPlan.toJson]. Readers refuse a +/// newer major version instead of silently dropping fields they do not know. +const int kHyroxPlanVersion = 1; + +/// Every HYROX segment in this build is one round. The kind exists so the row +/// format can grow (e.g. a finer run/station split) without a schema change. +enum HyroxSegmentKind { hyroxRound } + +extension HyroxSegmentKindX on HyroxSegmentKind { + String get key => switch (this) { + HyroxSegmentKind.hyroxRound => 'hyrox_round', + }; + + static HyroxSegmentKind? fromKey(String? key) => switch (key) { + 'hyrox_round' => HyroxSegmentKind.hyroxRound, + _ => null, + }; +} + +/// The eight stations, in the fixed HYROX race order. `key` is the on-disk +/// value; free-form presets reuse the same vocabulary. +enum HyroxStation { + skierg, + sledPush, + sledPull, + burpeeBroadJump, + rowing, + farmersCarry, + sandbagLunges, + wallBalls, +} + +extension HyroxStationX on HyroxStation { + String get key => switch (this) { + HyroxStation.skierg => 'skierg', + HyroxStation.sledPush => 'sled_push', + HyroxStation.sledPull => 'sled_pull', + HyroxStation.burpeeBroadJump => 'burpee_broad_jump', + HyroxStation.rowing => 'rowing', + HyroxStation.farmersCarry => 'farmers_carry', + HyroxStation.sandbagLunges => 'sandbag_lunges', + HyroxStation.wallBalls => 'wall_balls', + }; + + static HyroxStation? fromKey(String? key) => switch (key) { + 'skierg' => HyroxStation.skierg, + 'sled_push' => HyroxStation.sledPush, + 'sled_pull' => HyroxStation.sledPull, + 'burpee_broad_jump' => HyroxStation.burpeeBroadJump, + 'rowing' => HyroxStation.rowing, + 'farmers_carry' => HyroxStation.farmersCarry, + 'sandbag_lunges' => HyroxStation.sandbagLunges, + 'wall_balls' => HyroxStation.wallBalls, + _ => null, + }; +} + +/// The fixed station order of a HYROX race. +const List kHyroxStationOrder = [ + HyroxStation.skierg, + HyroxStation.sledPush, + HyroxStation.sledPull, + HyroxStation.burpeeBroadJump, + HyroxStation.rowing, + HyroxStation.farmersCarry, + HyroxStation.sandbagLunges, + HyroxStation.wallBalls, +]; + +/// Every round starts with a 1 km run. Fixed by the race format, so it is a +/// constant of the domain and not a per-segment plan field. +const double kHyroxRunPerRoundM = 1000; + +/// Preset keys. `hyrox` and `hyrox_doubles` are the fixed eight-station race +/// (doubles only changes who does which round — a pairing concern, not a +/// storage one, so the segments are identical); `hyrox_training` and `custom` +/// are free-form ordered lists. No event-brand marks, see TRADEMARKS.md. +const List kHyroxPresets = [ + 'hyrox', + 'hyrox_doubles', + 'hyrox_training', + 'custom', +]; + +/// True for the two fixed-race presets that must always be the canonical +/// eight rounds in order. +bool _isFixedRacePreset(String preset) => + preset == 'hyrox' || preset == 'hyrox_doubles'; + +/// Planned targets per station for the fixed race: (distance m, reps, cal). +/// Matches the official HYROX rulebook (Singles/Doubles/Relay 26/27): every +/// round is a 1000 m run followed by one station — SkiErg 1000 m, sled push +/// 4×12.5 m = 50 m, sled pull 50 m, burpee broad jumps 80 m, rowing 1000 m, +/// farmers carry 200 m, sandbag lunges 100 m, wall balls 100 reps. Loads +/// (sled/kettlebell/sandbag/ball weights) vary by division and are a gym-setup +/// detail, not stored. `planned_cal` exists only for free-form training plans +/// that target calories instead of metres on an erg. +const Map kHyroxStationTargets = { + HyroxStation.skierg: (1000, null, null), + HyroxStation.sledPush: (50, null, null), + HyroxStation.sledPull: (50, null, null), + HyroxStation.burpeeBroadJump: (80, null, null), + HyroxStation.rowing: (1000, null, null), + HyroxStation.farmersCarry: (200, null, null), + HyroxStation.sandbagLunges: (100, null, null), + HyroxStation.wallBalls: (null, 100, null), +}; + +class HyroxSegmentPlan { + const HyroxSegmentPlan({ + required this.kind, + required this.station, + this.roundIndex, + this.plannedDistanceM, + this.plannedReps, + this.plannedCal, + this.goalSec, + }); + + final HyroxSegmentKind kind; + + /// Which station this round ends with. + final HyroxStation station; + + /// 1..8 in a fixed race; null in a free-form plan. A plan target, not a + /// measurement. + final int? roundIndex; + + /// Target distance in metres for distance-based stations, or null. This is + /// a PLAN, not a measurement. + final double? plannedDistanceM; + + /// Target repetitions for rep-based stations (wall balls), or null. + final int? plannedReps; + + /// Target calories for cal-based stations (skierg/rowing alternative), or + /// null. + final int? plannedCal; + + /// Optional goal time in seconds for the whole round. Null = no goal. + final int? goalSec; + + Map toJson() => { + 'kind': kind.key, + 'station': station.key, + 'round_index': roundIndex, + 'planned_distance_m': plannedDistanceM, + 'planned_reps': plannedReps, + 'planned_cal': plannedCal, + 'goal_sec': goalSec, + }; + + static HyroxSegmentPlan? fromJson(Map j) { + final rawKind = j['kind']; + final rawStation = j['station']; + final kind = HyroxSegmentKindX.fromKey(rawKind is String ? rawKind : null); + final station = + HyroxStationX.fromKey(rawStation is String ? rawStation : null); + if (kind == null || station == null) return null; + final dist = j['planned_distance_m']; + final reps = j['planned_reps']; + final cal = j['planned_cal']; + final round = j['round_index']; + final goal = j['goal_sec']; + return HyroxSegmentPlan( + kind: kind, + station: station, + roundIndex: round is num ? round.toInt() : null, + plannedDistanceM: dist is num ? dist.toDouble() : null, + plannedReps: reps is num ? reps.toInt() : null, + plannedCal: cal is num ? cal.toInt() : null, + goalSec: goal is num ? goal.toInt() : null, + ); + } +} + +class HyroxPlan { + const HyroxPlan({ + required this.preset, + required this.segments, + }); + + /// 'hyrox' | 'hyrox_doubles' | 'hyrox_training' | 'custom'. Free-form on + /// disk on purpose: an older build must still be able to SHOW a plan whose + /// preset it does not know. + final String preset; + final List segments; + + bool get isTraining => preset == 'hyrox_training' || preset == 'custom'; + + /// The fixed eight-round race. `preset` must be 'hyrox' or 'hyrox_doubles'; + /// [goalSec] optionally overrides the per-round goal times. + factory HyroxPlan.race( + String preset, { + Map? goalSec, + }) { + if (!_isFixedRacePreset(preset)) { + throw ArgumentError('race() needs a fixed preset, got "$preset"'); + } + return HyroxPlan( + preset: preset, + segments: [ + for (var i = 0; i < kHyroxStationOrder.length; i++) + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: kHyroxStationOrder[i], + roundIndex: i + 1, + plannedDistanceM: kHyroxStationTargets[kHyroxStationOrder[i]]?.$1, + plannedReps: kHyroxStationTargets[kHyroxStationOrder[i]]?.$2, + plannedCal: kHyroxStationTargets[kHyroxStationOrder[i]]?.$3, + goalSec: goalSec?[kHyroxStationOrder[i]], + ), + ], + ); + } + + /// Free-form training: any ordered subset/repetition of stations (e.g. a + /// single-station drill or a 4-round opener). + factory HyroxPlan.training(List segments) => + HyroxPlan(preset: 'hyrox_training', segments: segments); + + /// Free-form custom session. + factory HyroxPlan.custom(List segments) => + HyroxPlan(preset: 'custom', segments: segments); + + /// Structural validation. Returns a human-readable problem, or null when the + /// plan is storable. This is not a judgement about whether the workout is a + /// good idea — only whether the row can be interpreted later. + String? validate() { + if (segments.isEmpty) return 'a plan needs at least one segment'; + for (var i = 0; i < segments.length; i++) { + final s = segments[i]; + final r = s.roundIndex; + if (r != null && r <= 0) return 'a round index is positive or absent'; + final d = s.plannedDistanceM; + if (d != null && (!d.isFinite || d <= 0)) { + return 'a planned distance is finite and positive, or absent'; + } + final reps = s.plannedReps; + if (reps != null && reps <= 0) { + return 'a planned rep count is positive or absent'; + } + final cal = s.plannedCal; + if (cal != null && cal <= 0) { + return 'a planned calorie target is positive or absent'; + } + final g = s.goalSec; + if (g != null && g <= 0) return 'a goal time is positive or absent'; + } + if (_isFixedRacePreset(preset)) { + if (segments.length != kHyroxStationOrder.length) { + return 'a fixed race plan is exactly the eight stations in order'; + } + for (var i = 0; i < kHyroxStationOrder.length; i++) { + final s = segments[i]; + if (s.station != kHyroxStationOrder[i] || s.roundIndex != i + 1) { + return 'a fixed race plan is exactly the eight stations in order'; + } + } + } + return null; + } + + /// HYROX is an indoor arena event end to end — the runs are on an indoor + /// track — so no segment ever arms GPS. + bool get needsGps => false; + + Map toJson() => { + 'v': kHyroxPlanVersion, + 'preset': preset, + 'segments': [for (final s in segments) s.toJson()], + }; + + /// Null when the payload is not a plan this build can read. Callers treat + /// that as "unknown plan", never as an empty race. + static HyroxPlan? fromJson(Map j) { + final rawV = j['v']; + final v = rawV is num ? rawV.toInt() : null; + if (v == null || v < 1 || v > kHyroxPlanVersion) return null; + final preset = j['preset']; + final raw = j['segments']; + if (preset is! String || raw is! List) return null; + final segments = []; + for (final e in raw) { + if (e is! Map) return null; + final s = HyroxSegmentPlan.fromJson(e.cast()); + if (s == null) return null; + segments.add(s); + } + return HyroxPlan(preset: preset, segments: segments); + } +} diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart new file mode 100644 index 000000000..fc2c7eb9d --- /dev/null +++ b/lib/data/hyrox_store.dart @@ -0,0 +1,1492 @@ +// HYROX storage — schema, row models and the CRUD seam. +// +// Parent/child on purpose. `hyrox_session` owns the frozen plan and the +// lifecycle; `hyrox_segment` owns the timed boundaries. One segment per +// ROUND (1 km run + station, see hyrox_plan.dart), so a race is exactly eight +// segments with `round_index` 1..8; round 8 runs to the finish line and +// includes the final 1 km run. A round segment MAY also get a real `sessions` +// row (workout_id) from the live wiring, so routes, zones, splits and Health +// export keep their single existing code path. +// +// THE TWO INVARIANTS THIS FILE ENFORCES, INDEPENDENT OF THE STATE MACHINE: +// +// 1. FINALIZED MEANS IMMUTABLE. `finalize()` stamps `finalized_at`; every +// later write to that session or its segments throws (including its note). +// A race that has been saved is a record, and a screen must not be able to +// quietly rewrite it. Boundary corrections belong to the not-yet-finalized +// session, and a future "re-derive" flow gets a NEW row, exactly like +// `day_result`'s immutable-per-version rule. +// +// 2. SEGMENTS CANNOT OVERLAP OR RUN BACKWARDS. A segment's end is at or after +// its start, and neighbours meet at one instant or leave a gap: segment +// i's end is at or before segment i+1's start. That is what a single +// "next" tap on the band means. A gap is a PAUSE (station setup, a breather +// between rounds), reported separately from moving time — never billed as +// workout time. +// +// Units follow the `sessions` table: segment boundaries are epoch SECONDS, +// bookkeeping stamps (created_at / updated_at / finalized_at) are epoch +// MILLISECONDS. Mixing them is the classic silent bug, so the row models name +// the unit in every field. + +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; + +import 'hyrox_plan.dart'; + +/// schemaVersion 62. Parent before child on every create. (Rung 60 belongs to +/// the triathlon storage PR; rung 61 created these tables, rung 62 adds the +/// reconcile-exclusion column.) +Future createHyroxTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS hyrox_session ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + preset TEXT NOT NULL, + status TEXT NOT NULL, + plan_json TEXT NOT NULL, + start_ts INTEGER, + end_ts INTEGER, + note TEXT NOT NULL DEFAULT '', + finalized_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + excluded_taps_json TEXT NOT NULL DEFAULT '[]' + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_session_status ' + 'ON hyrox_session(status)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_session_start ' + 'ON hyrox_session(start_ts)', + ); + await db.execute(''' + CREATE TABLE IF NOT EXISTS hyrox_segment ( + session_id TEXT NOT NULL, + idx INTEGER NOT NULL, + kind TEXT NOT NULL, + station TEXT NOT NULL, + round_index INTEGER, + planned_distance_m REAL, + planned_reps INTEGER, + planned_cal INTEGER, + goal_sec INTEGER, + start_ts INTEGER, + end_ts INTEGER, + boundary_source TEXT NOT NULL DEFAULT 'unknown', + workout_id TEXT, + distance_m REAL, + distance_source TEXT, + hr_source TEXT, + PRIMARY KEY (session_id, idx) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_segment_workout ' + 'ON hyrox_segment(workout_id)', + ); +} + +/// How a segment boundary was established. `gesture` is a live band tap, +/// `phone` an on-screen button, `manual` a boundary the user moved afterwards, +/// `posthoc` a boundary reconstructed from band history or suggested by the +/// timeline — i.e. NOT a measurement of when the athlete actually changed +/// station, and therefore never to be presented as one. `unknown` is the +/// honest default for a row this build did not write. +const List kHyroxBoundarySources = [ + 'gesture', + 'phone', + 'manual', + 'posthoc', + 'unknown', +]; + +class HyroxSegment { + const HyroxSegment({ + required this.sessionId, + required this.idx, + required this.kind, + required this.station, + this.roundIndex, + this.plannedDistanceM, + this.plannedReps, + this.plannedCal, + this.goalSec, + this.startTs, + this.endTs, + this.boundarySource = 'unknown', + this.workoutId, + this.distanceM, + this.distanceSource, + this.hrSource, + }); + + final String sessionId; + final int idx; + final HyroxSegmentKind kind; + + /// The station this round ends with. + final HyroxStation station; + + /// 1..8 in a fixed race; null in a free-form plan. + final int? roundIndex; + + /// Planned targets frozen from the plan. A PLAN, never a measurement. + final double? plannedDistanceM; + final int? plannedReps; + final int? plannedCal; + final int? goalSec; + + /// Epoch SECONDS. Null until the segment is entered / left. + final int? startTs; + final int? endTs; + + final String boundarySource; + + /// The linked `sessions.id` for a round segment, null when not banked yet. + final String? workoutId; + + /// MEASURED or explicitly user-entered distance in metres. Null means "we do + /// not know" and the UI shows "—". It is NEVER copied from + /// [plannedDistanceM]: a plan is not a measurement. + final double? distanceM; + + /// 'gps' | 'user' | null. Null whenever [distanceM] is null — a distance + /// without a source is exactly the number this repo refuses to show. + final String? distanceSource; + + /// 'band' | 'strap' | null: which heart-rate source the segment used. + final String? hrSource; + + bool get isOpen => startTs != null && endTs == null; + + /// Seconds in the segment, or null while it is still open. Zero-length + /// segments are legal (an instant tap-through), negative ones are not. + int? get durationSec => + startTs == null || endTs == null ? null : endTs! - startTs!; + + HyroxSegment copyWith({ + int? roundIndex, + double? plannedDistanceM, + int? plannedReps, + int? plannedCal, + int? goalSec, + int? startTs, + int? endTs, + String? boundarySource, + String? workoutId, + double? distanceM, + String? distanceSource, + String? hrSource, + bool clearEnd = false, + bool clearStart = false, + }) { + return HyroxSegment( + sessionId: sessionId, + idx: idx, + kind: kind, + station: station, + roundIndex: roundIndex ?? this.roundIndex, + plannedDistanceM: plannedDistanceM ?? this.plannedDistanceM, + plannedReps: plannedReps ?? this.plannedReps, + plannedCal: plannedCal ?? this.plannedCal, + goalSec: goalSec ?? this.goalSec, + startTs: clearStart ? null : (startTs ?? this.startTs), + endTs: clearEnd ? null : (endTs ?? this.endTs), + boundarySource: boundarySource ?? this.boundarySource, + workoutId: workoutId ?? this.workoutId, + distanceM: distanceM ?? this.distanceM, + distanceSource: distanceSource ?? this.distanceSource, + hrSource: hrSource ?? this.hrSource, + ); + } + + Map toRow() => { + 'session_id': sessionId, + 'idx': idx, + 'kind': kind.key, + 'station': station.key, + 'round_index': roundIndex, + 'planned_distance_m': plannedDistanceM, + 'planned_reps': plannedReps, + 'planned_cal': plannedCal, + 'goal_sec': goalSec, + 'start_ts': startTs, + 'end_ts': endTs, + 'boundary_source': boundarySource, + 'workout_id': workoutId, + 'distance_m': distanceM, + 'distance_source': distanceSource, + 'hr_source': hrSource, + }; + + static HyroxSegment? fromRow(Map r) { + final kind = HyroxSegmentKindX.fromKey(r['kind'] as String?); + final station = HyroxStationX.fromKey(r['station'] as String?); + if (kind == null || station == null) return null; + return HyroxSegment( + sessionId: r['session_id'] as String, + idx: (r['idx'] as num).toInt(), + kind: kind, + station: station, + roundIndex: (r['round_index'] as num?)?.toInt(), + plannedDistanceM: (r['planned_distance_m'] as num?)?.toDouble(), + plannedReps: (r['planned_reps'] as num?)?.toInt(), + plannedCal: (r['planned_cal'] as num?)?.toInt(), + goalSec: (r['goal_sec'] as num?)?.toInt(), + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + boundarySource: r['boundary_source'] as String? ?? 'unknown', + workoutId: r['workout_id'] as String?, + distanceM: (r['distance_m'] as num?)?.toDouble(), + distanceSource: r['distance_source'] as String?, + hrSource: r['hr_source'] as String?, + ); + } +} + +class HyroxSession { + const HyroxSession({ + required this.id, + required this.kind, + required this.preset, + required this.status, + required this.plan, + this.startTs, + this.endTs, + this.note = '', + this.finalizedAt, + required this.createdAt, + required this.updatedAt, + this.segments = const [], + }); + + final String id; + + /// 'race' | 'training'. + final String kind; + final String preset; + + /// 'armed' | 'active' | 'finished' | 'finalized' | 'discarded'. The state + /// machine owns transitions; storage only refuses to write a finalized row. + final String status; + + /// The frozen plan. Null only when a row was written by a build whose plan + /// format this one cannot read — callers show "unknown plan", not a guess. + final HyroxPlan? plan; + + /// Epoch SECONDS, mirroring the first/last segment boundary. + final int? startTs; + final int? endTs; + + final String note; + + /// Epoch MILLISECONDS. Non-null makes the session and its segments read-only. + final int? finalizedAt; + + /// Epoch MILLISECONDS. + final int createdAt; + final int updatedAt; + + final List segments; + + bool get isFinalized => finalizedAt != null; + + /// 'armed' | 'active' | 'finished' | 'abandoned' | 'discarded' | 'finalized'. + static const statuses = { + 'armed', + 'active', + 'finished', + 'abandoned', + 'discarded', + 'finalized', + }; + + /// Sum of closed segment durations = time actually in a round. + /// Null when no segment is closed yet — a pause or an open tail is never + /// billed as workout time. + int? get movingSec { + final closed = [ + for (final s in segments) + if (s.startTs != null && s.endTs != null) s.endTs! - s.startTs!, + ]; + return closed.isEmpty ? null : closed.reduce((a, b) => a + b); + } + + /// Total wall-clock time of the session (start to last end). INCLUDES any + /// pauses between segments — that is the difference from [movingSec]. + int? get totalSec { + if (startTs == null || endTs == null) return null; + return endTs! - startTs!; + } + + /// Pauses between segments, in seconds. Always >= 0. + int? get pauseSec { + final t = totalSec; + final m = movingSec; + if (t == null || m == null) return null; + return t - m; + } + + Map toRow() => { + 'id': id, + 'kind': kind, + 'preset': preset, + 'status': status, + 'plan_json': plan == null ? '{}' : jsonEncode(plan!.toJson()), + 'start_ts': startTs, + 'end_ts': endTs, + 'note': note, + 'finalized_at': finalizedAt, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; + + static HyroxSession fromRow(Map r) { + HyroxPlan? plan; + try { + final decoded = jsonDecode(r['plan_json'] as String? ?? '{}'); + if (decoded is Map) { + plan = HyroxPlan.fromJson(decoded.cast()); + } + } on FormatException { + plan = null; + } catch (_) { + // A persisted payload that is not even a JSON map (or contains + // wrong-typed fields) is an unreadable plan, not a crash. + plan = null; + } + return HyroxSession( + id: r['id'] as String, + kind: r['kind'] as String? ?? 'race', + preset: r['preset'] as String? ?? 'custom', + status: r['status'] as String? ?? 'armed', + plan: plan, + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + note: r['note'] as String? ?? '', + finalizedAt: (r['finalized_at'] as num?)?.toInt(), + createdAt: (r['created_at'] as num?)?.toInt() ?? 0, + updatedAt: (r['updated_at'] as num?)?.toInt() ?? 0, + ); + } +} + +/// One boundary derived from the strap's stored taps (see the reconciler). +/// +/// Lives in the data layer so storage never has to import a state-layer file: +/// the dependency runs one way, reconciler -> store. +class ReconcileBoundary { + const ReconcileBoundary(this.atSec, this.taps, this.kind, this.station); + + /// Strap time of the FIRST tap of the accepted cluster. + final int atSec; + final int taps; + + /// The segment the cluster closes; its successor starts at [atSec]. + final HyroxSegmentKind kind; + + /// The station of the segment the cluster closes. + final HyroxStation station; + + @override + String toString() => + 'ReconcileBoundary($kind/${station.key} @$atSec, $taps taps)'; +} + +/// Thrown when a write targets a finalized session, or would make segments +/// overlap. Deliberately loud: a silent no-op here reads as "the race saved". +class HyroxWriteError implements Exception { + HyroxWriteError(this.message); + final String message; + @override + String toString() => 'HyroxWriteError: $message'; +} + +class HyroxDb { + HyroxDb._(); + + /// Persist a new ARMED session with its planned segments in one transaction. + /// Nothing here starts a clock: arming is a choice, not a measurement. + static Future create(Database db, HyroxSession session) async { + final plan0 = session.plan; + if (plan0 == null) throw HyroxWriteError('a session needs a readable plan'); + final problem = plan0.validate(); + if (problem != null) throw HyroxWriteError(problem); + if (session.preset != plan0.preset) { + throw HyroxWriteError( + 'session preset ${session.preset} does not match the plan preset ' + '${plan0.preset}', + ); + } + if (session.status != 'armed') { + throw HyroxWriteError('a new session is created armed, not ${session.status}'); + } + if (session.isFinalized || session.startTs != null || session.endTs != null) { + throw HyroxWriteError('create cannot pre-fill session bounds'); + } + if (session.segments.isNotEmpty && + session.segments.length != session.plan!.segments.length) { + throw HyroxWriteError('segment rows do not match the plan'); + } + await db.transaction((txn) async { + final clash = await txn.query( + 'hyrox_session', + columns: ['id'], + where: 'id = ?', + whereArgs: [session.id], + limit: 1, + ); + if (clash.isNotEmpty) { + throw HyroxWriteError('session ${session.id} already exists'); + } + await txn.insert('hyrox_session', session.toRow()); + final plan = session.plan!; + for (var i = 0; i < plan.segments.length; i++) { + final p = plan.segments[i]; + final supplied = i < session.segments.length ? session.segments[i] : null; + final seg = supplied ?? + HyroxSegment( + sessionId: session.id, + idx: i, + kind: p.kind, + station: p.station, + roundIndex: p.roundIndex, + plannedDistanceM: p.plannedDistanceM, + plannedReps: p.plannedReps, + plannedCal: p.plannedCal, + goalSec: p.goalSec, + ); + if (seg.sessionId != session.id || seg.idx != i || seg.kind != p.kind || + seg.station != p.station || seg.roundIndex != p.roundIndex || + seg.plannedDistanceM != p.plannedDistanceM || + seg.plannedReps != p.plannedReps || seg.plannedCal != p.plannedCal || + seg.goalSec != p.goalSec) { + throw HyroxWriteError('segment $i does not match the plan'); + } + if (seg.startTs != null || + seg.endTs != null || + seg.workoutId != null || + seg.distanceM != null || + seg.distanceSource != null) { + throw HyroxWriteError( + 'create stores a plan; segment $i already carries history', + ); + } + _checkSegmentShape(seg); + if (i > 0 && session.segments.isNotEmpty) { + _checkNeighbour(seg, session.segments[i - 1]); + } + await txn.insert('hyrox_segment', seg.toRow()); + } + }); + } + + static Future byId(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_session', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, HyroxSession.fromRow(rows.first)); + } + + static Future> segments(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + orderBy: 'idx ASC', + ); + return [for (final r in rows) HyroxSegment.fromRow(r)].whereType().toList(); + } + + /// Strict variant for WRITERS: segments() silently drops rows an older + /// build cannot parse, which would make a writer rewrite the wrong rows. + /// A write path must see the whole stored timeline or refuse. + static Future> segmentsStrict( + DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + orderBy: 'idx ASC', + ); + final out = []; + for (var i = 0; i < rows.length; i++) { + final seg = HyroxSegment.fromRow(rows[i]); + if (seg == null || seg.idx != i) { + throw HyroxWriteError( + 'segment row idx=${rows[i]['idx']} of session $id is unreadable ' + 'or the stored timeline is incomplete; refusing to write on a ' + 'partial timeline', + ); + } + out.add(seg); + } + // Completeness against the frozen plan: a missing row would make a + // writer treat the last present row as the final round. + final sess = await db.query( + 'hyrox_session', + columns: ['plan_json'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (sess.isNotEmpty) { + final plan = HyroxPlan.fromJson( + jsonDecode('${sess.first['plan_json']}') as Map? ?? const {}); + if (plan != null && out.length != plan.segments.length) { + throw HyroxWriteError( + 'session $id stores ${out.length} of ${plan.segments.length} planned ' + 'segments; refusing to write on a partial timeline', + ); + } + } + return out; + } + + static Future _withSegments(DatabaseExecutor db, HyroxSession s) async { + final segs = await segments(db, s.id); + return HyroxSession( + id: s.id, + kind: s.kind, + preset: s.preset, + status: s.status, + plan: s.plan, + startTs: s.startTs, + endTs: s.endTs, + note: s.note, + finalizedAt: s.finalizedAt, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + segments: segs, + ); + } + + /// The ONE session a relaunch must resume: not finalized, not discarded, + /// newest first. Returning more than the newest would let a stale armed row + /// shadow the race actually in progress, so callers get a single answer. + static Future activeSession(Database db) async { + final rows = await db.query( + 'hyrox_session', + where: "finalized_at IS NULL AND status != 'discarded'", + orderBy: 'created_at DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, HyroxSession.fromRow(rows.first)); + } + + /// Finalized sessions at [preset], newest first — the comparison set for + /// "total time vs your previous races". + static Future> finalized( + Database db, { + String? preset, + int limit = 50, + }) async { + final rows = await db.query( + 'hyrox_session', + where: preset == null ? 'finalized_at IS NOT NULL' : 'finalized_at IS NOT NULL AND preset = ?', + whereArgs: preset == null ? null : [preset], + orderBy: 'start_ts DESC', + limit: limit, + ); + final out = []; + for (final r in rows) { + out.add(await _withSegments(db, HyroxSession.fromRow(r))); + } + return out; + } + + /// Write one segment's boundaries. + /// + /// Reads the plan and the neighbours INSIDE the same transaction, so the + /// checks cannot be made against a stale in-memory copy. A segment's INDEX + /// never changes here: this method only moves/records the boundaries of a + /// row the plan already created. Moving a boundary that neighbours share is + /// [adjustBoundary]'s job, because both sides have to move together. + static Future saveSegment(Database db, HyroxSegment segment) async { + _checkSegmentShape(segment); + await db.transaction((txn) async { + await _requireWritable(txn, segment.sessionId); + final existing = await segmentsStrict(txn, segment.sessionId); + final matching = existing.where((s) => s.idx == segment.idx).toList(); + if (matching.isEmpty) { + throw HyroxWriteError('segment ${segment.idx} is not in the plan'); + } + final original = matching.single; + if (segment.kind != original.kind || + segment.station != original.station || + segment.roundIndex != original.roundIndex || + segment.plannedDistanceM != original.plannedDistanceM || + segment.plannedReps != original.plannedReps || + segment.plannedCal != original.plannedCal || + segment.goalSec != original.goalSec) { + throw HyroxWriteError('the planned segment is immutable'); + } + // The workout link is set ONLY through linkWorkout, which enforces the + // no-duplicate rule — saveSegment must not replace, duplicate or clear it. + if (segment.workoutId != original.workoutId) { + throw HyroxWriteError( + 'the workout link is managed by linkWorkout, not saveSegment', + ); + } + for (final other in existing) { + if (other.idx != segment.idx) _checkNeighbour(segment, other); + } + await txn.update( + 'hyrox_segment', + segment.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + _validateTimeline(await segmentsStrict(txn, segment.sessionId)); + await _recomputeSessionBounds(txn, segment.sessionId); + }); + } + + /// Opens segment 0 at [ts] and marks the session active. + /// + /// Fails when the session already has a boundary: an armed session may start + /// exactly once, and a second start is a bug in the caller, not a rewind. + static Future startSegment({ + required Database db, + required String sessionId, + required int ts, + required String boundarySource, + }) async { + if (ts <= 0) throw HyroxWriteError('a start needs a real timestamp'); + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); + if (segs.any((s) => s.startTs != null || s.endTs != null)) { + throw HyroxWriteError('the session has already started'); + } + await txn.update( + 'hyrox_segment', + segs.first + .copyWith(startTs: ts, boundarySource: boundarySource) + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + }); + } + + /// Atomically closes segment [currentIdx] and opens [currentIdx + 1] at [ts]. + /// + /// Both sides move or neither does. Validation is deliberately strict: the + /// current segment must be open, the next one must not have started, and + /// [ts] must not run backwards past either neighbouring boundary. + static Future transitionSegment({ + required Database db, + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _transitionInTx( + txn, + sessionId: sessionId, + currentIdx: currentIdx, + transitionTs: transitionTs, + boundarySource: boundarySource, + ); + }); + } + + /// Closes the FINAL segment at [ts] and marks the session finished. + /// + /// Only the last segment may be finished: closing an earlier one would leave + /// later segments unstarted while the session claims to be over, and + /// [finalize] — which requires every segment closed — could never accept it. + /// A DNF needs an explicit model (the open cut segment), not a silent + /// half-race. + static Future finishSegment({ + required Database db, + required String sessionId, + required int ts, + required String boundarySource, + }) async { + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + final open = segs.where((s) => s.isOpen).toList(); + if (open.length != 1) { + throw HyroxWriteError('exactly one segment must be open to finish'); + } + final last = open.single; + if (last.startTs == null || ts < last.startTs!) { + throw HyroxWriteError('a segment cannot end before it starts'); + } + if (last.idx != segs.length - 1) { + throw HyroxWriteError( + 'finish closes the final segment; close segment ${last.idx} with a ' + 'transition instead', + ); + } + await txn.update( + 'hyrox_segment', + last.copyWith(endTs: ts, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, last.idx], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'finished'); + }); + } + + /// Moves the race start: clears every boundary of the not-yet-finalized + /// session and restarts segment 0 at [newTs]. + /// + /// A false triple-tap at the start line starts the race wrongly, and because + /// the session may only start once, no existing write can heal that. This + /// is the explicit correction path: it is only legal while NO segment has + /// ended yet (a race with closed splits keeps its history), and the new + /// start is stamped 'manual'. + static Future adjustStart({ + required Database db, + required String sessionId, + required int newTs, + }) async { + if (newTs <= 0) { + throw HyroxWriteError('a start needs a real timestamp'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); + if (segs.any((s) => s.endTs != null)) { + throw HyroxWriteError( + 'a segment already ended — the start can no longer be moved', + ); + } + if (segs.any((s) => s.workoutId != null)) { + throw HyroxWriteError( + 'a segment is linked to a workout — unlink it before moving the start', + ); + } + for (var i = 0; i < segs.length; i++) { + final cleared = segs[i].copyWith( + clearStart: true, + clearEnd: true, + boundarySource: i == 0 ? 'manual' : segs[i].boundarySource, + ); + await txn.update( + 'hyrox_segment', + cleared.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, segs[i].idx], + ); + } + final first = (await segmentsStrict(txn, sessionId)).first; + await txn.update( + 'hyrox_segment', + first.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + }); + } + + /// Atomically moves a boundary shared by segment [idx] and [idx + 1]. + /// + /// Separate [saveSegment] calls cannot do this: as soon as both sides have + /// timestamps, changing one alone breaks the contiguity rule and is refused. + /// Both rows must already meet, and [newTs] must stay inside both segments. + static Future adjustBoundary({ + required Database db, + required String sessionId, + required int idx, + required int newTs, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + if (idx < 0 || idx + 1 >= segs.length) { + throw HyroxWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + final wasStart = right.startTs; + final wasEnd = left.endTs; + if (wasStart == null || wasEnd == null || wasStart != wasEnd) { + throw HyroxWriteError('segments $idx and ${idx + 1} do not meet'); + } + if (left.startTs != null && newTs < left.startTs!) { + throw HyroxWriteError('the boundary would precede segment $idx'); + } + if (right.endTs != null && newTs > right.endTs!) { + throw HyroxWriteError('the boundary would follow segment ${idx + 1}'); + } + // Provenance follows the actor: a boundary moved by hand is 'manual' on + // both rows. Keeping 'gesture' here would claim the band recorded a time + // the athlete actually corrected. + await txn.update( + 'hyrox_segment', + left.copyWith(endTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'hyrox_segment', + right.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + }); + } + + /// Removes the boundary after segment [idx]: segments [idx] and [idx + 1] + /// merge into one, the successor's boundaries are cleared, and the merged + /// segment keeps the earlier start and the later end. Both rows must have + /// started, and the boundary is stamped 'manual' on the survivor. + /// + /// This is the correction for a false triple-tap mid-race that split one + /// round in two: [adjustBoundary] can only move a boundary, not delete + /// one, and after [finalize] the record is immutable by design. + static Future removeBoundary({ + required Database db, + required String sessionId, + required int idx, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + if (idx < 0 || idx + 1 >= segs.length) { + throw HyroxWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + if (left.startTs == null || right.startTs == null) { + throw HyroxWriteError( + 'segments $idx and ${idx + 1} have not both started', + ); + } + if (left.endTs != null && left.endTs != right.startTs) { + throw HyroxWriteError('segments $idx and ${idx + 1} do not meet'); + } + // Merging is only meaningful while the successor is the newest started + // segment: clearing its start would break the started-prefix rule the + // moment any later segment has already begun. + if (segs.any((s) => s.idx > idx + 1 && s.startTs != null)) { + throw HyroxWriteError( + 'a later segment already started — the boundary cannot be removed', + ); + } + if (left.workoutId != null || right.workoutId != null) { + throw HyroxWriteError( + 'a segment is linked to a workout — unlink it before removing the boundary', + ); + } + // Merging into the final segment when it is already CLOSED would leave + // the session 'finished' with an unstarted last segment — no write could + // ever progress or finalize it again. Move the boundary instead. + if (right.idx == segs.length - 1 && right.endTs != null) { + throw HyroxWriteError( + 'the final segment is closed — move the boundary instead of removing it', + ); + } + final merged = right.endTs == null + ? left.copyWith(clearEnd: true, boundarySource: 'manual') + : left.copyWith(endTs: right.endTs, boundarySource: 'manual'); + await txn.update( + 'hyrox_segment', + merged.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'hyrox_segment', + right.copyWith(clearStart: true, clearEnd: true).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + // Persist the removed boundary anchor so a later reconcile does not + // re-derive the same boundary from the still-present band taps. + await _addExcludedTap(txn, sessionId, right.startTs!); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + }); + } + + /// Applies a whole offline-derived boundary chain in ONE transaction. + /// + /// Used by the reconciler. The FIRST boundary starts the race when nothing + /// has started yet; every later one closes the current segment and opens the + /// next; a boundary at the very end closes the last segment. A failure + /// anywhere rolls the whole chain back, so a race is never left half-marked. + /// + /// Rejects a boundary that would not advance, so a stale or repeated event + /// log cannot rewind a session. + static Future applyOfflineBoundariesInTx( + DatabaseExecutor txn, { + required String sessionId, + required List boundaries, + }) async { + if (boundaries.isEmpty) return; + await _requireWritable(txn, sessionId); + var segs = await segmentsStrict(txn, sessionId); + if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); + for (var i = 0; i < boundaries.length; i++) { + final b = boundaries[i]; + segs = await segmentsStrict(txn, sessionId); + final open = segs.where((s) => s.isOpen).toList(); + if (open.isEmpty) { + // Nothing running: this boundary STARTS the race, and only as the + // first one. Anything later is a log we cannot place. + if (i != 0 || + segs.any((s) => s.startTs != null) || + b.kind != segs.first.kind) { + throw HyroxWriteError('no open segment to close at ${b.atSec}'); + } + await txn.update( + 'hyrox_segment', + segs.first + .copyWith(startTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(segs = await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + continue; + } + if (open.length != 1) { + throw HyroxWriteError('more than one open segment'); + } + final current = open.single; + if (b.kind != current.kind || b.station != current.station) { + throw HyroxWriteError('boundary does not close the open segment'); + } + final hasNext = current.idx + 1 < segs.length; + if (!hasNext) { + // Closing the final segment ends the race, so nothing may follow it. + if (i != boundaries.length - 1) { + throw HyroxWriteError('boundaries follow the final segment'); + } + if (current.startTs == null || b.atSec < current.startTs!) { + throw HyroxWriteError( + 'the final segment would end before it started', + ); + } + await txn.update( + 'hyrox_segment', + current + .copyWith(endTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, current.idx], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'finished'); + return; + } + await _transitionInTx( + txn, + sessionId: sessionId, + currentIdx: current.idx, + transitionTs: b.atSec, + boundarySource: 'posthoc', + ); + } + // A chain that never reached the last segment leaves it running; the + // session bounds are still correct for what is known. + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + } + + /// Link a round segment to the `sessions` row the live workout banked. + /// + /// Every HYROX segment is a real round (there is no transition kind), so + /// unlike the multisport store there is no transition refusal here — the + /// null check on [HyroxSegment.fromRow] already refuses an unreadable row. + static Future linkWorkout( + Database db, + String sessionId, + int idx, + String workoutId, + ) async { + if (workoutId.trim().isEmpty) { + throw HyroxWriteError('a workout link needs a workout id'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final rows = await txn.query( + 'hyrox_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + limit: 1, + ); + if (rows.isEmpty) { + throw HyroxWriteError('segment $idx is not in session $sessionId'); + } + final seg = HyroxSegment.fromRow(rows.first); + if (seg == null) { + throw HyroxWriteError('segment $idx has an unknown kind or station'); + } + final existing = await txn.query( + 'hyrox_segment', + columns: ['session_id', 'idx'], + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [workoutId, sessionId, idx], + ); + if (existing.isNotEmpty) { + throw HyroxWriteError('workout $workoutId is already linked'); + } + final updated = await txn.update( + 'hyrox_segment', + {'workout_id': workoutId}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + if (updated != 1) { + throw HyroxWriteError('workout link matched no segment row'); + } + await _touch(txn, sessionId); + }); + } + + /// Tap-cluster anchors the athlete manually removed via removeBoundary; + /// reconcile must never re-derive them. Stored as a JSON int list on the + /// session row (rung 62 column). + static Future> excludedTaps(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_session', + columns: ['excluded_taps_json'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return const []; + final raw = rows.first['excluded_taps_json']; + if (raw is! String || raw.isEmpty) return const []; + final decoded = jsonDecode(raw); + return decoded is List ? [for (final v in decoded) (v as num).toInt()] : const []; + } + + static Future _addExcludedTap( + DatabaseExecutor db, String id, int tapSec) async { + final cur = await excludedTaps(db, id); + if (cur.contains(tapSec)) return; + final next = [...cur, tapSec]..sort(); + await db.update( + 'hyrox_session', + {'excluded_taps_json': jsonEncode(next), 'updated_at': DateTime.now().millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Clear a workout link. Only allowed when no OTHER segment references the + /// same workout (the sessions row itself is left alone — deleting workout + /// data is the athlete's call from the workouts screen, not ours). + static Future unlinkWorkout( + Database db, + String sessionId, + int idx, + ) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final rows = await txn.query( + 'hyrox_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + limit: 1, + ); + if (rows.isEmpty) { + throw HyroxWriteError('segment $idx is not in session $sessionId'); + } + final seg = HyroxSegment.fromRow(rows.first); + if (seg == null || seg.workoutId == null) { + throw HyroxWriteError('segment $idx has no workout link to clear'); + } + final others = await txn.query( + 'hyrox_segment', + columns: ['session_id', 'idx'], + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [seg.workoutId, sessionId, idx], + ); + if (others.isNotEmpty) { + throw HyroxWriteError( + 'workout ${seg.workoutId} is still referenced by another segment', + ); + } + final updated = await txn.update( + 'hyrox_segment', + {'workout_id': null}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + if (updated != 1) { + throw HyroxWriteError('unlink matched no segment row'); + } + await _touch(txn, sessionId); + }); + } + + /// Note and lifecycle status. Refuses a finalized row, and never clears + /// `finalized_at` — finalization is one-way by design. + /// + /// Session bounds are NOT writable here: they are derived from the segments + /// by [_recomputeSessionBounds], and letting a screen set them would give the + /// same fact two sources. `finished`/`finalized` are set by their own calls. + static Future updateSession(Database db, HyroxSession session) async { + await db.transaction((txn) async { + await _requireWritable(txn, session.id); + if (session.isFinalized || session.status == 'finalized') { + throw HyroxWriteError('use finalize to finalize a session'); + } + const writable = {'armed', 'active', 'discarded', 'abandoned'}; + if (!writable.contains(session.status)) { + throw HyroxWriteError( + 'status ${session.status} is set by start/finish/finalize, not here', + ); + } + // Bounds are derived from the segments (_recomputeSessionBounds) and + // deliberately absent here: a screen must not be able to desync them. + final row = { + 'status': session.status, + 'note': session.note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + await txn.update( + 'hyrox_session', + row, + where: 'id = ?', + whereArgs: [session.id], + ); + }); + } + + /// Stamp finalization. Every segment must be CLOSED for a finished race, + /// otherwise the saved record would contain a split with no end — an open + /// segment is a live session, and finalizing it would freeze a lie. + static Future finalize(Database db, String id) async { + await db.transaction((txn) async { + await _requireWritable(txn, id); + final rows = await txn.query( + 'hyrox_session', + columns: ['status'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw HyroxWriteError('no such session: $id'); + final status = rows.first['status'] as String; + if (status != 'finished' && status != 'abandoned') { + throw HyroxWriteError( + 'only a finished or abandoned session can be finalized', + ); + } + final segs = await segmentsStrict(txn, id); + if (segs.isEmpty) throw HyroxWriteError('nothing to finalize'); + if (status == 'finished') { + for (final s in segs) { + if (s.startTs == null || s.endTs == null) { + throw HyroxWriteError('segment ${s.idx} is still open'); + } + } + } else { + // ABANDONED (DNF): exactly one segment may be open — the round the + // athlete stopped in. Its duration stays null; nothing is invented. + // Every segment before it must be closed, every segment after it + // must be untouched, and at least one segment must have started + // (otherwise this is a discard, not a race). + final open = segs.where((s) => s.isOpen).toList(); + final started = segs.where((s) => s.startTs != null).length; + if (started == 0) { + throw HyroxWriteError( + 'nothing started — discard the session instead of finalizing', + ); + } + if (open.length > 1) { + throw HyroxWriteError('more than one open segment'); + } + if (open.isNotEmpty) { + final cut = open.single; + if (segs.where((s) => s.idx < cut.idx && s.endTs == null).isNotEmpty) { + throw HyroxWriteError('an earlier segment is still open'); + } + if (segs.where((s) => s.idx > cut.idx && s.startTs != null).isNotEmpty) { + throw HyroxWriteError('a later segment already started'); + } + } + } + _validateTimeline(segs); + await txn.update( + 'hyrox_session', + { + 'status': 'finalized', + 'finalized_at': DateTime.now().millisecondsSinceEpoch, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + }); + } + + /// Deleting a race removes its segment rows with it; the linked `sessions` + /// rows are deleted by the caller, which owns that table's cascade (route, + /// splits, sets). Returns the workout ids so the caller can do exactly that. + static Future> delete(Database db, String id) async { + return db.transaction((txn) async { + final segs = await txn.query( + 'hyrox_segment', + columns: ['workout_id'], + where: 'session_id = ?', + whereArgs: [id], + ); + final workoutIds = [ + for (final r in segs) + if (r['workout_id'] is String) r['workout_id'] as String, + ]; + await txn.delete( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'hyrox_session', + where: 'id = ?', + whereArgs: [id], + ); + return workoutIds; + }); + } + + /// THE timeline rule, applied to the whole session and not just the row + /// that changed. + /// + /// Started segments form a PREFIX (segment 3 cannot start while segment 0 + /// is still a plan row), boundaries are MONOTONIC, and no two segments + /// OVERLAP. A gap between one segment's end and the next one's start is a + /// PAUSE, not a gap in the data: a workout can be interrupted for minutes + /// between stations, and a missed transition tap looks exactly like that. + /// The session then reports total and moving time separately, so a pause is + /// never silently billed as workout time. + static void _validateTimeline(List segs) { + var seenUnstarted = false; + var open = 0; + int? prevEnd; + for (final s in segs) { + final started = s.startTs != null; + final ended = s.endTs != null; + if (!started) { + seenUnstarted = true; + if (ended) { + throw HyroxWriteError('segment ${s.idx} ends before it starts'); + } + continue; + } + if (seenUnstarted) { + throw HyroxWriteError( + 'segment ${s.idx} starts while an earlier segment never did', + ); + } + if (ended && s.endTs! < s.startTs!) { + throw HyroxWriteError('segment ${s.idx} ends before it starts'); + } + if (prevEnd != null) { + if (s.startTs! < prevEnd) { + throw HyroxWriteError( + 'segment ${s.idx} overlaps its predecessor', + ); + } + // s.startTs == prevEnd is a direct transition; > is a pause. Both legal. + } + if (ended) { + prevEnd = s.endTs; + } else { + open++; + if (open > 1) { + throw HyroxWriteError('more than one segment is open'); + } + for (final later in segs) { + if (later.idx > s.idx && later.startTs != null) { + throw HyroxWriteError( + 'segment ${later.idx} starts while ${s.idx} is open', + ); + } + } + prevEnd = null; + } + } + } + + static void _checkBoundarySource(String source) { + if (!kHyroxBoundarySources.contains(source)) { + throw HyroxWriteError('unknown boundary source $source'); + } + } + + /// The transition body, callable from inside an existing transaction. + /// + /// Public [transitionSegment] wraps it; the offline reconciler uses it to + /// apply a whole chain of boundaries atomically instead of one commit each. + static Future _transitionInTx( + DatabaseExecutor txn, { + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + await _requireWritable(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); + final current = segs.where((s) => s.idx == currentIdx).toList(); + final next = segs.where((s) => s.idx == currentIdx + 1).toList(); + if (current.isEmpty || next.isEmpty) { + throw HyroxWriteError('segment $currentIdx has no successor'); + } + final a = current.single; + final b = next.single; + if (!a.isOpen) { + throw HyroxWriteError('segment $currentIdx is not open'); + } + if (b.startTs != null) { + throw HyroxWriteError('segment ${currentIdx + 1} has already started'); + } + if (a.startTs != null && transitionTs < a.startTs!) { + throw HyroxWriteError('a transition cannot precede its segment'); + } + await txn.update( + 'hyrox_segment', + a.copyWith(endTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx], + ); + await txn.update( + 'hyrox_segment', + b.copyWith(startTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx + 1], + ); + _validateTimeline(await segmentsStrict(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + } + + /// Derives the session bounds from its segments — one source of truth. + /// + /// With NO started segment both bounds are cleared, not kept at a stale + /// value; a bound is written only when a segment actually supports it. + /// [status] is left untouched unless the caller is changing the lifecycle. + static Future _recomputeSessionBounds( + DatabaseExecutor db, + String sessionId, { + String? status, + }) async { + final segs = await segments(db, sessionId); + int? start; + int? end; + for (final s in segs) { + if (s.startTs != null && (start == null || s.startTs! < start)) { + start = s.startTs; + } + if (s.endTs != null && (end == null || s.endTs! > end)) { + end = s.endTs; + } + } + final row = { + 'start_ts': start, + 'end_ts': end, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + if (status != null) row['status'] = status; + await db.update( + 'hyrox_session', + row, + where: 'id = ?', + whereArgs: [sessionId], + ); + } + + static Future _requireWritable(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_session', + columns: ['finalized_at', 'status'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw HyroxWriteError('no such session: $id'); + if (rows.first['finalized_at'] != null) { + throw HyroxWriteError('session $id is finalized and immutable'); + } + // A discarded session is a terminal local decision: no write may revive + // or mutate it. (Unfinalized `abandoned` stays writable on purpose — + // `finalize` explicitly accepts it.) + if (rows.first['status'] == 'discarded') { + throw HyroxWriteError('session $id is discarded and cannot be modified'); + } + } + + static Future _touch(DatabaseExecutor db, String id) async { + await db.update( + 'hyrox_session', + {'updated_at': DateTime.now().millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Fields that must hold for ANY segment row, planned or timed. + static void _checkSegmentShape(HyroxSegment s) { + if (s.distanceM == null && s.distanceSource != null) { + throw HyroxWriteError('a distance source without a distance'); + } + if (s.distanceM != null && (!s.distanceM!.isFinite || s.distanceM! < 0)) { + throw HyroxWriteError('a measured distance is finite and not negative'); + } + if (s.distanceM != null && s.distanceSource == null) { + throw HyroxWriteError('a distance without a source'); + } + if (s.startTs != null && s.endTs != null && s.endTs! < s.startTs!) { + throw HyroxWriteError('segment ends before it starts'); + } + if (!kHyroxBoundarySources.contains(s.boundarySource)) { + throw HyroxWriteError('unknown boundary source ${s.boundarySource}'); + } + } + + /// Neighbour rule for a single edit: the previous segment's end must not + /// lie AFTER this segment's start (overlap), and this segment's end must + /// not precede the next segment's start. Equal means a direct transition; + /// a gap means a pause. Both are legal now. + static void _checkNeighbour(HyroxSegment changed, HyroxSegment other) { + if (other.idx == changed.idx - 1) { + if (other.endTs != null && + changed.startTs != null && + other.endTs! > changed.startTs!) { + throw HyroxWriteError( + 'segment ${changed.idx} starts before ${other.idx} ended', + ); + } + } else if (other.idx == changed.idx + 1) { + if (changed.endTs != null && + other.startTs != null && + changed.endTs! > other.startTs!) { + throw HyroxWriteError( + 'segment ${other.idx} starts before ${changed.idx} ended', + ); + } + } + } +} diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index a97a59523..628e45da0 100644 --- a/lib/data/recent_slice.dart +++ b/lib/data/recent_slice.dart @@ -96,6 +96,10 @@ const Map kSliceTables = { 'activity_review_days': (SliceKeep.all, null, 'activity review state'), 'activity_review_meta': (SliceKeep.all, null, 'activity review state'), 'breathing_session': (SliceKeep.all, null, 'breathing sessions'), + 'hyrox_session': (SliceKeep.all, null, 'HYROX sessions'), + 'hyrox_segment': (SliceKeep.all, null, 'HYROX round segments'), + 'triathlon_session': (SliceKeep.all, null, 'triathlon sessions'), + 'triathlon_segment': (SliceKeep.all, null, 'triathlon segments'), 'exercise_def': (SliceKeep.all, null, 'exercise list'), // What the user logs: small. 'journal': (SliceKeep.all, null, 'journal'), diff --git a/lib/data/triathlon_plan.dart b/lib/data/triathlon_plan.dart new file mode 100644 index 000000000..27170632c --- /dev/null +++ b/lib/data/triathlon_plan.dart @@ -0,0 +1,300 @@ +// Triathlon / multisport PLAN — pure Dart, no Flutter, no sqflite. +// +// A plan is what the athlete chose BEFORE arming: the ordered segments, the +// environment of each discipline, the planned distances and the optional goal +// times. It is frozen into `triathlon_session.plan_json` at arm time and never +// edited afterwards, so a finished race still says what was actually attempted +// rather than what the preset means today. +// +// Two rules shape this file: +// +// * A PLANNED distance is not a MEASURED distance. The preset table below is +// a target the user typed or accepted, never something the band measured, +// so it lives here and not in `triathlon_segment.distance_m`. +// * Environment is PER SEGMENT, because a real race can be mixed (open-water +// swim, outdoor bike, treadmill run in a winter training block) and the +// honest metric set differs per segment, not per session. Indoor segments +// must never inherit a route or a pace from an outdoor sibling. + +import '../gps/route_types.dart'; + +/// Current serialization version of [TriathlonPlan.toJson]. Readers refuse a +/// newer major version instead of silently dropping fields they do not know. +const int kTriathlonPlanVersion = 1; + +enum TriSegmentKind { swim, t1, bike, t2, run } + +enum TriEnvironment { indoor, outdoor } + +extension TriSegmentKindX on TriSegmentKind { + String get key => switch (this) { + TriSegmentKind.swim => 'swim', + TriSegmentKind.t1 => 't1', + TriSegmentKind.bike => 'bike', + TriSegmentKind.t2 => 't2', + TriSegmentKind.run => 'run', + }; + + /// T1/T2 are first-class timed segments, but they are not a discipline: no + /// route, no distance, no `sessions` row of their own. + bool get isTransition => + this == TriSegmentKind.t1 || this == TriSegmentKind.t2; + + static TriSegmentKind? fromKey(String? key) => switch (key) { + 'swim' => TriSegmentKind.swim, + 't1' => TriSegmentKind.t1, + 'bike' => TriSegmentKind.bike, + 't2' => TriSegmentKind.t2, + 'run' => TriSegmentKind.run, + _ => null, + }; +} + +extension TriEnvironmentX on TriEnvironment { + String get key => this == TriEnvironment.indoor ? 'indoor' : 'outdoor'; + + static TriEnvironment? fromKey(String? key) => switch (key) { + 'indoor' => TriEnvironment.indoor, + 'outdoor' => TriEnvironment.outdoor, + _ => null, + }; +} + +/// The `sessions.type` a timed segment is banked as, or null for a transition. +/// +/// These are the EXISTING catalogue keys, deliberately not new ones: banking a +/// bike leg as `cycling` is what lets the existing route, zone, split and +/// Health-export code read it without a second code path. A swim maps to +/// `swimming` in BOTH environments because this app records no swim route — +/// open water included, since a wrist GPS fix under water is not a measurement +/// this stack can stand behind. +String? triathlonSessionType(TriSegmentKind kind, TriEnvironment? env) { + if (kind.isTransition) return null; + return switch (kind) { + TriSegmentKind.swim => 'swimming', + TriSegmentKind.bike => + env == TriEnvironment.indoor ? 'indoor_bike' : 'cycling', + TriSegmentKind.run => + env == TriEnvironment.indoor ? 'treadmill' : 'running', + _ => null, + }; +} + +/// True when this segment may arm GPS. Derived from the shared route gate, so +/// a new catalogue entry cannot drift away from it (pinned by test). +bool triathlonSegmentRecordsRoute(TriSegmentKind kind, TriEnvironment? env) { + final type = triathlonSessionType(kind, env); + return type != null && typeRecordsRoute(type); +} + +/// Preset keys. Generic distance names only — no event-brand marks, see +/// TRADEMARKS.md. `custom` keeps the race segment order with user distances; +/// `training` is a free ordered list (brick, aquathlon, single discipline). +const List kTriathlonRacePresets = [ + 'sprint', + 'olympic', + 'half_distance', + 'full_distance', +]; + +/// Planned distances in METRES for (swim, bike, run). These are the commonly +/// published nominal distances for each format, stored as an editable default: +/// the user's own race may differ, which is exactly why the plan is frozen per +/// session instead of looked up at display time. +const Map> kTriathlonPresetDistancesM = { + 'sprint': [750, 20000, 5000], + 'olympic': [1500, 40000, 10000], + 'half_distance': [1900, 90000, 21100], + 'full_distance': [3800, 180000, 42200], +}; + +class TriSegmentPlan { + const TriSegmentPlan({ + required this.kind, + this.environment, + this.plannedDistanceM, + this.goalSec, + }); + + final TriSegmentKind kind; + + /// Null for transitions (a transition has no environment of its own) and + /// null when the user genuinely did not say. Never defaulted on read. + final TriEnvironment? environment; + + /// Target distance in metres, or null when there is no target. A transition + /// never has one. This is a PLAN, not a measurement. + final double? plannedDistanceM; + + /// Optional goal time in seconds. Null = no goal, not zero. + final int? goalSec; + + Map toJson() => { + 'kind': kind.key, + 'environment': environment?.key, + 'planned_distance_m': plannedDistanceM, + 'goal_sec': goalSec, + }; + + static TriSegmentPlan? fromJson(Map j) { + final rawKind = j['kind']; + final kind = TriSegmentKindX.fromKey(rawKind is String ? rawKind : null); + if (kind == null) return null; + final env = j['environment']; + final dist = j['planned_distance_m']; + final goal = j['goal_sec']; + return TriSegmentPlan( + kind: kind, + environment: TriEnvironmentX.fromKey(env is String ? env : null), + plannedDistanceM: dist is num ? dist.toDouble() : null, + goalSec: goal is num ? goal.toInt() : null, + ); + } +} + +class TriathlonPlan { + const TriathlonPlan({ + required this.preset, + required this.segments, + }); + + /// 'sprint' | 'olympic' | 'half_distance' | 'full_distance' | 'custom' | + /// 'training'. Free-form on disk on purpose: an older build must still be + /// able to SHOW a plan whose preset it does not know. + final String preset; + final List segments; + + bool get isTraining => preset == 'training'; + + /// A standard race order. [swimEnv]/[bikeEnv]/[runEnv] are required because + /// "the user decides before starting" is the whole point of the setting; + /// silently defaulting to outdoor would arm GPS nobody asked for. + factory TriathlonPlan.race( + String preset, { + required TriEnvironment swimEnv, + required TriEnvironment bikeEnv, + required TriEnvironment runEnv, + Map? distanceOverridesM, + Map? goalSec, + }) { + final defaults = kTriathlonPresetDistancesM[preset]; + double? distance(TriSegmentKind kind, int index) { + final override = distanceOverridesM?[kind]; + if (override != null) return override; + return defaults == null ? null : defaults[index]; + } + + return TriathlonPlan( + preset: preset, + segments: [ + TriSegmentPlan( + kind: TriSegmentKind.swim, + environment: swimEnv, + plannedDistanceM: distance(TriSegmentKind.swim, 0), + goalSec: goalSec?[TriSegmentKind.swim], + ), + const TriSegmentPlan(kind: TriSegmentKind.t1), + TriSegmentPlan( + kind: TriSegmentKind.bike, + environment: bikeEnv, + plannedDistanceM: distance(TriSegmentKind.bike, 1), + goalSec: goalSec?[TriSegmentKind.bike], + ), + const TriSegmentPlan(kind: TriSegmentKind.t2), + TriSegmentPlan( + kind: TriSegmentKind.run, + environment: runEnv, + plannedDistanceM: distance(TriSegmentKind.run, 2), + goalSec: goalSec?[TriSegmentKind.run], + ), + ], + ); + } + + /// Free-form training: brick (bike→run), aquathlon (swim→run), or a single + /// discipline. Transitions are inserted by the caller where they belong. + factory TriathlonPlan.training(List segments) => + TriathlonPlan(preset: 'training', segments: segments); + + /// Structural validation. Returns a human-readable problem, or null when the + /// plan is storable. This is not a judgement about whether the workout is a + /// good idea — only whether the row can be interpreted later. + String? validate() { + if (segments.isEmpty) return 'a plan needs at least one segment'; + if (segments.first.kind.isTransition || segments.last.kind.isTransition) { + return 'a plan starts and ends with a discipline, not a transition'; + } + for (var i = 0; i < segments.length; i++) { + final s = segments[i]; + if (s.kind.isTransition) { + if (i > 0 && segments[i - 1].kind.isTransition) { + return 'two transitions cannot be adjacent'; + } + if (s.environment != null || s.plannedDistanceM != null) { + return 'a transition has no environment or distance'; + } + continue; + } + if (s.environment == null) { + return 'every discipline needs an indoor/outdoor choice'; + } + final d = s.plannedDistanceM; + if (d != null && (!d.isFinite || d <= 0)) { + return 'a planned distance is finite and positive, or absent'; + } + } + if (!isTraining) { + const order = [ + TriSegmentKind.swim, + TriSegmentKind.t1, + TriSegmentKind.bike, + TriSegmentKind.t2, + TriSegmentKind.run, + ]; + if (segments.length != order.length) { + return 'a race plan is swim, T1, bike, T2, run'; + } + for (var i = 0; i < order.length; i++) { + if (segments[i].kind != order[i]) { + return 'a race plan is swim, T1, bike, T2, run'; + } + } + } + for (final s in segments) { + final g = s.goalSec; + if (g != null && g <= 0) return 'a goal time is positive or absent'; + } + return null; + } + + /// True when ANY segment may record a route — the only case in which the + /// setup flow should ask for location permission at all. + bool get needsGps => segments.any( + (s) => triathlonSegmentRecordsRoute(s.kind, s.environment), + ); + + Map toJson() => { + 'v': kTriathlonPlanVersion, + 'preset': preset, + 'segments': [for (final s in segments) s.toJson()], + }; + + /// Null when the payload is not a plan this build can read. Callers treat + /// that as "unknown plan", never as an empty race. + static TriathlonPlan? fromJson(Map j) { + final rawV = j['v']; + final v = rawV is num ? rawV.toInt() : null; + if (v == null || v > kTriathlonPlanVersion) return null; + final preset = j['preset']; + final raw = j['segments']; + if (preset is! String || raw is! List) return null; + final segments = []; + for (final e in raw) { + if (e is! Map) return null; + final s = TriSegmentPlan.fromJson(e.cast()); + if (s == null) return null; + segments.add(s); + } + return TriathlonPlan(preset: preset, segments: segments); + } +} diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart new file mode 100644 index 000000000..1d96f411e --- /dev/null +++ b/lib/data/triathlon_store.dart @@ -0,0 +1,1306 @@ +// Triathlon / multisport storage — schema, row models and the CRUD seam. +// +// Parent/child on purpose. `triathlon_session` owns the frozen plan and the +// lifecycle; `triathlon_segment` owns the timed boundaries. A discipline +// segment ALSO gets a real `sessions` row (workout_id) from the live wiring, +// so routes, zones, splits and Health export keep their single existing code +// path; a transition (T1/T2) has no `sessions` row because it is not a sport. +// +// THE TWO INVARIANTS THIS FILE ENFORCES, INDEPENDENT OF THE STATE MACHINE: +// +// 1. FINALIZED MEANS IMMUTABLE. `finalize()` stamps `finalized_at`; every +// later write to that session or its segments throws (including its note). +// A race that has been saved is a record, and a screen must not be able to +// quietly rewrite it. Boundary corrections belong to the not-yet-finalized +// session, and a future "re-derive" flow gets a NEW row, exactly like +// `day_result`'s immutable-per-version rule. +// +// 2. SEGMENTS CANNOT OVERLAP OR RUN BACKWARDS. A segment's end is at or after +// its start, and neighbours meet at one instant: segment i's end IS segment +// i+1's start. That is what a single "next" tap on the band means, and it +// keeps total time equal to the sum of the splits by construction. +// +// Units follow the `sessions` table: segment boundaries are epoch SECONDS, +// bookkeeping stamps (created_at / updated_at / finalized_at) are epoch +// MILLISECONDS. Mixing them is the classic silent bug, so the row models name +// the unit in every field. + +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; + +import 'triathlon_plan.dart'; + +/// schemaVersion 60. Parent before child on every create. +Future createTriathlonTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS triathlon_session ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + preset TEXT NOT NULL, + status TEXT NOT NULL, + plan_json TEXT NOT NULL, + start_ts INTEGER, + end_ts INTEGER, + note TEXT NOT NULL DEFAULT '', + finalized_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_triathlon_session_status ' + 'ON triathlon_session(status)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_triathlon_session_start ' + 'ON triathlon_session(start_ts)', + ); + await db.execute(''' + CREATE TABLE IF NOT EXISTS triathlon_segment ( + session_id TEXT NOT NULL, + idx INTEGER NOT NULL, + kind TEXT NOT NULL, + environment TEXT, + planned_distance_m REAL, + goal_sec INTEGER, + start_ts INTEGER, + end_ts INTEGER, + boundary_source TEXT NOT NULL DEFAULT 'unknown', + workout_id TEXT, + distance_m REAL, + distance_source TEXT, + hr_source TEXT, + PRIMARY KEY (session_id, idx) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_triathlon_segment_workout ' + 'ON triathlon_segment(workout_id)', + ); +} + +/// Double-tap event id, the only gesture the firmware emits today. +const int kTriathlonDoubleTapEventId = 14; + +/// Widening applied to the event window on both sides, because the strap's RTC +/// and the phone's clock are different clocks and can drift. The boundary +/// floor still rejects anything at or before the last recorded boundary, so a +/// wider read can only ADD taps the strap really recorded, never rewind. +const int kTriathlonClockDriftSec = 120; + +/// How a segment boundary was established. `gesture` is a live band tap, +/// `phone` an on-screen button, `manual` a boundary the user moved afterwards, +/// `posthoc` a boundary reconstructed from band history or suggested by the +/// timeline — i.e. NOT a measurement of when the athlete actually changed +/// discipline, and therefore never to be presented as one. `unknown` is the +/// honest default for a row this build did not write. +const List kTriBoundarySources = [ + 'gesture', + 'phone', + 'manual', + 'posthoc', + 'unknown', +]; + +class TriSegment { + const TriSegment({ + required this.sessionId, + required this.idx, + required this.kind, + this.environment, + this.plannedDistanceM, + this.goalSec, + this.startTs, + this.endTs, + this.boundarySource = 'unknown', + this.workoutId, + this.distanceM, + this.distanceSource, + this.hrSource, + }); + + final String sessionId; + final int idx; + final TriSegmentKind kind; + final TriEnvironment? environment; + final double? plannedDistanceM; + final int? goalSec; + + /// Epoch SECONDS. Null until the segment is entered / left. + final int? startTs; + final int? endTs; + + final String boundarySource; + + /// The linked `sessions.id` for a discipline segment, null for transitions + /// and for a segment that has not been banked yet. + final String? workoutId; + + /// MEASURED or explicitly user-entered distance in metres. Null means "we do + /// not know" and the UI shows "—". It is NEVER copied from + /// [plannedDistanceM]: a plan is not a measurement. + final double? distanceM; + + /// 'gps' | 'user' | null. Null whenever [distanceM] is null — a distance + /// without a source is exactly the number this repo refuses to show. + final String? distanceSource; + + /// 'band' | 'strap' | null: which heart-rate source the segment used. + final String? hrSource; + + bool get isTransition => kind.isTransition; + bool get isOpen => startTs != null && endTs == null; + + /// Seconds in the segment, or null while it is still open. Zero-length + /// segments are legal (an instant tap-through), negative ones are not. + int? get durationSec => + startTs == null || endTs == null ? null : endTs! - startTs!; + + TriSegment copyWith({ + int? startTs, + int? endTs, + String? boundarySource, + String? workoutId, + double? distanceM, + String? distanceSource, + String? hrSource, + bool clearEnd = false, + bool clearStart = false, + }) { + return TriSegment( + sessionId: sessionId, + idx: idx, + kind: kind, + environment: environment, + plannedDistanceM: plannedDistanceM, + goalSec: goalSec, + startTs: clearStart ? null : (startTs ?? this.startTs), + endTs: clearEnd ? null : (endTs ?? this.endTs), + boundarySource: boundarySource ?? this.boundarySource, + workoutId: workoutId ?? this.workoutId, + distanceM: distanceM ?? this.distanceM, + distanceSource: distanceSource ?? this.distanceSource, + hrSource: hrSource ?? this.hrSource, + ); + } + + Map toRow() => { + 'session_id': sessionId, + 'idx': idx, + 'kind': kind.key, + 'environment': environment?.key, + 'planned_distance_m': plannedDistanceM, + 'goal_sec': goalSec, + 'start_ts': startTs, + 'end_ts': endTs, + 'boundary_source': boundarySource, + 'workout_id': workoutId, + 'distance_m': distanceM, + 'distance_source': distanceSource, + 'hr_source': hrSource, + }; + + static TriSegment? fromRow(Map r) { + final kind = TriSegmentKindX.fromKey(r['kind'] as String?); + if (kind == null) return null; + return TriSegment( + sessionId: r['session_id'] as String, + idx: (r['idx'] as num).toInt(), + kind: kind, + environment: TriEnvironmentX.fromKey(r['environment'] as String?), + plannedDistanceM: (r['planned_distance_m'] as num?)?.toDouble(), + goalSec: (r['goal_sec'] as num?)?.toInt(), + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + boundarySource: r['boundary_source'] as String? ?? 'unknown', + workoutId: r['workout_id'] as String?, + distanceM: (r['distance_m'] as num?)?.toDouble(), + distanceSource: r['distance_source'] as String?, + hrSource: r['hr_source'] as String?, + ); + } +} + +class TriSession { + const TriSession({ + required this.id, + required this.kind, + required this.preset, + required this.status, + required this.plan, + this.startTs, + this.endTs, + this.note = '', + this.finalizedAt, + required this.createdAt, + required this.updatedAt, + this.segments = const [], + }); + + final String id; + + /// 'race' | 'training'. + final String kind; + final String preset; + + /// 'armed' | 'active' | 'finished' | 'finalized' | 'discarded'. The state + /// machine owns transitions; storage only refuses to write a finalized row. + final String status; + + /// The frozen plan. Null only when a row was written by a build whose plan + /// format this one cannot read — callers show "unknown plan", not a guess. + final TriathlonPlan? plan; + + /// Epoch SECONDS, mirroring the first/last segment boundary. + final int? startTs; + final int? endTs; + + final String note; + + /// Epoch MILLISECONDS. Non-null makes the session and its segments read-only. + final int? finalizedAt; + + /// Epoch MILLISECONDS. + final int createdAt; + final int updatedAt; + + final List segments; + + bool get isFinalized => finalizedAt != null; + + /// 'armed' | 'active' | 'finished' | 'abandoned' | 'discarded' | 'finalized'. + static const statuses = { + 'armed', + 'active', + 'finished', + 'abandoned', + 'discarded', + 'finalized', + }; + + /// Sum of closed segment durations = time actually in a discipline. + /// Null when no segment is closed yet — a pause or an open tail is never + /// billed as training. + int? get movingSec { + final closed = [ + for (final s in segments) + if (s.startTs != null && s.endTs != null) s.endTs! - s.startTs!, + ]; + return closed.isEmpty ? null : closed.reduce((a, b) => a + b); + } + + /// Total wall-clock time of the session (start to last end). INCLUDES any + /// pauses between segments — that is the difference from [movingSec]. + int? get totalSec { + if (startTs == null || endTs == null) return null; + return endTs! - startTs!; + } + + /// Pauses between segments, in seconds. Always >= 0. + int? get pauseSec { + final t = totalSec; + final m = movingSec; + if (t == null || m == null) return null; + return t - m; + } + + Map toRow() => { + 'id': id, + 'kind': kind, + 'preset': preset, + 'status': status, + 'plan_json': plan == null ? '{}' : jsonEncode(plan!.toJson()), + 'start_ts': startTs, + 'end_ts': endTs, + 'note': note, + 'finalized_at': finalizedAt, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; + + static TriSession fromRow(Map r) { + TriathlonPlan? plan; + try { + final decoded = jsonDecode(r['plan_json'] as String? ?? '{}'); + if (decoded is Map) { + plan = TriathlonPlan.fromJson(decoded.cast()); + } + } on FormatException { + plan = null; + } catch (_) { + // A persisted payload that is not even a JSON map (or contains + // wrong-typed fields) is an unreadable plan, not a crash. + plan = null; + } + return TriSession( + id: r['id'] as String, + kind: r['kind'] as String? ?? 'race', + preset: r['preset'] as String? ?? 'custom', + status: r['status'] as String? ?? 'armed', + plan: plan, + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + note: r['note'] as String? ?? '', + finalizedAt: (r['finalized_at'] as num?)?.toInt(), + createdAt: (r['created_at'] as num?)?.toInt() ?? 0, + updatedAt: (r['updated_at'] as num?)?.toInt() ?? 0, + ); + } +} + +/// One boundary derived from the strap's stored taps (see the reconciler). +/// +/// Lives in the data layer so storage never has to import a state-layer file: +/// the dependency runs one way, reconciler -> store. +class ReconcileBoundary { + const ReconcileBoundary(this.atSec, this.taps, this.kind); + + /// Strap time of the FIRST tap of the accepted cluster. + final int atSec; + final int taps; + + /// The segment the cluster closes; its successor starts at [atSec]. + final TriSegmentKind kind; + + @override + String toString() => 'ReconcileBoundary($kind @$atSec, $taps taps)'; +} + +/// Thrown when a write targets a finalized session, or would make segments +/// overlap. Deliberately loud: a silent no-op here reads as "the race saved". +class TriathlonWriteError implements Exception { + TriathlonWriteError(this.message); + final String message; + @override + String toString() => 'TriathlonWriteError: $message'; +} + +class TriathlonDb { + TriathlonDb._(); + + /// Persist a new ARMED session with its planned segments in one transaction. + /// Nothing here starts a clock: arming is a choice, not a measurement. + static Future create(Database db, TriSession session) async { + final plan0 = session.plan; + if (plan0 == null) throw TriathlonWriteError('a session needs a readable plan'); + final problem = plan0.validate(); + if (problem != null) throw TriathlonWriteError(problem); + if (session.status != 'armed') { + throw TriathlonWriteError('a new session is created armed, not ${session.status}'); + } + if (session.isFinalized || session.startTs != null || session.endTs != null) { + throw TriathlonWriteError('create cannot pre-fill session bounds'); + } + if (session.segments.isNotEmpty && + session.segments.length != session.plan!.segments.length) { + throw TriathlonWriteError('segment rows do not match the plan'); + } + await db.transaction((txn) async { + final clash = await txn.query( + 'triathlon_session', + columns: ['id'], + where: 'id = ?', + whereArgs: [session.id], + limit: 1, + ); + if (clash.isNotEmpty) { + throw TriathlonWriteError('session ${session.id} already exists'); + } + await txn.insert('triathlon_session', session.toRow()); + final plan = session.plan!; + for (var i = 0; i < plan.segments.length; i++) { + final p = plan.segments[i]; + final supplied = i < session.segments.length ? session.segments[i] : null; + final seg = supplied ?? + TriSegment( + sessionId: session.id, + idx: i, + kind: p.kind, + environment: p.environment, + plannedDistanceM: p.plannedDistanceM, + goalSec: p.goalSec, + ); + if (seg.sessionId != session.id || seg.idx != i || seg.kind != p.kind || + seg.environment != p.environment || + seg.plannedDistanceM != p.plannedDistanceM || seg.goalSec != p.goalSec) { + throw TriathlonWriteError('segment $i does not match the plan'); + } + if (seg.startTs != null || + seg.endTs != null || + seg.workoutId != null || + seg.distanceM != null || + seg.distanceSource != null) { + throw TriathlonWriteError( + 'create stores a plan; segment $i already carries history', + ); + } + _checkSegmentShape(seg); + if (i > 0 && session.segments.isNotEmpty) { + _checkNeighbour(seg, session.segments[i - 1]); + } + await txn.insert('triathlon_segment', seg.toRow()); + } + }); + } + + static Future byId(Database db, String id) async { + final rows = await db.query( + 'triathlon_session', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, TriSession.fromRow(rows.first)); + } + + static Future> segments(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'triathlon_segment', + where: 'session_id = ?', + whereArgs: [id], + orderBy: 'idx ASC', + ); + return [for (final r in rows) TriSegment.fromRow(r)].whereType().toList(); + } + + static Future _withSegments(Database db, TriSession s) async { + final segs = await segments(db, s.id); + return TriSession( + id: s.id, + kind: s.kind, + preset: s.preset, + status: s.status, + plan: s.plan, + startTs: s.startTs, + endTs: s.endTs, + note: s.note, + finalizedAt: s.finalizedAt, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + segments: segs, + ); + } + + /// The ONE session a relaunch must resume: not finalized, not discarded, + /// newest first. Returning more than the newest would let a stale armed row + /// shadow the race actually in progress, so callers get a single answer. + static Future activeSession(Database db) async { + final rows = await db.query( + 'triathlon_session', + where: "finalized_at IS NULL AND status != 'discarded'", + orderBy: 'created_at DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, TriSession.fromRow(rows.first)); + } + + /// Finalized sessions at [preset], newest first — the comparison set for + /// "total time vs your previous races at this distance". + static Future> finalized( + Database db, { + String? preset, + int limit = 50, + }) async { + final rows = await db.query( + 'triathlon_session', + where: preset == null ? 'finalized_at IS NOT NULL' : 'finalized_at IS NOT NULL AND preset = ?', + whereArgs: preset == null ? null : [preset], + orderBy: 'start_ts DESC', + limit: limit, + ); + final out = []; + for (final r in rows) { + out.add(await _withSegments(db, TriSession.fromRow(r))); + } + return out; + } + + /// Write one segment's boundaries. + /// + /// Reads the plan and the neighbours INSIDE the same transaction, so the + /// checks cannot be made against a stale in-memory copy. A segment's INDEX + /// never changes here: this method only moves/records the boundaries of a + /// row the plan already created. Moving a boundary that neighbours share is + /// [adjustBoundary]'s job, because both sides have to move together. + static Future saveSegment(Database db, TriSegment segment) async { + _checkSegmentShape(segment); + await db.transaction((txn) async { + await _requireWritable(txn, segment.sessionId); + final existing = await segments(txn, segment.sessionId); + final matching = existing.where((s) => s.idx == segment.idx).toList(); + if (matching.isEmpty) { + throw TriathlonWriteError('segment ${segment.idx} is not in the plan'); + } + final original = matching.single; + if (segment.kind != original.kind || + segment.environment != original.environment || + segment.plannedDistanceM != original.plannedDistanceM || + segment.goalSec != original.goalSec) { + throw TriathlonWriteError('the planned segment is immutable'); + } + for (final other in existing) { + if (other.idx != segment.idx) _checkNeighbour(segment, other); + } + await txn.update( + 'triathlon_segment', + segment.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + _validateTimeline(await segments(txn, segment.sessionId)); + await _recomputeSessionBounds(txn, segment.sessionId); + }); + } + + /// Opens segment 0 at [ts] and marks the session active. + /// + /// Fails when the session already has a boundary: an armed session may start + /// exactly once, and a second start is a bug in the caller, not a rewind. + static Future startSegment({ + required Database db, + required String sessionId, + required int ts, + required String boundarySource, + }) async { + if (ts <= 0) throw TriathlonWriteError('a start needs a real timestamp'); + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (segs.isEmpty) throw TriathlonWriteError('the plan has no segments'); + if (segs.any((s) => s.startTs != null || s.endTs != null)) { + throw TriathlonWriteError('the session has already started'); + } + await txn.update( + 'triathlon_segment', + segs.first + .copyWith(startTs: ts, boundarySource: boundarySource) + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + }); + } + + /// Atomically closes segment [currentIdx] and opens [currentIdx + 1] at [ts]. + /// + /// Both sides move or neither does. Validation is deliberately strict: the + /// current segment must be open, the next one must not have started, and + /// [ts] must not run backwards past either neighbouring boundary. + static Future transitionSegment({ + required Database db, + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _transitionInTx( + txn, + sessionId: sessionId, + currentIdx: currentIdx, + transitionTs: transitionTs, + boundarySource: boundarySource, + ); + }); + } + + /// Closes the FINAL segment at [ts] and marks the session finished. + /// + /// Only the last segment may be finished: closing an earlier one would leave + /// later segments unstarted while the session claims to be over, and + /// [finalize] — which requires every segment closed — could never accept it. + /// A DNF needs an explicit model (skipped markers), not a silent half-race. + static Future finishSegment({ + required Database db, + required String sessionId, + required int ts, + required String boundarySource, + }) async { + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + final open = segs.where((s) => s.isOpen).toList(); + if (open.length != 1) { + throw TriathlonWriteError('exactly one segment must be open to finish'); + } + final last = open.single; + if (last.startTs == null || ts < last.startTs!) { + throw TriathlonWriteError('a segment cannot end before it starts'); + } + if (last.idx != segs.length - 1) { + throw TriathlonWriteError( + 'finish closes the final segment; close segment ${last.idx} with a ' + 'transition instead', + ); + } + await txn.update( + 'triathlon_segment', + last.copyWith(endTs: ts, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, last.idx], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'finished'); + }); + } + + /// Moves the race start: clears every boundary of the not-yet-finalized + /// session and restarts segment 0 at [newTs]. + /// + /// A false triple-tap in the start pen starts the race wrongly, and because + /// the session may only start once, no existing write can heal that. This + /// is the explicit correction path: it is only legal while NO segment has + /// ended yet (a race with closed splits keeps its history), and the new + /// start is stamped 'manual'. + static Future adjustStart({ + required Database db, + required String sessionId, + required int newTs, + }) async { + if (newTs <= 0) { + throw TriathlonWriteError('a start needs a real timestamp'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (segs.isEmpty) throw TriathlonWriteError('the plan has no segments'); + if (segs.any((s) => s.endTs != null)) { + throw TriathlonWriteError( + 'a segment already ended \u2014 the start can no longer be moved', + ); + } + if (segs.any((s) => s.workoutId != null)) { + throw TriathlonWriteError( + 'a segment is linked to a workout \u2014 unlink it before moving the start', + ); + } + for (var i = 0; i < segs.length; i++) { + final cleared = segs[i].copyWith( + clearStart: true, + clearEnd: true, + boundarySource: i == 0 ? 'manual' : segs[i].boundarySource, + ); + await txn.update( + 'triathlon_segment', + cleared.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, i], + ); + } + final first = (await segments(txn, sessionId)).first; + await txn.update( + 'triathlon_segment', + first.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + }); + } + + /// Atomically moves a boundary shared by segment [idx] and [idx + 1]. + /// + /// Separate [saveSegment] calls cannot do this: as soon as both sides have + /// timestamps, changing one alone breaks the contiguity rule and is refused. + /// Both rows must already meet, and [newTs] must stay inside both segments. + static Future adjustBoundary({ + required Database db, + required String sessionId, + required int idx, + required int newTs, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (idx < 0 || idx + 1 >= segs.length) { + throw TriathlonWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + final wasStart = right.startTs; + final wasEnd = left.endTs; + if (wasStart == null || wasEnd == null || wasStart != wasEnd) { + throw TriathlonWriteError('segments $idx and ${idx + 1} do not meet'); + } + if (left.startTs != null && newTs < left.startTs!) { + throw TriathlonWriteError('the boundary would precede segment $idx'); + } + if (right.endTs != null && newTs > right.endTs!) { + throw TriathlonWriteError('the boundary would follow segment ${idx + 1}'); + } + // Provenance follows the actor: a boundary moved by hand is 'manual' on + // both rows. Keeping 'gesture' here would claim the band recorded a time + // the athlete actually corrected. + await txn.update( + 'triathlon_segment', + left.copyWith(endTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'triathlon_segment', + right.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + }); + } + + /// Removes the boundary after segment [idx]: segments [idx] and [idx + 1] + /// merge into one, the successor's boundaries are cleared, and the merged + /// segment keeps the earlier start and the later end. Both rows must have + /// started, and the boundary is stamped 'manual' on the survivor. + /// + /// This is the correction for a false triple-tap mid-race that split one + /// discipline in two: [adjustBoundary] can only move a boundary, not delete + /// one, and after [finalize] the record is immutable by design. + static Future removeBoundary({ + required Database db, + required String sessionId, + required int idx, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (idx < 0 || idx + 1 >= segs.length) { + throw TriathlonWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + if (left.startTs == null || right.startTs == null) { + throw TriathlonWriteError( + 'segments $idx and ${idx + 1} have not both started', + ); + } + if (left.endTs != null && left.endTs != right.startTs) { + throw TriathlonWriteError('segments $idx and ${idx + 1} do not meet'); + } + // Merging is only meaningful while the successor is the newest started + // segment: clearing its start would break the started-prefix rule the + // moment any later segment has already begun. + if (segs.any((s) => s.idx > idx + 1 && s.startTs != null)) { + throw TriathlonWriteError( + 'a later segment already started \u2014 the boundary cannot be removed', + ); + } + if (left.workoutId != null || right.workoutId != null) { + throw TriathlonWriteError( + 'a segment is linked to a workout \u2014 unlink it before removing the boundary', + ); + } + final merged = right.endTs == null + ? left.copyWith(clearEnd: true, boundarySource: 'manual') + : left.copyWith(endTs: right.endTs, boundarySource: 'manual'); + await txn.update( + 'triathlon_segment', + merged.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'triathlon_segment', + right.copyWith(clearStart: true, clearEnd: true).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + }); + } + + /// Applies a whole offline-derived boundary chain in ONE transaction. + /// + /// Used by the reconciler. The FIRST boundary starts the race when nothing + /// has started yet; every later one closes the current segment and opens the + /// next; a boundary at the very end closes the last segment. A failure + /// anywhere rolls the whole chain back, so a race is never left half-marked. + /// + /// Rejects a boundary that would not advance, so a stale or repeated event + /// log cannot rewind a session. + static Future applyOfflineBoundariesInTx( + DatabaseExecutor txn, { + required String sessionId, + required List boundaries, + }) async { + if (boundaries.isEmpty) return; + await _requireWritable(txn, sessionId); + var segs = await segments(txn, sessionId); + if (segs.isEmpty) throw TriathlonWriteError('the plan has no segments'); + for (var i = 0; i < boundaries.length; i++) { + final b = boundaries[i]; + _checkBoundarySource('posthoc'); + segs = await segments(txn, sessionId); + final open = segs.where((s) => s.isOpen).toList(); + if (open.isEmpty) { + // Nothing running: this boundary STARTS the race, and only as the + // first one. Anything later is a log we cannot place. + if (i != 0 || + segs.any((s) => s.startTs != null) || + b.kind != segs.first.kind) { + throw TriathlonWriteError('no open segment to close at ${b.atSec}'); + } + await txn.update( + 'triathlon_segment', + segs.first + .copyWith(startTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(segs = await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + continue; + } + if (open.length != 1) { + throw TriathlonWriteError('more than one open segment'); + } + final current = open.single; + if (b.kind != current.kind) { + throw TriathlonWriteError('boundary does not close the open segment'); + } + final hasNext = current.idx + 1 < segs.length; + if (!hasNext) { + // Closing the final segment ends the race, so nothing may follow it. + if (i != boundaries.length - 1) { + throw TriathlonWriteError('boundaries follow the final segment'); + } + if (current.startTs == null || b.atSec < current.startTs!) { + throw TriathlonWriteError( + 'the final segment would end before it started', + ); + } + await txn.update( + 'triathlon_segment', + current + .copyWith(endTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, current.idx], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'finished'); + return; + } + await _transitionInTx( + txn, + sessionId: sessionId, + currentIdx: current.idx, + transitionTs: b.atSec, + boundarySource: 'posthoc', + ); + } + // A chain that never reached the last segment leaves it running; the + // session bounds are still correct for what is known. + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + } + + /// Link a discipline segment to the `sessions` row the live workout banked. + static Future linkWorkout( + Database db, + String sessionId, + int idx, + String workoutId, + ) async { + if (workoutId.trim().isEmpty) { + throw TriathlonWriteError('a workout link needs a workout id'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final rows = await txn.query( + 'triathlon_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + limit: 1, + ); + if (rows.isEmpty) { + throw TriathlonWriteError('segment $idx is not in session $sessionId'); + } + final seg = TriSegment.fromRow(rows.first); + if (seg == null) { + throw TriathlonWriteError('segment $idx has an unknown kind'); + } + if (seg.isTransition) { + throw TriathlonWriteError( + 'a transition has no workout row to link', + ); + } + final existing = await txn.query( + 'triathlon_segment', + columns: ['session_id', 'idx'], + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [workoutId, sessionId, idx], + ); + if (existing.isNotEmpty) { + throw TriathlonWriteError('workout $workoutId is already linked'); + } + final updated = await txn.update( + 'triathlon_segment', + {'workout_id': workoutId}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + if (updated != 1) { + throw TriathlonWriteError('workout link matched no segment row'); + } + await _touch(txn, sessionId); + }); + } + + /// Note and lifecycle status. Refuses a finalized row, and never clears + /// `finalized_at` — finalization is one-way by design. + /// + /// Session bounds are NOT writable here: they are derived from the segments + /// by [_recomputeSessionBounds], and letting a screen set them would give the + /// same fact two sources. `finished`/`finalized` are set by their own calls. + static Future updateSession(Database db, TriSession session) async { + await db.transaction((txn) async { + await _requireWritable(txn, session.id); + if (session.isFinalized || session.status == 'finalized') { + throw TriathlonWriteError('use finalize to finalize a session'); + } + const writable = {'armed', 'active', 'discarded', 'abandoned'}; + if (!writable.contains(session.status)) { + throw TriathlonWriteError( + 'status ${session.status} is set by start/finish/finalize, not here', + ); + } + // Bounds are derived from the segments (_recomputeSessionBounds) and + // deliberately absent here: a screen must not be able to desync them. + final row = { + 'status': session.status, + 'note': session.note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + await txn.update( + 'triathlon_session', + row, + where: 'id = ?', + whereArgs: [session.id], + ); + }); + } + + /// Stamp finalization. Every segment must be CLOSED, otherwise the saved + /// record would contain a split with no end — an open segment is a live + /// session, and finalizing it would freeze a lie. + static Future finalize(Database db, String id) async { + await db.transaction((txn) async { + await _requireWritable(txn, id); + final rows = await txn.query( + 'triathlon_session', + columns: ['status'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw TriathlonWriteError('no such session: $id'); + final status = rows.first['status'] as String; + if (status != 'finished' && status != 'abandoned') { + throw TriathlonWriteError( + 'only a finished or abandoned session can be finalized', + ); + } + final segs = await segments(txn, id); + if (segs.isEmpty) throw TriathlonWriteError('nothing to finalize'); + if (status == 'finished') { + for (final s in segs) { + if (s.startTs == null || s.endTs == null) { + throw TriathlonWriteError('segment ${s.idx} is still open'); + } + } + } else { + // ABANDONED (DNF): exactly one segment may be open — the discipline + // the athlete stopped in. Its duration stays null; nothing is + // invented. Every segment before it must be closed, every segment + // after it must be untouched, and at least one segment must have + // started (otherwise this is a discard, not a race). + final open = segs.where((s) => s.isOpen).toList(); + final started = segs.where((s) => s.startTs != null).length; + if (started == 0) { + throw TriathlonWriteError( + 'nothing started — discard the session instead of finalizing', + ); + } + if (open.length > 1) { + throw TriathlonWriteError('more than one open segment'); + } + if (open.isNotEmpty) { + final cut = open.single; + if (segs.where((s) => s.idx < cut.idx && s.endTs == null).isNotEmpty) { + throw TriathlonWriteError('an earlier segment is still open'); + } + if (segs.where((s) => s.idx > cut.idx && s.startTs != null).isNotEmpty) { + throw TriathlonWriteError('a later segment already started'); + } + } + } + _validateTimeline(segs); + await txn.update( + 'triathlon_session', + { + 'status': 'finalized', + 'finalized_at': DateTime.now().millisecondsSinceEpoch, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + }); + } + + /// Deleting a race removes its segment rows with it; the linked `sessions` + /// rows are deleted by the caller, which owns that table's cascade (route, + /// splits, sets). Returns the workout ids so the caller can do exactly that. + static Future> delete(Database db, String id) async { + return db.transaction((txn) async { + final segs = await txn.query( + 'triathlon_segment', + columns: ['workout_id'], + where: 'session_id = ?', + whereArgs: [id], + ); + final workoutIds = [ + for (final r in segs) + if (r['workout_id'] is String) r['workout_id'] as String, + ]; + await txn.delete( + 'triathlon_segment', + where: 'session_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'triathlon_session', + where: 'id = ?', + whereArgs: [id], + ); + return workoutIds; + }); + } + + /// THE timeline rule, applied to the whole session and not just the row + /// that changed. + /// + /// Started segments form a PREFIX (segment 3 cannot start while segment 0 + /// is still a plan row), boundaries are MONOTONIC, and no two segments + /// OVERLAP. A gap between one segment's end and the next one's start is a + /// PAUSE, not a gap in the data: a workout can be interrupted for hours + /// between disciplines, and a missed transition tap looks exactly like + /// that. The session then reports total and moving time separately, so a + /// pause is never silently billed as training time. + static void _validateTimeline(List segs) { + var seenUnstarted = false; + var open = 0; + int? prevEnd; + for (final s in segs) { + final started = s.startTs != null; + final ended = s.endTs != null; + if (!started) { + seenUnstarted = true; + if (ended) { + throw TriathlonWriteError('segment ${s.idx} ends before it starts'); + } + continue; + } + if (seenUnstarted) { + throw TriathlonWriteError( + 'segment ${s.idx} starts while an earlier segment never did', + ); + } + if (ended && s.endTs! < s.startTs!) { + throw TriathlonWriteError('segment ${s.idx} ends before it starts'); + } + if (prevEnd != null) { + if (s.startTs! < prevEnd) { + throw TriathlonWriteError( + 'segment ${s.idx} overlaps its predecessor', + ); + } + // s.startTs == prevEnd is a direct transition; > is a pause. Both legal. + } + if (ended) { + prevEnd = s.endTs; + } else { + open++; + if (open > 1) { + throw TriathlonWriteError('more than one segment is open'); + } + for (final later in segs) { + if (later.idx > s.idx && later.startTs != null) { + throw TriathlonWriteError( + 'segment ${later.idx} starts while ${s.idx} is open', + ); + } + } + prevEnd = null; + } + } + } + + static void _checkBoundarySource(String source) { + if (!kTriBoundarySources.contains(source)) { + throw TriathlonWriteError('unknown boundary source $source'); + } + } + + /// The transition body, callable from inside an existing transaction. + /// + /// Public [transitionSegment] wraps it; the offline reconciler uses it to + /// apply a whole chain of boundaries atomically instead of one commit each. + static Future _transitionInTx( + DatabaseExecutor txn, { + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + final current = segs.where((s) => s.idx == currentIdx).toList(); + final next = segs.where((s) => s.idx == currentIdx + 1).toList(); + if (current.isEmpty || next.isEmpty) { + throw TriathlonWriteError('segment $currentIdx has no successor'); + } + final a = current.single; + final b = next.single; + if (!a.isOpen) { + throw TriathlonWriteError('segment $currentIdx is not open'); + } + if (b.startTs != null) { + throw TriathlonWriteError('segment ${currentIdx + 1} has already started'); + } + if (a.startTs != null && transitionTs < a.startTs!) { + throw TriathlonWriteError('a transition cannot precede its segment'); + } + await txn.update( + 'triathlon_segment', + a.copyWith(endTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx], + ); + await txn.update( + 'triathlon_segment', + b.copyWith(startTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx + 1], + ); + _validateTimeline(await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId); + } + + /// Derives the session bounds from its segments — one source of truth. + /// + /// With NO started segment both bounds are cleared, not kept at a stale + /// value; a bound is written only when a segment actually supports it. + /// [status] is left untouched unless the caller is changing the lifecycle. + static Future _recomputeSessionBounds( + DatabaseExecutor db, + String sessionId, { + String? status, + }) async { + final segs = await segments(db, sessionId); + int? start; + int? end; + for (final s in segs) { + if (s.startTs != null && (start == null || s.startTs! < start)) { + start = s.startTs; + } + if (s.endTs != null && (end == null || s.endTs! > end)) { + end = s.endTs; + } + } + final row = { + 'start_ts': start, + 'end_ts': end, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + 'status': ?status, + }; + await db.update( + 'triathlon_session', + row, + where: 'id = ?', + whereArgs: [sessionId], + ); + } + + static Future _requireWritable(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'triathlon_session', + columns: ['finalized_at'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw TriathlonWriteError('no such session: $id'); + if (rows.first['finalized_at'] != null) { + throw TriathlonWriteError('session $id is finalized and immutable'); + } + } + + static Future _touch(DatabaseExecutor db, String id) async { + await db.update( + 'triathlon_session', + {'updated_at': DateTime.now().millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Fields that must hold for ANY segment row, planned or timed. + static void _checkSegmentShape(TriSegment s) { + if (s.isTransition && + (s.environment != null || s.plannedDistanceM != null)) { + throw TriathlonWriteError('a transition has no environment or distance'); + } + if (s.distanceM == null && s.distanceSource != null) { + throw TriathlonWriteError('a distance source without a distance'); + } + if (s.distanceM != null && (!s.distanceM!.isFinite || s.distanceM! < 0)) { + throw TriathlonWriteError('a measured distance is finite and not negative'); + } + if (s.distanceM != null && s.distanceSource == null) { + throw TriathlonWriteError('a distance without a source'); + } + if (s.startTs != null && s.endTs != null && s.endTs! < s.startTs!) { + throw TriathlonWriteError('segment ends before it starts'); + } + if (!kTriBoundarySources.contains(s.boundarySource)) { + throw TriathlonWriteError('unknown boundary source ${s.boundarySource}'); + } + } + + /// Neighbour rule for a single edit: the previous segment's end must not + /// lie AFTER this segment's start (overlap), and this segment's end must + /// not precede the next segment's start. Equal means a direct transition; + /// a gap means a pause. Both are legal now. + static void _checkNeighbour(TriSegment changed, TriSegment other) { + if (other.idx == changed.idx - 1) { + if (other.endTs != null && + changed.startTs != null && + other.endTs! > changed.startTs!) { + throw TriathlonWriteError( + 'segment ${changed.idx} starts before ${other.idx} ended', + ); + } + } else if (other.idx == changed.idx + 1) { + if (changed.endTs != null && + other.startTs != null && + changed.endTs! > other.startTs!) { + throw TriathlonWriteError( + 'segment ${other.idx} starts before ${changed.idx} ended', + ); + } + } + } +} diff --git a/lib/state/hyrox_reconciler.dart b/lib/state/hyrox_reconciler.dart new file mode 100644 index 000000000..c60b38431 --- /dev/null +++ b/lib/state/hyrox_reconciler.dart @@ -0,0 +1,266 @@ +// hyrox_reconciler.dart — rebuilds a race from the band's own record. +// +// The band IS the source of truth for the taps. When the phone was away from +// the athlete (charging at the venue, out of radio range in the arena), a +// tap still reached the strap and sits in `band_events` with the strap's own +// timestamp. This class turns those stored taps into round boundaries +// afterwards. +// +// WHAT IT WILL AND WILL NOT DO: +// +// * Only EVENTS ARE READ, never `events`/`raw_*`. `band_events` is the +// decoded, deduplicated history (PK on the frame hex), retained for the +// band's own retention window. +// * Only LIVE-provided timestamps count. The strap's RTC is the clock; a row +// whose `ts` is zero (an unset RTC) is skipped rather than placed at 1970. +// * A tap becomes a round boundary only when it CLEARS THE GATE: at least +// [kMinTaps] double-taps within [kTapWindowSec], anchored on the FIRST tap +// of the burst, and at least the segment's minimum duration after that +// round started. Anything less is treated as an accidental touch. The live +// gate (later PR) is specified to apply the same rule; this file is the +// offline implementation, not a claim that the live path already exists. +// * The whole chain is applied in ONE transaction through +// [HyroxDb.applyOfflineBoundariesInTx]. A failure halfway through leaves the +// race exactly as it was, not half-segmented. +// * It NEVER overwrites a live-recorded boundary and never touches a +// finalized session. Re-running is therefore safe and idempotent: the taps +// at or before the last recorded boundary are ignored. +// +// What it does not decide: whether a race happened at all. The caller passes +// the session id of an armed/active session; an absent session is a no-op. + +import 'package:sqflite/sqflite.dart'; + +import '../data/hyrox_plan.dart'; +import '../data/hyrox_store.dart'; + +/// Double-tap event id, the only gesture the firmware emits today. +const int kHyroxDoubleTapEventId = 14; + +/// How many double-taps make one accepted boundary. Matches the "keep +/// tapping until it answers" rule: an accidental blocked tap cannot reach it. +const int kMinTaps = 3; + +/// The window those taps must fall inside, in seconds. The burst is anchored +/// on its FIRST tap: a chained 0 s / 6 s / 12 s sequence does NOT count, +/// because the third tap is outside the window of the first. +const int kTapWindowSec = 6; + +/// Widening applied to the event window on both sides, because the strap's RTC +/// and the phone's clock are different clocks and can drift. The boundary +/// floor still rejects anything at or before the last recorded boundary, so a +/// wider read can only ADD taps the strap really recorded, never rewind. +const int kClockDriftToleranceSec = 120; + +/// Minimum time a round must have been running before a boundary is +/// believable, per STATION. These are deliberately conservative floors on the +/// whole round (1 km run + station), not expected durations: they exist to +/// stop a tap during a sled struggle from ending the round, while still +/// allowing a fast round. +const Map kMinSegmentSec = { + HyroxStation.skierg: 300, + HyroxStation.sledPush: 240, + HyroxStation.sledPull: 240, + HyroxStation.burpeeBroadJump: 300, + HyroxStation.rowing: 300, + HyroxStation.farmersCarry: 240, + HyroxStation.sandbagLunges: 300, + HyroxStation.wallBalls: 240, +}; + +/// Floor for free-form training/custom plans: the race floors above assume a +/// full round (1 km run + station); a drill may be much shorter. 30 s still +/// rejects an accidental tap. +const int kTrainingFloorSec = 30; + +class HyroxReconciler { + const HyroxReconciler(this.db); + + final Database db; + + /// Rebuild boundaries for [sessionId] from the strap's stored taps. + /// + /// Returns the boundaries it actually applied, oldest first; an empty list + /// means every accepted tap was already accounted for (or there were none). + /// Throws [HyroxWriteError] on a finalized session, so a caller can + /// report it instead of silently doing nothing. + Future> reconcile( + String sessionId, { + required String deviceId, + }) async { + // Read the session, the taps and apply the boundaries in ONE transaction, + // so the decision is made against the same snapshot it is applied to. + late final List applied; + await db.transaction((txn) async { + final session = await HyroxDb.byId(txn, sessionId); + if (session == null) { + applied = const []; + return; + } + if (session.isFinalized) { + throw HyroxWriteError('session $sessionId is finalized'); + } + final plan = session.plan; + if (plan == null) { + applied = const []; + return; + } + // Only a race that is still going may be rebuilt. 'discarded' must stay + // discarded (a sync must not resurrect it), and 'finished'/'finalized' + // already carry their own boundaries. + if (session.status != 'armed' && session.status != 'active') { + applied = const []; + return; + } + + // The taps to consider: from the first thing this race recorded (a live + // boundary, or the armed stamp when nothing has started) to now. + final alreadyStarted = + session.segments.where((s) => s.startTs != null).toList(); + final since = alreadyStarted.isEmpty + ? session.createdAt ~/ 1000 + : alreadyStarted.map((s) => s.startTs!).reduce((a, b) => a > b ? a : b); + final until = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final rows = await txn.query( + 'band_events', + columns: ['ts'], + where: 'event_id = ? AND device_id = ? AND ts >= ? AND ts <= ?', + whereArgs: [ + kHyroxDoubleTapEventId, + deviceId, + since - kClockDriftToleranceSec, + until + kClockDriftToleranceSec, + ], + orderBy: 'ts ASC', + ); + final excluded = (await HyroxDb.excludedTaps(txn, sessionId)).toSet(); + final taps = [ + for (final r in rows) + if (((r['ts'] as num?) ?? 0).toInt() > 0) (r['ts'] as num).toInt(), + ].where((t) => !excluded.contains(t)).toList(); + if (taps.isEmpty) { + applied = const []; + return; + } + + final boundaries = deriveBoundaries( + taps: taps, + segments: session.segments, + plan: plan, + sessionCreatedAtSec: session.createdAt ~/ 1000, + ); + if (boundaries.isEmpty) { + applied = const []; + return; + } + + await HyroxDb.applyOfflineBoundariesInTx( + txn, + sessionId: sessionId, + boundaries: boundaries, + ); + applied = boundaries; + }); + return applied; + } +} + +/// Pure: turns raw strap-tap times into the boundaries this race can accept. +/// +/// Kept Flutter- and database-free so the whole decision is unit-testable +/// without a band, a clock or a file. [segments] is the stored plan state; +/// only segments that have not started yet are advanced into. +List deriveBoundaries({ + required List taps, + required List segments, + required HyroxPlan plan, + required int sessionCreatedAtSec, +}) { + if (taps.isEmpty || segments.isEmpty) return const []; + if (plan.segments.length != segments.length) return const []; + final sorted = [...taps]..sort(); + + // Where the race stands right now. `started` counts the segments that have + // a start, and a started segment is the one still running unless it already + // has an end. + var started = 0; + var openIdx = -1; + final localStarts = {}; + for (final s in segments) { + if (s.startTs != null) { + started = s.idx + 1; + localStarts[s.idx] = s.startTs!; + } + if (s.isOpen) openIdx = s.idx; + } + // A tap at or before the last recorded boundary is already accounted for. + // Before anything is recorded the floor is the arming time, which is the + // PHONE's clock, while the taps are the STRAP's. The query widens its window + // by [kClockDriftToleranceSec] to catch a strap running behind, so the floor + // has to give the same allowance, or the very start tap it just fetched is + // thrown away here. + int floor = sessionCreatedAtSec - kClockDriftToleranceSec; + for (final s in segments) { + final t = s.startTs; + if (t != null && t > floor) floor = t; + } + + // One intent = taps that BOTH start within [kTapWindowSec] of the first tap + // AND follow the previous tap without a longer pause. The first condition + // alone stops a chain (0 s, 6 s, 12 s) from counting as a single burst. + final clusters = >[]; + for (final t in sorted) { + final current = clusters.isEmpty ? null : clusters.last; + if (current != null && + t <= current.first + kTapWindowSec && + t - current.last <= kTapWindowSec) { + current.add(t); + } else { + clusters.add([t]); + } + } + + final out = []; + for (final cluster in clusters) { + final at = cluster.first; + if (at <= floor) continue; + // Every member is inside the window by construction. + final supporting = cluster.length; + if (supporting < kMinTaps) continue; + + if (started == 0) { + // Nothing running: this cluster STARTS the race. There is no round to + // close, so no minimum duration applies here — waiting at the start + // line is not a round. + out.add(ReconcileBoundary(at, supporting, plan.segments[0].kind, + plan.segments[0].station)); + started = 1; + openIdx = 0; + localStarts[0] = at; + floor = at; + continue; + } + if (openIdx < 0) break; // the race is already fully marked + final current = plan.segments[openIdx]; + // Full-round floors only apply to the fixed race presets. A free-form + // training drill (single station, short piece) may legitimately end before + // the race floor — use a small training floor so an accidental touch still + // cannot close a drill. + final minSec = plan.isTraining + ? (kMinSegmentSec[current.station] != null + ? (kMinSegmentSec[current.station]! < kTrainingFloorSec + ? kMinSegmentSec[current.station]! + : kTrainingFloorSec) + : kTrainingFloorSec) + : (kMinSegmentSec[current.station] ?? 0); + final currentStart = localStarts[openIdx]; + if (currentStart != null && at - currentStart < minSec) continue; + + out.add(ReconcileBoundary(at, supporting, current.kind, current.station)); + localStarts[openIdx + 1] = at; + openIdx = openIdx + 1 < plan.segments.length ? openIdx + 1 : -1; + floor = at; + } + return out; +} diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart new file mode 100644 index 000000000..ff50c0c83 --- /dev/null +++ b/lib/state/triathlon_reconciler.dart @@ -0,0 +1,222 @@ +// triathlon_reconciler.dart — rebuilds a race from the band's own record. +// +// The band IS the source of truth for the taps. When the phone was in the +// transition bag, in a pocket under a wetsuit, or simply out of radio range, +// a tap still reached the strap and sits in `band_events` with the strap's +// own timestamp. This class turns those stored taps into segment boundaries +// afterwards. +// +// WHAT IT WILL AND WILL NOT DO: +// +// * Only EVENTS ARE READ, never `events`/`raw_*`. `band_events` is the +// decoded, deduplicated history (PK on the frame hex), retained for the +// band's own retention window. +// * Only LIVE-provided timestamps count. The strap's RTC is the clock; a row +// whose `ts` is zero (an unset RTC) is skipped rather than placed at 1970. +// * A tap becomes a transition only when it CLEARS THE GATE: at least +// [kMinTaps] double-taps within [kTapWindowSec], and at least the segment's +// minimum duration after that segment started. Anything less is treated as +// an accidental touch. The live gate (later PR) is specified to apply the +// same rule; this file is the offline implementation, not a claim that the +// live path already exists. +// * The whole chain is applied in ONE transaction through +// [TriathlonDb.applyOfflineBoundariesInTx]. A failure halfway through leaves the +// race exactly as it was, not half-segmented. +// * It NEVER overwrites a live-recorded boundary and never touches a +// finalized session. Re-running is therefore safe and idempotent: the taps +// at or before the last recorded boundary are ignored. +// +// What it does not decide: whether a race happened at all. The caller passes +// the session id of an armed/active session; an absent session is a no-op. + +import 'package:sqflite/sqflite.dart'; + +import '../data/triathlon_plan.dart'; +import '../data/triathlon_store.dart'; + +// The double-tap id and the clock tolerance live beside the store so the +// retention prune can honour them without importing this layer. +export '../data/triathlon_store.dart' + show kTriathlonDoubleTapEventId, kTriathlonClockDriftSec; + +/// How many double-taps make one accepted transition. Matches the "keep +/// tapping until it answers" rule: an accidental blocked tap cannot reach it. +const int kMinTaps = 3; + +/// The window those taps must fall inside, in seconds. +const int kTapWindowSec = 6; + +/// Minimum time a segment must have been running before a transition is +/// believable, per segment kind. These are deliberately conservative floors, +/// not expected durations: they exist to stop a tap during a wetsuit struggle +/// from ending the swim, while still allowing a fast T1. +const Map kMinSegmentSec = { + TriSegmentKind.swim: 300, + TriSegmentKind.t1: 20, + TriSegmentKind.bike: 600, + TriSegmentKind.t2: 20, + TriSegmentKind.run: 480, +}; + +class TriathlonReconciler { + const TriathlonReconciler(this.db); + + final Database db; + + /// Rebuild boundaries for [sessionId] from the strap's stored taps. + /// + /// Returns the boundaries it actually applied, oldest first; an empty list + /// means every accepted tap was already accounted for (or there were none). + /// Throws [TriathlonWriteError] on a finalized session, so a caller can + /// report it instead of silently doing nothing. + Future> reconcile( + String sessionId, { + String deviceId = '', + }) async { + final session = await TriathlonDb.byId(db, sessionId); + if (session == null) return const []; + if (session.isFinalized) { + throw TriathlonWriteError('session $sessionId is finalized'); + } + final plan = session.plan; + if (plan == null) return const []; + // Only a race that is still going may be rebuilt. 'discarded' must stay + // discarded (a sync must not resurrect it), and 'finished'/'finalized' + // already carry their own boundaries. + if (session.status != 'armed' && session.status != 'active') { + return const []; + } + + // The taps to consider: from the first thing this race recorded (a live + // boundary, or the armed stamp when nothing has started) to now. + final alreadyStarted = + session.segments.where((s) => s.startTs != null).toList(); + final since = alreadyStarted.isEmpty + ? session.createdAt ~/ 1000 + : alreadyStarted.map((s) => s.startTs!).reduce((a, b) => a > b ? a : b); + final until = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final rows = await db.query( + 'band_events', + columns: ['ts'], + where: 'event_id = ? AND device_id = ? AND ts >= ? AND ts <= ?', + whereArgs: [ + kTriathlonDoubleTapEventId, + deviceId, + since - kTriathlonClockDriftSec, + until + kTriathlonClockDriftSec, + ], + orderBy: 'ts ASC', + ); + final taps = [ + for (final r in rows) + if (((r['ts'] as num?) ?? 0).toInt() > 0) (r['ts'] as num).toInt(), + ]; + if (taps.isEmpty) return const []; + + final boundaries = deriveBoundaries( + taps: taps, + segments: session.segments, + plan: plan, + sessionCreatedAtSec: session.createdAt ~/ 1000, + ); + if (boundaries.isEmpty) return const []; + + await db.transaction((txn) async { + await TriathlonDb.applyOfflineBoundariesInTx( + txn, + sessionId: sessionId, + boundaries: boundaries, + ); + }); + return boundaries; + } +} + +/// Pure: turns raw strap-tap times into the boundaries this race can accept. +/// +/// Kept Flutter- and database-free so the whole decision is unit-testable +/// without a band, a clock or a file. [segments] is the stored plan state; +/// only segments that have not started yet are advanced into. +List deriveBoundaries({ + required List taps, + required List segments, + required TriathlonPlan plan, + required int sessionCreatedAtSec, +}) { + if (taps.isEmpty || segments.isEmpty) return const []; + if (plan.segments.length != segments.length) return const []; + final sorted = [...taps]..sort(); + + // Where the race stands right now. `started` counts the segments that have + // a start, and a started segment is the one still running unless it already + // has an end. + var started = 0; + var openIdx = -1; + final localStarts = {}; + for (final s in segments) { + if (s.startTs != null) { + started = s.idx + 1; + localStarts[s.idx] = s.startTs!; + } + if (s.isOpen) openIdx = s.idx; + } + // A tap at or before the last recorded boundary is already accounted for. + // Before anything is recorded the floor is the arming time, which is the + // PHONE's clock, while the taps are the STRAP's. The query widens its window + // by [kTriathlonClockDriftSec] to catch a strap running behind, so the floor + // has to give the same allowance, or the very start tap it just fetched is + // thrown away here. + int floor = sessionCreatedAtSec - kTriathlonClockDriftSec; + for (final s in segments) { + final t = s.startTs; + if (t != null && t > floor) floor = t; + } + + // One intent = taps that BOTH start within [kTapWindowSec] of the first tap + // AND follow the previous tap without a longer pause. The first condition + // alone stops a chain (0 s, 6 s, 12 s) from counting as a single burst. + final clusters = >[]; + for (final t in sorted) { + final current = clusters.isEmpty ? null : clusters.last; + if (current != null && + t <= current.first + kTapWindowSec && + t - current.last <= kTapWindowSec) { + current.add(t); + } else { + clusters.add([t]); + } + } + + final out = []; + for (final cluster in clusters) { + final at = cluster.first; + if (at <= floor) continue; + // Every member is inside the window by construction. + final supporting = cluster.length; + if (supporting < kMinTaps) continue; + + if (started == 0) { + // Nothing running: this cluster STARTS the race. There is no segment to + // close, so no minimum duration applies here — waiting in the pen is + // not a swim. + out.add(ReconcileBoundary(at, supporting, plan.segments[0].kind)); + started = 1; + openIdx = 0; + localStarts[0] = at; + floor = at; + continue; + } + if (openIdx < 0) break; // the race is already fully marked + final current = plan.segments[openIdx]; + final minSec = kMinSegmentSec[current.kind] ?? 0; + final currentStart = localStarts[openIdx]; + if (currentStart != null && at - currentStart < minSec) continue; + + out.add(ReconcileBoundary(at, supporting, current.kind)); + localStarts[openIdx + 1] = at; + openIdx = openIdx + 1 < plan.segments.length ? openIdx + 1 : -1; + floor = at; + } + return out; +} diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart new file mode 100644 index 000000000..c1236e361 --- /dev/null +++ b/test/hyrox_model_storage_test.dart @@ -0,0 +1,1380 @@ +// HYROX model, validation, storage invariants, and schema migration ladder +// integration — the mirror of the triathlon storage suite (PR #648). +// +// Runs against the real sqflite_ffi database — the repo standard for DB tests. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/state/hyrox_reconciler.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/hyrox_plan.dart'; +import 'package:openstrap_edge/data/hyrox_store.dart'; + +// exportDaysDb resolves the temp dir through path_provider, which needs a +// platform channel — fake it, same pattern as db_paged_import_export_test. +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + final tmp = await Directory.systemTemp.createTemp('openstrap_hyrox_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + }); + + tearDownAll(() async { + await LocalDb.close(); + }); + + group('HyroxPlan pure model validation', () { + test('a race plan builds the fixed eight rounds with station targets', () { + final plan = HyroxPlan.race('hyrox'); + expect(plan.validate(), isNull); + expect(plan.segments.length, 8); + expect(plan.segments[0].kind, HyroxSegmentKind.hyroxRound); + expect(plan.segments[0].station, HyroxStation.skierg); + expect(plan.segments[0].roundIndex, 1); + expect(plan.segments[0].plannedDistanceM, 1000.0); + expect(plan.segments[0].plannedCal, isNull); // official target is 1000 m + expect(plan.segments[1].station, HyroxStation.sledPush); + expect(plan.segments[1].plannedDistanceM, 50.0); + expect(plan.segments[4].station, HyroxStation.rowing); + expect(plan.segments[5].station, HyroxStation.farmersCarry); + expect(plan.segments[5].plannedDistanceM, 200.0); // farmers carry 200 m + expect(plan.segments[6].station, HyroxStation.sandbagLunges); + expect(plan.segments[7].station, HyroxStation.wallBalls); + expect(plan.segments[7].roundIndex, 8); + expect(plan.segments[7].plannedReps, 100); + expect(plan.segments[7].plannedDistanceM, isNull); + expect(plan.needsGps, isFalse); // HYROX is an indoor arena event + }); + + test('the doubles preset is the same eight rounds', () { + final plan = HyroxPlan.race('hyrox_doubles'); + expect(plan.validate(), isNull); + expect(plan.preset, 'hyrox_doubles'); + expect(plan.segments.length, 8); + expect(plan.segments[7].station, HyroxStation.wallBalls); + }); + + test('training and custom plans are free-form', () { + final plan = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.wallBalls, + plannedReps: 50, + ), + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + plannedCal: 25, + ), + ]); + expect(plan.validate(), isNull); + expect(plan.isTraining, isTrue); + expect(plan.segments[0].roundIndex, isNull); + + final custom = HyroxPlan.custom(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.rowing, + plannedDistanceM: 500, + ), + ]); + expect(custom.validate(), isNull); + expect(custom.preset, 'custom'); + }); + + test('validation catches broken plans', () { + final empty = HyroxPlan.training(const []); + expect(empty.validate(), contains('at least one segment')); + + final negativeDistance = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.sledPush, + plannedDistanceM: -50, + ), + ]); + expect(negativeDistance.validate(), contains('finite and positive')); + + final zeroReps = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.wallBalls, + plannedReps: 0, + ), + ]); + expect(zeroReps.validate(), contains('rep count')); + + final badRound = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + roundIndex: 0, + ), + ]); + expect(badRound.validate(), contains('round index')); + + final negativeGoal = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + goalSec: -5, + ), + ]); + expect(negativeGoal.validate(), contains('goal time')); + + // A fixed preset must be exactly the canonical eight rounds in order. + final wrongOrder = HyroxPlan( + preset: 'hyrox', + segments: [ + for (final st in kHyroxStationOrder.reversed) + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: st, + roundIndex: kHyroxStationOrder.indexOf(st) + 1, + ), + ], + ); + expect(wrongOrder.validate(), contains('eight stations in order')); + }); + + test('json round-trip preserves preset, stations and targets', () { + final plan = HyroxPlan.race( + 'hyrox', + goalSec: const {HyroxStation.wallBalls: 600}, + ); + final json = plan.toJson(); + expect(json['v'], 1); + final roundTrip = HyroxPlan.fromJson(json); + expect(roundTrip, isNotNull); + expect(roundTrip!.preset, 'hyrox'); + expect(roundTrip.segments, hasLength(8)); + expect(roundTrip.segments[7].station, HyroxStation.wallBalls); + expect(roundTrip.segments[7].plannedReps, 100); + expect(roundTrip.segments[7].goalSec, 600); + expect(roundTrip.segments[0].plannedCal, isNull); + }); + + test('wrong-typed fields parse to null without throwing; newer v refused', + () { + final bad = HyroxPlan.fromJson({ + 'v': 1, + 'preset': 'hyrox', + 'segments': [ + { + 'kind': 'hyrox_round', + 'station': 'skierg', + 'round_index': 'three', // wrong type -> null, not a crash + 'planned_distance_m': '1000', // wrong type -> null + 'planned_reps': 5, + 'planned_cal': null, + 'goal_sec': 300, + }, + ], + }); + expect(bad, isNotNull); + expect(bad!.segments[0].roundIndex, isNull); + expect(bad.segments[0].plannedDistanceM, isNull); + expect(bad.segments[0].plannedReps, 5); + + expect(HyroxPlan.fromJson({'v': 99, 'preset': 'hyrox', 'segments': []}), + isNull); + expect(HyroxPlan.fromJson({'v': 0, 'preset': 'hyrox', 'segments': []}), + isNull, reason: 'versions below 1 are refused too'); + expect(HyroxPlan.fromJson({'v': 1, 'preset': 42, 'segments': []}), isNull); + expect( + HyroxPlan.fromJson({ + 'v': 1, + 'preset': 'hyrox', + 'segments': [ + {'kind': 'not_a_kind', 'station': 'skierg'}, + ], + }), + isNull, + ); + }); + }); + + group('HyroxDb storage seam', () { + Future freshDb(String name) async { + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + return LocalDb.instance; + } + + HyroxSession armedSession(String id, {int createdAt = 1, String preset = 'hyrox'}) { + return HyroxSession( + id: id, + kind: 'race', + preset: preset, + status: 'armed', + plan: HyroxPlan.race(preset), + createdAt: createdAt, + updatedAt: createdAt, + ); + } + + test('create stores session and pre-populates all eight round rows', + () async { + final db = await freshDb('hx_create_test.db'); + await HyroxDb.create(db, armedSession('hx_create_1', createdAt: 1000)); + final read = await HyroxDb.byId(db, 'hx_create_1'); + expect(read, isNotNull); + expect(read!.status, 'armed'); + expect(read.segments.length, 8); + expect(read.segments[0].station, HyroxStation.skierg); + expect(read.segments[0].roundIndex, 1); + expect(read.segments[0].plannedDistanceM, 1000.0); + expect(read.segments[0].startTs, isNull); + expect(read.segments[7].station, HyroxStation.wallBalls); + expect(read.plan?.preset, 'hyrox'); + await LocalDb.close(); + }); + + test('activeSession returns the unfinalized active session', () async { + final db = await freshDb('hx_active_test.db'); + await HyroxDb.create(db, armedSession('hx_active_1', createdAt: 1000)); + await HyroxDb.startSegment( + db: db, + sessionId: 'hx_active_1', + ts: 2000, + boundarySource: 'gesture', + ); + final active = await HyroxDb.activeSession(db); + expect(active, isNotNull); + expect(active!.id, 'hx_active_1'); + expect(active.status, 'active'); + expect(active.startTs, 2000); + await LocalDb.close(); + }); + + test('segment save enforces the no-overlap boundary constraint', () async { + final db = await freshDb('hx_contig_test.db'); + await HyroxDb.create(db, armedSession('hx_contig_1')); + await HyroxDb.startSegment( + db: db, + sessionId: 'hx_contig_1', + ts: 1000, + boundarySource: 'gesture', + ); + await HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_contig_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + // Try to move round 0's end AFTER round 1's start: overlap, must fail. + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_contig_1'))[0] + .copyWith(endTs: 2100), + ), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_contig_1'); + expect(segs[0].endTs, 2000); + await LocalDb.close(); + }); + + test('finalizing freezes the session and rejects later writes', () async { + final db = await freshDb('hx_finalize_test.db'); + await HyroxDb.create(db, armedSession('hx_fin_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_fin_1', ts: 1000, boundarySource: 'gesture'); + for (var i = 0; i < 7; i++) { + await HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_fin_1', + currentIdx: i, + transitionTs: 2000 + i * 600, + boundarySource: 'gesture', + ); + } + await HyroxDb.finishSegment( + db: db, sessionId: 'hx_fin_1', ts: 2000 + 7 * 600 + 600, + boundarySource: 'gesture', + ); + await HyroxDb.finalize(db, 'hx_fin_1'); + final fin = (await HyroxDb.byId(db, 'hx_fin_1'))!; + expect(fin.isFinalized, isTrue); + expect(fin.status, 'finalized'); + expect(fin.totalSec, 2000 + 7 * 600 + 600 - 1000); + await expectLater( + HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_fin_1', + currentIdx: 0, + transitionTs: 9999, + boundarySource: 'gesture', + ), + throwsA(isA()), + ); + await expectLater( + HyroxDb.updateSession( + db, + HyroxSession( + id: fin.id, + kind: fin.kind, + preset: fin.preset, + status: 'discarded', + plan: fin.plan, + note: 'oops', + createdAt: fin.createdAt, + updatedAt: fin.updatedAt, + ), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalize refuses a session that never finished', () async { + final db = await freshDb('hx_fin_refuse_test.db'); + await HyroxDb.create(db, armedSession('hx_finref_1')); + await expectLater( + HyroxDb.finalize(db, 'hx_fin_refuse_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('hyrox tables participate in backup restore and salvage', () { + expect(LocalDb.restoreTablesForTest, + containsAllInOrder(['hyrox_session', 'hyrox_segment'])); + expect(LocalDb.salvageTablesForTest, + containsAllInOrder(['hyrox_session', 'hyrox_segment'])); + }); + + test('deleting a day works when no hyrox session exists', () async { + await freshDb('hx_delete_day_test.db'); + await LocalDb.deleteDays({'2026-01-01'}); + await LocalDb.close(); + }); + + test('band-only race: stored taps are reconciled into boundaries', + () async { + final db = await freshDb('hx_posthoc_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_posthoc_1', createdAt: t0 * 1000)); + + Future taps(int first, List offsets) async { + for (final o in offsets) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Start 10 min after arming; round 1 (skierg, 300 s floor) runs long + // enough; then two more accepted bursts. A lone accidental bump and a + // chained 0/6/12 s sequence must NOT count. + await taps(t0 + 600, [0, 2, 4]); // accepted: race start + await taps(t0 + 640, [0]); // lone tap: ignored + await taps(t0 + 900, [0, 1, 2]); // accepted: closes round 1 + await taps(t0 + 960, [0, 6, 12]); // chained: NOT a burst (12 > 6 window) + await taps(t0 + 1300, [0, 2, 4]); // accepted: closes round 2 + + final reconciler = HyroxReconciler(db); + final applied = await reconciler.reconcile('hx_posthoc_1', deviceId: ''); + + expect(applied, hasLength(3)); + final segs = await HyroxDb.segments(db, 'hx_posthoc_1'); + expect(segs[0].startTs, t0 + 600); + expect(segs[0].endTs, t0 + 900); + expect(segs[0].boundarySource, 'posthoc'); + expect(segs[1].startTs, t0 + 900); + expect(segs[1].endTs, t0 + 1300); + expect(segs[2].startTs, t0 + 1300); + expect(segs[2].endTs, isNull); + final read = await HyroxDb.byId(db, 'hx_posthoc_1'); + expect(read!.startTs, t0 + 600); + expect(read.status, 'active'); + + // Idempotent: the same log a second time adds nothing. + final again = await reconciler.reconcile('hx_posthoc_1', deviceId: ''); + expect(again, isEmpty); + await LocalDb.close(); + }); + + test('a burst under the round minimum is ignored', () async { + final db = await freshDb('hx_floor_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_floor_1', createdAt: t0 * 1000)); + + Future taps(int first) async { + for (final o in [0, 1, 2]) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Clears the gate 600 s after arming: this is the START, so no minimum + // applies to it. + await taps(t0 + 600); + // 60 s later a second burst tries to end round 1 (skierg floor 300 s). + // A round cannot be over, so it must be ignored. + await taps(t0 + 660); + + final applied = await HyroxReconciler(db).reconcile('hx_floor_1', deviceId: ''); + expect(applied, hasLength(1)); + final segs = await HyroxDb.segments(db, 'hx_floor_1'); + expect(segs[0].startTs, t0 + 600); + expect(segs[0].endTs, isNull); + expect(segs[1].startTs, isNull); + await LocalDb.close(); + }); + + test('a discarded session rejects every write path', () async { + final db = await freshDb('hx_discarded_writes_test.db'); + await HyroxDb.create(db, armedSession('hx_disc_w_1')); + final current = (await HyroxDb.byId(db, 'hx_disc_w_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'discarded', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await expectLater( + HyroxDb.startSegment( + db: db, sessionId: 'hx_disc_w_1', ts: 2000, + boundarySource: 'gesture'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.transitionSegment( + db: db, sessionId: 'hx_disc_w_1', currentIdx: 0, + transitionTs: 3000, boundarySource: 'gesture'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.adjustStart(db: db, sessionId: 'hx_disc_w_1', newTs: 2500), + throwsA(isA()), + ); + await expectLater( + HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'active', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ), + throwsA(isA()), + ); + expect((await HyroxDb.byId(db, 'hx_disc_w_1'))!.status, 'discarded'); + await LocalDb.close(); + }); + + test('saveSegment cannot change the workout link', () async { + final db = await freshDb('hx_link_imm_test.db'); + await HyroxDb.create(db, armedSession('hx_linkimm_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_linkimm_1', ts: 1000, + boundarySource: 'gesture'); + await HyroxDb.linkWorkout(db, 'hx_linkimm_1', 0, 'w1'); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_linkimm_1'))[0] + .copyWith(workoutId: 'w2'), + ), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_linkimm_1'); + expect(segs[0].workoutId, 'w1'); + await LocalDb.close(); + }); + + test('a discarded race is never rebuilt by the reconciler', () async { + final db = await freshDb('hx_discarded_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_disc_1', createdAt: t0 * 1000)); + final current = (await HyroxDb.byId(db, 'hx_disc_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, + kind: current.kind, + preset: current.preset, + status: 'discarded', + plan: current.plan, + createdAt: current.createdAt, + updatedAt: current.updatedAt, + ), + ); + for (final o in [0, 1, 2]) { + final ts = t0 + 600 + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + final applied = await HyroxReconciler(db).reconcile('hx_disc_1', deviceId: ''); + expect(applied, isEmpty); + expect((await HyroxDb.byId(db, 'hx_disc_1'))!.status, 'discarded'); + await LocalDb.close(); + }); + + test('transition and boundary adjustment move both sides atomically', + () async { + final db = await freshDb('hx_adjbound_test.db'); + await HyroxDb.create(db, armedSession('hx_adjb_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_adjb_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_adjb_1', currentIdx: 0, + transitionTs: 1600, boundarySource: 'gesture'); + await HyroxDb.adjustBoundary( + db: db, sessionId: 'hx_adjb_1', idx: 0, newTs: 1700); + final segs = await HyroxDb.segments(db, 'hx_adjb_1'); + expect(segs[0].endTs, 1700); + expect(segs[1].startTs, 1700); + // A human correction is 'manual' on BOTH rows. + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].boundarySource, 'manual'); + // Moving the boundary before round 0's start is refused. + await expectLater( + HyroxDb.adjustBoundary( + db: db, sessionId: 'hx_adjb_1', idx: 0, newTs: 500), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('an out-of-order segment is refused by the timeline rule', () async { + final db = await freshDb('hx_order_test.db'); + await HyroxDb.create(db, armedSession('hx_order_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_order_1', ts: 1000, boundarySource: 'gesture'); + // Round 2 has no start while round 1 never started: prefix rule. + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_order_1'))[2] + .copyWith(startTs: 1500, endTs: 1600), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finish only closes the final segment', () async { + final db = await freshDb('hx_finish_test.db'); + await HyroxDb.create(db, armedSession('hx_finish_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_finish_1', ts: 1000, boundarySource: 'gesture'); + // Round 0 is open but is not the last: finishSegment must refuse. + await expectLater( + HyroxDb.finishSegment( + db: db, sessionId: 'hx_finish_1', ts: 2000, + boundarySource: 'gesture'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalizing refuses an open segment in a finished session', () async { + final db = await freshDb('hx_openfin_test.db'); + await HyroxDb.create(db, armedSession('hx_openfin_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_openfin_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_openfin_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + // Force the status to finished while round 1 is still open: finalize + // must refuse rather than freeze a lie. + await db.update('hyrox_session', {'status': 'finished'}, + where: 'id = ?', whereArgs: ['hx_openfin_1']); + await expectLater( + HyroxDb.finalize(db, 'hx_openfin_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a plan field cannot be rewritten through saveSegment', () async { + final db = await freshDb('hx_planimm_test.db'); + await HyroxDb.create(db, armedSession('hx_planimm_1')); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_planimm_1'))[0].copyWith( + plannedReps: 999, + ), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a workout cannot be linked twice, and a missing segment refuses', + () async { + final db = await freshDb('hx_link_test.db'); + await HyroxDb.create(db, armedSession('hx_link_1')); + await HyroxDb.linkWorkout(db, 'hx_link_1', 0, 'w1'); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 1, 'w1'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 42, 'w2'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 2, ' '), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_link_1'); + expect(segs[0].workoutId, 'w1'); + expect(segs[1].workoutId, isNull); + await LocalDb.close(); + }); + + test('deleting a day removes the race and its segments', () async { + final db = await freshDb('hx_delete_rows_test.db'); + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_delete_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_delete_1', ts: nowSec - 60, + boundarySource: 'gesture'); + await LocalDb.deleteDays({dayLabelOf(DateTime.now())}); + expect(await HyroxDb.byId(db, 'hx_delete_1'), isNull); + expect( + await db.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_delete_1']), + isEmpty, + ); + await LocalDb.close(); + }); + + test('a fresh install creates the hyrox tables at the current schema', + () async { + final db = await freshDb('hx_schema_fresh_test.db'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['hyrox_session', 'hyrox_segment'])); + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion); + expect(LocalDb.schemaVersion, 60); + await LocalDb.close(); + }); + + test('an install at v58 is upgraded to the current schema', () async { + const name = 'hx_upgrade_58_test.db'; + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + final path = p.join(dir, name); + await databaseFactory.deleteDatabase(path); + await LocalDb.instance; + await LocalDb.close(); + + // Rewind to v58 without the hyrox tables, exactly what a user who + // installed before this PR has. + final raw = await databaseFactory.openDatabase(path); + await raw.execute('DROP TABLE hyrox_segment'); + await raw.execute('DROP TABLE hyrox_session'); + await raw.execute('PRAGMA user_version = 58'); + await raw.close(); + + final db = await LocalDb.instance; + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion, + reason: 'the v60 ladder rung must run, not just the repair pass'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['hyrox_session', 'hyrox_segment'])); + await LocalDb.close(); + }); + + test('updateSession cannot move the derived bounds', () async { + final db = await freshDb('hx_bounds_test.db'); + await HyroxDb.create(db, armedSession('hx_bounds_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_bounds_1', ts: 1000, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_bounds_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, + kind: current.kind, + preset: current.preset, + status: 'active', + plan: current.plan, + startTs: 999999, + endTs: 999999, + note: 'felt good', + createdAt: current.createdAt, + updatedAt: current.updatedAt, + ), + ); + final read = (await HyroxDb.byId(db, 'hx_bounds_1'))!; + expect(read.startTs, 1000); + expect(read.endTs, isNull); + expect(read.note, 'felt good'); + await LocalDb.close(); + }); + + test('deleting the second day removes a race that crossed midnight', + () async { + final db = await freshDb('hx_midnight_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_midnight_1', createdAt: (midnightSec - 600) * 1000)); + // Round 1 starts yesterday, round 2 starts today. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_midnight_1', ts: midnightSec - 600, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_midnight_1', currentIdx: 0, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_midnight_1'), isNull); + await LocalDb.close(); + }); + + test('a day export carries the race and restores it whole', () async { + final db = await freshDb('hx_roundtrip_source.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_export_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_export_1', ts: nowSec - 60, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_export_1', currentIdx: 0, + transitionTs: nowSec - 30, boundarySource: 'gesture'); + + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + expect(File(path).existsSync(), isTrue); + + // Import into a second, empty database. + await LocalDb.close(); + LocalDb.dbName = 'hx_roundtrip_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + final counts = await LocalDb.importFromDbFile(path); + expect(counts['hyrox_session'], 1); + expect(counts['hyrox_segment'], 8); + + final restored = await HyroxDb.byId(dest, 'hx_export_1'); + expect(restored, isNotNull); + expect(restored!.segments, hasLength(8)); + expect(restored.segments[0].startTs, nowSec - 60); + expect(restored.segments[1].startTs, nowSec - 30); + expect(restored.plan?.preset, 'hyrox'); + await File(path).delete(); + await LocalDb.close(); + }); + + test('create only accepts an armed plan without history', () async { + final db = await freshDb('hx_create_inv_test.db'); + final plan = HyroxPlan.race('hyrox'); + // Not armed. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_1', kind: 'race', preset: 'hyrox', status: 'active', + plan: plan, createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); + // Pre-filled bounds. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_2', kind: 'race', preset: 'hyrox', status: 'armed', + plan: plan, startTs: 5, createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); + // Segment rows carrying history. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_3', kind: 'race', preset: 'hyrox', status: 'armed', + plan: plan, createdAt: 1, updatedAt: 1, + segments: [ + HyroxSegment( + sessionId: 'hx_ci_3', idx: 0, + kind: HyroxSegmentKind.hyroxRound, station: HyroxStation.skierg, + roundIndex: 1, plannedDistanceM: 1000, + startTs: 100, + ), + for (var i = 1; i < 8; i++) + HyroxSegment( + sessionId: 'hx_ci_3', idx: i, + kind: HyroxSegmentKind.hyroxRound, + station: kHyroxStationOrder[i], roundIndex: i + 1, + plannedDistanceM: kHyroxStationTargets[kHyroxStationOrder[i]]?.$1, + plannedReps: kHyroxStationTargets[kHyroxStationOrder[i]]?.$2, + plannedCal: kHyroxStationTargets[kHyroxStationOrder[i]]?.$3, + ), + ], + ), + ), + throwsA(isA()), + ); + // Duplicate id. + await HyroxDb.create(db, armedSession('hx_ci_4')); + await expectLater( + HyroxDb.create(db, armedSession('hx_ci_4')), + throwsA(isA()), + ); + // Session preset must match the frozen plan's preset. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_5', kind: 'race', preset: 'hyrox_doubles', + status: 'armed', plan: HyroxPlan.race('hyrox'), + createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('adjustStart corrects a false start and refuses a started race', + () async { + final db = await freshDb('hx_adjstart_test.db'); + await HyroxDb.create(db, armedSession('hx_adjstart_1')); + // False triple-tap at the start line starts the race at the wrong time. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_adjstart_1', ts: 1000, + boundarySource: 'gesture'); + // Not yet transitioned: the start is still correctable. + await HyroxDb.adjustStart( + db: db, sessionId: 'hx_adjstart_1', newTs: 1500); + var segs = await HyroxDb.segments(db, 'hx_adjstart_1'); + expect(segs[0].startTs, 1500); + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].startTs, isNull); + var read = await HyroxDb.byId(db, 'hx_adjstart_1'); + expect(read!.startTs, 1500); + expect(read.status, 'active'); + + // Once a segment has ENDED, the start is history and cannot move. + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_adjstart_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await expectLater( + HyroxDb.adjustStart( + db: db, sessionId: 'hx_adjstart_1', newTs: 1600), + throwsA(isA()), + ); + segs = await HyroxDb.segments(db, 'hx_adjstart_1'); + expect(segs[0].startTs, 1500); + await LocalDb.close(); + }); + + test('removeBoundary merges a false split and refuses a started tail', + () async { + final db = await freshDb('hx_rmb_test.db'); + await HyroxDb.create(db, armedSession('hx_rmb_1')); + // round0 1000->1600, round1 1600->1700, round2 1700->3100, round3 open. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_rmb_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 0, + transitionTs: 1600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 1, + transitionTs: 1700, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 2, + transitionTs: 3100, boundarySource: 'gesture'); + // A later segment started: boundary 1 cannot be removed anymore. + await expectLater( + HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmb_1', idx: 1), + throwsA(isA()), + ); + // Boundary 2 is the live edge: removing it merges round 3 into round 2. + await HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmb_1', idx: 2); + final segs = await HyroxDb.segments(db, 'hx_rmb_1'); + expect(segs[2].startTs, 1700); + expect(segs[2].endTs, isNull); + expect(segs[2].boundarySource, 'manual'); + expect(segs[3].startTs, isNull); + expect(segs[3].endTs, isNull); + expect(segs.where((s) => s.isOpen).length, 1); + await LocalDb.close(); + }); + + test('an open segment from yesterday is deleted with today', () async { + final db = await freshDb('hx_open_overlap_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_open_overlap_1', + createdAt: (midnightSec - 600) * 1000)); + // Round 1 started yesterday and is STILL OPEN at midnight (live session). + await HyroxDb.startSegment( + db: db, sessionId: 'hx_open_overlap_1', ts: midnightSec - 300, + boundarySource: 'gesture'); + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_open_overlap_1'), isNull); + await LocalDb.close(); + }); + + test('offline boundaries after the final segment are rejected', () async { + final db = await freshDb('hx_offline_tail_test.db'); + await HyroxDb.create(db, armedSession('hx_tail_1')); + // Boundary i closes round i (the first one also starts the race), so it + // carries that round's station; a 9th boundary follows the final round. + final bounds = [ + for (var i = 0; i < 8; i++) + ReconcileBoundary(1000 + i * 1000, 3, HyroxSegmentKind.hyroxRound, + kHyroxStationOrder[i]), + const ReconcileBoundary(9999, 3, HyroxSegmentKind.hyroxRound, + HyroxStation.wallBalls), + ]; + await expectLater( + db.transaction((txn) async { + await HyroxDb.applyOfflineBoundariesInTx( + txn, + sessionId: 'hx_tail_1', + boundaries: bounds, + ); + }), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_tail_1'); + expect(segs[7].endTs, isNull, reason: 'the transaction rolled back'); + expect(segs[0].startTs, isNull, reason: 'nothing half-applied'); + await LocalDb.close(); + }); + + test('a day export includes a race whose open segment overlaps it', + () async { + final db = await freshDb('hx_export_overlap_src.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_export_overlap_1', + createdAt: (midnightSec - 600) * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_export_overlap_1', ts: midnightSec - 300, + boundarySource: 'gesture'); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + final exported = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true)); + final rows = await exported.query('hyrox_session', + where: 'id = ?', whereArgs: ['hx_export_overlap_1']); + final segs = await exported.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_export_overlap_1']); + expect(rows, hasLength(1)); + expect(segs, hasLength(8)); + await exported.close(); + await File(path).delete(); + await LocalDb.close(); + }); + + test('a pause between rounds is legal and reported separately', () async { + final db = await freshDb('hx_pause_test.db'); + await HyroxDb.create(db, armedSession('hx_pause_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_pause_1', ts: 1000, boundarySource: 'gesture'); + // Round 0 ends at 2000; round 1 only starts at 5600 -> 3600 s pause. + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_pause_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_pause_1'))[1] + .copyWith(startTs: 5600, endTs: 5660), + ); + final read = (await HyroxDb.byId(db, 'hx_pause_1'))!; + expect(read.movingSec, 1000 + 60); // round 0 + round 1 + expect(read.totalSec, 5660 - 1000); + expect(read.pauseSec, (5660 - 1000) - (1000 + 60)); + await LocalDb.close(); + }); + + test('an overlap between rounds is refused', () async { + final db = await freshDb('hx_overlap_test.db'); + await HyroxDb.create(db, armedSession('hx_overlap_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_overlap_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_overlap_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_overlap_1'))[0] + .copyWith(endTs: 2100), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a DNF (abandoned mid-race) finalizes with an open cut segment', + () async { + final db = await freshDb('hx_dnf_test.db'); + await HyroxDb.create(db, armedSession('hx_dnf_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + // Athlete quits during round 2: round 2 stays open, later rounds untouched. + final current = (await HyroxDb.byId(db, 'hx_dnf_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_1'); + final fin = (await HyroxDb.byId(db, 'hx_dnf_1'))!; + expect(fin.isFinalized, isTrue); + expect(fin.status, 'finalized'); + expect(fin.segments[1].endTs, isNull, reason: 'open segment stays open'); + expect(fin.segments[1].durationSec, isNull, reason: 'no invented time'); + expect(fin.segments[2].startTs, isNull); + expect(fin.movingSec, 1000); // only round 1 was measured + await LocalDb.close(); + }); + + test('finalizing a never-started session is refused', () async { + final db = await freshDb('hx_dnf_empty_test.db'); + await HyroxDb.create(db, armedSession('hx_dnf_empty_1')); + final current = (await HyroxDb.byId(db, 'hx_dnf_empty_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await expectLater( + HyroxDb.finalize(db, 'hx_dnf_empty_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + // CodeRabbit follow-up: a finalized DNF whose open cut segment STARTS on + // the later day must still be caught by that day's window (the segment + // start branch), both on delete and on export. + test('a DNF cut segment starting after midnight is deleted with the ' + 'second day', () async { + final db = await freshDb('hx_dnf_midnight_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_dnf_mid_1', + createdAt: (midnightSec - 3600) * 1000)); + // Round 1 runs yesterday and closes yesterday; round 2 (the cut) STARTS + // today and stays open; the race is abandoned + finalized. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_mid_1', ts: midnightSec - 3000, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_1', currentIdx: 0, + transitionTs: midnightSec - 600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_1', currentIdx: 1, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_dnf_mid_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_mid_1'); + + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_dnf_mid_1'), isNull, + reason: 'the cut segment starts today, so the race belongs to today'); + expect( + await db.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_dnf_mid_1']), + isEmpty, + ); + await LocalDb.close(); + }); + + test('a DNF cut segment starting after midnight is included in the ' + 'second day export', () async { + final db = await freshDb('hx_dnf_midnight_export_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_dnf_mid_exp_1', + createdAt: (midnightSec - 3600) * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', ts: midnightSec - 3000, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', currentIdx: 0, + transitionTs: midnightSec - 600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', currentIdx: 1, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_dnf_mid_exp_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_mid_exp_1'); + + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + final exported = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true)); + final rows = await exported.query('hyrox_session', + where: 'id = ?', whereArgs: ['hx_dnf_mid_exp_1']); + final segs = await exported.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_dnf_mid_exp_1']); + expect(rows, hasLength(1), + reason: 'the cut segment starts today, so the race exports with today'); + expect(segs, hasLength(8)); + await exported.close(); + await File(path).delete(); + await LocalDb.close(); + }); + + // CodeRabbit follow-up: re-importing an OLDER snapshot must not overwrite + // the segments of a locally newer session whose parent row was skipped. + test('restore keeps segments of a locally newer (skipped) session', + () async { + final db = await freshDb('hx_restore_src.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_restore_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_restore_1', ts: nowSec - 120, + boundarySource: 'gesture'); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + await LocalDb.close(); + + // Destination: import the snapshot, then advance the race locally so + // the local row is newer than the snapshot. + LocalDb.dbName = 'hx_restore_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + await LocalDb.importFromDbFile(path); + await HyroxDb.transitionSegment( + db: dest, sessionId: 'hx_restore_1', currentIdx: 0, + transitionTs: nowSec - 60, boundarySource: 'gesture'); + final before = await HyroxDb.segments(dest, 'hx_restore_1'); + expect(before[0].endTs, nowSec - 60); + expect(before[1].startTs, nowSec - 60); + + // Re-import the same (older) snapshot: the parent is skipped because + // the local row is newer, and its segments must follow that verdict. + await LocalDb.importFromDbFile(path); + final after = await HyroxDb.segments(dest, 'hx_restore_1'); + expect(after[0].endTs, nowSec - 60, + reason: 'segment rows must not be overwritten by the older snapshot'); + expect(after[1].startTs, nowSec - 60); + await File(path).delete(); + await LocalDb.close(); + }); + + test('a restore cut off after the parent resumes and brings the segments', + () async { + final db = await freshDb('hx_resume_src.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_resume_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, + sessionId: 'hx_resume_1', + ts: nowSec - 120, + boundarySource: 'gesture'); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + await LocalDb.close(); + + LocalDb.dbName = 'hx_resume_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + await LocalDb.importFromDbFile(path); + + // The restore stopped between the two tables: the parent is on disk, + // its segments are not. + await dest.delete('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_resume_1']); + expect(await HyroxDb.segments(dest, 'hx_resume_1'), isEmpty); + + final counts = await LocalDb.importFromDbFile(path); + expect(counts['hyrox_segment'], 8, + reason: 'the parent being current must not hide its missing rows'); + final back = await HyroxDb.segments(dest, 'hx_resume_1'); + expect(back, hasLength(8)); + expect(back[0].startTs, nowSec - 120); + + // A complete race is still protected: a second import writes nothing. + final again = await LocalDb.importFromDbFile(path); + expect(again['hyrox_segment'] ?? 0, 0); + await File(path).delete(); + await LocalDb.close(); + }); + + test('retention keeps the taps of a race that is still armed or active', + () async { + final db = await freshDb('hx_prune_taps_test.db'); + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_prune_1', createdAt: nowSec * 1000)); + Future tap(int ts) => db.insert('band_events', { + 'device_id': '', + 'hex': 'e14_$ts', + 'event_id': 14, + 'name': 'E14', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + await tap(nowSec + 10); + await tap(nowSec - 5000); + + Future> left() async => [ + for (final r in await db.query('band_events', + columns: ['ts'], where: 'event_id = 14')) + (r['ts'] as num).toInt(), + ]; + await LocalDb.pruneDecodedBeforeRecTs(nowSec + 100000); + expect(await left(), [nowSec + 10]); + + await db.update('hyrox_session', {'status': 'discarded'}, + where: 'id = ?', whereArgs: ['hx_prune_1']); + await LocalDb.pruneDecodedBeforeRecTs(nowSec + 100000); + expect(await left(), isEmpty); + await LocalDb.close(); + }); + + test('a start tap from a strap running behind the phone is not dropped', + () { + final plan = HyroxPlan.race('hyrox'); + final segs = [ + for (var i = 0; i < plan.segments.length; i++) + HyroxSegment( + sessionId: 's', + idx: i, + kind: plan.segments[i].kind, + station: plan.segments[i].station, + roundIndex: plan.segments[i].roundIndex, + ), + ]; + // Armed at 10_000 on the phone; the strap is 60 s behind, so a start + // tapped 40 s after arming carries a strap time 20 s BEFORE it. + final out = deriveBoundaries( + taps: [9980, 9982, 9984], + segments: segs, + plan: plan, + sessionCreatedAtSec: 10000, + ); + expect(out, hasLength(1)); + expect(out.single.atSec, 9980); + + final stale = deriveBoundaries( + taps: [9800, 9802, 9804], + segments: segs, + plan: plan, + sessionCreatedAtSec: 10000, + ); + expect(stale, isEmpty); + }); + + // CodeRabbit follow-up: removeBoundary must refuse to merge into the final + // segment when that segment is already closed (finished race). + test('removeBoundary refuses to merge into a closed final segment', + () async { + final db = await freshDb('hx_rmb_final_test.db'); + await HyroxDb.create(db, armedSession('hx_rmbf_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_rmbf_1', ts: 1000, boundarySource: 'gesture'); + for (var i = 0; i < 7; i++) { + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmbf_1', currentIdx: i, + transitionTs: 2000 + i * 600, boundarySource: 'gesture'); + } + await HyroxDb.finishSegment( + db: db, sessionId: 'hx_rmbf_1', ts: 2000 + 7 * 600 + 600, + boundarySource: 'gesture'); + // The race is finished: boundary 6 (round 7 -> final round 8) must not + // be removable, because that would clear the closed final segment. + await expectLater( + HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmbf_1', idx: 6), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_rmbf_1'); + expect(segs[7].startTs, isNotNull); + expect(segs[7].endTs, isNotNull); + await LocalDb.close(); + }); + }); +} diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart new file mode 100644 index 000000000..281e6924b --- /dev/null +++ b/test/triathlon_model_storage_test.dart @@ -0,0 +1,1699 @@ +// PR 1 tests: triathlon model, validation, storage invariants, and schema +// migration ladder integration. +// +// Runs against the real sqflite_ffi database — the repo standard for DB tests. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/state/triathlon_reconciler.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/triathlon_plan.dart'; +import 'package:openstrap_edge/data/triathlon_store.dart'; + +/// path_provider has no plugin in a unit test; exportDaysDb needs a temp dir. +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + PathProviderPlatform.instance = _FakePathProvider( + Directory.systemTemp.createTempSync('tri_paths').path, + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + }); + + group('TriathlonPlan pure model validation', () { + test('a race plan builds the standard 5-segment order with distances', () { + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + expect(plan.validate(), isNull); + expect(plan.segments.length, 5); + expect(plan.segments[0].kind, TriSegmentKind.swim); + expect(plan.segments[0].plannedDistanceM, 750.0); + expect(plan.segments[1].kind, TriSegmentKind.t1); + expect(plan.segments[2].kind, TriSegmentKind.bike); + expect(plan.segments[2].plannedDistanceM, 20000.0); + expect(plan.segments[3].kind, TriSegmentKind.t2); + expect(plan.segments[4].kind, TriSegmentKind.run); + expect(plan.segments[4].plannedDistanceM, 5000.0); + expect(plan.needsGps, isTrue); + }); + + test('an all-indoor race plan needs no GPS and keeps its distances', () { + final plan = TriathlonPlan.race( + 'olympic', + swimEnv: TriEnvironment.indoor, + bikeEnv: TriEnvironment.indoor, + runEnv: TriEnvironment.indoor, + ); + expect(plan.validate(), isNull); + expect(plan.needsGps, isFalse); + expect(plan.segments[0].environment, TriEnvironment.indoor); + expect(plan.segments[2].environment, TriEnvironment.indoor); + expect(plan.segments[4].environment, TriEnvironment.indoor); + }); + + test('mixed environment arms GPS only for outdoor segments', () { + final plan = TriathlonPlan.race( + 'half_distance', + swimEnv: TriEnvironment.indoor, // pool + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.indoor, // treadmill + ); + expect(plan.validate(), isNull); + expect(plan.needsGps, isTrue); + expect( + triathlonSegmentRecordsRoute(TriSegmentKind.bike, TriEnvironment.outdoor), + isTrue, + ); + expect( + triathlonSegmentRecordsRoute(TriSegmentKind.run, TriEnvironment.indoor), + isFalse, + ); + }); + + test('training plan allows brick workouts (bike -> run)', () { + final plan = TriathlonPlan.training(const [ + TriSegmentPlan( + kind: TriSegmentKind.bike, + environment: TriEnvironment.outdoor, + plannedDistanceM: 40000, + ), + TriSegmentPlan(kind: TriSegmentKind.t2), + TriSegmentPlan( + kind: TriSegmentKind.run, + environment: TriEnvironment.outdoor, + plannedDistanceM: 10000, + ), + ]); + expect(plan.validate(), isNull); + expect(plan.segments.length, 3); + expect(plan.isTraining, isTrue); + }); + + test('validation catches broken plans', () { + final empty = TriathlonPlan.training(const []); + expect(empty.validate(), contains('needs at least one segment')); + + final startsWithT1 = TriathlonPlan.training(const [ + TriSegmentPlan(kind: TriSegmentKind.t1), + TriSegmentPlan(kind: TriSegmentKind.run, environment: TriEnvironment.outdoor), + ]); + expect(startsWithT1.validate(), contains('discipline')); + + final unchosenEnv = TriathlonPlan.training(const [ + TriSegmentPlan(kind: TriSegmentKind.run), + ]); + expect(unchosenEnv.validate(), contains('indoor/outdoor')); + + final negativeDistance = TriathlonPlan.training(const [ + TriSegmentPlan( + kind: TriSegmentKind.run, + environment: TriEnvironment.outdoor, + plannedDistanceM: -100, + ), + ]); + expect(negativeDistance.validate(), contains('finite and positive')); + }); + + test('json round-trip preserves preset and environment choices', () { + final plan = TriathlonPlan.race( + 'full_distance', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.indoor, + goalSec: const {TriSegmentKind.run: 14400}, + ); + final json = plan.toJson(); + final roundTrip = TriathlonPlan.fromJson(json); + expect(roundTrip, isNotNull); + expect(roundTrip!.preset, 'full_distance'); + expect(roundTrip.segments[4].environment, TriEnvironment.indoor); + expect(roundTrip.segments[4].goalSec, 14400); + }); + }); + + group('TriathlonDb storage seam', () { + Future freshDb(String name) async { + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + return LocalDb.instance; + } + + test('create stores session and pre-populates all planned segment rows', + () async { + final db = await freshDb('tri_create_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final t0 = DateTime.now().millisecondsSinceEpoch; + final session = TriSession( + id: 'tri_sprint_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + + await TriathlonDb.create(db, session); + final read = await TriathlonDb.byId(db, 'tri_sprint_1'); + expect(read, isNotNull); + expect(read!.status, 'armed'); + expect(read.segments.length, 5); + expect(read.segments[0].kind, TriSegmentKind.swim); + expect(read.segments[0].plannedDistanceM, 750.0); + expect(read.segments[0].startTs, isNull); + await LocalDb.close(); + }); + + test('activeSession returns the unfinalized active session', () async { + final db = await freshDb('tri_active_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final t0 = DateTime.now().millisecondsSinceEpoch; + final session = TriSession( + id: 'tri_sprint_active', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + + await TriathlonDb.create(db, session); + final active = await TriathlonDb.activeSession(db); + expect(active, isNotNull); + expect(active!.id, 'tri_sprint_active'); + await LocalDb.close(); + }); + + test('segment save enforces contiguous boundary constraint', () async { + final db = await freshDb('tri_boundary_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final t0 = DateTime.now().millisecondsSinceEpoch; + final session = TriSession( + id: 'tri_sprint_bounds', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + await TriathlonDb.create(db, session); + + final seg0 = (await TriathlonDb.segments(db, 'tri_sprint_bounds'))[0]; + // Swim: 10:00 -> 10:15 + await TriathlonDb.saveSegment( + db, + seg0.copyWith(startTs: 1000, endTs: 1900, boundarySource: 'gesture'), + ); + + final seg1 = (await TriathlonDb.segments(db, 'tri_sprint_bounds'))[1]; + // T1 starts at 1900 (matches swim end). + await TriathlonDb.saveSegment( + db, + seg1.copyWith(startTs: 1900, endTs: 1960, boundarySource: 'gesture'), + ); + + // A gap after T1's 1960 is a PAUSE and is legal now: T2 may start at + // 2000 (40 s later). + final seg2 = (await TriathlonDb.segments(db, 'tri_sprint_bounds'))[2]; + await TriathlonDb.saveSegment( + db, + seg2.copyWith(startTs: 2000, endTs: 3000, boundarySource: 'gesture'), + ); + final read = (await TriathlonDb.byId(db, 'tri_sprint_bounds'))!; + expect(read.pauseSec, 40); + await LocalDb.close(); + }); + + test('finalizing freezes session and rejects later writes', () async { + final db = await freshDb('tri_finalize_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final t0 = DateTime.now().millisecondsSinceEpoch; + final session = TriSession( + id: 'tri_sprint_fin', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + await TriathlonDb.create(db, session); + + var t = 1000; + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_sprint_fin', + ts: t, + boundarySource: 'gesture', + ); + for (var i = 0; i < 4; i++) { + t += (i.isEven ? 1000 : 60); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_sprint_fin', + currentIdx: i, + transitionTs: t, + boundarySource: 'gesture', + ); + } + t += 800; + await TriathlonDb.finishSegment( + db: db, + sessionId: 'tri_sprint_fin', + ts: t, + boundarySource: 'gesture', + ); + + await TriathlonDb.finalize(db, 'tri_sprint_fin'); + final finalized = await TriathlonDb.byId(db, 'tri_sprint_fin'); + expect(finalized!.isFinalized, isTrue); + expect(finalized.status, 'finalized'); + expect(finalized.endTs, t); + + // Any attempt to edit a finalized segment must throw. + final seg0 = finalized.segments[0]; + await expectLater( + TriathlonDb.saveSegment( + db, + seg0.copyWith(distanceM: 750, distanceSource: 'user'), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalize refuses a session that never finished', () async { + final db = await freshDb('tri_finalize_unfinished_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_unfinished_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_unfinished_1', + ts: 1000, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.finalize(db, 'tri_unfinished_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('triathlon tables participate in backup restore and salvage', () { + expect(LocalDb.restoreTablesForTest, + containsAllInOrder(['triathlon_session', 'triathlon_segment'])); + expect(LocalDb.salvageTablesForTest, + containsAllInOrder(['triathlon_session', 'triathlon_segment'])); + }); + + test('deleting a day works when no triathlon exists', () async { + await freshDb('tri_delete_day_test.db'); + await LocalDb.deleteDays({'2026-01-01'}); + await LocalDb.close(); + }); + + test('band-only race: stored taps are reconciled into boundaries', + () async { + final db = await freshDb('tri_sync_posthoc_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final t0 = 1_000_000; + final session = TriSession( + id: 'tri_posthoc_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + await TriathlonDb.create(db, session); + + // The whole race happened with no phone in range: the strap recorded + // every tap. Three taps clear the gate; two do not. + Future taps(int first, List offsets) async { + for (final o in offsets) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Start 10 min after arming, swim for the 300 s floor, then T1 for + // longer than its own 20 s floor. + await taps(t0 + 600, [0, 2, 4]); + // A lone accidental bump must NOT advance anything. + await taps(t0 + 640, [0]); + await taps(t0 + 900, [0, 1, 2]); + await taps(t0 + 930, [0, 2, 4]); + + final reconciler = TriathlonReconciler(db); + final applied = await reconciler.reconcile('tri_posthoc_1'); + + expect(applied, hasLength(3)); + final segs = await TriathlonDb.segments(db, 'tri_posthoc_1'); + expect(segs[0].startTs, t0 + 600); + expect(segs[0].endTs, t0 + 900); + expect(segs[0].boundarySource, 'posthoc'); + expect(segs[1].startTs, t0 + 900); + expect(segs[1].endTs, t0 + 930); + expect(segs[2].startTs, t0 + 930); + final read = await TriathlonDb.byId(db, 'tri_posthoc_1'); + expect(read!.startTs, t0 + 600); + expect(read.status, 'active'); + + // Idempotent: the same log a second time adds nothing. + final again = await reconciler.reconcile('tri_posthoc_1'); + expect(again, isEmpty); + await LocalDb.close(); + }); + + test('a transition under the segment minimum is ignored', () async { + final db = await freshDb('tri_posthoc_floor_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_floor_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1_000_000, + updatedAt: 1_000_000, + ); + await TriathlonDb.create(db, session); + + Future taps(int first) async { + for (final o in [0, 1, 2]) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Clears the gate 600 s after arming: this is the START, not a + // transition, so no minimum applies to it. + await taps(1_000_600); + // 20 s later a second burst tries to end the swim. A swim cannot be + // over, so it must be ignored — the race keeps running. + await taps(1_000_620); + + final applied = await TriathlonReconciler(db).reconcile('tri_floor_1'); + expect(applied, hasLength(1)); + final segs = await TriathlonDb.segments(db, 'tri_floor_1'); + expect(segs[0].startTs, 1_000_600); + expect(segs[0].endTs, isNull); + expect(segs[1].startTs, isNull); + await LocalDb.close(); + }); + + test('transition and boundary adjustment move both sides atomically', + () async { + final db = await freshDb('tri_transition_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_move_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_move_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_move_1', + currentIdx: 0, + transitionTs: 1600, + boundarySource: 'gesture', + ); + await TriathlonDb.adjustBoundary( + db: db, + sessionId: 'tri_move_1', + idx: 0, + newTs: 1700, + ); + var segs = await TriathlonDb.segments(db, 'tri_move_1'); + expect(segs[0].endTs, 1700); + expect(segs[1].startTs, 1700); + + // A closed segment cannot be transitioned again. + await expectLater( + TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_move_1', + currentIdx: 0, + transitionTs: 1800, + boundarySource: 'gesture', + ), + throwsA(isA()), + ); + // But the next segment MAY start later than the previous end: a pause. + await TriathlonDb.saveSegment( + db, + (await TriathlonDb.segments(db, 'tri_move_1'))[1] + .copyWith(startTs: 2000), + ); + segs = await TriathlonDb.segments(db, 'tri_move_1'); + expect(segs[0].endTs, 1700); + expect(segs[1].startTs, 2000, reason: 'the gap is a pause, not an error'); + await LocalDb.close(); + }); + + test('an out-of-order segment is refused by the timeline rule', () async { + final db = await freshDb('tri_timeline_order_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_order_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + final seg3 = (await TriathlonDb.segments(db, 'tri_order_1'))[3]; + await expectLater( + TriathlonDb.saveSegment( + db, + seg3.copyWith(startTs: 500, endTs: 600), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finish only closes the final segment', () async { + final db = await freshDb('tri_finish_early_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_finish_early_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_finish_early_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_finish_early_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.finishSegment( + db: db, + sessionId: 'tri_finish_early_1', + ts: 2100, + boundarySource: 'gesture', + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalizing refuses an open segment', () async { + final db = await freshDb('tri_finalize_open_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_open_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_open_1', + ts: 1000, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.finalize(db, 'tri_open_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a plan field cannot be rewritten through saveSegment', () async { + final db = await freshDb('tri_plan_immutable_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_plan_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + final seg = (await TriathlonDb.segments(db, 'tri_plan_1'))[0]; + final relabelled = TriSegment( + sessionId: seg.sessionId, + idx: seg.idx, + kind: seg.kind, + environment: TriEnvironment.indoor, + plannedDistanceM: seg.plannedDistanceM, + ); + await expectLater( + TriathlonDb.saveSegment(db, relabelled), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a workout cannot be linked to two segments', () async { + final db = await freshDb('tri_link_unique_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_link_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.linkWorkout(db, 'tri_link_1', 0, 'w42'); + await expectLater( + TriathlonDb.linkWorkout(db, 'tri_link_1', 2, 'w42'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('deleting a day removes the race and its segments', () async { + final db = await freshDb('tri_delete_day_rows_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final session = TriSession( + id: 'tri_delete_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: nowSec * 1000, + updatedAt: nowSec * 1000, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_delete_1', + ts: nowSec - 60, + boundarySource: 'gesture', + ); + await LocalDb.deleteDays({dayLabelOf(DateTime.now())}); + expect(await TriathlonDb.byId(db, 'tri_delete_1'), isNull); + expect( + await db.query('triathlon_segment', + where: 'session_id = ?', whereArgs: ['tri_delete_1']), + isEmpty, + ); + await LocalDb.close(); + }); + + + test('a fresh install creates the triathlon tables', () async { + final db = await freshDb('tri_schema_fresh_test.db'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['triathlon_session', 'triathlon_segment'])); + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion); + await LocalDb.close(); + }); + + test('an install at v58 is upgraded to the current schema', () async { + const name = 'tri_upgrade_58_test.db'; + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + final path = p.join(dir, name); + await databaseFactory.deleteDatabase(path); + await LocalDb.instance; + await LocalDb.close(); + + // Rewind to v58 without the triathlon tables, exactly what a user who + // installed before this PR has. + final raw = await databaseFactory.openDatabase(path); + await raw.execute('DROP TABLE triathlon_segment'); + await raw.execute('DROP TABLE triathlon_session'); + await raw.execute('PRAGMA user_version = 58'); + await raw.close(); + + final db = await LocalDb.instance; + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion, + reason: 'the v60 ladder rung must run, not just the repair pass'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['triathlon_session', 'triathlon_segment'])); + await LocalDb.close(); + }); + + test('updateSession cannot move the derived bounds', () async { + final db = await freshDb('tri_update_bounds_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_bounds_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_bounds_1', + ts: 1000, + boundarySource: 'gesture', + ); + final current = (await TriathlonDb.byId(db, 'tri_bounds_1'))!; + await TriathlonDb.updateSession( + db, + TriSession( + id: current.id, + kind: current.kind, + preset: current.preset, + status: 'active', + plan: current.plan, + startTs: 999999, + endTs: 999999, + note: 'felt good', + createdAt: current.createdAt, + updatedAt: current.updatedAt, + ), + ); + final read = (await TriathlonDb.byId(db, 'tri_bounds_1'))!; + expect(read.startTs, 1000); + expect(read.endTs, isNull); + expect(read.note, 'felt good'); + await LocalDb.close(); + }); + + test('deleting the second day removes a race that crossed midnight', + () async { + final db = await freshDb('tri_midnight_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_midnight_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: (midnightSec - 600) * 1000, + updatedAt: (midnightSec - 600) * 1000, + ); + await TriathlonDb.create(db, session); + // Swim starts yesterday, the bike starts today. + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_midnight_1', + ts: midnightSec - 600, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_midnight_1', + currentIdx: 0, + transitionTs: midnightSec + 60, + boundarySource: 'gesture', + ); + + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await TriathlonDb.byId(db, 'tri_midnight_1'), isNull); + await LocalDb.close(); + }); + + + test('a day export carries the race and restores it whole', () async { + final db = await freshDb('tri_roundtrip_source.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_export_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: nowSec * 1000, + updatedAt: nowSec * 1000, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_export_1', + ts: nowSec - 60, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_export_1', + currentIdx: 0, + transitionTs: nowSec - 30, + boundarySource: 'gesture', + ); + + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + expect(File(path).existsSync(), isTrue); + + // Import into a second, empty database. + await LocalDb.close(); + LocalDb.dbName = 'tri_roundtrip_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + final counts = await LocalDb.importFromDbFile(path); + expect(counts['triathlon_session'], 1); + expect(counts['triathlon_segment'], 5); + + final restored = await TriathlonDb.byId(dest, 'tri_export_1'); + expect(restored, isNotNull); + expect(restored!.segments, hasLength(5)); + expect(restored.segments[0].startTs, nowSec - 60); + expect(restored.segments[1].startTs, nowSec - 30); + expect(restored.plan?.preset, 'sprint'); + await File(path).delete(); + await LocalDb.close(); + }); + + + test('a restore cut off after the parent resumes and brings the segments', + () async { + final db = await freshDb('tri_resume_source.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + await TriathlonDb.create( + db, + TriSession( + id: 'tri_resume_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: nowSec * 1000, + updatedAt: nowSec * 1000, + ), + ); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_resume_1', + ts: nowSec - 60, + boundarySource: 'gesture', + ); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + + await LocalDb.close(); + LocalDb.dbName = 'tri_resume_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + await LocalDb.importFromDbFile(path); + + // The restore stopped between the two tables: the parent is on disk, + // its segments are not. + await dest.delete('triathlon_segment', + where: 'session_id = ?', whereArgs: ['tri_resume_1']); + expect(await TriathlonDb.segments(dest, 'tri_resume_1'), isEmpty); + + final counts = await LocalDb.importFromDbFile(path); + expect(counts['triathlon_segment'], 5, + reason: 'the parent being current must not hide its missing rows'); + final back = await TriathlonDb.byId(dest, 'tri_resume_1'); + expect(back!.segments, hasLength(5)); + expect(back.segments[0].startTs, nowSec - 60); + + // A complete race is still protected: a second import writes nothing. + final again = await LocalDb.importFromDbFile(path); + expect(again['triathlon_segment'] ?? 0, 0); + await File(path).delete(); + await LocalDb.close(); + }); + + test('retention keeps the taps of a race that is still armed or active', + () async { + final db = await freshDb('tri_prune_taps_test.db'); + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + await TriathlonDb.create( + db, + TriSession( + id: 'tri_prune_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: nowSec * 1000, + updatedAt: nowSec * 1000, + ), + ); + Future tap(int ts, {int event = 14}) => db.insert('band_events', { + 'device_id': '', + 'hex': 'e${event}_$ts', + 'event_id': event, + 'name': 'E$event', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + await tap(nowSec + 10); // after arming: the race's own + await tap(nowSec - 5000); // long before arming: nobody's + await tap(nowSec + 11, event: 33); // some other event: ordinary prune + + await LocalDb.pruneDecodedBeforeRecTs(nowSec + 100000); + Future> left(int event) async => [ + for (final r in await db.query('band_events', + columns: ['ts'], + where: 'event_id = ?', + whereArgs: [event])) + (r['ts'] as num).toInt(), + ]; + expect(await left(14), [nowSec + 10]); + expect(await left(33), isEmpty); + + // Once the race is no longer open its taps are ordinary again. + await db.update('triathlon_session', {'status': 'discarded'}, + where: 'id = ?', whereArgs: ['tri_prune_1']); + await LocalDb.pruneDecodedBeforeRecTs(nowSec + 100000); + expect(await left(14), isEmpty); + await LocalDb.close(); + }); + + test('a start tap from a strap running behind the phone is not dropped', + () { + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final segs = [ + for (var i = 0; i < plan.segments.length; i++) + TriSegment( + sessionId: 's', + idx: i, + kind: plan.segments[i].kind, + ), + ]; + // Armed at 10_000 on the phone; the strap is 60 s behind, so a start + // tapped 40 s after arming carries a strap time 20 s BEFORE it. + final out = deriveBoundaries( + taps: [9980, 9982, 9984], + segments: segs, + plan: plan, + sessionCreatedAtSec: 10000, + ); + expect(out, hasLength(1)); + expect(out.single.atSec, 9980); + + // Past the drift allowance it is genuinely from before the race. + final stale = deriveBoundaries( + taps: [9800, 9802, 9804], + segments: segs, + plan: plan, + sessionCreatedAtSec: 10000, + ); + expect(stale, isEmpty); + }); + + + test('create only accepts an armed plan without history', () async { + final db = await freshDb('tri_create_invariants_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final base = TriSession( + id: 'tri_create_bad_1', + kind: 'race', + preset: 'sprint', + status: 'active', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await expectLater( + TriathlonDb.create(db, base), + throwsA(isA()), + ); + + // A full plan whose first segment already carries a start: the plan is + // fine, the HISTORY is what create must refuse. + final history = TriSession( + id: 'tri_create_bad_2', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + segments: [ + for (var i = 0; i < plan.segments.length; i++) + TriSegment( + sessionId: 'tri_create_bad_2', + idx: i, + kind: plan.segments[i].kind, + environment: plan.segments[i].environment, + plannedDistanceM: plan.segments[i].plannedDistanceM, + startTs: i == 0 ? 1000 : null, + ), + ], + ); + await expectLater( + TriathlonDb.create(db, history), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a manual boundary is marked manual on both sides', () async { + final db = await freshDb('tri_manual_source_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_manual_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_manual_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_manual_1', + currentIdx: 0, + transitionTs: 1600, + boundarySource: 'gesture', + ); + await TriathlonDb.adjustBoundary( + db: db, + sessionId: 'tri_manual_1', + idx: 0, + newTs: 1700, + ); + final segs = await TriathlonDb.segments(db, 'tri_manual_1'); + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].boundarySource, 'manual'); + await LocalDb.close(); + }); + + test('adjustStart corrects a false start and refuses a started race', + () async { + final db = await freshDb('tri_adjust_start_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_adjstart_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + // False triple-tap in the pen starts the race at the wrong time. + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_adjstart_1', + ts: 1000, + boundarySource: 'gesture', + ); + // Not yet transitioned: the start is still correctable. + await TriathlonDb.adjustStart( + db: db, + sessionId: 'tri_adjstart_1', + newTs: 1500, + ); + var segs = await TriathlonDb.segments(db, 'tri_adjstart_1'); + expect(segs[0].startTs, 1500); + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].startTs, isNull); + var read = await TriathlonDb.byId(db, 'tri_adjstart_1'); + expect(read!.startTs, 1500); + expect(read.status, 'active'); + + // Once a segment has ENDED, the start is history and cannot move. + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_adjstart_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.adjustStart( + db: db, + sessionId: 'tri_adjstart_1', + newTs: 1600, + ), + throwsA(isA()), + ); + segs = await TriathlonDb.segments(db, 'tri_adjstart_1'); + expect(segs[0].startTs, 1500); + await LocalDb.close(); + }); + + test('removeBoundary merges a false split and refuses a started tail', + () async { + final db = await freshDb('tri_remove_boundary_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_rmb_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + // swim 1000->1600, t1 1600->1700, bike 1700->3100, t2 3100->open. + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_rmb_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_rmb_1', + currentIdx: 0, + transitionTs: 1600, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_rmb_1', + currentIdx: 1, + transitionTs: 1700, + boundarySource: 'gesture', + ); + // A later segment started: boundary 1 (t1) cannot be removed anymore. + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_rmb_1', + currentIdx: 2, + transitionTs: 3100, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.removeBoundary(db: db, sessionId: 'tri_rmb_1', idx: 1), + throwsA(isA()), + ); + // Boundary 2 (bike->t2) is the live edge: removing it merges t2 into + // the bike segment; t2 was just opened so the merge re-opens the bike. + await TriathlonDb.removeBoundary(db: db, sessionId: 'tri_rmb_1', idx: 2); + final segs = await TriathlonDb.segments(db, 'tri_rmb_1'); + expect(segs[2].startTs, 1700); + expect(segs[2].endTs, isNull); + expect(segs[2].boundarySource, 'manual'); + expect(segs[3].startTs, isNull); + expect(segs[3].endTs, isNull); + // The merged session is still a valid timeline: one open segment. + expect(segs.where((s) => s.isOpen).length, 1); + await LocalDb.close(); + }); + + test('linkWorkout refuses a transition and a missing segment', () async { + final db = await freshDb('tri_link_guards_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_link_guard_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + // idx 1 is T1: no workout row exists for a transition. + await expectLater( + TriathlonDb.linkWorkout(db, 'tri_link_guard_1', 1, 'w1'), + throwsA(isA()), + ); + await expectLater( + TriathlonDb.linkWorkout(db, 'tri_link_guard_1', 42, 'w2'), + throwsA(isA()), + ); + expect( + await db.query('triathlon_segment', where: 'workout_id IS NOT NULL'), + isEmpty, + ); + await LocalDb.close(); + }); + + test('a discarded race is never rebuilt by the reconciler', () async { + final db = await freshDb('tri_discarded_reconcile_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_discarded_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1_000_000, + updatedAt: 1_000_000, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.updateSession( + db, + TriSession( + id: session.id, + kind: session.kind, + preset: session.preset, + status: 'discarded', + plan: plan, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + ), + ); + for (final o in [0, 1, 2]) { + final ts = 1_000_600 + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + final applied = await TriathlonReconciler(db).reconcile('tri_discarded_1'); + expect(applied, isEmpty); + expect( + (await TriathlonDb.byId(db, 'tri_discarded_1'))!.segments[0].startTs, + isNull, + ); + await LocalDb.close(); + }); + + test('an open segment from yesterday is deleted with today', () async { + final db = await freshDb('tri_open_overlap_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_open_overlap_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: (midnightSec - 600) * 1000, + updatedAt: (midnightSec - 600) * 1000, + ); + await TriathlonDb.create(db, session); + // The swim started yesterday and is STILL OPEN at midnight. + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_open_overlap_1', + ts: midnightSec - 300, + boundarySource: 'gesture', + ); + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await TriathlonDb.byId(db, 'tri_open_overlap_1'), isNull); + await LocalDb.close(); + }); + + test('offline boundaries after the final segment are rejected', () async { + final db = await freshDb('tri_offline_tail_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_tail_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + const bounds = [ + ReconcileBoundary(1000, 3, TriSegmentKind.swim), + ReconcileBoundary(2000, 3, TriSegmentKind.swim), + ReconcileBoundary(2060, 3, TriSegmentKind.t1), + ReconcileBoundary(3060, 3, TriSegmentKind.bike), + ReconcileBoundary(3120, 3, TriSegmentKind.t2), + ReconcileBoundary(4000, 3, TriSegmentKind.run), + ReconcileBoundary(4100, 3, TriSegmentKind.run), + ]; + await expectLater( + db.transaction((txn) async { + await TriathlonDb.applyOfflineBoundariesInTx( + txn, + sessionId: 'tri_tail_1', + boundaries: bounds, + ); + }), + throwsA(isA()), + ); + final segs = await TriathlonDb.segments(db, 'tri_tail_1'); + expect(segs[4].endTs, isNull, reason: 'the transaction rolled back'); + await LocalDb.close(); + }); + + + test('a day export includes a race whose open segment overlaps it', + () async { + final db = await freshDb('tri_export_overlap_src.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_export_overlap_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: (midnightSec - 600) * 1000, + updatedAt: (midnightSec - 600) * 1000, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_export_overlap_1', + ts: midnightSec - 300, + boundarySource: 'gesture', + ); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + final exported = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true)); + final rows = await exported.query('triathlon_session', + where: 'id = ?', whereArgs: ['tri_export_overlap_1']); + final segs = await exported.query('triathlon_segment', + where: 'session_id = ?', whereArgs: ['tri_export_overlap_1']); + expect(rows, hasLength(1)); + expect(segs, hasLength(5)); + await exported.close(); + await File(path).delete(); + await LocalDb.close(); + }); + + + test('a pause between segments is legal and reported separately', + () async { + final db = await freshDb('tri_pause_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_pause_1', + kind: 'training', + preset: 'training', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_pause_1', + ts: 1000, + boundarySource: 'gesture', + ); + // Swim ends at 2000; T1 only starts at 5600 -> 3600 s pause. + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_pause_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + // Simulate the pause by moving the T1 start later (manual correction). + await TriathlonDb.saveSegment( + db, + (await TriathlonDb.segments(db, 'tri_pause_1'))[1] + .copyWith(startTs: 5600, endTs: 5660), + ); + final read = (await TriathlonDb.byId(db, 'tri_pause_1'))!; + expect(read.movingSec, 1000 + 60); // swim 1000s + t1 60s + expect(read.totalSec, 5660 - 1000); + expect(read.pauseSec, (5660 - 1000) - (1000 + 60)); + await LocalDb.close(); + }); + + test('an overlap between segments is refused', () async { + final db = await freshDb('tri_overlap_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_overlap_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_overlap_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_overlap_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + // Try to move the swim end AFTER the T1 start: overlap, must fail. + await expectLater( + TriathlonDb.saveSegment( + db, + (await TriathlonDb.segments(db, 'tri_overlap_1'))[0] + .copyWith(endTs: 2100), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a DNF (abandoned mid-race) finalizes with an open cut segment', + () async { + final db = await freshDb('tri_dnf_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_dnf_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.startSegment( + db: db, + sessionId: 'tri_dnf_1', + ts: 1000, + boundarySource: 'gesture', + ); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_dnf_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + // Athlete quits during T1: T1 stays open, bike/run untouched. + await TriathlonDb.updateSession( + db, + TriSession( + id: session.id, + kind: session.kind, + preset: session.preset, + status: 'abandoned', + plan: plan, + createdAt: 1, + updatedAt: 1, + ), + ); + await TriathlonDb.finalize(db, 'tri_dnf_1'); + final fin = (await TriathlonDb.byId(db, 'tri_dnf_1'))!; + expect(fin.isFinalized, isTrue); + expect(fin.status, 'finalized'); + expect(fin.segments[1].endTs, isNull, reason: 'open segment stays open'); + expect(fin.segments[1].durationSec, isNull, reason: 'no invented time'); + expect(fin.segments[2].startTs, isNull); + expect(fin.movingSec, 1000); // only the swim was measured + await LocalDb.close(); + }); + + test('finalizing a never-started session is refused', () async { + final db = await freshDb('tri_dnf_empty_test.db'); + final plan = TriathlonPlan.race( + 'sprint', + swimEnv: TriEnvironment.outdoor, + bikeEnv: TriEnvironment.outdoor, + runEnv: TriEnvironment.outdoor, + ); + final session = TriSession( + id: 'tri_dnf_empty_1', + kind: 'race', + preset: 'sprint', + status: 'armed', + plan: plan, + createdAt: 1, + updatedAt: 1, + ); + await TriathlonDb.create(db, session); + await TriathlonDb.updateSession( + db, + TriSession( + id: session.id, + kind: session.kind, + preset: session.preset, + status: 'abandoned', + plan: plan, + createdAt: 1, + updatedAt: 1, + ), + ); + await expectLater( + TriathlonDb.finalize(db, 'tri_dnf_empty_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + }); +}