From f2ff8dea76bc6f3210e84456ac6e918ac12e4b70 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Oct 2026 08:26:01 +0100 Subject: [PATCH 01/10] =?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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 61872db23a320e3cba5d21f28511b5d63e461ded Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 14:34:19 +0530 Subject: [PATCH 10/10] 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();