Repository navigation
feat(triathlon): model and storage foundation #648
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f2ff8de
7e7ac0a
a589c97
2f239e0
f721b6c
d405d2c
210f8aa
7c8f257
c275bd2
7d0bf51
61872db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,7 @@ import 'live_coverage_policy.dart'; | |
| import '../compute/step_calibration_fit.dart'; | ||
| import 'step_calibration.dart'; | ||
| import '../sync/reset_gate.dart'; | ||
| import 'triathlon_store.dart'; | ||
| import 'med_store.dart'; | ||
| import 'models.dart'; | ||
| import 'nutrition_store.dart'; | ||
|
|
@@ -321,6 +322,10 @@ class LocalDb { | |
| 'imported_workout', | ||
| 'workout_route', | ||
| 'workout_split', | ||
| // A triathlon is hand-marked at the band: the boundaries exist nowhere | ||
| // else, and the plan (incl. indoor/outdoor) was typed by the athlete. | ||
| 'triathlon_session', | ||
| 'triathlon_segment', | ||
| // The only copy of what a paired sensor measured during a session. | ||
| 'external_hr', | ||
| 'imported_measurement', | ||
|
|
@@ -664,7 +669,7 @@ class LocalDb { | |
| /// pass it: sqflite throws `ArgumentError('onCreate must be null if no | ||
| /// version is specified')` BEFORE opening anything when `onCreate` is given | ||
| /// without `version` (sqflite_common database_mixin.dart). | ||
| static const int schemaVersion = 58; | ||
| static const int schemaVersion = 59; | ||
|
|
||
| /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — | ||
| /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` | ||
|
|
@@ -1434,6 +1439,11 @@ class LocalDb { | |
| // A band's own hypnogram (`vendor_staged`). New table only. | ||
| await _createVendorSleepEpoch(db); | ||
| } | ||
| if (oldV < 59) { | ||
| // Triathlon/multisport: parent session + per-segment boundaries. | ||
| // New tables only, no backfill — there is no older data to move. | ||
| await createTriathlonTables(db); | ||
| } | ||
| }, | ||
| onOpen: (db) async { | ||
| await _repairOpenSchema(db); | ||
|
|
@@ -5735,6 +5745,10 @@ class LocalDb { | |
| // DDL belongs next to the code that reads it, not two thousand lines away. | ||
| await createNutritionTables(db); | ||
| await createMedTables(db); | ||
| // Triathlon sessions and their segments: hand-recorded race data, so the | ||
| // creator lives here where onCreate, the onOpen repair pass and the export | ||
| // database all run it. CREATE IF NOT EXISTS, so re-running is free. | ||
| await createTriathlonTables(db); | ||
| // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. | ||
| await db.execute(''' | ||
| CREATE TABLE IF NOT EXISTS cycle_log ( | ||
|
|
@@ -11177,6 +11191,32 @@ class LocalDb { | |
| where: 'start_ts >= ? AND start_ts < ?', | ||
| whereArgs: [startSec, endSec], | ||
| ); | ||
| // Same midnight rule as deleteDays: a race is exported for a day when | ||
| // it starts that day OR one of its segments does, and always whole. | ||
| // An open segment only overlaps later days while the session is still | ||
| // live; a FINALIZED race (incl. an abandoned DNF with an open cut | ||
| // segment) is bounded by COALESCE(session.end_ts, session.start_ts), | ||
| // so it does not match every later day. | ||
| const triInWindow = '(SELECT id FROM triathlon_session ' | ||
| 'WHERE start_ts >= ? AND start_ts < ? ' | ||
| 'UNION SELECT session_id FROM triathlon_segment ' | ||
| 'WHERE start_ts IS NOT NULL AND start_ts < ? ' | ||
| 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' | ||
| ' OR (end_ts IS NULL AND start_ts < ? ' | ||
| ' AND EXISTS (SELECT 1 FROM triathlon_session s ' | ||
| ' WHERE s.id = triathlon_segment.session_id ' | ||
| ' AND (s.status <> \'finalized\' ' | ||
| ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; | ||
| await copyRows( | ||
| 'triathlon_session', | ||
| where: 'id IN $triInWindow', | ||
| whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], | ||
| ); | ||
| await copyRows( | ||
| 'triathlon_segment', | ||
| where: 'session_id IN $triInWindow', | ||
| whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], | ||
|
Comment on lines
+11215
to
+11218
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Export linked workouts with the whole race. This copies every segment of a selected race, but Copy the referenced
|
||
| ); | ||
| await copyRows( | ||
| 'live_coverage', | ||
| where: 'end_ts > ? AND start_ts < ?', | ||
|
|
@@ -11359,6 +11399,78 @@ class LocalDb { | |
| [startSec, endSec], | ||
| ); | ||
| } | ||
| // Triathlon rows are NOT children of `sessions`: their key is the | ||
| // triathlon session id, and a segment's `workout_id` merely points at | ||
| // one. Treating them as session children matched nothing (a privacy | ||
| // leak on delete) and could delete a race segment whose string id | ||
| // collided with a workout id. | ||
| // | ||
| // A race belongs to a day when it STARTS that day, or when one of its | ||
| // segments OVERLAPS it. Overlap is not "starts inside": a segment that | ||
| // began yesterday and is still open, or ends today, was recorded on | ||
| // this day too. An open segment has no end, so it overlaps every later | ||
| // window. Deleting either day takes the WHOLE race — half a race is a | ||
| // record nobody can read. | ||
| // | ||
| // The ids are read first and deleted second: deleting the segments | ||
| // before the parent statement would empty the very subquery the parent | ||
| // delete is built from, which is how a cross-midnight race used to | ||
| // survive its own deletion. | ||
| // An open segment only overlaps later days while the session is still | ||
| // live; a FINALIZED race (incl. an abandoned DNF with an open cut | ||
| // segment) is bounded by COALESCE(session.end_ts, session.start_ts). | ||
| const triInWindow = '(SELECT id FROM triathlon_session ' | ||
| 'WHERE start_ts >= ? AND start_ts < ? ' | ||
| 'UNION SELECT session_id FROM triathlon_segment ' | ||
| 'WHERE start_ts IS NOT NULL AND start_ts < ? ' | ||
| 'AND ( (end_ts IS NOT NULL AND end_ts > ?) ' | ||
| ' OR (end_ts IS NULL AND start_ts < ? ' | ||
| ' AND EXISTS (SELECT 1 FROM triathlon_session s ' | ||
| ' WHERE s.id = triathlon_segment.session_id ' | ||
| ' AND (s.status <> \'finalized\' ' | ||
| ' OR COALESCE(s.end_ts, s.start_ts, 0) > ?)))) )'; | ||
| final triIds = <String>[ | ||
| for (final r in await txn.query( | ||
| 'triathlon_session', | ||
| columns: ['id'], | ||
| where: 'id IN $triInWindow', | ||
| whereArgs: [startSec, endSec, endSec, startSec, endSec, endSec], | ||
| )) | ||
| r['id'] as String, | ||
| ]; | ||
| for (final chunk in _sqlVarChunks(triIds)) { | ||
| final marks = List.filled(chunk.length, '?').join(','); | ||
| // Collect the linked workouts BEFORE the segment rows go: a segment | ||
| // deleted with its `sessions` row left behind would orphan the | ||
| // workout's route and splits on a day the user asked to delete. | ||
| final linked = await txn.query( | ||
| 'triathlon_segment', | ||
| columns: ['workout_id'], | ||
| where: 'session_id IN ($marks) AND workout_id IS NOT NULL', | ||
| whereArgs: chunk, | ||
| ); | ||
| for (final w in linked) { | ||
| final wid = w['workout_id'] as String; | ||
| await txn.delete('workout_route', | ||
| where: 'session_id = ?', whereArgs: [wid]); | ||
| await txn.delete('workout_split', | ||
| where: 'session_id = ?', whereArgs: [wid]); | ||
| await txn.delete('session_sensor', | ||
| where: 'session_id = ?', whereArgs: [wid]); | ||
| await txn.delete('strength_set', | ||
| where: 'session_id = ?', whereArgs: [wid]); | ||
| deleted += await txn.delete('sessions', | ||
| where: 'id = ?', whereArgs: [wid]); | ||
| } | ||
| deleted += await txn.rawDelete( | ||
| 'DELETE FROM triathlon_segment WHERE session_id IN ($marks)', | ||
|
Comment on lines
+11465
to
+11466
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove linked workouts with the race. If a selected race has a linked workout that starts on another day, this deletes the segment but leaves its
|
||
| chunk, | ||
| ); | ||
| deleted += await txn.rawDelete( | ||
| 'DELETE FROM triathlon_session WHERE id IN ($marks)', | ||
| chunk, | ||
| ); | ||
| } | ||
| deleted += await txn.delete( | ||
| 'sessions', | ||
| where: 'start_ts >= ? AND start_ts < ?', | ||
|
|
@@ -11802,6 +11914,9 @@ class LocalDb { | |
| // Keep the decision merge after both kinds of accepted activity. | ||
| 'sessions', | ||
| 'session_sensor', | ||
| // The recorded segment boundaries and their plans cannot be rebuilt. | ||
| 'triathlon_session', | ||
| 'triathlon_segment', | ||
| 'activity_suggestions', | ||
| 'activity_review_meta', | ||
| 'activity_review_days', | ||
|
|
@@ -12012,6 +12127,27 @@ class LocalDb { | |
| return {for (final c in info) (c['name'] as String)}; | ||
| } | ||
|
|
||
| // A LOCAL race wins over an OLDER snapshot, but a NEWER backup must be | ||
| // able to advance it (e.g. the final boundaries recorded on another | ||
| // phone). `discarded` is an explicit local decision and always wins. | ||
| // Segments follow their parent's verdict, because they arrive as separate | ||
| // rows and would otherwise rewrite the boundaries of a skipped race. | ||
| final protectedTriathlons = <String>{}; | ||
| final triLastEdit = <String, int>{}; | ||
| final skippedTriathlons = <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<String, Object?>? 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 = <String, int>{}; | ||
| // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as | ||
| // "N days imported". | ||
|
|
@@ -12032,6 +12168,48 @@ class LocalDb { | |
| // get the same protection, or trends and the rolling baselines would be | ||
| // rebuilt from the other export's numbers while day detail serves ours. | ||
| final finalizedDays = <String>{}; | ||
| 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 = <String, int>{ | ||
| for (final r in await db.rawQuery( | ||
| 'SELECT session_id, COUNT(*) AS n FROM triathlon_segment ' | ||
| 'GROUP BY session_id', | ||
| )) | ||
| '${r['session_id']}': (r['n'] as num?)?.toInt() ?? 0, | ||
| }; | ||
| int? plannedSegments(Object? planJson) { | ||
| try { | ||
| final j = jsonDecode('$planJson'); | ||
| final segs = j is Map ? j['segments'] : null; | ||
| return segs is List ? segs.length : null; | ||
| } catch (_) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| for (final r in await db.query( | ||
| 'triathlon_session', | ||
| columns: ['id', 'status', 'updated_at', 'plan_json'], | ||
| distinct: true, | ||
| )) { | ||
| final id = '${r['id']}'; | ||
| if (r['status'] == 'discarded') { | ||
| protectedTriathlons.add(id); | ||
|
Comment on lines
+12201
to
+12202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Protect locally finalized races regardless of the backup timestamp. Only This bypasses the immutable-record contract enforced by
|
||
| } | ||
| final planned = plannedSegments(r['plan_json']); | ||
| if (planned == null || (haveSegments[id] ?? 0) < planned) { | ||
| continue; // an interrupted import: let the next one finish it | ||
| } | ||
| triLastEdit[id] = (r['updated_at'] as num?)?.toInt() ?? 0; | ||
| } | ||
| } on DatabaseException { | ||
| /* table absent in an older build of this file — nothing to protect */ | ||
| } | ||
| var archiveSkipped = 0; | ||
| int? archiveEdgeSec; | ||
| var impAttached = false; | ||
|
|
@@ -12764,6 +12942,19 @@ class LocalDb { | |
| } | ||
| } | ||
| } | ||
| if (t == 'triathlon_session' && | ||
| triProtected('${row['id']}', row)) { | ||
| // Locally discarded, or newer than the snapshot. Its | ||
| // segments are skipped with it: the parent table is merged | ||
| // first, so the verdict is already known when they arrive. | ||
| skippedTriathlons.add('${row['id']}'); | ||
| continue; | ||
| } | ||
| if (t == 'triathlon_segment' && | ||
| (protectedTriathlons.contains('${row['session_id']}') || | ||
| skippedTriathlons.contains('${row['session_id']}'))) { | ||
| continue; | ||
|
Comment on lines
+12953
to
+12956
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make interrupted race restores resumable. The parent and segment tables commit separately. If restore stops after the parent commits but before its segments commit, the next restore treats that parent as local and skips its segments permanently. Restore each race atomically, or track incomplete imports so a retry can finish them.
|
||
| } | ||
| if (t == 'day_result') { | ||
| if (protectedKeys.contains( | ||
| '${row['day_id']}|${row['algo_version']}', | ||
|
|
@@ -13157,6 +13348,8 @@ class LocalDb { | |
| 'signal_priority', | ||
| 'vendor_sleep_epoch', | ||
| 'substrate_archive', | ||
| 'triathlon_session', | ||
| 'triathlon_segment', | ||
| ]; | ||
|
|
||
| final missingTables = <String>[]; | ||
|
|
@@ -15608,15 +15801,25 @@ class LocalDb { | |
| // on the three real exports these four ids are 87, 22 and 12 rows across | ||
| // 8 to 11 days — a few thousand a year. Event 33 alone is ~1900 A DAY, | ||
| // which is why the exemption is four ids and not the whole table. | ||
| // | ||
| // The double-taps (event 14) that mark a triathlon's boundaries are also | ||
| // kept while a race is still armed or active: the reconciler rebuilds the | ||
| // boundaries from them, and a race left unreconciled past the cutoff | ||
| // would otherwise lose the only record of where its segments change. | ||
| deleted += await txn.delete( | ||
| 'band_events', | ||
| where: 'ts < ? AND event_id NOT IN (?, ?, ?, ?)', | ||
| where: 'ts < ? AND event_id NOT IN (?, ?, ?, ?) ' | ||
| 'AND NOT (event_id = ? AND ts >= COALESCE(' | ||
| '(SELECT MIN(created_at) / 1000 - ? FROM triathlon_session ' | ||
| "WHERE status IN ('armed', 'active')), 9223372036854775807))", | ||
| whereArgs: [ | ||
| cutoffSec, | ||
| proto.EventId.chargingOn, | ||
| proto.EventId.chargingOff, | ||
| proto.EventId.wristOn, | ||
| proto.EventId.wristOff, | ||
| kTriathlonDoubleTapEventId, | ||
| kClockDriftToleranceSec, | ||
| ], | ||
| ); | ||
| // THE UNDECODABLE ARCHIVE IS THINNED, NOT KEPT WHOLE. See | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Include the recorded start day of an open DNF segment.
Consider a training session with a closed segment on day A and, after a pause, an open segment starting on day B. After abandonment and finalization,
session.end_tsremains on day A. This predicate excludes the open segment from day B because that bound is not after day B's end.Both export and deletion then omit the race from a day with a recorded segment start. Include each segment's start day explicitly in both selectors, while keeping later-day overlap bounded.