diff --git a/lib/data/db.dart b/lib/data/db.dart index 69c2eca0e..da4820980 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -58,6 +58,7 @@ import 'live_coverage_policy.dart'; import '../compute/step_calibration_fit.dart'; import 'step_calibration.dart'; import '../sync/reset_gate.dart'; +import 'triathlon_store.dart'; import 'med_store.dart'; import 'models.dart'; import 'nutrition_store.dart'; @@ -321,6 +322,10 @@ class LocalDb { 'imported_workout', 'workout_route', 'workout_split', + // 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 +669,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 = 59; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -1434,6 +1439,11 @@ 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); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -5735,6 +5745,10 @@ class LocalDb { // DDL belongs next to the code that reads it, not two thousand lines away. await createNutritionTables(db); await createMedTables(db); + // Triathlon sessions and their 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 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 +11191,32 @@ class LocalDb { where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); + // 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 +11399,78 @@ class LocalDb { [startSec, endSec], ); } + // 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 +11914,9 @@ class LocalDb { // Keep the decision merge after both kinds of accepted activity. 'sessions', 'session_sensor', + // The recorded segment boundaries and their plans cannot be rebuilt. + 'triathlon_session', + 'triathlon_segment', 'activity_suggestions', 'activity_review_meta', 'activity_review_days', @@ -12012,6 +12127,27 @@ class LocalDb { return {for (final c in info) (c['name'] as String)}; } + // 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; + } + final counts = {}; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". @@ -12032,6 +12168,48 @@ 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 = {}; + 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 +12942,19 @@ class LocalDb { } } } + 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 +13348,8 @@ class LocalDb { 'signal_priority', 'vendor_sleep_epoch', 'substrate_archive', + 'triathlon_session', + 'triathlon_segment', ]; final missingTables = []; @@ -15608,15 +15801,25 @@ 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 boundaries are also + // kept while a race is still armed or active: the reconciler rebuilds 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(created_at) / 1000 - ? FROM triathlon_session ' + "WHERE status IN ('armed', 'active')), 9223372036854775807))", whereArgs: [ cutoffSec, proto.EventId.chargingOn, proto.EventId.chargingOff, proto.EventId.wristOn, proto.EventId.wristOff, + kTriathlonDoubleTapEventId, + kClockDriftToleranceSec, ], ); // THE UNDECODABLE ARCHIVE IS THINNED, NOT KEPT WHOLE. See diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index a97a59523..b0cd09336 100644 --- a/lib/data/recent_slice.dart +++ b/lib/data/recent_slice.dart @@ -96,6 +96,8 @@ 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'), + '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..03910ce91 --- /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 kClockDriftToleranceSec = 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/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart new file mode 100644 index 000000000..3f10e7363 --- /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, kClockDriftToleranceSec; + +/// 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 - kClockDriftToleranceSec, + until + kClockDriftToleranceSec, + ], + 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 [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 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/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(); + }); + + }); +}