From f2ff8dea76bc6f3210e84456ac6e918ac12e4b70 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Oct 2026 08:26:01 +0100 Subject: [PATCH 01/22] =?UTF-8?q?feat(triathlon):=20PR1=20=E2=80=94=20sess?= =?UTF-8?q?ion/segment=20data=20model=20and=20schema=20migration=20(v60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the storage foundation for triathlon workout mode: - TriathlonPlan pure model (race presets Sprint / Olympic / Half Distance / Full Distance + custom + training mode) with per-segment indoor/outdoor environment selection. - TriathlonDb storage seam on schema v60 (triathlon_session parent + triathlon_segment child rows) enforcing two invariants: 1. Finalized means immutable — a finalized session or segment cannot be edited. 2. Segments cannot overlap or leave gaps — neighbour boundaries meet at one instant so split sum equals total duration. - Registered in restore, salvage, slice and delete-cascade lists. - kAlgoVersion UNCHANGED — pure session storage with no changed derived daily analytics outputs. --- lib/data/db.dart | 34 +- lib/data/recent_slice.dart | 2 + lib/data/triathlon_plan.dart | 295 ++++++++++++ lib/data/triathlon_store.dart | 623 +++++++++++++++++++++++++ lib/ui2/activity/catalogue.dart | 1 + test/triathlon_model_storage_test.dart | 307 ++++++++++++ 6 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 lib/data/triathlon_plan.dart create mode 100644 lib/data/triathlon_store.dart create mode 100644 test/triathlon_model_storage_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 578df4b9c..e31b99ac7 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -57,6 +57,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'; @@ -299,6 +300,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', @@ -479,7 +484,11 @@ 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; + // 59 is claimed by the open stranded-device-id PR (#537); this feature + // takes the next free rung rather than collide with it. The ladder is a + // sequence of `oldV < N` checks, so an unused 59 costs nothing: a device + // upgrading from 58 runs this step, and whichever PR lands second renumbers. + static const int schemaVersion = 60; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -1249,6 +1258,11 @@ class LocalDb { // A band's own hypnogram (`vendor_staged`). New table only. await _createVendorSleepEpoch(db); } + if (oldV < 60) { + // 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); @@ -5504,6 +5518,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 ( @@ -11106,6 +11124,8 @@ class LocalDb { for (final child in const [ 'workout_route', 'workout_split', + 'triathlon_session', + 'triathlon_segment', 'strength_set', 'session_sensor', ]) { @@ -11115,6 +11135,16 @@ class LocalDb { [startSec, endSec], ); } + deleted += await txn.rawDelete( + 'DELETE FROM triathlon_segment WHERE session_id IN ' + '(SELECT id FROM triathlon_session WHERE start_ts >= ? AND start_ts < ?)', + [startSec, endSec], + ); + deleted += await txn.delete( + 'triathlon_session', + where: 'start_ts >= ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); deleted += await txn.delete( 'sessions', where: 'start_ts >= ? AND start_ts < ?', @@ -12829,6 +12859,8 @@ class LocalDb { 'signal_priority', 'vendor_sleep_epoch', 'substrate_archive', + 'triathlon_session', + 'triathlon_segment', ]; final missingTables = []; 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..8528a435c --- /dev/null +++ b/lib/data/triathlon_plan.dart @@ -0,0 +1,295 @@ +// 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 kind = TriSegmentKindX.fromKey(j['kind'] as String?); + if (kind == null) return null; + return TriSegmentPlan( + kind: kind, + environment: TriEnvironmentX.fromKey(j['environment'] as String?), + plannedDistanceM: (j['planned_distance_m'] as num?)?.toDouble(), + goalSec: (j['goal_sec'] as num?)?.toInt(), + ); + } +} + +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 v = (j['v'] as num?)?.toInt(); + 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..16f4c893f --- /dev/null +++ b/lib/data/triathlon_store.dart @@ -0,0 +1,623 @@ +// 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. 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)', + ); +} + +/// 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, + }) { + return TriSegment( + sessionId: sessionId, + idx: idx, + kind: kind, + environment: environment, + plannedDistanceM: plannedDistanceM, + goalSec: goalSec, + startTs: 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; + + 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; + } + 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, + ); + } +} + +/// 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.isFinalized) { + throw TriathlonWriteError('cannot create an already-finalized session'); + } + 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 { + 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.idx != i || seg.kind != p.kind) { + throw TriathlonWriteError('segment $i does not match the plan'); + } + _checkSegmentShape(seg); + 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; + final session = TriSession.fromRow(rows.first); + if (session == null) return null; + return _withSegments(db, session); + } + + 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; + final session = TriSession.fromRow(rows.first); + if (session == null) return null; + return _withSegments(db, session); + } + + /// 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) { + final s = TriSession.fromRow(r); + if (s != null) out.add(await _withSegments(db, s)); + } + return out; + } + + /// Write one segment's boundaries. The whole point of doing this here rather + /// than in the UI: the neighbour rows are read INSIDE the same transaction, + /// so the no-overlap rule cannot be checked against a stale in-memory copy. + static Future saveSegment(Database db, TriSegment segment) async { + _checkSegmentShape(segment); + await db.transaction((txn) async { + await _requireWritable(txn, segment.sessionId); + final rows = await txn.query( + 'triathlon_segment', + where: 'session_id = ?', + whereArgs: [segment.sessionId], + orderBy: 'idx ASC', + ); + final existing = [ + for (final r in rows) TriSegment.fromRow(r) + ].whereType().toList(); + if (existing.every((s) => s.idx != segment.idx)) { + throw TriathlonWriteError('segment ${segment.idx} is not in the plan'); + } + for (final other in existing) { + if (other.idx == segment.idx) continue; + _checkNeighbour(segment, other); + } + await txn.update( + 'triathlon_segment', + segment.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + await _touch(txn, segment.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 { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + await txn.update( + 'triathlon_segment', + {'workout_id': workoutId}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await _touch(txn, sessionId); + }); + } + + /// Status / session-level bounds / notes. Refuses a finalized row, and never + /// clears `finalized_at` — finalization is one-way by design. + static Future updateSession(Database db, TriSession session) async { + await db.transaction((txn) async { + await _requireWritable(txn, session.id); + final row = session.toRow(); + row['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 segs = await segments(db, id); + if (segs.isEmpty) throw TriathlonWriteError('nothing to finalize'); + for (final s in segs) { + if (s.startTs == null || s.endTs == null) { + throw TriathlonWriteError('segment ${s.idx} is still open'); + } + } + 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; + }); + } + + 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}'); + } + } + + /// Neighbours share one instant: the earlier segment's end is the later + /// segment's start. Anything else is either a gap or an overlap, and both + /// would make the splits stop adding up to the total. + static void _checkNeighbour(TriSegment changed, TriSegment other) { + if (other.idx == changed.idx - 1) { + // [other] is the previous segment. + if (other.endTs != null && + changed.startTs != null && + other.endTs != changed.startTs) { + throw TriathlonWriteError( + 'segment ${changed.idx} does not start where ${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} does not start where ${changed.idx} ended', + ); + } + } + } +} diff --git a/lib/ui2/activity/catalogue.dart b/lib/ui2/activity/catalogue.dart index bc47dbe52..698abe720 100644 --- a/lib/ui2/activity/catalogue.dart +++ b/lib/ui2/activity/catalogue.dart @@ -135,6 +135,7 @@ const activityLibrary = [ Activity('Indoor bike', LucideIcons.bike, C.blue, Track.duration, 7.0), Activity('Rowing', LucideIcons.sailboat, C.teal, Track.distance, 7.0), Activity('Swimming', LucideIcons.waves, C.blue, Track.distance, 8.3), + Activity('Triathlon', LucideIcons.trophy, C.orange, Track.duration, null), Activity('Elliptical', LucideIcons.activity, C.purple, Track.duration, 5.0), Activity( 'Stair climber', LucideIcons.trendingUp, C.orange, Track.duration, 9.0), diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart new file mode 100644 index 000000000..a97c28c8c --- /dev/null +++ b/test/triathlon_model_storage_test.dart @@ -0,0 +1,307 @@ +// 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 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/triathlon_plan.dart'; +import 'package:openstrap_edge/data/triathlon_store.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + 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: 'active', + 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'), + ); + + // Attempting to start T2 at 2000 (gap after T1's 1960) throws. + final seg2 = (await TriathlonDb.segments(db, 'tri_sprint_bounds'))[2]; + expect( + () => TriathlonDb.saveSegment( + db, + seg2.copyWith(startTs: 2000, endTs: 3000, boundarySource: 'gesture'), + ), + throwsA(isA()), + ); + 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: 'active', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + await TriathlonDb.create(db, session); + + // Close all 5 segments contiguously. + var t = 1000; + for (var i = 0; i < 5; i++) { + final seg = (await TriathlonDb.segments(db, 'tri_sprint_fin'))[i]; + final len = i.isEven ? 1000 : 60; + await TriathlonDb.saveSegment( + db, + seg.copyWith(startTs: t, endTs: t + len, boundarySource: 'gesture'), + ); + t += len; + } + + await TriathlonDb.finalize(db, 'tri_sprint_fin'); + final finalized = await TriathlonDb.byId(db, 'tri_sprint_fin'); + expect(finalized!.isFinalized, isTrue); + + // Any attempt to edit a finalized segment must throw. + final seg0 = finalized.segments[0]; + expect( + () => TriathlonDb.saveSegment( + db, + seg0.copyWith(distanceM: 750, distanceSource: 'user'), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('schema migration v60 creates triathlon tables', () async { + LocalDb.dbName = 'tri_schema_v60_fresh.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.instance; + final names = await LocalDb.tableNames(); + expect(names, containsAll(['triathlon_session', 'triathlon_segment'])); + await LocalDb.close(); + }); + }); +} From 7e7ac0a5e8c04b1e57d0d2c5c339c88e8d56500c Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Thu, 29 Oct 2026 08:39:29 +0100 Subject: [PATCH 02/22] fix(triathlon): address draft storage review blockers --- lib/data/db.dart | 16 +++++++++++-- lib/data/triathlon_store.dart | 32 ++++++++++++++++++++++---- lib/ui2/activity/catalogue.dart | 1 - test/triathlon_model_storage_test.dart | 23 ++++++++++++++---- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index e31b99ac7..a676f7c28 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10951,6 +10951,17 @@ class LocalDb { where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); + await copyRows( + 'triathlon_session', + where: 'start_ts >= ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'triathlon_segment', + where: 'session_id IN (SELECT id FROM triathlon_session ' + 'WHERE start_ts >= ? AND start_ts < ?)', + whereArgs: [startSec, endSec], + ); await copyRows( 'live_coverage', where: 'end_ts > ? AND start_ts < ?', @@ -11124,8 +11135,6 @@ class LocalDb { for (final child in const [ 'workout_route', 'workout_split', - 'triathlon_session', - 'triathlon_segment', 'strength_set', 'session_sensor', ]) { @@ -11521,6 +11530,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', diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 16f4c893f..bebb8ab5e 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -340,10 +340,15 @@ class TriathlonDb { plannedDistanceM: p.plannedDistanceM, goalSec: p.goalSec, ); - if (seg.idx != i || seg.kind != p.kind) { + 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'); } _checkSegmentShape(seg); + if (i > 0 && session.segments.isNotEmpty) { + _checkNeighbour(seg, session.segments[i - 1]); + } await txn.insert('triathlon_segment', seg.toRow()); } }); @@ -444,9 +449,17 @@ class TriathlonDb { final existing = [ for (final r in rows) TriSegment.fromRow(r) ].whereType().toList(); - if (existing.every((s) => s.idx != segment.idx)) { + 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) continue; _checkNeighbour(segment, other); @@ -485,8 +498,17 @@ class TriathlonDb { static Future updateSession(Database db, TriSession session) async { await db.transaction((txn) async { await _requireWritable(txn, session.id); - final row = session.toRow(); - row['updated_at'] = DateTime.now().millisecondsSinceEpoch; + if (session.isFinalized || session.status == 'finalized') { + throw TriathlonWriteError('use finalize to finalize a session'); + } + // Do not rewrite the frozen plan or an unknown future plan version. + final row = { + 'status': session.status, + 'start_ts': session.startTs, + 'end_ts': session.endTs, + 'note': session.note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; await txn.update( 'triathlon_session', row, @@ -502,7 +524,7 @@ class TriathlonDb { static Future finalize(Database db, String id) async { await db.transaction((txn) async { await _requireWritable(txn, id); - final segs = await segments(db, id); + final segs = await segments(txn, id); if (segs.isEmpty) throw TriathlonWriteError('nothing to finalize'); for (final s in segs) { if (s.startTs == null || s.endTs == null) { diff --git a/lib/ui2/activity/catalogue.dart b/lib/ui2/activity/catalogue.dart index 698abe720..bc47dbe52 100644 --- a/lib/ui2/activity/catalogue.dart +++ b/lib/ui2/activity/catalogue.dart @@ -135,7 +135,6 @@ const activityLibrary = [ Activity('Indoor bike', LucideIcons.bike, C.blue, Track.duration, 7.0), Activity('Rowing', LucideIcons.sailboat, C.teal, Track.distance, 7.0), Activity('Swimming', LucideIcons.waves, C.blue, Track.distance, 8.3), - Activity('Triathlon', LucideIcons.trophy, C.orange, Track.duration, null), Activity('Elliptical', LucideIcons.activity, C.purple, Track.duration, 5.0), Activity( 'Stair climber', LucideIcons.trendingUp, C.orange, Track.duration, 9.0), diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index a97c28c8c..ccefb39c0 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -236,8 +236,8 @@ void main() { // Attempting to start T2 at 2000 (gap after T1's 1960) throws. final seg2 = (await TriathlonDb.segments(db, 'tri_sprint_bounds'))[2]; - expect( - () => TriathlonDb.saveSegment( + await expectLater( + TriathlonDb.saveSegment( db, seg2.copyWith(startTs: 2000, endTs: 3000, boundarySource: 'gesture'), ), @@ -284,8 +284,8 @@ void main() { // Any attempt to edit a finalized segment must throw. final seg0 = finalized.segments[0]; - expect( - () => TriathlonDb.saveSegment( + await expectLater( + TriathlonDb.saveSegment( db, seg0.copyWith(distanceM: 750, distanceSource: 'user'), ), @@ -294,7 +294,20 @@ void main() { await LocalDb.close(); }); - test('schema migration v60 creates triathlon tables', () async { + 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('fresh schema creates triathlon tables', () async { LocalDb.dbName = 'tri_schema_v60_fresh.db'; final dir = await databaseFactory.getDatabasesPath(); await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); From a589c976b9e4b946d79d0dfae7c03d0603ba2685 Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Thu, 29 Oct 2026 11:15:43 +0100 Subject: [PATCH 03/22] wip(triathlon): snapshot unverified offline reconciliation experiment Known broken and not wired: static review found compile errors and unsafe persistence. Do not merge; see PR description. --- lib/data/db.dart | 14 +++- lib/data/triathlon_store.dart | 103 ++++++++++++++++++++++++- lib/gestures/gesture_dispatcher.dart | 9 ++- lib/state/app_state.dart | 2 +- lib/state/triathlon_reconciler.dart | 38 +++++++++ test/triathlon_model_storage_test.dart | 43 +++++++++-- 6 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 lib/state/triathlon_reconciler.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index a676f7c28..8107b44f3 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11137,6 +11137,7 @@ class LocalDb { 'workout_split', 'strength_set', 'session_sensor', + 'triathlon_segment', ]) { deleted += await txn.rawDelete( 'DELETE FROM $child WHERE session_id IN ' @@ -11144,22 +11145,23 @@ class LocalDb { [startSec, endSec], ); } - deleted += await txn.rawDelete( + // Specific triathlon table cleanup. + await txn.rawDelete( 'DELETE FROM triathlon_segment WHERE session_id IN ' '(SELECT id FROM triathlon_session WHERE start_ts >= ? AND start_ts < ?)', [startSec, endSec], ); - deleted += await txn.delete( + await txn.delete( 'triathlon_session', where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); - deleted += await txn.delete( + await txn.delete( 'sessions', where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); - deleted += await txn.delete( + await txn.delete( 'live_coverage', where: 'end_ts > ? AND start_ts < ?', whereArgs: [startSec, endSec], @@ -11511,6 +11513,10 @@ class LocalDb { 'cycle_log', 'cycle_symptom', 'breathing_session', + // 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', // Vendor-computed, typed-in and imported scalars. In the hand-entered // block because a third of it IS hand-entered and nothing regenerates // any of it — a `reports` band trims its own history, and the app whose diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index bebb8ab5e..499a3b9b5 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -439,6 +439,39 @@ class TriathlonDb { static Future saveSegment(Database db, TriSegment segment) async { _checkSegmentShape(segment); await db.transaction((txn) async { + final sessionRows = await txn.query('triathlon_session', + where: 'id = ?', whereArgs: [segment.sessionId]); + if (sessionRows.isEmpty) throw TriathlonWriteError('no such session'); + final finalized = sessionRows.first['finalized_at'] != null; + if (finalized) { + throw TriathlonWriteError('cannot write to a finalized session'); + } + + final existing = await txn.query( + 'triathlon_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + if (existing.isEmpty) { + await txn.insert('triathlon_segment', segment.toRow()); + } else { + await txn.update( + 'triathlon_segment', + segment.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + } + + // Update session start/end + final segs = await txn.query('triathlon_segment', + where: 'session_id = ?', whereArgs: [segment.sessionId], orderBy: 'idx ASC'); + final all = [for (final r in segs) TriSegment.fromRow(r)!]; + final startTs = all.where((s) => s.startTs != null).map((s) => s.startTs!).reduce((a, b) => a < b ? a : b); + final endTs = all.where((s) => s.endTs != null).map((s) => s.endTs!).reduce((a, b) => a > b ? a : b); + await txn.update('triathlon_session', {'start_ts': startTs, 'end_ts': endTs, 'updated_at': DateTime.now().millisecondsSinceEpoch}, where: 'id = ?', whereArgs: [segment.sessionId]); + }); + } await _requireWritable(txn, segment.sessionId); final rows = await txn.query( 'triathlon_segment', @@ -453,6 +486,9 @@ class TriathlonDb { if (matching.isEmpty) { throw TriathlonWriteError('segment ${segment.idx} is not in the plan'); } + 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 || @@ -460,10 +496,20 @@ class TriathlonDb { segment.goalSec != original.goalSec) { throw TriathlonWriteError('the planned segment is immutable'); } + if (segment.startTs != null && segment.endTs != null && segment.endTs! < segment.startTs!) { + throw TriathlonWriteError('segment ends before it starts'); + } + + // Contiguous boundary check against current DB state for (final other in existing) { - if (other.idx == segment.idx) continue; - _checkNeighbour(segment, other); + if (other.idx == segment.idx - 1 && other.endTs != null && segment.startTs != null && other.endTs != segment.startTs) { + throw TriathlonWriteError('segment ${segment.idx} does not start where ${other.idx} ended'); + } + if (other.idx == segment.idx + 1 && other.startTs != null && segment.endTs != null && other.startTs != segment.endTs) { + throw TriathlonWriteError('segment ${other.idx} does not start where ${segment.idx} ended'); + } } + await txn.update( 'triathlon_segment', segment.toRow(), @@ -474,6 +520,53 @@ class TriathlonDb { }); } + /// Atomically closes segment [idx] and opens segment [idx+1] at [ts]. + static Future transitionSegment({ + required Database db, + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + final current = segs.firstWhere((s) => s.idx == currentIdx); + final next = segs.firstWhere((s) => s.idx == currentIdx + 1); + + await txn.update('triathlon_segment', + current.copyWith(endTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx]); + await txn.update('triathlon_segment', + next.copyWith(startTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx + 1]); + await _touch(txn, sessionId); + }); + } + + /// Atomically adjusts a boundary between segment [idx] and [idx+1] to [newTs]. + 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); + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + + await txn.update('triathlon_segment', + left.copyWith(endTs: newTs).toRow(), + where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx]); + await txn.update('triathlon_segment', + right.copyWith(startTs: newTs).toRow(), + where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1]); + await _touch(txn, sessionId); + }); + } + /// Link a discipline segment to the `sessions` row the live workout banked. static Future linkWorkout( Database db, @@ -483,6 +576,12 @@ class TriathlonDb { ) async { await db.transaction((txn) async { await _requireWritable(txn, sessionId); + final existing = await txn.query('triathlon_segment', + where: 'workout_id = ? AND session_id != ?', + whereArgs: [workoutId, sessionId]); + if (existing.isNotEmpty) { + throw TriathlonWriteError('workout $workoutId is already linked'); + } await txn.update( 'triathlon_segment', {'workout_id': workoutId}, diff --git a/lib/gestures/gesture_dispatcher.dart b/lib/gestures/gesture_dispatcher.dart index 8fb3fc222..101f6ad4e 100644 --- a/lib/gestures/gesture_dispatcher.dart +++ b/lib/gestures/gesture_dispatcher.dart @@ -44,6 +44,13 @@ class GestureDispatcher { int _lastFiredMs = 0; + bool _triathlonActive = false; + + void setTriathlonActive(bool active) { + _triathlonActive = active; + log?.call('[gesture] triathlon sync override: $_triathlonActive'); + } + /// Feed every live event here (id, band timestamp seconds, raw hex). Cheap to call /// for non-gesture events — it returns immediately. void onEvent(int eventId, int tsEpoch, String hex) { @@ -56,7 +63,7 @@ class GestureDispatcher { // Stale only when the ts is plausibly a real (recent-ish but past) historical // record. A wildly-off ts from an unset RTC is treated as "can't tell" → allow, // and rely on debounce + the live-only wiring. - if (tsEpoch > 0 && age > _recencyWindowSec && age < _plausibleAgeCapSec) { + if (!_triathlonActive && tsEpoch > 0 && age > _recencyWindowSec && age < _plausibleAgeCapSec) { log?.call('[gesture] ignoring stale double-tap (${age}s old)'); return; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index b581d5318..da8045c93 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -143,7 +143,7 @@ import '../telemetry/telemetry_service.dart'; import '../telemetry/health_uploader.dart'; import '../widget/widget_service.dart'; import '../sync/file_log.dart'; -import 'breathing_controller.dart'; +import '../state/triathlon_reconciler.dart'; import 'package:uuid/uuid.dart'; export 'derive_coordinator.dart' show DeriveRunHook; diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart new file mode 100644 index 000000000..145cf31ef --- /dev/null +++ b/lib/state/triathlon_reconciler.dart @@ -0,0 +1,38 @@ +// triathlon_reconciler.dart +// Post-hoc reconciliation of missed transition double-taps during a race. + +import 'package:sqflite/sqflite.dart'; +// Note: Assuming standard project structure for DB imports +import '../data/triathlon_store.dart'; + +class TriathlonReconciler { + final Database db; + + TriathlonReconciler(this.db); + + /// Scans band events for missed double-tap transitions within a race. + Future reconcile(int sessionId, int startTs, int endTs) async { + final List> missedEvents = await db.query( + 'band_events', + where: 'eventId = ? AND tsEpoch >= ? AND tsEpoch <= ?', + whereArgs: [14, startTs, endTs], // EventId.doubleTap is 14 + orderBy: 'tsEpoch ASC', + ); + + for (final event in missedEvents) { + final ts = event['tsEpoch'] as int; + // Fetch segment to update + final segs = await TriathlonDb.segments(db, sessionId.toString()); + for (final seg in segs) { + if (ts > seg.startTs! && ts < seg.endTs!) { + // This is a transition point within a segment. + // In triathlon, transitions are segments. + // Post-hoc logic: split current segment or create transition. + // Simplification: mark as transition boundary + final newSeg = seg.copyWith(endTs: ts, boundarySource: 'posthoc'); + await TriathlonDb.saveSegment(db, newSeg); + } + } + } + } +} diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index ccefb39c0..82496d845 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; 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/triathlon_plan.dart'; import 'package:openstrap_edge/data/triathlon_store.dart'; @@ -307,13 +308,41 @@ void main() { await LocalDb.close(); }); - test('fresh schema creates triathlon tables', () async { - LocalDb.dbName = 'tri_schema_v60_fresh.db'; - final dir = await databaseFactory.getDatabasesPath(); - await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); - await LocalDb.instance; - final names = await LocalDb.tableNames(); - expect(names, containsAll(['triathlon_session', 'triathlon_segment'])); + test('Sync after band-only race: reconciler segments post-hoc', () 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 = 1000; + final session = TriSession( + id: 'tri_posthoc_1', + kind: 'race', + preset: 'sprint', + status: 'active', + plan: plan, + createdAt: t0, + updatedAt: t0, + ); + await TriathlonDb.create(db, session); + + // Create a long segment 0 + final seg0 = (await TriathlonDb.segments(db, 'tri_posthoc_1'))[0]; + await TriathlonDb.saveSegment(db, seg0.copyWith(startTs: 1000, endTs: 5000)); + + // Insert missed band events + await db.insert('band_events', {'eventId': 14, 'tsEpoch': 2000}); // Double tap + + // Reconcile + final reconciler = TriathlonReconciler(db); + await reconciler.reconcile('tri_posthoc_1', 1000, 5000); + + // Assert + final updatedSegs = await TriathlonDb.segments(db, 'tri_posthoc_1'); + expect(updatedSegs[0].endTs, 2000); + expect(updatedSegs[0].boundarySource, 'posthoc'); await LocalDb.close(); }); }); From 2f239e0350e814a8d487274553c84d1fa6f30c35 Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Thu, 29 Oct 2026 21:24:46 +0100 Subject: [PATCH 04/22] fix(triathlon): repair storage blockers and make reconciliation safe - Restore the breathing controller import and drop the dispatcher override that would have replayed old taps into existing actions. - Replace the broken saveSegment body with a transactional version that keeps planned fields immutable and checks neighbours from the database. - Add startSegment, finishSegment, validated transitionSegment and adjustBoundary; session bounds now derive from the segments. - Rewrite the offline reconciler against the real band_events schema: gate on taps within the window and per-segment minimum durations, ignore already-consumed taps, and apply the chain in one transaction. - Fix day deletion (triathlon rows are not session children) and the duplicated restore entries; protect finalized races on restore. - Extend tests: offline segmentation, floors, transitions, planning immutability, workout-link uniqueness and day deletion. Unverified: flutter analyze/test have not run here, and the reconciler is still not wired into the sync flow. --- lib/data/db.dart | 44 ++- lib/data/triathlon_store.dart | 383 +++++++++++++++++++------ lib/gestures/gesture_dispatcher.dart | 9 +- lib/state/app_state.dart | 2 +- lib/state/triathlon_reconciler.dart | 210 ++++++++++++-- test/triathlon_model_storage_test.dart | 279 +++++++++++++++++- 6 files changed, 785 insertions(+), 142 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 8107b44f3..3f1a55132 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11137,7 +11137,6 @@ class LocalDb { 'workout_split', 'strength_set', 'session_sensor', - 'triathlon_segment', ]) { deleted += await txn.rawDelete( 'DELETE FROM $child WHERE session_id IN ' @@ -11145,23 +11144,27 @@ class LocalDb { [startSec, endSec], ); } - // Specific triathlon table cleanup. - await txn.rawDelete( + // 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. The child rows go first, then the parent. + deleted += await txn.rawDelete( 'DELETE FROM triathlon_segment WHERE session_id IN ' '(SELECT id FROM triathlon_session WHERE start_ts >= ? AND start_ts < ?)', [startSec, endSec], ); - await txn.delete( + deleted += await txn.delete( 'triathlon_session', where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); - await txn.delete( + deleted += await txn.delete( 'sessions', where: 'start_ts >= ? AND start_ts < ?', whereArgs: [startSec, endSec], ); - await txn.delete( + deleted += await txn.delete( 'live_coverage', where: 'end_ts > ? AND start_ts < ?', whereArgs: [startSec, endSec], @@ -11513,10 +11516,6 @@ class LocalDb { 'cycle_log', 'cycle_symptom', 'breathing_session', - // 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', // Vendor-computed, typed-in and imported scalars. In the hand-entered // block because a third of it IS hand-entered and nothing regenerates // any of it — a `reports` band trims its own history, and the app whose @@ -11758,6 +11757,22 @@ 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 = {}; + // Finalized triathlons are this device's own record: a restore must not + // overwrite one with an older snapshot of the same race (its segments + // arrive separately, so protecting only the parent would still rewrite + // the boundaries). + final protectedTriathlons = {}; + try { + for (final r in await db.query( + 'triathlon_session', + columns: ['id'], + distinct: true, + )) { + protectedTriathlons.add('${r['id']}'); + } + } on DatabaseException { + /* table absent in an older build of this file — nothing to protect */ + } var archiveSkipped = 0; int? archiveEdgeSec; var impAttached = false; @@ -12484,6 +12499,15 @@ class LocalDb { } } } + if (t == 'triathlon_session' && + protectedTriathlons.contains('${row['id']}')) { + continue; // locally finalized — never overwritten by import + } + if (t == 'triathlon_segment' && + protectedTriathlons.contains('${row['session_id']}')) { + continue; + } + } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 499a3b9b5..d83e8daef 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -301,6 +301,24 @@ class TriSession { /// 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". +/// 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)'; +} + class TriathlonWriteError implements Exception { TriathlonWriteError(this.message); final String message; @@ -433,62 +451,22 @@ class TriathlonDb { return out; } - /// Write one segment's boundaries. The whole point of doing this here rather - /// than in the UI: the neighbour rows are read INSIDE the same transaction, - /// so the no-overlap rule cannot be checked against a stale in-memory copy. + /// 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 { - final sessionRows = await txn.query('triathlon_session', - where: 'id = ?', whereArgs: [segment.sessionId]); - if (sessionRows.isEmpty) throw TriathlonWriteError('no such session'); - final finalized = sessionRows.first['finalized_at'] != null; - if (finalized) { - throw TriathlonWriteError('cannot write to a finalized session'); - } - - final existing = await txn.query( - 'triathlon_segment', - where: 'session_id = ? AND idx = ?', - whereArgs: [segment.sessionId, segment.idx], - ); - if (existing.isEmpty) { - await txn.insert('triathlon_segment', segment.toRow()); - } else { - await txn.update( - 'triathlon_segment', - segment.toRow(), - where: 'session_id = ? AND idx = ?', - whereArgs: [segment.sessionId, segment.idx], - ); - } - - // Update session start/end - final segs = await txn.query('triathlon_segment', - where: 'session_id = ?', whereArgs: [segment.sessionId], orderBy: 'idx ASC'); - final all = [for (final r in segs) TriSegment.fromRow(r)!]; - final startTs = all.where((s) => s.startTs != null).map((s) => s.startTs!).reduce((a, b) => a < b ? a : b); - final endTs = all.where((s) => s.endTs != null).map((s) => s.endTs!).reduce((a, b) => a > b ? a : b); - await txn.update('triathlon_session', {'start_ts': startTs, 'end_ts': endTs, 'updated_at': DateTime.now().millisecondsSinceEpoch}, where: 'id = ?', whereArgs: [segment.sessionId]); - }); - } await _requireWritable(txn, segment.sessionId); - final rows = await txn.query( - 'triathlon_segment', - where: 'session_id = ?', - whereArgs: [segment.sessionId], - orderBy: 'idx ASC', - ); - final existing = [ - for (final r in rows) TriSegment.fromRow(r) - ].whereType().toList(); + 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'); } - 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 || @@ -496,31 +474,55 @@ class TriathlonDb { segment.goalSec != original.goalSec) { throw TriathlonWriteError('the planned segment is immutable'); } - if (segment.startTs != null && segment.endTs != null && segment.endTs! < segment.startTs!) { - throw TriathlonWriteError('segment ends before it starts'); - } - - // Contiguous boundary check against current DB state for (final other in existing) { - if (other.idx == segment.idx - 1 && other.endTs != null && segment.startTs != null && other.endTs != segment.startTs) { - throw TriathlonWriteError('segment ${segment.idx} does not start where ${other.idx} ended'); - } - if (other.idx == segment.idx + 1 && other.startTs != null && segment.endTs != null && other.startTs != segment.endTs) { - throw TriathlonWriteError('segment ${other.idx} does not start where ${segment.idx} ended'); - } + 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], ); - await _touch(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], + ); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); }); } - /// Atomically closes segment [idx] and opens segment [idx+1] at [ts]. + /// 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, @@ -528,23 +530,56 @@ class TriathlonDb { 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 last open segment at [ts] and marks the session finished. + 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 current = segs.firstWhere((s) => s.idx == currentIdx); - final next = segs.firstWhere((s) => s.idx == currentIdx + 1); - - await txn.update('triathlon_segment', - current.copyWith(endTs: transitionTs, boundarySource: boundarySource).toRow(), - where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx]); - await txn.update('triathlon_segment', - next.copyWith(startTs: transitionTs, boundarySource: boundarySource).toRow(), - where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx + 1]); - await _touch(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 && + segs.any((s) => s.idx > last.idx && s.startTs != null)) { + throw TriathlonWriteError('the last open segment is not the last'); + } + await txn.update( + 'triathlon_segment', + last.copyWith(endTs: ts, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, last.idx], + ); + await _recomputeSessionBounds(txn, sessionId, status: 'finished'); }); } - /// Atomically adjusts a boundary between segment [idx] and [idx+1] to [newTs]. + /// 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, @@ -554,19 +589,112 @@ class TriathlonDb { 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); - - await txn.update('triathlon_segment', - left.copyWith(endTs: newTs).toRow(), - where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx]); - await txn.update('triathlon_segment', - right.copyWith(startTs: newTs).toRow(), - where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1]); - await _touch(txn, sessionId); + 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}'); + } + await txn.update( + 'triathlon_segment', + left.copyWith(endTs: newTs).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'triathlon_segment', + right.copyWith(startTs: newTs).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + 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], + ); + 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) { + await txn.update( + 'triathlon_segment', + current + .copyWith(endTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, current.idx], + ); + 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. + await _recomputeSessionBounds(txn, sessionId); + } + /// Link a discipline segment to the `sessions` row the live workout banked. static Future linkWorkout( Database db, @@ -576,9 +704,11 @@ class TriathlonDb { ) async { await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final existing = await txn.query('triathlon_segment', - where: 'workout_id = ? AND session_id != ?', - whereArgs: [workoutId, sessionId]); + final existing = await txn.query( + 'triathlon_segment', + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [workoutId, sessionId, idx], + ); if (existing.isNotEmpty) { throw TriathlonWriteError('workout $workoutId is already linked'); } @@ -672,6 +802,91 @@ class TriathlonDb { }); } + 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], + ); + await _recomputeSessionBounds(txn, sessionId); + } + + /// Derives the session bounds from its segments — one source of truth. + /// + /// With NO started segment both bounds are cleared, not kept at a stale + /// value; a bound is written only when a segment actually supports it. + /// [status] is left untouched unless the caller is changing the lifecycle. + static Future _recomputeSessionBounds( + DatabaseExecutor db, + String sessionId, { + String? status, + }) async { + final segs = await segments(db, sessionId); + int? start; + int? end; + for (final s in segs) { + if (s.startTs != null && (start == null || s.startTs! < start)) { + start = s.startTs; + } + if (s.endTs != null && (end == null || s.endTs! > end)) { + end = s.endTs; + } + } + final row = { + 'start_ts': start, + 'end_ts': end, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + if (status != null) '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', diff --git a/lib/gestures/gesture_dispatcher.dart b/lib/gestures/gesture_dispatcher.dart index 101f6ad4e..8fb3fc222 100644 --- a/lib/gestures/gesture_dispatcher.dart +++ b/lib/gestures/gesture_dispatcher.dart @@ -44,13 +44,6 @@ class GestureDispatcher { int _lastFiredMs = 0; - bool _triathlonActive = false; - - void setTriathlonActive(bool active) { - _triathlonActive = active; - log?.call('[gesture] triathlon sync override: $_triathlonActive'); - } - /// Feed every live event here (id, band timestamp seconds, raw hex). Cheap to call /// for non-gesture events — it returns immediately. void onEvent(int eventId, int tsEpoch, String hex) { @@ -63,7 +56,7 @@ class GestureDispatcher { // Stale only when the ts is plausibly a real (recent-ish but past) historical // record. A wildly-off ts from an unset RTC is treated as "can't tell" → allow, // and rely on debounce + the live-only wiring. - if (!_triathlonActive && tsEpoch > 0 && age > _recencyWindowSec && age < _plausibleAgeCapSec) { + if (tsEpoch > 0 && age > _recencyWindowSec && age < _plausibleAgeCapSec) { log?.call('[gesture] ignoring stale double-tap (${age}s old)'); return; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index da8045c93..b581d5318 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -143,7 +143,7 @@ import '../telemetry/telemetry_service.dart'; import '../telemetry/health_uploader.dart'; import '../widget/widget_service.dart'; import '../sync/file_log.dart'; -import '../state/triathlon_reconciler.dart'; +import 'breathing_controller.dart'; import 'package:uuid/uuid.dart'; export 'derive_coordinator.dart' show DeriveRunHook; diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart index 145cf31ef..a473a47b6 100644 --- a/lib/state/triathlon_reconciler.dart +++ b/lib/state/triathlon_reconciler.dart @@ -1,38 +1,198 @@ -// triathlon_reconciler.dart -// Post-hoc reconciliation of missed transition double-taps during a race. +// 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 same rule the live path applies. +// * The whole chain is applied in ONE transaction through +// [TriathlonDb.applyOfflineBoundaries]. 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'; -// Note: Assuming standard project structure for DB imports -import '../data/triathlon_store.dart'; + +import '../data/triathlon_plan.dart'; +import '../data/triathlon_store.dart'; + +/// Double-tap event id, the only gesture the firmware emits today. +const int kTriathlonDoubleTapEventId = 14; + +/// 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; - TriathlonReconciler(this.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 []; + + // 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; - /// Scans band events for missed double-tap transitions within a race. - Future reconcile(int sessionId, int startTs, int endTs) async { - final List> missedEvents = await db.query( + final rows = await db.query( 'band_events', - where: 'eventId = ? AND tsEpoch >= ? AND tsEpoch <= ?', - whereArgs: [14, startTs, endTs], // EventId.doubleTap is 14 - orderBy: 'tsEpoch ASC', + columns: ['ts'], + where: 'event_id = ? AND device_id = ? AND ts >= ? AND ts <= ?', + whereArgs: [kTriathlonDoubleTapEventId, deviceId, since, until], + 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 []; - for (final event in missedEvents) { - final ts = event['tsEpoch'] as int; - // Fetch segment to update - final segs = await TriathlonDb.segments(db, sessionId.toString()); - for (final seg in segs) { - if (ts > seg.startTs! && ts < seg.endTs!) { - // This is a transition point within a segment. - // In triathlon, transitions are segments. - // Post-hoc logic: split current segment or create transition. - // Simplification: mark as transition boundary - final newSeg = seg.copyWith(endTs: ts, boundarySource: 'posthoc'); - await TriathlonDb.saveSegment(db, newSeg); - } - } + 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. + int floor = sessionCreatedAtSec; + for (final s in segments) { + final t = s.startTs; + if (t != null && t > floor) floor = t; + } + + // Consecutive taps no more than [kTapWindowSec] apart form one intent. The + // FIRST tap of the cluster is when the athlete said "next"; the rest are + // the "did it register?" taps. + final clusters = >[]; + for (final t in sorted) { + if (clusters.isNotEmpty && t - clusters.last.last <= kTapWindowSec) { + clusters.last.add(t); + } else { + clusters.add([t]); } } + + final out = []; + for (final cluster in clusters) { + final at = cluster.first; + if (at <= floor) continue; + final supporting = cluster.where((t) => t <= at + kTapWindowSec).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 index 82496d845..aec82259f 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; 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'; @@ -308,7 +309,8 @@ void main() { await LocalDb.close(); }); - test('Sync after band-only race: reconciler segments post-hoc', () async { + 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', @@ -316,34 +318,283 @@ void main() { bikeEnv: TriEnvironment.outdoor, runEnv: TriEnvironment.outdoor, ); - final t0 = 1000; + final t0 = 1_000_000; final session = TriSession( id: 'tri_posthoc_1', kind: 'race', preset: 'sprint', - status: 'active', + status: 'armed', plan: plan, createdAt: t0, updatedAt: t0, ); await TriathlonDb.create(db, session); - // Create a long segment 0 - final seg0 = (await TriathlonDb.segments(db, 'tri_posthoc_1'))[0]; - await TriathlonDb.saveSegment(db, seg0.copyWith(startTs: 1000, endTs: 5000)); + // 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, + }); + } + } - // Insert missed band events - await db.insert('band_events', {'eventId': 14, 'tsEpoch': 2000}); // Double tap + // 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]); - // Reconcile final reconciler = TriathlonReconciler(db); - await reconciler.reconcile('tri_posthoc_1', 1000, 5000); + 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 race under its minimum duration is not segmented by taps', + () 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); + // 20 s in: a real swim cannot be over, so this is not a transition. + for (final o in [0, 1, 2]) { + final ts = 1_000_020 + 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_floor_1'); + expect(applied, isEmpty); + 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', + ); + var segs = await TriathlonDb.segments(db, 'tri_move_1'); + expect(segs[0].endTs, 1600); + expect(segs[1].startTs, 1600); + await TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_move_1', + currentIdx: 0, + transitionTs: 1700, + boundarySource: 'gesture', + ); + await expectLater( + TriathlonDb.transitionSegment( + db: db, + sessionId: 'tri_move_1', + currentIdx: 0, + transitionTs: 1500, + boundarySource: 'gesture', + ), + throwsA(isA()), + ); + segs = await TriathlonDb.segments(db, 'tri_move_1'); + expect(segs[0].endTs, 1700); + expect(segs[1].startTs, 1700); + 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(); + }); - // Assert - final updatedSegs = await TriathlonDb.segments(db, 'tri_posthoc_1'); - expect(updatedSegs[0].endTs, 2000); - expect(updatedSegs[0].boundarySource, 'posthoc'); + 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(); }); + }); } From f721b6cf11e7137c0309d0a503776aa2aed6a057 Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Thu, 29 Oct 2026 22:12:30 +0100 Subject: [PATCH 05/22] fix(triathlon): enforce timeline and lifecycle invariants; fix midnight paths Storage: - One timeline rule for the whole session (started segments form a prefix, one open segment, contiguous monotonic boundaries), enforced inside every mutator so a failed check rolls the transaction back. - Offline start now marks the session active. - finishSegment only closes the FINAL segment: closing an earlier one left a session finalize could never accept. - finalize requires status finished and a valid timeline. - updateSession no longer writes derived bounds or lifecycle-only statuses. - copyWith can clear a start; duplicate ids raise TriathlonWriteError. - linkWorkout refuses the same workout on any two segments. Reconciler: - Strict 6 s burst window anchored on the first tap (a chained 0/6/12 s run no longer counts as one burst), clock-drift tolerance on the event read. Day paths: - deleteDays selects a race by parent start OR any segment start and deletes the ids it read (the second statement used to invalidate its own subquery, so a cross-midnight race survived deletion). - exportDaysDb uses the same rule; restore protects locally running and finalized races. Tests: - lifecycle walk, early-finish refusal, out-of-order timeline, updateSession bounds, v58 -> v60 upgrade, fresh install, cross-midnight deletion and a day export/import round trip. Verification here was static only: bracket/syntax structure, API references, the SQL statements against the real DDL, and a Python mirror of the boundary and timeline rules. flutter analyze/test have NOT run. --- lib/data/db.dart | 61 ++-- lib/data/triathlon_store.dart | 145 ++++++++-- lib/state/triathlon_reconciler.dart | 35 ++- test/triathlon_model_storage_test.dart | 381 ++++++++++++++++++++++--- 4 files changed, 541 insertions(+), 81 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 3f1a55132..693f090bc 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10951,16 +10951,21 @@ 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. + const triInWindow = '(SELECT id FROM triathlon_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM triathlon_segment ' + 'WHERE start_ts >= ? AND start_ts < ?)'; await copyRows( 'triathlon_session', - where: 'start_ts >= ? AND start_ts < ?', - whereArgs: [startSec, endSec], + where: 'id IN $triInWindow', + whereArgs: [startSec, endSec, startSec, endSec], ); await copyRows( 'triathlon_segment', - where: 'session_id IN (SELECT id FROM triathlon_session ' - 'WHERE start_ts >= ? AND start_ts < ?)', - whereArgs: [startSec, endSec], + where: 'session_id IN $triInWindow', + whereArgs: [startSec, endSec, startSec, endSec], ); await copyRows( 'live_coverage', @@ -11148,17 +11153,41 @@ class LocalDb { // 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. The child rows go first, then the parent. - deleted += await txn.rawDelete( - 'DELETE FROM triathlon_segment WHERE session_id IN ' - '(SELECT id FROM triathlon_session WHERE start_ts >= ? AND start_ts < ?)', - [startSec, endSec], - ); - deleted += await txn.delete( - 'triathlon_session', - where: 'start_ts >= ? AND start_ts < ?', - whereArgs: [startSec, endSec], - ); + // collided with a workout id. + // + // A race that starts before midnight and ends after it belongs to + // BOTH days: deleting either day must not leave its rows behind, so a + // race is selected when it starts in the window OR when any of its + // segments does. The whole race goes — deleting half a race would + // leave a record nobody can read. + // READ THE IDS FIRST. Deleting the segments first would empty the + // subquery the parent delete is built from, so a race selected only + // through a segment (the cross-midnight case) survived its own + // deletion. Selection and deletion must not share one statement here. + const triInWindow = '(SELECT id FROM triathlon_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM triathlon_segment ' + 'WHERE start_ts >= ? AND start_ts < ?)'; + final triIds = [ + for (final r in await txn.query( + 'triathlon_session', + columns: ['id'], + where: 'id IN $triInWindow', + whereArgs: [startSec, endSec, startSec, endSec], + )) + r['id'] as String, + ]; + for (final chunk in _sqlVarChunks(triIds)) { + final marks = List.filled(chunk.length, '?').join(','); + 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 < ?', diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index d83e8daef..513a6d6d7 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -9,11 +9,11 @@ // 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. 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. +// 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 @@ -157,6 +157,7 @@ class TriSegment { String? distanceSource, String? hrSource, bool clearEnd = false, + bool clearStart = false, }) { return TriSegment( sessionId: sessionId, @@ -165,7 +166,7 @@ class TriSegment { environment: environment, plannedDistanceM: plannedDistanceM, goalSec: goalSec, - startTs: startTs ?? this.startTs, + startTs: clearStart ? null : (startTs ?? this.startTs), endTs: clearEnd ? null : (endTs ?? this.endTs), boundarySource: boundarySource ?? this.boundarySource, workoutId: workoutId ?? this.workoutId, @@ -273,7 +274,7 @@ class TriSession { 'updated_at': updatedAt, }; - static TriSession? fromRow(Map r) { + static TriSession fromRow(Map r) { TriathlonPlan? plan; try { final decoded = jsonDecode(r['plan_json'] as String? ?? '{}'); @@ -344,6 +345,16 @@ class TriathlonDb { 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++) { @@ -380,9 +391,7 @@ class TriathlonDb { limit: 1, ); if (rows.isEmpty) return null; - final session = TriSession.fromRow(rows.first); - if (session == null) return null; - return _withSegments(db, session); + return _withSegments(db, TriSession.fromRow(rows.first)); } static Future> segments(DatabaseExecutor db, String id) async { @@ -424,9 +433,7 @@ class TriathlonDb { limit: 1, ); if (rows.isEmpty) return null; - final session = TriSession.fromRow(rows.first); - if (session == null) return null; - return _withSegments(db, session); + return _withSegments(db, TriSession.fromRow(rows.first)); } /// Finalized sessions at [preset], newest first — the comparison set for @@ -445,8 +452,7 @@ class TriathlonDb { ); final out = []; for (final r in rows) { - final s = TriSession.fromRow(r); - if (s != null) out.add(await _withSegments(db, s)); + out.add(await _withSegments(db, TriSession.fromRow(r))); } return out; } @@ -483,6 +489,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = ?', whereArgs: [segment.sessionId, segment.idx], ); + _validateTimeline(await segments(txn, segment.sessionId)); await _recomputeSessionBounds(txn, segment.sessionId); }); } @@ -514,6 +521,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = 0', whereArgs: [sessionId], ); + _validateTimeline(await segments(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'active'); }); } @@ -542,7 +550,12 @@ class TriathlonDb { }); } - /// Closes the last open segment at [ts] and marks the session finished. + /// 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, @@ -561,9 +574,11 @@ class TriathlonDb { if (last.startTs == null || ts < last.startTs!) { throw TriathlonWriteError('a segment cannot end before it starts'); } - if (last.idx != segs.length - 1 && - segs.any((s) => s.idx > last.idx && s.startTs != null)) { - throw TriathlonWriteError('the last open segment is not the last'); + 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', @@ -571,6 +586,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, last.idx], ); + _validateTimeline(await segments(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'finished'); }); } @@ -617,6 +633,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1], ); + _validateTimeline(await segments(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); }); } @@ -660,6 +677,8 @@ class TriathlonDb { 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) { @@ -692,6 +711,7 @@ class TriathlonDb { } // 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); } @@ -722,19 +742,28 @@ class TriathlonDb { }); } - /// Status / session-level bounds / notes. Refuses a finalized row, and never - /// clears `finalized_at` — finalization is one-way by design. + /// 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'); } - // Do not rewrite the frozen plan or an unknown future plan version. + const writable = {'armed', 'active', 'discarded'}; + 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, - 'start_ts': session.startTs, - 'end_ts': session.endTs, 'note': session.note, 'updated_at': DateTime.now().millisecondsSinceEpoch, }; @@ -753,6 +782,17 @@ class TriathlonDb { 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'); + if (rows.first['status'] != 'finished') { + throw TriathlonWriteError('only a finished session can be finalized'); + } final segs = await segments(txn, id); if (segs.isEmpty) throw TriathlonWriteError('nothing to finalize'); for (final s in segs) { @@ -760,6 +800,7 @@ class TriathlonDb { throw TriathlonWriteError('segment ${s.idx} is still open'); } } + _validateTimeline(segs); await txn.update( 'triathlon_session', { @@ -802,6 +843,61 @@ class TriathlonDb { }); } + /// THE timeline rule, applied to the whole session and not just the row + /// that changed. + /// + /// Started segments form a PREFIX: segment 3 cannot have a start while + /// segment 0 is still a plan row. Boundaries are contiguous and monotonic, + /// and at most one segment is open (started, not ended). Checking only the + /// edited row let a caller time segment 3 while 0 was open, which no later + /// operation can repair. + static void _validateTimeline(List segs) { + int? prevEnd; + var seenUnstarted = false; + var open = 0; + 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 (prevEnd != null && s.startTs != prevEnd) { + throw TriathlonWriteError( + 'segment ${s.idx} does not start where its predecessor ended', + ); + } + if (ended) { + if (s.endTs! < s.startTs!) { + throw TriathlonWriteError('segment ${s.idx} ends before it starts'); + } + prevEnd = s.endTs; + } else { + open++; + if (open > 1) { + throw TriathlonWriteError('more than one segment is open'); + } + // An open segment cannot have a started successor. + 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'); @@ -849,6 +945,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx + 1], ); + _validateTimeline(await segments(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); } diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart index a473a47b6..86bf341a6 100644 --- a/lib/state/triathlon_reconciler.dart +++ b/lib/state/triathlon_reconciler.dart @@ -16,9 +16,11 @@ // * 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 same rule the live path applies. +// 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.applyOfflineBoundaries]. A failure halfway through leaves the +// [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 @@ -42,6 +44,12 @@ const int kMinTaps = 3; /// The window those taps must fall inside, in seconds. const int kTapWindowSec = 6; +/// Widening applied to the event window on both sides, because the strap's RTC +/// and the phone's clock are different clocks and can drift. The boundary +/// floor still rejects anything at or before the last recorded boundary, so a +/// wider read can only ADD taps the strap really recorded, never rewind. +const int kClockDriftToleranceSec = 120; + /// Minimum time a 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 @@ -90,7 +98,12 @@ class TriathlonReconciler { 'band_events', columns: ['ts'], where: 'event_id = ? AND device_id = ? AND ts >= ? AND ts <= ?', - whereArgs: [kTriathlonDoubleTapEventId, deviceId, since, until], + whereArgs: [ + kTriathlonDoubleTapEventId, + deviceId, + since - kClockDriftToleranceSec, + until + kClockDriftToleranceSec, + ], orderBy: 'ts ASC', ); final taps = [ @@ -153,13 +166,16 @@ List deriveBoundaries({ if (t != null && t > floor) floor = t; } - // Consecutive taps no more than [kTapWindowSec] apart form one intent. The - // FIRST tap of the cluster is when the athlete said "next"; the rest are - // the "did it register?" taps. + // 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) { - if (clusters.isNotEmpty && t - clusters.last.last <= kTapWindowSec) { - clusters.last.add(t); + 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]); } @@ -169,7 +185,8 @@ List deriveBoundaries({ for (final cluster in clusters) { final at = cluster.first; if (at <= floor) continue; - final supporting = cluster.where((t) => t <= at + kTapWindowSec).length; + // Every member is inside the window by construction. + final supporting = cluster.length; if (supporting < kMinTaps) continue; if (started == 0) { diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index aec82259f..85324323c 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -3,6 +3,8 @@ // // 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:sqflite_common_ffi/sqflite_ffi.dart'; @@ -261,28 +263,43 @@ void main() { id: 'tri_sprint_fin', kind: 'race', preset: 'sprint', - status: 'active', + status: 'armed', plan: plan, createdAt: t0, updatedAt: t0, ); await TriathlonDb.create(db, session); - // Close all 5 segments contiguously. var t = 1000; - for (var i = 0; i < 5; i++) { - final seg = (await TriathlonDb.segments(db, 'tri_sprint_fin'))[i]; - final len = i.isEven ? 1000 : 60; - await TriathlonDb.saveSegment( - db, - seg.copyWith(startTs: t, endTs: t + len, boundarySource: 'gesture'), + 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 += len; } + 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]; @@ -296,6 +313,37 @@ void main() { 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'])); @@ -376,8 +424,7 @@ void main() { await LocalDb.close(); }); - test('a race under its minimum duration is not segmented by taps', - () async { + test('a transition under the segment minimum is ignored', () async { final db = await freshDb('tri_posthoc_floor_test.db'); final plan = TriathlonPlan.race( 'sprint', @@ -395,21 +442,35 @@ void main() { updatedAt: 1_000_000, ); await TriathlonDb.create(db, session); - // 20 s in: a real swim cannot be over, so this is not a transition. - for (final o in [0, 1, 2]) { - final ts = 1_000_020 + 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, - }); + + 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, isEmpty); + 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(); }); @@ -445,22 +506,23 @@ void main() { transitionTs: 1600, boundarySource: 'gesture', ); - var segs = await TriathlonDb.segments(db, 'tri_move_1'); - expect(segs[0].endTs, 1600); - expect(segs[1].startTs, 1600); - await TriathlonDb.transitionSegment( + await TriathlonDb.adjustBoundary( db: db, sessionId: 'tri_move_1', - currentIdx: 0, - transitionTs: 1700, - boundarySource: 'gesture', + 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: 1500, + transitionTs: 1800, boundarySource: 'gesture', ), throwsA(isA()), @@ -471,6 +533,78 @@ void main() { 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( @@ -596,5 +730,188 @@ void main() { 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(); + }); + }); } From d405d2ce6db47aae3e8e4db2133919c85cf24fa4 Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Sun, 1 Nov 2026 19:52:35 +0100 Subject: [PATCH 06/22] fix(triathlon): close the seven CodeRabbit findings - Day delete/export select a race by START or by segment OVERLAP, so an open or cross-midnight segment is included (arguments reordered accordingly). - Restore protects every locally present race, including discarded ones. - create accepts an armed plan only and refuses pre-filled session bounds or segment history. - adjustBoundary marks both moved boundaries 'manual': a human correction is not a gesture measurement. - The offline final branch rejects trailing boundaries and an end before the start, and validates the timeline before finishing. - linkWorkout refuses a missing segment, a transition and an empty id, and verifies one row changed. - The reconciler ignores sessions that are neither armed nor active. Tests: create invariants, manual provenance, link guards, discarded sessions, open cross-midnight segment deletion, export overlap and a trailing offline boundary chain. Verification remains static: structure/API, SQL against the real DDL, Python mirror of the pure rules. flutter analyze/test have not run. --- lib/data/db.dart | 42 ++-- lib/data/triathlon_store.dart | 57 ++++- lib/state/triathlon_reconciler.dart | 6 + test/triathlon_model_storage_test.dart | 300 ++++++++++++++++++++++++- 4 files changed, 381 insertions(+), 24 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 693f090bc..a7c4d68f6 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10956,16 +10956,17 @@ class LocalDb { const triInWindow = '(SELECT id FROM triathlon_session ' 'WHERE start_ts >= ? AND start_ts < ? ' 'UNION SELECT session_id FROM triathlon_segment ' - 'WHERE start_ts >= ? AND start_ts < ?)'; + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND (end_ts IS NULL OR end_ts > ?))'; await copyRows( 'triathlon_session', where: 'id IN $triInWindow', - whereArgs: [startSec, endSec, startSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec], ); await copyRows( 'triathlon_segment', where: 'session_id IN $triInWindow', - whereArgs: [startSec, endSec, startSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec], ); await copyRows( 'live_coverage', @@ -11155,25 +11156,28 @@ class LocalDb { // leak on delete) and could delete a race segment whose string id // collided with a workout id. // - // A race that starts before midnight and ends after it belongs to - // BOTH days: deleting either day must not leave its rows behind, so a - // race is selected when it starts in the window OR when any of its - // segments does. The whole race goes — deleting half a race would - // leave a record nobody can read. - // READ THE IDS FIRST. Deleting the segments first would empty the - // subquery the parent delete is built from, so a race selected only - // through a segment (the cross-midnight case) survived its own - // deletion. Selection and deletion must not share one statement here. + // 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. const triInWindow = '(SELECT id FROM triathlon_session ' 'WHERE start_ts >= ? AND start_ts < ? ' 'UNION SELECT session_id FROM triathlon_segment ' - 'WHERE start_ts >= ? AND start_ts < ?)'; + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND (end_ts IS NULL OR end_ts > ?))'; final triIds = [ for (final r in await txn.query( 'triathlon_session', columns: ['id'], where: 'id IN $triInWindow', - whereArgs: [startSec, endSec, startSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec], )) r['id'] as String, ]; @@ -11786,10 +11790,12 @@ 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 = {}; - // Finalized triathlons are this device's own record: a restore must not - // overwrite one with an older snapshot of the same race (its segments - // arrive separately, so protecting only the parent would still rewrite - // the boundaries). + // A LOCAL race always wins over a snapshot. Restore runs into an empty + // table on a new phone, so "local exists" means this device already has a + // newer (or deliberately discarded) record and the backup must not + // resurrect or rewind it. Segments are protected through the same id set, + // because they arrive as separate rows and would otherwise rewrite the + // boundaries of a race whose parent was skipped. final protectedTriathlons = {}; try { for (final r in await db.query( diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 513a6d6d7..73c48098b 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -337,8 +337,11 @@ class TriathlonDb { if (plan0 == null) throw TriathlonWriteError('a session needs a readable plan'); final problem = plan0.validate(); if (problem != null) throw TriathlonWriteError(problem); - if (session.isFinalized) { - throw TriathlonWriteError('cannot create an already-finalized session'); + 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) { @@ -374,6 +377,15 @@ class TriathlonDb { 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]); @@ -621,15 +633,18 @@ class TriathlonDb { 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).toRow(), + left.copyWith(endTs: newTs, boundarySource: 'manual').toRow(), where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx], ); await txn.update( 'triathlon_segment', - right.copyWith(startTs: newTs).toRow(), + right.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1], ); @@ -690,6 +705,15 @@ class TriathlonDb { } 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 @@ -698,6 +722,7 @@ class TriathlonDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, current.idx], ); + _validateTimeline(await segments(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'finished'); return; } @@ -722,22 +747,44 @@ class TriathlonDb { 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.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'); } - await txn.update( + 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); }); } diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart index 86bf341a6..fcc9ce84d 100644 --- a/lib/state/triathlon_reconciler.dart +++ b/lib/state/triathlon_reconciler.dart @@ -84,6 +84,12 @@ class TriathlonReconciler { } 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. diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index 85324323c..986700b05 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -217,7 +217,7 @@ void main() { id: 'tri_sprint_bounds', kind: 'race', preset: 'sprint', - status: 'active', + status: 'armed', plan: plan, createdAt: t0, updatedAt: t0, @@ -913,5 +913,303 @@ void main() { await LocalDb.close(); }); + + 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('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(); + }); + }); } From 210f8aaddcf4218611419f175d7ae0cf30a6003f Mon Sep 17 00:00:00 2001 From: BucciMobile Date: Sun, 1 Nov 2026 23:05:56 +0100 Subject: [PATCH 07/22] feat(triathlon): model pauses and DNF in the segment timeline - The timeline rule is now monotonic-with-optional-gaps instead of strictly contiguous: a workout can be interrupted for hours between disciplines, and a missed transition tap looks exactly like a pause. Overlaps and backwards time are still rejected; started segments still form a prefix. - TriSession reports totalSec, movingSec and pauseSec separately, so a pause is never billed as training time and an unmeasured segment stays null. - New status 'abandoned' models DNF: finalize accepts it when at least one segment started, exactly one segment may be open (the discipline the athlete stopped in) with a null duration, earlier segments closed and later ones untouched. A never-started session must be discarded instead. Tests: pause legality and pauseSec, overlap refusal, DNF finalize with an open cut segment, refusal to finalize a never-started session. Verification remains static (structure/API, SQL vs real DDL, Python mirror of the rules). flutter analyze/test have not run. --- lib/data/triathlon_store.dart | 123 ++++++++++++--- test/triathlon_model_storage_test.dart | 203 ++++++++++++++++++++++++- 2 files changed, 294 insertions(+), 32 deletions(-) diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 73c48098b..717d661b1 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -260,6 +260,42 @@ class TriSession { 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, @@ -801,7 +837,7 @@ class TriathlonDb { if (session.isFinalized || session.status == 'finalized') { throw TriathlonWriteError('use finalize to finalize a session'); } - const writable = {'armed', 'active', 'discarded'}; + const writable = {'armed', 'active', 'discarded', 'abandoned'}; if (!writable.contains(session.status)) { throw TriathlonWriteError( 'status ${session.status} is set by start/finish/finalize, not here', @@ -837,14 +873,44 @@ class TriathlonDb { limit: 1, ); if (rows.isEmpty) throw TriathlonWriteError('no such session: $id'); - if (rows.first['status'] != 'finished') { - throw TriathlonWriteError('only a finished session can be finalized'); + 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'); - for (final s in segs) { - if (s.startTs == null || s.endTs == null) { - throw TriathlonWriteError('segment ${s.idx} is still open'); + 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); @@ -893,15 +959,17 @@ class TriathlonDb { /// THE timeline rule, applied to the whole session and not just the row /// that changed. /// - /// Started segments form a PREFIX: segment 3 cannot have a start while - /// segment 0 is still a plan row. Boundaries are contiguous and monotonic, - /// and at most one segment is open (started, not ended). Checking only the - /// edited row let a caller time segment 3 while 0 was open, which no later - /// operation can repair. + /// 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) { - int? prevEnd; var seenUnstarted = false; var open = 0; + int? prevEnd; for (final s in segs) { final started = s.startTs != null; final ended = s.endTs != null; @@ -917,22 +985,24 @@ class TriathlonDb { 'segment ${s.idx} starts while an earlier segment never did', ); } - if (prevEnd != null && s.startTs != prevEnd) { - throw TriathlonWriteError( - 'segment ${s.idx} does not start where its predecessor ended', - ); + if (ended && s.endTs! < s.startTs!) { + throw TriathlonWriteError('segment ${s.idx} ends before it starts'); } - if (ended) { - if (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'); } - // An open segment cannot have a started successor. for (final later in segs) { if (later.idx > s.idx && later.startTs != null) { throw TriathlonWriteError( @@ -1080,22 +1150,25 @@ class TriathlonDb { /// Neighbours share one instant: the earlier segment's end is the later /// segment's start. Anything else is either a gap or an overlap, and both /// would make the splits stop adding up to the total. + /// 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) { - // [other] is the previous segment. if (other.endTs != null && changed.startTs != null && - other.endTs != changed.startTs) { + other.endTs! > changed.startTs!) { throw TriathlonWriteError( - 'segment ${changed.idx} does not start where ${other.idx} ended', + '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) { + changed.endTs! > other.startTs!) { throw TriathlonWriteError( - 'segment ${other.idx} does not start where ${changed.idx} ended', + 'segment ${other.idx} starts before ${changed.idx} ended', ); } } diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index 986700b05..011d045b7 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -238,15 +238,15 @@ void main() { seg1.copyWith(startTs: 1900, endTs: 1960, boundarySource: 'gesture'), ); - // Attempting to start T2 at 2000 (gap after T1's 1960) throws. + // 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 expectLater( - TriathlonDb.saveSegment( - db, - seg2.copyWith(startTs: 2000, endTs: 3000, boundarySource: 'gesture'), - ), - throwsA(isA()), + 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(); }); @@ -527,6 +527,12 @@ void main() { ), 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, 1700); @@ -1211,5 +1217,188 @@ void main() { 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, 660 + 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(); + }); + }); } From 7c8f2572ca3d76fa2f2c755e540b40b1c3edcf1d Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 1 Nov 2026 22:57:27 +0000 Subject: [PATCH 08/22] fix(triathlon): add adjustStart and removeBoundary correction paths A false triple-tap in the start pen started the race at the wrong time with no way to heal it (a session may only start once), and a false triple-tap mid-race split one discipline in two, while adjustBoundary can only move a boundary, never remove one. - adjustStart moves the race start, legal only while no segment has ended and no segment is linked to a workout; stamps 'manual'. - removeBoundary merges segment idx+1 into idx (successor must be the newest started segment), keeps earlier start / later end, stamps 'manual' on the survivor. - Tests: false start correction + refusal after a closed split, merge of a live-edge boundary + refusal once a later segment started. Verification: static only (no Flutter SDK here); flutter test still pending in CI. Co-authored-by: BucciMobile --- lib/data/triathlon_store.dart | 112 ++++++++++++++++++++++ test/triathlon_model_storage_test.dart | 127 +++++++++++++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 717d661b1..292f5348d 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -639,6 +639,61 @@ class TriathlonDb { }); } + /// 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 @@ -689,6 +744,63 @@ class TriathlonDb { }); } + /// 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', + ); + } + 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 diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index 011d045b7..9e72fa17d 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -1014,6 +1014,133 @@ void main() { 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( From c275bd239f7a10ff2f0d804f7ec49077d0ee9c90 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 1 Nov 2026 23:31:02 +0000 Subject: [PATCH 09/22] fix(triathlon): address CodeRabbit full-review findings on 7c8f2572 - db.dart: remove extra closing brace (compile error from rebase) - db.dart: day delete/export bound open segments of FINALIZED races by COALESCE(session.end_ts, session.start_ts); live sessions keep the open-overlap rule (verified against SQLite for closed/open/cross-midnight) - db.dart: day delete now cascades linked workouts (route, splits, session_sensor, strength_set, sessions row) in the same transaction - db.dart: restore guard compares updated_at so a newer backup can advance a race; discarded stays protected; _triProtected helper - store: linkWorkout null-checks TriSegment.fromRow (compile error) - store: removeBoundary rejects segments with a linked workout - plan: fromJson parses wrong-typed JSON fields without throwing; TriSession.fromRow catches all parse errors as 'unknown plan' - store: doc comments moved/corrected (TriathlonWriteError, _checkNeighbour) Not addressed here (heavy lifts, reported in PR): band_events retention for unreconciled sessions, reconciler clock-domain floor. Verification: static + SQL against real DDL in SQLite; flutter analyze/test still pending (no SDK here). Co-authored-by: BucciMobile --- lib/data/db.dart | 89 ++++++++++++++++++++++++++++------- lib/data/triathlon_plan.dart | 15 ++++-- lib/data/triathlon_store.dart | 19 ++++++-- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index a7c4d68f6..d841612ff 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10953,20 +10953,29 @@ class LocalDb { ); // 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 NULL OR end_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], + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], ); await copyRows( 'triathlon_segment', where: 'session_id IN $triInWindow', - whereArgs: [startSec, endSec, endSec, startSec], + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], ); await copyRows( 'live_coverage', @@ -11167,22 +11176,52 @@ class LocalDb { // 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 NULL OR end_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], + 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, @@ -11770,6 +11809,19 @@ class LocalDb { return {for (final c in info) (c['name'] as String)}; } + // 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". @@ -11790,20 +11842,24 @@ class LocalDb { // get the same protection, or trends and the rolling baselines would be // rebuilt from the other export's numbers while day detail serves ours. final finalizedDays = {}; - // A LOCAL race always wins over a snapshot. Restore runs into an empty - // table on a new phone, so "local exists" means this device already has a - // newer (or deliberately discarded) record and the backup must not - // resurrect or rewind it. Segments are protected through the same id set, - // because they arrive as separate rows and would otherwise rewrite the - // boundaries of a race whose parent was skipped. + // 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 = {}; try { for (final r in await db.query( 'triathlon_session', - columns: ['id'], + columns: ['id', 'status', 'updated_at'], distinct: true, )) { - protectedTriathlons.add('${r['id']}'); + final id = '${r['id']}'; + triLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; + if (r['status'] == 'discarded') { + protectedTriathlons.add(id); + } } } on DatabaseException { /* table absent in an older build of this file — nothing to protect */ @@ -12535,14 +12591,13 @@ class LocalDb { } } if (t == 'triathlon_session' && - protectedTriathlons.contains('${row['id']}')) { - continue; // locally finalized — never overwritten by import + _triProtected('${row['id']}', row)) { + continue; // locally discarded, or newer than the snapshot } if (t == 'triathlon_segment' && - protectedTriathlons.contains('${row['session_id']}')) { + _triProtected('${row['session_id']}', null)) { continue; } - } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', diff --git a/lib/data/triathlon_plan.dart b/lib/data/triathlon_plan.dart index 8528a435c..27170632c 100644 --- a/lib/data/triathlon_plan.dart +++ b/lib/data/triathlon_plan.dart @@ -137,13 +137,17 @@ class TriSegmentPlan { }; static TriSegmentPlan? fromJson(Map j) { - final kind = TriSegmentKindX.fromKey(j['kind'] as String?); + 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(j['environment'] as String?), - plannedDistanceM: (j['planned_distance_m'] as num?)?.toDouble(), - goalSec: (j['goal_sec'] as num?)?.toInt(), + environment: TriEnvironmentX.fromKey(env is String ? env : null), + plannedDistanceM: dist is num ? dist.toDouble() : null, + goalSec: goal is num ? goal.toInt() : null, ); } } @@ -278,7 +282,8 @@ class TriathlonPlan { /// 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 v = (j['v'] as num?)?.toInt(); + 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']; diff --git a/lib/data/triathlon_store.dart b/lib/data/triathlon_store.dart index 292f5348d..e4302f947 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -319,6 +319,10 @@ class TriSession { } } 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, @@ -336,8 +340,6 @@ class TriSession { } } -/// 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". /// 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: @@ -356,6 +358,8 @@ class ReconcileBoundary { 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; @@ -781,6 +785,11 @@ class TriathlonDb { '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'); @@ -910,6 +919,9 @@ class TriathlonDb { 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', @@ -1259,9 +1271,6 @@ class TriathlonDb { } } - /// Neighbours share one instant: the earlier segment's end is the later - /// segment's start. Anything else is either a gap or an overlap, and both - /// would make the splits stop adding up to the total. /// 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; From 918f883d6d7eeb21e17bc331f403aa3651ab209a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 16:39:03 +0000 Subject: [PATCH 10/22] feat(hyrox): model and storage foundation (schema rung 61) Mirror of the triathlon storage PR (#648) for HYROX, storage-only: - lib/data/hyrox_plan.dart: frozen versioned plan (v:1), one segment per round (kind hyrox_round, station, round_index 1..8), presets hyrox / hyrox_doubles / hyrox_training / custom, strict validate(), wrong-typed JSON fields parse to null without throwing. - lib/data/hyrox_store.dart: hyrox_session + hyrox_segment tables (rung 61; 60 is taken by #648), finalized = immutable, started-prefix/monotonic/ no-overlap timeline with gaps as pauses (totalSec vs movingSec/pauseSec), create/startSegment/transitionSegment/finishSegment/adjustBoundary/ adjustStart/removeBoundary/linkWorkout/finalize (incl. DNF)/ applyOfflineBoundariesInTx, all SQL bound-params only. - lib/state/hyrox_reconciler.dart: offline burst rule (>=3 double-taps in 6s anchored on the first tap, chained 0/6/12 does not count), per-station minimum-duration floors, +/-120s clock-drift tolerance on read, never touches finalized sessions; no production caller yet (follow-up PR). - lib/data/db.dart + recent_slice.dart: migration rung 61, day delete/export with segment-overlap selection (open segments of finalized sessions bounded by COALESCE(end_ts, start_ts)), chunked IN via _sqlVarChunks, linked-workout cascade in the same txn, restore protects discarded and updated_at-compares, salvage + recent_slice registration. - test/hyrox_model_storage_test.dart: model validation, lifecycle, timeline, DNF, cross-midnight delete/export, restore, reconciler bursts/floors, correction refusal paths. kAlgoVersion unchanged; no UI/haptics/live-tap wiring in this PR. flutter analyze / flutter test have NOT run (no Flutter SDK in the sandbox); CI must verify. Co-authored-by: BucciMobile --- lib/data/db.dart | 167 +++- lib/data/hyrox_plan.dart | 316 +++++++ lib/data/hyrox_store.dart | 1337 ++++++++++++++++++++++++++++ lib/data/recent_slice.dart | 2 + lib/state/hyrox_reconciler.dart | 228 +++++ test/hyrox_model_storage_test.dart | 1020 +++++++++++++++++++++ 6 files changed, 3069 insertions(+), 1 deletion(-) create mode 100644 lib/data/hyrox_plan.dart create mode 100644 lib/data/hyrox_store.dart create mode 100644 lib/state/hyrox_reconciler.dart create mode 100644 test/hyrox_model_storage_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 578df4b9c..a3fcd2e37 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -57,6 +57,7 @@ import 'live_coverage_policy.dart'; import '../compute/step_calibration_fit.dart'; import 'step_calibration.dart'; import '../sync/reset_gate.dart'; +import 'hyrox_store.dart'; import 'med_store.dart'; import 'models.dart'; import 'nutrition_store.dart'; @@ -299,6 +300,10 @@ class LocalDb { 'imported_workout', 'workout_route', 'workout_split', + // A HYROX race is hand-marked at the band: the round boundaries exist + // nowhere else, and the plan was typed by the athlete. + 'hyrox_session', + 'hyrox_segment', // The only copy of what a paired sensor measured during a session. 'external_hr', 'imported_measurement', @@ -479,7 +484,12 @@ 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; + // 59 is claimed by the open stranded-device-id PR (#537); 60 by the + // triathlon storage PR (#648). This feature takes the next free rung rather + // than collide with them. The ladder is a sequence of `oldV < N` checks, so + // an unused rung costs nothing: a device upgrading from 58 runs this step, + // and whichever PR lands second renumbers. + static const int schemaVersion = 61; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -1249,6 +1259,11 @@ class LocalDb { // A band's own hypnogram (`vendor_staged`). New table only. await _createVendorSleepEpoch(db); } + if (oldV < 61) { + // HYROX: parent session + per-round boundaries. New tables only, + // no backfill — there is no older data to move. + await createHyroxTables(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -5504,6 +5519,10 @@ class LocalDb { // DDL belongs next to the code that reads it, not two thousand lines away. await createNutritionTables(db); await createMedTables(db); + // HYROX sessions and their round segments: hand-recorded race data, so + // the creator lives here where onCreate, the onOpen repair pass and the + // export database all run it. CREATE IF NOT EXISTS, so re-running is free. + await createHyroxTables(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -10933,6 +10952,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 hyroxInWindow = '(SELECT id FROM hyrox_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM hyrox_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL AND start_ts < ? ' + ' AND EXISTS (SELECT 1 FROM hyrox_session s ' + ' WHERE s.id = hyrox_segment.session_id ' + ' AND (s.status <> \'finalized\' ' + ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + await copyRows( + 'hyrox_session', + where: 'id IN $hyroxInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + ); + await copyRows( + 'hyrox_segment', + where: 'session_id IN $hyroxInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + ); await copyRows( 'live_coverage', where: 'end_ts > ? AND start_ts < ?', @@ -11115,6 +11160,78 @@ class LocalDb { [startSec, endSec], ); } + // HYROX rows are NOT children of `sessions`: their key is the hyrox + // session id, and a segment's `workout_id` merely points at one. + // Treating them as session children matched nothing (a privacy leak + // on delete) and could delete a race segment whose string id collided + // with a workout id. + // + // A race belongs to a day when it STARTS that day, or when one of its + // segments OVERLAPS it. Overlap is not "starts inside": a segment that + // began yesterday and is still open, or ends today, was recorded on + // this day too. An open segment has no end, so it overlaps every later + // window. Deleting either day takes the WHOLE race — half a race is a + // record nobody can read. + // + // The ids are read first and deleted second: deleting the segments + // before the parent statement would empty the very subquery the parent + // delete is built from, which is how a cross-midnight race used to + // survive its own deletion. + // An open segment only overlaps later days while the session is still + // live; a FINALIZED race (incl. an abandoned DNF with an open cut + // segment) is bounded by COALESCE(session.end_ts, session.start_ts). + const hyroxInWindow = '(SELECT id FROM hyrox_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM hyrox_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL AND start_ts < ? ' + ' AND EXISTS (SELECT 1 FROM hyrox_session s ' + ' WHERE s.id = hyrox_segment.session_id ' + ' AND (s.status <> \'finalized\' ' + ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + final hyroxIds = [ + for (final r in await txn.query( + 'hyrox_session', + columns: ['id'], + where: 'id IN $hyroxInWindow', + whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + )) + r['id'] as String, + ]; + for (final chunk in _sqlVarChunks(hyroxIds)) { + final marks = List.filled(chunk.length, '?').join(','); + // Collect the linked workouts BEFORE the segment rows go: a segment + // deleted with its `sessions` row left behind would orphan the + // workout's route and splits on a day the user asked to delete. + final linked = await txn.query( + 'hyrox_segment', + columns: ['workout_id'], + where: 'session_id IN ($marks) AND workout_id IS NOT NULL', + whereArgs: chunk, + ); + for (final w in linked) { + final wid = w['workout_id'] as String; + await txn.delete('workout_route', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('workout_split', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('session_sensor', + where: 'session_id = ?', whereArgs: [wid]); + await txn.delete('strength_set', + where: 'session_id = ?', whereArgs: [wid]); + deleted += await txn.delete('sessions', + where: 'id = ?', whereArgs: [wid]); + } + deleted += await txn.rawDelete( + 'DELETE FROM hyrox_segment WHERE session_id IN ($marks)', + chunk, + ); + deleted += await txn.rawDelete( + 'DELETE FROM hyrox_session WHERE id IN ($marks)', + chunk, + ); + } deleted += await txn.delete( 'sessions', where: 'start_ts >= ? AND start_ts < ?', @@ -11491,6 +11608,9 @@ class LocalDb { // Keep the decision merge after both kinds of accepted activity. 'sessions', 'session_sensor', + // The recorded round boundaries and their plans cannot be rebuilt. + 'hyrox_session', + 'hyrox_segment', 'activity_suggestions', 'activity_review_meta', 'activity_review_days', @@ -11680,6 +11800,19 @@ class LocalDb { 'metric_method', }; + // A local race is protected from an import when the athlete explicitly + // discarded it, or when the local row was edited no earlier than the + // imported snapshot (a newer backup may still advance the race). [row] is + // the imported hyrox_session row, null for segment rows. + bool _hyroxProtected(String id, Map? row) { + if (protectedHyrox.contains(id)) return true; + if (row == null) return false; + final local = hyroxLastEdit[id]; + if (local == null) return false; + final theirs = (row['updated_at'] as num?)?.toInt() ?? 0; + return local >= theirs; + } + // Columns this app's schema actually has, per table — so a row from a NEWER // export carrying extra columns this build doesn't know about is filtered // down (dropped) instead of throwing "no such column". A column the source @@ -11710,6 +11843,28 @@ class LocalDb { // get the same protection, or trends and the rolling baselines would be // rebuilt from the other export's numbers while day detail serves ours. final finalizedDays = {}; + // A LOCAL race wins over an OLDER snapshot, but a NEWER backup must be + // able to advance it (e.g. the final boundaries recorded on another + // phone). `discarded` is an explicit local decision and always wins. + // Segments follow their parent's verdict, because they arrive as separate + // rows and would otherwise rewrite the boundaries of a skipped race. + final protectedHyrox = {}; + final hyroxLastEdit = {}; + try { + for (final r in await db.query( + 'hyrox_session', + columns: ['id', 'status', 'updated_at'], + distinct: true, + )) { + final id = '${r['id']}'; + hyroxLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; + if (r['status'] == 'discarded') { + protectedHyrox.add(id); + } + } + } on DatabaseException { + /* table absent in an older build of this file — nothing to protect */ + } var archiveSkipped = 0; int? archiveEdgeSec; var impAttached = false; @@ -12436,6 +12591,14 @@ class LocalDb { } } } + if (t == 'hyrox_session' && + _hyroxProtected('${row['id']}', row)) { + continue; // locally discarded, or newer than the snapshot + } + if (t == 'hyrox_segment' && + _hyroxProtected('${row['session_id']}', null)) { + continue; + } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', @@ -12829,6 +12992,8 @@ class LocalDb { 'signal_priority', 'vendor_sleep_epoch', 'substrate_archive', + 'hyrox_session', + 'hyrox_segment', ]; final missingTables = []; diff --git a/lib/data/hyrox_plan.dart b/lib/data/hyrox_plan.dart new file mode 100644 index 000000000..4f09bd3f4 --- /dev/null +++ b/lib/data/hyrox_plan.dart @@ -0,0 +1,316 @@ +// HYROX PLAN — pure Dart, no Flutter, no sqflite. +// +// A plan is what the athlete chose BEFORE arming: the ordered rounds, the +// station of each round, the planned targets and the optional goal times. It +// is frozen into `hyrox_session.plan_json` at arm time and never edited +// afterwards, so a finished race still says what was actually attempted +// rather than what the preset means today. +// +// Domain decision, made once and documented here: ONE SEGMENT PER ROUND. A +// HYROX round is 1 km run + one station, and this app models the round as a +// single `hyrox_round` segment (kind + `station` + `round_index` 1..8) rather +// than a run/station split. The athlete taps at round transitions — after +// each station, before the next run — which is exactly eight boundaries for +// eight rounds plus the start. The final 1 km run after round 8 is part of +// round 8's segment: segment 8 runs from the start of round 8's run to the +// finish line. The interleaved 1 km runs are NOT separate segments in this +// model; if a future PR wants run/station granularity that is a new segment +// kind, not a reinterpretation of `hyrox_round`. +// +// Two rules shape this file: +// +// * A PLANNED distance/reps/calorie target is not a MEASURED distance. The +// preset table below is a target the user typed or accepted, never +// something the band measured, so it lives here and not in +// `hyrox_segment.distance_m`. +// * HYROX is always an indoor arena event, so there is no per-segment +// indoor/outdoor choice (unlike triathlon): the runs happen on an indoor +// track and no segment arms GPS. + +/// Current serialization version of [HyroxPlan.toJson]. Readers refuse a +/// newer major version instead of silently dropping fields they do not know. +const int kHyroxPlanVersion = 1; + +/// Every HYROX segment in this build is one round. The kind exists so the row +/// format can grow (e.g. a finer run/station split) without a schema change. +enum HyroxSegmentKind { hyroxRound } + +extension HyroxSegmentKindX on HyroxSegmentKind { + String get key => switch (this) { + HyroxSegmentKind.hyroxRound => 'hyrox_round', + }; + + static HyroxSegmentKind? fromKey(String? key) => switch (key) { + 'hyrox_round' => HyroxSegmentKind.hyroxRound, + _ => null, + }; +} + +/// The eight stations, in the fixed HYROX race order. `key` is the on-disk +/// value; free-form presets reuse the same vocabulary. +enum HyroxStation { + skierg, + sledPush, + sledPull, + burpeeBroadJump, + rowing, + farmersCarry, + sandbagLunges, + wallBalls, +} + +extension HyroxStationX on HyroxStation { + String get key => switch (this) { + HyroxStation.skierg => 'skierg', + HyroxStation.sledPush => 'sled_push', + HyroxStation.sledPull => 'sled_pull', + HyroxStation.burpeeBroadJump => 'burpee_broad_jump', + HyroxStation.rowing => 'rowing', + HyroxStation.farmersCarry => 'farmers_carry', + HyroxStation.sandbagLunges => 'sandbag_lunges', + HyroxStation.wallBalls => 'wall_balls', + }; + + static HyroxStation? fromKey(String? key) => switch (key) { + 'skierg' => HyroxStation.skierg, + 'sled_push' => HyroxStation.sledPush, + 'sled_pull' => HyroxStation.sledPull, + 'burpee_broad_jump' => HyroxStation.burpeeBroadJump, + 'rowing' => HyroxStation.rowing, + 'farmers_carry' => HyroxStation.farmersCarry, + 'sandbag_lunges' => HyroxStation.sandbagLunges, + 'wall_balls' => HyroxStation.wallBalls, + _ => null, + }; +} + +/// The fixed station order of a HYROX race. +const List kHyroxStationOrder = [ + HyroxStation.skierg, + HyroxStation.sledPush, + HyroxStation.sledPull, + HyroxStation.burpeeBroadJump, + HyroxStation.rowing, + HyroxStation.farmersCarry, + HyroxStation.sandbagLunges, + HyroxStation.wallBalls, +]; + +/// Every round starts with a 1 km run. Fixed by the race format, so it is a +/// constant of the domain and not a per-segment plan field. +const double kHyroxRunPerRoundM = 1000; + +/// Preset keys. `hyrox` and `hyrox_doubles` are the fixed eight-station race +/// (doubles only changes who does which round — a pairing concern, not a +/// storage one, so the segments are identical); `hyrox_training` and `custom` +/// are free-form ordered lists. No event-brand marks, see TRADEMARKS.md. +const List kHyroxPresets = [ + 'hyrox', + 'hyrox_doubles', + 'hyrox_training', + 'custom', +]; + +/// True for the two fixed-race presets that must always be the canonical +/// eight rounds in order. +bool _isFixedRacePreset(String preset) => + preset == 'hyrox' || preset == 'hyrox_doubles'; + +/// Planned targets per station for the fixed race: (distance m, reps, cal). +/// SkiErg and rowing are 1000 m OR 50 cal — configurable by the athlete, so +/// both targets may be present and the UI picks which to show; farmers carry +/// is 2×40 m = 80 m; wall balls are rep-based (100 reps, ball weight is a +/// gym-setup detail, not stored). +const Map kHyroxStationTargets = { + HyroxStation.skierg: (1000, null, 50), + HyroxStation.sledPush: (50, null, null), + HyroxStation.sledPull: (50, null, null), + HyroxStation.burpeeBroadJump: (80, null, null), + HyroxStation.rowing: (1000, null, 50), + HyroxStation.farmersCarry: (80, null, null), + HyroxStation.sandbagLunges: (100, null, null), + HyroxStation.wallBalls: (null, 100, null), +}; + +class HyroxSegmentPlan { + const HyroxSegmentPlan({ + required this.kind, + required this.station, + this.roundIndex, + this.plannedDistanceM, + this.plannedReps, + this.plannedCal, + this.goalSec, + }); + + final HyroxSegmentKind kind; + + /// Which station this round ends with. + final HyroxStation station; + + /// 1..8 in a fixed race; null in a free-form plan. A plan target, not a + /// measurement. + final int? roundIndex; + + /// Target distance in metres for distance-based stations, or null. This is + /// a PLAN, not a measurement. + final double? plannedDistanceM; + + /// Target repetitions for rep-based stations (wall balls), or null. + final int? plannedReps; + + /// Target calories for cal-based stations (skierg/rowing alternative), or + /// null. + final int? plannedCal; + + /// Optional goal time in seconds for the whole round. Null = no goal. + final int? goalSec; + + Map toJson() => { + 'kind': kind.key, + 'station': station.key, + 'round_index': roundIndex, + 'planned_distance_m': plannedDistanceM, + 'planned_reps': plannedReps, + 'planned_cal': plannedCal, + 'goal_sec': goalSec, + }; + + static HyroxSegmentPlan? fromJson(Map j) { + final kind = HyroxSegmentKindX.fromKey(j['kind'] is String ? j['kind'] : null); + final station = + HyroxStationX.fromKey(j['station'] is String ? j['station'] : null); + if (kind == null || station == null) return null; + final dist = j['planned_distance_m']; + final reps = j['planned_reps']; + final cal = j['planned_cal']; + final round = j['round_index']; + final goal = j['goal_sec']; + return HyroxSegmentPlan( + kind: kind, + station: station, + roundIndex: round is num ? round.toInt() : null, + plannedDistanceM: dist is num ? dist.toDouble() : null, + plannedReps: reps is num ? reps.toInt() : null, + plannedCal: cal is num ? cal.toInt() : null, + goalSec: goal is num ? goal.toInt() : null, + ); + } +} + +class HyroxPlan { + const HyroxPlan({ + required this.preset, + required this.segments, + }); + + /// 'hyrox' | 'hyrox_doubles' | 'hyrox_training' | 'custom'. Free-form on + /// disk on purpose: an older build must still be able to SHOW a plan whose + /// preset it does not know. + final String preset; + final List segments; + + bool get isTraining => preset == 'hyrox_training' || preset == 'custom'; + + /// The fixed eight-round race. `preset` must be 'hyrox' or 'hyrox_doubles'; + /// [goalSec] optionally overrides the per-round goal times. + factory HyroxPlan.race( + String preset, { + Map? goalSec, + }) { + if (!_isFixedRacePreset(preset)) { + throw ArgumentError('race() needs a fixed preset, got "$preset"'); + } + return HyroxPlan( + preset: preset, + segments: [ + for (var i = 0; i < kHyroxStationOrder.length; i++) + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: kHyroxStationOrder[i], + roundIndex: i + 1, + plannedDistanceM: kHyroxStationTargets[kHyroxStationOrder[i]]?.$1, + plannedReps: kHyroxStationTargets[kHyroxStationOrder[i]]?.$2, + plannedCal: kHyroxStationTargets[kHyroxStationOrder[i]]?.$3, + goalSec: goalSec?[kHyroxStationOrder[i]], + ), + ], + ); + } + + /// Free-form training: any ordered subset/repetition of stations (e.g. a + /// single-station drill or a 4-round opener). + factory HyroxPlan.training(List segments) => + HyroxPlan(preset: 'hyrox_training', segments: segments); + + /// Free-form custom session. + factory HyroxPlan.custom(List segments) => + HyroxPlan(preset: 'custom', segments: segments); + + /// Structural validation. Returns a human-readable problem, or null when the + /// plan is storable. This is not a judgement about whether the workout is a + /// good idea — only whether the row can be interpreted later. + String? validate() { + if (segments.isEmpty) return 'a plan needs at least one segment'; + for (var i = 0; i < segments.length; i++) { + final s = segments[i]; + final r = s.roundIndex; + if (r != null && r <= 0) return 'a round index is positive or absent'; + final d = s.plannedDistanceM; + if (d != null && (!d.isFinite || d <= 0)) { + return 'a planned distance is finite and positive, or absent'; + } + final reps = s.plannedReps; + if (reps != null && reps <= 0) { + return 'a planned rep count is positive or absent'; + } + final cal = s.plannedCal; + if (cal != null && cal <= 0) { + return 'a planned calorie target is positive or absent'; + } + final g = s.goalSec; + if (g != null && g <= 0) return 'a goal time is positive or absent'; + } + if (_isFixedRacePreset(preset)) { + if (segments.length != kHyroxStationOrder.length) { + return 'a fixed race plan is exactly the eight stations in order'; + } + for (var i = 0; i < kHyroxStationOrder.length; i++) { + final s = segments[i]; + if (s.station != kHyroxStationOrder[i] || s.roundIndex != i + 1) { + return 'a fixed race plan is exactly the eight stations in order'; + } + } + } + return null; + } + + /// HYROX is an indoor arena event end to end — the runs are on an indoor + /// track — so no segment ever arms GPS. + bool get needsGps => false; + + Map toJson() => { + 'v': kHyroxPlanVersion, + 'preset': preset, + 'segments': [for (final s in segments) s.toJson()], + }; + + /// Null when the payload is not a plan this build can read. Callers treat + /// that as "unknown plan", never as an empty race. + static HyroxPlan? fromJson(Map j) { + final rawV = j['v']; + final v = rawV is num ? rawV.toInt() : null; + if (v == null || v > kHyroxPlanVersion) return null; + final preset = j['preset']; + final raw = j['segments']; + if (preset is! String || raw is! List) return null; + final segments = []; + for (final e in raw) { + if (e is! Map) return null; + final s = HyroxSegmentPlan.fromJson(e.cast()); + if (s == null) return null; + segments.add(s); + } + return HyroxPlan(preset: preset, segments: segments); + } +} diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart new file mode 100644 index 000000000..a9c9b249e --- /dev/null +++ b/lib/data/hyrox_store.dart @@ -0,0 +1,1337 @@ +// HYROX storage — schema, row models and the CRUD seam. +// +// Parent/child on purpose. `hyrox_session` owns the frozen plan and the +// lifecycle; `hyrox_segment` owns the timed boundaries. One segment per +// ROUND (1 km run + station, see hyrox_plan.dart), so a race is exactly eight +// segments with `round_index` 1..8; round 8 runs to the finish line and +// includes the final 1 km run. A round segment MAY also get a real `sessions` +// row (workout_id) from the live wiring, so routes, zones, splits and Health +// export keep their single existing code path. +// +// THE TWO INVARIANTS THIS FILE ENFORCES, INDEPENDENT OF THE STATE MACHINE: +// +// 1. FINALIZED MEANS IMMUTABLE. `finalize()` stamps `finalized_at`; every +// later write to that session or its segments throws (including its note). +// A race that has been saved is a record, and a screen must not be able to +// quietly rewrite it. Boundary corrections belong to the not-yet-finalized +// session, and a future "re-derive" flow gets a NEW row, exactly like +// `day_result`'s immutable-per-version rule. +// +// 2. SEGMENTS CANNOT OVERLAP OR RUN BACKWARDS. A segment's end is at or after +// its start, and neighbours meet at one instant or leave a gap: segment +// i's end is at or before segment i+1's start. That is what a single +// "next" tap on the band means. A gap is a PAUSE (station setup, a breather +// between rounds), reported separately from moving time — never billed as +// workout time. +// +// Units follow the `sessions` table: segment boundaries are epoch SECONDS, +// bookkeeping stamps (created_at / updated_at / finalized_at) are epoch +// MILLISECONDS. Mixing them is the classic silent bug, so the row models name +// the unit in every field. + +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; + +import 'hyrox_plan.dart'; + +/// schemaVersion 61. Parent before child on every create. (Rung 60 belongs to +/// the triathlon storage PR; this feature takes the next free rung.) +Future createHyroxTables(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS hyrox_session ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + preset TEXT NOT NULL, + status TEXT NOT NULL, + plan_json TEXT NOT NULL, + start_ts INTEGER, + end_ts INTEGER, + note TEXT NOT NULL DEFAULT '', + finalized_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_session_status ' + 'ON hyrox_session(status)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_session_start ' + 'ON hyrox_session(start_ts)', + ); + await db.execute(''' + CREATE TABLE IF NOT EXISTS hyrox_segment ( + session_id TEXT NOT NULL, + idx INTEGER NOT NULL, + kind TEXT NOT NULL, + station TEXT NOT NULL, + round_index INTEGER, + planned_distance_m REAL, + planned_reps INTEGER, + planned_cal INTEGER, + goal_sec INTEGER, + start_ts INTEGER, + end_ts INTEGER, + boundary_source TEXT NOT NULL DEFAULT 'unknown', + workout_id TEXT, + distance_m REAL, + distance_source TEXT, + hr_source TEXT, + PRIMARY KEY (session_id, idx) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_hyrox_segment_workout ' + 'ON hyrox_segment(workout_id)', + ); +} + +/// How a segment boundary was established. `gesture` is a live band tap, +/// `phone` an on-screen button, `manual` a boundary the user moved afterwards, +/// `posthoc` a boundary reconstructed from band history or suggested by the +/// timeline — i.e. NOT a measurement of when the athlete actually changed +/// station, and therefore never to be presented as one. `unknown` is the +/// honest default for a row this build did not write. +const List kHyroxBoundarySources = [ + 'gesture', + 'phone', + 'manual', + 'posthoc', + 'unknown', +]; + +class HyroxSegment { + const HyroxSegment({ + required this.sessionId, + required this.idx, + required this.kind, + required this.station, + this.roundIndex, + this.plannedDistanceM, + this.plannedReps, + this.plannedCal, + this.goalSec, + this.startTs, + this.endTs, + this.boundarySource = 'unknown', + this.workoutId, + this.distanceM, + this.distanceSource, + this.hrSource, + }); + + final String sessionId; + final int idx; + final HyroxSegmentKind kind; + + /// The station this round ends with. + final HyroxStation station; + + /// 1..8 in a fixed race; null in a free-form plan. + final int? roundIndex; + + /// Planned targets frozen from the plan. A PLAN, never a measurement. + final double? plannedDistanceM; + final int? plannedReps; + final int? plannedCal; + final int? goalSec; + + /// Epoch SECONDS. Null until the segment is entered / left. + final int? startTs; + final int? endTs; + + final String boundarySource; + + /// The linked `sessions.id` for a round segment, null when not banked yet. + final String? workoutId; + + /// MEASURED or explicitly user-entered distance in metres. Null means "we do + /// not know" and the UI shows "—". It is NEVER copied from + /// [plannedDistanceM]: a plan is not a measurement. + final double? distanceM; + + /// 'gps' | 'user' | null. Null whenever [distanceM] is null — a distance + /// without a source is exactly the number this repo refuses to show. + final String? distanceSource; + + /// 'band' | 'strap' | null: which heart-rate source the segment used. + final String? hrSource; + + bool get isOpen => startTs != null && endTs == null; + + /// Seconds in the segment, or null while it is still open. Zero-length + /// segments are legal (an instant tap-through), negative ones are not. + int? get durationSec => + startTs == null || endTs == null ? null : endTs! - startTs!; + + HyroxSegment copyWith({ + int? roundIndex, + double? plannedDistanceM, + int? plannedReps, + int? plannedCal, + int? goalSec, + int? startTs, + int? endTs, + String? boundarySource, + String? workoutId, + double? distanceM, + String? distanceSource, + String? hrSource, + bool clearEnd = false, + bool clearStart = false, + }) { + return HyroxSegment( + sessionId: sessionId, + idx: idx, + kind: kind, + station: station, + roundIndex: roundIndex ?? this.roundIndex, + plannedDistanceM: plannedDistanceM ?? this.plannedDistanceM, + plannedReps: plannedReps ?? this.plannedReps, + plannedCal: plannedCal ?? this.plannedCal, + goalSec: goalSec ?? this.goalSec, + startTs: clearStart ? null : (startTs ?? this.startTs), + endTs: clearEnd ? null : (endTs ?? this.endTs), + boundarySource: boundarySource ?? this.boundarySource, + workoutId: workoutId ?? this.workoutId, + distanceM: distanceM ?? this.distanceM, + distanceSource: distanceSource ?? this.distanceSource, + hrSource: hrSource ?? this.hrSource, + ); + } + + Map toRow() => { + 'session_id': sessionId, + 'idx': idx, + 'kind': kind.key, + 'station': station.key, + 'round_index': roundIndex, + 'planned_distance_m': plannedDistanceM, + 'planned_reps': plannedReps, + 'planned_cal': plannedCal, + 'goal_sec': goalSec, + 'start_ts': startTs, + 'end_ts': endTs, + 'boundary_source': boundarySource, + 'workout_id': workoutId, + 'distance_m': distanceM, + 'distance_source': distanceSource, + 'hr_source': hrSource, + }; + + static HyroxSegment? fromRow(Map r) { + final kind = HyroxSegmentKindX.fromKey(r['kind'] as String?); + final station = HyroxStationX.fromKey(r['station'] as String?); + if (kind == null || station == null) return null; + return HyroxSegment( + sessionId: r['session_id'] as String, + idx: (r['idx'] as num).toInt(), + kind: kind, + station: station, + roundIndex: (r['round_index'] as num?)?.toInt(), + plannedDistanceM: (r['planned_distance_m'] as num?)?.toDouble(), + plannedReps: (r['planned_reps'] as num?)?.toInt(), + plannedCal: (r['planned_cal'] as num?)?.toInt(), + goalSec: (r['goal_sec'] as num?)?.toInt(), + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + boundarySource: r['boundary_source'] as String? ?? 'unknown', + workoutId: r['workout_id'] as String?, + distanceM: (r['distance_m'] as num?)?.toDouble(), + distanceSource: r['distance_source'] as String?, + hrSource: r['hr_source'] as String?, + ); + } +} + +class HyroxSession { + const HyroxSession({ + required this.id, + required this.kind, + required this.preset, + required this.status, + required this.plan, + this.startTs, + this.endTs, + this.note = '', + this.finalizedAt, + required this.createdAt, + required this.updatedAt, + this.segments = const [], + }); + + final String id; + + /// 'race' | 'training'. + final String kind; + final String preset; + + /// 'armed' | 'active' | 'finished' | 'finalized' | 'discarded'. The state + /// machine owns transitions; storage only refuses to write a finalized row. + final String status; + + /// The frozen plan. Null only when a row was written by a build whose plan + /// format this one cannot read — callers show "unknown plan", not a guess. + final HyroxPlan? plan; + + /// Epoch SECONDS, mirroring the first/last segment boundary. + final int? startTs; + final int? endTs; + + final String note; + + /// Epoch MILLISECONDS. Non-null makes the session and its segments read-only. + final int? finalizedAt; + + /// Epoch MILLISECONDS. + final int createdAt; + final int updatedAt; + + final List segments; + + bool get isFinalized => finalizedAt != null; + + /// 'armed' | 'active' | 'finished' | 'abandoned' | 'discarded' | 'finalized'. + static const statuses = { + 'armed', + 'active', + 'finished', + 'abandoned', + 'discarded', + 'finalized', + }; + + /// Sum of closed segment durations = time actually in a round. + /// Null when no segment is closed yet — a pause or an open tail is never + /// billed as workout time. + int? get movingSec { + final closed = [ + for (final s in segments) + if (s.startTs != null && s.endTs != null) s.endTs! - s.startTs!, + ]; + return closed.isEmpty ? null : closed.reduce((a, b) => a + b); + } + + /// Total wall-clock time of the session (start to last end). INCLUDES any + /// pauses between segments — that is the difference from [movingSec]. + int? get totalSec { + if (startTs == null || endTs == null) return null; + return endTs! - startTs!; + } + + /// Pauses between segments, in seconds. Always >= 0. + int? get pauseSec { + final t = totalSec; + final m = movingSec; + if (t == null || m == null) return null; + return t - m; + } + + Map toRow() => { + 'id': id, + 'kind': kind, + 'preset': preset, + 'status': status, + 'plan_json': plan == null ? '{}' : jsonEncode(plan!.toJson()), + 'start_ts': startTs, + 'end_ts': endTs, + 'note': note, + 'finalized_at': finalizedAt, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; + + static HyroxSession fromRow(Map r) { + HyroxPlan? plan; + try { + final decoded = jsonDecode(r['plan_json'] as String? ?? '{}'); + if (decoded is Map) { + plan = HyroxPlan.fromJson(decoded.cast()); + } + } on FormatException { + plan = null; + } catch (_) { + // A persisted payload that is not even a JSON map (or contains + // wrong-typed fields) is an unreadable plan, not a crash. + plan = null; + } + return HyroxSession( + id: r['id'] as String, + kind: r['kind'] as String? ?? 'race', + preset: r['preset'] as String? ?? 'custom', + status: r['status'] as String? ?? 'armed', + plan: plan, + startTs: (r['start_ts'] as num?)?.toInt(), + endTs: (r['end_ts'] as num?)?.toInt(), + note: r['note'] as String? ?? '', + finalizedAt: (r['finalized_at'] as num?)?.toInt(), + createdAt: (r['created_at'] as num?)?.toInt() ?? 0, + updatedAt: (r['updated_at'] as num?)?.toInt() ?? 0, + ); + } +} + +/// One boundary derived from the strap's stored taps (see the reconciler). +/// +/// Lives in the data layer so storage never has to import a state-layer file: +/// the dependency runs one way, reconciler -> store. +class ReconcileBoundary { + const ReconcileBoundary(this.atSec, this.taps, this.kind, this.station); + + /// Strap time of the FIRST tap of the accepted cluster. + final int atSec; + final int taps; + + /// The segment the cluster closes; its successor starts at [atSec]. + final HyroxSegmentKind kind; + + /// The station of the segment the cluster closes. + final HyroxStation station; + + @override + String toString() => + 'ReconcileBoundary($kind/${station.key} @$atSec, $taps taps)'; +} + +/// Thrown when a write targets a finalized session, or would make segments +/// overlap. Deliberately loud: a silent no-op here reads as "the race saved". +class HyroxWriteError implements Exception { + HyroxWriteError(this.message); + final String message; + @override + String toString() => 'HyroxWriteError: $message'; +} + +class HyroxDb { + HyroxDb._(); + + /// Persist a new ARMED session with its planned segments in one transaction. + /// Nothing here starts a clock: arming is a choice, not a measurement. + static Future create(Database db, HyroxSession session) async { + final plan0 = session.plan; + if (plan0 == null) throw HyroxWriteError('a session needs a readable plan'); + final problem = plan0.validate(); + if (problem != null) throw HyroxWriteError(problem); + if (session.status != 'armed') { + throw HyroxWriteError('a new session is created armed, not ${session.status}'); + } + if (session.isFinalized || session.startTs != null || session.endTs != null) { + throw HyroxWriteError('create cannot pre-fill session bounds'); + } + if (session.segments.isNotEmpty && + session.segments.length != session.plan!.segments.length) { + throw HyroxWriteError('segment rows do not match the plan'); + } + await db.transaction((txn) async { + final clash = await txn.query( + 'hyrox_session', + columns: ['id'], + where: 'id = ?', + whereArgs: [session.id], + limit: 1, + ); + if (clash.isNotEmpty) { + throw HyroxWriteError('session ${session.id} already exists'); + } + await txn.insert('hyrox_session', session.toRow()); + final plan = session.plan!; + for (var i = 0; i < plan.segments.length; i++) { + final p = plan.segments[i]; + final supplied = i < session.segments.length ? session.segments[i] : null; + final seg = supplied ?? + HyroxSegment( + sessionId: session.id, + idx: i, + kind: p.kind, + station: p.station, + roundIndex: p.roundIndex, + plannedDistanceM: p.plannedDistanceM, + plannedReps: p.plannedReps, + plannedCal: p.plannedCal, + goalSec: p.goalSec, + ); + if (seg.sessionId != session.id || seg.idx != i || seg.kind != p.kind || + seg.station != p.station || seg.roundIndex != p.roundIndex || + seg.plannedDistanceM != p.plannedDistanceM || + seg.plannedReps != p.plannedReps || seg.plannedCal != p.plannedCal || + seg.goalSec != p.goalSec) { + throw HyroxWriteError('segment $i does not match the plan'); + } + if (seg.startTs != null || + seg.endTs != null || + seg.workoutId != null || + seg.distanceM != null || + seg.distanceSource != null) { + throw HyroxWriteError( + 'create stores a plan; segment $i already carries history', + ); + } + _checkSegmentShape(seg); + if (i > 0 && session.segments.isNotEmpty) { + _checkNeighbour(seg, session.segments[i - 1]); + } + await txn.insert('hyrox_segment', seg.toRow()); + } + }); + } + + static Future byId(Database db, String id) async { + final rows = await db.query( + 'hyrox_session', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, HyroxSession.fromRow(rows.first)); + } + + static Future> segments(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + orderBy: 'idx ASC', + ); + return [for (final r in rows) HyroxSegment.fromRow(r)].whereType().toList(); + } + + static Future _withSegments(Database db, HyroxSession s) async { + final segs = await segments(db, s.id); + return HyroxSession( + id: s.id, + kind: s.kind, + preset: s.preset, + status: s.status, + plan: s.plan, + startTs: s.startTs, + endTs: s.endTs, + note: s.note, + finalizedAt: s.finalizedAt, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + segments: segs, + ); + } + + /// The ONE session a relaunch must resume: not finalized, not discarded, + /// newest first. Returning more than the newest would let a stale armed row + /// shadow the race actually in progress, so callers get a single answer. + static Future activeSession(Database db) async { + final rows = await db.query( + 'hyrox_session', + where: "finalized_at IS NULL AND status != 'discarded'", + orderBy: 'created_at DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return _withSegments(db, HyroxSession.fromRow(rows.first)); + } + + /// Finalized sessions at [preset], newest first — the comparison set for + /// "total time vs your previous races". + static Future> finalized( + Database db, { + String? preset, + int limit = 50, + }) async { + final rows = await db.query( + 'hyrox_session', + where: preset == null ? 'finalized_at IS NOT NULL' : 'finalized_at IS NOT NULL AND preset = ?', + whereArgs: preset == null ? null : [preset], + orderBy: 'start_ts DESC', + limit: limit, + ); + final out = []; + for (final r in rows) { + out.add(await _withSegments(db, HyroxSession.fromRow(r))); + } + return out; + } + + /// Write one segment's boundaries. + /// + /// Reads the plan and the neighbours INSIDE the same transaction, so the + /// checks cannot be made against a stale in-memory copy. A segment's INDEX + /// never changes here: this method only moves/records the boundaries of a + /// row the plan already created. Moving a boundary that neighbours share is + /// [adjustBoundary]'s job, because both sides have to move together. + static Future saveSegment(Database db, HyroxSegment segment) async { + _checkSegmentShape(segment); + await db.transaction((txn) async { + await _requireWritable(txn, segment.sessionId); + final existing = await segments(txn, segment.sessionId); + final matching = existing.where((s) => s.idx == segment.idx).toList(); + if (matching.isEmpty) { + throw HyroxWriteError('segment ${segment.idx} is not in the plan'); + } + final original = matching.single; + if (segment.kind != original.kind || + segment.station != original.station || + segment.roundIndex != original.roundIndex || + segment.plannedDistanceM != original.plannedDistanceM || + segment.plannedReps != original.plannedReps || + segment.plannedCal != original.plannedCal || + segment.goalSec != original.goalSec) { + throw HyroxWriteError('the planned segment is immutable'); + } + for (final other in existing) { + if (other.idx != segment.idx) _checkNeighbour(segment, other); + } + await txn.update( + 'hyrox_segment', + segment.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [segment.sessionId, segment.idx], + ); + _validateTimeline(await 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 HyroxWriteError('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 HyroxWriteError('the plan has no segments'); + if (segs.any((s) => s.startTs != null || s.endTs != null)) { + throw HyroxWriteError('the session has already started'); + } + await txn.update( + 'hyrox_segment', + segs.first + .copyWith(startTs: ts, boundarySource: boundarySource) + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await 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 (the open cut segment), not a silent + /// half-race. + static Future finishSegment({ + required Database db, + required String sessionId, + required int ts, + required String boundarySource, + }) async { + _checkBoundarySource(boundarySource); + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + final open = segs.where((s) => s.isOpen).toList(); + if (open.length != 1) { + throw HyroxWriteError('exactly one segment must be open to finish'); + } + final last = open.single; + if (last.startTs == null || ts < last.startTs!) { + throw HyroxWriteError('a segment cannot end before it starts'); + } + if (last.idx != segs.length - 1) { + throw HyroxWriteError( + 'finish closes the final segment; close segment ${last.idx} with a ' + 'transition instead', + ); + } + await txn.update( + 'hyrox_segment', + last.copyWith(endTs: ts, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, last.idx], + ); + _validateTimeline(await 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 at the start line starts the race wrongly, and because + /// the session may only start once, no existing write can heal that. This + /// is the explicit correction path: it is only legal while NO segment has + /// ended yet (a race with closed splits keeps its history), and the new + /// start is stamped 'manual'. + static Future adjustStart({ + required Database db, + required String sessionId, + required int newTs, + }) async { + if (newTs <= 0) { + throw HyroxWriteError('a start needs a real timestamp'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); + if (segs.any((s) => s.endTs != null)) { + throw HyroxWriteError( + 'a segment already ended — the start can no longer be moved', + ); + } + if (segs.any((s) => s.workoutId != null)) { + throw HyroxWriteError( + 'a segment is linked to a workout — unlink it before moving the start', + ); + } + for (var i = 0; i < segs.length; i++) { + final cleared = segs[i].copyWith( + clearStart: true, + clearEnd: true, + boundarySource: i == 0 ? 'manual' : segs[i].boundarySource, + ); + await txn.update( + 'hyrox_segment', + cleared.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, i], + ); + } + final first = (await segments(txn, sessionId)).first; + await txn.update( + 'hyrox_segment', + first.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(await 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 HyroxWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + final wasStart = right.startTs; + final wasEnd = left.endTs; + if (wasStart == null || wasEnd == null || wasStart != wasEnd) { + throw HyroxWriteError('segments $idx and ${idx + 1} do not meet'); + } + if (left.startTs != null && newTs < left.startTs!) { + throw HyroxWriteError('the boundary would precede segment $idx'); + } + if (right.endTs != null && newTs > right.endTs!) { + throw HyroxWriteError('the boundary would follow segment ${idx + 1}'); + } + // Provenance follows the actor: a boundary moved by hand is 'manual' on + // both rows. Keeping 'gesture' here would claim the band recorded a time + // the athlete actually corrected. + await txn.update( + 'hyrox_segment', + left.copyWith(endTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'hyrox_segment', + right.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + _validateTimeline(await 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 + /// round in two: [adjustBoundary] can only move a boundary, not delete + /// one, and after [finalize] the record is immutable by design. + static Future removeBoundary({ + required Database db, + required String sessionId, + required int idx, + }) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final segs = await segments(txn, sessionId); + if (idx < 0 || idx + 1 >= segs.length) { + throw HyroxWriteError('there is no boundary after segment $idx'); + } + final left = segs.firstWhere((s) => s.idx == idx); + final right = segs.firstWhere((s) => s.idx == idx + 1); + if (left.startTs == null || right.startTs == null) { + throw HyroxWriteError( + 'segments $idx and ${idx + 1} have not both started', + ); + } + if (left.endTs != null && left.endTs != right.startTs) { + throw HyroxWriteError('segments $idx and ${idx + 1} do not meet'); + } + // Merging is only meaningful while the successor is the newest started + // segment: clearing its start would break the started-prefix rule the + // moment any later segment has already begun. + if (segs.any((s) => s.idx > idx + 1 && s.startTs != null)) { + throw HyroxWriteError( + 'a later segment already started — the boundary cannot be removed', + ); + } + if (left.workoutId != null || right.workoutId != null) { + throw HyroxWriteError( + 'a segment is linked to a workout — unlink it before removing the boundary', + ); + } + final merged = right.endTs == null + ? left.copyWith(clearEnd: true, boundarySource: 'manual') + : left.copyWith(endTs: right.endTs, boundarySource: 'manual'); + await txn.update( + 'hyrox_segment', + merged.toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + await txn.update( + 'hyrox_segment', + right.copyWith(clearStart: true, clearEnd: true).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx + 1], + ); + _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 HyroxWriteError('the plan has no segments'); + for (var i = 0; i < boundaries.length; i++) { + final b = boundaries[i]; + 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 HyroxWriteError('no open segment to close at ${b.atSec}'); + } + await txn.update( + 'hyrox_segment', + segs.first + .copyWith(startTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = 0', + whereArgs: [sessionId], + ); + _validateTimeline(segs = await segments(txn, sessionId)); + await _recomputeSessionBounds(txn, sessionId, status: 'active'); + continue; + } + if (open.length != 1) { + throw HyroxWriteError('more than one open segment'); + } + final current = open.single; + if (b.kind != current.kind || b.station != current.station) { + throw HyroxWriteError('boundary does not close the open segment'); + } + final hasNext = current.idx + 1 < segs.length; + if (!hasNext) { + // Closing the final segment ends the race, so nothing may follow it. + if (i != boundaries.length - 1) { + throw HyroxWriteError('boundaries follow the final segment'); + } + if (current.startTs == null || b.atSec < current.startTs!) { + throw HyroxWriteError( + 'the final segment would end before it started', + ); + } + await txn.update( + 'hyrox_segment', + current + .copyWith(endTs: b.atSec, boundarySource: 'posthoc') + .toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, current.idx], + ); + _validateTimeline(await 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 round segment to the `sessions` row the live workout banked. + /// + /// Every HYROX segment is a real round (there is no transition kind), so + /// unlike the multisport store there is no transition refusal here — the + /// null check on [HyroxSegment.fromRow] already refuses an unreadable row. + static Future linkWorkout( + Database db, + String sessionId, + int idx, + String workoutId, + ) async { + if (workoutId.trim().isEmpty) { + throw HyroxWriteError('a workout link needs a workout id'); + } + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final rows = await txn.query( + 'hyrox_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + limit: 1, + ); + if (rows.isEmpty) { + throw HyroxWriteError('segment $idx is not in session $sessionId'); + } + final seg = HyroxSegment.fromRow(rows.first); + if (seg == null) { + throw HyroxWriteError('segment $idx has an unknown kind or station'); + } + final existing = await txn.query( + 'hyrox_segment', + columns: ['session_id', 'idx'], + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [workoutId, sessionId, idx], + ); + if (existing.isNotEmpty) { + throw HyroxWriteError('workout $workoutId is already linked'); + } + final updated = await txn.update( + 'hyrox_segment', + {'workout_id': workoutId}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + if (updated != 1) { + throw HyroxWriteError('workout link matched no segment row'); + } + await _touch(txn, sessionId); + }); + } + + /// Note and lifecycle status. Refuses a finalized row, and never clears + /// `finalized_at` — finalization is one-way by design. + /// + /// Session bounds are NOT writable here: they are derived from the segments + /// by [_recomputeSessionBounds], and letting a screen set them would give the + /// same fact two sources. `finished`/`finalized` are set by their own calls. + static Future updateSession(Database db, HyroxSession session) async { + await db.transaction((txn) async { + await _requireWritable(txn, session.id); + if (session.isFinalized || session.status == 'finalized') { + throw HyroxWriteError('use finalize to finalize a session'); + } + const writable = {'armed', 'active', 'discarded', 'abandoned'}; + if (!writable.contains(session.status)) { + throw HyroxWriteError( + 'status ${session.status} is set by start/finish/finalize, not here', + ); + } + // Bounds are derived from the segments (_recomputeSessionBounds) and + // deliberately absent here: a screen must not be able to desync them. + final row = { + 'status': session.status, + 'note': session.note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + await txn.update( + 'hyrox_session', + row, + where: 'id = ?', + whereArgs: [session.id], + ); + }); + } + + /// Stamp finalization. Every segment must be CLOSED for a finished race, + /// otherwise the saved record would contain a split with no end — an open + /// segment is a live session, and finalizing it would freeze a lie. + static Future finalize(Database db, String id) async { + await db.transaction((txn) async { + await _requireWritable(txn, id); + final rows = await txn.query( + 'hyrox_session', + columns: ['status'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw HyroxWriteError('no such session: $id'); + final status = rows.first['status'] as String; + if (status != 'finished' && status != 'abandoned') { + throw HyroxWriteError( + 'only a finished or abandoned session can be finalized', + ); + } + final segs = await segments(txn, id); + if (segs.isEmpty) throw HyroxWriteError('nothing to finalize'); + if (status == 'finished') { + for (final s in segs) { + if (s.startTs == null || s.endTs == null) { + throw HyroxWriteError('segment ${s.idx} is still open'); + } + } + } else { + // ABANDONED (DNF): exactly one segment may be open — the round the + // athlete stopped in. Its duration stays null; nothing is invented. + // Every segment before it must be closed, every segment after it + // must be untouched, and at least one segment must have started + // (otherwise this is a discard, not a race). + final open = segs.where((s) => s.isOpen).toList(); + final started = segs.where((s) => s.startTs != null).length; + if (started == 0) { + throw HyroxWriteError( + 'nothing started — discard the session instead of finalizing', + ); + } + if (open.length > 1) { + throw HyroxWriteError('more than one open segment'); + } + if (open.isNotEmpty) { + final cut = open.single; + if (segs.where((s) => s.idx < cut.idx && s.endTs == null).isNotEmpty) { + throw HyroxWriteError('an earlier segment is still open'); + } + if (segs.where((s) => s.idx > cut.idx && s.startTs != null).isNotEmpty) { + throw HyroxWriteError('a later segment already started'); + } + } + } + _validateTimeline(segs); + await txn.update( + 'hyrox_session', + { + 'status': 'finalized', + 'finalized_at': DateTime.now().millisecondsSinceEpoch, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + }); + } + + /// Deleting a race removes its segment rows with it; the linked `sessions` + /// rows are deleted by the caller, which owns that table's cascade (route, + /// splits, sets). Returns the workout ids so the caller can do exactly that. + static Future> delete(Database db, String id) async { + return db.transaction((txn) async { + final segs = await txn.query( + 'hyrox_segment', + columns: ['workout_id'], + where: 'session_id = ?', + whereArgs: [id], + ); + final workoutIds = [ + for (final r in segs) + if (r['workout_id'] is String) r['workout_id'] as String, + ]; + await txn.delete( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'hyrox_session', + where: 'id = ?', + whereArgs: [id], + ); + return workoutIds; + }); + } + + /// THE timeline rule, applied to the whole session and not just the row + /// that changed. + /// + /// Started segments form a PREFIX (segment 3 cannot start while segment 0 + /// is still a plan row), boundaries are MONOTONIC, and no two segments + /// OVERLAP. A gap between one segment's end and the next one's start is a + /// PAUSE, not a gap in the data: a workout can be interrupted for minutes + /// between stations, and a missed transition tap looks exactly like that. + /// The session then reports total and moving time separately, so a pause is + /// never silently billed as workout time. + static void _validateTimeline(List segs) { + var seenUnstarted = false; + var open = 0; + int? prevEnd; + for (final s in segs) { + final started = s.startTs != null; + final ended = s.endTs != null; + if (!started) { + seenUnstarted = true; + if (ended) { + throw HyroxWriteError('segment ${s.idx} ends before it starts'); + } + continue; + } + if (seenUnstarted) { + throw HyroxWriteError( + 'segment ${s.idx} starts while an earlier segment never did', + ); + } + if (ended && s.endTs! < s.startTs!) { + throw HyroxWriteError('segment ${s.idx} ends before it starts'); + } + if (prevEnd != null) { + if (s.startTs! < prevEnd) { + throw HyroxWriteError( + 'segment ${s.idx} overlaps its predecessor', + ); + } + // s.startTs == prevEnd is a direct transition; > is a pause. Both legal. + } + if (ended) { + prevEnd = s.endTs; + } else { + open++; + if (open > 1) { + throw HyroxWriteError('more than one segment is open'); + } + for (final later in segs) { + if (later.idx > s.idx && later.startTs != null) { + throw HyroxWriteError( + 'segment ${later.idx} starts while ${s.idx} is open', + ); + } + } + prevEnd = null; + } + } + } + + static void _checkBoundarySource(String source) { + if (!kHyroxBoundarySources.contains(source)) { + throw HyroxWriteError('unknown boundary source $source'); + } + } + + /// The transition body, callable from inside an existing transaction. + /// + /// Public [transitionSegment] wraps it; the offline reconciler uses it to + /// apply a whole chain of boundaries atomically instead of one commit each. + static Future _transitionInTx( + DatabaseExecutor txn, { + required String sessionId, + required int currentIdx, + required int transitionTs, + required String boundarySource, + }) async { + await _requireWritable(txn, sessionId); + final segs = await 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 HyroxWriteError('segment $currentIdx has no successor'); + } + final a = current.single; + final b = next.single; + if (!a.isOpen) { + throw HyroxWriteError('segment $currentIdx is not open'); + } + if (b.startTs != null) { + throw HyroxWriteError('segment ${currentIdx + 1} has already started'); + } + if (a.startTs != null && transitionTs < a.startTs!) { + throw HyroxWriteError('a transition cannot precede its segment'); + } + await txn.update( + 'hyrox_segment', + a.copyWith(endTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx], + ); + await txn.update( + 'hyrox_segment', + b.copyWith(startTs: transitionTs, boundarySource: boundarySource).toRow(), + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, currentIdx + 1], + ); + _validateTimeline(await 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, + if (status != null) 'status': status, + }; + await db.update( + 'hyrox_session', + row, + where: 'id = ?', + whereArgs: [sessionId], + ); + } + + static Future _requireWritable(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_session', + columns: ['finalized_at'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) throw HyroxWriteError('no such session: $id'); + if (rows.first['finalized_at'] != null) { + throw HyroxWriteError('session $id is finalized and immutable'); + } + } + + static Future _touch(DatabaseExecutor db, String id) async { + await db.update( + 'hyrox_session', + {'updated_at': DateTime.now().millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Fields that must hold for ANY segment row, planned or timed. + static void _checkSegmentShape(HyroxSegment s) { + if (s.distanceM == null && s.distanceSource != null) { + throw HyroxWriteError('a distance source without a distance'); + } + if (s.distanceM != null && (!s.distanceM!.isFinite || s.distanceM! < 0)) { + throw HyroxWriteError('a measured distance is finite and not negative'); + } + if (s.distanceM != null && s.distanceSource == null) { + throw HyroxWriteError('a distance without a source'); + } + if (s.startTs != null && s.endTs != null && s.endTs! < s.startTs!) { + throw HyroxWriteError('segment ends before it starts'); + } + if (!kHyroxBoundarySources.contains(s.boundarySource)) { + throw HyroxWriteError('unknown boundary source ${s.boundarySource}'); + } + } + + /// Neighbour rule for a single edit: the previous segment's end must not + /// lie AFTER this segment's start (overlap), and this segment's end must + /// not precede the next segment's start. Equal means a direct transition; + /// a gap means a pause. Both are legal now. + static void _checkNeighbour(HyroxSegment changed, HyroxSegment other) { + if (other.idx == changed.idx - 1) { + if (other.endTs != null && + changed.startTs != null && + other.endTs! > changed.startTs!) { + throw HyroxWriteError( + 'segment ${changed.idx} starts before ${other.idx} ended', + ); + } + } else if (other.idx == changed.idx + 1) { + if (changed.endTs != null && + other.startTs != null && + changed.endTs! > other.startTs!) { + throw HyroxWriteError( + 'segment ${other.idx} starts before ${changed.idx} ended', + ); + } + } + } +} diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index a97a59523..e36ca54eb 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'), + 'hyrox_session': (SliceKeep.all, null, 'HYROX sessions'), + 'hyrox_segment': (SliceKeep.all, null, 'HYROX round segments'), 'exercise_def': (SliceKeep.all, null, 'exercise list'), // What the user logs: small. 'journal': (SliceKeep.all, null, 'journal'), diff --git a/lib/state/hyrox_reconciler.dart b/lib/state/hyrox_reconciler.dart new file mode 100644 index 000000000..9f54f1d3e --- /dev/null +++ b/lib/state/hyrox_reconciler.dart @@ -0,0 +1,228 @@ +// hyrox_reconciler.dart — rebuilds a race from the band's own record. +// +// The band IS the source of truth for the taps. When the phone was away from +// the athlete (charging at the venue, out of radio range in the arena), a +// tap still reached the strap and sits in `band_events` with the strap's own +// timestamp. This class turns those stored taps into round boundaries +// afterwards. +// +// WHAT IT WILL AND WILL NOT DO: +// +// * Only EVENTS ARE READ, never `events`/`raw_*`. `band_events` is the +// decoded, deduplicated history (PK on the frame hex), retained for the +// band's own retention window. +// * Only LIVE-provided timestamps count. The strap's RTC is the clock; a row +// whose `ts` is zero (an unset RTC) is skipped rather than placed at 1970. +// * A tap becomes a round boundary only when it CLEARS THE GATE: at least +// [kMinTaps] double-taps within [kTapWindowSec], anchored on the FIRST tap +// of the burst, and at least the segment's minimum duration after that +// round started. Anything less is treated as an accidental touch. The live +// gate (later PR) is specified to apply the same rule; this file is the +// offline implementation, not a claim that the live path already exists. +// * The whole chain is applied in ONE transaction through +// [HyroxDb.applyOfflineBoundariesInTx]. A failure halfway through leaves the +// race exactly as it was, not half-segmented. +// * It NEVER overwrites a live-recorded boundary and never touches a +// finalized session. Re-running is therefore safe and idempotent: the taps +// at or before the last recorded boundary are ignored. +// +// What it does not decide: whether a race happened at all. The caller passes +// the session id of an armed/active session; an absent session is a no-op. + +import 'package:sqflite/sqflite.dart'; + +import '../data/hyrox_plan.dart'; +import '../data/hyrox_store.dart'; + +/// Double-tap event id, the only gesture the firmware emits today. +const int kHyroxDoubleTapEventId = 14; + +/// How many double-taps make one accepted boundary. Matches the "keep +/// tapping until it answers" rule: an accidental blocked tap cannot reach it. +const int kMinTaps = 3; + +/// The window those taps must fall inside, in seconds. The burst is anchored +/// on its FIRST tap: a chained 0 s / 6 s / 12 s sequence does NOT count, +/// because the third tap is outside the window of the first. +const int kTapWindowSec = 6; + +/// Widening applied to the event window on both sides, because the strap's RTC +/// and the phone's clock are different clocks and can drift. The boundary +/// floor still rejects anything at or before the last recorded boundary, so a +/// wider read can only ADD taps the strap really recorded, never rewind. +const int kClockDriftToleranceSec = 120; + +/// Minimum time a round must have been running before a boundary is +/// believable, per STATION. These are deliberately conservative floors on the +/// whole round (1 km run + station), not expected durations: they exist to +/// stop a tap during a sled struggle from ending the round, while still +/// allowing a fast round. +const Map kMinSegmentSec = { + HyroxStation.skierg: 300, + HyroxStation.sledPush: 240, + HyroxStation.sledPull: 240, + HyroxStation.burpeeBroadJump: 300, + HyroxStation.rowing: 300, + HyroxStation.farmersCarry: 240, + HyroxStation.sandbagLunges: 300, + HyroxStation.wallBalls: 240, +}; + +class HyroxReconciler { + const HyroxReconciler(this.db); + + final Database db; + + /// Rebuild boundaries for [sessionId] from the strap's stored taps. + /// + /// Returns the boundaries it actually applied, oldest first; an empty list + /// means every accepted tap was already accounted for (or there were none). + /// Throws [HyroxWriteError] on a finalized session, so a caller can + /// report it instead of silently doing nothing. + Future> reconcile( + String sessionId, { + String deviceId = '', + }) async { + final session = await HyroxDb.byId(db, sessionId); + if (session == null) return const []; + if (session.isFinalized) { + throw HyroxWriteError('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: [ + kHyroxDoubleTapEventId, + 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 HyroxDb.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 HyroxPlan plan, + required int sessionCreatedAtSec, +}) { + if (taps.isEmpty || segments.isEmpty) return const []; + if (plan.segments.length != segments.length) return const []; + final sorted = [...taps]..sort(); + + // Where the race stands right now. `started` counts the segments that have + // a start, and a started segment is the one still running unless it already + // has an end. + var started = 0; + var openIdx = -1; + final localStarts = {}; + for (final s in segments) { + if (s.startTs != null) { + started = s.idx + 1; + localStarts[s.idx] = s.startTs!; + } + if (s.isOpen) openIdx = s.idx; + } + // A tap at or before the last recorded boundary is already accounted for. + int floor = sessionCreatedAtSec; + for (final s in segments) { + final t = s.startTs; + if (t != null && t > floor) floor = t; + } + + // One intent = taps that BOTH start within [kTapWindowSec] of the first tap + // AND follow the previous tap without a longer pause. The first condition + // alone stops a chain (0 s, 6 s, 12 s) from counting as a single burst. + final clusters = >[]; + for (final t in sorted) { + final current = clusters.isEmpty ? null : clusters.last; + if (current != null && + t <= current.first + kTapWindowSec && + t - current.last <= kTapWindowSec) { + current.add(t); + } else { + clusters.add([t]); + } + } + + final out = []; + for (final cluster in clusters) { + final at = cluster.first; + if (at <= floor) continue; + // Every member is inside the window by construction. + final supporting = cluster.length; + if (supporting < kMinTaps) continue; + + if (started == 0) { + // Nothing running: this cluster STARTS the race. There is no round to + // close, so no minimum duration applies here — waiting at the start + // line is not a round. + out.add(ReconcileBoundary(at, supporting, plan.segments[0].kind, + plan.segments[0].station)); + started = 1; + openIdx = 0; + localStarts[0] = at; + floor = at; + continue; + } + if (openIdx < 0) break; // the race is already fully marked + final current = plan.segments[openIdx]; + final minSec = kMinSegmentSec[current.station] ?? 0; + final currentStart = localStarts[openIdx]; + if (currentStart != null && at - currentStart < minSec) continue; + + out.add(ReconcileBoundary(at, supporting, current.kind, current.station)); + localStarts[openIdx + 1] = at; + openIdx = openIdx + 1 < plan.segments.length ? openIdx + 1 : -1; + floor = at; + } + return out; +} diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart new file mode 100644 index 000000000..95e6ab8c9 --- /dev/null +++ b/test/hyrox_model_storage_test.dart @@ -0,0 +1,1020 @@ +// HYROX model, validation, storage invariants, and schema migration ladder +// integration — the mirror of the triathlon storage suite (PR #648). +// +// Runs against the real sqflite_ffi database — the repo standard for DB tests. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/state/hyrox_reconciler.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/hyrox_plan.dart'; +import 'package:openstrap_edge/data/hyrox_store.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() async { + await LocalDb.close(); + }); + + group('HyroxPlan pure model validation', () { + test('a race plan builds the fixed eight rounds with station targets', () { + final plan = HyroxPlan.race('hyrox'); + expect(plan.validate(), isNull); + expect(plan.segments.length, 8); + expect(plan.segments[0].kind, HyroxSegmentKind.hyroxRound); + expect(plan.segments[0].station, HyroxStation.skierg); + expect(plan.segments[0].roundIndex, 1); + expect(plan.segments[0].plannedDistanceM, 1000.0); + expect(plan.segments[0].plannedCal, 50); + expect(plan.segments[1].station, HyroxStation.sledPush); + expect(plan.segments[1].plannedDistanceM, 50.0); + expect(plan.segments[4].station, HyroxStation.rowing); + expect(plan.segments[5].station, HyroxStation.farmersCarry); + expect(plan.segments[5].plannedDistanceM, 80.0); // 2x40 m + expect(plan.segments[6].station, HyroxStation.sandbagLunges); + expect(plan.segments[7].station, HyroxStation.wallBalls); + expect(plan.segments[7].roundIndex, 8); + expect(plan.segments[7].plannedReps, 100); + expect(plan.segments[7].plannedDistanceM, isNull); + expect(plan.needsGps, isFalse); // HYROX is an indoor arena event + }); + + test('the doubles preset is the same eight rounds', () { + final plan = HyroxPlan.race('hyrox_doubles'); + expect(plan.validate(), isNull); + expect(plan.preset, 'hyrox_doubles'); + expect(plan.segments.length, 8); + expect(plan.segments[7].station, HyroxStation.wallBalls); + }); + + test('training and custom plans are free-form', () { + final plan = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.wallBalls, + plannedReps: 50, + ), + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + plannedCal: 25, + ), + ]); + expect(plan.validate(), isNull); + expect(plan.isTraining, isTrue); + expect(plan.segments[0].roundIndex, isNull); + + final custom = HyroxPlan.custom(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.rowing, + plannedDistanceM: 500, + ), + ]); + expect(custom.validate(), isNull); + expect(custom.preset, 'custom'); + }); + + test('validation catches broken plans', () { + final empty = HyroxPlan.training(const []); + expect(empty.validate(), contains('at least one segment')); + + final negativeDistance = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.sledPush, + plannedDistanceM: -50, + ), + ]); + expect(negativeDistance.validate(), contains('finite and positive')); + + final zeroReps = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.wallBalls, + plannedReps: 0, + ), + ]); + expect(zeroReps.validate(), contains('rep count')); + + final badRound = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + roundIndex: 0, + ), + ]); + expect(badRound.validate(), contains('round index')); + + final negativeGoal = HyroxPlan.training(const [ + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: HyroxStation.skierg, + goalSec: -5, + ), + ]); + expect(negativeGoal.validate(), contains('goal time')); + + // A fixed preset must be exactly the canonical eight rounds in order. + final wrongOrder = HyroxPlan( + preset: 'hyrox', + segments: [ + for (final st in kHyroxStationOrder.reversed) + HyroxSegmentPlan( + kind: HyroxSegmentKind.hyroxRound, + station: st, + roundIndex: kHyroxStationOrder.indexOf(st) + 1, + ), + ], + ); + expect(wrongOrder.validate(), contains('eight stations in order')); + }); + + test('json round-trip preserves preset, stations and targets', () { + final plan = HyroxPlan.race( + 'hyrox', + goalSec: const {HyroxStation.wallBalls: 600}, + ); + final json = plan.toJson(); + expect(json['v'], 1); + final roundTrip = HyroxPlan.fromJson(json); + expect(roundTrip, isNotNull); + expect(roundTrip!.preset, 'hyrox'); + expect(roundTrip.segments, hasLength(8)); + expect(roundTrip.segments[7].station, HyroxStation.wallBalls); + expect(roundTrip.segments[7].plannedReps, 100); + expect(roundTrip.segments[7].goalSec, 600); + expect(roundTrip.segments[0].plannedCal, 50); + }); + + test('wrong-typed fields parse to null without throwing; newer v refused', + () { + final bad = HyroxPlan.fromJson({ + 'v': 1, + 'preset': 'hyrox', + 'segments': [ + { + 'kind': 'hyrox_round', + 'station': 'skierg', + 'round_index': 'three', // wrong type -> null, not a crash + 'planned_distance_m': '1000', // wrong type -> null + 'planned_reps': 5, + 'planned_cal': null, + 'goal_sec': 300, + }, + ], + }); + expect(bad, isNotNull); + expect(bad!.segments[0].roundIndex, isNull); + expect(bad.segments[0].plannedDistanceM, isNull); + expect(bad.segments[0].plannedReps, 5); + + expect(HyroxPlan.fromJson({'v': 99, 'preset': 'hyrox', 'segments': []}), + isNull); + expect(HyroxPlan.fromJson({'v': 1, 'preset': 42, 'segments': []}), isNull); + expect( + HyroxPlan.fromJson({ + 'v': 1, + 'preset': 'hyrox', + 'segments': [ + {'kind': 'not_a_kind', 'station': 'skierg'}, + ], + }), + isNull, + ); + }); + }); + + group('HyroxDb storage seam', () { + Future freshDb(String name) async { + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + return LocalDb.instance; + } + + HyroxSession armedSession(String id, {int createdAt = 1, String preset = 'hyrox'}) { + return HyroxSession( + id: id, + kind: 'race', + preset: preset, + status: 'armed', + plan: HyroxPlan.race(preset), + createdAt: createdAt, + updatedAt: createdAt, + ); + } + + test('create stores session and pre-populates all eight round rows', + () async { + final db = await freshDb('hx_create_test.db'); + await HyroxDb.create(db, armedSession('hx_create_1', createdAt: 1000)); + final read = await HyroxDb.byId(db, 'hx_create_1'); + expect(read, isNotNull); + expect(read!.status, 'armed'); + expect(read.segments.length, 8); + expect(read.segments[0].station, HyroxStation.skierg); + expect(read.segments[0].roundIndex, 1); + expect(read.segments[0].plannedDistanceM, 1000.0); + expect(read.segments[0].startTs, isNull); + expect(read.segments[7].station, HyroxStation.wallBalls); + expect(read.plan?.preset, 'hyrox'); + await LocalDb.close(); + }); + + test('activeSession returns the unfinalized active session', () async { + final db = await freshDb('hx_active_test.db'); + await HyroxDb.create(db, armedSession('hx_active_1', createdAt: 1000)); + await HyroxDb.startSegment( + db: db, + sessionId: 'hx_active_1', + ts: 2000, + boundarySource: 'gesture', + ); + final active = await HyroxDb.activeSession(db); + expect(active, isNotNull); + expect(active!.id, 'hx_active_1'); + expect(active.status, 'active'); + expect(active.startTs, 2000); + await LocalDb.close(); + }); + + test('segment save enforces the no-overlap boundary constraint', () async { + final db = await freshDb('hx_contig_test.db'); + await HyroxDb.create(db, armedSession('hx_contig_1')); + await HyroxDb.startSegment( + db: db, + sessionId: 'hx_contig_1', + ts: 1000, + boundarySource: 'gesture', + ); + await HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_contig_1', + currentIdx: 0, + transitionTs: 2000, + boundarySource: 'gesture', + ); + // Try to move round 0's end AFTER round 1's start: overlap, must fail. + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_contig_1'))[0] + .copyWith(endTs: 2100), + ), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_contig_1'); + expect(segs[0].endTs, 2000); + await LocalDb.close(); + }); + + test('finalizing freezes the session and rejects later writes', () async { + final db = await freshDb('hx_finalize_test.db'); + await HyroxDb.create(db, armedSession('hx_fin_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_fin_1', ts: 1000, boundarySource: 'gesture'); + for (var i = 0; i < 7; i++) { + await HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_fin_1', + currentIdx: i, + transitionTs: 2000 + i * 600, + boundarySource: 'gesture', + ); + } + await HyroxDb.finishSegment( + db: db, sessionId: 'hx_fin_1', ts: 2000 + 7 * 600 + 600, + boundarySource: 'gesture', + ); + await HyroxDb.finalize(db, 'hx_fin_1'); + final fin = (await HyroxDb.byId(db, 'hx_fin_1'))!; + expect(fin.isFinalized, isTrue); + expect(fin.status, 'finalized'); + expect(fin.totalSec, 2000 + 7 * 600 + 600 - 1000); + await expectLater( + HyroxDb.transitionSegment( + db: db, + sessionId: 'hx_fin_1', + currentIdx: 0, + transitionTs: 9999, + boundarySource: 'gesture', + ), + throwsA(isA()), + ); + await expectLater( + HyroxDb.updateSession( + db, + HyroxSession( + id: fin.id, + kind: fin.kind, + preset: fin.preset, + status: 'discarded', + plan: fin.plan, + note: 'oops', + createdAt: fin.createdAt, + updatedAt: fin.updatedAt, + ), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalize refuses a session that never finished', () async { + final db = await freshDb('hx_fin_refuse_test.db'); + await HyroxDb.create(db, armedSession('hx_finref_1')); + await expectLater( + HyroxDb.finalize(db, 'hx_fin_refuse_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('hyrox tables participate in backup restore and salvage', () { + expect(LocalDb.restoreTablesForTest, + containsAllInOrder(['hyrox_session', 'hyrox_segment'])); + expect(LocalDb.salvageTablesForTest, + containsAllInOrder(['hyrox_session', 'hyrox_segment'])); + }); + + test('deleting a day works when no hyrox session exists', () async { + await freshDb('hx_delete_day_test.db'); + await LocalDb.deleteDays({'2026-01-01'}); + await LocalDb.close(); + }); + + test('band-only race: stored taps are reconciled into boundaries', + () async { + final db = await freshDb('hx_posthoc_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_posthoc_1', createdAt: t0 * 1000)); + + Future taps(int first, List offsets) async { + for (final o in offsets) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Start 10 min after arming; round 1 (skierg, 300 s floor) runs long + // enough; then two more accepted bursts. A lone accidental bump and a + // chained 0/6/12 s sequence must NOT count. + await taps(t0 + 600, [0, 2, 4]); // accepted: race start + await taps(t0 + 640, [0]); // lone tap: ignored + await taps(t0 + 900, [0, 1, 2]); // accepted: closes round 1 + await taps(t0 + 960, [0, 6, 12]); // chained: NOT a burst (12 > 6 window) + await taps(t0 + 1300, [0, 2, 4]); // accepted: closes round 2 + + final reconciler = HyroxReconciler(db); + final applied = await reconciler.reconcile('hx_posthoc_1'); + + expect(applied, hasLength(3)); + final segs = await HyroxDb.segments(db, 'hx_posthoc_1'); + expect(segs[0].startTs, t0 + 600); + expect(segs[0].endTs, t0 + 900); + expect(segs[0].boundarySource, 'posthoc'); + expect(segs[1].startTs, t0 + 900); + expect(segs[1].endTs, t0 + 1300); + expect(segs[2].startTs, t0 + 1300); + expect(segs[2].endTs, isNull); + final read = await HyroxDb.byId(db, 'hx_posthoc_1'); + expect(read!.startTs, t0 + 600); + expect(read.status, 'active'); + + // Idempotent: the same log a second time adds nothing. + final again = await reconciler.reconcile('hx_posthoc_1'); + expect(again, isEmpty); + await LocalDb.close(); + }); + + test('a burst under the round minimum is ignored', () async { + final db = await freshDb('hx_floor_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_floor_1', createdAt: t0 * 1000)); + + Future taps(int first) async { + for (final o in [0, 1, 2]) { + final ts = first + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + } + + // Clears the gate 600 s after arming: this is the START, so no minimum + // applies to it. + await taps(t0 + 600); + // 60 s later a second burst tries to end round 1 (skierg floor 300 s). + // A round cannot be over, so it must be ignored. + await taps(t0 + 660); + + final applied = await HyroxReconciler(db).reconcile('hx_floor_1'); + expect(applied, hasLength(1)); + final segs = await HyroxDb.segments(db, 'hx_floor_1'); + expect(segs[0].startTs, t0 + 600); + expect(segs[0].endTs, isNull); + expect(segs[1].startTs, isNull); + await LocalDb.close(); + }); + + test('a discarded race is never rebuilt by the reconciler', () async { + final db = await freshDb('hx_discarded_test.db'); + final t0 = 1_000_000; + await HyroxDb.create(db, armedSession('hx_disc_1', createdAt: t0 * 1000)); + final current = (await HyroxDb.byId(db, 'hx_disc_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, + kind: current.kind, + preset: current.preset, + status: 'discarded', + plan: current.plan, + createdAt: current.createdAt, + updatedAt: current.updatedAt, + ), + ); + for (final o in [0, 1, 2]) { + final ts = t0 + 600 + o; + await db.insert('band_events', { + 'device_id': '', + 'hex': 'dtap_$ts', + 'event_id': 14, + 'name': 'DOUBLE_TAP', + 'ts': ts, + 'payload_json': '{}', + 'captured_at': ts * 1000, + }); + } + final applied = await HyroxReconciler(db).reconcile('hx_disc_1'); + expect(applied, isEmpty); + expect((await HyroxDb.byId(db, 'hx_disc_1'))!.status, 'discarded'); + await LocalDb.close(); + }); + + test('transition and boundary adjustment move both sides atomically', + () async { + final db = await freshDb('hx_adjbound_test.db'); + await HyroxDb.create(db, armedSession('hx_adjb_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_adjb_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_adjb_1', currentIdx: 0, + transitionTs: 1600, boundarySource: 'gesture'); + await HyroxDb.adjustBoundary( + db: db, sessionId: 'hx_adjb_1', idx: 0, newTs: 1700); + final segs = await HyroxDb.segments(db, 'hx_adjb_1'); + expect(segs[0].endTs, 1700); + expect(segs[1].startTs, 1700); + // A human correction is 'manual' on BOTH rows. + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].boundarySource, 'manual'); + // Moving the boundary before round 0's start is refused. + await expectLater( + HyroxDb.adjustBoundary( + db: db, sessionId: 'hx_adjb_1', idx: 0, newTs: 500), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('an out-of-order segment is refused by the timeline rule', () async { + final db = await freshDb('hx_order_test.db'); + await HyroxDb.create(db, armedSession('hx_order_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_order_1', ts: 1000, boundarySource: 'gesture'); + // Round 2 has no start while round 1 never started: prefix rule. + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_order_1'))[2] + .copyWith(startTs: 1500, endTs: 1600), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finish only closes the final segment', () async { + final db = await freshDb('hx_finish_test.db'); + await HyroxDb.create(db, armedSession('hx_finish_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_finish_1', ts: 1000, boundarySource: 'gesture'); + // Round 0 is open but is not the last: finishSegment must refuse. + await expectLater( + HyroxDb.finishSegment( + db: db, sessionId: 'hx_finish_1', ts: 2000, + boundarySource: 'gesture'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('finalizing refuses an open segment in a finished session', () async { + final db = await freshDb('hx_openfin_test.db'); + await HyroxDb.create(db, armedSession('hx_openfin_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_openfin_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_openfin_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + // Force the status to finished while round 1 is still open: finalize + // must refuse rather than freeze a lie. + await db.update('hyrox_session', {'status': 'finished'}, + where: 'id = ?', whereArgs: ['hx_openfin_1']); + await expectLater( + HyroxDb.finalize(db, 'hx_openfin_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a plan field cannot be rewritten through saveSegment', () async { + final db = await freshDb('hx_planimm_test.db'); + await HyroxDb.create(db, armedSession('hx_planimm_1')); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_planimm_1'))[0].copyWith( + station: HyroxStation.wallBalls, + plannedReps: 999, + ), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a workout cannot be linked twice, and a missing segment refuses', + () async { + final db = await freshDb('hx_link_test.db'); + await HyroxDb.create(db, armedSession('hx_link_1')); + await HyroxDb.linkWorkout(db, 'hx_link_1', 0, 'w1'); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 1, 'w1'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 42, 'w2'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.linkWorkout(db, 'hx_link_1', 2, ' '), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_link_1'); + expect(segs[0].workoutId, 'w1'); + expect(segs[1].workoutId, isNull); + await LocalDb.close(); + }); + + test('deleting a day removes the race and its segments', () async { + final db = await freshDb('hx_delete_rows_test.db'); + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_delete_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_delete_1', ts: nowSec - 60, + boundarySource: 'gesture'); + await LocalDb.deleteDays({dayLabelOf(DateTime.now())}); + expect(await HyroxDb.byId(db, 'hx_delete_1'), isNull); + expect( + await db.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_delete_1']), + isEmpty, + ); + await LocalDb.close(); + }); + + test('a fresh install creates the hyrox tables at the current schema', + () async { + final db = await freshDb('hx_schema_fresh_test.db'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['hyrox_session', 'hyrox_segment'])); + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion); + expect(LocalDb.schemaVersion, 61); + await LocalDb.close(); + }); + + test('an install at v58 is upgraded to the current schema', () async { + const name = 'hx_upgrade_58_test.db'; + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + final path = p.join(dir, name); + await databaseFactory.deleteDatabase(path); + await LocalDb.instance; + await LocalDb.close(); + + // Rewind to v58 without the hyrox tables, exactly what a user who + // installed before this PR has. + final raw = await databaseFactory.openDatabase(path); + await raw.execute('DROP TABLE hyrox_segment'); + await raw.execute('DROP TABLE hyrox_session'); + await raw.execute('PRAGMA user_version = 58'); + await raw.close(); + + final db = await LocalDb.instance; + final v = await db.rawQuery('PRAGMA user_version'); + expect((v.first.values.first as num).toInt(), LocalDb.schemaVersion, + reason: 'the v61 ladder rung must run, not just the repair pass'); + final names = await LocalDb.tableNames(); + expect(names, containsAll(['hyrox_session', 'hyrox_segment'])); + await LocalDb.close(); + }); + + test('updateSession cannot move the derived bounds', () async { + final db = await freshDb('hx_bounds_test.db'); + await HyroxDb.create(db, armedSession('hx_bounds_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_bounds_1', ts: 1000, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_bounds_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, + kind: current.kind, + preset: current.preset, + status: 'active', + plan: current.plan, + startTs: 999999, + endTs: 999999, + note: 'felt good', + createdAt: current.createdAt, + updatedAt: current.updatedAt, + ), + ); + final read = (await HyroxDb.byId(db, 'hx_bounds_1'))!; + expect(read.startTs, 1000); + expect(read.endTs, isNull); + expect(read.note, 'felt good'); + await LocalDb.close(); + }); + + test('deleting the second day removes a race that crossed midnight', + () async { + final db = await freshDb('hx_midnight_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_midnight_1', createdAt: (midnightSec - 600) * 1000)); + // Round 1 starts yesterday, round 2 starts today. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_midnight_1', ts: midnightSec - 600, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_midnight_1', currentIdx: 0, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_midnight_1'), isNull); + await LocalDb.close(); + }); + + test('a day export carries the race and restores it whole', () async { + final db = await freshDb('hx_roundtrip_source.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_export_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_export_1', ts: nowSec - 60, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_export_1', currentIdx: 0, + transitionTs: nowSec - 30, boundarySource: 'gesture'); + + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + expect(File(path).existsSync(), isTrue); + + // Import into a second, empty database. + await LocalDb.close(); + LocalDb.dbName = 'hx_roundtrip_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + final counts = await LocalDb.importFromDbFile(path); + expect(counts['hyrox_session'], 1); + expect(counts['hyrox_segment'], 8); + + final restored = await HyroxDb.byId(dest, 'hx_export_1'); + expect(restored, isNotNull); + expect(restored!.segments, hasLength(8)); + expect(restored.segments[0].startTs, nowSec - 60); + expect(restored.segments[1].startTs, nowSec - 30); + expect(restored.plan?.preset, 'hyrox'); + await File(path).delete(); + await LocalDb.close(); + }); + + test('create only accepts an armed plan without history', () async { + final db = await freshDb('hx_create_inv_test.db'); + final plan = HyroxPlan.race('hyrox'); + // Not armed. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_1', kind: 'race', preset: 'hyrox', status: 'active', + plan: plan, createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); + // Pre-filled bounds. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_2', kind: 'race', preset: 'hyrox', status: 'armed', + plan: plan, startTs: 5, createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); + // Segment rows carrying history. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_3', kind: 'race', preset: 'hyrox', status: 'armed', + plan: plan, createdAt: 1, updatedAt: 1, + segments: [ + HyroxSegment( + sessionId: 'hx_ci_3', idx: 0, + kind: HyroxSegmentKind.hyroxRound, station: HyroxStation.skierg, + roundIndex: 1, plannedDistanceM: 1000, plannedCal: 50, + startTs: 100, + ), + for (var i = 1; i < 8; i++) + HyroxSegment( + sessionId: 'hx_ci_3', idx: i, + kind: HyroxSegmentKind.hyroxRound, + station: kHyroxStationOrder[i], roundIndex: i + 1, + plannedDistanceM: kHyroxStationTargets[kHyroxStationOrder[i]]?.$1, + plannedReps: kHyroxStationTargets[kHyroxStationOrder[i]]?.$2, + plannedCal: kHyroxStationTargets[kHyroxStationOrder[i]]?.$3, + ), + ], + ), + ), + throwsA(isA()), + ); + // Duplicate id. + await HyroxDb.create(db, armedSession('hx_ci_4')); + await expectLater( + HyroxDb.create(db, armedSession('hx_ci_4')), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('adjustStart corrects a false start and refuses a started race', + () async { + final db = await freshDb('hx_adjstart_test.db'); + await HyroxDb.create(db, armedSession('hx_adjstart_1')); + // False triple-tap at the start line starts the race at the wrong time. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_adjstart_1', ts: 1000, + boundarySource: 'gesture'); + // Not yet transitioned: the start is still correctable. + await HyroxDb.adjustStart( + db: db, sessionId: 'hx_adjstart_1', newTs: 1500); + var segs = await HyroxDb.segments(db, 'hx_adjstart_1'); + expect(segs[0].startTs, 1500); + expect(segs[0].boundarySource, 'manual'); + expect(segs[1].startTs, isNull); + var read = await HyroxDb.byId(db, 'hx_adjstart_1'); + expect(read!.startTs, 1500); + expect(read.status, 'active'); + + // Once a segment has ENDED, the start is history and cannot move. + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_adjstart_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await expectLater( + HyroxDb.adjustStart( + db: db, sessionId: 'hx_adjstart_1', newTs: 1600), + throwsA(isA()), + ); + segs = await HyroxDb.segments(db, 'hx_adjstart_1'); + expect(segs[0].startTs, 1500); + await LocalDb.close(); + }); + + test('removeBoundary merges a false split and refuses a started tail', + () async { + final db = await freshDb('hx_rmb_test.db'); + await HyroxDb.create(db, armedSession('hx_rmb_1')); + // round0 1000->1600, round1 1600->1700, round2 1700->3100, round3 open. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_rmb_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 0, + transitionTs: 1600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 1, + transitionTs: 1700, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmb_1', currentIdx: 2, + transitionTs: 3100, boundarySource: 'gesture'); + // A later segment started: boundary 1 cannot be removed anymore. + await expectLater( + HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmb_1', idx: 1), + throwsA(isA()), + ); + // Boundary 2 is the live edge: removing it merges round 3 into round 2. + await HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmb_1', idx: 2); + final segs = await HyroxDb.segments(db, 'hx_rmb_1'); + expect(segs[2].startTs, 1700); + expect(segs[2].endTs, isNull); + expect(segs[2].boundarySource, 'manual'); + expect(segs[3].startTs, isNull); + expect(segs[3].endTs, isNull); + expect(segs.where((s) => s.isOpen).length, 1); + await LocalDb.close(); + }); + + test('an open segment from yesterday is deleted with today', () async { + final db = await freshDb('hx_open_overlap_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_open_overlap_1', + createdAt: (midnightSec - 600) * 1000)); + // Round 1 started yesterday and is STILL OPEN at midnight (live session). + await HyroxDb.startSegment( + db: db, sessionId: 'hx_open_overlap_1', ts: midnightSec - 300, + boundarySource: 'gesture'); + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_open_overlap_1'), isNull); + await LocalDb.close(); + }); + + test('offline boundaries after the final segment are rejected', () async { + final db = await freshDb('hx_offline_tail_test.db'); + await HyroxDb.create(db, armedSession('hx_tail_1')); + // Boundary i closes round i (the first one also starts the race), so it + // carries that round's station; a 9th boundary follows the final round. + final bounds = [ + for (var i = 0; i < 8; i++) + ReconcileBoundary(1000 + i * 1000, 3, HyroxSegmentKind.hyroxRound, + kHyroxStationOrder[i]), + const ReconcileBoundary(9999, 3, HyroxSegmentKind.hyroxRound, + HyroxStation.wallBalls), + ]; + await expectLater( + db.transaction((txn) async { + await HyroxDb.applyOfflineBoundariesInTx( + txn, + sessionId: 'hx_tail_1', + boundaries: bounds, + ); + }), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_tail_1'); + expect(segs[7].endTs, isNull, reason: 'the transaction rolled back'); + expect(segs[0].startTs, isNull, reason: 'nothing half-applied'); + await LocalDb.close(); + }); + + test('a day export includes a race whose open segment overlaps it', + () async { + final db = await freshDb('hx_export_overlap_src.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_export_overlap_1', + createdAt: (midnightSec - 600) * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_export_overlap_1', ts: midnightSec - 300, + boundarySource: 'gesture'); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + final exported = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true)); + final rows = await exported.query('hyrox_session', + where: 'id = ?', whereArgs: ['hx_export_overlap_1']); + final segs = await exported.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_export_overlap_1']); + expect(rows, hasLength(1)); + expect(segs, hasLength(8)); + await exported.close(); + await File(path).delete(); + await LocalDb.close(); + }); + + test('a pause between rounds is legal and reported separately', () async { + final db = await freshDb('hx_pause_test.db'); + await HyroxDb.create(db, armedSession('hx_pause_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_pause_1', ts: 1000, boundarySource: 'gesture'); + // Round 0 ends at 2000; round 1 only starts at 5600 -> 3600 s pause. + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_pause_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_pause_1'))[1] + .copyWith(startTs: 5600, endTs: 5660), + ); + final read = (await HyroxDb.byId(db, 'hx_pause_1'))!; + expect(read.movingSec, 1000 + 60); // round 0 + round 1 + expect(read.totalSec, 5660 - 1000); + expect(read.pauseSec, (5660 - 1000) - (1000 + 60)); + await LocalDb.close(); + }); + + test('an overlap between rounds is refused', () async { + final db = await freshDb('hx_overlap_test.db'); + await HyroxDb.create(db, armedSession('hx_overlap_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_overlap_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_overlap_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_overlap_1'))[0] + .copyWith(endTs: 2100), + ), + throwsA(isA()), + ); + await LocalDb.close(); + }); + + test('a DNF (abandoned mid-race) finalizes with an open cut segment', + () async { + final db = await freshDb('hx_dnf_test.db'); + await HyroxDb.create(db, armedSession('hx_dnf_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_1', ts: 1000, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_1', currentIdx: 0, + transitionTs: 2000, boundarySource: 'gesture'); + // Athlete quits during round 2: round 2 stays open, later rounds untouched. + final current = (await HyroxDb.byId(db, 'hx_dnf_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_1'); + final fin = (await HyroxDb.byId(db, 'hx_dnf_1'))!; + expect(fin.isFinalized, isTrue); + expect(fin.status, 'finalized'); + expect(fin.segments[1].endTs, isNull, reason: 'open segment stays open'); + expect(fin.segments[1].durationSec, isNull, reason: 'no invented time'); + expect(fin.segments[2].startTs, isNull); + expect(fin.movingSec, 1000); // only round 1 was measured + await LocalDb.close(); + }); + + test('finalizing a never-started session is refused', () async { + final db = await freshDb('hx_dnf_empty_test.db'); + await HyroxDb.create(db, armedSession('hx_dnf_empty_1')); + final current = (await HyroxDb.byId(db, 'hx_dnf_empty_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await expectLater( + HyroxDb.finalize(db, 'hx_dnf_empty_1'), + throwsA(isA()), + ); + await LocalDb.close(); + }); + }); +} From 9922dbc337887ebf37c141fae35589d6ffb86e01 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 16:50:18 +0000 Subject: [PATCH 11/22] fix(hyrox): address flutter analyze errors found by CI - db.dart: declare protectedHyrox/hyroxLastEdit before the local _hyroxProtected closure (referenced_before_declaration) - hyrox_plan.dart: promote raw kind/station via local variables before fromKey (argument_type_not_assignable) - test: drop undefined copyWith(station:) named parameter Co-authored-by: BucciMobile --- lib/data/db.dart | 25 ++++++++++++------------- lib/data/hyrox_plan.dart | 6 ++++-- test/hyrox_model_storage_test.dart | 1 - 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index a3fcd2e37..1bc038b31 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11800,19 +11800,6 @@ class LocalDb { 'metric_method', }; - // A local race is protected from an import when the athlete explicitly - // discarded it, or when the local row was edited no earlier than the - // imported snapshot (a newer backup may still advance the race). [row] is - // the imported hyrox_session row, null for segment rows. - bool _hyroxProtected(String id, Map? row) { - if (protectedHyrox.contains(id)) return true; - if (row == null) return false; - final local = hyroxLastEdit[id]; - if (local == null) return false; - final theirs = (row['updated_at'] as num?)?.toInt() ?? 0; - return local >= theirs; - } - // Columns this app's schema actually has, per table — so a row from a NEWER // export carrying extra columns this build doesn't know about is filtered // down (dropped) instead of throwing "no such column". A column the source @@ -11865,6 +11852,18 @@ class LocalDb { } on DatabaseException { /* table absent in an older build of this file — nothing to protect */ } + // A local race is protected from an import when the athlete explicitly + // discarded it, or when the local row was edited no earlier than the + // imported snapshot (a newer backup may still advance the race). [row] is + // the imported hyrox_session row, null for segment rows. + bool _hyroxProtected(String id, Map? row) { + if (protectedHyrox.contains(id)) return true; + if (row == null) return false; + final local = hyroxLastEdit[id]; + if (local == null) return false; + final theirs = (row['updated_at'] as num?)?.toInt() ?? 0; + return local >= theirs; + } var archiveSkipped = 0; int? archiveEdgeSec; var impAttached = false; diff --git a/lib/data/hyrox_plan.dart b/lib/data/hyrox_plan.dart index 4f09bd3f4..d59879a85 100644 --- a/lib/data/hyrox_plan.dart +++ b/lib/data/hyrox_plan.dart @@ -177,9 +177,11 @@ class HyroxSegmentPlan { }; static HyroxSegmentPlan? fromJson(Map j) { - final kind = HyroxSegmentKindX.fromKey(j['kind'] is String ? j['kind'] : null); + final rawKind = j['kind']; + final rawStation = j['station']; + final kind = HyroxSegmentKindX.fromKey(rawKind is String ? rawKind : null); final station = - HyroxStationX.fromKey(j['station'] is String ? j['station'] : null); + HyroxStationX.fromKey(rawStation is String ? rawStation : null); if (kind == null || station == null) return null; final dist = j['planned_distance_m']; final reps = j['planned_reps']; diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index 95e6ab8c9..ce1062aa9 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -559,7 +559,6 @@ void main() { HyroxDb.saveSegment( db, (await HyroxDb.segments(db, 'hx_planimm_1'))[0].copyWith( - station: HyroxStation.wallBalls, plannedReps: 999, ), ), From 3455c1517b600051863d7e16bc454d909c99211f Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 16:53:10 +0000 Subject: [PATCH 12/22] fix(hyrox): clear the two remaining info lints (fatal-infos CI) - rename local closure _hyroxProtected -> isHyroxProtected (no_leading_underscores_for_local_identifiers) - _recomputeSessionBounds: assign optional status after the map literal (use_null_aware_elements) Co-authored-by: BucciMobile --- lib/data/db.dart | 6 +++--- lib/data/hyrox_store.dart | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 1bc038b31..86e7ca8c1 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11856,7 +11856,7 @@ class LocalDb { // discarded it, or when the local row was edited no earlier than the // imported snapshot (a newer backup may still advance the race). [row] is // the imported hyrox_session row, null for segment rows. - bool _hyroxProtected(String id, Map? row) { + bool isHyroxProtected(String id, Map? row) { if (protectedHyrox.contains(id)) return true; if (row == null) return false; final local = hyroxLastEdit[id]; @@ -12591,11 +12591,11 @@ class LocalDb { } } if (t == 'hyrox_session' && - _hyroxProtected('${row['id']}', row)) { + isHyroxProtected('${row['id']}', row)) { continue; // locally discarded, or newer than the snapshot } if (t == 'hyrox_segment' && - _hyroxProtected('${row['session_id']}', null)) { + isHyroxProtected('${row['session_id']}', null)) { continue; } if (t == 'day_result') { diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index a9c9b249e..10c3a0cd2 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -1259,8 +1259,8 @@ class HyroxDb { 'start_ts': start, 'end_ts': end, 'updated_at': DateTime.now().millisecondsSinceEpoch, - if (status != null) 'status': status, }; + if (status != null) row['status'] = status; await db.update( 'hyrox_session', row, From bf556cfef2fcbc976a0ccc7a5b087d4d02f838da Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 18:16:23 +0000 Subject: [PATCH 13/22] fix(hyrox): fake path_provider in tests so exportDaysDb runs without a binding The two day-export tests hit LocalDb.exportDaysDb -> getTemporaryDirectory, which needs ServicesBinding. Install a fake PathProviderPlatform in setUpAll, same pattern as db_paged_import_export_test.dart. Co-authored-by: BucciMobile --- test/hyrox_model_storage_test.dart | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index ce1062aa9..c3efd232b 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/state/hyrox_reconciler.dart'; import 'package:openstrap_edge/data/db.dart'; @@ -14,10 +15,31 @@ import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/hyrox_plan.dart'; import 'package:openstrap_edge/data/hyrox_store.dart'; +// exportDaysDb resolves the temp dir through path_provider, which needs a +// platform channel — fake it, same pattern as db_paged_import_export_test. +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + void main() { setUpAll(() async { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; + final tmp = await Directory.systemTemp.createTemp('openstrap_hyrox_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); }); tearDownAll(() async { From 848298a3a048ccd01c9b9c2c54ebe7784a378ff8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 3 Nov 2026 07:07:39 +0000 Subject: [PATCH 14/22] fix(hyrox): address CodeRabbit review on PR #663 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Day window (export + delete): a finalized DNF whose open cut segment STARTS on the later day now matches that day via a new start_ts >= ? branch; predicate extracted into shared _hyroxInWindowSql so export and delete cannot drift apart (7 bound args). 2. Restore: session ids whose imported hyrox_session row is skipped (discarded or locally newer) are recorded in skippedHyrox, and hyrox_segment rows of those parents are skipped too — an older backup can no longer overwrite the segments of a newer local race. 3. removeBoundary refuses to merge into a closed final segment (would leave a finished session with an unstarted last segment and no legal exit). Regression tests added for all three paths. Co-authored-by: BucciMobile --- lib/data/db.dart | 77 +++++++------- lib/data/hyrox_store.dart | 8 ++ test/hyrox_model_storage_test.dart | 155 +++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 34 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 86e7ca8c1..df5668ad5 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -497,6 +497,28 @@ class LocalDb { /// `decoded_onehz` is 86 400 counters. Same reason `commitSyncBatch` chunks. static const int _maxSqlVars = 500; + /// HYROX day-window predicate, shared by day export and day delete so the + /// two paths cannot drift apart. A race belongs to a day when it STARTS that + /// day, or when one of its segments STARTS that day or OVERLAPS it. The + /// segment-start branch matters for a finalized DNF whose open cut segment + /// starts on the later day: its session start and closed-segment ends are + /// all on the earlier day, so without it the race would survive a privacy + /// delete of the later day. An open segment only overlaps later days while + /// the session is still live; a FINALIZED race (incl. an abandoned DNF) is + /// bounded by COALESCE(session.end_ts, session.start_ts) for the open branch. + /// Args: [startSec, endSec, endSec, startSec, startSec, endSec, endSec]. + static const String _hyroxInWindowSql = '(SELECT id FROM hyrox_session ' + 'WHERE start_ts >= ? AND start_ts < ? ' + 'UNION SELECT session_id FROM hyrox_segment ' + 'WHERE start_ts IS NOT NULL AND start_ts < ? ' + 'AND ( start_ts >= ? ' + ' OR (end_ts IS NOT NULL AND end_ts > ?) ' + ' OR (end_ts IS NULL AND start_ts < ? ' + ' AND EXISTS (SELECT 1 FROM hyrox_session s ' + ' WHERE s.id = hyrox_segment.session_id ' + ' AND (s.status <> \'finalized\' ' + ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + /// THE PRIMARY BAND'S `device_id`, and it is reserved permanently. /// /// Not a migration default — a standing rule, load-bearing twice over: @@ -10952,31 +10974,18 @@ 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 hyroxInWindow = '(SELECT id FROM hyrox_session ' - 'WHERE start_ts >= ? AND start_ts < ? ' - 'UNION SELECT session_id FROM hyrox_segment ' - 'WHERE start_ts IS NOT NULL AND start_ts < ? ' - 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' - ' OR (end_ts IS NULL AND start_ts < ? ' - ' AND EXISTS (SELECT 1 FROM hyrox_session s ' - ' WHERE s.id = hyrox_segment.session_id ' - ' AND (s.status <> \'finalized\' ' - ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + // Same midnight rule as deleteDays (shared predicate _hyroxInWindowSql): + // a race is exported for a day when it starts that day OR one of its + // segments starts that day or overlaps it, and always whole. await copyRows( 'hyrox_session', - where: 'id IN $hyroxInWindow', - whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + where: 'id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], ); await copyRows( 'hyrox_segment', - where: 'session_id IN $hyroxInWindow', - whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + where: 'session_id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], ); await copyRows( 'live_coverage', @@ -11180,22 +11189,14 @@ class LocalDb { // 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 hyroxInWindow = '(SELECT id FROM hyrox_session ' - 'WHERE start_ts >= ? AND start_ts < ? ' - 'UNION SELECT session_id FROM hyrox_segment ' - 'WHERE start_ts IS NOT NULL AND start_ts < ? ' - 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' - ' OR (end_ts IS NULL AND start_ts < ? ' - ' AND EXISTS (SELECT 1 FROM hyrox_session s ' - ' WHERE s.id = hyrox_segment.session_id ' - ' AND (s.status <> \'finalized\' ' - ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + // The predicate is the shared _hyroxInWindowSql so export and delete + // cannot drift apart. final hyroxIds = [ for (final r in await txn.query( 'hyrox_session', columns: ['id'], - where: 'id IN $hyroxInWindow', - whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], + where: 'id IN $_hyroxInWindowSql', + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], )) r['id'] as String, ]; @@ -11837,6 +11838,12 @@ class LocalDb { // rows and would otherwise rewrite the boundaries of a skipped race. final protectedHyrox = {}; final hyroxLastEdit = {}; + // Session ids whose imported hyrox_session row the merge skipped (locally + // discarded or newer). Their segment rows must be skipped too: segments + // follow their parent's verdict, and hyrox_session is merged before + // hyrox_segment in _restoreTables, so the set is complete before the + // segment pass starts. + final skippedHyrox = {}; try { for (final r in await db.query( 'hyrox_session', @@ -12592,11 +12599,13 @@ class LocalDb { } if (t == 'hyrox_session' && isHyroxProtected('${row['id']}', row)) { + skippedHyrox.add('${row['id']}'); continue; // locally discarded, or newer than the snapshot } if (t == 'hyrox_segment' && - isHyroxProtected('${row['session_id']}', null)) { - continue; + (isHyroxProtected('${row['session_id']}', null) || + skippedHyrox.contains('${row['session_id']}'))) { + continue; // parent skipped — the segments follow its verdict } if (t == 'day_result') { if (protectedKeys.contains( diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index 10c3a0cd2..a30b211bd 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -836,6 +836,14 @@ class HyroxDb { 'a segment is linked to a workout — unlink it before removing the boundary', ); } + // Merging into the final segment when it is already CLOSED would leave + // the session 'finished' with an unstarted last segment — no write could + // ever progress or finalize it again. Move the boundary instead. + if (right.idx == segs.length - 1 && right.endTs != null) { + throw HyroxWriteError( + 'the final segment is closed — move the boundary instead of removing it', + ); + } final merged = right.endTs == null ? left.copyWith(clearEnd: true, boundarySource: 'manual') : left.copyWith(endTs: right.endTs, boundarySource: 'manual'); diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index c3efd232b..7882ab569 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -1037,5 +1037,160 @@ void main() { ); await LocalDb.close(); }); + + // CodeRabbit follow-up: a finalized DNF whose open cut segment STARTS on + // the later day must still be caught by that day's window (the segment + // start branch), both on delete and on export. + test('a DNF cut segment starting after midnight is deleted with the ' + 'second day', () async { + final db = await freshDb('hx_dnf_midnight_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_dnf_mid_1', + createdAt: (midnightSec - 3600) * 1000)); + // Round 1 runs yesterday and closes yesterday; round 2 (the cut) STARTS + // today and stays open; the race is abandoned + finalized. + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_mid_1', ts: midnightSec - 3000, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_1', currentIdx: 0, + transitionTs: midnightSec - 600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_1', currentIdx: 1, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_dnf_mid_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_mid_1'); + + await LocalDb.deleteDays({dayLabelOf(now)}); + expect(await HyroxDb.byId(db, 'hx_dnf_mid_1'), isNull, + reason: 'the cut segment starts today, so the race belongs to today'); + expect( + await db.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_dnf_mid_1']), + isEmpty, + ); + await LocalDb.close(); + }); + + test('a DNF cut segment starting after midnight is included in the ' + 'second day export', () async { + final db = await freshDb('hx_dnf_midnight_export_test.db'); + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + final midnightSec = midnight.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create(db, armedSession('hx_dnf_mid_exp_1', + createdAt: (midnightSec - 3600) * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', ts: midnightSec - 3000, + boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', currentIdx: 0, + transitionTs: midnightSec - 600, boundarySource: 'gesture'); + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_dnf_mid_exp_1', currentIdx: 1, + transitionTs: midnightSec + 60, boundarySource: 'gesture'); + final current = (await HyroxDb.byId(db, 'hx_dnf_mid_exp_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'abandoned', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await HyroxDb.finalize(db, 'hx_dnf_mid_exp_1'); + + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + final exported = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(readOnly: true)); + final rows = await exported.query('hyrox_session', + where: 'id = ?', whereArgs: ['hx_dnf_mid_exp_1']); + final segs = await exported.query('hyrox_segment', + where: 'session_id = ?', whereArgs: ['hx_dnf_mid_exp_1']); + expect(rows, hasLength(1), + reason: 'the cut segment starts today, so the race exports with today'); + expect(segs, hasLength(8)); + await exported.close(); + await File(path).delete(); + await LocalDb.close(); + }); + + // CodeRabbit follow-up: re-importing an OLDER snapshot must not overwrite + // the segments of a locally newer session whose parent row was skipped. + test('restore keeps segments of a locally newer (skipped) session', + () async { + final db = await freshDb('hx_restore_src.db'); + final now = DateTime.now(); + final nowSec = now.millisecondsSinceEpoch ~/ 1000; + await HyroxDb.create( + db, armedSession('hx_restore_1', createdAt: nowSec * 1000)); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_restore_1', ts: nowSec - 120, + boundarySource: 'gesture'); + final path = await LocalDb.exportDaysDb({dayLabelOf(now)}); + await LocalDb.close(); + + // Destination: import the snapshot, then advance the race locally so + // the local row is newer than the snapshot. + LocalDb.dbName = 'hx_restore_dest.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + final dest = await LocalDb.instance; + await LocalDb.importFromDbFile(path); + await HyroxDb.transitionSegment( + db: dest, sessionId: 'hx_restore_1', currentIdx: 0, + transitionTs: nowSec - 60, boundarySource: 'gesture'); + final before = await HyroxDb.segments(dest, 'hx_restore_1'); + expect(before[0].endTs, nowSec - 60); + expect(before[1].startTs, nowSec - 60); + + // Re-import the same (older) snapshot: the parent is skipped because + // the local row is newer, and its segments must follow that verdict. + await LocalDb.importFromDbFile(path); + final after = await HyroxDb.segments(dest, 'hx_restore_1'); + expect(after[0].endTs, nowSec - 60, + reason: 'segment rows must not be overwritten by the older snapshot'); + expect(after[1].startTs, nowSec - 60); + await File(path).delete(); + await LocalDb.close(); + }); + + // CodeRabbit follow-up: removeBoundary must refuse to merge into the final + // segment when that segment is already closed (finished race). + test('removeBoundary refuses to merge into a closed final segment', + () async { + final db = await freshDb('hx_rmb_final_test.db'); + await HyroxDb.create(db, armedSession('hx_rmbf_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_rmbf_1', ts: 1000, boundarySource: 'gesture'); + for (var i = 0; i < 7; i++) { + await HyroxDb.transitionSegment( + db: db, sessionId: 'hx_rmbf_1', currentIdx: i, + transitionTs: 2000 + i * 600, boundarySource: 'gesture'); + } + await HyroxDb.finishSegment( + db: db, sessionId: 'hx_rmbf_1', ts: 2000 + 7 * 600 + 600, + boundarySource: 'gesture'); + // The race is finished: boundary 6 (round 7 -> final round 8) must not + // be removable, because that would clear the closed final segment. + await expectLater( + HyroxDb.removeBoundary(db: db, sessionId: 'hx_rmbf_1', idx: 6), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_rmbf_1'); + expect(segs[7].startTs, isNotNull); + expect(segs[7].endTs, isNotNull); + await LocalDb.close(); + }); }); } From 8ce70168d5808211e6b9f16d349dff87f88967c4 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 08:04:45 +0000 Subject: [PATCH 15/22] fix(hyrox): address second CodeRabbit round on PR #663 - db.dart: live open segments now match a day window only when the session was written at/after the window start (updated_at ms >= startSec*1000); finalized races keep the COALESCE end-time bound. 7 placeholders kept, 7th argument is startSec at both call sites. - hyrox_plan.dart: fromJson rejects versions below 1 as well as above kHyroxPlanVersion. - hyrox_store.dart: create rejects a session whose preset does not match the validated plan's preset; byId/_withSegments accept DatabaseExecutor so the reconciler can read inside its transaction. - hyrox_reconciler.dart: reconcile takes a required deviceId and runs the session read, tap query, deriveBoundaries and applyOfflineBoundariesInTx in ONE transaction. - Skipped the kHyroxStationTargets change (farmersCarry 200 m / dropping the 50-cal alternative): contradicts the HYROX spec (2x40 m = 80 m; cal OR metres is the configurable target per the feature request). Co-authored-by: BucciMobile --- lib/data/db.dart | 24 +++--- lib/data/hyrox_plan.dart | 2 +- lib/data/hyrox_store.dart | 10 ++- lib/state/hyrox_reconciler.dart | 119 ++++++++++++++++------------- test/hyrox_model_storage_test.dart | 22 +++++- 5 files changed, 109 insertions(+), 68 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index df5668ad5..45f08716f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -503,21 +503,25 @@ class LocalDb { /// segment-start branch matters for a finalized DNF whose open cut segment /// starts on the later day: its session start and closed-segment ends are /// all on the earlier day, so without it the race would survive a privacy - /// delete of the later day. An open segment only overlaps later days while - /// the session is still live; a FINALIZED race (incl. an abandoned DNF) is - /// bounded by COALESCE(session.end_ts, session.start_ts) for the open branch. - /// Args: [startSec, endSec, endSec, startSec, startSec, endSec, endSec]. + /// delete of the later day. An open segment of a LIVE session only overlaps + /// the window while the session was written at or after the window start + /// (updated_at is epoch MILLISECONDS, window bounds are seconds); a + /// FINALIZED race (incl. an abandoned DNF) keeps the end-time bound + /// COALESCE(session.end_ts, session.start_ts) for the open branch. + /// Args: [startSec, endSec, endSec, startSec, startSec, endSec, startSec]. static const String _hyroxInWindowSql = '(SELECT id FROM hyrox_session ' 'WHERE start_ts >= ? AND start_ts < ? ' 'UNION SELECT session_id FROM hyrox_segment ' 'WHERE start_ts IS NOT NULL AND start_ts < ? ' 'AND ( start_ts >= ? ' ' OR (end_ts IS NOT NULL AND end_ts > ?) ' - ' OR (end_ts IS NULL AND start_ts < ? ' + ' OR (end_ts IS NULL ' ' AND EXISTS (SELECT 1 FROM hyrox_session s ' ' WHERE s.id = hyrox_segment.session_id ' - ' AND (s.status <> \'finalized\' ' - ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; + ' AND ( (s.status = \'finalized\' ' + ' AND COALESCE(s.end_ts, s.start_ts, 0) > ?) ' + ' OR (s.status <> \'finalized\' ' + ' AND s.updated_at >= ? * 1000))))) )'; /// THE PRIMARY BAND'S `device_id`, and it is reserved permanently. /// @@ -10980,12 +10984,12 @@ class LocalDb { await copyRows( 'hyrox_session', where: 'id IN $_hyroxInWindowSql', - whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], ); await copyRows( 'hyrox_segment', where: 'session_id IN $_hyroxInWindowSql', - whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], ); await copyRows( 'live_coverage', @@ -11196,7 +11200,7 @@ class LocalDb { 'hyrox_session', columns: ['id'], where: 'id IN $_hyroxInWindowSql', - whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, endSec], + whereArgs: [startSec, endSec, endSec, startSec, startSec, endSec, startSec], )) r['id'] as String, ]; diff --git a/lib/data/hyrox_plan.dart b/lib/data/hyrox_plan.dart index d59879a85..ae9deb4e2 100644 --- a/lib/data/hyrox_plan.dart +++ b/lib/data/hyrox_plan.dart @@ -302,7 +302,7 @@ class HyroxPlan { static HyroxPlan? fromJson(Map j) { final rawV = j['v']; final v = rawV is num ? rawV.toInt() : null; - if (v == null || v > kHyroxPlanVersion) return null; + if (v == null || v < 1 || v > kHyroxPlanVersion) return null; final preset = j['preset']; final raw = j['segments']; if (preset is! String || raw is! List) return null; diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index a30b211bd..b8e97aa77 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -414,6 +414,12 @@ class HyroxDb { if (plan0 == null) throw HyroxWriteError('a session needs a readable plan'); final problem = plan0.validate(); if (problem != null) throw HyroxWriteError(problem); + if (session.preset != plan0.preset) { + throw HyroxWriteError( + 'session preset ${session.preset} does not match the plan preset ' + '${plan0.preset}', + ); + } if (session.status != 'armed') { throw HyroxWriteError('a new session is created armed, not ${session.status}'); } @@ -477,7 +483,7 @@ class HyroxDb { }); } - static Future byId(Database db, String id) async { + static Future byId(DatabaseExecutor db, String id) async { final rows = await db.query( 'hyrox_session', where: 'id = ?', @@ -498,7 +504,7 @@ class HyroxDb { return [for (final r in rows) HyroxSegment.fromRow(r)].whereType().toList(); } - static Future _withSegments(Database db, HyroxSession s) async { + static Future _withSegments(DatabaseExecutor db, HyroxSession s) async { final segs = await segments(db, s.id); return HyroxSession( id: s.id, diff --git a/lib/state/hyrox_reconciler.dart b/lib/state/hyrox_reconciler.dart index 9f54f1d3e..209885ac2 100644 --- a/lib/state/hyrox_reconciler.dart +++ b/lib/state/hyrox_reconciler.dart @@ -81,65 +81,82 @@ class HyroxReconciler { /// report it instead of silently doing nothing. Future> reconcile( String sessionId, { - String deviceId = '', + required String deviceId, }) async { - final session = await HyroxDb.byId(db, sessionId); - if (session == null) return const []; - if (session.isFinalized) { - throw HyroxWriteError('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: [ - kHyroxDoubleTapEventId, - 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 []; - + // Read the session, the taps and apply the boundaries in ONE transaction, + // so the decision is made against the same snapshot it is applied to. + late final List applied; await db.transaction((txn) async { + final session = await HyroxDb.byId(txn, sessionId); + if (session == null) { + applied = const []; + return; + } + if (session.isFinalized) { + throw HyroxWriteError('session $sessionId is finalized'); + } + final plan = session.plan; + if (plan == null) { + applied = const []; + return; + } + // Only a race that is still going may be rebuilt. 'discarded' must stay + // discarded (a sync must not resurrect it), and 'finished'/'finalized' + // already carry their own boundaries. + if (session.status != 'armed' && session.status != 'active') { + applied = const []; + return; + } + + // The taps to consider: from the first thing this race recorded (a live + // boundary, or the armed stamp when nothing has started) to now. + final alreadyStarted = + session.segments.where((s) => s.startTs != null).toList(); + final since = alreadyStarted.isEmpty + ? session.createdAt ~/ 1000 + : alreadyStarted.map((s) => s.startTs!).reduce((a, b) => a > b ? a : b); + final until = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final rows = await txn.query( + 'band_events', + columns: ['ts'], + where: 'event_id = ? AND device_id = ? AND ts >= ? AND ts <= ?', + whereArgs: [ + kHyroxDoubleTapEventId, + deviceId, + since - kClockDriftToleranceSec, + until + kClockDriftToleranceSec, + ], + orderBy: 'ts ASC', + ); + final taps = [ + for (final r in rows) + if (((r['ts'] as num?) ?? 0).toInt() > 0) (r['ts'] as num).toInt(), + ]; + if (taps.isEmpty) { + applied = const []; + return; + } + + final boundaries = deriveBoundaries( + taps: taps, + segments: session.segments, + plan: plan, + sessionCreatedAtSec: session.createdAt ~/ 1000, + ); + if (boundaries.isEmpty) { + applied = const []; + return; + } + await HyroxDb.applyOfflineBoundariesInTx( txn, sessionId: sessionId, boundaries: boundaries, ); + applied = boundaries; }); - return boundaries; + return applied; } } diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index 7882ab569..11476a29a 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -201,6 +201,8 @@ void main() { expect(HyroxPlan.fromJson({'v': 99, 'preset': 'hyrox', 'segments': []}), isNull); + expect(HyroxPlan.fromJson({'v': 0, 'preset': 'hyrox', 'segments': []}), + isNull, reason: 'versions below 1 are refused too'); expect(HyroxPlan.fromJson({'v': 1, 'preset': 42, 'segments': []}), isNull); expect( HyroxPlan.fromJson({ @@ -405,7 +407,7 @@ void main() { await taps(t0 + 1300, [0, 2, 4]); // accepted: closes round 2 final reconciler = HyroxReconciler(db); - final applied = await reconciler.reconcile('hx_posthoc_1'); + final applied = await reconciler.reconcile('hx_posthoc_1', deviceId: ''); expect(applied, hasLength(3)); final segs = await HyroxDb.segments(db, 'hx_posthoc_1'); @@ -421,7 +423,7 @@ void main() { expect(read.status, 'active'); // Idempotent: the same log a second time adds nothing. - final again = await reconciler.reconcile('hx_posthoc_1'); + final again = await reconciler.reconcile('hx_posthoc_1', deviceId: ''); expect(again, isEmpty); await LocalDb.close(); }); @@ -453,7 +455,7 @@ void main() { // A round cannot be over, so it must be ignored. await taps(t0 + 660); - final applied = await HyroxReconciler(db).reconcile('hx_floor_1'); + final applied = await HyroxReconciler(db).reconcile('hx_floor_1', deviceId: ''); expect(applied, hasLength(1)); final segs = await HyroxDb.segments(db, 'hx_floor_1'); expect(segs[0].startTs, t0 + 600); @@ -491,7 +493,7 @@ void main() { 'captured_at': ts * 1000, }); } - final applied = await HyroxReconciler(db).reconcile('hx_disc_1'); + final applied = await HyroxReconciler(db).reconcile('hx_disc_1', deviceId: ''); expect(applied, isEmpty); expect((await HyroxDb.byId(db, 'hx_disc_1'))!.status, 'discarded'); await LocalDb.close(); @@ -811,6 +813,18 @@ void main() { HyroxDb.create(db, armedSession('hx_ci_4')), throwsA(isA()), ); + // Session preset must match the frozen plan's preset. + await expectLater( + HyroxDb.create( + db, + HyroxSession( + id: 'hx_ci_5', kind: 'race', preset: 'hyrox_doubles', + status: 'armed', plan: HyroxPlan.race('hyrox'), + createdAt: 1, updatedAt: 1, + ), + ), + throwsA(isA()), + ); await LocalDb.close(); }); From a00f9f88f4bc64994391b9fdc7ca932d902ad2a4 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 08:33:42 +0000 Subject: [PATCH 16/22] fix(hyrox): correct station targets to the official HYROX rulebook 26/27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Farmers Carry is 200 m (not 2x40 m = 80 m); SkiErg and rowing are 1000 m (metres per the rulebook — planned_cal stays available only for free-form training plans). Sled push/pull 4x12.5 m = 50 m, burpee broad jumps 80 m, sandbag lunges 100 m, wall balls 100 reps were already correct. Tests updated. Co-authored-by: BucciMobile --- lib/data/hyrox_plan.dart | 17 ++++++++++------- test/hyrox_model_storage_test.dart | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/data/hyrox_plan.dart b/lib/data/hyrox_plan.dart index ae9deb4e2..45cfb604a 100644 --- a/lib/data/hyrox_plan.dart +++ b/lib/data/hyrox_plan.dart @@ -117,17 +117,20 @@ bool _isFixedRacePreset(String preset) => preset == 'hyrox' || preset == 'hyrox_doubles'; /// Planned targets per station for the fixed race: (distance m, reps, cal). -/// SkiErg and rowing are 1000 m OR 50 cal — configurable by the athlete, so -/// both targets may be present and the UI picks which to show; farmers carry -/// is 2×40 m = 80 m; wall balls are rep-based (100 reps, ball weight is a -/// gym-setup detail, not stored). +/// Matches the official HYROX rulebook (Singles/Doubles/Relay 26/27): every +/// round is a 1000 m run followed by one station — SkiErg 1000 m, sled push +/// 4×12.5 m = 50 m, sled pull 50 m, burpee broad jumps 80 m, rowing 1000 m, +/// farmers carry 200 m, sandbag lunges 100 m, wall balls 100 reps. Loads +/// (sled/kettlebell/sandbag/ball weights) vary by division and are a gym-setup +/// detail, not stored. `planned_cal` exists only for free-form training plans +/// that target calories instead of metres on an erg. const Map kHyroxStationTargets = { - HyroxStation.skierg: (1000, null, 50), + HyroxStation.skierg: (1000, null, null), HyroxStation.sledPush: (50, null, null), HyroxStation.sledPull: (50, null, null), HyroxStation.burpeeBroadJump: (80, null, null), - HyroxStation.rowing: (1000, null, 50), - HyroxStation.farmersCarry: (80, null, null), + HyroxStation.rowing: (1000, null, null), + HyroxStation.farmersCarry: (200, null, null), HyroxStation.sandbagLunges: (100, null, null), HyroxStation.wallBalls: (null, 100, null), }; diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index 11476a29a..ffe3f7149 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -55,12 +55,12 @@ void main() { expect(plan.segments[0].station, HyroxStation.skierg); expect(plan.segments[0].roundIndex, 1); expect(plan.segments[0].plannedDistanceM, 1000.0); - expect(plan.segments[0].plannedCal, 50); + expect(plan.segments[0].plannedCal, isNull); // official target is 1000 m expect(plan.segments[1].station, HyroxStation.sledPush); expect(plan.segments[1].plannedDistanceM, 50.0); expect(plan.segments[4].station, HyroxStation.rowing); expect(plan.segments[5].station, HyroxStation.farmersCarry); - expect(plan.segments[5].plannedDistanceM, 80.0); // 2x40 m + expect(plan.segments[5].plannedDistanceM, 200.0); // farmers carry 200 m expect(plan.segments[6].station, HyroxStation.sandbagLunges); expect(plan.segments[7].station, HyroxStation.wallBalls); expect(plan.segments[7].roundIndex, 8); @@ -174,7 +174,7 @@ void main() { expect(roundTrip.segments[7].station, HyroxStation.wallBalls); expect(roundTrip.segments[7].plannedReps, 100); expect(roundTrip.segments[7].goalSec, 600); - expect(roundTrip.segments[0].plannedCal, 50); + expect(roundTrip.segments[0].plannedCal, isNull); }); test('wrong-typed fields parse to null without throwing; newer v refused', From cf3e7b23227017547ec2b31173d30e12139ddce1 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 08:34:00 +0000 Subject: [PATCH 17/22] test(hyrox): hand-built segment in create-invariants test matches new plan targets (cal null) Co-authored-by: BucciMobile --- test/hyrox_model_storage_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index ffe3f7149..170df3760 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -790,7 +790,7 @@ void main() { HyroxSegment( sessionId: 'hx_ci_3', idx: 0, kind: HyroxSegmentKind.hyroxRound, station: HyroxStation.skierg, - roundIndex: 1, plannedDistanceM: 1000, plannedCal: 50, + roundIndex: 1, plannedDistanceM: 1000, startTs: 100, ), for (var i = 1; i < 8; i++) From 0f33678217d9130a5b2453b90627a38b2dc1453e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 15:15:51 +0000 Subject: [PATCH 18/22] fix(hyrox): CodeRabbit round 3 on PRs #11/#15 (storage-side findings) - db.dart restore: protect locally FINALIZED sessions too (finalized_at in the protection query), and delete local hyrox_segment rows of a replaced parent so the restored race shows exactly the backup's segments - hyrox_store.dart: _requireWritable also rejects discarded sessions; saveSegment rejects workoutId changes (link stays managed by linkWorkout) - hyrox_reconciler.dart: full-round station floors only for fixed race presets; training/custom get a 30 s floor (kTrainingFloorSec) - tap_router.dart: register /hyrox in _screenRoutes (Workouts tab) Co-authored-by: BucciMobile --- lib/data/db.dart | 21 ++++++++++++++++++--- lib/data/hyrox_store.dart | 15 ++++++++++++++- lib/notify/tap_router.dart | 1 + lib/state/hyrox_reconciler.dart | 17 ++++++++++++++++- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index bea5b4c79..98a8d6067 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12046,12 +12046,15 @@ class LocalDb { try { for (final r in await db.query( 'hyrox_session', - columns: ['id', 'status', 'updated_at'], + columns: ['id', 'status', 'updated_at', 'finalized_at'], distinct: true, )) { final id = '${r['id']}'; hyroxLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; - if (r['status'] == 'discarded') { + // Finalization is one-way: a finalized local race is a record and is + // never replaced by an import, not even a newer backup's unfinalized + // copy. Discarded is an explicit local decision and always wins too. + if (r['status'] == 'discarded' || r['finalized_at'] != null) { protectedHyrox.add(id); } } @@ -12799,7 +12802,19 @@ class LocalDb { if (t == 'hyrox_session' && isHyroxProtected('${row['id']}', row)) { skippedHyrox.add('${row['id']}'); - continue; // locally discarded, or newer than the snapshot + continue; // locally discarded/finalized, or newer than the snapshot + } + if (t == 'hyrox_session') { + // The session pass runs before the segment pass and FKs are + // off: a replaced parent would keep local segment rows the + // backup does not carry. Delete them so the restored race + // shows exactly the backup's segments. Skipped (protected) + // sessions never reach here, so their segments stay protected. + await db.delete( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: ['${row['id']}'], + ); } if (t == 'hyrox_segment' && (isHyroxProtected('${row['session_id']}', null) || diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index b8e97aa77..58284ebf3 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -583,6 +583,13 @@ class HyroxDb { segment.goalSec != original.goalSec) { throw HyroxWriteError('the planned segment is immutable'); } + // The workout link is set ONLY through linkWorkout, which enforces the + // no-duplicate rule — saveSegment must not replace, duplicate or clear it. + if (segment.workoutId != original.workoutId) { + throw HyroxWriteError( + 'the workout link is managed by linkWorkout, not saveSegment', + ); + } for (final other in existing) { if (other.idx != segment.idx) _checkNeighbour(segment, other); } @@ -1286,7 +1293,7 @@ class HyroxDb { static Future _requireWritable(DatabaseExecutor db, String id) async { final rows = await db.query( 'hyrox_session', - columns: ['finalized_at'], + columns: ['finalized_at', 'status'], where: 'id = ?', whereArgs: [id], limit: 1, @@ -1295,6 +1302,12 @@ class HyroxDb { if (rows.first['finalized_at'] != null) { throw HyroxWriteError('session $id is finalized and immutable'); } + // A discarded session is a terminal local decision: no write may revive + // or mutate it. (Unfinalized `abandoned` stays writable on purpose — + // `finalize` explicitly accepts it.) + if (rows.first['status'] == 'discarded') { + throw HyroxWriteError('session $id is discarded and cannot be modified'); + } } static Future _touch(DatabaseExecutor db, String id) async { diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index 82e9549f7..c8a2f3287 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -157,6 +157,7 @@ const Map _screenRoutes = { kRouteProfile: 0, kRouteRecap: 2, // Heart, i.e. Health — domainForRoute decides anyway kRouteAlarm: 0, + kRouteHyrox: 4, // Workouts/Activity tab }; TapTarget resolveTapRoute(String route) { diff --git a/lib/state/hyrox_reconciler.dart b/lib/state/hyrox_reconciler.dart index 209885ac2..b8178332b 100644 --- a/lib/state/hyrox_reconciler.dart +++ b/lib/state/hyrox_reconciler.dart @@ -68,6 +68,11 @@ const Map kMinSegmentSec = { HyroxStation.wallBalls: 240, }; +/// Floor for free-form training/custom plans: the race floors above assume a +/// full round (1 km run + station); a drill may be much shorter. 30 s still +/// rejects an accidental tap. +const int kTrainingFloorSec = 30; + class HyroxReconciler { const HyroxReconciler(this.db); @@ -232,7 +237,17 @@ List deriveBoundaries({ } if (openIdx < 0) break; // the race is already fully marked final current = plan.segments[openIdx]; - final minSec = kMinSegmentSec[current.station] ?? 0; + // Full-round floors only apply to the fixed race presets. A free-form + // training drill (single station, short piece) may legitimately end before + // the race floor — use a small training floor so an accidental touch still + // cannot close a drill. + final minSec = plan.isTraining + ? (kMinSegmentSec[current.station] != null + ? (kMinSegmentSec[current.station]! < kTrainingFloorSec + ? kMinSegmentSec[current.station]! + : kTrainingFloorSec) + : kTrainingFloorSec) + : (kMinSegmentSec[current.station] ?? 0); final currentStart = localStarts[openIdx]; if (currentStart != null && at - currentStart < minSec) continue; From 6c8b1f106e47387c0b3ed7177bf469c8fd82c764 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 15:23:34 +0000 Subject: [PATCH 19/22] test(hyrox): cover discarded-write rejection, saveSegment link immutability, training floor Co-authored-by: BucciMobile --- test/hyrox_model_storage_test.dart | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/hyrox_model_storage_test.dart b/test/hyrox_model_storage_test.dart index 170df3760..c96fc72be 100644 --- a/test/hyrox_model_storage_test.dart +++ b/test/hyrox_model_storage_test.dart @@ -464,6 +464,69 @@ void main() { await LocalDb.close(); }); + test('a discarded session rejects every write path', () async { + final db = await freshDb('hx_discarded_writes_test.db'); + await HyroxDb.create(db, armedSession('hx_disc_w_1')); + final current = (await HyroxDb.byId(db, 'hx_disc_w_1'))!; + await HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'discarded', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ); + await expectLater( + HyroxDb.startSegment( + db: db, sessionId: 'hx_disc_w_1', ts: 2000, + boundarySource: 'gesture'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.transitionSegment( + db: db, sessionId: 'hx_disc_w_1', currentIdx: 0, + transitionTs: 3000, boundarySource: 'gesture'), + throwsA(isA()), + ); + await expectLater( + HyroxDb.adjustStart(db: db, sessionId: 'hx_disc_w_1', newTs: 2500), + throwsA(isA()), + ); + await expectLater( + HyroxDb.updateSession( + db, + HyroxSession( + id: current.id, kind: current.kind, preset: current.preset, + status: 'active', plan: current.plan, + createdAt: current.createdAt, updatedAt: current.updatedAt, + ), + ), + throwsA(isA()), + ); + expect((await HyroxDb.byId(db, 'hx_disc_w_1'))!.status, 'discarded'); + await LocalDb.close(); + }); + + test('saveSegment cannot change the workout link', () async { + final db = await freshDb('hx_link_imm_test.db'); + await HyroxDb.create(db, armedSession('hx_linkimm_1')); + await HyroxDb.startSegment( + db: db, sessionId: 'hx_linkimm_1', ts: 1000, + boundarySource: 'gesture'); + await HyroxDb.linkWorkout(db, 'hx_linkimm_1', 0, 'w1'); + await expectLater( + HyroxDb.saveSegment( + db, + (await HyroxDb.segments(db, 'hx_linkimm_1'))[0] + .copyWith(workoutId: 'w2'), + ), + throwsA(isA()), + ); + final segs = await HyroxDb.segments(db, 'hx_linkimm_1'); + expect(segs[0].workoutId, 'w1'); + await LocalDb.close(); + }); + test('a discarded race is never rebuilt by the reconciler', () async { final db = await freshDb('hx_discarded_test.db'); final t0 = 1_000_000; From 70abf7b8b12660993051623f20c4bd824e604aac Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 4 Nov 2026 19:11:44 +0000 Subject: [PATCH 20/22] =?UTF-8?q?fix(hyrox):=20CodeRabbit=20round=203b=20?= =?UTF-8?q?=E2=80=94=20txn.delete=20in=20restore,=20strict=20writer=20read?= =?UTF-8?q?s,=20unlinkWorkout,=20reconcile=20exclusions=20(schema=20rung?= =?UTF-8?q?=2062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db.dart: the segment cleanup in restore now uses txn.delete (db.delete inside db.transaction deadlocks); schemaVersion 62 adds hyrox_session.excluded_taps_json - hyrox_store.dart: segmentsStrict for all write paths (refuse partial timelines); adjustStart binds updates to the row's own idx; new unlinkWorkout; removeBoundary persists the removed tap anchor - hyrox_reconciler.dart: excluded taps are filtered before deriveBoundaries Co-authored-by: BucciMobile --- lib/data/db.dart | 13 ++- lib/data/hyrox_store.dart | 158 +++++++++++++++++++++++++++----- lib/state/hyrox_reconciler.dart | 3 +- 3 files changed, 146 insertions(+), 28 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 98a8d6067..b1cb61436 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -664,7 +664,7 @@ class LocalDb { // than collide with them. The ladder is a sequence of `oldV < N` checks, so // an unused rung costs nothing: a device upgrading from 58 runs this step, // and whichever PR lands second renumbers. - static const int schemaVersion = 61; + static const int schemaVersion = 62; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -1465,6 +1465,13 @@ class LocalDb { // no backfill — there is no older data to move. await createHyroxTables(db); } + if (oldV < 62) { + // HYROX: reconcile-exclusion list for manually removed boundaries. + await db.execute( + "ALTER TABLE hyrox_session ADD COLUMN " + "excluded_taps_json TEXT NOT NULL DEFAULT '[]'", + ); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -12810,7 +12817,9 @@ class LocalDb { // backup does not carry. Delete them so the restored race // shows exactly the backup's segments. Skipped (protected) // sessions never reach here, so their segments stay protected. - await db.delete( + // MUST run on the transaction (txn), not db — calling db.* + // inside db.transaction deadlocks. + await txn.delete( 'hyrox_segment', where: 'session_id = ?', whereArgs: ['${row['id']}'], diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index 58284ebf3..fe2a1e097 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -35,8 +35,9 @@ import 'package:sqflite/sqflite.dart'; import 'hyrox_plan.dart'; -/// schemaVersion 61. Parent before child on every create. (Rung 60 belongs to -/// the triathlon storage PR; this feature takes the next free rung.) +/// schemaVersion 62. Parent before child on every create. (Rung 60 belongs to +/// the triathlon storage PR; rung 61 created these tables, rung 62 adds the +/// reconcile-exclusion column.) Future createHyroxTables(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS hyrox_session ( @@ -50,7 +51,8 @@ Future createHyroxTables(DatabaseExecutor db) async { note TEXT NOT NULL DEFAULT '', finalized_at INTEGER, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + excluded_taps_json TEXT NOT NULL DEFAULT '[]' ) '''); await db.execute( @@ -504,6 +506,31 @@ class HyroxDb { return [for (final r in rows) HyroxSegment.fromRow(r)].whereType().toList(); } + /// Strict variant for WRITERS: segments() silently drops rows an older + /// build cannot parse, which would make a writer rewrite the wrong rows. + /// A write path must see the whole stored timeline or refuse. + static Future> segmentsStrict( + DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_segment', + where: 'session_id = ?', + whereArgs: [id], + orderBy: 'idx ASC', + ); + final out = []; + for (final r in rows) { + final seg = HyroxSegment.fromRow(r); + if (seg == null) { + throw HyroxWriteError( + 'segment row idx=${r['idx']} of session $id is unreadable; ' + 'refusing to write on a partial timeline', + ); + } + out.add(seg); + } + return out; + } + static Future _withSegments(DatabaseExecutor db, HyroxSession s) async { final segs = await segments(db, s.id); return HyroxSession( @@ -568,7 +595,7 @@ class HyroxDb { _checkSegmentShape(segment); await db.transaction((txn) async { await _requireWritable(txn, segment.sessionId); - final existing = await segments(txn, segment.sessionId); + final existing = await segmentsStrict(txn, segment.sessionId); final matching = existing.where((s) => s.idx == segment.idx).toList(); if (matching.isEmpty) { throw HyroxWriteError('segment ${segment.idx} is not in the plan'); @@ -599,7 +626,7 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [segment.sessionId, segment.idx], ); - _validateTimeline(await segments(txn, segment.sessionId)); + _validateTimeline(await segmentsStrict(txn, segment.sessionId)); await _recomputeSessionBounds(txn, segment.sessionId); }); } @@ -618,7 +645,7 @@ class HyroxDb { _checkBoundarySource(boundarySource); await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); if (segs.any((s) => s.startTs != null || s.endTs != null)) { throw HyroxWriteError('the session has already started'); @@ -631,7 +658,7 @@ class HyroxDb { where: 'session_id = ? AND idx = 0', whereArgs: [sessionId], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'active'); }); } @@ -676,7 +703,7 @@ class HyroxDb { _checkBoundarySource(boundarySource); await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); final open = segs.where((s) => s.isOpen).toList(); if (open.length != 1) { throw HyroxWriteError('exactly one segment must be open to finish'); @@ -697,7 +724,7 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, last.idx], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'finished'); }); } @@ -720,7 +747,7 @@ class HyroxDb { } await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); if (segs.any((s) => s.endTs != null)) { throw HyroxWriteError( @@ -742,17 +769,17 @@ class HyroxDb { 'hyrox_segment', cleared.toRow(), where: 'session_id = ? AND idx = ?', - whereArgs: [sessionId, i], + whereArgs: [sessionId, segs[i].idx], ); } - final first = (await segments(txn, sessionId)).first; + final first = (await segmentsStrict(txn, sessionId)).first; await txn.update( 'hyrox_segment', first.copyWith(startTs: newTs, boundarySource: 'manual').toRow(), where: 'session_id = ? AND idx = 0', whereArgs: [sessionId], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'active'); }); } @@ -770,7 +797,7 @@ class HyroxDb { }) async { await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); if (idx < 0 || idx + 1 >= segs.length) { throw HyroxWriteError('there is no boundary after segment $idx'); } @@ -802,7 +829,7 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); }); } @@ -822,7 +849,7 @@ class HyroxDb { }) async { await db.transaction((txn) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); if (idx < 0 || idx + 1 >= segs.length) { throw HyroxWriteError('there is no boundary after segment $idx'); } @@ -872,7 +899,10 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, idx + 1], ); - _validateTimeline(await segments(txn, sessionId)); + // Persist the removed boundary anchor so a later reconcile does not + // re-derive the same boundary from the still-present band taps. + await _addExcludedTap(txn, sessionId, right.startTs!); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); }); } @@ -893,11 +923,11 @@ class HyroxDb { }) async { if (boundaries.isEmpty) return; await _requireWritable(txn, sessionId); - var segs = await segments(txn, sessionId); + var segs = await segmentsStrict(txn, sessionId); if (segs.isEmpty) throw HyroxWriteError('the plan has no segments'); for (var i = 0; i < boundaries.length; i++) { final b = boundaries[i]; - segs = await segments(txn, sessionId); + segs = await segmentsStrict(txn, sessionId); final open = segs.where((s) => s.isOpen).toList(); if (open.isEmpty) { // Nothing running: this boundary STARTS the race, and only as the @@ -915,7 +945,7 @@ class HyroxDb { where: 'session_id = ? AND idx = 0', whereArgs: [sessionId], ); - _validateTimeline(segs = await segments(txn, sessionId)); + _validateTimeline(segs = await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'active'); continue; } @@ -945,7 +975,7 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, current.idx], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId, status: 'finished'); return; } @@ -959,7 +989,7 @@ class HyroxDb { } // 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)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); } @@ -1014,6 +1044,84 @@ class HyroxDb { }); } + /// Tap-cluster anchors the athlete manually removed via removeBoundary; + /// reconcile must never re-derive them. Stored as a JSON int list on the + /// session row (rung 62 column). + static Future> excludedTaps(DatabaseExecutor db, String id) async { + final rows = await db.query( + 'hyrox_session', + columns: ['excluded_taps_json'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return const []; + final raw = rows.first['excluded_taps_json']; + if (raw is! String || raw.isEmpty) return const []; + final decoded = jsonDecode(raw); + return decoded is List ? [for (final v in decoded) (v as num).toInt()] : const []; + } + + static Future _addExcludedTap( + DatabaseExecutor db, String id, int tapSec) async { + final cur = await excludedTaps(db, id); + if (cur.contains(tapSec)) return; + final next = [...cur, tapSec]..sort(); + await db.update( + 'hyrox_session', + {'excluded_taps_json': jsonEncode(next), 'updated_at': DateTime.now().millisecondsSinceEpoch}, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Clear a workout link. Only allowed when no OTHER segment references the + /// same workout (the sessions row itself is left alone — deleting workout + /// data is the athlete's call from the workouts screen, not ours). + static Future unlinkWorkout( + Database db, + String sessionId, + int idx, + ) async { + await db.transaction((txn) async { + await _requireWritable(txn, sessionId); + final rows = await txn.query( + 'hyrox_segment', + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + limit: 1, + ); + if (rows.isEmpty) { + throw HyroxWriteError('segment $idx is not in session $sessionId'); + } + final seg = HyroxSegment.fromRow(rows.first); + if (seg == null || seg.workoutId == null) { + throw HyroxWriteError('segment $idx has no workout link to clear'); + } + final others = await txn.query( + 'hyrox_segment', + columns: ['session_id', 'idx'], + where: 'workout_id = ? AND NOT (session_id = ? AND idx = ?)', + whereArgs: [seg.workoutId, sessionId, idx], + ); + if (others.isNotEmpty) { + throw HyroxWriteError( + 'workout ${seg.workoutId} is still referenced by another segment', + ); + } + final updated = await txn.update( + 'hyrox_segment', + {'workout_id': null}, + where: 'session_id = ? AND idx = ?', + whereArgs: [sessionId, idx], + ); + if (updated != 1) { + throw HyroxWriteError('unlink matched no segment row'); + } + await _touch(txn, sessionId); + }); + } + /// Note and lifecycle status. Refuses a finalized row, and never clears /// `finalized_at` — finalization is one-way by design. /// @@ -1068,7 +1176,7 @@ class HyroxDb { 'only a finished or abandoned session can be finalized', ); } - final segs = await segments(txn, id); + final segs = await segmentsStrict(txn, id); if (segs.isEmpty) throw HyroxWriteError('nothing to finalize'); if (status == 'finished') { for (final s in segs) { @@ -1222,7 +1330,7 @@ class HyroxDb { required String boundarySource, }) async { await _requireWritable(txn, sessionId); - final segs = await segments(txn, sessionId); + final segs = await segmentsStrict(txn, sessionId); final current = segs.where((s) => s.idx == currentIdx).toList(); final next = segs.where((s) => s.idx == currentIdx + 1).toList(); if (current.isEmpty || next.isEmpty) { @@ -1251,7 +1359,7 @@ class HyroxDb { where: 'session_id = ? AND idx = ?', whereArgs: [sessionId, currentIdx + 1], ); - _validateTimeline(await segments(txn, sessionId)); + _validateTimeline(await segmentsStrict(txn, sessionId)); await _recomputeSessionBounds(txn, sessionId); } diff --git a/lib/state/hyrox_reconciler.dart b/lib/state/hyrox_reconciler.dart index b8178332b..7c2839739 100644 --- a/lib/state/hyrox_reconciler.dart +++ b/lib/state/hyrox_reconciler.dart @@ -134,10 +134,11 @@ class HyroxReconciler { ], orderBy: 'ts ASC', ); + final excluded = (await HyroxDb.excludedTaps(txn, sessionId)).toSet(); final taps = [ for (final r in rows) if (((r['ts'] as num?) ?? 0).toInt() > 0) (r['ts'] as num).toInt(), - ]; + ].where((t) => !excluded.contains(t)).toList(); if (taps.isEmpty) { applied = const []; return; From 00f9da7370a0d528f78b6e08bf32c6f012a7b91e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 15:12:06 +0000 Subject: [PATCH 21/22] =?UTF-8?q?fix(hyrox):=20CodeRabbit=20round=204=20?= =?UTF-8?q?=E2=80=94=20drop=20stray=20kRouteHyrox=20entry=20on=20storage?= =?UTF-8?q?=20branch,=20guard=20rung-62=20ALTER,=20completeness=20check=20?= =?UTF-8?q?in=20segmentsStrict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tap_router.dart: the /hyrox route registration lives only on the UI branch (the const exists there); remove the dangling entry here (fixes analyze) - db.dart: rung-62 ALTER TABLE only when excluded_taps_json is actually missing (fresh rung-61 installs already have it; duplicate column would abort the upgrade) - hyrox_store.dart: segmentsStrict now also rejects incomplete timelines — unreadable rows, non-contiguous idx, or fewer rows than the frozen plan Co-authored-by: BucciMobile --- lib/data/db.dart | 15 +++++++++++---- lib/data/hyrox_store.dart | 30 +++++++++++++++++++++++++----- lib/notify/tap_router.dart | 1 - 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index b1cb61436..4ba22f728 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1467,10 +1467,17 @@ class LocalDb { } if (oldV < 62) { // HYROX: reconcile-exclusion list for manually removed boundaries. - await db.execute( - "ALTER TABLE hyrox_session ADD COLUMN " - "excluded_taps_json TEXT NOT NULL DEFAULT '[]'", - ); + // Rung 61's createHyroxTables already carries the column for fresh + // installs, so only add it when it is genuinely missing — a duplicate + // ALTER would abort the upgrade and quarantine the database. + final cols = await db.rawQuery('PRAGMA table_info(hyrox_session)'); + final hasCol = cols.any((c) => c['name'] == 'excluded_taps_json'); + if (!hasCol) { + await db.execute( + "ALTER TABLE hyrox_session ADD COLUMN " + "excluded_taps_json TEXT NOT NULL DEFAULT '[]'", + ); + } } }, onOpen: (db) async { diff --git a/lib/data/hyrox_store.dart b/lib/data/hyrox_store.dart index fe2a1e097..fc2c7eb9d 100644 --- a/lib/data/hyrox_store.dart +++ b/lib/data/hyrox_store.dart @@ -518,16 +518,36 @@ class HyroxDb { orderBy: 'idx ASC', ); final out = []; - for (final r in rows) { - final seg = HyroxSegment.fromRow(r); - if (seg == null) { + for (var i = 0; i < rows.length; i++) { + final seg = HyroxSegment.fromRow(rows[i]); + if (seg == null || seg.idx != i) { throw HyroxWriteError( - 'segment row idx=${r['idx']} of session $id is unreadable; ' - 'refusing to write on a partial timeline', + 'segment row idx=${rows[i]['idx']} of session $id is unreadable ' + 'or the stored timeline is incomplete; refusing to write on a ' + 'partial timeline', ); } out.add(seg); } + // Completeness against the frozen plan: a missing row would make a + // writer treat the last present row as the final round. + final sess = await db.query( + 'hyrox_session', + columns: ['plan_json'], + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (sess.isNotEmpty) { + final plan = HyroxPlan.fromJson( + jsonDecode('${sess.first['plan_json']}') as Map? ?? const {}); + if (plan != null && out.length != plan.segments.length) { + throw HyroxWriteError( + 'session $id stores ${out.length} of ${plan.segments.length} planned ' + 'segments; refusing to write on a partial timeline', + ); + } + } return out; } diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index c8a2f3287..82e9549f7 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -157,7 +157,6 @@ const Map _screenRoutes = { kRouteProfile: 0, kRouteRecap: 2, // Heart, i.e. Health — domainForRoute decides anyway kRouteAlarm: 0, - kRouteHyrox: 4, // Workouts/Activity tab }; TapTarget resolveTapRoute(String route) { From 61872db23a320e3cba5d21f28511b5d63e461ded Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 14:34:19 +0530 Subject: [PATCH 22/22] triathlon storage: schema 59, resumable restore, keep open-race taps through retention, fix floor clock schema takes the next rung after main. restore only treats a race as current once all its segments are on disk, and segments follow whether their parent was skipped. retention keeps double-taps while a race is armed or active. the arming floor gets the same strap drift allowance as the tap query. moved the declarations the restore helper needs above it so it compiles, and fixed the storage tests that never ran or contradicted themselves. --- lib/data/db.dart | 79 +++++++++--- lib/data/triathlon_store.dart | 11 +- lib/state/triathlon_reconciler.dart | 19 +-- test/triathlon_model_storage_test.dart | 172 ++++++++++++++++++++++++- 4 files changed, 249 insertions(+), 32 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 1c37930c1..da4820980 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -669,11 +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). - // 59 is claimed by the open stranded-device-id PR (#537); this feature - // takes the next free rung rather than collide with it. The ladder is a - // sequence of `oldV < N` checks, so an unused 59 costs nothing: a device - // upgrading from 58 runs this step, and whichever PR lands second renumbers. - static const int schemaVersion = 60; + 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 (?, ?, …)` @@ -1443,7 +1439,7 @@ class LocalDb { // A band's own hypnogram (`vendor_staged`). New table only. await _createVendorSleepEpoch(db); } - if (oldV < 60) { + 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); @@ -12131,11 +12127,19 @@ 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) { + bool triProtected(String id, Map? row) { if (protectedTriathlons.contains(id)) return true; if (row == null) return false; final local = triLastEdit[id]; @@ -12164,24 +12168,44 @@ class LocalDb { // get the same protection, or trends and the rolling baselines would be // rebuilt from the other export's numbers while day detail serves ours. final finalizedDays = {}; - // A LOCAL race wins over an OLDER snapshot, but a NEWER backup must be - // able to advance it (e.g. the final boundaries recorded on another - // phone). `discarded` is an explicit local decision and always wins. - // Segments follow their parent's verdict, because they arrive as separate - // rows and would otherwise rewrite the boundaries of a skipped race. - final protectedTriathlons = {}; - final triLastEdit = {}; 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'], + columns: ['id', 'status', 'updated_at', 'plan_json'], distinct: true, )) { final id = '${r['id']}'; - triLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; 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 */ @@ -12919,11 +12943,16 @@ class LocalDb { } } if (t == 'triathlon_session' && - _triProtected('${row['id']}', row)) { - continue; // locally discarded, or newer than the snapshot + 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' && - _triProtected('${row['session_id']}', null)) { + (protectedTriathlons.contains('${row['session_id']}') || + skippedTriathlons.contains('${row['session_id']}'))) { continue; } if (t == 'day_result') { @@ -15772,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/triathlon_store.dart b/lib/data/triathlon_store.dart index e4302f947..03910ce91 100644 --- a/lib/data/triathlon_store.dart +++ b/lib/data/triathlon_store.dart @@ -80,6 +80,15 @@ Future createTriathlonTables(DatabaseExecutor db) async { ); } +/// 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 @@ -1215,7 +1224,7 @@ class TriathlonDb { 'start_ts': start, 'end_ts': end, 'updated_at': DateTime.now().millisecondsSinceEpoch, - if (status != null) 'status': status, + 'status': ?status, }; await db.update( 'triathlon_session', diff --git a/lib/state/triathlon_reconciler.dart b/lib/state/triathlon_reconciler.dart index fcc9ce84d..3f10e7363 100644 --- a/lib/state/triathlon_reconciler.dart +++ b/lib/state/triathlon_reconciler.dart @@ -34,8 +34,10 @@ import 'package:sqflite/sqflite.dart'; import '../data/triathlon_plan.dart'; import '../data/triathlon_store.dart'; -/// Double-tap event id, the only gesture the firmware emits today. -const int kTriathlonDoubleTapEventId = 14; +// 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. @@ -44,12 +46,6 @@ const int kMinTaps = 3; /// The window those taps must fall inside, in seconds. const int kTapWindowSec = 6; -/// Widening applied to the event window on both sides, because the strap's RTC -/// and the phone's clock are different clocks and can drift. The boundary -/// floor still rejects anything at or before the last recorded boundary, so a -/// wider read can only ADD taps the strap really recorded, never rewind. -const int kClockDriftToleranceSec = 120; - /// Minimum time a 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 @@ -166,7 +162,12 @@ List deriveBoundaries({ if (s.isOpen) openIdx = s.idx; } // A tap at or before the last recorded boundary is already accounted for. - int floor = sessionCreatedAtSec; + // 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; diff --git a/test/triathlon_model_storage_test.dart b/test/triathlon_model_storage_test.dart index 9e72fa17d..281e6924b 100644 --- a/test/triathlon_model_storage_test.dart +++ b/test/triathlon_model_storage_test.dart @@ -7,6 +7,7 @@ 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'; @@ -14,10 +15,27 @@ 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 { @@ -535,7 +553,7 @@ void main() { ); segs = await TriathlonDb.segments(db, 'tri_move_1'); expect(segs[0].endTs, 1700); - expect(segs[1].startTs, 1700); + expect(segs[1].startTs, 2000, reason: 'the gap is a pause, not an error'); await LocalDb.close(); }); @@ -920,6 +938,156 @@ void main() { }); + 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( @@ -1385,7 +1553,7 @@ void main() { .copyWith(startTs: 5600, endTs: 5660), ); final read = (await TriathlonDb.byId(db, 'tri_pause_1'))!; - expect(read.movingSec, 660 + 60); // swim 1000s + t1 60s + expect(read.movingSec, 1000 + 60); // swim 1000s + t1 60s expect(read.totalSec, 5660 - 1000); expect(read.pauseSec, (5660 - 1000) - (1000 + 60)); await LocalDb.close();