From 2e3a18e5c0522eba0d9561192145ccbad120a2f8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:45:15 +0530 Subject: [PATCH 01/53] pair journal coffee and alcohol with the night that followed the numeric journal correlation looked up each field's lag in analytics' map, which is keyed by generic names (caffeine, alcohol, water, screen_time). none of them is a key the app stores (caffeine_mg, alcohol_units, water_ml, screens_min, caffeine_last_min), so every behaviour fell back to lag 0 and an evening's drinks were correlated with the night that had already ended that morning. that could publish a finding about an unrelated night, sometimes with the sign flipped. I pass a lag map keyed by the app's own field keys now, and the outcome axis includes each logged day's next day, so a lag-1 field no longer drops every evening whose next day had no entry. the insights caption reads the same map, so it no longer tells caffeine rows they were matched to the following night while the maths used the same day. --- lib/data/journal_fields.dart | 27 +++++++++++++ lib/data/local_repository_impl.dart | 16 +++++++- lib/ui2/screens/wellness_screen.dart | 7 +--- test/local_repository_p0_test.dart | 60 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/lib/data/journal_fields.dart b/lib/data/journal_fields.dart index e46492a72..4668dba15 100644 --- a/lib/data/journal_fields.dart +++ b/lib/data/journal_fields.dart @@ -14,6 +14,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'package:openstrap_analytics/onehz.dart' show journalFieldLagDays; import '../state/clock_format.dart'; import 'day_label.dart'; @@ -223,6 +224,32 @@ const kJournalFields = [ ), ]; +/// Outcome lag per journal field, keyed by THIS app's field keys (MIND-02). +/// +/// Analytics' [journalFieldLagDays] is keyed by generic names ('caffeine', +/// 'alcohol', 'water', 'screen_time'), none of which is a key this app stores +/// ('caffeine_mg', 'alcohol_units', ...). An unmatched key falls back to lag 0, +/// which pairs today's coffee and drinks with the night that ended before they +/// happened. Behaviour lands on the night that follows (+1); a retrospective +/// self-report describes the night that just finished (0). The analytics map is +/// spread in first so a custom field that happens to use one of its names keeps +/// that classification. +const Map kJournalFieldLagDays = { + ...journalFieldLagDays, + 'caffeine_mg': 1, + // The synthetic last-cup clock time carries caffeine's own lag. + 'caffeine_last_min': 1, + 'alcohol_units': 1, + 'water_ml': 1, + 'screens_min': 1, + // mood / sleep_quality / energy / stress / soreness share analytics' names + // and come in through the spread at lag 0. +}; + +/// The lag the correlation pairs [key] at, or null for a field neither +/// classification covers (it is still paired at 0, but no night is claimed). +int? journalFieldLag(String key) => kJournalFieldLagDays[key]; + /// Half-life of the weight trend, in days. /// /// A week is long enough that a salty dinner does not move the line and short diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 3e32607c7..6efcc596d 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -3933,9 +3933,18 @@ class LocalRepositoryImpl extends LocalRepository { }) async { if (metricsByDay.isEmpty) return const []; - final dates = metricsByDay.keys.toList()..sort(); + final logged = metricsByDay.keys.toList()..sort(); + // The outcome axis also needs each logged day's NEXT day: a behaviour field + // is paired with the night that follows it (MIND-02), and analytics looks + // that night up on this same axis. Logged days alone would drop every + // evening whose next day had no entry of its own. + final dates = { + ...logged, + for (final d in logged) ?ana.shiftDayLabel(d, 1), + }.toList() + ..sort(); final days = [ - for (final d in dates) + for (final d in logged) ana.JournalNumericDay(d, { for (final e in metricsByDay[d]!.entries) e.key: e.value.value, // MT-06 — the last cup, as a clock time, alongside the day's total. @@ -3964,6 +3973,9 @@ class LocalRepositoryImpl extends LocalRepository { journal: days, dates: dates, outcomes: outcomes, + // Keyed by this app's field keys; analytics' own map uses generic names + // that match none of them, which paired every behaviour at lag 0. + fieldLagDays: kJournalFieldLagDays, ); // Custom field definitions so a user-invented field reads by its own name diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index eba6aca69..95598976f 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -20,7 +20,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:openstrap_analytics/onehz.dart' show journalFieldLagDays; import 'package:provider/provider.dart'; import '../../data/db.dart'; @@ -1718,11 +1717,7 @@ class _JournalFindingsState extends State { String _alignment(AppLocalizations? l, Map r) { // Read from the same constant analytics paired on, so the sentence cannot // drift away from the arithmetic. - final field = (r['field'] ?? '').toString(); - // The two derived caffeine-timing keys carry caffeine's own lag. - final lag = - journalFieldLagDays[field] ?? - (field.startsWith('caffeine') ? journalFieldLagDays['caffeine'] : null); + final lag = journalFieldLag((r['field'] ?? '').toString()); if (lag == null) { return l?.wellnessMatchedSameDay ?? 'Matched against the same day\'s numbers.'; } diff --git a/test/local_repository_p0_test.dart b/test/local_repository_p0_test.dart index 430ba4af3..51bc9f157 100644 --- a/test/local_repository_p0_test.dart +++ b/test/local_repository_p0_test.dart @@ -311,6 +311,66 @@ void main() { expect(rows, isEmpty); }); + // ── MIND-02 (edge keys) ────────────────────────────────────────────────── + test( + "an evening's alcohol is paired with the night that FOLLOWED, even when " + 'the next day has no journal entry', + () async { + // Alcohol logged every other evening only. Efficiency for date D is the + // night that ended on the morning of D, so the truth here is + // efficiency[D+1] = 95 - 3 * alcohol[D]. The logged day's own + // efficiency comes from an un-logged evening and is unrelated. + // March, not June: the test above marks June's efficiency as a + // wearable estimate in metric_method, which setUp does not clear. + String label(int i) => + '2026-03-${(1 + i).toString().padLeft(2, '0')}'; + for (var i = 0; i < 30; i += 2) { + final units = (i ~/ 2) % 5; + await LocalDb.putJournalMetrics(label(i), { + 'alcohol_units': JournalMetricValue(units.toDouble()), + }); + await LocalDb.putMetricSeriesValue( + label(i + 1), + 'efficiency', + 95.0 - 3 * units, + ); + // Same-day value: an unrelated pattern, the wrong night. + await LocalDb.putMetricSeriesValue( + label(i), + 'efficiency', + 80.0 + ((i * 7) % 11), + ); + } + + final j = await repo.getJournalInsights(range: 'all'); + final rows = (j['numeric_insights'] as List).cast>(); + final alcohol = rows.where( + (r) => r['field'] == 'alcohol_units' && r['outcome'] == 'efficiency', + ); + expect( + alcohol, + isNotEmpty, + reason: 'alcohol was paired with the night before it was drunk, or ' + 'every pairing was dropped because the next day had no entry', + ); + expect(alcohol.first['rho'] as double, lessThan(-0.9)); + expect(alcohol.first['n'], 15); + expect(alcohol.first['helped'], isFalse); + }, + ); + + test('the alignment caption reads the lag the correlation used', () { + expect(journalFieldLag('caffeine_mg'), 1); + expect(journalFieldLag('caffeine_last_min'), 1); + expect(journalFieldLag('alcohol_units'), 1); + expect(journalFieldLag('water_ml'), 1); + expect(journalFieldLag('screens_min'), 1); + expect(journalFieldLag('mood'), 0); + expect(journalFieldLag('sleep_quality'), 0); + // An unclassified custom field: neither direction is claimed. + expect(journalFieldLag('my_custom_field'), isNull); + }); + // ── MIND-12 ────────────────────────────────────────────────────────────── test( 'the weekday test refuses under its history floor, and says why', From aa6660ef6956886abccc9b6e4b10d37c334b9d01 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 09:26:07 +0530 Subject: [PATCH 02/53] pair tagged evenings with the next night even when it wasn't journaled tag correlations used the journal dates as the outcome axis, so a lag-1 tag like alcohol only counted when the day after it also had a journal entry. someone who journals only on the nights they drink got no alcohol finding at all. the outcome axis is now every journal date plus the day after it; the tag rows stay the real journal entries, and the four-day floor still counts real journal days. --- lib/data/local_repository_impl.dart | 24 +++++++++++---- test/local_repository_p0_test.dart | 46 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 6efcc596d..d691ef5dd 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -3833,12 +3833,21 @@ class LocalRepositoryImpl extends LocalRepository { maps[key] = m; } - // The union of journal dates (the days we can attribute behaviours on), - // sorted oldest-first — the shared index for journal + outcome arrays. - final dates = { + // The days we can attribute behaviours on. + final journalDates = { for (final j in journal) if (j['date'] is String) j['date'] as String, - }.toList()..sort(); + }; + // The outcome axis also needs each journal day's NEXT day: a lag-1 tag + // (alcohol, late meal) is paired with the night that follows it, and + // analytics looks that night up on this axis. Journal days alone would + // drop every tagged evening whose next day had no entry of its own — a + // user who journals only on the nights they drink would get no finding. + final dates = { + ...journalDates, + for (final d in journalDates) ?ana.shiftDayLabel(d, 1), + }.toList() + ..sort(); final numericInsights = await _numericJournalInsights( metricsByDay: metricsByDay, @@ -3849,7 +3858,7 @@ class LocalRepositoryImpl extends LocalRepository { // The tag pass needs four tagged days before it says anything. The numeric // pass has its own, stricter floor and is already computed, so an early // return here must not take it down with it. - if (dates.length < 4) { + if (journalDates.length < 4) { return {'insights': const [], 'numeric_insights': numericInsights}; } @@ -3859,8 +3868,11 @@ class LocalRepositoryImpl extends LocalRepository { if (d == null) continue; (tagsByDate[d] ??= {}).addAll(_decodeStrList(j['tags_json'])); } + // The real rows only. A next-day padding date has no row and must stay + // unknown, not become an untagged day. final jdays = [ - for (final d in dates) ana.JournalDay(d, tagsByDate[d] ?? const {}), + for (final d in journalDates) + ana.JournalDay(d, tagsByDate[d] ?? const {}), ]; final outcomes = >{ for (final od in outcomeDefs) diff --git a/test/local_repository_p0_test.dart b/test/local_repository_p0_test.dart index 51bc9f157..0e465212e 100644 --- a/test/local_repository_p0_test.dart +++ b/test/local_repository_p0_test.dart @@ -47,6 +47,7 @@ void main() { ..clear() ..addAll({'track_cycle': true}); final db = await LocalDb.instance; + await db.delete('journal'); await db.delete('journal_metric'); await db.delete('cycle_log'); await db.delete('day_result'); @@ -359,6 +360,51 @@ void main() { }, ); + test( + 'a tagged evening is paired with the night that FOLLOWED, even when the ' + 'next day has no journal entry', + () async { + // Journal rows only on Fridays (alcohol) and Tuesdays (nothing tagged), + // nightly HRV every day, ~20 ms lower on the night after drinking. The + // night that follows each entry (Saturday / Wednesday) is never + // journaled, so the outcome axis has to reach past the journal rows. + final start = DateTime.utc(2025, 1, 3); // a Friday + String label(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + for (var i = 0; i < 120; i++) { + final d = start.add(Duration(days: i)); + final afterDrinking = d.weekday == DateTime.saturday; + await LocalDb.putMetricSeriesValue( + label(d), + 'rmssd', + (afterDrinking ? 42.0 : 62.0) + (i * 7) % 5, + ); + if (d.weekday == DateTime.friday) { + await LocalDb.putJournal(label(d), '["alcohol"]', ''); + } else if (d.weekday == DateTime.tuesday) { + await LocalDb.putJournal(label(d), '[]', ''); + } + } + + final j = await repo.getJournalInsights(range: 'all'); + final rows = (j['insights'] as List).cast>(); + final alcohol = rows.where( + (r) => r['tag'] == 'alcohol' && r['outcome'] == 'rmssd', + ); + expect( + alcohol, + isNotEmpty, + reason: 'the night after a tagged evening was dropped because that ' + 'day had no journal row of its own', + ); + expect(alcohol.first['delta_pct'] as double, lessThan(-20)); + expect(alcohol.first['helped'], isFalse); + expect(alcohol.first['n_with'], greaterThanOrEqualTo(15)); + }, + ); + test('the alignment caption reads the lag the correlation used', () { expect(journalFieldLag('caffeine_mg'), 1); expect(journalFieldLag('caffeine_last_min'), 1); From 986b060a93a1c52b6ef1ae5e21fc8dff33177f0b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 10:49:22 +0530 Subject: [PATCH 03/53] fall back to an attach copy when sqlite has no vacuum into export, encrypted backup, automatic backup, the full-database issue report and the health upload all built their snapshot with VACUUM INTO, which only exists from sqlite 3.27. android 8-10 ship the framework sqlite 3.18/3.22, so every one of those threw a syntax error there and auto-backup never wrote a file. on older sqlite the snapshot is now built table by table through ATTACH + INSERT ... SELECT inside one read transaction, the same way the issue report's two-week slice already worked; that copy logic moved into attach_copy.dart so both share it. --- lib/data/attach_copy.dart | 76 +++++++++++++++++++++++++++++ lib/data/auto_backup.dart | 2 +- lib/data/db.dart | 34 ++++++++++++- lib/data/recent_slice.dart | 61 ++--------------------- test/substrate_archive_db_test.dart | 59 ++++++++++++++++++++++ 5 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 lib/data/attach_copy.dart diff --git a/lib/data/attach_copy.dart b/lib/data/attach_copy.dart new file mode 100644 index 000000000..3e0f77159 --- /dev/null +++ b/lib/data/attach_copy.dart @@ -0,0 +1,76 @@ +// Copy a SQLite database into a new file with the SAME schema (tables, +// indexes, views, triggers, sqlite_sequence, user_version), entirely inside +// SQLite: ATTACH the source read side, then INSERT ... SELECT per table, so no +// row passes through Dart. sqflite runs those statements off the UI thread. +// +// Used for the issue report's recent slice, and as the whole-database export +// on SQLite older than 3.27, which has no `VACUUM INTO` (Android 8-10 ship +// the framework SQLite 3.18/3.22, and sqflite uses the framework's). + +import 'package:sqflite/sqflite.dart'; + +/// Rows of [table] to copy: '' = every row, a ' WHERE ...' clause = those +/// rows, null = none (the table itself is still created). The source is +/// attached as `src`. +typedef AttachCopyWhere = Future Function( + DatabaseExecutor db, String table); + +/// Build [dest] (replaced if it exists) from the database at [srcPath]. The +/// source is only ever READ. On failure [dest] is deleted and the error +/// rethrown. +Future attachCopy( + String srcPath, String dest, AttachCopyWhere whereFor) async { + await deleteDatabase(dest); + final out = await openDatabase(dest, singleInstance: false); + try { + await out.execute('ATTACH DATABASE ? AS src', [srcPath]); + final schema = await out.rawQuery( + "SELECT type, name, sql FROM src.sqlite_master WHERE sql IS NOT NULL " + "AND name NOT LIKE 'sqlite_%' ORDER BY rowid"); + final have = { + for (final r in await out.rawQuery( + "SELECT name FROM main.sqlite_master WHERE type = 'table'")) + r['name'] as String + }; + // One DEFERRED transaction = one consistent read of the source, and + // only a read lock on it. sqflite's transaction() is BEGIN IMMEDIATE, + // which would take the source's write lock too and stall syncing. + await out.execute('BEGIN'); + for (final r in schema) { + if (r['type'] != 'table' || have.contains(r['name'])) continue; + final name = r['name'] as String; + await out.execute(r['sql'] as String); + final where = await whereFor(out, name); + if (where == null) continue; + await out.execute( + 'INSERT INTO main."$name" SELECT * FROM src."$name"$where'); + } + final seq = await out.rawQuery( + "SELECT 1 FROM src.sqlite_master WHERE name = 'sqlite_sequence'"); + if (seq.isNotEmpty) { + await out.execute('DELETE FROM main.sqlite_sequence'); + await out.execute( + 'INSERT INTO main.sqlite_sequence SELECT * FROM src.sqlite_sequence'); + } + // Indexes, views and triggers after the rows (a trigger must not fire + // on the copy). + for (final type in const ['index', 'view', 'trigger']) { + for (final r in schema) { + if (r['type'] == type) await out.execute(r['sql'] as String); + } + } + final v = await out.rawQuery('PRAGMA src.user_version'); + await out.execute('PRAGMA user_version = ${v.first.values.first}'); + await out.execute('COMMIT'); + await out.execute('DETACH DATABASE src'); + await out.close(); + } catch (_) { + try { + await out.close(); + } catch (_) {} + try { + await deleteDatabase(dest); + } catch (_) {} + rethrow; + } +} diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart index 10071eb8d..739b521de 100644 --- a/lib/data/auto_backup.dart +++ b/lib/data/auto_backup.dart @@ -341,7 +341,7 @@ Future _runBackup({ final when = now ?? DateTime.now(); try { final destination = storage ?? await _storage(); - // `exportCopy` is VACUUM INTO — a transactionally consistent snapshot, + // `exportCopy` is a transactionally consistent snapshot, // not a file copy of a database that may be mid-write. // Destination FIRST. Exporting before checking meant a failure here left a // full copy of the database sitting in temp, once per attempt. diff --git a/lib/data/db.dart b/lib/data/db.dart index 27a2e2885..a2b4f21c5 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -46,6 +46,7 @@ import '../import/import_container.dart'; import 'coverage_resolver.dart' show CoverageInterval; import 'day_label.dart'; import 'activity_store.dart'; +import 'attach_copy.dart'; import '../models/activity_suggestion.dart'; import '../compute/accepted_naps.dart'; import '../compute/nap_edits.dart' show NapEditKind; @@ -9236,6 +9237,9 @@ class LocalDb { /// Uses `VACUUM INTO` (NOT a raw file copy) so the snapshot is transactionally /// consistent — a plain copy of a live SQLite file can produce torn pages /// (a corrupt export). VACUUM INTO also defragments, so the file is small. + /// SQLite older than 3.27 has no VACUUM INTO (Android 8-10: the framework + /// ships 3.18/3.22 and sqflite uses it), so there the copy is built table by + /// table through [attachCopy] instead, inside one read transaction. /// /// [includeSubstrateArchive] false empties `substrate_archive` in the COPY /// (never the live database). Automatic backups pass false: they keep five @@ -9253,7 +9257,11 @@ class LocalDb { if (await f.exists()) await f.delete(); // VACUUM INTO requires a fresh path Database? copy; try { - await db.execute('VACUUM INTO ?', [dest]); + if (!debugForceAttachExport && await _vacuumIntoSupported(db)) { + await db.execute('VACUUM INTO ?', [dest]); + } else { + await attachCopy(db.path, dest, (_, _) async => ''); + } if (!includeSubstrateArchive) { copy = await openDatabase(dest, singleInstance: false); if (debugFailExportStrip) throw StateError('test: strip failed'); @@ -9280,6 +9288,30 @@ class LocalDb { @visibleForTesting static bool debugFailExportStrip = false; + /// Test seam: take the pre-3.27 [attachCopy] path even on a newer SQLite. + @visibleForTesting + static bool debugForceAttachExport = false; + + static bool? _vacuumInto; + + static Future _vacuumIntoSupported(Database db) async { + final cached = _vacuumInto; + if (cached != null) return cached; + final v = await db.rawQuery('SELECT sqlite_version() AS v'); + return _vacuumInto = sqliteHasVacuumInto(v.first['v'] as String); + } + + /// Whether SQLite [version] ("3.22.0") parses `VACUUM INTO` (3.27.0+). + @visibleForTesting + static bool sqliteHasVacuumInto(String version) { + final parts = [ + for (final x in version.trim().split('.')) int.tryParse(x) ?? 0, + ]; + final major = parts.isNotEmpty ? parts[0] : 0; + final minor = parts.length > 1 ? parts[1] : 0; + return major > 3 || (major == 3 && minor >= 27); + } + static Future databaseFileBytes() async { final dir = await getDatabasesPath(); final path = p.join(dir, dbName); diff --git a/lib/data/recent_slice.dart b/lib/data/recent_slice.dart index 2b4ab7bf1..1440e13d2 100644 --- a/lib/data/recent_slice.dart +++ b/lib/data/recent_slice.dart @@ -5,16 +5,14 @@ // second ones possibly fewer, to fit the upload cap); the small, diagnostic // tables come whole. The live database is only ever READ. // -// Built entirely inside SQLite (ATTACH the live file read side, INSERT ... -// SELECT into the new one), so no row passes through Dart. sqflite runs those -// statements off the UI thread (a native worker on iOS/Android, a helper -// isolate under sqflite_common_ffi). +// Built entirely inside SQLite by [attachCopy], so no row passes through Dart. import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../sync/file_log.dart'; +import 'attach_copy.dart'; import 'day_label.dart'; import 'db.dart'; @@ -142,59 +140,8 @@ Future exportRecentSlice({DateTime? now, int rawDays = kSliceDays}) asyn dayLabelOf(DateTime(t.year, t.month, t.day - kSliceDays)); final dest = p.join((await getTemporaryDirectory()).path, 'openstrap_slice_${DateTime.now().microsecondsSinceEpoch}.db'); - await deleteDatabase(dest); - final out = await openDatabase(dest, singleInstance: false); - try { - await out.execute('ATTACH DATABASE ? AS src', [live.path]); - final schema = await out.rawQuery( - "SELECT type, name, sql FROM src.sqlite_master WHERE sql IS NOT NULL " - "AND name NOT LIKE 'sqlite_%' ORDER BY rowid"); - final have = { - for (final r in await out.rawQuery( - "SELECT name FROM main.sqlite_master WHERE type = 'table'")) - r['name'] as String - }; - // One DEFERRED transaction = one consistent read of the live file, and - // only a read lock on it. sqflite's transaction() is BEGIN IMMEDIATE, - // which would take the live file's write lock too and stall syncing. - await out.execute('BEGIN'); - for (final r in schema) { - if (r['type'] != 'table' || have.contains(r['name'])) continue; - final name = r['name'] as String; - await out.execute(r['sql'] as String); - final where = await _where(out, name, cutoff, rawCutoff, cutoffDay); - if (where == null) continue; - await out.execute( - 'INSERT INTO main."$name" SELECT * FROM src."$name"$where'); - } - final seq = await out.rawQuery( - "SELECT 1 FROM src.sqlite_master WHERE name = 'sqlite_sequence'"); - if (seq.isNotEmpty) { - await out.execute('DELETE FROM main.sqlite_sequence'); - await out.execute( - 'INSERT INTO main.sqlite_sequence SELECT * FROM src.sqlite_sequence'); - } - // Indexes, views and triggers after the rows (a trigger must not fire - // on the copy). - for (final type in const ['index', 'view', 'trigger']) { - for (final r in schema) { - if (r['type'] == type) await out.execute(r['sql'] as String); - } - } - final v = await out.rawQuery('PRAGMA src.user_version'); - await out.execute('PRAGMA user_version = ${v.first.values.first}'); - await out.execute('COMMIT'); - await out.execute('DETACH DATABASE src'); - await out.close(); - } catch (_) { - try { - await out.close(); - } catch (_) {} - try { - await deleteDatabase(dest); - } catch (_) {} - rethrow; - } + await attachCopy(live.path, dest, + (db, name) => _where(db, name, cutoff, rawCutoff, cutoffDay)); return dest; } diff --git a/test/substrate_archive_db_test.dart b/test/substrate_archive_db_test.dart index 523e586f0..5c4872d1b 100644 --- a/test/substrate_archive_db_test.dart +++ b/test/substrate_archive_db_test.dart @@ -1194,6 +1194,65 @@ void main() { expect(leftovers(), isEmpty); }); + test('VACUUM INTO is gated on SQLite 3.27', () { + expect(LocalDb.sqliteHasVacuumInto('3.18.2'), isFalse); // Android 8 + expect(LocalDb.sqliteHasVacuumInto('3.22.0'), isFalse); // Android 9-10 + expect(LocalDb.sqliteHasVacuumInto('3.26.0'), isFalse); + expect(LocalDb.sqliteHasVacuumInto('3.27.0'), isTrue); + expect(LocalDb.sqliteHasVacuumInto('3.28.0'), isTrue); // Android 11 + expect(LocalDb.sqliteHasVacuumInto('3.45.1'), isTrue); + }); + + test('the pre-3.27 export copies the same schema and rows as VACUUM INTO', + () async { + await _seed(); + await LocalDb.pruneDecodedBeforeRecTs(cutoff, archive: policy); + Future<(Map, Map, Object?)> shape( + String path) async { + final db = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + try { + final schema = { + for (final r in await db.rawQuery( + "SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL " + "AND name NOT LIKE 'sqlite_%'")) + r['name'] as String: r['sql'] as String, + }; + final counts = {}; + for (final r in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table'")) { + final t = r['name'] as String; + counts[t] = (await db.rawQuery('SELECT COUNT(*) AS n FROM "$t"')) + .first['n'] as int; + } + final v = (await db.rawQuery('PRAGMA user_version')).first.values.first; + return (schema, counts, v); + } finally { + await db.close(); + } + } + + final vacuum = await LocalDb.exportCopy(); + LocalDb.debugForceAttachExport = true; + try { + final attached = await LocalDb.exportCopy(); + final stripped = + await LocalDb.exportCopy(includeSubstrateArchive: false); + final a = await shape(vacuum); + final b = await shape(attached); + expect(b.$1, a.$1); + expect(b.$2, a.$2); + expect(b.$3, a.$3); + expect(a.$2['substrate_archive'], greaterThan(0)); + expect(a.$2['decoded_onehz'], greaterThan(0)); + final c = await shape(stripped); + expect(c.$2['substrate_archive'], 0); + expect(c.$2['decoded_onehz'], a.$2['decoded_onehz']); + } finally { + LocalDb.debugForceAttachExport = false; + } + }); + test('page queries seek on the primary key', () async { final db = await _db(); for (final sql in [ From f989167a17c16d49a924aa588670b568e792dce2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:23:31 +0530 Subject: [PATCH 04/53] stop manual database exports piling up in temp every export database / export encrypted tap left another full copy of the health record (and a full-size .osbk) in the temp directory, and nothing ever deleted them. manual exports now go into their own folder and keep only the two newest, so a share sheet still reading the previous file is not broken. copies older versions leaked into the temp root are removed once they are a day old; snapshots other features are still working on are never that old. --- lib/data/db.dart | 7 +- lib/data/export_retention.dart | 81 +++++++++++++++++++ lib/ui2/profile/data.dart | 19 ++++- test/export_retention_test.dart | 138 ++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 lib/data/export_retention.dart create mode 100644 test/export_retention_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index a2b4f21c5..231321205 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9246,11 +9246,16 @@ class LocalDb { /// rotating copies, and the archive is already deflated, so gzip cannot /// shrink it — a year of history would cost its full size five times over. /// A manual export keeps it, which is the lossless copy the user asked for. + /// + /// [into] puts the copy somewhere other than the temp root; the manual + /// export uses its own folder so it can prune its old copies without + /// touching snapshots other features are still working on. static Future exportCopy({ bool includeSubstrateArchive = true, + Directory? into, }) async { final db = await instance; - final tmp = await getTemporaryDirectory(); + final tmp = into ?? await getTemporaryDirectory(); final stamp = DateTime.now().millisecondsSinceEpoch; final dest = p.join(tmp.path, 'openstrap_export_$stamp.db'); final f = File(dest); diff --git a/lib/data/export_retention.dart b/lib/data/export_retention.dart new file mode 100644 index 000000000..1d15af90c --- /dev/null +++ b/lib/data/export_retention.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Where the manual "Export database" / "Export encrypted" snapshots go. +/// +/// Their own folder, not the temp root: automatic backups, issue reports and +/// the health upload also write `openstrap_export_*.db` snapshots to temp and +/// delete them when done, so a sweep over the temp root could remove one of +/// those mid-flight. Inside this folder every file belongs to a manual export. +Future sharedExportDir() async { + final tmp = await getTemporaryDirectory(); + final dir = Directory(p.join(tmp.path, 'db_share')); + await dir.create(recursive: true); + return dir; +} + +/// How many manual exports survive a sweep. +/// +/// Two, for the same reason the CSV export keeps two runs: the share target +/// reads the file lazily, so the previous export's file may still be open in a +/// share sheet when the next one starts. Each export is a full copy of the +/// health record (often 100+ MB, plaintext unless encrypted), and nothing else +/// ever removed them, so every tap added one more. +const kSharedExportsKept = 2; + +final _exportName = RegExp(r'^openstrap_export_(\d+)\.db(\.osbk)?$'); + +/// Delete all but the [keep] newest manual exports in [dir]. +/// +/// An export is identified by the millisecond stamp in its name, so the +/// plaintext `.db` and its `.osbk` count as one export. Files that do not +/// match the export name are left alone. +Future pruneSharedExports( + Directory dir, { + int keep = kSharedExportsKept, +}) async { + try { + final byStamp = >{}; + for (final f in dir.listSync().whereType()) { + final m = _exportName.firstMatch(p.basename(f.path)); + if (m == null) continue; + byStamp.putIfAbsent(int.parse(m.group(1)!), () => []).add(f); + } + final stamps = byStamp.keys.toList()..sort((a, b) => b.compareTo(a)); + for (final s in stamps.skip(keep)) { + for (final f in byStamp[s]!) { + try { + await f.delete(); + } catch (_) {} + } + } + } catch (_) { + // Housekeeping only — a failure here must never fail the export itself. + } +} + +/// Remove manual-export copies that earlier versions left in the temp root. +/// +/// Before [sharedExportDir] existed, manual exports went straight into temp +/// and were never deleted. Other features still create (and delete) their own +/// snapshots there, so only files older than [minAge] are touched: an +/// in-flight snapshot is minutes old, a leaked one is not. +Future pruneLegacyRootExports( + Directory tmpRoot, { + Duration minAge = const Duration(days: 1), + DateTime? now, +}) async { + try { + final cutoff = (now ?? DateTime.now()).subtract(minAge); + for (final f in tmpRoot.listSync().whereType()) { + if (!_exportName.hasMatch(p.basename(f.path))) continue; + try { + if (f.lastModifiedSync().isBefore(cutoff)) await f.delete(); + } catch (_) {} + } + } catch (_) { + // Housekeeping only. + } +} diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index 5524a2e0f..bf10e2871 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -26,6 +26,7 @@ import '../../data/auto_backup.dart'; import '../../data/csv_export.dart'; import '../../data/day_label.dart' show dayLabelOf; import '../../data/db.dart'; +import '../../data/export_retention.dart'; import '../../import/backup_crypto.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; @@ -165,7 +166,7 @@ class _DataScreenState extends State { final l = AppLocalizations.of(context); final origin = shareOrigin(context); // VACUUM INTO — a transactionally consistent snapshot, not a file copy. - final path = await LocalDb.exportCopy(); + final path = await _manualExportCopy(); await Share.shareXFiles([XFile(path)], subject: 'OpenStrap database', sharePositionOrigin: origin); return ( @@ -174,6 +175,20 @@ class _DataScreenState extends State { ); } + /// A full snapshot in the manual-export folder, with older exports swept. + /// + /// Each one is the whole health record, and the share sheet is handed the + /// file without ever telling us when it is done with it, so they used to + /// pile up in temp forever. The sweep runs after the new copy exists and + /// keeps the previous export too, in case its share sheet is still open. + Future _manualExportCopy() async { + final dir = await sharedExportDir(); + final path = await LocalDb.exportCopy(into: dir); + await pruneSharedExports(dir); + await pruneLegacyRootExports(dir.parent); + return path; + } + /// The same VACUUM'd snapshot as [_exportDb], sealed with AES-256-GCM under /// a key derived from a passphrase this app never stores. /// @@ -186,7 +201,7 @@ class _DataScreenState extends State { if (!mounted) return ('', false); final l = AppLocalizations.of(context); final origin = shareOrigin(context); - final plain = await LocalDb.exportCopy(); + final plain = await _manualExportCopy(); final dest = '$plain.osbk'; try { // 210 000 PBKDF2 rounds is seconds of solid CPU. On the UI isolate that diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart new file mode 100644 index 000000000..516aa2bae --- /dev/null +++ b/test/export_retention_test.dart @@ -0,0 +1,138 @@ +// Manual database exports used to pile up in temp: every "Export database" +// left another full copy of the health record (and every encrypted export a +// full-size .osbk) that nothing ever deleted. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/export_retention.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'; + +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; +} + +Future _touch(Directory dir, String name, {DateTime? at}) async { + final f = File(p.join(dir.path, name)); + await f.writeAsString('x'); + if (at != null) await f.setLastModified(at); + return f; +} + +List _names(Directory dir) => + dir.listSync().map((e) => p.basename(e.path)).toList()..sort(); + +void main() { + group('pruneSharedExports', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('osx_')); + tearDown(() async => dir.delete(recursive: true)); + + test('keeps the two newest exports, .db and .osbk alike', () async { + await _touch(dir, 'openstrap_export_1000.db'); + await _touch(dir, 'openstrap_export_2000.db.osbk'); + await _touch(dir, 'openstrap_export_3000.db'); + await _touch(dir, 'openstrap_export_4000.db'); + await _touch(dir, 'openstrap_export_4000.db.osbk'); + + await pruneSharedExports(dir); + + expect(_names(dir), [ + 'openstrap_export_3000.db', + 'openstrap_export_4000.db', + 'openstrap_export_4000.db.osbk', + ]); + }); + + test('leaves files that are not exports alone', () async { + await _touch(dir, 'openstrap_export_1.db'); + await _touch(dir, 'openstrap_export_2.db'); + await _touch(dir, 'openstrap_export_3.db'); + await _touch(dir, 'notes.txt'); + await _touch(dir, 'openstrap_export_x.db'); + + await pruneSharedExports(dir); + + expect(_names(dir), [ + 'notes.txt', + 'openstrap_export_2.db', + 'openstrap_export_3.db', + 'openstrap_export_x.db', + ]); + }); + }); + + group('pruneLegacyRootExports', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('osx_')); + tearDown(() async => dir.delete(recursive: true)); + + test('removes stale root copies but not in-flight snapshots', () async { + final now = DateTime(2026, 10, 17, 12); + await _touch(dir, 'openstrap_export_1.db', + at: now.subtract(const Duration(days: 3))); + await _touch(dir, 'openstrap_export_2.db.osbk', + at: now.subtract(const Duration(days: 2))); + await _touch(dir, 'openstrap_export_3.db', + at: now.subtract(const Duration(minutes: 1))); + await _touch(dir, 'something_else.db', + at: now.subtract(const Duration(days: 9))); + + await pruneLegacyRootExports(dir, now: now); + + expect(_names(dir), ['openstrap_export_3.db', 'something_else.db']); + }); + }); + + group('exportCopy(into:)', () { + late Directory root; + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + root = await Directory.systemTemp.createTemp('osx_root_'); + PathProviderPlatform.instance = _FakePathProvider(root.path); + LocalDb.dbName = 'openstrap_export_retention_test.db'; + final dbDir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dbDir, LocalDb.dbName)); + }); + tearDownAll(() async { + await LocalDb.close(); + await root.delete(recursive: true); + }); + + test('repeated manual exports stay bounded in their own folder', () async { + final dir = await sharedExportDir(); + expect(p.dirname(dir.path), root.path); + final paths = []; + for (var i = 0; i < 4; i++) { + final path = await LocalDb.exportCopy(into: dir); + expect(p.dirname(path), dir.path); + paths.add(path); + await pruneSharedExports(dir); + // Distinct millisecond stamps per export. + await Future.delayed(const Duration(milliseconds: 3)); + } + + final left = dir.listSync().whereType().map((f) => f.path).toSet(); + expect(left, {paths[2], paths[3]}); + // Nothing landed in the temp root. + expect( + root + .listSync() + .whereType() + .where((f) => p.basename(f.path).startsWith('openstrap_export_')), + isEmpty, + ); + }); + }); +} From 683ff77e331e26c604c7a97f360b1dfca3140eff Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 14:27:40 +0530 Subject: [PATCH 05/53] remove unused debug_mode flag advancedDebugMode read DEBUG_MODE from the environment but nothing ever imported it, so building with --dart-define=DEBUG_MODE=true changed nothing. dropping the file rather than leaving a switch that gates nothing. --- lib/debug/debug_mode.dart | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 lib/debug/debug_mode.dart diff --git a/lib/debug/debug_mode.dart b/lib/debug/debug_mode.dart deleted file mode 100644 index 9e6710240..000000000 --- a/lib/debug/debug_mode.dart +++ /dev/null @@ -1,4 +0,0 @@ -const bool advancedDebugMode = bool.fromEnvironment( - 'DEBUG_MODE', - defaultValue: false, -); From 3733f28c400e7fe07ce5e1b428d1bddc2f2d7107 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 18 Oct 2026 14:32:20 +0530 Subject: [PATCH 06/53] drop lib/debug from the contributing layout map the directory went away with the unused debug_mode flag, but the repo layout block still pointed at it. --- CONTRIBUTING.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 399c3449b..d341f61ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,6 @@ lib/cloud/ optional companion/backend + cloud import clients lib/coach/ read-only SQL coach over allow-listed views lib/compute/ runs the analytics pipeline, writes results lib/data/ local storage + the repository seam the UI reads from -lib/debug/ debug-mode flags lib/ecg/ ECG readings lib/gestures/ device action / gesture dispatch lib/gps/ GPS route tracking for outdoor activities From fd1c659d3e96ba89748693fd9f2f06ddc34bfb8b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:56:35 +0530 Subject: [PATCH 07/53] restoring an older backup lowered the prune edge sync_cursor was merged with a plain replace, so a backup's older decoded_pruned_before overwrote the local one, along with derive bookkeeping like baseline_sig. A restored day whose rows stop at the backup moment then stopped counting as truncated and got re-derived over its finalized full-day result. Cursors this install already has now win, except the prune edge, which takes the higher of the two. --- lib/compute/derivation_engine.dart | 3 +- lib/data/db.dart | 32 ++++++++++ test/restore_sync_cursor_test.dart | 93 ++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 test/restore_sync_cursor_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index dc8446793..aea18937f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -7194,7 +7194,8 @@ class DerivationEngine { static const int _maxRawHoldDays = 14; /// Cursor holding the highest `rec_ts` cutoff the raw prune has applied. - static const String _prunedBeforeCursor = 'decoded_pruned_before'; + static const String _prunedBeforeCursor = + LocalDb.kDecodedPrunedBeforeCursor; /// The `rec_ts` below which decoded substrate may be deleted, or null when /// nothing may be. PURE — the decision the raw prune is, separated from the diff --git a/lib/data/db.dart b/lib/data/db.dart index 231321205..b3087f62e 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4002,6 +4002,11 @@ class LocalDb { /// the day. Day-tagged so it survives restarts and is ignored on a new day. static const String kFrozenHeadlineCursor = 'frozen_headline'; + /// Cursor holding the highest `rec_ts` cutoff the raw prune has applied. + /// Only ever raised; the derive engine refuses to re-derive a day whose + /// window reaches below it. + static const String kDecodedPrunedBeforeCursor = 'decoded_pruned_before'; + /// The pinned morning readiness headline (day + value), or null if unset / /// unparseable. The `day` must be compared to today's label by the caller — a /// pin left over from a previous day must NOT be surfaced. @@ -10365,6 +10370,22 @@ class LocalDb { )) '${r['day']}', }; + // sync_cursor is this install's own bookkeeping, so a row it already + // has wins. A backup's older copy would otherwise lower the prune edge + // (which is only ever raised): the derive engine would then stop + // treating a restored day whose rows end at the backup moment as + // truncated, and overwrite its finalized full-day result from that + // partial substrate. The prune edge takes the higher of the two; + // every other key the backup has that this install lacks still comes + // across, which is what a restore onto a new phone needs. + final localCursors = { + if (t == 'sync_cursor') + for (final r in await db.query( + 'sync_cursor', + columns: ['name', 'value'], + )) + '${r['name']}': r['value'] as String?, + }; // sleep_user_profile is an accumulator (one fold per finalized // night), so the local-wins rule below would let a few local nights // throw away a backup's months. The side that folded more nights @@ -10542,6 +10563,17 @@ class LocalDb { } continue; } + if (t == 'sync_cursor') { + final name = '${row['name']}'; + if (localCursors.containsKey(name)) { + if (name != kDecodedPrunedBeforeCursor) continue; + final mine = int.tryParse('${localCursors[name]}'); + final theirs = int.tryParse('${row['value']}'); + if (theirs == null || (mine != null && theirs <= mine)) { + continue; + } + } + } if (t == 'day_result') { if (protectedKeys.contains( '${row['day_id']}|${row['algo_version']}', diff --git a/test/restore_sync_cursor_test.dart b/test/restore_sync_cursor_test.dart new file mode 100644 index 000000000..c247a8810 --- /dev/null +++ b/test/restore_sync_cursor_test.dart @@ -0,0 +1,93 @@ +// Restoring an older backup must not move this install's sync_cursor +// bookkeeping backwards. The prune edge (`decoded_pruned_before`) is only ever +// raised; a restore that lowered it made the derive engine treat a restored +// day whose rows stop at the backup moment as complete, and re-derive it over +// its finalized full-day result. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; + +int _midnight(DateTime d) => + DateTime(d.year, d.month, d.day).millisecondsSinceEpoch ~/ 1000; + +String _label(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +Future _backup(String name, Map cursors) async { + final dir = await databaseFactory.getDatabasesPath(); + final path = p.join(dir, name); + await databaseFactory.deleteDatabase(path); + final src = await databaseFactory.openDatabase(path); + await src.execute('CREATE TABLE sync_cursor (name TEXT PRIMARY KEY, ' + 'value TEXT, updated_at INTEGER NOT NULL)'); + for (final e in cursors.entries) { + await src.insert( + 'sync_cursor', {'name': e.key, 'value': e.value, 'updated_at': 1}); + } + await src.close(); + return path; +} + +void main() { + final today = DateTime.now(); + final local = _midnight(today.subtract(const Duration(days: 5))); + final older = _midnight(today.subtract(const Duration(days: 15))); + final newer = _midnight(today.subtract(const Duration(days: 2))); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_restore_sync_cursor_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + for (final f in ['openstrap_restore_sync_cursor_test.db', 'old.db', 'new.db']) { + try { + File(p.join(dir, f)).deleteSync(); + } catch (_) {} + } + }); + + test('an older backup leaves the prune edge and derive bookkeeping alone', + () async { + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, '$local'); + await LocalDb.setCursor('baseline_sig', 'local-sig'); + final path = await _backup('old.db', { + LocalDb.kDecodedPrunedBeforeCursor: '$older', + 'baseline_sig': 'backup-sig', + 'health_export_through': '2026-01-01', + }); + + await LocalDb.importFromDbFile(path); + + final after = + await LocalDb.getCursorInt(LocalDb.kDecodedPrunedBeforeCursor); + expect(after, local); + expect(await LocalDb.getCursor('baseline_sig'), 'local-sig'); + // A key this install never had still comes across. + expect(await LocalDb.getCursor('health_export_through'), '2026-01-01'); + // The restored day ten days back still reads as truncated, so the + // engine will not re-derive it from the backup's partial substrate. + final day10 = _label(today.subtract(const Duration(days: 10))); + expect(DerivationEngine.windowTruncatedByPrune(day10, after), isTrue); + }); + + test('a backup with a higher prune edge raises it', () async { + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, '$local'); + final path = + await _backup('new.db', {LocalDb.kDecodedPrunedBeforeCursor: '$newer'}); + + await LocalDb.importFromDbFile(path); + + expect(await LocalDb.getCursorInt(LocalDb.kDecodedPrunedBeforeCursor), + newer); + }); +} From 82390c5ad7ffb19a20dde0c841cce4f1215dbd8b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:20:28 +0530 Subject: [PATCH 08/53] the weekly energy balance subtracted today's burn so far from a mean of whole days eaten the card read as a big surplus every morning and only shrank to the truth near midnight, because today's burn is a running total. eaten and burned are now both means over the same complete days, taking each day's total burn from the derived series, and the card is hidden when no counted day has one. (cherry picked from commit 50f7d62b496dd4ccdd261d581436877bd28fc060) --- lib/data/nutrition_store.dart | 33 ++++++++++++ lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 4 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/screens/nutrition_screen.dart | 27 +++++++--- test/nutrition_energy_balance_test.dart | 69 +++++++++++++++++++++++++ 10 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 test/nutrition_energy_balance_test.dart diff --git a/lib/data/nutrition_store.dart b/lib/data/nutrition_store.dart index f0ca3d2ff..a77547b3f 100644 --- a/lib/data/nutrition_store.dart +++ b/lib/data/nutrition_store.dart @@ -456,6 +456,39 @@ class NutritionWindow { NutrientMean get meanFibre => _mean((d) => d.fibre); } +/// Eaten against burned over the SAME days. +/// +/// The balance used to be the seven-day eaten mean minus today's burn so far. +/// Today's burn is a running total — basal is pro-rated over the minutes the +/// day has covered — so at 08:00 a maintenance week read as a ~1,700 kcal +/// surplus that only shrank to the truth near midnight. Only a counted day +/// with a derived total burn goes in, and today never counts, so both sides +/// are whole days and the difference means something. +class EnergyBalance { + const EnergyBalance(this.eaten, this.burned, this.days); + final double eaten; + final double burned; + final int days; + double get balance => eaten - burned; +} + +/// [burnedByDate] maps a local day label to that day's total burn. Null when +/// no counted day has one — the card then has nothing honest to subtract. +EnergyBalance? weeklyEnergyBalance( + NutritionWindow w, + Map burnedByDate, +) { + var eaten = 0.0, burned = 0.0, n = 0; + for (final d in w.counted) { + final b = burnedByDate[d.date]; + if (b == null || !d.kcal.complete) continue; + eaten += d.kcal.value!; + burned += b; + n++; + } + return n == 0 ? null : EnergyBalance(eaten / n, burned / n, n); +} + // ══════════════════ STORE ══════════════════ class NutritionDb { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index caab8fcf5..66ffc582c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -833,7 +833,7 @@ "nutritionLabelEaten": "GEGESSEN", "nutritionLabelBurned": "VERBRANNT", "nutritionLabelBalance": "BILANZ", - "nutritionEatenMeanNote": "„Gegessen“ ist der Durchschnitt aus {days} vollständigen Tagen. „Verbrannt“ gilt nur für heute.", + "nutritionEatenMeanNote": "„Gegessen“ und „Verbrannt“ sind beide Durchschnitte über dieselben {days} vollständigen Tage.", "nutritionEnergyLoggedTitle": "Eingetragene Energie", "nutritionPartialFootnote": "{n} unvollständig, in den Durchschnitten unten nicht enthalten.", "nutritionNothingLoggedYet": "Noch nichts eingetragen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f57947b32..5bd7280e1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3996,9 +3996,9 @@ "@nutritionLabelBalance": { "description": "Nutrition Week tab: all-caps inline metric label for the eaten-minus-burned balance" }, - "nutritionEatenMeanNote": "Eaten is the mean of {days} complete days. Burned is today only.", + "nutritionEatenMeanNote": "Eaten and burned are both means over the same {days} complete days.", "@nutritionEatenMeanNote": { - "description": "Nutrition Week tab: footnote explaining the eaten mean is over N complete days while burned is today only", + "description": "Nutrition Week tab: footnote explaining eaten and burned are both means over the same N complete days", "placeholders": { "days": { "type": "int" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5090dfc23..aaff820f4 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "COMIDO", "nutritionLabelBurned": "QUEMADO", "nutritionLabelBalance": "BALANCE", - "nutritionEatenMeanNote": "Lo comido es la media de {days} días completos. Lo quemado es solo de hoy.", + "nutritionEatenMeanNote": "Lo comido y lo quemado son medias de los mismos {days} días completos.", "nutritionEnergyLoggedTitle": "Energía registrada", "nutritionPartialFootnote": "{n} parciales, excluidos de las medias de abajo.", "nutritionNothingLoggedYet": "Nada registrado aún", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 599e9e8a1..380d19caf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "MANGÉ", "nutritionLabelBurned": "BRÛLÉ", "nutritionLabelBalance": "BILAN", - "nutritionEatenMeanNote": "Le mangé est la moyenne sur {days} jours complets. Le brûlé est celui d'aujourd'hui seulement.", + "nutritionEatenMeanNote": "Le mangé et le brûlé sont deux moyennes sur les mêmes {days} jours complets.", "nutritionEnergyLoggedTitle": "Énergie enregistrée", "nutritionPartialFootnote": "{n} partiel(s), exclu(s) des moyennes ci-dessous.", "nutritionNothingLoggedYet": "Rien d'enregistré pour l'instant", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 94a430258..f890be0f0 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "खाया गया", "nutritionLabelBurned": "जला गया", "nutritionLabelBalance": "संतुलन", - "nutritionEatenMeanNote": "खाया गया {days} पूरे दिनों का औसत है। जला गया केवल आज का है।", + "nutritionEatenMeanNote": "खाया गया और जला गया, दोनों उन्हीं {days} पूरे दिनों का औसत हैं।", "nutritionEnergyLoggedTitle": "दर्ज की गई ऊर्जा", "nutritionPartialFootnote": "{n} अधूरे, नीचे के औसत से बाहर रखे गए।", "nutritionNothingLoggedYet": "अभी तक कुछ भी दर्ज नहीं हुआ", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4a8597964..070186263 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1727,7 +1727,7 @@ "nutritionLabelEaten": "СЪЕДЕНО", "nutritionLabelBurned": "ПОТРАЧЕНО", "nutritionLabelBalance": "БАЛАНС", - "nutritionEatenMeanNote": "Съедено — среднее за {days} полных дней. Израсходовано — только за сегодня.", + "nutritionEatenMeanNote": "Съедено и израсходовано — средние за одни и те же {days} полных дней.", "@nutritionEatenMeanNote": { "placeholders": { "days": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2e45f9561..49d1d6de9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "摄入", "nutritionLabelBurned": "消耗", "nutritionLabelBalance": "平衡", - "nutritionEatenMeanNote": "摄入量是 {days} 个完整天的平均值。消耗量仅为今天的数值。", + "nutritionEatenMeanNote": "摄入量和消耗量都是同样 {days} 个完整天的平均值。", "nutritionEnergyLoggedTitle": "已记录的热量", "nutritionPartialFootnote": "{n} 天记录不完整,已从下面的平均值中排除。", "nutritionNothingLoggedYet": "还没有任何记录", diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index 6c2b6c7ec..363c0d8a0 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -58,6 +58,11 @@ class _NutritionScreenState extends State with RevisionReload { String get _date => todayLabel(); NutritionWindow? _week; Metric? _burned; + + /// Whole-day total burn per local day label, for the weekly balance. Kept + /// apart from [_burned], which is today's running total and only fits the + /// per-day card. + Map _burnedByDate = const {}; double? _waterMl; /// The day [_waterMl] was read for. Past midnight on a tab nobody reloaded /// it is still yesterday's, and a full yesterday disabled today's + button. @@ -87,6 +92,12 @@ class _NutritionScreenState extends State with RevisionReload { final app = context.read(); final db = await LocalDb.instance; final week = await NutritionDb.window(db, days: 7); + final weekDates = {for (final d in week.days) d.date}; + final burnedByDate = { + for (final r in await LocalDb.metricSeries('calories_total')) + if (weekDates.contains(r['date'])) + r['date'] as String: (r['value'] as num).toDouble(), + }; Metric? burned; double? water; final date = _date; @@ -101,6 +112,7 @@ class _NutritionScreenState extends State with RevisionReload { setState(() { _week = week; _burned = burned; + _burnedByDate = burnedByDate; _waterMl = water; _waterDate = date; _profile = {...?app.user}; @@ -369,6 +381,7 @@ class _NutritionScreenState extends State with RevisionReload { final w = _week; if (w == null) return const SizedBox.shrink(); final counted = w.counted.length; + final balance = weeklyEnergyBalance(w, _burnedByDate); // PARTIAL, not "excluded": today is excluded from the averages too, and it // is not partial — a day cannot be judged incomplete while it is still // happening (see `DayLogState.inProgress`). @@ -414,7 +427,7 @@ class _NutritionScreenState extends State with RevisionReload { ), ), ), - if (counted > 0 && w.meanKcal.value != null && _burned?.value != null) + if (balance != null) Section( l?.nutritionEnergyBalance ?? 'Energy balance', Surface( @@ -423,20 +436,20 @@ class _NutritionScreenState extends State with RevisionReload { children: [ InlineMetrics([ (l?.nutritionLabelEaten ?? 'EATEN', - '${w.meanKcal.value!.round()} kcal', C.domFood), + '${balance.eaten.round()} kcal', C.domFood), (l?.nutritionLabelBurned ?? 'BURNED', - '${_burned!.value!.round()} kcal', C.purple), + '${balance.burned.round()} kcal', C.purple), ( l?.nutritionLabelBalance ?? 'BALANCE', - '${(w.meanKcal.value! - _burned!.value!).round()} kcal', + '${balance.balance.round()} kcal', C.teal, ), ]), const SizedBox(height: S.x3), Text( - l?.nutritionEatenMeanNote(w.meanKcal.days) ?? - 'Eaten is the mean of ${w.meanKcal.days} complete ' - 'days. Burned is today only.', + l?.nutritionEatenMeanNote(balance.days) ?? + 'Eaten and burned are both means over the same ' + '${balance.days} complete days.', style: F.cap.copyWith(color: P.of(c).ink3, height: 1.45), ), ], diff --git a/test/nutrition_energy_balance_test.dart b/test/nutrition_energy_balance_test.dart new file mode 100644 index 000000000..c66c3b2f0 --- /dev/null +++ b/test/nutrition_energy_balance_test.dart @@ -0,0 +1,69 @@ +// The weekly energy balance subtracted today's running burn from a mean of +// whole days eaten, so a maintenance week read as a large surplus every +// morning. These pin that both sides now come from the same whole days. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/nutrition_store.dart'; + +FoodEntry _entry(String date, double? kcal, int hour) { + final p = date.split('-').map(int.parse).toList(); + return FoodEntry( + id: '$date-$hour', + date: date, + meal: hour < 11 ? 'breakfast' : 'dinner', + label: 'Something', + atTs: DateTime(p[0], p[1], p[2], hour).millisecondsSinceEpoch ~/ 1000, + kcal: kcal, + ); +} + +/// A complete day: two occasions, the second in the evening. +NutritionDay _complete(String date, double kcal, String today) => rollupDay( + date, + [_entry(date, kcal / 2, 8), _entry(date, kcal / 2, 20)], + today: today, + ); + +void main() { + const today = '2026-08-15'; + + test('today\'s running burn never enters the weekly balance', () { + final w = NutritionWindow([ + _complete('2026-08-13', 2300, today), + _complete('2026-08-14', 2300, today), + // Today is in progress, with breakfast logged and 621 kcal burned by + // 08:00. The old card subtracted exactly this figure. + rollupDay(today, [_entry(today, 500, 8)], today: today), + ]); + final b = weeklyEnergyBalance(w, { + '2026-08-13': 2350, + '2026-08-14': 2250, + today: 621, + })!; + expect(b.days, 2); + expect(b.eaten, 2300); + expect(b.burned, 2300); + expect(b.balance, 0); + }); + + test('a counted day with no derived burn drops out of BOTH sides', () { + final w = NutritionWindow([ + _complete('2026-08-12', 3000, today), + _complete('2026-08-13', 2000, today), + ]); + final b = weeklyEnergyBalance(w, {'2026-08-13': 2200})!; + expect(b.days, 1); + expect(b.eaten, 2000); + expect(b.burned, 2200); + expect(b.balance, -200); + }); + + test('no day with both figures is an absence, not a zero balance', () { + final w = NutritionWindow([ + _complete('2026-08-14', 2300, today), + rollupDay(today, [_entry(today, 500, 8)], today: today), + ]); + expect(weeklyEnergyBalance(w, {today: 621}), isNull); + expect(weeklyEnergyBalance(w, const {}), isNull); + }); +} From 15367a3a72ee8ac5388bb25ddf1b4de734959bc6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:33:39 +0530 Subject: [PATCH 09/53] stop adding a medication by an existing name from wiping it typing a name that already existed in "add a medication" built a fresh definition and replaced the stored one, so adding an evening ibuprofen dropped the morning and afternoon times and blanked the dose, unit, kind and note. the screen now adds the picked time to the existing medication, matching by label as well so an older key is not duplicated. (cherry picked from commit eb9342e7363b9325f5e9b3a49492303194dbd51e) --- lib/data/med_store.dart | 47 ++++++++++++++ lib/ui2/screens/wellness_screen.dart | 14 ++-- test/med_add_existing_name_test.dart | 95 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 test/med_add_existing_name_test.dart diff --git a/lib/data/med_store.dart b/lib/data/med_store.dart index 83b777407..13d828cff 100644 --- a/lib/data/med_store.dart +++ b/lib/data/med_store.dart @@ -309,6 +309,53 @@ class MedDb { }, conflictAlgorithm: ConflictAlgorithm.replace); } + /// Add one scheduled time to the medication called [name], creating it if + /// there is none. + /// + /// The Wellness screen's "Add a medication" used to build a fresh [MedDef] + /// and hand it to [putDef]. [keyFor] is deterministic, so typing an existing + /// name to add an evening dose REPLACED that medication: its other times of + /// day left the schedule (and adherence), and its dose, unit, kind and note + /// were blanked, with no warning. Matched by label too, because a med saved + /// before [keyFor] kept non-ASCII letters has an older key and would + /// otherwise get a second row with the same name. + static Future addScheduledTime( + Database db, + String name, + MedSchedule slot, + ) async { + final label = name.trim(); + final key = keyFor(label); + final existing = (await defs(db, activeOnly: false)) + .where( + (d) => + d.key == key || + d.label.trim().toLowerCase() == label.toLowerCase(), + ) + .firstOrNull; + // Same time of day twice is one entry, the newer day-set winning. + final schedule = [ + ...?existing?.schedule.where((e) => e.minuteOfDay != slot.minuteOfDay), + slot, + ]..sort((x, y) => x.minuteOfDay.compareTo(y.minuteOfDay)); + await putDef( + db, + MedDef( + key: existing?.key ?? key, + label: existing?.label ?? label, + doseValue: existing?.doseValue, + doseUnit: existing?.doseUnit ?? '', + kind: existing?.kind ?? 'medication', + schedule: schedule, + note: existing?.note ?? '', + // Deliberately active: the screen lists only active medications, so + // adding a time to one that stayed inactive would appear to do nothing. + active: true, + createdAt: existing?.createdAt, + ), + ); + } + static Future deleteDef(Database db, String key) async { await db.delete('med_def', where: 'key = ?', whereArgs: [key]); // The dose history stays: those doses were still taken, and a re-added key diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index 95598976f..f57f83881 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -1060,15 +1060,13 @@ class _WellnessScreenState extends State with RevisionReload { // on screen, produced by the app rather than by the person. final picked = await pickMedSchedule(c); if (picked == null || !mounted) return; - await MedDb.putDef( + // A new medication's dose is left null — "as directed" is a real answer, + // and demanding a number to get a reminder is how a checklist stops being + // used. An existing one keeps its dose and gains this time. + await MedDb.addScheduledTime( await LocalDb.instance, - MedDef( - key: MedDb.keyFor(name), - label: name, - // Dose is left null — "as directed" is a real answer, and demanding a - // number to get a reminder is how a checklist stops being used. - schedule: [MedSchedule(picked.minuteOfDay, picked.days)], - ), + name, + MedSchedule(picked.minuteOfDay, picked.days), ); await _wrote(); } diff --git a/test/med_add_existing_name_test.dart b/test/med_add_existing_name_test.dart new file mode 100644 index 000000000..96bb8e557 --- /dev/null +++ b/test/med_add_existing_name_test.dart @@ -0,0 +1,95 @@ +// "Add a medication" with a name that already exists adds a time to it; it +// does not replace the medication. putDef is INSERT OR REPLACE, so a fresh +// def built from the name alone used to drop every other time of day and +// blank the dose, unit, kind and note. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/med_store.dart'; + +void main() { + late Database db; + + setUpAll(sqfliteFfiInit); + + setUp(() async { + db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + await createMedTables(db); + }); + + tearDown(() => db.close()); + + test('an existing name keeps its schedule, dose, kind and note', () async { + await MedDb.putDef( + db, + const MedDef( + key: 'custom_ibuprofen', + label: 'Ibuprofen', + doseValue: 400, + doseUnit: 'mg', + kind: 'supplement', + note: 'with food', + schedule: [MedSchedule(480, []), MedSchedule(840, [])], + ), + ); + final before = (await MedDb.defs(db)).single; + + await MedDb.addScheduledTime( + db, + ' ibuprofen ', + const MedSchedule(1320, []), + ); + + final after = (await MedDb.defs(db, activeOnly: false)).single; + expect(after.key, 'custom_ibuprofen'); + expect(after.label, 'Ibuprofen'); + expect([for (final s in after.schedule) s.minuteOfDay], [480, 840, 1320]); + expect(after.doseValue, 400); + expect(after.doseUnit, 'mg'); + expect(after.kind, 'supplement'); + expect(after.note, 'with food'); + expect(after.createdAt, before.createdAt); + }); + + test( + 'the same time of day again takes the new days, not a duplicate', + () async { + await MedDb.addScheduledTime(db, 'Vitamin D', const MedSchedule(480, [])); + await MedDb.addScheduledTime( + db, + 'Vitamin D', + const MedSchedule(480, [1, 4]), + ); + final d = (await MedDb.defs(db)).single; + expect(d.schedule.length, 1); + expect(d.schedule.single.days, [1, 4]); + }, + ); + + test('a legacy key is matched by label instead of duplicated', () async { + await MedDb.putDef( + db, + const MedDef( + key: 'custom_gr_ner_tee', + label: 'Grüner Tee', + doseValue: 1, + doseUnit: 'cup', + schedule: [MedSchedule(600, [])], + ), + ); + await MedDb.addScheduledTime(db, 'grüner tee', const MedSchedule(900, [])); + final all = await MedDb.defs(db, activeOnly: false); + expect(all.length, 1); + expect(all.single.key, 'custom_gr_ner_tee'); + expect([for (final s in all.single.schedule) s.minuteOfDay], [600, 900]); + expect(all.single.doseLabel, '1 cup'); + }); + + test('a new name is created as directed', () async { + await MedDb.addScheduledTime(db, 'Magnesium', const MedSchedule(1260, [])); + final d = (await MedDb.defs(db)).single; + expect(d.key, MedDb.keyFor('Magnesium')); + expect(d.doseLabel, 'As directed'); + expect(d.kind, 'medication'); + }); +} From 8a74687a109fb00d367048fc0d2d83c9e72679bb Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:36:02 +0530 Subject: [PATCH 10/53] stop concurrent journal writes on one day from erasing each other's fields every journal_metric write replaces the whole day, and each caller read the day, awaited, then wrote it back under its own private guard. a strap double-tap for water landing during a journal save (or the wellness tick and the nutrition + both alive, or two coach tool calls) could interleave read/read/write/write, and the second write deleted the first one's field while the strap still buzzed to confirm it. all of those callers now go through a new editJournalMetrics, which does the read, the edit and the write as one step on a single process-wide queue that postJournalMetrics also uses. (cherry picked from commit e16b0838fe7813eb96084a25b40e1c53389699d2) --- lib/coach/coach_actions.dart | 6 +- lib/data/local_repository.dart | 14 ++++ lib/data/local_repository_impl.dart | 30 ++++++++ lib/state/app_state.dart | 16 ++-- lib/ui2/screens/journal_compose.dart | 20 ++--- lib/ui2/screens/nutrition_screen.dart | 21 ++--- lib/ui2/screens/wellness_screen.dart | 14 ++-- ...journal_metric_concurrent_writes_test.dart | 77 +++++++++++++++++++ 8 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 test/journal_metric_concurrent_writes_test.dart diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart index 2700314b1..726336ffa 100644 --- a/lib/coach/coach_actions.dart +++ b/lib/coach/coach_actions.dart @@ -349,7 +349,7 @@ class CoachActions { } final known = {for (final f in await repo.getJournalFields()) f.key: f}; final at = str(a['time']).isEmpty ? null : minuteOfDay(a['time']); - final merged = {...await repo.getJournalMetrics(d)}; + final updates = {}; final written = []; for (final e in raw.entries) { final key = str(e.key); @@ -364,13 +364,13 @@ class CoachActions { if (v == null) { throw CoachActionError('Field "$key" needs a number.'); } - merged[key] = JournalMetricValue( + updates[key] = JournalMetricValue( v, atMinuteOfDay: spec.hasTime ? at : null, ); written.add(key); } - await repo.postJournalMetrics(d, merged); + await repo.editJournalMetrics(d, (day) => day.addAll(updates)); return jsonEncode({'saved': true, 'date': d, 'fields': written}); } diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index 212fcf96d..8c3a4e8a6 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -321,6 +321,20 @@ abstract class LocalRepository { Map fields, ) => throw UnimplementedError('re-layer: postJournalMetrics'); + /// Read one day's fields, let [edit] change that map in place, and write it + /// back as the whole day. Every caller that changes a field without owning + /// the whole day must come through here: a get followed by a separate post + /// is a read-modify-write, and two of them overlapping on one date lose the + /// first writer's field when the second replaces the day. + Future editJournalMetrics( + String date, + void Function(Map day) edit, + ) async { + final day = {...await getJournalMetrics(date)}; + edit(day); + await postJournalMetrics(date, day); + } + /// Built-in fields followed by the user's own, in editor order. Future> getJournalFields() => throw UnimplementedError('re-layer: getJournalFields'); diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index d691ef5dd..c596b66be 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -3714,10 +3714,40 @@ class LocalRepositoryImpl extends LocalRepository { Future> getJournalMetrics(String date) => LocalDb.journalMetricsForDay(date); + /// Tail of the journal_metric write queue. Static because the store is one + /// process-wide database and more than one repository instance can be alive + /// (the strap gesture, the screens and the coach each hold one). + static Future _journalMetricWrites = Future.value(); + + /// Run [body] after every earlier journal_metric write has finished. + /// `putJournalMetrics` deletes the day and re-inserts what it is handed, so + /// a read-modify-write that interleaves with another one on the same date + /// silently drops the other writer's field. + static Future _serialJournalMetrics(Future Function() body) { + final run = _journalMetricWrites.then((_) => body()); + _journalMetricWrites = run.then((_) {}, onError: (Object _) {}); + return run; + } + @override Future postJournalMetrics( String date, Map fields, + ) => _serialJournalMetrics(() => _putJournalMetricsClamped(date, fields)); + + @override + Future editJournalMetrics( + String date, + void Function(Map day) edit, + ) => _serialJournalMetrics(() async { + final day = {...await LocalDb.journalMetricsForDay(date)}; + edit(day); + await _putJournalMetricsClamped(date, day); + }); + + Future _putJournalMetricsClamped( + String date, + Map fields, ) async { // Clamp on the way in rather than trusting the editor. A value past the // field's ceiling is almost always a mis-tap, and a single 40-coffee day diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fe6267e37..f8d192475 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5405,13 +5405,15 @@ class AppState extends ChangeNotifier { final spec = kJournalFieldsByKey['water_ml']!; final date = todayLabel(); // Inside the try: the READ can throw too, and a guard set before it would - // stay set forever. Spread into a fresh map — postJournalMetrics rewrites - // the whole day from what it is handed. - final fields = {...await r.getJournalMetrics(date)}; - final now = fields['water_ml']?.value ?? 0; - fields['water_ml'] = - JournalMetricValue((now + spec.step).clamp(0, spec.max).toDouble()); - await r.postJournalMetrics(date, fields); + // stay set forever. Through editJournalMetrics, not get-then-post: the + // tap can land while a screen or the coach is writing the same day, and + // whichever replaced the day second would erase the other's field. + await r.editJournalMetrics(date, (fields) { + final now = fields['water_ml']?.value ?? 0; + fields['water_ml'] = JournalMetricValue( + (now + spec.step).clamp(0, spec.max).toDouble(), + ); + }); _log('[gesture] water logged (+${spec.step.round()} ${spec.unit})'); await HapticFeedback.mediumImpact(); } catch (e) { diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 9bd9eacbc..6c565f672 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -211,17 +211,17 @@ class _JournalComposeState extends State { // water, a moment tag) writes the same day while this screen is open. // Re-read and lay only this screen's own edits over it, or Save puts the // snapshot from when the screen opened back and the tap is lost. - final metrics = {...await repo.getJournalMetrics(_date)}; - for (final k in {..._loadedValues.keys, ..._values.keys}) { - final v = _values[k]; - if (v == _loadedValues[k]) continue; - if (v == null) { - metrics.remove(k); - } else { - metrics[k] = v; + await repo.editJournalMetrics(_date, (metrics) { + for (final k in {..._loadedValues.keys, ..._values.keys}) { + final v = _values[k]; + if (v == _loadedValues[k]) continue; + if (v == null) { + metrics.remove(k); + } else { + metrics[k] = v; + } } - } - await repo.postJournalMetrics(_date, metrics); + }); final tags = {}; for (final e in await repo.getJournal(range: '30d')) { if (e['date'] == _date) { diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index 363c0d8a0..87a9c2746 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -169,24 +169,25 @@ class _NutritionScreenState extends State with RevisionReload { // out is what "no answer today" looks like on disk — and spreading the // old map back in is exactly what made this un-clearable. final date = _date; - final fields = {...await repo.getJournalMetrics(date)}; - final v = fields.remove('water_ml')?.value; final spec = _waterSpec; double? next; - if (dir > 0) { - next = ((v ?? 0) + spec.step).clamp(0, spec.max).toDouble(); - } else { - final down = (v ?? 0) - spec.step; - next = down <= 0 ? (v == null || v == 0 ? null : 0.0) : down; - } + await repo.editJournalMetrics(date, (fields) { + final v = fields.remove('water_ml')?.value; + if (dir > 0) { + next = ((v ?? 0) + spec.step).clamp(0, spec.max).toDouble(); + } else { + final down = (v ?? 0) - spec.step; + next = down <= 0 ? (v == null || v == 0 ? null : 0.0) : down; + } + final n = next; + if (n != null) fields['water_ml'] = JournalMetricValue(n); + }); if (mounted) { setState(() { _waterMl = next; _waterDate = date; }); } - if (next != null) fields['water_ml'] = JournalMetricValue(next); - await repo.postJournalMetrics(date, fields); await _load(); } finally { // Cleared unconditionally; the setState is only for the repaint. Gating diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index f57f83881..f5ab67764 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -444,13 +444,13 @@ class _WellnessScreenState extends State with RevisionReload { // field written since. Open Wellness, go and write your journal from the // compose screen, come back without the tab reloading, tick one habit, // and the journal entry was gone. - final next = {...await repo.getJournalMetrics(_date)}; - if (v == null) { - next.remove(key); - } else { - next[key] = JournalMetricValue(v); - } - await repo.postJournalMetrics(_date, next); + await repo.editJournalMetrics(_date, (next) { + if (v == null) { + next.remove(key); + } else { + next[key] = JournalMetricValue(v); + } + }); await _wrote(); } finally { if (mounted) setState(() => _writingField = false); diff --git a/test/journal_metric_concurrent_writes_test.dart b/test/journal_metric_concurrent_writes_test.dart new file mode 100644 index 000000000..5434736ef --- /dev/null +++ b/test/journal_metric_concurrent_writes_test.dart @@ -0,0 +1,77 @@ +// Every journal_metric write replaces the whole day, so two callers that each +// read the day and write it back on the same date used to interleave +// read/read/write/write, and the second write erased the first one's field. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_actions.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'journal_metric_concurrent_writes_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + tearDownAll(() => LocalDb.close()); + + test('two coach writes on one day both survive', () async { + final repo = LocalRepositoryImpl(getProfileMap: () => const {}); + const date = '2026-10-16'; + await Future.wait([ + CoachActions.logJournalFields(repo, { + 'date': date, + 'fields': {'water_ml': 250}, + }), + CoachActions.logJournalFields(repo, { + 'date': date, + 'fields': {'mood': 4}, + }), + ]); + final day = await LocalDb.journalMetricsForDay(date); + expect(day.keys.toSet(), {'water_ml', 'mood'}); + }); + + test('a water tap and a journal save on one day both survive, across ' + 'separate repository instances', () async { + final gestureRepo = LocalRepositoryImpl(getProfileMap: () => const {}); + final screenRepo = LocalRepositoryImpl(getProfileMap: () => const {}); + const date = '2026-10-17'; + final spec = kJournalFieldsByKey['water_ml']!; + await Future.wait([ + gestureRepo.editJournalMetrics(date, (day) { + final now = day['water_ml']?.value ?? 0; + day['water_ml'] = JournalMetricValue(now + spec.step); + }), + screenRepo.editJournalMetrics(date, (day) { + day['mood'] = const JournalMetricValue(4); + }), + gestureRepo.editJournalMetrics(date, (day) { + final now = day['water_ml']?.value ?? 0; + day['water_ml'] = JournalMetricValue(now + spec.step); + }), + ]); + final day = await LocalDb.journalMetricsForDay(date); + expect(day.keys.toSet(), {'water_ml', 'mood'}); + expect(day['water_ml']!.value, spec.step * 2); + }); + + test('a throwing edit does not wedge later writes', () async { + final repo = LocalRepositoryImpl(getProfileMap: () => const {}); + const date = '2026-10-15'; + await expectLater( + repo.editJournalMetrics(date, (_) => throw StateError('boom')), + throwsStateError, + ); + await repo.editJournalMetrics(date, (day) { + day['mood'] = const JournalMetricValue(3); + }); + final day = await LocalDb.journalMetricsForDay(date); + expect(day.keys, ['mood']); + }); +} From 85391ba1fdb0c1968f8af67ca9e22c484091cb6b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:40:19 +0530 Subject: [PATCH 11/53] changing a medication's time made every past dose read as missed the dose rows stayed keyed at the old minute while every day, past ones included, is resolved against the current schedule, so moving an 08:00 dose to 08:30 turned 7/7 adherence into 0/7. the slot's recorded doses now move with it when its time changes; a row already recorded at the new time on the same day is kept, and nothing moves when another slot already sits at the picked minute. (cherry picked from commit 5c0fe70932277489a46a25d74025f0c27adf03b5) --- lib/data/med_store.dart | 22 +++++++++ lib/ui2/screens/wellness_screen.dart | 13 ++++- test/med_retime_test.dart | 74 ++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/med_retime_test.dart diff --git a/lib/data/med_store.dart b/lib/data/med_store.dart index 13d828cff..e3c198691 100644 --- a/lib/data/med_store.dart +++ b/lib/data/med_store.dart @@ -356,6 +356,28 @@ class MedDb { ); } + /// Moves a slot's recorded doses with the slot when its time changes. + /// + /// [slotsForDay] resolves every day, past ones included, against the def's + /// CURRENT schedule and looks doses up by minute. Left keyed at the old + /// minute, every dose taken before a retime became unreachable and each of + /// those days read the new time as missed: moving an 08:00 dose to 08:30 + /// turned 7/7 adherence into 0/7. A day that already has a row at [toMin] + /// keeps it (OR IGNORE) — that one was recorded against the new time. + static Future retimeSlot( + Database db, { + required String medKey, + required int fromMin, + required int toMin, + }) async { + if (fromMin == toMin) return; + await db.rawUpdate( + 'UPDATE OR IGNORE med_dose SET slot_min = ? ' + 'WHERE med_key = ? AND slot_min = ?', + [toMin, medKey, fromMin], + ); + } + static Future deleteDef(Database db, String key) async { await db.delete('med_def', where: 'key = ?', whereArgs: [key]); // The dose history stays: those doses were still taken, and a re-added key diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index f5ab67764..a06af8f18 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -1007,8 +1007,9 @@ class _WellnessScreenState extends State with RevisionReload { for (final sch in s.def.schedule) if (sch.minuteOfDay != s.slotMin) sch, ]; + final db = await LocalDb.instance; await MedDb.putDef( - await LocalDb.instance, + db, MedDef( key: s.def.key, label: s.def.label, @@ -1021,6 +1022,16 @@ class _WellnessScreenState extends State with RevisionReload { schedule: [...rest, MedSchedule(picked.minuteOfDay, picked.days)], ), ); + // Another slot already at the picked minute owns the doses there; merging + // this slot's history into it would count them twice. + if (!rest.any((sch) => sch.minuteOfDay == picked.minuteOfDay)) { + await MedDb.retimeSlot( + db, + medKey: s.def.key, + fromMin: s.slotMin, + toMin: picked.minuteOfDay, + ); + } await _wrote(); } diff --git a/test/med_retime_test.dart b/test/med_retime_test.dart new file mode 100644 index 000000000..e4dd536a7 --- /dev/null +++ b/test/med_retime_test.dart @@ -0,0 +1,74 @@ +// Changing a dose's time must carry its history with it. slotsForDay resolves +// past days against the CURRENT schedule and looks doses up by minute, so rows +// left at the old minute made every earlier dose read as missed. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/med_store.dart'; + +void main() { + setUpAll(sqfliteFfiInit); + + test('moving 08:00 to 08:30 keeps a week of taken doses taken', () async { + final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(db.close); + await createMedTables(db); + + final now = DateTime(2026, 10, 17, 12); + final created = DateTime(2026, 10, 1, 7).millisecondsSinceEpoch; + MedDef def(int min) => MedDef( + key: 'custom_vitamin_d', + label: 'Vitamin D', + schedule: [MedSchedule(min, const [])], + createdAt: created, + ); + await MedDb.putDef(db, def(480)); + for (var i = 6; i >= 0; i--) { + await MedDb.mark( + db, + medKey: 'custom_vitamin_d', + date: dayLabelOf(DateTime(now.year, now.month, now.day - i)), + slotMin: 480, + taken: true, + ); + } + // A row already recorded against the new time on one day wins over the + // moved one rather than tripping the primary key. + final clash = dayLabelOf(DateTime(now.year, now.month, now.day - 3)); + await MedDb.mark( + db, + medKey: 'custom_vitamin_d', + date: clash, + slotMin: 510, + taken: false, + skipped: true, + ); + expect(await MedDb.adherenceWindow(db, now: now), (taken: 7, of: 7)); + + await MedDb.putDef(db, def(510)); + await MedDb.retimeSlot( + db, + medKey: 'custom_vitamin_d', + fromMin: 480, + toMin: 510, + ); + + expect(await MedDb.adherenceWindow(db, now: now), (taken: 6, of: 7)); + final past = slotsForDay( + [def(510)], + dayLabelOf(DateTime(2026, 10, 15)), + await MedDb.dosesForDay(db, dayLabelOf(DateTime(2026, 10, 15))), + now: now, + ); + expect(past.single.slotMin, 510); + expect(past.single.state, DoseState.taken); + final clashDay = slotsForDay( + [def(510)], + clash, + await MedDb.dosesForDay(db, clash), + now: now, + ); + expect(clashDay.single.state, DoseState.skipped); + }); +} From 9911857e3e0fe8e50b3c63a474db9c698a7c3ba8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:39:16 +0530 Subject: [PATCH 12/53] restoring a backup overwrote edits made after it the restore merged journal, journal fields, labs, cycle symptoms and logs, sleep corrections, workouts and strength sets with a plain replace on their key, so an older backup silently reverted a rewritten note, a corrected sleep window or lab value, and turned a finished workout back into the live one a mid-workout backup caught. a day's journal fields and a session's sets were also patched row by row, so a field removed after the backup came back next to the newer ones. the newer side now keeps the key, judged by each table's own edit stamp; tables without one keep the local row. journal fields and strength sets are compared as whole sets, and a newer backup day replaces the local day's fields instead of splicing into them. (cherry picked from commit a9084311195234ae84539902c31a190ffda87ccb) --- lib/data/db.dart | 61 +++++++++ test/restore_keeps_newer_edits_test.dart | 161 +++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 test/restore_keeps_newer_edits_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index b3087f62e..65575fd45 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10180,6 +10180,22 @@ class LocalDb { 'substrate_archive', ]; + /// Restore tables whose rows the user edits in place, keyed the way the + /// app writes them, with the column that says when (null: none — the local + /// row wins). `journal_metric` and `strength_set` are keyed by their SET + /// (one day's fields, one session's sets), because the app rewrites each + /// as a whole and a row-by-row merge splices two versions together. + static const Map, String?)> _restoreEditedKeys = { + 'journal': (['date'], 'updated_at'), + 'journal_metric': (['date'], 'updated_at'), + 'lab_result': (['marker', 'taken_on'], 'updated_at'), + 'cycle_symptom': (['date'], 'updated_at'), + 'sleep_override': (['day_id'], 'created_at'), + 'cycle_log': (['date'], null), + 'sessions': (['id'], null), + 'strength_set': (['session_id'], null), + }; + @visibleForTesting static List get restoreTablesForTest => _restoreTables; @@ -10396,6 +10412,34 @@ class LocalDb { as String?, ).length : 0; + // Hand-entered rows the user may have edited since the backup was + // taken. A plain REPLACE handed every colliding key back to the + // backup's older copy: a rewritten journal note, a corrected lab + // value or sleep window, a finished workout turned back into the + // 'live' one a mid-workout backup caught. So the newer side keeps + // the key; a table with no edit stamp keeps the local row, and a + // restore only adds what this install has lost. + // + // Snapshotted BEFORE the table merges, so a key the backup fills in + // on page 1 does not read as "local" on page 2. + final edited = _restoreEditedKeys[t]; + final localEdits = {}; + String editKey(Map r) => + [for (final c in edited!.$1) '${r[c]}'].join('|'); + if (edited != null) { + final keys = edited.$1.join(', '); + final stamp = edited.$2 == null + ? '0' + : 'MAX(COALESCE(${edited.$2}, 0))'; + for (final r in await db.rawQuery( + 'SELECT $keys, $stamp AS f FROM $t GROUP BY $keys', + )) { + localEdits[editKey(r)] = (r['f'] as num?)?.toInt() ?? 0; + } + } + // Sets already replaced wholesale by a newer backup copy — the + // local set is cleared once, on the first row, never per page. + final replacedSets = {}; var copied = 0; var page = firstPage; // ONE TRANSACTION PER PAGE, not per table. The whole-table transaction @@ -10499,6 +10543,23 @@ class LocalDb { // A newer local correction has already answered this window. if (local.isNotEmpty) continue; } + if (edited != null) { + final key = editKey(row); + final mine = localEdits[key]; + if (mine != null) { + final stamp = edited.$2; + final theirs = + stamp == null ? null : (row[stamp] as num?)?.toInt(); + if (theirs == null || theirs <= mine) continue; + // The backup's day is newer, and a day's fields are a SET: + // patching it row by row would keep a field the backup's + // version had already removed. + if (t == 'journal_metric' && replacedSets.add(key)) { + batch.delete(t, where: 'date = ?', whereArgs: [row['date']]); + if (++ops >= chunkOps) await flush(); + } + } + } if (t == 'activity_suggestions') { final local = await txn.query(t, where: 'id = ?', whereArgs: [row['id']], limit: 1); if (local.isNotEmpty && (local.single['status'] != 'pending' || diff --git a/test/restore_keeps_newer_edits_test.dart b/test/restore_keeps_newer_edits_test.dart new file mode 100644 index 000000000..efea3f447 --- /dev/null +++ b/test/restore_keeps_newer_edits_test.dart @@ -0,0 +1,161 @@ +// Restoring an older backup must not hand the user's newer edits back to the +// backup's copies. Every hand-entered table used to merge with a plain +// INSERT OR REPLACE, so a journal note rewritten after the backup, a corrected +// sleep window or a finished workout all reverted on restore — and a day's +// journal fields were patched row by row, bringing back one the user removed. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; + +const _day = '2026-10-12'; +const _other = '2026-10-13'; + +Future _snapshot(String name) async { + final dir = await databaseFactory.getDatabasesPath(); + final path = p.join(dir, name); + if (File(path).existsSync()) File(path).deleteSync(); + await (await LocalDb.instance).execute("VACUUM INTO '$path'"); + return path; +} + +Future _clear() async { + final db = await LocalDb.instance; + for (final t in [ + 'journal', + 'journal_metric', + 'lab_result', + 'sleep_override', + 'cycle_symptom', + 'cycle_log', + 'sessions', + 'strength_set', + ]) { + await db.delete(t); + } +} + +Map _session(String status, int? endTs) => { + 'id': 's1', + 'start_ts': 100, + 'end_ts': endTs, + 'type': 'run', + 'status': status, + 'created_at': 1, + }; + +Map _set(int seq, int reps) => { + 'session_id': 's1', + 'seq': seq, + 'exercise_key': 'squat', + 'set_index': seq, + 'reps': reps, + }; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_restore_keeps_newer_edits_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + for (final f in [ + 'openstrap_restore_keeps_newer_edits_test.db', + 'monday.db', + 'newer.db', + ]) { + try { + File(p.join(dir, f)).deleteSync(); + } catch (_) {} + } + }); + + test('an older backup leaves edits made after it alone', () async { + await _clear(); + final db = await LocalDb.instance; + await db.insert('journal', + {'date': _day, 'tags_json': '[]', 'note': 'MONDAY', 'updated_at': 1000}); + await db.insert('journal_metric', + {'date': _day, 'field': 'caffeine', 'value': 3.0, 'updated_at': 1000}); + await db.insert('lab_result', { + 'marker': 'ldl', 'taken_on': _day, 'value': 9.0, 'unit': 'mg/dL', + 'updated_at': 1000, + }); + await db.insert('sleep_override', { + 'day_id': _day, 'onset_ts': 100, 'offset_ts': 200, 'source': 'manual', + 'created_at': 1000, + }); + await db.insert('cycle_symptom', + {'date': _day, 'symptoms_json': '["cramps"]', 'updated_at': 1000}); + await db.insert('cycle_log', {'date': _day, 'kind': 'start'}); + await db.insert('sessions', _session('live', null)); + for (var i = 0; i < 3; i++) { + await db.insert('strength_set', _set(i, 5)); + } + // Only in the backup: a restore still brings back what was lost. + await db.insert('journal', + {'date': _other, 'tags_json': '[]', 'note': 'LOST', 'updated_at': 900}); + final backup = await _snapshot('monday.db'); + + await db.delete('journal', where: 'date = ?', whereArgs: [_other]); + await db.update('journal', {'note': 'TUESDAY', 'updated_at': 2000}); + await db.delete('journal_metric'); + await db.insert('journal_metric', + {'date': _day, 'field': 'alcohol', 'value': 1.0, 'updated_at': 2000}); + await db.update('lab_result', {'value': 90.0, 'updated_at': 2000}); + await db.update('sleep_override', + {'onset_ts': 300, 'offset_ts': 400, 'created_at': 2000}); + await db.update('cycle_symptom', + {'symptoms_json': '["headache"]', 'updated_at': 2000}); + await db.update('cycle_log', {'kind': 'end'}); + await db.update('sessions', {'status': 'done', 'end_ts': 900}); + await db.delete('strength_set', where: 'seq = 2'); + + await LocalDb.importFromDbFile(backup); + + final journal = await db.query('journal', orderBy: 'date'); + expect([for (final r in journal) r['note']], ['TUESDAY', 'LOST']); + final fields = await db.query('journal_metric'); + expect([for (final r in fields) r['field']], ['alcohol']); + expect((await db.query('lab_result')).single['value'], 90.0); + expect((await db.query('sleep_override')).single['onset_ts'], 300); + expect((await db.query('cycle_symptom')).single['symptoms_json'], + '["headache"]'); + expect((await db.query('cycle_log')).single['kind'], 'end'); + expect((await db.query('sessions')).single['status'], 'done'); + expect(await db.query('strength_set'), hasLength(2)); + }); + + test('a backup newer than the local copy still wins, as a whole day', + () async { + await _clear(); + final db = await LocalDb.instance; + await db.insert('journal_metric', + {'date': _day, 'field': 'alcohol', 'value': 2.0, 'updated_at': 3000}); + await db.insert('sleep_override', { + 'day_id': _day, 'onset_ts': 500, 'offset_ts': 600, 'source': 'manual', + 'created_at': 3000, + }); + final backup = await _snapshot('newer.db'); + + // The local copy is rolled back to something older than the backup. + await db.delete('journal_metric'); + await db.insert('journal_metric', + {'date': _day, 'field': 'caffeine', 'value': 4.0, 'updated_at': 1000}); + await db.update('sleep_override', + {'onset_ts': 100, 'offset_ts': 200, 'created_at': 1000}); + + await LocalDb.importFromDbFile(backup); + + final fields = await db.query('journal_metric'); + expect([for (final r in fields) r['field']], ['alcohol']); + expect((await db.query('sleep_override')).single['onset_ts'], 500); + }); +} From 71797277a1f3aa52dc6b6fb79f822be487527475 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:37:37 +0530 Subject: [PATCH 13/53] freshness refresh pulled and decoded 30 day bundles on every derive pass refreshComputeFreshness runs after every derive, including the light pass after each drain, and it selected r.* for the last 30 days, so every payload_json crossed the channel. When today had no row yet it also never broke out of the loop, so all 30 bundles were decoded and curve-expanded on the ui isolate each time. it now reads the payload-free day list, fetches a bundle only for the rows it visits, and stops once it is past today. it also skips the diagnostics-only counts, and rawStats no longer counts decoded_onehz twice. (cherry picked from commit b68c9a97829d2b4d2873dc84e6e0f5fcf1fefd2d) --- lib/data/db.dart | 63 +++++++++++-- test/compute_freshness_meta_test.dart | 122 ++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 test/compute_freshness_meta_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 65575fd45..8333c5e47 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10848,11 +10848,7 @@ class LocalDb { final tsRow = (await db.rawQuery( 'SELECT MIN(rec_ts) AS lo, MAX(rec_ts) AS hi FROM decoded_onehz WHERE rec_ts > 0', )).first; - final decodedOneHz = - Sqflite.firstIntValue( - await db.rawQuery('SELECT COUNT(*) FROM decoded_onehz'), - ) ?? - 0; + final decodedOneHz = count; final decodedRr = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM decoded_rr'), @@ -11672,9 +11668,51 @@ class LocalDb { static String localDayLabelNow() => todayLabel(); + /// `payload_json` of the exact row [recentDayResultsMeta] served for + /// [dayId], or null. + static Future _servedPayloadJson( + String dayId, + Object? algoVersion, + ) async { + final db = await instance; + final rows = await db.rawQuery( + 'SELECT payload_json FROM day_result WHERE day_id = ? AND algo_version = ?', + [dayId, algoVersion], + ); + return rows.isEmpty ? null : rows.first['payload_json']; + } + + /// The three [rawStats] fields freshness reads. [rawStats] also counts + /// `samples` and takes MIN(rec_ts) for the diagnostics screen, which a + /// derive pass has no use for. + static Future> _freshnessRawStats() async { + final db = await instance; + final hi = (await db.rawQuery( + 'SELECT MAX(rec_ts) AS hi FROM decoded_onehz WHERE rec_ts > 0', + )).first['hi']; + final oneHz = + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM decoded_onehz'), + ) ?? + 0; + final rr = + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM decoded_rr'), + ) ?? + 0; + return { + 'max_rec_ts': (hi as num?)?.toInt(), + 'decoded_onehz': oneHz, + 'decoded_rr': rr, + }; + } + static Future refreshComputeFreshness() async { - final raw = await rawStats(); - final recent = await recentDayResults(30); + final raw = await _freshnessRawStats(); + // Payload-free: this runs at the end of every derive pass, including the + // light one after each BLE drain, on the UI isolate. The loop usually + // stops after a row or two, so each bundle is fetched only when visited. + final recent = await recentDayResultsMeta(30); final rolling = await baseline('rolling'); final cross = await baseline('crossday'); final today = localDayLabelNow(); @@ -11697,7 +11735,9 @@ class LocalDb { if (dayId == null || dayId.isEmpty) continue; if (dayId == today && todayRow == null) todayRow = row; final decoded = - SeriesCodec.decodePayloadJson(row['payload_json']) ?? + SeriesCodec.decodePayloadJson( + await _servedPayloadJson(dayId, row['algo_version']), + ) ?? const {}; if (decoded['skipped'] == true) continue; // #448: today's night is not today's overnight until the drain has @@ -11761,9 +11801,14 @@ class LocalDb { latestRecoveryDay = dayId; latestRecoveryComputedAt = (row['computed_at'] as num?)?.toInt(); } + // Rows run newest day first, so once one is older than today, today has + // no row and cannot turn up further down. Waiting for it walked and + // decoded all 30 bundles on every pass whenever today was not derived + // yet (just after midnight, a held day, a drain that only moved last + // night's tail). if (latestOvernightDay != null && latestRecoveryDay != null && - todayRow != null) { + (todayRow != null || dayId.compareTo(today) < 0)) { break; } } diff --git a/test/compute_freshness_meta_test.dart b/test/compute_freshness_meta_test.dart new file mode 100644 index 000000000..bee4435f6 --- /dev/null +++ b/test/compute_freshness_meta_test.dart @@ -0,0 +1,122 @@ +// refreshComputeFreshness runs after every derive pass, so it reads day rows +// payload-free and fetches a bundle only for the rows it visits. These pin +// what it reports when today has no row yet, the case that used to walk and +// decode all 30 bundles, and that it reads the SERVED version of each day. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + late Database db; + late String dir; + + setUp(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_compute_freshness_meta_test.db'; + dir = await databaseFactory.getDatabasesPath(); + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + db = await LocalDb.instance; + }); + + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + String daysAgo(int n) { + final d = DateTime.now().subtract(Duration(days: n)); + return '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + } + + Future put( + String day, { + int version = kAlgoVersion, + bool sleep = false, + num? readiness, + int computedAt = 1, + }) async { + await db.insert('day_result', { + 'day_id': day, + 'algo_version': version, + 'payload_json': jsonEncode({ + 'scalars': {'readiness': ?readiness}, + if (sleep) + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 6 * 3600}, + }, + }, + }), + 'computed_at': computedAt, + 'finalized': 1, + }); + } + + Future> today() async { + await LocalDb.refreshComputeFreshness(); + final row = await LocalDb.computeFreshness('today'); + return jsonDecode(row!['payload_json'] as String) as Map; + } + + test('no row for today: overnight and recovery come from older days', + () async { + expect(daysAgo(0), todayLabel()); + await put(daysAgo(1), sleep: true, computedAt: 11); + await put(daysAgo(2)); + await put(daysAgo(3), sleep: true, readiness: 64, computedAt: 33); + for (var i = 4; i < 30; i++) { + await put(daysAgo(i), sleep: true, readiness: 50); + } + final t = await today(); + expect(t['activity_state'], 'missing'); + expect(t['activity_day'], isNull); + expect(t['overnight_day'], daysAgo(1)); + expect(t['overnight_computed_at'], 11); + expect(t['recovery_day'], daysAgo(3)); + expect(t['recovery_computed_at'], 33); + expect(t['recovery_state'], isNull); + expect(t['showing_prior_overnight'], isTrue); + }); + + test('reads the served version of a day, not a newer unserved one', + () async { + await put(daysAgo(1), sleep: true, readiness: 70, computedAt: 5); + // A row above the served ceiling must not be the one whose bundle is read. + await db.insert('day_result', { + 'day_id': daysAgo(1), + 'algo_version': 1 << 30, + 'payload_json': jsonEncode({'skipped': true}), + 'computed_at': 99, + 'finalized': 1, + }); + final t = await today(); + expect(t['overnight_day'], daysAgo(1)); + expect(t['recovery_day'], daysAgo(1)); + expect(t['recovery_computed_at'], 5); + }); + + test('capture still reports the band edge and the decoded counts', () async { + await db.insert('decoded_onehz', { + 'ts_ms': 1700000000 * 1000, + 'rec_ts': 1700000000, + 'counter': 1, + 'hr': 60, + }); + await LocalDb.refreshComputeFreshness(); + final row = await LocalDb.computeFreshness('capture'); + final c = jsonDecode(row!['payload_json'] as String); + expect(c['latest_raw_rec_ts'], 1700000000); + expect(c['decoded_onehz'], 1); + expect(c['decoded_rr'], 0); + }); +} From a932bceefaa551ca3ad0838f3fb599c07c24d436 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:43:57 +0530 Subject: [PATCH 14/53] stop a restored backup's sensors from filling the sensor cap restoring a backup on a new phone brought each paired sensor's row back with the old install's bluetooth handle. those rows could never connect, and since a sensor's id is minted from that handle, re-pairing the same strap wrote a new row beside the dead one. two restored sensors filled the cap and every pair was refused until each was forgotten by hand. a restore now keeps the sensor row (so its history still has a name) but drops the handle, leaves a row this phone already has untouched, and the cap no longer counts a row with no handle. (cherry picked from commit 81126f33025964ee3b293b1033f1c343fe4aed6e) --- lib/data/db.dart | 18 +++++ lib/ui2/profile/devices.dart | 10 ++- test/restore_sensor_handle_test.dart | 108 +++++++++++++++++++++++++++ test/sensor_cap_test.dart | 4 +- 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 test/restore_sensor_handle_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 8333c5e47..b8b182f5f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10624,6 +10624,24 @@ class LocalDb { } continue; } + // A SENSOR'S HANDLE IS NOT PORTABLE EITHER, for the same + // reason, but its row is: it names the `device_id` its + // history carries. So the row comes across WITHOUT the + // `remote_id`. Kept verbatim, it would never connect, and + // because a sensor's id is minted from that handle + // (`HrsLink.mintDeviceId`) re-pairing the same strap here + // writes a NEW row instead of overwriting it — two such rows + // filled the sensor cap and refused every pair until each was + // forgotten by hand. Every link already refuses to arm a row + // with no handle, and `sensorCapReached` does not count one. + // A row this phone already has is its own live pairing and + // stays untouched: replacing it would blank a working handle. + if (t == 'device') { + final local = await txn.query('device', + columns: ['id'], where: 'id = ?', whereArgs: [row['id']]); + if (local.isNotEmpty) continue; + row['remote_id'] = null; + } if (t == 'sync_cursor') { final name = '${row['name']}'; if (localCursors.containsKey(name)) { diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 48c4be5fc..970971b0b 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1293,8 +1293,14 @@ final List<({BandEntry entry, String blurb, Future Function(BluetoothDe /// row and is counted. bool sensorCapReached(Iterable> deviceRows, {String? repairingId, String? adapterId, bool anyOfAdapter = false}) { - final secondary = - deviceRows.where((r) => r['id'] != LocalDb.kPrimaryDeviceId).toList(); + // A row with no `remote_id` holds no link — a backup restore brings a + // sensor's row across without the old phone's handle — so it cannot take + // a slot, and counting it would refuse the very re-pair that replaces it. + final secondary = deviceRows + .where((r) => + r['id'] != LocalDb.kPrimaryDeviceId && + ((r['remote_id'] as String?) ?? '').isNotEmpty) + .toList(); final sameAdapterRewrites = adapterId != null && (anyOfAdapter || adapterId == kOura.id); for (final r in secondary) { diff --git a/test/restore_sensor_handle_test.dart b/test/restore_sensor_handle_test.dart new file mode 100644 index 000000000..cd133bc3a --- /dev/null +++ b/test/restore_sensor_handle_test.dart @@ -0,0 +1,108 @@ +// A backup restored onto a new phone must not bring back the old install's +// sensor handles. A sensor's `remote_id` is a per-install peripheral handle, +// and its row id is minted from it, so a verbatim copy is a row that never +// connects, is never overwritten by re-pairing the same strap, and still +// counts toward the sensor cap: two of them refused every pair. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart' show sensorCapReached; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const oldDb = 'openstrap_restore_sensor_old_test.db'; + const newDb = 'openstrap_restore_sensor_new_test.db'; + const oldStrap = '11111111-AAAA-4000-8000-000000000001'; + const oldPolar = '11111111-AAAA-4000-8000-000000000002'; + late String backup; + + Future fresh(String name) async { + await LocalDb.close(); + LocalDb.dbName = name; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, name)); + } + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + SharedPreferences.setMockInitialValues({}); + await fresh(oldDb); + await LocalDb.upsertDevice(remoteId: 'OLD-WHOOP-UUID'); + await LocalDb.upsertDevice( + id: HrsLink.mintDeviceId(kBleHrs, oldStrap), + adapterId: kBleHrs.id, + remoteId: oldStrap, + label: 'Chest strap'); + await LocalDb.upsertDevice( + id: HrsLink.mintDeviceId(kPolarPmd, oldPolar), + adapterId: kPolarPmd.id, + remoteId: oldPolar, + label: 'Polar'); + final tmp = await Directory.systemTemp.createTemp('restore_sensor'); + backup = await LocalDb.exportCopy(into: tmp); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + for (final n in [oldDb, newDb]) { + await databaseFactory.deleteDatabase(p.join(dir, n)); + } + }); + + test('a new-phone restore keeps sensor rows but not their old handles', + () async { + await fresh(newDb); + await LocalDb.importFromDbFile(backup); + final rows = await LocalDb.deviceRows(); + expect(rows.where((r) => r['id'] == LocalDb.kPrimaryDeviceId), isEmpty); + expect(rows.map((r) => r['id']).toSet(), { + HrsLink.mintDeviceId(kBleHrs, oldStrap), + HrsLink.mintDeviceId(kPolarPmd, oldPolar), + }); + expect(rows.map((r) => r['label']).toSet(), {'Chest strap', 'Polar'}); + expect(rows.every((r) => r['remote_id'] == null), isTrue); + + // Re-pairing the same strap: this phone sees a different peripheral id. + final newId = HrsLink.mintDeviceId( + kBleHrs, '22222222-BBBB-4000-8000-000000000001'); + expect(sensorCapReached(rows, repairingId: newId, adapterId: kBleHrs.id), + isFalse); + expect(sensorCapReached(rows), isFalse); + }); + + test('a restore never blanks a sensor this phone already has paired', + () async { + await fresh(newDb); + final id = HrsLink.mintDeviceId(kBleHrs, oldStrap); + await LocalDb.upsertDevice( + id: id, adapterId: kBleHrs.id, remoteId: oldStrap, label: 'Mine'); + await LocalDb.importFromDbFile(backup); + final row = await LocalDb.deviceRow(id); + expect(row?['remote_id'], oldStrap); + expect(row?['label'], 'Mine'); + }); + + test('handle-less rows do not count toward the cap; live ones still do', () { + final live = [ + {'id': 'ble_hrs-1', 'adapter_id': kBleHrs.id, 'remote_id': 'A'}, + {'id': 'polar_pmd-2', 'adapter_id': kPolarPmd.id, 'remote_id': 'B'}, + ]; + expect(sensorCapReached(live), isTrue); + expect( + sensorCapReached([ + ...live.take(1), + {'id': 'polar_pmd-2', 'adapter_id': kPolarPmd.id, 'remote_id': null}, + ]), + isFalse); + }); +} diff --git a/test/sensor_cap_test.dart b/test/sensor_cap_test.dart index 17901546b..9b64d9c36 100644 --- a/test/sensor_cap_test.dart +++ b/test/sensor_cap_test.dart @@ -20,7 +20,7 @@ List> _rows(int secondaries, [ if (band) {'id': LocalDb.kPrimaryDeviceId, 'adapter_id': null}, for (var i = 0; i < secondaries; i++) - {'id': 'hrs:$i', 'adapter_id': adapter}, + {'id': 'hrs:$i', 'adapter_id': adapter, 'remote_id': 'R-$i'}, ]; const _limitText = 'This phone will pair at most $kMaxConcurrentSecondaryLinks ' @@ -56,7 +56,7 @@ void main() { test('the ring has one row, so any ring pair rewrites it', () { final rows = [ ..._rows(kMaxConcurrentSecondaryLinks - 1), - {'id': 'oura-1', 'adapter_id': kOura.id}, + {'id': 'oura-1', 'adapter_id': kOura.id, 'remote_id': 'R-oura'}, ]; expect(sensorCapReached(rows, adapterId: kOura.id), isFalse); // A strap of the same kind is not single-slot: a new one is a new row. From 078b64d34d966af9c1726ffaac306ee9711df6a3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:01:31 +0530 Subject: [PATCH 15/53] delete everything left coach chats on disk the coach saves each conversation as a json file in the documents folder, not in the database, so the reset's database wipe never reached them. the storage key is always 'local', so after a reset the coach reopened the old conversation, health tool results and all. the reset now deletes every coach session and index file as well. (cherry picked from commit 8c7e859dfdefb2f9033c68c722bed6c5cf63b041) --- lib/coach/coach_engine.dart | 23 +++++++++++++++ lib/state/app_state.dart | 8 ++++++ test/coach_reset_delete_test.dart | 47 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 test/coach_reset_delete_test.dart diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index fed12ddbd..910a11600 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -321,6 +321,29 @@ class CoachEngine { Future _indexFile() async => File('${(await _dir()).path}/coach_idx_$storageKey.json'); String _newId() => '${DateTime.now().millisecondsSinceEpoch}'; + /// Deletes every saved chat for every storage key. The transcripts live as + /// files beside the database, not in it, so wiping the database leaves + /// them, and the key is the same 'local' after a reset — the next profile + /// would reopen the previous one's conversations, tool results included. + /// Returns the number of files that could not be deleted. + static Future deleteAllSavedSessions() async { + final dir = await getApplicationDocumentsDirectory(); + if (!await dir.exists()) return 0; + var failed = 0; + await for (final e in dir.list(followLinks: false)) { + if (e is! File) continue; + final name = e.uri.pathSegments.last; + if (!name.endsWith('.json')) continue; + if (!name.startsWith('coach_s_') && !name.startsWith('coach_idx_')) continue; + try { + await e.delete(); + } catch (_) { + failed++; + } + } + return failed; + } + /// All saved sessions for this user, most recent first. Future> listSessions() async { try { diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index f8d192475..fd6fedfd3 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -28,6 +28,7 @@ import '../ai/briefing.dart'; import '../ai/briefing_engine.dart'; import '../ai/nightly_sweep.dart'; import '../coach/coach_config.dart'; +import '../coach/coach_engine.dart'; import '../models/app_status.dart'; import '../ble/accessory_setup.dart'; import '../ble/adapters/_registry.dart' show kBleHrs, kPolarPmd, kWhoopGen4; @@ -1342,6 +1343,13 @@ class AppState extends ChangeNotifier { } catch (e) { _log('[reset] keychain clear failed: $e'); } + // Coach chats are files, not rows, so wipeAll does not reach them. + try { + final left = await CoachEngine.deleteAllSavedSessions(); + if (left > 0) _log('[reset] $left coach chat files not deleted'); + } catch (e) { + _log('[reset] coach chat delete failed: $e'); + } // The ring's and Mi Band's 16-byte pairing keys. iOS keeps keychain // items across an uninstall, and a ring still holding the old key needs // a factory reset before it pairs again. A delete of a key that was diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart new file mode 100644 index 000000000..05ae2a066 --- /dev/null +++ b/test/coach_reset_delete_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; + +class _DocumentsProvider extends PathProviderPlatform { + _DocumentsProvider(this.root); + final String root; + + @override + Future getApplicationDocumentsPath() async => root; +} + +void main() { + test('deleteAllSavedSessions removes every coach chat file and nothing else', + () async { + final dir = await Directory.systemTemp.createTemp('coach-reset-'); + final original = PathProviderPlatform.instance; + PathProviderPlatform.instance = _DocumentsProvider(dir.path); + addTearDown(() async { + PathProviderPlatform.instance = original; + await dir.delete(recursive: true); + }); + + final chats = [ + 'coach_s_local_1700000000000.json', + 'coach_s_local_1700000000001.json', + 'coach_idx_local.json', + 'coach_s_other_1.json', + 'coach_idx_other.json', + ]; + final keep = ['edge_sync.log', 'coach_notes.txt', 'backup.json']; + for (final n in [...chats, ...keep]) { + await File('${dir.path}/$n').writeAsString('{}'); + } + + expect(await CoachEngine.deleteAllSavedSessions(), 0); + + for (final n in chats) { + expect(await File('${dir.path}/$n').exists(), isFalse, reason: n); + } + for (final n in keep) { + expect(await File('${dir.path}/$n').exists(), isTrue, reason: n); + } + }); +} From 884631f93371d503eb65c1f030e4d91b18f014a3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:03:00 +0530 Subject: [PATCH 16/53] ask before deleting a sent issue report each row under my reports is one big tap target titled with the report id, so tapping it to read or copy the id deleted the ticket on the server and the only local copy of its delete token, with no way back. the tap now goes through the same remove confirmation the rest of the app uses. (cherry picked from commit 6ff34286133d123a70bc708ff71365dd0734a079) --- lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 13 +++++++++++++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_hi.arb | 2 ++ lib/l10n/app_ru.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/ui2/profile/report_issue.dart | 8 ++++++++ test/issue_report_test.dart | 24 ++++++++++++++++++++++++ 9 files changed, 57 insertions(+) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 66ffc582c..bc0850c40 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2788,5 +2788,7 @@ "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "reportDeleteConfirmTitle": "Bericht {id} löschen?", + "reportDeleteConfirmBody": "Er wird sofort vom Server entfernt und lässt sich danach nicht mehr über seine ID öffnen. Das lässt sich nicht rückgängig machen.", "profileImplausibleFields": "Bitte {fields} prüfen. Der Wert liegt außerhalb des gültigen Bereichs, daher wurde nichts gespeichert." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5bd7280e1..a242c734a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15737,6 +15737,19 @@ "@reportDeleteFailed": { "description": "Issue report: delete failed." }, + "reportDeleteConfirmTitle": "Delete report {id}?", + "@reportDeleteConfirmTitle": { + "description": "Issue report: confirm before deleting a sent report.", + "placeholders": { + "id": { + "type": "String" + } + } + }, + "reportDeleteConfirmBody": "It's removed from the server now, so it can't be opened from its ID any more. There's no undo.", + "@reportDeleteConfirmBody": { + "description": "Issue report: what deleting a sent report does." + }, "reportSaveFailedTitle": "Couldn't share the report", "@reportSaveFailedTitle": { "description": "Report screen: title of the card shown when building or sharing the local zip failed. Nothing was sent." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index aaff820f4..ce865efb4 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2692,5 +2692,7 @@ "dataReanalyzeFailed": "El reanálisis falló: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "reportDeleteConfirmTitle": "¿Eliminar el informe {id}?", + "reportDeleteConfirmBody": "Se borra del servidor ahora mismo y ya no se podrá abrir con su ID. No se puede deshacer.", "profileImplausibleFields": "Revisa {fields}. El valor está fuera de rango, así que no se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 380d19caf..1646b4633 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2663,5 +2663,7 @@ "dataReanalyzeFailed": "La réanalyse a échoué : {error}", "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "reportDeleteConfirmTitle": "Supprimer le rapport {id} ?", + "reportDeleteConfirmBody": "Il est retiré du serveur tout de suite et ne pourra plus être ouvert avec son identifiant. Impossible d'annuler.", "profileImplausibleFields": "Vérifiez {fields}. La valeur est hors limites, rien n'a donc été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f890be0f0..e1b59672c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2663,5 +2663,7 @@ "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "reportDeleteConfirmTitle": "रिपोर्ट {id} हटाएँ?", + "reportDeleteConfirmBody": "यह अभी सर्वर से हट जाएगी और इसके ID से फिर नहीं खुलेगी। इसे वापस नहीं लाया जा सकता।", "profileImplausibleFields": "{fields} जाँचें। मान सीमा से बाहर है, इसलिए कुछ भी सहेजा नहीं गया।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 070186263..d73a9525d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7494,6 +7494,8 @@ }, "reportDelete": "Удалить", "reportDeleteFailed": "Не удалось удалить. Попробуйте позже.", + "reportDeleteConfirmTitle": "Удалить сообщение {id}?", + "reportDeleteConfirmBody": "Оно сразу удаляется с сервера, и открыть его по ID больше не получится. Отменить нельзя.", "reportSaveFailedTitle": "Не удалось поделиться сообщением", "reportSaveFailedBody": "Не удалось собрать или передать файл. Тикет не создан.", "reportSaveRetry": "Повторить", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 49d1d6de9..3cc71437d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2663,5 +2663,7 @@ "dataReanalyzeFailed": "重新分析失败:{error}", "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "reportDeleteConfirmTitle": "删除报告 {id}?", + "reportDeleteConfirmBody": "它会立即从服务器上删除,之后无法再通过 ID 打开。此操作无法撤销。", "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。" } diff --git a/lib/ui2/profile/report_issue.dart b/lib/ui2/profile/report_issue.dart index e8ccdb233..9a87263cb 100644 --- a/lib/ui2/profile/report_issue.dart +++ b/lib/ui2/profile/report_issue.dart @@ -174,6 +174,14 @@ class _ReportIssueScreenState extends State { final l = _l(context); final messenger = ScaffoldMessenger.of(context); if (_busy) return; + // The row is the whole tap target and its title is the ID people come + // here to copy, while the delete drops the ticket on the server and the + // only copy of its delete token, so one stray tap had no way back. + final ok = await confirmRemove(context, + title: l.reportDeleteConfirmTitle(r.id), + body: l.reportDeleteConfirmBody, + remove: l.reportDelete); + if (!ok || !mounted || _busy) return; setState(() => _busy = true); try { await deleteReport(r); diff --git a/test/issue_report_test.dart b/test/issue_report_test.dart index 23eafbcc6..536fff250 100644 --- a/test/issue_report_test.dart +++ b/test/issue_report_test.dart @@ -218,6 +218,30 @@ void main() { expect(find.text(noUpload), findsNothing); }); + testWidgets('tapping a sent report asks before deleting it', (tester) async { + final exp = DateTime.now().add(const Duration(days: 7)).toUtc(); + SharedPreferences.setMockInitialValues({ + 'issue_reports_sent': jsonEncode([ + {'id': 'r-abc123', 'deleteToken': 't', 'expiresAt': exp.toIso8601String()} + ]), + }); + tester.view.physicalSize = const Size(390 * 3, 3000 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: const ReportIssueScreen())); + await tester.pumpAndSettle(); + await tester.tap(find.text('r-abc123')); + await tester.pumpAndSettle(); + expect(find.text('Delete report r-abc123?'), findsOneWidget); + await tester.tap(find.text('Keep it')); + await tester.pumpAndSettle(); + expect(find.text('Delete report r-abc123?'), findsNothing); + expect(find.text('r-abc123'), findsOneWidget); + expect((await loadSentReports()).map((r) => r.id), ['r-abc123']); + }); + test('local path has no size cap: a gzip past the upload limit builds a zip', () async { final dir = await Directory.systemTemp.createTemp('gz'); From ed3522884fe4ac63b4b1be2422503c199d55000f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:02:42 +0530 Subject: [PATCH 17/53] delete everything left plaintext copies of the database in the cache "share locally" on an issue report never deleted its zip, and with the full database attached that zip is the whole health record. those now keep only the newest two, like manual exports. delete everything also removes every copy the app staged for a share sheet (database exports, csv runs, report archives and their intermediates) instead of leaving them for the os to purge whenever. (cherry picked from commit 935be8a26d1c3a29b8fba2de70de6f5839544834) --- lib/data/export_retention.dart | 62 +++++++++++++++++++++++++++++++ lib/state/app_state.dart | 4 ++ lib/ui2/profile/report_issue.dart | 2 + test/export_retention_test.dart | 48 ++++++++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/lib/data/export_retention.dart b/lib/data/export_retention.dart index 1d15af90c..9c6f98065 100644 --- a/lib/data/export_retention.dart +++ b/lib/data/export_retention.dart @@ -79,3 +79,65 @@ Future pruneLegacyRootExports( // Housekeeping only. } } + +final _reportName = RegExp(r'^openstrap_report_(\d+)\.zip$'); + +/// Delete all but the [keep] newest issue-report archives in [tmpRoot]. +/// +/// "Share locally" hands the zip to the share sheet and, like a manual +/// export, never hears back, so the zip was never deleted — and one built +/// with the full database is a complete plaintext copy of the health record. +/// The previous one is kept in case its share sheet is still reading it. +Future pruneSharedReports( + Directory tmpRoot, { + int keep = kSharedExportsKept, +}) async { + try { + final byStamp = {}; + for (final f in tmpRoot.listSync().whereType()) { + final m = _reportName.firstMatch(p.basename(f.path)); + if (m != null) byStamp[int.parse(m.group(1)!)] = f; + } + final stamps = byStamp.keys.toList()..sort((a, b) => b.compareTo(a)); + for (final s in stamps.skip(keep)) { + try { + await byStamp[s]!.delete(); + } catch (_) {} + } + } catch (_) { + // Housekeeping only — a failure here must never fail the share. + } +} + +final _healthCopyName = RegExp( + r'^openstrap_(export_\d+\.db(\.osbk)?|report_\d+(\.zip|\.db\.gz)|slice_\d+\.db)$'); + +/// Delete every copy of the health record the app has handed to a share +/// sheet or staged in [tmpRoot]: manual exports, CSV runs, issue-report +/// archives and their intermediates. +/// +/// For "Delete everything". The retention sweeps above keep the newest +/// couple on purpose, and the OS purges the cache only when it likes, so a +/// reset that wiped the database still left whole plaintext copies of it +/// behind. Best effort: one undeletable file must not fail the reset. +Future purgeSharedHealthCopies([Directory? tmpRoot]) async { + try { + tmpRoot ??= await getTemporaryDirectory(); + } catch (_) { + return; + } + for (final name in const ['db_share', 'openstrap_csv']) { + try { + final d = Directory(p.join(tmpRoot.path, name)); + if (d.existsSync()) await d.delete(recursive: true); + } catch (_) {} + } + try { + for (final f in tmpRoot.listSync().whereType()) { + if (!_healthCopyName.hasMatch(p.basename(f.path))) continue; + try { + await f.delete(); + } catch (_) {} + } + } catch (_) {} +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fd6fedfd3..d92035cb0 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -90,6 +90,7 @@ import 'prefs.dart'; import '../ble/adapters/signals.dart' show InputSignal; import '../ui2/profile/devices.dart' show liveSources, rankSources; import '../data/db.dart'; +import '../data/export_retention.dart' show purgeSharedHealthCopies; import '../ecg/ble_ecg_transport.dart'; import '../ecg/ecg_controller.dart'; import '../ecg/ecg_guard_store.dart'; @@ -1334,6 +1335,9 @@ class AppState extends ChangeNotifier { // 2 · every row in every table (see LocalDb.wipeAll for why it is not a // hand-written table list, and for the sync_cursor decision). await LocalDb.wipeAll(); + // The copies already handed to share sheets (exports, CSVs, issue + // reports) are the same records in plaintext, sitting in the cache. + await purgeSharedHealthCopies(); // Surfaces outside the database that were still showing it. await NotificationService.instance.cancelAll(); diff --git a/lib/ui2/profile/report_issue.dart b/lib/ui2/profile/report_issue.dart index 9a87263cb..b0155a697 100644 --- a/lib/ui2/profile/report_issue.dart +++ b/lib/ui2/profile/report_issue.dart @@ -14,6 +14,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; +import '../../data/export_retention.dart' show pruneSharedReports; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; import '../../support/issue_report.dart'; @@ -149,6 +150,7 @@ class _ReportIssueScreenState extends State { try { final r = await collectReport(context.read(), _draft(), onStage: (st) => _setStep(_stageText(l, st))); + await pruneSharedReports(r.zip.parent); _setStep(l.reportStageSharing); await Share.shareXFiles([XFile(r.zip.path)], subject: 'OpenStrap issue report', sharePositionOrigin: origin); diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart index 516aa2bae..81c5ea565 100644 --- a/test/export_retention_test.dart +++ b/test/export_retention_test.dart @@ -135,4 +135,52 @@ void main() { ); }); }); + + group('pruneSharedReports', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('osr_')); + tearDown(() async => dir.delete(recursive: true)); + + test('keeps the two newest locally shared report archives', () async { + for (final us in [100, 300, 200, 400]) { + await _touch(dir, 'openstrap_report_$us.zip'); + } + await _touch(dir, 'openstrap_report_50.db.gz'); + await _touch(dir, 'notes.txt'); + await pruneSharedReports(dir); + expect(_names(dir), [ + 'notes.txt', + 'openstrap_report_300.zip', + 'openstrap_report_400.zip', + 'openstrap_report_50.db.gz', + ]); + }); + }); + + group('purgeSharedHealthCopies', () { + late Directory dir; + setUp(() async => dir = await Directory.systemTemp.createTemp('osp_')); + tearDown(() async => dir.delete(recursive: true)); + + test('removes every shared or staged copy, nothing else', () async { + final share = Directory(p.join(dir.path, 'db_share'))..createSync(); + await _touch(share, 'openstrap_export_2.db'); + final csv = Directory(p.join(dir.path, 'openstrap_csv', '9')) + ..createSync(recursive: true); + await _touch(csv, 'heart_rate.csv'); + Directory(p.join(dir.path, 'osm_tiles')).createSync(); + for (final n in [ + 'openstrap_export_1.db', + 'openstrap_export_1.db.osbk', + 'openstrap_report_7.zip', + 'openstrap_report_8.db.gz', + 'openstrap_slice_9.db', + 'unrelated.db', + ]) { + await _touch(dir, n); + } + await purgeSharedHealthCopies(dir); + expect(_names(dir), ['osm_tiles', 'unrelated.db']); + }); + }); } From ecc7cab80ececebcb03eafae3d5fc0758355c46e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:05:34 +0530 Subject: [PATCH 18/53] reject self-referencing ctes in the coach sql guard the guard only banned the RECURSIVE keyword, but sqlite treats any cte that reads itself in its own body as recursive with or without it. so a query the guard had just refused could be resent without the keyword and run as an unbounded generator; layer 2 never sees the working table, and the 10s timeout's close() queues behind the still-running statement, so run_sql (and on android every other db call) stalled for good. I now reject a cte whose own body names it in a table position. (cherry picked from commit 4e3112f5e6cb20d20471efc51b493f97f7802a5c) --- lib/coach/coach_db.dart | 42 ++++++++++++++++++++++ test/coach_sql_guard_adversarial_test.dart | 29 +++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/coach/coach_db.dart b/lib/coach/coach_db.dart index 1fc024dc9..7d968dac0 100644 --- a/lib/coach/coach_db.dart +++ b/lib/coach/coach_db.dart @@ -214,6 +214,17 @@ class CoachDb { final refs = _tableRefs(toks, allowed: {...allowedViews, ...cte}); if (refs.isEmpty) throw SqlGuardError('No table reference found.'); + // (h2) RECURSIVE is optional in SQLite: a CTE that reads itself in its own + // body recurses whether or not the keyword is there, and its working + // table is invisible to layer 2. Without this, dropping the keyword + // from a rejected query gets an unbounded generator past both layers, + // and the read-only handle can't be closed until the statement ends. + final selfRef = _selfReferencingCte(toks, cte); + if (selfRef != null) { + throw SqlGuardError( + 'Recursive CTEs are not allowed: "$selfRef" reads from itself.'); + } + // (i) Auto-append a LIMIT to protect context. if (!RegExp(r'\blimit\b', caseSensitive: false).hasMatch(lower)) { body = '$body LIMIT $rowCap'; @@ -327,6 +338,37 @@ class CoachDb { return names; } + /// The first CTE whose own `AS (...)` body has its name in a table position, + /// or null. Table positions come from [_tableRefs] run over the body alone, + /// so a column that merely shares the CTE's name doesn't count. + static String? _selfReferencingCte(List<_Tok> toks, Set cte) { + for (var i = 1; i < toks.length; i++) { + final prev = toks[i - 1]; + final t = toks[i]; + if (!t.ident || !cte.contains(t.text)) continue; + final opensCte = (prev.ident && prev.text == 'with') || + (!prev.ident && prev.text == ','); + if (!opensCte) continue; + var j = i + 1; + if (j < toks.length && !toks[j].ident && toks[j].text == '(') { + j = _skipParens(toks, j); + } + if (j >= toks.length || !toks[j].ident || toks[j].text != 'as') continue; + var k = j + 1; + while (k < toks.length && + toks[k].ident && + (toks[k].text == 'not' || toks[k].text == 'materialized')) { + k++; + } + if (k >= toks.length || toks[k].ident || toks[k].text != '(') continue; + final end = _skipParens(toks, k); + final body = toks.sublist(k + 1, end > k + 1 ? end - 1 : k + 1); + final refs = _tableRefs(body, allowed: {...allowedViews, ...cte}); + if (refs.contains(t.text)) return t.text; + } + return null; + } + /// Index just past the balanced ')' that matches the '(' at [open]. static int _skipParens(List<_Tok> toks, int open) { var depth = 0; diff --git a/test/coach_sql_guard_adversarial_test.dart b/test/coach_sql_guard_adversarial_test.dart index 8eba0ac75..6759f0eb5 100644 --- a/test/coach_sql_guard_adversarial_test.dart +++ b/test/coach_sql_guard_adversarial_test.dart @@ -126,8 +126,37 @@ void main() { 'recursive statement with a non-recursive sibling CTE': 'WITH RECURSIVE a AS (SELECT 1 x), b(n) AS (SELECT 1 UNION ALL ' 'SELECT n+1 FROM b) SELECT * FROM a, b', + // RECURSIVE is optional in SQLite: a CTE that names itself in its own + // body recurses anyway, so dropping the keyword must not get past. + 'self-referencing CTE without the RECURSIVE keyword': + 'WITH x(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM x) ' + 'SELECT count(*) FROM x JOIN v_daily ON 1=0', + 'unbounded date generator, termination only in the outer WHERE': + "WITH d(day) AS (SELECT '2026-01-01' UNION ALL " + "SELECT date(day,'+1 day') FROM d) SELECT * FROM d " + "JOIN v_daily ON v_daily.date=d.day WHERE d.day<'2026-02-01'", + 'self-reference via JOIN inside the CTE body': + 'WITH x(n) AS (SELECT 1 UNION ALL SELECT x.n+1 FROM v_daily ' + 'JOIN x ON 1=1) SELECT * FROM x', + 'self-reference in a comma table list, second CTE': + 'WITH a AS (SELECT 1 n), b(n) AS (SELECT 1 UNION ALL ' + 'SELECT b.n+1 FROM a, b) SELECT * FROM b', }; + test('a CTE reading an EARLIER sibling CTE is still allowed', () { + expect( + CoachDb.guardAndPrepare('WITH a AS (SELECT date d FROM v_daily), ' + 'b AS (SELECT d FROM a) SELECT * FROM b'), + contains('LIMIT')); + }); + + test('a column sharing the CTE name is not mistaken for recursion', () { + expect( + CoachDb.guardAndPrepare( + 'WITH d AS (SELECT date AS d FROM v_daily) SELECT d FROM d'), + contains('LIMIT')); + }); + test('DELIBERATE TRADE-OFF: "recursive" is unusable as a CTE/alias name', () { // The RECURSIVE ban is a blanket token match, not position-sensitive — // it also rejects the (legal, non-recursive) use of "recursive" as an From 0dc623683185fcf45b1b4469a321fe764b535e3e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:04:58 +0530 Subject: [PATCH 19/53] coach journal logging no longer wipes the day's existing entry the coach's log_journal wrote only the tags and note from the current message, and postJournal replaces the whole row for that date, so logging a coffee through the coach erased every tag and note already logged for the day. it now reads the stored day and merges into it with mergeJournalEntry, the same way the compose screen does. (cherry picked from commit 0e703a2983f3c2981cefd88e596de29f2f0d7e2d) --- lib/coach/coach_engine.dart | 21 +++++++++++++-- test/coach_engine_date_validation_test.dart | 30 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 910a11600..9134f6990 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -18,6 +18,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; +import '../ai/journal_ai.dart' show mergeJournalEntry; import '../data/day_label.dart'; import '../data/db.dart'; import '../data/local_repository.dart'; @@ -881,8 +882,24 @@ class CoachEngine { summary: 'Add journal for $journalDate: tags ${args['tags'] ?? []}, note "${args['note'] ?? ''}".', args: args, ), () async { - await api.postJournal(journalDate, - ((args['tags'] as List?) ?? const []).map((e) => '$e').toList(), '${args['note'] ?? ''}'); + // postJournal replaces the day's whole row, so the stored day is + // read and merged first — otherwise "log that I had coffee" wipes + // every tag and note already logged for that day. + Map? row; + for (final e in await api.getJournal(range: 'all')) { + if (e['date'] == journalDate) row = e; + } + final merged = mergeJournalEntry( + existingTags: ((row?['tags'] as List?) ?? const []) + .map((e) => '$e') + .toList(), + existingNote: (row?['note'] as String?) ?? '', + newTags: ((args['tags'] as List?) ?? const []) + .map((e) => '$e') + .toList(), + newNote: '${args['note'] ?? ''}', + ); + await api.postJournal(journalDate, merged.tags, merged.note); return 'Journal saved.'; }); case 'log_period': diff --git a/test/coach_engine_date_validation_test.dart b/test/coach_engine_date_validation_test.dart index 4fd12984f..bdb02b596 100644 --- a/test/coach_engine_date_validation_test.dart +++ b/test/coach_engine_date_validation_test.dart @@ -19,11 +19,20 @@ import 'package:openstrap_edge/data/local_repository.dart'; class _FakeRepo extends LocalRepository { String? journalDate; + List? journalTags; + String? journalNote; String? cycleDate; + final List> journal = []; + + @override + Future>> getJournal({String range = '30d'}) async => + journal; @override Future postJournal(String date, List tags, String note) async { journalDate = date; + journalTags = tags; + journalNote = note; } @override @@ -103,6 +112,27 @@ void main() { final toolResult = engine.debugHistory.firstWhere((m) => m['role'] == 'tool'); expect(toolResult['content'], contains('is not a day')); }); + + // postJournal replaces the day's whole row, so writing only what this + // utterance carried erased the tags and note already logged that day. + test('merges into what the day already holds', () async { + final repo = _FakeRepo() + ..journal.addAll([ + {'date': '2026-08-13', 'tags': ['other-day'], 'note': 'not this one'}, + { + 'date': '2026-08-14', + 'tags': ['alcohol', 'late_meal'], + 'note': 'dinner out', + }, + ]); + await run('log_journal', { + 'date': '2026-08-14', + 'tags': ['caffeine', 'alcohol'], + 'note': 'coffee in the afternoon', + }, repo); + expect(repo.journalTags, ['alcohol', 'late_meal', 'caffeine']); + expect(repo.journalNote, 'dinner out\ncoffee in the afternoon'); + }); }); group('log_period', () { From 24956689c8a0b4d010deaf8db41c190303010dc6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:06:40 +0530 Subject: [PATCH 20/53] coach history trim could delete the question mid-turn on the chat completions path a turn with several large run_sql results could outgrow the history budget by itself, and the trim walked past the user's question and sent the provider a bare system prompt. the current turn is now never dropped: older turns go first, then this turn's oldest tool results are replaced with a stub (keeping their tool_call_id), and if it still doesn't fit it fails with the same size-limit error the responses path already gives. (cherry picked from commit 7d839f8b3eabfc7b67a8362c448a766097fd90be) --- lib/coach/coach_engine.dart | 38 +++++++++++++++++ test/coach_history_trim_test.dart | 69 ++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 9134f6990..22b9873ea 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -257,6 +257,11 @@ class CoachEngine { /// Max characters of running history resent on each turn. static const int kMaxHistoryChars = 120000; + /// What an over-budget tool result in the current turn is replaced with. + static const String _kDroppedToolResult = + '(earlier result dropped to fit the conversation size limit — ' + 're-run the query if it is still needed)'; + /// Hard ceiling on one serialized provider request body. static const int kMaxRequestBytes = 400 * 1024; @@ -297,6 +302,39 @@ class CoachEngine { } return; } + // The current turn starts at the LAST user message and is never dropped: + // a tool-heavy turn (a handful of 16k run_sql results, each re-escaped by + // jsonEncode) can outgrow the budget by itself, and walking the history + // down past the question sent the provider a bare system prompt — the + // model then answered "how can I help?" or the provider 400'd on a request + // with no user message, and the question was gone from the saved session. + if (_history.any((m) => m['role'] == 'user')) { + // Whole older turns go first, oldest first. The search from index 1 + // stops at or before the current question, so it is never removed. + while (_historyChars() > kMaxHistoryChars && + _history.lastIndexWhere((m) => m['role'] == 'user') > 0) { + _history.removeRange( + 0, _history.indexWhere((m) => m['role'] == 'user', 1)); + } + // Still over with only the current turn left: replace this turn's + // oldest tool results with a stub. The message itself stays so its + // tool_call_id still pairs with the assistant turn that requested it. + for (var i = 0; + i < _history.length && _historyChars() > kMaxHistoryChars; + i++) { + final m = _history[i]; + if (m['role'] == 'tool' && m['content'] != _kDroppedToolResult) { + _history[i] = {...m, 'content': _kDroppedToolResult}; + } + } + if (_historyChars() > kMaxHistoryChars) { + throw CoachException( + 'This turn exceeded the conversation size limit. ' + 'Start a new chat or ask a narrower question.', + ); + } + return; + } while (_historyChars() > kMaxHistoryChars && _history.length > 1) { _history.removeAt(0); while (_history.length > 1 && _history.first['role'] != 'user') { diff --git a/test/coach_history_trim_test.dart b/test/coach_history_trim_test.dart index 1f18fd996..214d02e5e 100644 --- a/test/coach_history_trim_test.dart +++ b/test/coach_history_trim_test.dart @@ -11,6 +11,8 @@ // turn larger than the whole byte budget walked the history down to exactly one // element and left it there — and if that survivor was the `tool` half of a // pair, the very orphan the method exists to prevent was what got sent. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/coach/coach_config.dart'; import 'package:openstrap_edge/coach/coach_engine.dart'; @@ -61,11 +63,11 @@ void main() { {'role': 'assistant', 'content': 'You slept well.'}, ]); - engine.debugTrimHistory(); - - if (engine.debugHistory.isNotEmpty) { - expect(engine.debugHistory.first['role'], isNot('tool')); - } + // The current turn is never dropped, and an assistant message alone + // bigger than the budget cannot be shrunk, so this fails loudly + // instead of sending a request that has lost the question. + expect(engine.debugTrimHistory, throwsA(isA())); + expect(engine.debugHistory.first['role'], 'user'); }); test('a history already under the ceiling is left completely alone', () { @@ -103,4 +105,61 @@ void main() { expect(engine.debugHistory.first['role'], 'user'); }); }); + + group('CoachEngine history trimming keeps the current question', () { + // One clipped run_sql result: 16k of JSON whose quotes jsonEncode escapes + // again when the history is measured. + Map sqlResult(int i) => { + 'role': 'tool', + 'tool_call_id': 'c$i', + 'name': 'run_sql', + 'content': '{"r":"${'"' * 2000}${'x' * 14000}"}', + }; + List> toolHeavyTurn(String q, int calls) => [ + {'role': 'user', 'content': q}, + for (var i = 0; i < calls; i++) ...[ + { + 'role': 'assistant', + 'content': '', + 'tool_calls': [ + {'id': 'c$i', 'type': 'function'} + ], + }, + sqlResult(i), + ], + ]; + + test('a tool-heavy turn over budget keeps its question and its pairing', + () { + engine.debugHistory + .addAll(toolHeavyTurn('compare sleep, hrv and strain', 8)); + + engine.debugTrimHistory(); + + final h = engine.debugHistory; + expect(h.first, + containsPair('content', 'compare sleep, hrv and strain')); + expect(h.where((m) => m['role'] == 'tool'), hasLength(8)); + expect(h.where((m) => m['role'] == 'assistant'), hasLength(8)); + // The newest result is the one the model is about to read. + expect(h.last['content'], sqlResult(7)['content']); + final encoded = h.fold(0, (n, m) => n + jsonEncode(m).length); + expect(encoded, lessThanOrEqualTo(CoachEngine.kMaxHistoryChars)); + }); + + test('older turns are dropped before the current turn is touched', () { + engine.debugHistory + ..addAll(toolHeavyTurn('old question', 4)) + ..add({'role': 'assistant', 'content': 'old answer'}) + ..addAll(toolHeavyTurn('new question', 4)); + + engine.debugTrimHistory(); + + final h = engine.debugHistory; + expect(h.first, containsPair('content', 'new question')); + expect(h.where((m) => m['role'] == 'tool'), hasLength(4)); + expect(h.where((m) => m['role'] == 'tool').map((m) => m['content']), + everyElement(startsWith('{"r":'))); + }); + }); } From ff78bca65e81fd971a195546c2ed4db9381c82b8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:07:29 +0530 Subject: [PATCH 21/53] deleting a report left its github issue up the tracker issue gets the report's title and full description in plain text, but deleting the report and the 90-day expiry only scrubbed the d1 row and the blob, so the issue stayed forever. the worker now overwrites and closes the issue on delete and when the row reaches 90 days. a failed attempt keeps the issue url so the cron retries it, and an expired row is held until its issue is gone, since the row is the only pointer to it. (cherry picked from commit 86c21061d0f74d9a8ac6401cf5bdbbf2d70e5287) --- server/report-worker/README.md | 1 + server/report-worker/src/index.ts | 62 +++++++++++++++++++++--- server/report-worker/test/worker.test.ts | 51 ++++++++++++++++++- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/server/report-worker/README.md b/server/report-worker/README.md index 0143b715f..fa4233c94 100644 --- a/server/report-worker/README.md +++ b/server/report-worker/README.md @@ -19,6 +19,7 @@ Cloudflare Worker behind "Report an issue" in the app. Contract: `~/.openstrap-r | Blob without DB | deleted after 30 days | | Blob with DB | deleted after 14 days | | Metadata row | deleted after 90 days | +| GitHub issue | title and body overwritten and the issue closed when the user deletes the report or the row reaches 90 days (the token cannot delete issues, so the old text stays in the issue's edit history, visible only to repo collaborators); a failed attempt is retried by the cron and the row is kept until it succeeds | | Blob size | 5 MB, or 95 MB when `hasDatabase` (the free plan caps request bodies at 100 MB) | | Rate limit | 3 reports per install id per UTC day (user-deleted reports still count), and 1000 per UTC day across all installs, since install ids are chosen by the client | | Storage ceiling | DB reports get `507 storage_full` above 8 GB stored; every report gets it above 9 GB, so R2 never leaves the free 10 GB | diff --git a/server/report-worker/src/index.ts b/server/report-worker/src/index.ts index ff5f5d467..edc7bcd06 100644 --- a/server/report-worker/src/index.ts +++ b/server/report-worker/src/index.ts @@ -110,6 +110,32 @@ async function createIssue(env: Env, id: string, m: Meta): Promise { + if (!env.GITHUB_TOKEN) return false; + const m = issueUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)$/); + if (!m) return true; // nothing we could ever reach; do not hold the row for it + try { + const res = await fetch(`https://api.github.com/repos/${m[1]}/issues/${m[2]}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", + "user-agent": "openstrap-reports", "content-type": "application/json", + }, + body: JSON.stringify({ title: `[${id}] removed`, body: "Removed: deleted by the reporter or past retention.", state: "closed" }), + signal: AbortSignal.timeout(5000), + }); + return res.ok || res.status === 404 || res.status === 410; + } catch { + return false; + } +} + /** X-Report-Meta: base64url(JSON). Null when missing, oversized or invalid. */ function decodeMetaHeader(h: string | null): Meta | null { if (!h || h.length > LIMITS.metaHeader) return null; @@ -225,18 +251,21 @@ async function postReport(req: Request, env: Env): Promise { async function deleteReport(req: Request, env: Env, id: string): Promise { const token = req.headers.get("x-delete-token") ?? ""; - const row = await env.DB.prepare("SELECT delete_token_hash, blob_key FROM reports WHERE id = ?") - .bind(id).first<{ delete_token_hash: string; blob_key: string | null }>(); + const row = await env.DB.prepare("SELECT delete_token_hash, blob_key, github_issue_url FROM reports WHERE id = ?") + .bind(id).first<{ delete_token_hash: string; blob_key: string | null; github_issue_url: string | null }>(); if (!row || !row.delete_token_hash) return err(404, "not_found"); if (!(await safeEqual(hex(await sha256(token)), row.delete_token_hash))) return err(403, "forbidden"); if (row.blob_key) await env.BLOBS.delete(row.blob_key); + // A failed retraction keeps the URL so the cron retries it; the user's token is spent either way. + const retracted = !row.github_issue_url || (await retractIssue(env, id, row.github_issue_url)); // Scrub but keep the row so the per-day rate limit still counts it; the cron removes it at 90 days. // Stats first: it reads the row's blob_bytes before the scrub zeroes it (a repeat delete subtracts 0). await env.DB.batch([ env.DB.prepare("UPDATE stats SET v = v - (SELECT blob_bytes FROM reports WHERE id = ?) WHERE k = 'bytes'").bind(id), env.DB.prepare( - `UPDATE reports SET blob_key=NULL, blob_bytes=0, title='', description='', meta='{}', delete_token_hash='' WHERE id = ?`) - .bind(id), + `UPDATE reports SET blob_key=NULL, blob_bytes=0, title='', description='', meta='{}', delete_token_hash='', + github_issue_url = CASE WHEN ? THEN NULL ELSE github_issue_url END WHERE id = ?`) + .bind(retracted ? 1 : 0, id), ]); return new Response(null, { status: 204 }); } @@ -267,7 +296,7 @@ async function admin(req: Request, env: Env, url: URL): Promise { return err(404, "not_found"); } -export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: number; rows: number }> { +export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: number; rows: number; issues: number }> { const cut = (days: number) => new Date(now.getTime() - days * DAY).toISOString(); const { results } = await env.DB.prepare( `SELECT id, blob_key FROM reports WHERE blob_key IS NOT NULL AND @@ -280,13 +309,30 @@ export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: numb env.DB.prepare("UPDATE reports SET blob_key=NULL, blob_bytes=0 WHERE id = ?").bind(r.id), ]); } + // Issues of user-deleted reports whose retraction failed, and of rows reaching 90 days. Capped per run to + // stay inside the per-invocation subrequest limit; the rest go on the next run. + let issues = 0; + if (env.GITHUB_TOKEN) { + const { results: pending } = await env.DB.prepare( + `SELECT id, github_issue_url FROM reports WHERE github_issue_url IS NOT NULL AND (delete_token_hash = '' OR created_at < ?) + ORDER BY created_at LIMIT 20`) + .bind(cut(LIMITS.rowTtlDays)).all<{ id: string; github_issue_url: string }>(); + for (const r of pending) { + if (!(await retractIssue(env, r.id, r.github_issue_url))) continue; + await env.DB.prepare("UPDATE reports SET github_issue_url = NULL WHERE id = ?").bind(r.id).run(); + issues++; + } + } + // A row whose issue is still up stays until the issue is gone: the row holds the only pointer to it. + // Without a token nothing can retract it, so the row goes on schedule. + const expired = env.GITHUB_TOKEN ? "created_at < ? AND github_issue_url IS NULL" : "created_at < ?"; // Rows past retention should already be blob-free; subtract any stragglers so the counter cannot drift. const [, rows] = await env.DB.batch([ - env.DB.prepare("UPDATE stats SET v = v - COALESCE((SELECT SUM(blob_bytes) FROM reports WHERE created_at < ?), 0) WHERE k = 'bytes'").bind(cut(LIMITS.rowTtlDays)), - env.DB.prepare("DELETE FROM reports WHERE created_at < ?").bind(cut(LIMITS.rowTtlDays)), + env.DB.prepare(`UPDATE stats SET v = v - COALESCE((SELECT SUM(blob_bytes) FROM reports WHERE ${expired}), 0) WHERE k = 'bytes'`).bind(cut(LIMITS.rowTtlDays)), + env.DB.prepare(`DELETE FROM reports WHERE ${expired}`).bind(cut(LIMITS.rowTtlDays)), env.DB.prepare("DELETE FROM stats WHERE k LIKE 'day:%' AND k < ?").bind("day:" + cut(2).slice(0, 10)), ]); - return { blobs: results.length, rows: rows.meta.changes ?? 0 }; + return { blobs: results.length, rows: rows.meta.changes ?? 0, issues }; } export default { diff --git a/server/report-worker/test/worker.test.ts b/server/report-worker/test/worker.test.ts index a2ef9abab..69ceacfcd 100644 --- a/server/report-worker/test/worker.test.ts +++ b/server/report-worker/test/worker.test.ts @@ -237,6 +237,38 @@ describe("DELETE /v1/reports/:id", () => { }); it("unknown id 404", async () => expect((await del("OS-ZZZZZZ", "x")).status).toBe(404)); + + it("blanks and closes the GitHub issue; a failed attempt is left for the cron", async () => { + e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; + const issue = (n: number) => new Response(JSON.stringify({ html_url: `https://github.com/OpenStrap/reports/issues/${n}` }), { status: 201 }); + const spy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(issue(7)) + .mockResolvedValueOnce(new Response("{}", { status: 200 })); + const a = (await (await post(meta({ description: "started new meds" }))).json()) as any; + expect((await del(a.id, a.deleteToken)).status).toBe(204); + const [url, init] = spy.mock.calls[1] as [string, RequestInit]; + expect(url).toBe("https://api.github.com/repos/OpenStrap/reports/issues/7"); + expect(init.method).toBe("PATCH"); + const patch = JSON.parse(init.body as string); + expect(patch.state).toBe("closed"); + expect(JSON.stringify(patch)).not.toContain("meds"); + expect(JSON.stringify(patch)).not.toContain("Sync stuck"); + const rowA = await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(a.id).first(); + expect(rowA.u).toBeNull(); + + spy.mockResolvedValueOnce(issue(8)).mockRejectedValueOnce(new Error("down")); + const b = (await (await post(meta())).json()) as any; + expect((await del(b.id, b.deleteToken)).status).toBe(204); + const rowB = await e.DB.prepare("SELECT github_issue_url u, description d FROM reports WHERE id=?").bind(b.id).first(); + expect(rowB.d).toBe(""); + expect(rowB.u).toContain("/issues/8"); + + spy.mockResolvedValueOnce(new Response("{}", { status: 200 })); + expect((await cleanup(e)).issues).toBe(1); + expect((spy.mock.calls.at(-1) as [string])[0]).toBe("https://api.github.com/repos/OpenStrap/reports/issues/8"); + expect((await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(b.id).first()).u).toBeNull(); + spy.mockRestore(); + }); }); describe("admin + health", () => { @@ -294,7 +326,7 @@ describe("cleanup cron", () => { await e.DB.prepare(`INSERT INTO reports (id,install_id,created_at,category,title,description,meta,blob_bytes,has_db,delete_token_hash) VALUES ('OS-OLD001','i',?,'sync','t','d','{}',0,0,'h')`).bind(ago(91)).run(); const r = await cleanup(e); - expect(r).toEqual({ blobs: 2, rows: 1 }); + expect(r).toEqual({ blobs: 2, rows: 1, issues: 0 }); expect(await e.BLOBS.head("db/OS-DB0001")).toBeNull(); expect(await e.BLOBS.head("db/OS-DB0002")).not.toBeNull(); expect(await e.BLOBS.head("logs/OS-LG0001")).toBeNull(); @@ -315,6 +347,23 @@ describe("cleanup cron", () => { expect(await stat(today())).toBe(7); }); + it("retracts the issue of a row reaching 90 days and keeps the row until that succeeds", async () => { + e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; + await e.DB.prepare(`INSERT INTO reports (id,install_id,created_at,category,title,description,meta,blob_bytes,has_db,delete_token_hash,github_issue_url) + VALUES ('OS-OLD003','i',?,'sync','t','private detail','{}',0,0,'h','https://github.com/OpenStrap/reports/issues/9')`) + .bind(new Date(Date.now() - 91 * 864e5).toISOString()).run(); + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("{}", { status: 502 })); + expect(await cleanup(e)).toEqual({ blobs: 0, rows: 0, issues: 0 }); + expect(await e.DB.prepare("SELECT id FROM reports WHERE id='OS-OLD003'").first()).not.toBeNull(); + spy.mockResolvedValueOnce(new Response("{}", { status: 200 })); + expect(await cleanup(e)).toEqual({ blobs: 0, rows: 1, issues: 1 }); + const [url, init] = spy.mock.calls[1] as [string, RequestInit]; + expect(url).toBe("https://api.github.com/repos/OpenStrap/reports/issues/9"); + expect(init.method).toBe("PATCH"); + expect(await e.DB.prepare("SELECT id FROM reports WHERE id='OS-OLD003'").first()).toBeNull(); + spy.mockRestore(); + }); + it("concurrent posts cannot both pass the byte ceiling", async () => { await setStat("bytes", LIMITS.hardCeilingBytes - 300); const rs = await Promise.all([post(meta({ installId: "423e4567-e89b-42d3-a456-426614174000" })), post(meta({ installId: "523e4567-e89b-42d3-a456-426614174000" }))]); From 34550907d8fa8061804b2abe766f17a19264dd25 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:07:37 +0530 Subject: [PATCH 22/53] sweep old issue report files out of temp storage share locally keeps the newest two report zips, but a run the os killed mid-build left its .zip or .db.gz behind under a unique name every time, and with the full database each one is a plaintext copy of the whole health record. collectReport now deletes any openstrap_report_ file older than ten minutes before it builds a new one. the shared zip itself is still not deleted when the share sheet returns, because the target app can keep reading it after that. (cherry picked from commit 7511c1c23bd2872ce6bbe63ffc7f57a3ef577557) --- lib/support/issue_report.dart | 30 +++++++++++++++++++++++++++++- lib/ui2/profile/report_issue.dart | 3 +++ test/issue_report_test.dart | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/support/issue_report.dart b/lib/support/issue_report.dart index b0430d318..351fba304 100644 --- a/lib/support/issue_report.dart +++ b/lib/support/issue_report.dart @@ -203,8 +203,35 @@ Future gzipFile(String path, String to) async { return File(to); } +const _kReportPrefix = 'openstrap_report_'; + String _tmp(String dir, String ext) => - '$dir/openstrap_report_${DateTime.now().microsecondsSinceEpoch}$ext'; + '$dir/$_kReportPrefix${DateTime.now().microsecondsSinceEpoch}$ext'; + +/// Delete report files left in [dir] by earlier runs: a Share locally whose +/// cleanup never ran, or a run the OS killed mid-build. Each run writes a new +/// name, and with the full database each one is a plaintext copy of the whole +/// health record, so without this they pile up in the cache dir. Files newer +/// than [olderThan] are left alone in case another build is still using them. +Future sweepStaleReports(String dir, + {Duration olderThan = const Duration(minutes: 10), DateTime? now}) async { + final cutoff = (now ?? DateTime.now()).subtract(olderThan); + var n = 0; + try { + await for (final e in Directory(dir).list(followLinks: false)) { + if (e is! File) continue; + final name = e.uri.pathSegments.last; + if (!name.startsWith(_kReportPrefix)) continue; + try { + if (e.lastModifiedSync().isBefore(cutoff)) { + await e.delete(); + n++; + } + } catch (_) {} + } + } catch (_) {} + return n; +} /// Gather everything from the running app and write the archive to a temp /// file (the caller deletes it). Built on disk, so even the full database @@ -300,6 +327,7 @@ Future<({File zip, Map meta})> collectReport( ? null : await File(draft.screenshotPath!).readAsBytes(); final tmp = (await getTemporaryDirectory()).path; + await sweepStaleReports(tmp); final slice = draft.includeDatabase && !draft.fullDatabase; Future zipWith(File? dbGz, int? rawDays) { onStage?.call(ReportStage.packing); diff --git a/lib/ui2/profile/report_issue.dart b/lib/ui2/profile/report_issue.dart index b0155a697..8c4a6a811 100644 --- a/lib/ui2/profile/report_issue.dart +++ b/lib/ui2/profile/report_issue.dart @@ -157,6 +157,9 @@ class _ReportIssueScreenState extends State { } catch (e) { if (mounted) setState(() => _error = e is ReportSendError ? e.status : -2); } finally { + // The zip is not deleted here: the share target can still be reading + // it after the sheet returns. pruneSharedReports keeps the newest two, + // and collectReport sweeps any older than ten minutes on the next run. if (mounted) { setState(() { _busy = false; diff --git a/test/issue_report_test.dart b/test/issue_report_test.dart index 536fff250..7a850b3f5 100644 --- a/test/issue_report_test.dart +++ b/test/issue_report_test.dart @@ -259,6 +259,25 @@ void main() { expect(zip.length, greaterThan(kReportMaxBytesWithDb)); }); + test('stale report files left in temp are swept, others are kept', () async { + final dir = await Directory.systemTemp.createTemp('sweep'); + addTearDown(() => dir.delete(recursive: true)); + final now = DateTime.now(); + final old = File('${dir.path}/openstrap_report_1.zip')..writeAsStringSync('db'); + old.setLastModifiedSync(now.subtract(const Duration(hours: 2))); + final oldGz = File('${dir.path}/openstrap_report_2.db.gz')..writeAsStringSync('db'); + oldGz.setLastModifiedSync(now.subtract(const Duration(days: 3))); + final fresh = File('${dir.path}/openstrap_report_3.zip')..writeAsStringSync('db'); + final other = File('${dir.path}/something_else.zip')..writeAsStringSync('x'); + other.setLastModifiedSync(now.subtract(const Duration(days: 3))); + expect(await sweepStaleReports(dir.path, now: now), 2); + expect(old.existsSync(), isFalse); + expect(oldGz.existsSync(), isFalse); + expect(fresh.existsSync(), isTrue); + expect(other.existsSync(), isTrue); + expect(await sweepStaleReports('${dir.path}/missing'), 0); + }); + group('last-2-weeks slice', () { late Directory tmp; setUpAll(() async { From dd7bf0796949f829fc01973aca56cd26ac462f49 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:08:17 +0530 Subject: [PATCH 23/53] report issues no longer turn reporter text into github mentions anyone can post a report without a key, and the title, description and device fields went into the github issue as plain markdown, so @user, @org/team, owner/repo#1 or an issue url became real mentions and cross-references made with the maintainers' token. the description and app/device line now go inside a code block/span (fence longer than any backtick run in the text), and @ and # in the title get a zero-width space after them. (cherry picked from commit 2a543568db8bbe10eb4040a37bf681ea03bf5a22) --- server/report-worker/src/index.ts | 39 +++++++++++++++++++----- server/report-worker/test/worker.test.ts | 17 +++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/server/report-worker/src/index.ts b/server/report-worker/src/index.ts index edc7bcd06..a91431d5a 100644 --- a/server/report-worker/src/index.ts +++ b/server/report-worker/src/index.ts @@ -84,22 +84,47 @@ export function validateMeta(raw: unknown): Meta | null { const expiry = (createdIso: string, hasDb: boolean) => new Date(Date.parse(createdIso) + (hasDb ? LIMITS.dbTtlDays : LIMITS.blobTtlDays) * DAY).toISOString(); +/* + * Everything in Meta is typed by whoever calls POST /v1/reports, which needs no key. Rendered as plain + * markdown, "@user", "@org/team", "owner/repo#1" or an issue URL would become real mentions and + * cross-references made with the maintainers' token. GitHub does not process those inside code, so + * free text goes into a code span/block whose fence is longer than any backtick run in it; the title + * cannot hold code, so "@" and "#" get a zero-width space after them there instead. + */ +const fence = (s: string, min: number) => { + const longest = Math.max(0, ...(s.match(/`+/g) ?? []).map((r) => r.length)); + return "`".repeat(Math.max(min, longest + 1)); +}; +export const codeSpan = (s: string) => { + const f = fence(s, 1); + return `${f} ${s.replace(/[\r\n]+/g, " ")} ${f}`; +}; +export const codeBlock = (s: string) => { + const f = fence(s, 3); + return `${f}text\n${s}\n${f}`; +}; +export const inertTitle = (s: string) => s.replace(/[@#]/g, "$&\u200b"); + +export function issueBody(id: string, m: Meta): string { + return [ + `**Report:** ${id}`, `**Category:** ${m.category}`, + `**App:** ${codeSpan(`${m.appVersion} (${m.platform} ${m.osVersion}, ${m.deviceModel})`)}`, + `**Database attached:** ${m.hasDatabase ? "yes" : "no"} | **Screenshot:** ${m.hasScreenshot ? "yes" : "no"}`, + "", m.description ? codeBlock(m.description) : "(no description)", + ].join("\n"); +} + async function createIssue(env: Env, id: string, m: Meta): Promise { if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return null; try { - const body = [ - `**Report:** ${id}`, `**Category:** ${m.category}`, - `**App:** ${m.appVersion} (${m.platform} ${m.osVersion}, ${m.deviceModel})`, - `**Database attached:** ${m.hasDatabase ? "yes" : "no"} | **Screenshot:** ${m.hasScreenshot ? "yes" : "no"}`, - "", m.description || "(no description)", - ].join("\n"); + const body = issueBody(id, m); const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, { method: "POST", headers: { authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", "user-agent": "openstrap-reports", "content-type": "application/json", }, - body: JSON.stringify({ title: `[${id}] ${m.category}: ${m.title}`, body, labels: [m.category, "report"] }), + body: JSON.stringify({ title: `[${id}] ${m.category}: ${inertTitle(m.title)}`, body, labels: [m.category, "report"] }), signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; diff --git a/server/report-worker/test/worker.test.ts b/server/report-worker/test/worker.test.ts index 69ceacfcd..71b9aba20 100644 --- a/server/report-worker/test/worker.test.ts +++ b/server/report-worker/test/worker.test.ts @@ -215,6 +215,23 @@ describe("POST /v1/reports", () => { expect((await post(meta())).status).toBe(201); spy.mockRestore(); }); + + it("keeps reporter text from mentioning users or cross-referencing other repos", async () => { + e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("{}", { status: 201 })); + const description = "ping @victim and @org/team, see owner/other#1\n```\n@escaped https://github.com/o/r/issues/2"; + const r = await post(meta({ title: "@victim owner/other#1", description, deviceModel: "@phone #3" })); + expect(r.status).toBe(201); + const id = ((await r.json()) as any).id; + const body = JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.title).toBe(`[${id}] sync: @\u200bvictim owner/other#\u200b1`); + expect(body.body).toContain("````text\n" + description + "\n````"); + expect(body.body).toContain("` 0.10.3+72 (ios 19.0, @phone #3) `"); + // nothing reporter-typed sits outside the code block / span + const outside = body.body.replace(/````text\n[\s\S]*?\n````/, "").replace(/` [^`]* `/g, ""); + expect(outside).not.toMatch(/@|#|github\.com/); + spy.mockRestore(); + }); }); describe("DELETE /v1/reports/:id", () => { From ed9d1ae3e74979fad195980344a0fad3106ccbea Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:08:47 +0530 Subject: [PATCH 24/53] coach answers no longer fetch markdown images a coach reply is model output, and gpt_markdown renders ![](url) with a NetworkImage when no imageBuilder is given. any image link in a reply made the phone GET that url as soon as the bubble painted (and again whenever a saved conversation reopened), so a reply could carry health values it had just read out to an arbitrary host in the query string. coach answers now go through CoachAnswerText, which draws images as an inert placeholder icon instead of fetching them. (cherry picked from commit e45fa182a0bd6e5dcdc1caafbfe8bb453ed09a49) --- lib/ui2/screens/coach.dart | 31 ++++++++++++++++++- test/coach_markdown_image_test.dart | 46 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 test/coach_markdown_image_test.dart diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 18aa06773..8057a68a6 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -712,7 +712,7 @@ class _Bubble extends StatelessWidget { case CoachItemKind.assistant: return Padding( padding: const EdgeInsets.only(bottom: S.x4), - child: GptMarkdown( + child: CoachAnswerText( item.text ?? '', style: F.body.copyWith(color: p.ink, height: 1.5), ), @@ -740,6 +740,35 @@ class _Bubble extends StatelessWidget { } } +/// The markdown body of a coach answer. +/// +/// The text is model output, so it is untrusted: it reflects whatever provider +/// or proxy is configured and anything that was in the model's context. Left to +/// its defaults, GptMarkdown renders `![](url)` with a NetworkImage, which makes +/// the phone GET that URL the moment the bubble paints, and again every time a +/// saved conversation reopens. A reply could carry health values it just read +/// over SQL out in the query string to any host, without a tap. Images are +/// therefore drawn as an inert placeholder and never fetched. +class CoachAnswerText extends StatelessWidget { + const CoachAnswerText(this.text, {super.key, this.style}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return GptMarkdown( + text, + style: style, + imageBuilder: (context, url, width, height) => Icon( + LucideIcons.imageOff, + size: 18, + color: P.of(context).ink3, + ), + ); + } +} + class _MenuRow extends StatelessWidget { final IconData icon; final String title; diff --git a/test/coach_markdown_image_test.dart b/test/coach_markdown_image_test.dart new file mode 100644 index 000000000..93060bbe7 --- /dev/null +++ b/test/coach_markdown_image_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/coach.dart'; + +class _RecordingOverrides extends HttpOverrides { + final List requested = []; + + @override + HttpClient createHttpClient(SecurityContext? context) { + final client = super.createHttpClient(context); + client.connectionFactory = (uri, proxyHost, proxyPort) { + requested.add(uri); + throw const SocketException('blocked in test'); + }; + return client; + } +} + +void main() { + // A coach reply is model output. A markdown image in it must never make the + // phone fetch the URL, or the reply can ship values out in the query string. + testWidgets('markdown images in a coach answer are not fetched', + (tester) async { + final overrides = _RecordingOverrides(); + await HttpOverrides.runZoned(() async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: CoachAnswerText( + 'Your resting HR looks steady.\n\n' + '![](https://evil.example/p.png?rhr=52&hrv=41)', + ), + ), + )); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + }, createHttpClient: overrides.createHttpClient); + + expect(overrides.requested, isEmpty); + expect(find.byType(Image), findsNothing); + expect(find.textContaining('resting HR', findRichText: true), findsOneWidget); + }); +} From 6e2f2ce77a9e4acfb5af12e1ec3e8487388f3b42 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:06:58 +0530 Subject: [PATCH 25/53] sensor cap refused to re-pair a mi band when both slots were full the mi band pairs through a pick callback, so the cap check never knew which row it would write and counted it as a new sensor. but a mi band pair reuses the same band's row, and a different band's pair forgets the old mi band row once it succeeds, so it never adds a row. a reset band that needed re-pairing had to be forgotten first, losing its sync cursor. the mi band is now treated as single-slot like the ring. (cherry picked from commit a5edeb21181b2d3e846a341f2e2d9e43f5a31085) --- lib/ui2/profile/devices.dart | 16 +++++++++------- test/sensor_cap_test.dart | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 970971b0b..f89244f28 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1281,16 +1281,18 @@ final List<({BandEntry entry, String blurb, Future Function(BluetoothDe /// /// * [repairingId] — the row id the pair will write, when it is known before /// the pair runs (`HrsLink.mintDeviceId`, keyed by the peripheral). -/// * [adapterId] of a single-slot sensor (the ring: `pairOuraRing` reuses the -/// one ring row it finds) — any existing row of that adapter is the row the -/// pair rewrites. +/// * [adapterId] of a single-slot sensor — any existing row of that adapter +/// is the row the pair rewrites. The ring: `pairOuraRing` reuses the one +/// ring row it finds. The Mi Band: `pairMiBand234` reuses the row of the +/// same band, and a different band's successful pair forgets every other +/// Mi Band row (one Mi Band syncs at a time), so it never nets a row. /// * [anyOfAdapter] — a SOFT check before a sensor's own screen opens or a /// scan starts, when the peripheral is not known yet. An existing row of /// [adapterId] lets the user through to that screen's forget / re-pair /// section; the pair itself is checked again, exactly, before it runs. /// -/// Anything else (a Mi Band pair mints a fresh random id every time) adds a -/// row and is counted. +/// Anything else (a heart-rate strap pair whose peripheral has no row yet) +/// adds a row and is counted. bool sensorCapReached(Iterable> deviceRows, {String? repairingId, String? adapterId, bool anyOfAdapter = false}) { // A row with no `remote_id` holds no link — a backup restore brings a @@ -1301,8 +1303,8 @@ bool sensorCapReached(Iterable> deviceRows, r['id'] != LocalDb.kPrimaryDeviceId && ((r['remote_id'] as String?) ?? '').isNotEmpty) .toList(); - final sameAdapterRewrites = - adapterId != null && (anyOfAdapter || adapterId == kOura.id); + final sameAdapterRewrites = adapterId != null && + (anyOfAdapter || adapterId == kOura.id || adapterId == kMiBand234.id); for (final r in secondary) { if (repairingId != null && r['id'] == repairingId) return false; if (sameAdapterRewrites && r['adapter_id'] == adapterId) return false; diff --git a/test/sensor_cap_test.dart b/test/sensor_cap_test.dart index 9b64d9c36..7bb6af557 100644 --- a/test/sensor_cap_test.dart +++ b/test/sensor_cap_test.dart @@ -63,6 +63,27 @@ void main() { expect(sensorCapReached(rows, adapterId: 'ble_hrs'), isTrue); }); + test('a Mi Band pair replaces the Mi Band row, so it is not an addition', + () { + // The pick callback cannot name the row before the band is chosen, so + // the exact check runs with no repairingId. The pair either reuses the + // same band's row or forgets the other Mi Band rows once it succeeds. + final rows = [ + ..._rows(kMaxConcurrentSecondaryLinks - 1), + { + 'id': 'miband234-0a0b0c0d', + 'adapter_id': kMiBand234.id, + 'remote_id': 'R-mi' + }, + ]; + expect(sensorCapReached(rows, adapterId: kMiBand234.id), isFalse); + // With no Mi Band row yet, a first Mi Band is a new row and counts. + expect( + sensorCapReached(_rows(kMaxConcurrentSecondaryLinks), + adapterId: kMiBand234.id), + isTrue); + }); + test('the soft check lets a paired kind through to its own screen', () { final rows = _rows(kMaxConcurrentSecondaryLinks); expect(sensorCapReached(rows, adapterId: 'ble_hrs', anyOfAdapter: true), From 0c1aa1386b161dab267d5d82d117f90de138968f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:10:09 +0530 Subject: [PATCH 26/53] restoring an older backup undid medication doses marked after it med_dose was the one hand-edited table with an edit stamp that still merged with a plain replace, so restoring an older backup flipped a dose marked taken since back to skipped (or back to unmarked). it now goes through the same newer-side-wins check as the journal and sleep corrections, keyed on the dose slot. (cherry picked from commit 5be782aa53aff7a0b482652a606abf44d22e05be) --- lib/data/db.dart | 3 +++ test/restore_keeps_newer_edits_test.dart | 29 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/data/db.dart b/lib/data/db.dart index b8b182f5f..3de3c5706 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10191,6 +10191,9 @@ class LocalDb { 'lab_result': (['marker', 'taken_on'], 'updated_at'), 'cycle_symptom': (['date'], 'updated_at'), 'sleep_override': (['day_id'], 'created_at'), + // A dose slot is re-marked in place (taken, skipped, cleared), so an older + // backup would flip a dose logged since back to what it said then. + 'med_dose': (['med_key', 'date', 'slot_min'], 'updated_at'), 'cycle_log': (['date'], null), 'sessions': (['id'], null), 'strength_set': (['session_id'], null), diff --git a/test/restore_keeps_newer_edits_test.dart b/test/restore_keeps_newer_edits_test.dart index efea3f447..efdb45f10 100644 --- a/test/restore_keeps_newer_edits_test.dart +++ b/test/restore_keeps_newer_edits_test.dart @@ -32,6 +32,7 @@ Future _clear() async { 'cycle_log', 'sessions', 'strength_set', + 'med_dose', ]) { await db.delete(t); } @@ -70,6 +71,7 @@ void main() { 'openstrap_restore_keeps_newer_edits_test.db', 'monday.db', 'newer.db', + 'dose.db', ]) { try { File(p.join(dir, f)).deleteSync(); @@ -158,4 +160,31 @@ void main() { expect([for (final r in fields) r['field']], ['alcohol']); expect((await db.query('sleep_override')).single['onset_ts'], 500); }); + + test('an older backup does not undo a dose marked after it', () async { + await _clear(); + final db = await LocalDb.instance; + Map dose(int slot, {int? takenTs, int skipped = 0, + required int updatedAt}) => + { + 'med_key': 'iron', 'date': _day, 'slot_min': slot, + 'taken_ts': takenTs, 'skipped': skipped, 'note': '', + 'updated_at': updatedAt, + }; + await db.insert('med_dose', dose(480, skipped: 1, updatedAt: 1000)); + await db.insert('med_dose', dose(1200, updatedAt: 1000)); + final backup = await _snapshot('dose.db'); + + // Since the backup: the skipped morning dose was taken after all. + await db.update( + 'med_dose', {'taken_ts': 50, 'skipped': 0, 'updated_at': 2000}, + where: 'slot_min = 480'); + + await LocalDb.importFromDbFile(backup); + + final rows = await db.query('med_dose', orderBy: 'slot_min'); + expect(rows, hasLength(2)); + expect(rows.first['taken_ts'], 50); + expect(rows.first['skipped'], 0); + }); } From 7511aff26f508719eb67a02b7468259b48328c62 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:16:55 +0530 Subject: [PATCH 27/53] delete everything and delete days could be undone by a derive pass already running a pass that had read its substrate before the wipe kept going and wrote day_result, metric_series and the baselines back for the days the user had just deleted, and with their substrate gone nothing would ever re-derive or remove them. ResetGate only stops ingest. both deletes now wait for any running pass and hold the derive latch until they commit. (cherry picked from commit 7c3a55261f50637db31eea6cd5083ce4cef843c3) --- lib/compute/derivation_engine.dart | 21 ++++++ lib/state/app_state.dart | 12 +++- test/user_delete_waits_for_derive_test.dart | 79 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 test/user_delete_waits_for_derive_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index aea18937f..c90151123 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3035,6 +3035,27 @@ class DerivationEngine { _running = false; } } + + /// Run [body] once no pass is in flight, holding the latch so none starts + /// until it returns. For user deletes: a pass that built its substrate + /// before the delete keeps writing `day_result`, `metric_series` and the + /// baselines after it commits, and the deleted days come back with no + /// substrate left to ever re-derive or remove them. Waits rather than + /// skips, like [_withRunLock]'s import caller. [body] must not call a + /// locked entry point (it would be skipped as busy). + static Future whileIdle(Future Function() body) async { + while (_running) { + await Future.delayed(const Duration(milliseconds: 250)); + } + // No await between the loop's last check and taking the latch. + _running = true; + try { + return await body(); + } finally { + _running = false; + } + } + final Map _diag = { 'running': false, 'stage': 'idle', diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d92035cb0..09c4d7473 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1333,8 +1333,11 @@ class AppState extends ChangeNotifier { final secondaryIds = await _secondaryDeviceIdsForReset(); // 2 · every row in every table (see LocalDb.wipeAll for why it is not a - // hand-written table list, and for the sync_cursor decision). - await LocalDb.wipeAll(); + // hand-written table list, and for the sync_cursor decision). Behind the + // derive latch: ResetGate stops ingest, not a pass already deriving from + // the substrate it read before the wipe, which would write those days' + // results and baselines straight back into the emptied store. + await DerivationEngine.whileIdle(LocalDb.wipeAll); // The copies already handed to share sheets (exports, CSVs, issue // reports) are the same records in plaintext, sitting in the cache. await purgeSharedHealthCopies(); @@ -2688,7 +2691,10 @@ class AppState extends ChangeNotifier { LocalDb.exportDaysDb(dayIds); Future deleteDays(Set dayIds) async { - final deleted = await LocalDb.deleteDays(dayIds); + // Same race as resetAllData: a pass in flight would write the deleted + // days back after the delete, with no substrate left to ever remove them. + final deleted = + await DerivationEngine.whileIdle(() => LocalDb.deleteDays(dayIds)); await LocalDb.refreshComputeFreshness(); lastSynced = await LocalDb.latestSample(); // Deleting days is a durable write like any other, so the screens holding a diff --git a/test/user_delete_waits_for_derive_test.dart b/test/user_delete_waits_for_derive_test.dart new file mode 100644 index 000000000..f1d31057f --- /dev/null +++ b/test/user_delete_waits_for_derive_test.dart @@ -0,0 +1,79 @@ +// A user delete must not run under a derive pass already in flight: that pass +// built its substrate before the delete and writes day_result, metric_series +// and the baselines after it, bringing the deleted days back with nothing left +// to ever re-derive or remove them. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_user_delete_waits_for_derive_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + tearDown(() => DerivationEngine.debugRunning = false); + + test('whileIdle waits for a running pass and holds the latch for its body', + () async { + DerivationEngine.debugRunning = true; // a heavy pass mid-backfill + final engine = DerivationEngine(); + var ran = false; + bool? latchedInside; + final held = Completer(); + final done = DerivationEngine.whileIdle(() async { + ran = true; + latchedInside = engine.running; + await held.future; + }); + + await Future.delayed(const Duration(milliseconds: 600)); + expect(ran, isFalse, reason: 'the delete ran under a pass in flight'); + + DerivationEngine.debugRunning = false; + await Future.delayed(const Duration(milliseconds: 600)); + expect(ran, isTrue); + expect(latchedInside, isTrue); + // A pass the scheduler starts now is dropped as busy, not run alongside. + expect(engine.running, isTrue); + + held.complete(); + await done; + expect(engine.running, isFalse); + }); + + test('deleteDays waits out a derive pass already in flight', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + DerivationEngine.debugRunning = true; + + var finished = false; + final del = app.deleteDays({'2026-09-20'}).then((_) => finished = true); + await Future.delayed(const Duration(milliseconds: 700)); + expect(finished, isFalse); + + DerivationEngine.debugRunning = false; + await del; + expect(finished, isTrue); + }); +} From 4145eb2c805cdb439c4c92eab75a437c25ca993e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:17:55 +0530 Subject: [PATCH 28/53] cycle tab decoded 120 full day bundles on the ui isolate to read three numbers the biometric overlay pulled every served day_result row with its payload and expanded every compact curve back into per-sample maps, just to read rhr, rmssd and skin_temp_z. it reran after every derive pass while the tab sat in the shell. the three scalars are now extracted in sqlite by a new recentDayScalars read, which returns null for a day whose payload is corrupt, lacks the key or holds a non-number there, same as the bundle decode did. (cherry picked from commit f8a5162b65c8037b251ace1aa63e9bf75dca7bf0) --- lib/data/db.dart | 47 ++++++++++ lib/data/local_repository_impl.dart | 15 ++- test/recent_day_scalars_test.dart | 139 ++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 test/recent_day_scalars_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 3de3c5706..207e1ef85 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9073,6 +9073,53 @@ class LocalDb { return [for (final r in rows) _withDate(r)]; } + /// The N most recent days at their served version, newest day_id first, + /// each carrying `day_id`/`date` plus the numeric `scalars.` value for + /// every name in [keys] — extracted IN SQLite, so no payload crosses the + /// platform channel and nothing is decoded on the calling isolate. + /// + /// For a caller that wants a handful of scalars across months of days. + /// Doing it with [recentDayResults] moves ~88 KB of payload per day and then + /// expands every compact curve back into per-sample maps on the UI isolate + /// to read three numbers — the same work that, run over 90 bundles, was + /// measured as a multi-second main-isolate hang. + /// + /// Every served day comes back. A day whose payload is malformed, lacks the + /// key, or holds a non-number there gets null for that key — what decoding + /// the bundle and reading `scalars[key] is num` gave. + static Future>> recentDayScalars( + int limit, + List keys, + ) async { + // The names are spliced into the SQL as JSON paths and column aliases. + final plain = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$'); + for (final k in keys) { + if (!plain.hasMatch(k)) { + throw ArgumentError.value(k, 'keys', 'not a plain scalar name'); + } + } + final db = await instance; + // Nested CASE rather than AND: json_type/json_extract RAISE on a malformed + // document, and SQLite only promises lazy evaluation for CASE branches — + // one corrupt bundle must null its own day, not fail the whole read. + final cols = [ + for (final k in keys) + 'CASE WHEN json_valid(r.payload_json) THEN ' + "CASE WHEN json_type(r.payload_json, '\$.scalars.$k') " + "IN ('integer', 'real') " + "THEN json_extract(r.payload_json, '\$.scalars.$k') END " + 'END AS "$k"', + ]; + final rows = await db.rawQuery( + 'SELECT r.day_id${cols.isEmpty ? '' : ', ${cols.join(', ')}'} ' + 'FROM day_result r ' + '$_servedDayJoin ' + 'ORDER BY r.day_id DESC LIMIT ?', + [limit], + ); + return [for (final r in rows) _withDate(r)]; + } + /// `{day_id -> served algo_version}` for every day, payload-free. /// /// One query instead of a `dayResult()` per day: a caller walking history to diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index c596b66be..c67f8c55a 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4297,17 +4297,22 @@ class LocalRepositoryImpl extends LocalRepository { if (DateTime.tryParse(s) case final d?) DateTime(d.year, d.month, d.day), ]..sort(); final overlay = >[]; - final derived = await LocalDb.recentDayResults(120); + // Three scalars per day, read in SQL. Decoding 120 full bundles here ran on + // the UI isolate and repeated after every derive pass while this tab sat + // in the shell's IndexedStack. + final derived = await LocalDb.recentDayScalars( + 120, + const ['rhr', 'rmssd', 'skin_temp_z'], + ); if (derived.isNotEmpty) { // recentDerivedDays is newest-first; coverline wants oldest-first. final ordered = derived.reversed.toList(); final dates = []; final temps = []; for (final r in ordered) { - final b = _decode(r['payload_json']); final dt = r['date'] as String; dates.add(dt); - final z = _scalar(b, 'skin_temp_z')?.toDouble(); + final z = (r['skin_temp_z'] as num?)?.toDouble(); temps.add(z); // (cycle index, cycle day) for this row — see WH-02 above. int? cd, ci; @@ -4324,8 +4329,8 @@ class LocalRepositoryImpl extends LocalRepository { 'date': dt, 'cycle_day': ?cd, 'cycle_index': ?ci, - 'resting_hr': _scalar(b, 'rhr')?.toDouble(), - 'hrv_rmssd': _scalar(b, 'rmssd')?.toDouble(), + 'resting_hr': (r['rhr'] as num?)?.toDouble(), + 'hrv_rmssd': (r['rmssd'] as num?)?.toDouble(), 'skin_temp_idx': z, }); } diff --git a/test/recent_day_scalars_test.dart b/test/recent_day_scalars_test.dart new file mode 100644 index 000000000..bec9a0145 --- /dev/null +++ b/test/recent_day_scalars_test.dart @@ -0,0 +1,139 @@ +// The Cycle tab's biometric overlay reads three scalars across 120 days. It +// used to pull every full bundle (`SELECT r.*`) and decode it on the UI isolate +// to get them; `recentDayScalars` extracts them in SQLite instead. These tests +// hold the SQL read to exactly what the bundle decode answered, including the +// malformed and non-numeric cases where the answer is null. + +import 'dart:convert'; + +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/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/series_codec.dart'; + +Future _put(String dayId, String payload, {int? version}) => + LocalDb.putDayResult( + dayId: dayId, + algoVersion: version ?? kAlgoVersion, + payloadJson: payload, + windowJson: '{}', + ); + +const _keys = ['rhr', 'rmssd', 'skin_temp_z']; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const name = 'recent_day_scalars_test.db'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await LocalDb.close(); + LocalDb.dbName = name; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), name), + ); + + final curve = [ + for (var i = 0; i < 50; i++) {'t': 1767225600 + i * 60, 'v': 60 + i % 5}, + ]; + await _put( + '2026-03-01', + jsonEncode({ + 'scalars': {'rhr': 52, 'rmssd': 61.5, 'skin_temp_z': -0.4}, + 'series': {'hr_curve': curve}, + }), + ); + // Missing key, a string where a number belongs, and a JSON null. + await _put( + '2026-03-02', + jsonEncode({ + 'scalars': {'rhr': 'n/a', 'skin_temp_z': null}, + }), + ); + // No scalars object at all, and scalars that is not an object. + await _put('2026-03-03', jsonEncode({'skipped': true})); + await _put('2026-03-04', jsonEncode({'scalars': [1, 2, 3]})); + // A corrupt payload must null its own day, not fail the read. + await _put('2026-03-05', '{"scalars": {"rhr": 50'); + // A newer build's row is never served. + await _put('2026-03-06', jsonEncode({'scalars': {'rhr': 49}})); + await _put( + '2026-03-06', + jsonEncode({'scalars': {'rhr': 99}}), + version: kAlgoVersion + 1, + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), name), + ); + }); + + test('matches decoding each bundle and reading scalars[key] is num', + () async { + final got = await LocalDb.recentDayScalars(120, _keys); + + // The read it replaces, as the oracle. + final oracle = [ + for (final r in await LocalDb.recentDayResults(120)) + { + 'date': r['date'], + for (final k in _keys) + k: switch ((SeriesCodec.decodePayloadJson(r['payload_json'])?['scalars'])) { + final Map s when s[k] is num => s[k], + _ => null, + }, + }, + ]; + + expect( + [ + for (final r in got) {'date': r['date'], for (final k in _keys) k: r[k]}, + ], + oracle, + ); + expect([for (final r in got) r['date']], [ + '2026-03-06', + '2026-03-05', + '2026-03-04', + '2026-03-03', + '2026-03-02', + '2026-03-01', + ]); + + final byDay = {for (final r in got) r['date']: r}; + expect(byDay['2026-03-01']!['rhr'], 52); + expect(byDay['2026-03-01']!['rmssd'], 61.5); + expect(byDay['2026-03-01']!['skin_temp_z'], -0.4); + expect(byDay['2026-03-02']!['rhr'], isNull); + expect(byDay['2026-03-05']!['rhr'], isNull); + expect(byDay['2026-03-06']!['rhr'], 49); + }); + + test('never returns the payload', () async { + final got = await LocalDb.recentDayScalars(120, _keys); + expect(got, isNotEmpty); + for (final r in got) { + expect(r.containsKey('payload_json'), isFalse); + } + }); + + test('honours the limit, newest first', () async { + final got = await LocalDb.recentDayScalars(2, _keys); + expect([for (final r in got) r['date']], ['2026-03-06', '2026-03-05']); + }); + + test('refuses a key that is not a plain scalar name', () async { + expect( + () => LocalDb.recentDayScalars(10, ["rhr') --"]), + throwsArgumentError, + ); + }); +} From e9fa6457281d7229b568424cae81911539528e01 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:10:01 +0530 Subject: [PATCH 29/53] pre-3.27 export copied the whole substrate archive just to delete it on sqlite older than 3.27 (android 8-10) the table-by-table export copied every row of substrate_archive, then deleted it and ran a full VACUUM when the caller asked for a copy without it. auto-backup, issue reports and the health upload all do that, so each one needed about three times the archive size in free space. the archive is now left empty at copy time on that path and the strip/VACUUM step is skipped. (cherry picked from commit 350c46797bc154cf63e19444d04563cfa13fcc49) --- lib/data/db.dart | 15 +++++++++++++-- test/substrate_archive_db_test.dart | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 207e1ef85..c3b8bc8b8 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9314,12 +9314,23 @@ class LocalDb { if (await f.exists()) await f.delete(); // VACUUM INTO requires a fresh path Database? copy; try { + var archiveCopied = true; if (!debugForceAttachExport && await _vacuumIntoSupported(db)) { await db.execute('VACUUM INTO ?', [dest]); } else { - await attachCopy(db.path, dest, (_, _) async => ''); + // Leave the archive out at copy time: copying it only to delete it + // and VACUUM again needs roughly three times its size in free space, + // which fails every backup on a nearly full phone. + await attachCopy( + db.path, + dest, + (_, table) async => + !includeSubstrateArchive && table == SubstrateArchive.table + ? null + : ''); + archiveCopied = includeSubstrateArchive; } - if (!includeSubstrateArchive) { + if (!includeSubstrateArchive && archiveCopied) { copy = await openDatabase(dest, singleInstance: false); if (debugFailExportStrip) throw StateError('test: strip failed'); await copy.delete(SubstrateArchive.table); diff --git a/test/substrate_archive_db_test.dart b/test/substrate_archive_db_test.dart index 5c4872d1b..d007a39bc 100644 --- a/test/substrate_archive_db_test.dart +++ b/test/substrate_archive_db_test.dart @@ -1253,6 +1253,35 @@ void main() { } }); + test('the pre-3.27 export leaves the archive out instead of stripping it', + () async { + await _seed(); + await LocalDb.pruneDecodedBeforeRecTs(cutoff, archive: policy); + LocalDb.debugForceAttachExport = true; + // The strip-and-VACUUM step must not run on this path at all. + LocalDb.debugFailExportStrip = true; + try { + final path = await LocalDb.exportCopy(includeSubstrateArchive: false); + final db = await databaseFactory.openDatabase(path, + options: OpenDatabaseOptions(singleInstance: false)); + try { + final n = (await db.rawQuery( + 'SELECT COUNT(*) AS n FROM ${SubstrateArchive.table}')) + .first['n'] as int; + expect(n, 0); + final rows = (await db.rawQuery( + 'SELECT COUNT(*) AS n FROM decoded_onehz')) + .first['n'] as int; + expect(rows, greaterThan(0)); + } finally { + await db.close(); + } + } finally { + LocalDb.debugFailExportStrip = false; + LocalDb.debugForceAttachExport = false; + } + }); + test('page queries seek on the primary key', () async { final db = await _db(); for (final sql in [ From 33df498271516c4a6408b6e759fded5ffb70d522 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:53:25 +0530 Subject: [PATCH 30/53] restoring an older backup reverted medication schedules and food log edits med_def and food_entry merged with a plain replace, so a backup taken before a medication was retimed put the old schedule back (stranding every dose logged since at a minute no slot reads, and re-arming the old reminder), and a corrected meal went back to its old values. med_def now keeps the local row and food_entry keeps whichever side has the newer updated_at, the same rules the other hand-edited tables use. (cherry picked from commit 7e969936c569e45ca6903f7a6f8117a87c43e1d3) --- lib/data/db.dart | 6 ++++++ test/restore_keeps_newer_edits_test.dart | 27 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/data/db.dart b/lib/data/db.dart index c3b8bc8b8..4eedf08b9 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10252,6 +10252,12 @@ class LocalDb { // A dose slot is re-marked in place (taken, skipped, cleared), so an older // backup would flip a dose logged since back to what it said then. 'med_dose': (['med_key', 'date', 'slot_min'], 'updated_at'), + // A retimed schedule moves its past doses to the new minute, so the + // backup's old schedule would leave every dose logged since at a minute + // no slot reads, and bring the old reminder back. No edit stamp: local wins. + 'med_def': (['key'], null), + // Edited in place from the food log; `update` restamps `updated_at`. + 'food_entry': (['id'], 'updated_at'), 'cycle_log': (['date'], null), 'sessions': (['id'], null), 'strength_set': (['session_id'], null), diff --git a/test/restore_keeps_newer_edits_test.dart b/test/restore_keeps_newer_edits_test.dart index efdb45f10..8cd8f5b85 100644 --- a/test/restore_keeps_newer_edits_test.dart +++ b/test/restore_keeps_newer_edits_test.dart @@ -33,6 +33,8 @@ Future _clear() async { 'sessions', 'strength_set', 'med_dose', + 'med_def', + 'food_entry', ]) { await db.delete(t); } @@ -72,6 +74,7 @@ void main() { 'monday.db', 'newer.db', 'dose.db', + 'schedule.db', ]) { try { File(p.join(dir, f)).deleteSync(); @@ -187,4 +190,28 @@ void main() { expect(rows.first['taken_ts'], 50); expect(rows.first['skipped'], 0); }); + + test('an older backup does not undo a retimed schedule or an edited meal', + () async { + await _clear(); + final db = await LocalDb.instance; + await db.insert('med_def', { + 'key': 'iron', 'label': 'Iron', 'schedule_json': '[480]', + 'created_at': 1000, + }); + await db.insert('food_entry', { + 'id': 'f1', 'date': _day, 'meal': 'dinner', 'label': 'pasta', + 'kcal': 800.0, 'created_at': 1000, 'updated_at': 1000, + }); + final backup = await _snapshot('schedule.db'); + + // Since the backup: the dose moved to 09:00 and the meal was corrected. + await db.update('med_def', {'schedule_json': '[540]'}); + await db.update('food_entry', {'kcal': 450.0, 'updated_at': 2000}); + + await LocalDb.importFromDbFile(backup); + + expect((await db.query('med_def')).single['schedule_json'], '[540]'); + expect((await db.query('food_entry')).single['kcal'], 450.0); + }); } From 9f98445ad9688d3280ff83f7f50afc79dd2e1d23 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:54:32 +0530 Subject: [PATCH 31/53] restore rolled the step calibration back to the backup's older fit the learned step-counter profile was restored with a plain replace, so restoring an older backup put back a factor fit from fewer days and counter steps were scaled by it until the next finalized day refit. the profile now keeps whichever side has the newer updated_ts. (cherry picked from commit 378dab6f477d5ca0771223812ddf6a1ed621b33e) --- lib/data/db.dart | 4 +++ test/restore_keeps_newer_edits_test.dart | 44 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/data/db.dart b/lib/data/db.dart index 4eedf08b9..33623752a 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10258,6 +10258,10 @@ class LocalDb { 'med_def': (['key'], null), // Edited in place from the food log; `update` restamps `updated_at`. 'food_entry': (['id'], 'updated_at'), + // Refit from every admissible finalized day, so an older backup's profile + // was fit from fewer days and would scale counter steps by a stale factor + // until the next refit. + 'step_calibration': (['device_family', 'wearing'], 'updated_ts'), 'cycle_log': (['date'], null), 'sessions': (['id'], null), 'strength_set': (['session_id'], null), diff --git a/test/restore_keeps_newer_edits_test.dart b/test/restore_keeps_newer_edits_test.dart index 8cd8f5b85..9c7eec8ce 100644 --- a/test/restore_keeps_newer_edits_test.dart +++ b/test/restore_keeps_newer_edits_test.dart @@ -35,6 +35,7 @@ Future _clear() async { 'med_dose', 'med_def', 'food_entry', + 'step_calibration', ]) { await db.delete(t); } @@ -75,6 +76,8 @@ void main() { 'newer.db', 'dose.db', 'schedule.db', + 'calib.db', + 'calib_new.db', ]) { try { File(p.join(dir, f)).deleteSync(); @@ -214,4 +217,45 @@ void main() { expect((await db.query('med_def')).single['schedule_json'], '[540]'); expect((await db.query('food_entry')).single['kcal'], 450.0); }); + + test('an older backup does not roll back a refit step calibration', + () async { + await _clear(); + final db = await LocalDb.instance; + Map profile(double factor, int nDays, int ts) => { + 'device_family': 'whoop5', 'wearing': 1, 'factor': factor, + 'n_days': nDays, 'version': 1, 'updated_ts': ts, + }; + await db.insert('step_calibration', profile(1.12, 10, 1000)); + final backup = await _snapshot('calib.db'); + + // Since the backup: refit from more days. + await db.insert('step_calibration', profile(0.97, 24, 2000), + conflictAlgorithm: ConflictAlgorithm.replace); + + await LocalDb.importFromDbFile(backup); + + final row = (await db.query('step_calibration')).single; + expect(row['factor'], 0.97); + expect(row['n_days'], 24); + }); + + test('a newer backup step calibration still replaces an older local one', + () async { + await _clear(); + final db = await LocalDb.instance; + Map profile(double factor, int nDays, int ts) => { + 'device_family': 'whoop5', 'wearing': 1, 'factor': factor, + 'n_days': nDays, 'version': 1, 'updated_ts': ts, + }; + await db.insert('step_calibration', profile(0.97, 24, 2000)); + final backup = await _snapshot('calib_new.db'); + await db.insert('step_calibration', profile(1.12, 10, 1000), + conflictAlgorithm: ConflictAlgorithm.replace); + + await LocalDb.importFromDbFile(backup); + + final row = (await db.query('step_calibration')).single; + expect(row['factor'], 0.97); + }); } From 680139491874f76e6167691bec357b23fb181b5d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:14 +0530 Subject: [PATCH 32/53] two cycle symptom chips tapped quickly lost the first one the chip toggled against the screen's last loaded snapshot and replaced the whole day's list, so a second tap before the reload landed overwrote the first. it also wrote no note, so every tap wiped the day's note, and a failed write was an unhandled async error with nothing shown. the toggle now runs in one transaction against the stored row and keeps its note, and a failure shows a snackbar. (cherry picked from commit 3662b297da6535e7a6220f3be5798a83bc6da197) --- lib/data/db.dart | 46 ++++++++++++++++ lib/data/local_repository.dart | 2 + lib/data/local_repository_impl.dart | 4 ++ lib/ui2/screens/cycle_screen.dart | 18 +++++-- test/cycle_symptom_toggle_test.dart | 84 +++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 test/cycle_symptom_toggle_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 33623752a..6dfe313f2 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1696,6 +1696,52 @@ class LocalDb { }, conflictAlgorithm: ConflictAlgorithm.replace); } + /// Flip one symptom on [date] against what is STORED, inside one + /// transaction. The screen used to toggle against its last loaded snapshot + /// and hand the whole list to [putCycleSymptoms]; a second chip tapped before + /// the reload landed was computed from the same snapshot, and its REPLACE + /// wiped the first. It also passed no note, so every tap erased the day's + /// note. Returns the list now stored. + static Future> toggleCycleSymptom( + String date, + String symptom, + ) async { + final db = await instance; + return db.transaction((txn) async { + final rows = await txn.query( + 'cycle_symptom', + columns: ['symptoms_json', 'note'], + where: 'date = ?', + whereArgs: [date], + limit: 1, + ); + final row = rows.isEmpty ? null : rows.first; + final now = []; + final raw = row?['symptoms_json']; + if (raw is String) { + try { + final decoded = jsonDecode(raw); + if (decoded is List) now.addAll(decoded.whereType()); + } catch (_) { + // A corrupt list is treated as empty rather than blocking the tap. + } + } + final note = row?['note'] as String?; + now.contains(symptom) ? now.remove(symptom) : now.add(symptom); + if (now.isEmpty && (note == null || note.isEmpty)) { + await txn.delete('cycle_symptom', where: 'date = ?', whereArgs: [date]); + } else { + await txn.insert('cycle_symptom', { + 'date': date, + 'symptoms_json': jsonEncode(now), + 'note': note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + return now; + }); + } + /// All symptom rows (newest first): {date, symptoms_json, note}. static Future>> cycleSymptoms() async { final db = await instance; diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index 8c3a4e8a6..24453af85 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -360,6 +360,8 @@ abstract class LocalRepository { List symptoms, { String? note, }) => throw UnimplementedError('re-layer: postCycleSymptoms'); + Future> toggleCycleSymptom(String date, String symptom) => + throw UnimplementedError('re-layer: toggleCycleSymptom'); Future>> getCycleSymptoms() => throw UnimplementedError('re-layer: getCycleSymptoms'); diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index c67f8c55a..90d64f398 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4379,6 +4379,10 @@ class LocalRepositoryImpl extends LocalRepository { String? note, }) async => LocalDb.putCycleSymptoms(date, symptoms, note: note); + @override + Future> toggleCycleSymptom(String date, String symptom) async => + LocalDb.toggleCycleSymptom(date, symptom); + @override Future>> getCycleSymptoms() async { final rows = await LocalDb.cycleSymptoms(); diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index bb82ed72c..e0fadce0b 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -326,13 +326,21 @@ class _CycleTabState extends State with RevisionReload { await _load(); } + /// The toggle happens against the stored row, in the store, so two chips + /// tapped before the reload lands both count — toggling against `_d` here let + /// the second write REPLACE the first. Future _toggleSymptom(String s) async { final repo = context.read().repo; - final d = _d; - if (repo == null || d == null) return; - final now = [...?d.symptoms[_date]]; - now.contains(s) ? now.remove(s) : now.add(s); - await repo.postCycleSymptoms(_date, now); + if (repo == null || _d == null) return; + final messenger = ScaffoldMessenger.maybeOf(context); + final failed = AppLocalizations.of(context)?.journalComposeSaveFailed ?? + 'Could not save it — check storage and retry.'; + try { + await repo.toggleCycleSymptom(_date, s); + } catch (_) { + messenger?.showSnackBar(SnackBar(content: Text(failed))); + } + if (!mounted) return; await _load(); } diff --git a/test/cycle_symptom_toggle_test.dart b/test/cycle_symptom_toggle_test.dart new file mode 100644 index 000000000..2fa2a8527 --- /dev/null +++ b/test/cycle_symptom_toggle_test.dart @@ -0,0 +1,84 @@ +// Cycle symptom chips toggle against the STORED row. +// +// The screen used to toggle against its last loaded snapshot and REPLACE the +// whole day's list. Two chips tapped before the reload landed were both +// computed from the same snapshot, so the second write wiped the first. It +// also passed no note, so every tap erased the day's note. + +import 'dart:convert'; + +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'; + +Future?> _row(String date) async { + final rows = (await LocalDb.cycleSymptoms()) + .where((r) => r['date'] == date) + .toList(); + return rows.isEmpty ? null : rows.first; +} + +List _symptoms(Map? r) => r == null + ? const [] + : (jsonDecode(r['symptoms_json'] as String) as List).cast(); + +void main() { + const day = '2026-10-17'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_cycle_symptom_toggle_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + tearDownAll(() async => LocalDb.close()); + + setUp(() async { + final db = await LocalDb.instance; + await db.delete('cycle_symptom'); + }); + + test('two taps in flight at once both land', () async { + await Future.wait([ + LocalDb.toggleCycleSymptom(day, 'cramps'), + LocalDb.toggleCycleSymptom(day, 'headache'), + ]); + expect(_symptoms(await _row(day)).toSet(), {'cramps', 'headache'}); + }); + + test('a stale off-then-on does not resurrect a removed symptom', () async { + await LocalDb.putCycleSymptoms(day, ['cramps', 'headache']); + await Future.wait([ + LocalDb.toggleCycleSymptom(day, 'cramps'), + LocalDb.toggleCycleSymptom(day, 'bloating'), + ]); + expect(_symptoms(await _row(day)).toSet(), {'headache', 'bloating'}); + }); + + test('a toggle keeps the day\'s note', () async { + await LocalDb.putCycleSymptoms(day, ['cramps'], note: 'imported'); + await LocalDb.toggleCycleSymptom(day, 'headache'); + final r = await _row(day); + expect(r?['note'], 'imported'); + expect(_symptoms(r), ['cramps', 'headache']); + + // Clearing the last symptom keeps the row while it still holds a note. + await LocalDb.toggleCycleSymptom(day, 'cramps'); + await LocalDb.toggleCycleSymptom(day, 'headache'); + final cleared = await _row(day); + expect(cleared?['note'], 'imported'); + expect(_symptoms(cleared), isEmpty); + }); + + test('clearing the last symptom with no note removes the row', () async { + await LocalDb.toggleCycleSymptom(day, 'cramps'); + final now = await LocalDb.toggleCycleSymptom(day, 'cramps'); + expect(now, isEmpty); + expect(await _row(day), isNull); + }); +} From 404df7cf841e71e84a383ea947843fd99c65d9a9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:17 +0530 Subject: [PATCH 33/53] lab results in a different unit were judged against the catalogue range a lab csv row keeps the unit it was imported with, but the labs list coloured it and printed the typical range from the catalogue's interval, which is in the catalogue's own unit. a normal 5.4 mmol/L fasting glucose showed orange against 70-99 mg/dL. a result whose stored unit differs from the marker's now gets no verdict: grey dot and no reference interval. (cherry picked from commit 897493fd8aa8978c7e60b9860f07ee86399bb2b4) --- lib/data/lab_catalogue.dart | 13 +++++++++++++ lib/ui2/screens/health_screen.dart | 7 +++++-- test/lab_catalogue_test.dart | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/data/lab_catalogue.dart b/lib/data/lab_catalogue.dart index fe1ac1dc6..35f7de834 100644 --- a/lib/data/lab_catalogue.dart +++ b/lib/data/lab_catalogue.dart @@ -118,6 +118,19 @@ class LabMarker { /// as "fine". bool? inRange(double value, {String? sex}) => rangeFor(sex)?.contains(value); + /// Whether a result stored under [stored] can be judged against [ranges], + /// which are written in [unit]. A CSV import keeps whatever unit the row + /// carried, so 5.4 mmol/L glucose can sit next to a 70–99 mg/dL interval; + /// comparing across units would paint a normal value as out of range (or an + /// abnormal one as fine), and converting would be a guess. No stored unit + /// means the result was entered in the catalogue's own unit. The match is + /// exact on purpose: a mismatch only costs the verdict, a false match + /// costs a wrong one. + bool judgesUnit(Object? stored) { + final u = stored?.toString().trim() ?? ''; + return u.isEmpty || u == unit; + } + String format(double v) => v.toStringAsFixed(decimals); String formatWithUnit(double v) => '${format(v)} $unit'; } diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 296220310..99938963a 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1512,8 +1512,11 @@ class _HealthScreenState extends State with RevisionReload { final l = AppLocalizations.of(context); final v = (r['value'] as num?)?.toDouble(); final unit = (r['unit'] ?? m?.unit ?? '').toString(); - final range = m?.rangeFor(sex); - final inRange = v == null || m == null ? null : m.inRange(v, sex: sex); + // A result in another unit than the catalogue's gets no verdict at all: + // the interval is in the catalogue's unit, not the one printed beside it. + final judge = m != null && m.judgesUnit(r['unit']) ? m : null; + final range = judge?.rangeFor(sex); + final inRange = v == null ? null : judge?.inRange(v, sex: sex); // The whole row is the control, with the bin as its affordance — the same // shape a logged meal takes, and it costs no width, which at 3x text is diff --git a/test/lab_catalogue_test.dart b/test/lab_catalogue_test.dart index ddbd95c97..4c9d3ecaf 100644 --- a/test/lab_catalogue_test.dart +++ b/test/lab_catalogue_test.dart @@ -76,6 +76,25 @@ void main() { }); }); + group('unit of a stored result', () { + final glucose = kLabMarkersByKey['glucose_fasting']!; + + test('a result in another unit is not judged against the interval', () { + // 5.4 mmol/L is a normal fasting glucose; against 70–99 mg/dL it would + // read as far out of range. + expect(glucose.unit, 'mg/dL'); + expect(glucose.judgesUnit('mmol/L'), isFalse); + expect(glucose.judgesUnit('mg/dl'), isFalse); + }); + + test('the catalogue unit, or none stored, is judged', () { + expect(glucose.judgesUnit('mg/dL'), isTrue); + expect(glucose.judgesUnit(' mg/dL '), isTrue); + expect(glucose.judgesUnit(null), isTrue); + expect(glucose.judgesUnit(''), isTrue); + }); + }); + group('range selection', () { final ferritin = kLabMarkersByKey['ferritin']!; final hba1c = kLabMarkersByKey['hba1c']!; From 3fefd3bc12d07ec3129fa69209b62119bb8f2546 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 34/53] reset test looked for the bare wipe call after it moved behind the derive latch resetAllData now runs the wipe through DerivationEngine.whileIdle, so the source check for the gate-before-wipe order looks for that call. --- test/reset_quiesces_ingest_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index 964993e2f..0e976450a 100644 --- a/test/reset_quiesces_ingest_test.dart +++ b/test/reset_quiesces_ingest_test.dart @@ -90,7 +90,7 @@ void main() { test('resetAllData raises the flag and always lowers it', () { final body = src.substring(src.indexOf('Future resetAllData() async')); final raise = body.indexOf('ResetGate.enter();'); - final wipe = body.indexOf('LocalDb.wipeAll()'); + final wipe = body.indexOf('DerivationEngine.whileIdle(LocalDb.wipeAll)'); final lower = body.indexOf('ResetGate.leave();'); final fin = body.indexOf('} finally {'); @@ -119,7 +119,7 @@ void main() { final body = src.substring(src.indexOf('Future resetAllData() async')); final stop = body.indexOf('await _stopSecondaryLinksForReset();'); final ids = body.indexOf('await _secondaryDeviceIdsForReset();'); - final wipe = body.indexOf('LocalDb.wipeAll()'); + final wipe = body.indexOf('DerivationEngine.whileIdle(LocalDb.wipeAll)'); expect(stop, greaterThanOrEqualTo(0)); expect(stop, lessThan(wipe)); expect(ids, greaterThanOrEqualTo(0)); From 9a684344d7d0de42852ad4c4b9421ad0f5899080 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 02:45:21 +0530 Subject: [PATCH 35/53] add the coach answer text to the gallery and type the russian report delete placeholder the gallery check fails on any component it does not hold, and the russian reportDeleteConfirmTitle had text but no placeholder metadata. --- lib/l10n/app_ru.arb | 7 +++++++ lib/ui2/profile/gallery.dart | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d73a9525d..124637857 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7495,6 +7495,13 @@ "reportDelete": "Удалить", "reportDeleteFailed": "Не удалось удалить. Попробуйте позже.", "reportDeleteConfirmTitle": "Удалить сообщение {id}?", + "@reportDeleteConfirmTitle": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "reportDeleteConfirmBody": "Оно сразу удаляется с сервера, и открыть его по ID больше не получится. Отменить нельзя.", "reportSaveFailedTitle": "Не удалось поделиться сообщением", "reportSaveFailedBody": "Не удалось собрать или передать файл. Тикет не создан.", diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 53cf6fc1f..4d97bf051 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -395,6 +395,13 @@ Map _coachFigureCases() => { 'skin_temp_z': -0.4, }, ), + // A reply carrying a markdown image: it has to draw the inert placeholder, + // never a fetched picture. + 'coach_answer_text': const CoachAnswerText( + 'Your **resting HR** sat 3 bpm under your usual this week.\n\n' + '![chart](https://example.invalid/hr.png)\n\n' + '- sleep held at 7 h 12 min\n- strain eased off midweek', + ), }; /// Charts, framed. Every one of these is captured with the thing that was From 918802d989768a1c9b8c78e6daac9e4fa028b302 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 04:05:27 +0530 Subject: [PATCH 36/53] a data reset waited on its own derive hold before the wipe the reset now takes the derive latch before it stops anything and keeps it until the gate lowers, so waiting on that latch again around the wipe waited on itself and the reset never finished. it only waits there now when it could not take the latch earlier. --- lib/state/app_state.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 050bb9105..4fffb5782 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1376,8 +1376,12 @@ class AppState extends ChangeNotifier { // hand-written table list, and for the sync_cursor decision). Behind the // derive latch: ResetGate stops ingest, not a pass already deriving from // the substrate it read before the wipe, which would write those days' - // results and baselines straight back into the emptied store. - await DerivationEngine.whileIdle(LocalDb.wipeAll); + // results and baselines straight back into the emptied store. The reset + // normally holds the latch already (above); waiting on it again then + // would wait on itself forever. + await (deriveHeld + ? LocalDb.wipeAll() + : DerivationEngine.whileIdle(LocalDb.wipeAll)); // The copies already handed to share sheets (exports, CSVs, issue // reports) are the same records in plaintext, sitting in the cache. await purgeSharedHealthCopies(); From 696825d962d98cae29ef0b0738a4c52228891222 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 19:16:31 +0530 Subject: [PATCH 37/53] reset: wipe directly now that the derive latch is always held by then --- lib/state/app_state.dart | 13 ++++--------- test/reset_quiesces_ingest_test.dart | 4 ++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index aad2677d8..477d305e9 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1397,15 +1397,10 @@ class AppState extends ChangeNotifier { await _stopSecondaryLinksForReset(); // 2 · every row in every table (see LocalDb.wipeAll for why it is not a - // hand-written table list, and for the sync_cursor decision). Behind the - // derive latch: ResetGate stops ingest, not a pass already deriving from - // the substrate it read before the wipe, which would write those days' - // results and baselines straight back into the emptied store. The reset - // normally holds the latch already (above); waiting on it again then - // would wait on itself forever. - await (deriveHeld - ? LocalDb.wipeAll() - : DerivationEngine.whileIdle(LocalDb.wipeAll)); + // hand-written table list, and for the sync_cursor decision). The reset + // holds the derive latch from before the gate went up, so no pass that + // read the substrate before the wipe can write its days back after it. + await LocalDb.wipeAll(); // The copies already handed to share sheets (exports, CSVs, issue // reports) are the same records in plaintext, sitting in the cache. await purgeSharedHealthCopies(); diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index 9cb45b138..c5d0f03c4 100644 --- a/test/reset_quiesces_ingest_test.dart +++ b/test/reset_quiesces_ingest_test.dart @@ -90,7 +90,7 @@ void main() { test('resetAllData raises the flag and always lowers it', () { final body = src.substring(src.indexOf('Future resetAllData() async')); final raise = body.indexOf('ResetGate.enter();'); - final wipe = body.indexOf('DerivationEngine.whileIdle(LocalDb.wipeAll)'); + final wipe = body.indexOf('await LocalDb.wipeAll();'); final lower = body.indexOf('ResetGate.leave();'); final fin = body.indexOf('} finally {'); @@ -135,7 +135,7 @@ void main() { final body = src.substring(src.indexOf('Future resetAllData() async')); final stop = body.indexOf('await _stopSecondaryLinksForReset();'); final ids = body.indexOf('await _secondaryDeviceIdsForReset();'); - final wipe = body.indexOf('DerivationEngine.whileIdle(LocalDb.wipeAll)'); + final wipe = body.indexOf('await LocalDb.wipeAll();'); expect(stop, greaterThanOrEqualTo(0)); expect(stop, lessThan(wipe)); expect(ids, greaterThanOrEqualTo(0)); From 2ed9405c26e8e752dc0af195825520ba9b4b1163 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 16:39:28 +0530 Subject: [PATCH 38/53] leave the report worker out of this change the worker lives in its own repository now; its copy here is not built from this tree. --- server/report-worker/README.md | 1 - server/report-worker/src/index.ts | 101 ++++------------------- server/report-worker/test/worker.test.ts | 68 +-------------- 3 files changed, 16 insertions(+), 154 deletions(-) diff --git a/server/report-worker/README.md b/server/report-worker/README.md index fa4233c94..0143b715f 100644 --- a/server/report-worker/README.md +++ b/server/report-worker/README.md @@ -19,7 +19,6 @@ Cloudflare Worker behind "Report an issue" in the app. Contract: `~/.openstrap-r | Blob without DB | deleted after 30 days | | Blob with DB | deleted after 14 days | | Metadata row | deleted after 90 days | -| GitHub issue | title and body overwritten and the issue closed when the user deletes the report or the row reaches 90 days (the token cannot delete issues, so the old text stays in the issue's edit history, visible only to repo collaborators); a failed attempt is retried by the cron and the row is kept until it succeeds | | Blob size | 5 MB, or 95 MB when `hasDatabase` (the free plan caps request bodies at 100 MB) | | Rate limit | 3 reports per install id per UTC day (user-deleted reports still count), and 1000 per UTC day across all installs, since install ids are chosen by the client | | Storage ceiling | DB reports get `507 storage_full` above 8 GB stored; every report gets it above 9 GB, so R2 never leaves the free 10 GB | diff --git a/server/report-worker/src/index.ts b/server/report-worker/src/index.ts index a91431d5a..ff5f5d467 100644 --- a/server/report-worker/src/index.ts +++ b/server/report-worker/src/index.ts @@ -84,47 +84,22 @@ export function validateMeta(raw: unknown): Meta | null { const expiry = (createdIso: string, hasDb: boolean) => new Date(Date.parse(createdIso) + (hasDb ? LIMITS.dbTtlDays : LIMITS.blobTtlDays) * DAY).toISOString(); -/* - * Everything in Meta is typed by whoever calls POST /v1/reports, which needs no key. Rendered as plain - * markdown, "@user", "@org/team", "owner/repo#1" or an issue URL would become real mentions and - * cross-references made with the maintainers' token. GitHub does not process those inside code, so - * free text goes into a code span/block whose fence is longer than any backtick run in it; the title - * cannot hold code, so "@" and "#" get a zero-width space after them there instead. - */ -const fence = (s: string, min: number) => { - const longest = Math.max(0, ...(s.match(/`+/g) ?? []).map((r) => r.length)); - return "`".repeat(Math.max(min, longest + 1)); -}; -export const codeSpan = (s: string) => { - const f = fence(s, 1); - return `${f} ${s.replace(/[\r\n]+/g, " ")} ${f}`; -}; -export const codeBlock = (s: string) => { - const f = fence(s, 3); - return `${f}text\n${s}\n${f}`; -}; -export const inertTitle = (s: string) => s.replace(/[@#]/g, "$&\u200b"); - -export function issueBody(id: string, m: Meta): string { - return [ - `**Report:** ${id}`, `**Category:** ${m.category}`, - `**App:** ${codeSpan(`${m.appVersion} (${m.platform} ${m.osVersion}, ${m.deviceModel})`)}`, - `**Database attached:** ${m.hasDatabase ? "yes" : "no"} | **Screenshot:** ${m.hasScreenshot ? "yes" : "no"}`, - "", m.description ? codeBlock(m.description) : "(no description)", - ].join("\n"); -} - async function createIssue(env: Env, id: string, m: Meta): Promise { if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return null; try { - const body = issueBody(id, m); + const body = [ + `**Report:** ${id}`, `**Category:** ${m.category}`, + `**App:** ${m.appVersion} (${m.platform} ${m.osVersion}, ${m.deviceModel})`, + `**Database attached:** ${m.hasDatabase ? "yes" : "no"} | **Screenshot:** ${m.hasScreenshot ? "yes" : "no"}`, + "", m.description || "(no description)", + ].join("\n"); const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, { method: "POST", headers: { authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", "user-agent": "openstrap-reports", "content-type": "application/json", }, - body: JSON.stringify({ title: `[${id}] ${m.category}: ${inertTitle(m.title)}`, body, labels: [m.category, "report"] }), + body: JSON.stringify({ title: `[${id}] ${m.category}: ${m.title}`, body, labels: [m.category, "report"] }), signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; @@ -135,32 +110,6 @@ async function createIssue(env: Env, id: string, m: Meta): Promise { - if (!env.GITHUB_TOKEN) return false; - const m = issueUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)$/); - if (!m) return true; // nothing we could ever reach; do not hold the row for it - try { - const res = await fetch(`https://api.github.com/repos/${m[1]}/issues/${m[2]}`, { - method: "PATCH", - headers: { - authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", - "user-agent": "openstrap-reports", "content-type": "application/json", - }, - body: JSON.stringify({ title: `[${id}] removed`, body: "Removed: deleted by the reporter or past retention.", state: "closed" }), - signal: AbortSignal.timeout(5000), - }); - return res.ok || res.status === 404 || res.status === 410; - } catch { - return false; - } -} - /** X-Report-Meta: base64url(JSON). Null when missing, oversized or invalid. */ function decodeMetaHeader(h: string | null): Meta | null { if (!h || h.length > LIMITS.metaHeader) return null; @@ -276,21 +225,18 @@ async function postReport(req: Request, env: Env): Promise { async function deleteReport(req: Request, env: Env, id: string): Promise { const token = req.headers.get("x-delete-token") ?? ""; - const row = await env.DB.prepare("SELECT delete_token_hash, blob_key, github_issue_url FROM reports WHERE id = ?") - .bind(id).first<{ delete_token_hash: string; blob_key: string | null; github_issue_url: string | null }>(); + const row = await env.DB.prepare("SELECT delete_token_hash, blob_key FROM reports WHERE id = ?") + .bind(id).first<{ delete_token_hash: string; blob_key: string | null }>(); if (!row || !row.delete_token_hash) return err(404, "not_found"); if (!(await safeEqual(hex(await sha256(token)), row.delete_token_hash))) return err(403, "forbidden"); if (row.blob_key) await env.BLOBS.delete(row.blob_key); - // A failed retraction keeps the URL so the cron retries it; the user's token is spent either way. - const retracted = !row.github_issue_url || (await retractIssue(env, id, row.github_issue_url)); // Scrub but keep the row so the per-day rate limit still counts it; the cron removes it at 90 days. // Stats first: it reads the row's blob_bytes before the scrub zeroes it (a repeat delete subtracts 0). await env.DB.batch([ env.DB.prepare("UPDATE stats SET v = v - (SELECT blob_bytes FROM reports WHERE id = ?) WHERE k = 'bytes'").bind(id), env.DB.prepare( - `UPDATE reports SET blob_key=NULL, blob_bytes=0, title='', description='', meta='{}', delete_token_hash='', - github_issue_url = CASE WHEN ? THEN NULL ELSE github_issue_url END WHERE id = ?`) - .bind(retracted ? 1 : 0, id), + `UPDATE reports SET blob_key=NULL, blob_bytes=0, title='', description='', meta='{}', delete_token_hash='' WHERE id = ?`) + .bind(id), ]); return new Response(null, { status: 204 }); } @@ -321,7 +267,7 @@ async function admin(req: Request, env: Env, url: URL): Promise { return err(404, "not_found"); } -export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: number; rows: number; issues: number }> { +export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: number; rows: number }> { const cut = (days: number) => new Date(now.getTime() - days * DAY).toISOString(); const { results } = await env.DB.prepare( `SELECT id, blob_key FROM reports WHERE blob_key IS NOT NULL AND @@ -334,30 +280,13 @@ export async function cleanup(env: Env, now = new Date()): Promise<{ blobs: numb env.DB.prepare("UPDATE reports SET blob_key=NULL, blob_bytes=0 WHERE id = ?").bind(r.id), ]); } - // Issues of user-deleted reports whose retraction failed, and of rows reaching 90 days. Capped per run to - // stay inside the per-invocation subrequest limit; the rest go on the next run. - let issues = 0; - if (env.GITHUB_TOKEN) { - const { results: pending } = await env.DB.prepare( - `SELECT id, github_issue_url FROM reports WHERE github_issue_url IS NOT NULL AND (delete_token_hash = '' OR created_at < ?) - ORDER BY created_at LIMIT 20`) - .bind(cut(LIMITS.rowTtlDays)).all<{ id: string; github_issue_url: string }>(); - for (const r of pending) { - if (!(await retractIssue(env, r.id, r.github_issue_url))) continue; - await env.DB.prepare("UPDATE reports SET github_issue_url = NULL WHERE id = ?").bind(r.id).run(); - issues++; - } - } - // A row whose issue is still up stays until the issue is gone: the row holds the only pointer to it. - // Without a token nothing can retract it, so the row goes on schedule. - const expired = env.GITHUB_TOKEN ? "created_at < ? AND github_issue_url IS NULL" : "created_at < ?"; // Rows past retention should already be blob-free; subtract any stragglers so the counter cannot drift. const [, rows] = await env.DB.batch([ - env.DB.prepare(`UPDATE stats SET v = v - COALESCE((SELECT SUM(blob_bytes) FROM reports WHERE ${expired}), 0) WHERE k = 'bytes'`).bind(cut(LIMITS.rowTtlDays)), - env.DB.prepare(`DELETE FROM reports WHERE ${expired}`).bind(cut(LIMITS.rowTtlDays)), + env.DB.prepare("UPDATE stats SET v = v - COALESCE((SELECT SUM(blob_bytes) FROM reports WHERE created_at < ?), 0) WHERE k = 'bytes'").bind(cut(LIMITS.rowTtlDays)), + env.DB.prepare("DELETE FROM reports WHERE created_at < ?").bind(cut(LIMITS.rowTtlDays)), env.DB.prepare("DELETE FROM stats WHERE k LIKE 'day:%' AND k < ?").bind("day:" + cut(2).slice(0, 10)), ]); - return { blobs: results.length, rows: rows.meta.changes ?? 0, issues }; + return { blobs: results.length, rows: rows.meta.changes ?? 0 }; } export default { diff --git a/server/report-worker/test/worker.test.ts b/server/report-worker/test/worker.test.ts index 71b9aba20..a2ef9abab 100644 --- a/server/report-worker/test/worker.test.ts +++ b/server/report-worker/test/worker.test.ts @@ -215,23 +215,6 @@ describe("POST /v1/reports", () => { expect((await post(meta())).status).toBe(201); spy.mockRestore(); }); - - it("keeps reporter text from mentioning users or cross-referencing other repos", async () => { - e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; - const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("{}", { status: 201 })); - const description = "ping @victim and @org/team, see owner/other#1\n```\n@escaped https://github.com/o/r/issues/2"; - const r = await post(meta({ title: "@victim owner/other#1", description, deviceModel: "@phone #3" })); - expect(r.status).toBe(201); - const id = ((await r.json()) as any).id; - const body = JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string); - expect(body.title).toBe(`[${id}] sync: @\u200bvictim owner/other#\u200b1`); - expect(body.body).toContain("````text\n" + description + "\n````"); - expect(body.body).toContain("` 0.10.3+72 (ios 19.0, @phone #3) `"); - // nothing reporter-typed sits outside the code block / span - const outside = body.body.replace(/````text\n[\s\S]*?\n````/, "").replace(/` [^`]* `/g, ""); - expect(outside).not.toMatch(/@|#|github\.com/); - spy.mockRestore(); - }); }); describe("DELETE /v1/reports/:id", () => { @@ -254,38 +237,6 @@ describe("DELETE /v1/reports/:id", () => { }); it("unknown id 404", async () => expect((await del("OS-ZZZZZZ", "x")).status).toBe(404)); - - it("blanks and closes the GitHub issue; a failed attempt is left for the cron", async () => { - e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; - const issue = (n: number) => new Response(JSON.stringify({ html_url: `https://github.com/OpenStrap/reports/issues/${n}` }), { status: 201 }); - const spy = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(issue(7)) - .mockResolvedValueOnce(new Response("{}", { status: 200 })); - const a = (await (await post(meta({ description: "started new meds" }))).json()) as any; - expect((await del(a.id, a.deleteToken)).status).toBe(204); - const [url, init] = spy.mock.calls[1] as [string, RequestInit]; - expect(url).toBe("https://api.github.com/repos/OpenStrap/reports/issues/7"); - expect(init.method).toBe("PATCH"); - const patch = JSON.parse(init.body as string); - expect(patch.state).toBe("closed"); - expect(JSON.stringify(patch)).not.toContain("meds"); - expect(JSON.stringify(patch)).not.toContain("Sync stuck"); - const rowA = await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(a.id).first(); - expect(rowA.u).toBeNull(); - - spy.mockResolvedValueOnce(issue(8)).mockRejectedValueOnce(new Error("down")); - const b = (await (await post(meta())).json()) as any; - expect((await del(b.id, b.deleteToken)).status).toBe(204); - const rowB = await e.DB.prepare("SELECT github_issue_url u, description d FROM reports WHERE id=?").bind(b.id).first(); - expect(rowB.d).toBe(""); - expect(rowB.u).toContain("/issues/8"); - - spy.mockResolvedValueOnce(new Response("{}", { status: 200 })); - expect((await cleanup(e)).issues).toBe(1); - expect((spy.mock.calls.at(-1) as [string])[0]).toBe("https://api.github.com/repos/OpenStrap/reports/issues/8"); - expect((await e.DB.prepare("SELECT github_issue_url u FROM reports WHERE id=?").bind(b.id).first()).u).toBeNull(); - spy.mockRestore(); - }); }); describe("admin + health", () => { @@ -343,7 +294,7 @@ describe("cleanup cron", () => { await e.DB.prepare(`INSERT INTO reports (id,install_id,created_at,category,title,description,meta,blob_bytes,has_db,delete_token_hash) VALUES ('OS-OLD001','i',?,'sync','t','d','{}',0,0,'h')`).bind(ago(91)).run(); const r = await cleanup(e); - expect(r).toEqual({ blobs: 2, rows: 1, issues: 0 }); + expect(r).toEqual({ blobs: 2, rows: 1 }); expect(await e.BLOBS.head("db/OS-DB0001")).toBeNull(); expect(await e.BLOBS.head("db/OS-DB0002")).not.toBeNull(); expect(await e.BLOBS.head("logs/OS-LG0001")).toBeNull(); @@ -364,23 +315,6 @@ describe("cleanup cron", () => { expect(await stat(today())).toBe(7); }); - it("retracts the issue of a row reaching 90 days and keeps the row until that succeeds", async () => { - e.GITHUB_TOKEN = "t"; e.GITHUB_REPO = "OpenStrap/reports"; - await e.DB.prepare(`INSERT INTO reports (id,install_id,created_at,category,title,description,meta,blob_bytes,has_db,delete_token_hash,github_issue_url) - VALUES ('OS-OLD003','i',?,'sync','t','private detail','{}',0,0,'h','https://github.com/OpenStrap/reports/issues/9')`) - .bind(new Date(Date.now() - 91 * 864e5).toISOString()).run(); - const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("{}", { status: 502 })); - expect(await cleanup(e)).toEqual({ blobs: 0, rows: 0, issues: 0 }); - expect(await e.DB.prepare("SELECT id FROM reports WHERE id='OS-OLD003'").first()).not.toBeNull(); - spy.mockResolvedValueOnce(new Response("{}", { status: 200 })); - expect(await cleanup(e)).toEqual({ blobs: 0, rows: 1, issues: 1 }); - const [url, init] = spy.mock.calls[1] as [string, RequestInit]; - expect(url).toBe("https://api.github.com/repos/OpenStrap/reports/issues/9"); - expect(init.method).toBe("PATCH"); - expect(await e.DB.prepare("SELECT id FROM reports WHERE id='OS-OLD003'").first()).toBeNull(); - spy.mockRestore(); - }); - it("concurrent posts cannot both pass the byte ceiling", async () => { await setStat("bytes", LIMITS.hardCeilingBytes - 300); const rs = await Promise.all([post(meta({ installId: "423e4567-e89b-42d3-a456-426614174000" })), post(meta({ installId: "523e4567-e89b-42d3-a456-426614174000" }))]); From 0b26d84a44940064c4e9c7e4e1e635d680f2f460 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 16:46:21 +0530 Subject: [PATCH 39/53] delete selected-day exports with everything else, and keep the report being shared "delete everything" missed the openstrap_days_*.db copies a day export leaves in the cache. the report prune ranked archives by the clock stamp in their name, so a clock set back could delete the one about to be shared. --- lib/data/export_retention.dart | 13 +++++++++---- lib/ui2/profile/report_issue.dart | 2 +- test/export_retention_test.dart | 11 +++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/data/export_retention.dart b/lib/data/export_retention.dart index 9c6f98065..74e8e714c 100644 --- a/lib/data/export_retention.dart +++ b/lib/data/export_retention.dart @@ -88,18 +88,23 @@ final _reportName = RegExp(r'^openstrap_report_(\d+)\.zip$'); /// export, never hears back, so the zip was never deleted — and one built /// with the full database is a complete plaintext copy of the health record. /// The previous one is kept in case its share sheet is still reading it. +/// [current], the archive about to be shared, is never deleted: its name +/// stamp comes from the clock, and a clock set back after earlier reports +/// would rank it oldest. Future pruneSharedReports( Directory tmpRoot, { int keep = kSharedExportsKept, + File? current, }) async { try { final byStamp = {}; for (final f in tmpRoot.listSync().whereType()) { + if (current != null && p.equals(f.path, current.path)) continue; final m = _reportName.firstMatch(p.basename(f.path)); if (m != null) byStamp[int.parse(m.group(1)!)] = f; } final stamps = byStamp.keys.toList()..sort((a, b) => b.compareTo(a)); - for (final s in stamps.skip(keep)) { + for (final s in stamps.skip(current == null ? keep : keep - 1)) { try { await byStamp[s]!.delete(); } catch (_) {} @@ -110,11 +115,11 @@ Future pruneSharedReports( } final _healthCopyName = RegExp( - r'^openstrap_(export_\d+\.db(\.osbk)?|report_\d+(\.zip|\.db\.gz)|slice_\d+\.db)$'); + r'^openstrap_(export_\d+\.db(\.osbk)?|report_\d+(\.zip|\.db\.gz)|(slice|days)_\d+\.db)$'); /// Delete every copy of the health record the app has handed to a share -/// sheet or staged in [tmpRoot]: manual exports, CSV runs, issue-report -/// archives and their intermediates. +/// sheet or staged in [tmpRoot]: manual exports, selected-day exports, CSV +/// runs, issue-report archives and their intermediates. /// /// For "Delete everything". The retention sweeps above keep the newest /// couple on purpose, and the OS purges the cache only when it likes, so a diff --git a/lib/ui2/profile/report_issue.dart b/lib/ui2/profile/report_issue.dart index 8c4a6a811..d924331ca 100644 --- a/lib/ui2/profile/report_issue.dart +++ b/lib/ui2/profile/report_issue.dart @@ -150,7 +150,7 @@ class _ReportIssueScreenState extends State { try { final r = await collectReport(context.read(), _draft(), onStage: (st) => _setStep(_stageText(l, st))); - await pruneSharedReports(r.zip.parent); + await pruneSharedReports(r.zip.parent, current: r.zip); _setStep(l.reportStageSharing); await Share.shareXFiles([XFile(r.zip.path)], subject: 'OpenStrap issue report', sharePositionOrigin: origin); diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart index 81c5ea565..cf56802dc 100644 --- a/test/export_retention_test.dart +++ b/test/export_retention_test.dart @@ -155,6 +155,16 @@ void main() { 'openstrap_report_50.db.gz', ]); }); + + test('the archive being shared survives a clock set back', () async { + for (final n in ['openstrap_report_8.zip', 'openstrap_report_9.zip']) { + await _touch(dir, n); + } + // Built after the clock went back, so its stamp ranks oldest. + final current = await _touch(dir, 'openstrap_report_5.zip'); + await pruneSharedReports(dir, current: current); + expect(_names(dir), ['openstrap_report_5.zip', 'openstrap_report_9.zip']); + }); }); group('purgeSharedHealthCopies', () { @@ -175,6 +185,7 @@ void main() { 'openstrap_report_7.zip', 'openstrap_report_8.db.gz', 'openstrap_slice_9.db', + 'openstrap_days_10.db', 'unrelated.db', ]) { await _touch(dir, n); From d8edbb5c9ad72a53e2e83a77172254e889ab9be0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 16:46:21 +0530 Subject: [PATCH 40/53] count only whole worn days in the weekly energy balance a day's burn is pro-rated over the minutes the band recorded, so a day it spent half off the wrist read as a surplus. imported vendor totals are left out too, so the average is one estimate. --- lib/data/nutrition_store.dart | 17 ++++++++++++--- lib/ui2/screens/nutrition_screen.dart | 19 +++++++++++------ test/nutrition_energy_balance_test.dart | 28 +++++++++++++++++++++---- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/lib/data/nutrition_store.dart b/lib/data/nutrition_store.dart index a77547b3f..7ff2137a4 100644 --- a/lib/data/nutrition_store.dart +++ b/lib/data/nutrition_store.dart @@ -472,16 +472,27 @@ class EnergyBalance { double get balance => eaten - burned; } -/// [burnedByDate] maps a local day label to that day's total burn. Null when -/// no counted day has one — the card then has nothing honest to subtract. +/// Worn minutes a day needs before its total burn counts as a whole day. +/// The basal floor is pro-rated over the minutes the band recorded, so a day +/// it spent half off the wrist burns half a BMR and reads as a surplus. +/// About an hour off the wrist (a charge) still counts. +const int kWholeDayWornMin = 1380; + +/// [burnedByDate] maps a local day label to that day's total burn, and +/// [wornMinByDate] to its worn minutes. A day without a whole day's wear +/// drops out of both sides. Null when no counted day is left — the card then +/// has nothing honest to subtract. EnergyBalance? weeklyEnergyBalance( NutritionWindow w, Map burnedByDate, + Map wornMinByDate, ) { var eaten = 0.0, burned = 0.0, n = 0; for (final d in w.counted) { final b = burnedByDate[d.date]; - if (b == null || !d.kcal.complete) continue; + final worn = wornMinByDate[d.date]; + if (b == null || worn == null || worn < kWholeDayWornMin) continue; + if (!d.kcal.complete) continue; eaten += d.kcal.value!; burned += b; n++; diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index 87a9c2746..d29953cc5 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -63,6 +63,7 @@ class _NutritionScreenState extends State with RevisionReload { /// apart from [_burned], which is today's running total and only fits the /// per-day card. Map _burnedByDate = const {}; + Map _wornMinByDate = const {}; double? _waterMl; /// The day [_waterMl] was read for. Past midnight on a tab nobody reloaded /// it is still yesterday's, and a full yesterday disabled today's + button. @@ -93,11 +94,16 @@ class _NutritionScreenState extends State with RevisionReload { final db = await LocalDb.instance; final week = await NutritionDb.window(db, days: 7); final weekDates = {for (final d in week.days) d.date}; - final burnedByDate = { - for (final r in await LocalDb.metricSeries('calories_total')) - if (weekDates.contains(r['date'])) - r['date'] as String: (r['value'] as num).toDouble(), - }; + // Measured days only: an imported vendor total is a different estimate, + // and averaging it with ours mixes two scales in one figure. + Map weekOf(List> rows) => { + for (final r in rows) + if (weekDates.contains(r['date'])) + r['date'] as String: (r['value'] as num).toDouble(), + }; + final burnedByDate = weekOf( + await LocalDb.metricSeries('calories_total', measuredOnly: true)); + final wornMinByDate = weekOf(await LocalDb.metricSeries('worn_min')); Metric? burned; double? water; final date = _date; @@ -113,6 +119,7 @@ class _NutritionScreenState extends State with RevisionReload { _week = week; _burned = burned; _burnedByDate = burnedByDate; + _wornMinByDate = wornMinByDate; _waterMl = water; _waterDate = date; _profile = {...?app.user}; @@ -382,7 +389,7 @@ class _NutritionScreenState extends State with RevisionReload { final w = _week; if (w == null) return const SizedBox.shrink(); final counted = w.counted.length; - final balance = weeklyEnergyBalance(w, _burnedByDate); + final balance = weeklyEnergyBalance(w, _burnedByDate, _wornMinByDate); // PARTIAL, not "excluded": today is excluded from the averages too, and it // is not partial — a day cannot be judged incomplete while it is still // happening (see `DayLogState.inProgress`). diff --git a/test/nutrition_energy_balance_test.dart b/test/nutrition_energy_balance_test.dart index c66c3b2f0..572fc3a89 100644 --- a/test/nutrition_energy_balance_test.dart +++ b/test/nutrition_energy_balance_test.dart @@ -39,7 +39,7 @@ void main() { '2026-08-13': 2350, '2026-08-14': 2250, today: 621, - })!; + }, _worn(['2026-08-13', '2026-08-14', today]))!; expect(b.days, 2); expect(b.eaten, 2300); expect(b.burned, 2300); @@ -51,7 +51,8 @@ void main() { _complete('2026-08-12', 3000, today), _complete('2026-08-13', 2000, today), ]); - final b = weeklyEnergyBalance(w, {'2026-08-13': 2200})!; + final b = weeklyEnergyBalance( + w, {'2026-08-13': 2200}, _worn(['2026-08-12', '2026-08-13']))!; expect(b.days, 1); expect(b.eaten, 2000); expect(b.burned, 2200); @@ -63,7 +64,26 @@ void main() { _complete('2026-08-14', 2300, today), rollupDay(today, [_entry(today, 500, 8)], today: today), ]); - expect(weeklyEnergyBalance(w, {today: 621}), isNull); - expect(weeklyEnergyBalance(w, const {}), isNull); + expect(weeklyEnergyBalance(w, {today: 621}, _worn([today])), isNull); + expect(weeklyEnergyBalance(w, const {}, const {}), isNull); + }); + + test('a day the band was off for hours drops out of both sides', () { + final w = NutritionWindow([ + _complete('2026-08-13', 2300, today), + _complete('2026-08-14', 2300, today), + ]); + // 14th: worn 12 h, so its burn holds half a basal day. + final b = weeklyEnergyBalance( + w, + {'2026-08-13': 2300, '2026-08-14': 1400}, + {'2026-08-13': 1440, '2026-08-14': 720}, + )!; + expect(b.days, 1); + expect(b.balance, 0); + // No wear figure at all is not a whole day either. + expect(weeklyEnergyBalance(w, {'2026-08-14': 1400}, const {}), isNull); }); } + +Map _worn(List days) => {for (final d in days) d: 1440}; From fc42b1cd1dae4f5c1f4b5343d8c834ac9cf74871 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 16:46:21 +0530 Subject: [PATCH 41/53] do not save a coach chat back after the data was deleted a reply still in flight when the reset ran was persisted after the chat files were swept, writing the deleted conversation back. --- lib/coach/coach_engine.dart | 8 +++++++- test/coach_reset_delete_test.dart | 34 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 22b9873ea..f80b372b0 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -23,6 +23,7 @@ import '../data/day_label.dart'; import '../data/db.dart'; import '../data/local_repository.dart'; import '../models/payloads.dart' show todayHeadlineOf; +import '../sync/reset_gate.dart'; import 'coach_actions.dart'; import 'coach_chat_completions.dart'; import 'coach_config.dart'; @@ -168,6 +169,11 @@ class CoachEngine { final CoachConfig config; final LocalRepository api; final String storageKey; // per-user, so accounts don't share a transcript + + /// The data generation this engine started under. A reply still in flight + /// when "Delete everything" runs lands after the chat files were swept, and + /// saving it would write the deleted conversation back. + final int _resetGen = ResetGate.generation; final http.Client _http; /// Count of [send] calls currently inside their provider call(s). A local @@ -442,7 +448,7 @@ class CoachEngine { /// Persist the current session (caps history, updates the index). Future persist() async { - if (transcript.isEmpty) return; + if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; if (_createdAt == 0) _createdAt = now; diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart index 05ae2a066..5e69bc3a6 100644 --- a/test/coach_reset_delete_test.dart +++ b/test/coach_reset_delete_test.dart @@ -1,9 +1,15 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_config.dart'; import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/sync/reset_gate.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +class _FakeRepo extends LocalRepository {} + class _DocumentsProvider extends PathProviderPlatform { _DocumentsProvider(this.root); final String root; @@ -44,4 +50,32 @@ void main() { expect(await File('${dir.path}/$n').exists(), isTrue, reason: n); } }); + + test('a chat still answering when the data is deleted is not saved back', + () async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + final dir = await Directory.systemTemp.createTemp('coach-reset-'); + final original = PathProviderPlatform.instance; + PathProviderPlatform.instance = _DocumentsProvider(dir.path); + addTearDown(() async { + PathProviderPlatform.instance = original; + await dir.delete(recursive: true); + }); + final config = CoachConfig(); + addTearDown(config.dispose); + final engine = CoachEngine(config: config, api: _FakeRepo(), storageKey: 'local'); + addTearDown(engine.dispose); + engine.transcript.add(CoachItem.user('how did I sleep')); + + // The reset runs while the reply is still out. + ResetGate.enter(); + ResetGate.leave(); + await CoachEngine.deleteAllSavedSessions(); + + await engine.persist(); + final left = dir.listSync().map((e) => e.uri.pathSegments.last) + .where((n) => n.startsWith('coach_')); + expect(left, isEmpty); + }); } From 44f9c79f65dbf2492973f9423c9f88c7baeec762 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 16:46:21 +0530 Subject: [PATCH 42/53] lab units, sensor picker at the cap, and wording fixes a blank stored lab unit now shows the catalogue unit it is judged in. the add-sensor picker opens at the cap so a paired sensor can still be re-paired from it. the report delete confirmation says what will happen, and the nutrition note reads naturally in french, hindi, russian and chinese. --- lib/l10n/app_en.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/profile/devices.dart | 5 +++-- lib/ui2/screens/health_screen.dart | 11 +++++++++-- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 129c4a8e2..94e908c58 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15750,7 +15750,7 @@ } } }, - "reportDeleteConfirmBody": "It's removed from the server now, so it can't be opened from its ID any more. There's no undo.", + "reportDeleteConfirmBody": "It will be removed from the server straight away, and its ID won't open it any more. There's no undo.", "@reportDeleteConfirmBody": { "description": "Issue report: what deleting a sent report does." }, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 91965aec2..a980642e2 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "MANGÉ", "nutritionLabelBurned": "BRÛLÉ", "nutritionLabelBalance": "BILAN", - "nutritionEatenMeanNote": "Le mangé et le brûlé sont deux moyennes sur les mêmes {days} jours complets.", + "nutritionEatenMeanNote": "Les calories consommées et dépensées sont deux moyennes sur les mêmes {days} jours complets.", "nutritionEnergyLoggedTitle": "Énergie enregistrée", "nutritionPartialFootnote": "{n} partiel(s), exclu(s) des moyennes ci-dessous.", "nutritionNothingLoggedYet": "Rien d'enregistré pour l'instant", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9ce7544e1..39787fd6e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "खाया गया", "nutritionLabelBurned": "जला गया", "nutritionLabelBalance": "संतुलन", - "nutritionEatenMeanNote": "खाया गया और जला गया, दोनों उन्हीं {days} पूरे दिनों का औसत हैं।", + "nutritionEatenMeanNote": "ली गई और खर्च हुई कैलोरी, दोनों उन्हीं {days} पूरे दिनों का औसत हैं।", "nutritionEnergyLoggedTitle": "दर्ज की गई ऊर्जा", "nutritionPartialFootnote": "{n} अधूरे, नीचे के औसत से बाहर रखे गए।", "nutritionNothingLoggedYet": "अभी तक कुछ भी दर्ज नहीं हुआ", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d30467828..7e0819a9d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1727,7 +1727,7 @@ "nutritionLabelEaten": "СЪЕДЕНО", "nutritionLabelBurned": "ПОТРАЧЕНО", "nutritionLabelBalance": "БАЛАНС", - "nutritionEatenMeanNote": "Съедено и израсходовано — средние за одни и те же {days} полных дней.", + "nutritionEatenMeanNote": "Съедено и израсходовано — средние за {days, plural, one{один и тот же {days} полный день} few{одни и те же {days} полных дня} many{одни и те же {days} полных дней} other{одни и те же {days} полного дня}}.", "@nutritionEatenMeanNote": { "placeholders": { "days": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 60edd8aac..240da4fc3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -804,7 +804,7 @@ "nutritionLabelEaten": "摄入", "nutritionLabelBurned": "消耗", "nutritionLabelBalance": "平衡", - "nutritionEatenMeanNote": "摄入量和消耗量都是同样 {days} 个完整天的平均值。", + "nutritionEatenMeanNote": "摄入量和消耗量都是同一组 {days} 个完整天的平均值。", "nutritionEnergyLoggedTitle": "已记录的热量", "nutritionPartialFootnote": "{n} 天记录不完整,已从下面的平均值中排除。", "nutritionNothingLoggedYet": "还没有任何记录", diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index f89244f28..56c86d985 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1343,8 +1343,9 @@ Future refuseIfSensorCapReached(BuildContext c, /// Choose which kind of sensor to pair, then hand off to the pairing screen. Future addSensor(BuildContext c) async { - if (await refuseIfSensorCapReached(c)) return; - if (!c.mounted) return; + // No cap check here: at the cap the picker still has to open, so a sensor + // that already has a row can reach its own screen to re-pair or replace + // it. Each sensor and each pair is checked inside. // `includeBand: false` — this phone already has its one primary band; // re-pairing it is `RePair`'s job, not a row beside a chest strap here. await goto(c, const DevicePickerScreen(includeBand: false)); diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 99938963a..56fcabe7c 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1507,11 +1507,18 @@ class _HealthScreenState extends State with RevisionReload { ? m.label : uiText(context, m.label); + /// A blank stored unit means the catalogue's (see [LabMarker.judgesUnit]), + /// so the row never prints a verdict with no unit beside it. + static String _unitOf(LabMarker? m, Map r) { + final stored = '${r['unit'] ?? ''}'.trim(); + return stored.isEmpty ? (m?.unit ?? '') : stored; + } + Widget _lab(P p, LabMarker? m, Map r, String? sex, VoidCallback onRemove) { final l = AppLocalizations.of(context); final v = (r['value'] as num?)?.toDouble(); - final unit = (r['unit'] ?? m?.unit ?? '').toString(); + final unit = _unitOf(m, r); // A result in another unit than the catalogue's gets no verdict at all: // the interval is in the catalogue's unit, not the one printed beside it. final judge = m != null && m.judgesUnit(r['unit']) ? m : null; @@ -1593,7 +1600,7 @@ class _HealthScreenState extends State with RevisionReload { final takenOn = r['taken_on'].toString(); final label = _markerName(m, marker); final v = (r['value'] as num).toDouble(); - final unit = (r['unit'] ?? m?.unit ?? '').toString(); + final unit = _unitOf(m, r); // The row on screen is the NEWEST draw of its marker, so an earlier one // takes its place rather than the marker disappearing — which without // being told reads as the delete having failed. From 604b48225399f2b8defd519b795b1ed609ca8789 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 17:08:48 +0530 Subject: [PATCH 43/53] queue coach chat saves behind the reset's sweep a save that had already passed its reset check could still be writing while the sweep listed the folder, and land after it. saves and the sweep now take turns, and the check runs once a save's turn comes. --- lib/coach/coach_engine.dart | 21 +++++++++++++++++++-- test/coach_reset_delete_test.dart | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index f80b372b0..002a804da 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -371,7 +371,20 @@ class CoachEngine { /// them, and the key is the same 'local' after a reset — the next profile /// would reopen the previous one's conversations, tool results included. /// Returns the number of files that could not be deleted. - static Future deleteAllSavedSessions() async { + static Future deleteAllSavedSessions() => + _serialFiles(_deleteAllSavedSessions); + + /// One queue for every chat-file write and the reset's sweep. A save that + /// had already passed its reset check could otherwise still be writing + /// while the sweep listed the folder, and land after it. + static Future _filesTail = Future.value(); + static Future _serialFiles(Future Function() body) { + final run = _filesTail.then((_) => body()); + _filesTail = run.then((_) {}, onError: (_) {}); + return run; + } + + static Future _deleteAllSavedSessions() async { final dir = await getApplicationDocumentsDirectory(); if (!await dir.exists()) return 0; var failed = 0; @@ -447,7 +460,11 @@ class CoachEngine { } /// Persist the current session (caps history, updates the index). - Future persist() async { + Future persist() => _serialFiles(_persist); + + Future _persist() async { + // Checked inside the queue: a reset that started while this waited has + // swept the files by now, and this must not write the chat back. if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart index 5e69bc3a6..1584cff4b 100644 --- a/test/coach_reset_delete_test.dart +++ b/test/coach_reset_delete_test.dart @@ -78,4 +78,34 @@ void main() { .where((n) => n.startsWith('coach_')); expect(left, isEmpty); }); + + test('a save already under way when the sweep runs is swept with it', + () async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + final dir = await Directory.systemTemp.createTemp('coach-reset-'); + final original = PathProviderPlatform.instance; + PathProviderPlatform.instance = _DocumentsProvider(dir.path); + addTearDown(() async { + PathProviderPlatform.instance = original; + await dir.delete(recursive: true); + }); + final config = CoachConfig(); + addTearDown(config.dispose); + final engine = CoachEngine(config: config, api: _FakeRepo(), storageKey: 'local'); + addTearDown(engine.dispose); + engine.transcript.add(CoachItem.user('how did I sleep')); + + // The save passes its reset check, then the reset starts and sweeps + // while the save is still writing. + final saving = engine.persist(); + ResetGate.enter(); + final swept = CoachEngine.deleteAllSavedSessions(); + await Future.wait([saving, swept]); + ResetGate.leave(); + + final left = dir.listSync().map((e) => e.uri.pathSegments.last) + .where((n) => n.startsWith('coach_')); + expect(left, isEmpty); + }); } From df182d2b8d8838f04adbda8072f62d94c2884d9f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 17:08:48 +0530 Subject: [PATCH 44/53] lab results in another unit print as stored, and purge export sidecars the catalogue's decimals are for its own unit, so 5.4 mmol/L of glucose printed as 5. the reset purge also takes the journal and wal files an export leaves if the app dies mid-write, and the russian nutrition note no longer repeats the number for a single day. --- lib/data/export_retention.dart | 2 +- lib/l10n/app_ru.arb | 2 +- lib/ui2/screens/health_screen.dart | 16 +++++++++++++--- test/export_retention_test.dart | 3 +++ test/ui2_labs_delete_test.dart | 25 +++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/data/export_retention.dart b/lib/data/export_retention.dart index 74e8e714c..3376bb236 100644 --- a/lib/data/export_retention.dart +++ b/lib/data/export_retention.dart @@ -115,7 +115,7 @@ Future pruneSharedReports( } final _healthCopyName = RegExp( - r'^openstrap_(export_\d+\.db(\.osbk)?|report_\d+(\.zip|\.db\.gz)|(slice|days)_\d+\.db)$'); + r'^openstrap_(export_\d+\.db(\.osbk)?|report_\d+(\.zip|\.db\.gz)|(slice|days)_\d+\.db(-journal|-wal|-shm)?)$'); /// Delete every copy of the health record the app has handed to a share /// sheet or staged in [tmpRoot]: manual exports, selected-day exports, CSV diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7e0819a9d..671d7e5d0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1727,7 +1727,7 @@ "nutritionLabelEaten": "СЪЕДЕНО", "nutritionLabelBurned": "ПОТРАЧЕНО", "nutritionLabelBalance": "БАЛАНС", - "nutritionEatenMeanNote": "Съедено и израсходовано — средние за {days, plural, one{один и тот же {days} полный день} few{одни и те же {days} полных дня} many{одни и те же {days} полных дней} other{одни и те же {days} полного дня}}.", + "nutritionEatenMeanNote": "Съедено и израсходовано — средние за {days, plural, one{{days} полный день} few{одни и те же {days} полных дня} many{одни и те же {days} полных дней} other{одни и те же {days} полного дня}}.", "@nutritionEatenMeanNote": { "placeholders": { "days": { diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 56fcabe7c..60ab74886 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1509,6 +1509,16 @@ class _HealthScreenState extends State with RevisionReload { /// A blank stored unit means the catalogue's (see [LabMarker.judgesUnit]), /// so the row never prints a verdict with no unit beside it. + /// The catalogue's decimals belong to its own unit: 5.4 mmol/L of glucose + /// at the mg/dL catalogue's 0 decimals would print as 5. A result in another + /// unit prints as it was stored. + static String _valueOf(LabMarker? m, Map r, double v) => + m != null && m.judgesUnit(r['unit']) + ? m.format(v) + : v == v.roundToDouble() + ? v.round().toString() + : '$v'; + static String _unitOf(LabMarker? m, Map r) { final stored = '${r['unit'] ?? ''}'.trim(); return stored.isEmpty ? (m?.unit ?? '') : stored; @@ -1575,7 +1585,7 @@ class _HealthScreenState extends State with RevisionReload { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - Text(v == null ? '' : (m?.format(v) ?? v.toString()), + Text(v == null ? '' : _valueOf(m, r, v), style: F.n17.copyWith( color: inRange == false ? p.on(C.orange) : p.ink)), const SizedBox(width: 3), @@ -1613,8 +1623,8 @@ class _HealthScreenState extends State with RevisionReload { context, title: loc?.healthRemoveLabelFrom(label, takenOn) ?? 'Remove $label from $takenOn?', - body: (loc?.healthRemoveLabBody(m?.format(v) ?? _num(v), unit) ?? - 'The ${m?.format(v) ?? _num(v)} $unit you logged for that draw. ' + body: (loc?.healthRemoveLabBody(_valueOf(m, r, v), unit) ?? + 'The ${_valueOf(m, r, v)} $unit you logged for that draw. ' 'It leaves this device and there is no undo.') + (older == null ? '' diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart index cf56802dc..e806f0a37 100644 --- a/test/export_retention_test.dart +++ b/test/export_retention_test.dart @@ -186,6 +186,9 @@ void main() { 'openstrap_report_8.db.gz', 'openstrap_slice_9.db', 'openstrap_days_10.db', + 'openstrap_days_11.db-journal', + 'openstrap_days_12.db-wal', + 'openstrap_days_12.db-shm', 'unrelated.db', ]) { await _touch(dir, n); diff --git a/test/ui2_labs_delete_test.dart b/test/ui2_labs_delete_test.dart index 37df7fe9d..db5863103 100644 --- a/test/ui2_labs_delete_test.dart +++ b/test/ui2_labs_delete_test.dart @@ -158,6 +158,31 @@ void main() { ); }); + testWidgets('a result in another unit prints as stored, on the row and in the confirm', + (t) async { + // The catalogue's glucose is mg/dL at 0 decimals; an import in mmol/L + // must not print 5.4 as 5. + final labs = await _seed(t, [ + ['glucose_fasting', '2026-03-04', 5.4, 'mmol/L'], + ]); + await _pumpLabs(t, labs); + expect(find.text('5.4'), findsOneWidget); + expect(find.text('5'), findsNothing); + expect(find.text('mmol/L'), findsOneWidget); + + await t.tap(_control('Remove Fasting glucose from 2026-03-04')); + await t.pumpAndSettle(); + expect(find.textContaining('5.4 mmol/L'), findsOneWidget); + }); + + testWidgets('a result stored with no unit shows the catalogue unit', (t) async { + final labs = await _seed(t, [ + ['ferritin', '2026-03-04', 42, ''], + ]); + await _pumpLabs(t, labs); + expect(find.text('ng/mL'), findsOneWidget); + }); + testWidgets('a removed result leaves the store AND the screen, in place', (t) async { final labs = await _seed(t, _ferritinAndHba1c); From 22a586fe6318262bdce544bc79a9042b77932a08 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 17:26:23 +0530 Subject: [PATCH 45/53] test the coach save the sweep has to wait for the queued save ran its reset check after the test had already started the reset, so it returned early and the wait was never exercised. a test hook now holds a save past its check while the sweep runs. --- lib/coach/coach_engine.dart | 6 ++++++ test/coach_reset_delete_test.dart | 21 ++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 002a804da..f2669323d 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -378,6 +378,11 @@ class CoachEngine { /// had already passed its reset check could otherwise still be writing /// while the sweep listed the folder, and land after it. static Future _filesTail = Future.value(); + + /// Test seam: awaited by a save right after its reset check passes, so a + /// test can hold a save mid-write while a reset sweeps. + @visibleForTesting + static Future Function()? debugAfterSaveCheck; static Future _serialFiles(Future Function() body) { final run = _filesTail.then((_) => body()); _filesTail = run.then((_) {}, onError: (_) {}); @@ -466,6 +471,7 @@ class CoachEngine { // Checked inside the queue: a reset that started while this waited has // swept the files by now, and this must not write the chat back. if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; + await debugAfterSaveCheck?.call(); if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; if (_createdAt == 0) _createdAt = now; diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart index 1584cff4b..c137210b2 100644 --- a/test/coach_reset_delete_test.dart +++ b/test/coach_reset_delete_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -86,7 +87,13 @@ void main() { final dir = await Directory.systemTemp.createTemp('coach-reset-'); final original = PathProviderPlatform.instance; PathProviderPlatform.instance = _DocumentsProvider(dir.path); + final paused = Completer(), release = Completer(); + CoachEngine.debugAfterSaveCheck = () { + paused.complete(); + return release.future; + }; addTearDown(() async { + CoachEngine.debugAfterSaveCheck = null; PathProviderPlatform.instance = original; await dir.delete(recursive: true); }); @@ -96,11 +103,19 @@ void main() { addTearDown(engine.dispose); engine.transcript.add(CoachItem.user('how did I sleep')); - // The save passes its reset check, then the reset starts and sweeps - // while the save is still writing. + // The save is past its reset check and holding when the reset starts. final saving = engine.persist(); + await paused.future; ResetGate.enter(); - final swept = CoachEngine.deleteAllSavedSessions(); + var sweptDone = false; + final swept = + CoachEngine.deleteAllSavedSessions().then((_) => sweptDone = true); + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } + expect(sweptDone, isFalse, reason: 'the sweep waits for the save'); + + release.complete(); await Future.wait([saving, swept]); ResetGate.leave(); From 2e35f7e548e2c58dead7a68f61b3b8a48c21077a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 17:26:23 +0530 Subject: [PATCH 46/53] keep the export being handed back, and let a long lab value wrap the export prune ranked copies by the clock stamp in their name, so a clock set back could delete the new export before it was returned. a lab value in another unit prints every stored digit, and a long one pushed the row off the screen at large text sizes. --- lib/data/export_retention.dart | 12 +++++++++--- lib/ui2/profile/data.dart | 2 +- lib/ui2/screens/health_screen.dart | 23 ++++++++++++++++------- test/export_retention_test.dart | 14 ++++++++++++++ test/ui2_labs_delete_test.dart | 29 +++++++++++++++++++++-------- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/lib/data/export_retention.dart b/lib/data/export_retention.dart index 3376bb236..e7b35d709 100644 --- a/lib/data/export_retention.dart +++ b/lib/data/export_retention.dart @@ -31,20 +31,26 @@ final _exportName = RegExp(r'^openstrap_export_(\d+)\.db(\.osbk)?$'); /// /// An export is identified by the millisecond stamp in its name, so the /// plaintext `.db` and its `.osbk` count as one export. Files that do not -/// match the export name are left alone. +/// match the export name are left alone. [current], the export about to be +/// handed back, is never deleted: its stamp comes from the clock, and a clock +/// set back after earlier exports would rank it oldest. Future pruneSharedExports( Directory dir, { int keep = kSharedExportsKept, + String? current, }) async { try { + final currentStamp = current == null + ? null + : _exportName.firstMatch(p.basename(current))?.group(1); final byStamp = >{}; for (final f in dir.listSync().whereType()) { final m = _exportName.firstMatch(p.basename(f.path)); - if (m == null) continue; + if (m == null || m.group(1) == currentStamp) continue; byStamp.putIfAbsent(int.parse(m.group(1)!), () => []).add(f); } final stamps = byStamp.keys.toList()..sort((a, b) => b.compareTo(a)); - for (final s in stamps.skip(keep)) { + for (final s in stamps.skip(currentStamp == null ? keep : keep - 1)) { for (final f in byStamp[s]!) { try { await f.delete(); diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index bf10e2871..dec608392 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -184,7 +184,7 @@ class _DataScreenState extends State { Future _manualExportCopy() async { final dir = await sharedExportDir(); final path = await LocalDb.exportCopy(into: dir); - await pruneSharedExports(dir); + await pruneSharedExports(dir, current: path); await pruneLegacyRootExports(dir.parent); return path; } diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 60ab74886..ec0bef8fd 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1581,16 +1581,25 @@ class _HealthScreenState extends State with RevisionReload { ]), ), const SizedBox(width: S.x2), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(v == null ? '' : _valueOf(m, r, v), + // Sized to its text like before, but capped: a result in another + // unit prints every digit it was stored with, and at large text a + // long one would push the row past the screen. It wraps instead. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.4), + child: Text.rich( + TextSpan(children: [ + TextSpan( + text: v == null ? '' : _valueOf(m, r, v), style: F.n17.copyWith( color: inRange == false ? p.on(C.orange) : p.ink)), - const SizedBox(width: 3), - Text(unit, style: F.over.copyWith(color: p.ink3)), + const TextSpan(text: '\u2009'), + TextSpan(text: unit, style: F.over.copyWith(color: p.ink3)), ]), + textAlign: TextAlign.end, + softWrap: true, + ), + ), // This is the user's own blood work in an app that keeps it on their // phone; being able to take it back out is the premise, not a setting. const SizedBox(width: S.x2), diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart index e806f0a37..b0756b7e7 100644 --- a/test/export_retention_test.dart +++ b/test/export_retention_test.dart @@ -54,6 +54,20 @@ void main() { ]); }); + test('the export being handed back survives a clock set back', () async { + await _touch(dir, 'openstrap_export_8000.db'); + await _touch(dir, 'openstrap_export_9000.db'); + // Written after the clock went back, so its stamp ranks oldest. + final current = await _touch(dir, 'openstrap_export_5000.db'); + + await pruneSharedExports(dir, current: current.path); + + expect(_names(dir), [ + 'openstrap_export_5000.db', + 'openstrap_export_9000.db', + ]); + }); + test('leaves files that are not exports alone', () async { await _touch(dir, 'openstrap_export_1.db'); await _touch(dir, 'openstrap_export_2.db'); diff --git a/test/ui2_labs_delete_test.dart b/test/ui2_labs_delete_test.dart index db5863103..1b36ee421 100644 --- a/test/ui2_labs_delete_test.dart +++ b/test/ui2_labs_delete_test.dart @@ -123,7 +123,7 @@ void main() { for (final scale in const [1.0, 2.0, 3.0]) { await _pumpLabs(t, labs, scale: scale); expect(find.text('Ferritin'), findsOneWidget); - expect(find.text('42'), findsOneWidget); + expect(find.text('42\u2009ng/mL'), findsOneWidget); expect( _control('Remove Ferritin from 2026-03-04'), findsOneWidget, @@ -166,9 +166,8 @@ void main() { ['glucose_fasting', '2026-03-04', 5.4, 'mmol/L'], ]); await _pumpLabs(t, labs); - expect(find.text('5.4'), findsOneWidget); - expect(find.text('5'), findsNothing); - expect(find.text('mmol/L'), findsOneWidget); + expect(find.text('5.4\u2009mmol/L'), findsOneWidget); + expect(find.text('5\u2009mmol/L'), findsNothing); await t.tap(_control('Remove Fasting glucose from 2026-03-04')); await t.pumpAndSettle(); @@ -180,7 +179,20 @@ void main() { ['ferritin', '2026-03-04', 42, ''], ]); await _pumpLabs(t, labs); - expect(find.text('ng/mL'), findsOneWidget); + expect(find.text('42\u2009ng/mL'), findsOneWidget); + }); + + testWidgets('a long value in another unit wraps instead of overflowing', + (t) async { + final labs = await _seed(t, [ + ['glucose_fasting', '2026-03-04', 5.123456789012345, 'mmol/L'], + ]); + for (final scale in const [1.0, 3.0]) { + await _pumpLabs(t, labs, scale: scale); + expect(find.textContaining('5.123456789012345'), findsOneWidget); + expect(t.takeException(), isNull, + reason: 'nothing overflowed at ${scale}x'); + } }); testWidgets('a removed result leaves the store AND the screen, in place', @@ -218,7 +230,7 @@ void main() { // underneath surfaces in its place. expect(find.textContaining('2025-11-02 draw stays'), findsOneWidget); await t.tap(find.text('Remove')); - await _until(t, find.text('61')); + await _until(t, find.text('61\u2009ng/mL')); expect(find.text('Ferritin'), findsOneWidget); expect(find.textContaining('Showing your 2025-11-02 draw now'), @@ -266,8 +278,9 @@ void main() { await t.pumpAndSettle(); await t.enterText(find.byType(TextField).first, '42'); await t.tap(find.text('Save')); - await _until(t, find.text('42')); - expect(find.text('42'), findsOneWidget); + // The form's first marker is albumin: g/dL at one decimal. + await _until(t, find.text('42.0\u2009g/dL')); + expect(find.text('42.0\u2009g/dL'), findsOneWidget); }); }); From 6de9420605881905a95f16f7c0ee7654fbab1176 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 17:40:54 +0530 Subject: [PATCH 47/53] a deleted coach chat stays deleted, and the history budget counts bytes deleting the open chat while its reply was still coming in left the save after the reply to write it back, and a delete could interleave with a save already writing. deletes now take their turn with saves, and what a deleted chat's reply adds afterwards is dropped. the history budget counted characters while the limit and the request check are in bytes, so non-ASCII history got through the trim and then failed the request. --- lib/coach/coach_engine.dart | 29 ++++- test/coach_delete_session_test.dart | 180 ++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 test/coach_delete_session_test.dart diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index f2669323d..b61748cea 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -193,6 +193,11 @@ class CoachEngine { /// `finally` sees the count reach zero, not before. bool _disposeRequested = false; + /// Set when the open chat is deleted while a [send] is still running. What + /// that send adds afterwards is the deleted chat's tail: it is dropped + /// instead of saved. + bool _discardUnsaved = false; + // OpenAI-format running history (system is added per-request) — the context we // resend every turn so the model remembers the conversation. final List> _history = []; @@ -284,7 +289,10 @@ class CoachEngine { int _historyChars() { var n = 0; for (final m in _history) { - n += jsonEncode(m).length; + // Bytes, as the limit says and as the request size is checked: a + // character count lets non-ASCII history through the trim and then + // over kMaxRequestBytes. + n += utf8.encode(jsonEncode(m)).length; } return n; } @@ -471,6 +479,13 @@ class CoachEngine { // Checked inside the queue: a reset that started while this waited has // swept the files by now, and this must not write the chat back. if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; + if (_discardUnsaved) { + if (_sending == 0) { + _discardUnsaved = false; + newSession(); + } + return; + } await debugAfterSaveCheck?.call(); if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; @@ -513,7 +528,10 @@ class CoachEngine { } /// Delete a session (and start fresh if it was the current one). - Future deleteSession(String id) async { + Future deleteSession(String id) => + _serialFiles(() => _deleteSession(id)); + + Future _deleteSession(String id) async { try { final f = await _sessionFile(id); if (await f.exists()) await f.delete(); @@ -523,7 +541,12 @@ class CoachEngine { final f = await _indexFile(); await f.writeAsString(jsonEncode(metas.map((m) => m.toJson()).toList())); } catch (_) {} - if (id == _sessionId) newSession(); + if (id == _sessionId) { + // A reply still coming in belongs to the chat just deleted; its save + // would write that chat back under a new id. + if (_sending > 0) _discardUnsaved = true; + newSession(); + } } String _deriveTitle() { diff --git a/test/coach_delete_session_test.dart b/test/coach_delete_session_test.dart new file mode 100644 index 000000000..59514b85e --- /dev/null +++ b/test/coach_delete_session_test.dart @@ -0,0 +1,180 @@ +// Deleting a chat and the history budget, both against the real engine. +// +// A chat deleted while its reply was still coming in was written back by the +// save that followed the reply, and a delete could interleave with a save +// already writing. The history budget counted characters while the limit and +// the request check are in bytes, so non-ASCII history slipped through. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeRepo extends LocalRepository {} + +class _DocumentsProvider extends PathProviderPlatform { + _DocumentsProvider(this.root); + final String root; + + @override + Future getApplicationDocumentsPath() async => root; +} + +Future _documents() async { + final dir = await Directory.systemTemp.createTemp('coach-delete-'); + final original = PathProviderPlatform.instance; + PathProviderPlatform.instance = _DocumentsProvider(dir.path); + addTearDown(() async { + PathProviderPlatform.instance = original; + await dir.delete(recursive: true); + }); + return dir; +} + +Map _answer(String text) => { + 'id': 'msg_${text.hashCode}', + 'type': 'message', + 'status': 'completed', + 'role': 'assistant', + 'phase': 'final_answer', + 'content': [ + {'type': 'output_text', 'text': text, 'annotations': []}, + ], + }; + +http.Response _reply(String text) => http.Response( + jsonEncode({ + 'status': 'completed', + 'output': [_answer(text)], + }), + 200, + ); + +Future _engine(http.Client client) async { + final config = CoachConfig(); + await config.save(model: 'gpt-6.1-sol'); + addTearDown(config.dispose); + final engine = CoachEngine( + config: config, + api: _FakeRepo(), + client: client, + storageKey: 'local', + ); + addTearDown(engine.dispose); + return engine; +} + +List _coachFiles(Directory dir) => dir + .listSync() + .map((e) => e.uri.pathSegments.last) + .where((n) => n.startsWith('coach_s_')) + .toList(); + +Future> _indexIds(Directory dir) async { + final f = File('${dir.path}/coach_idx_local.json'); + if (!await f.exists()) return const []; + return [ + for (final m in jsonDecode(await f.readAsString()) as List) + (m as Map)['id'] as String, + ]; +} + +void _send(CoachEngine engine, String text, List> into) => + into.add(engine.send( + text, + onItem: (_) {}, + onStatus: (_) {}, + confirm: (_) async => false, + )); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('a chat deleted while its reply is coming in is not saved back', + () async { + final dir = await _documents(); + final answer = Completer(); + var calls = 0; + final engine = await _engine(MockClient((_) { + calls++; + return calls == 1 ? Future.value(_reply('first')) : answer.future; + })); + + // A saved chat, then a second question still waiting on its reply. + final sends = >[]; + _send(engine, 'how did I sleep', sends); + await sends.single; + await engine.persist(); + final id = engine.sessionId; + expect(_coachFiles(dir), hasLength(1)); + + _send(engine, 'and last week', sends); + await Future.delayed(Duration.zero); + await engine.deleteSession(id); + answer.complete(_reply('the tail of a deleted chat')); + await sends.last; + await engine.persist(); // what the screen's finally does + + expect(_coachFiles(dir), isEmpty); + expect(await _indexIds(dir), isEmpty); + }); + + test('a delete waits for a save already writing that chat', () async { + final dir = await _documents(); + final engine = await _engine(MockClient((_) async => _reply('ok'))); + final sends = >[]; + _send(engine, 'how did I sleep', sends); + await sends.single; + await engine.persist(); + final id = engine.sessionId; + + final paused = Completer(), release = Completer(); + CoachEngine.debugAfterSaveCheck = () { + paused.complete(); + return release.future; + }; + addTearDown(() => CoachEngine.debugAfterSaveCheck = null); + + final saving = engine.persist(); + await paused.future; + var deleted = false; + final deleting = engine.deleteSession(id).then((_) => deleted = true); + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } + expect(deleted, isFalse, reason: 'the delete waits for the save'); + + release.complete(); + await Future.wait([saving, deleting]); + expect(_coachFiles(dir), isEmpty); + expect(await _indexIds(dir), isEmpty); + }); + + test('the history budget counts bytes, so non-ASCII turns are trimmed', + () async { + await _documents(); + final engine = await _engine(MockClient((_) async => _reply('ok'))); + // About 50k characters each way but over 100k bytes each in UTF-8: under + // the budget by characters, well over it by bytes. + final old = 'сон' * 17000; + engine.debugHistory + ..add({'role': 'user', 'content': old}) + ..add({'role': 'assistant', 'content': old}); + + final sends = >[]; + _send(engine, 'and today', sends); + await sends.single; + + expect(engine.debugHistory.any((m) => m['content'] == old), isFalse); + expect(engine.debugHistory.first['content'], 'and today'); + }); +} From b81feeddd6e7b534747b3d2a738e4aa60ee7820f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 18:30:25 +0530 Subject: [PATCH 48/53] keep the open coach chat fixed while a reply is coming in the running send writes into the engine's open chat, so starting, opening or deleting a chat under it put the reply's tail in the wrong chat. those are unavailable until the reply lands, and the flag that tried to drop the tail is gone. a chat deleted from one screen is remembered for the process, so a second screen's engine still holding it does not save it back. --- lib/coach/coach_engine.dart | 30 +++++------ lib/ui2/screens/coach.dart | 81 +++++++++++++++++++++-------- test/coach_chat_list_lock_test.dart | 48 +++++++++++++++++ test/coach_delete_session_test.dart | 40 ++++++-------- 4 files changed, 135 insertions(+), 64 deletions(-) create mode 100644 test/coach_chat_list_lock_test.dart diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index b61748cea..69c386b87 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -193,11 +193,6 @@ class CoachEngine { /// `finally` sees the count reach zero, not before. bool _disposeRequested = false; - /// Set when the open chat is deleted while a [send] is still running. What - /// that send adds afterwards is the deleted chat's tail: it is dropped - /// instead of saved. - bool _discardUnsaved = false; - // OpenAI-format running history (system is added per-request) — the context we // resend every turn so the model remembers the conversation. final List> _history = []; @@ -387,6 +382,12 @@ class CoachEngine { /// while the sweep listed the folder, and land after it. static Future _filesTail = Future.value(); + /// Chats deleted in this process. Each screen has its own engine, so a + /// second screen on the same chat still holds its id; this is what stops + /// that engine's next save. Ids are minted fresh per chat and only reopened + /// from the saved list, so a deleted one never legitimately comes back. + static final Set _deletedSessionIds = {}; + /// Test seam: awaited by a save right after its reset check passes, so a /// test can hold a save mid-write while a reset sweeps. @visibleForTesting @@ -398,6 +399,7 @@ class CoachEngine { } static Future _deleteAllSavedSessions() async { + _deletedSessionIds.clear(); final dir = await getApplicationDocumentsDirectory(); if (!await dir.exists()) return 0; var failed = 0; @@ -479,13 +481,9 @@ class CoachEngine { // Checked inside the queue: a reset that started while this waited has // swept the files by now, and this must not write the chat back. if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; - if (_discardUnsaved) { - if (_sending == 0) { - _discardUnsaved = false; - newSession(); - } - return; - } + // Deleted from another screen's engine on the same chat: its late save + // must not write the file and index entry back. + if (_deletedSessionIds.contains(_sessionId)) return; await debugAfterSaveCheck?.call(); if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; @@ -532,6 +530,7 @@ class CoachEngine { _serialFiles(() => _deleteSession(id)); Future _deleteSession(String id) async { + _deletedSessionIds.add(id); try { final f = await _sessionFile(id); if (await f.exists()) await f.delete(); @@ -541,12 +540,7 @@ class CoachEngine { final f = await _indexFile(); await f.writeAsString(jsonEncode(metas.map((m) => m.toJson()).toList())); } catch (_) {} - if (id == _sessionId) { - // A reply still coming in belongs to the chat just deleted; its save - // would write that chat back under a new id. - if (_sending > 0) _discardUnsaved = true; - newSession(); - } + if (id == _sessionId) newSession(); } String _deriveTitle() { diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 8057a68a6..e247380d2 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -329,6 +329,11 @@ class _CoachScreenState extends State { final engine = _engine; final p = P.of(context); final l = AppLocalizations.of(context); + // While a reply is coming in, the open chat stays put: the running send + // keeps writing into the engine's working chat, so starting, opening or + // deleting one under it would put the reply's tail in the wrong chat or + // save a deleted one back. + final locked = _busy; await showModalBottomSheet( context: context, backgroundColor: p.card, @@ -343,10 +348,12 @@ class _CoachScreenState extends State { _MenuRow( LucideIcons.plus, l?.coachNewChat ?? 'New chat', - onTap: () { - Navigator.of(sheet).pop(); - _newChat(); - }, + onTap: locked + ? null + : () { + Navigator.of(sheet).pop(); + _newChat(); + }, ), // NO "AI settings" ROW. Choosing the model is a setting and // settings live in Profile — [CoachSetup] is pushed from there @@ -387,25 +394,19 @@ class _CoachScreenState extends State { style: F.cap.copyWith(color: p.ink3), ); } - return Column( - children: [ - for (final s in list.take(20)) - _MenuRow( - LucideIcons.messageSquare, - s.title.isEmpty ? (l?.coachUntitledChat ?? 'Untitled chat') : s.title, - sub: s.preview, - onTap: () { - Navigator.of(sheet).pop(); - _openSession(s.id); - }, - onRemove: () async { - final wasOpen = s.id == engine.sessionId; - await engine.deleteSession(s.id); - setSheet(() {}); - if (wasOpen && mounted) setState(_items.clear); - }, - ), - ], + return CoachChatList( + sessions: list.take(20).toList(), + locked: locked, + onOpen: (id) { + Navigator.of(sheet).pop(); + _openSession(id); + }, + onDelete: (id) async { + final wasOpen = id == engine.sessionId; + await engine.deleteSession(id); + setSheet(() {}); + if (wasOpen && mounted) setState(_items.clear); + }, ); }, ), @@ -769,6 +770,40 @@ class CoachAnswerText extends StatelessWidget { } } +/// The past-chats rows of the coach menu. [locked] while a reply is coming +/// in: the running send writes into the open chat, so neither opening another +/// one nor deleting one may happen under it. +class CoachChatList extends StatelessWidget { + final List sessions; + final bool locked; + final void Function(String id) onOpen; + final Future Function(String id) onDelete; + const CoachChatList({ + super.key, + required this.sessions, + required this.locked, + required this.onOpen, + required this.onDelete, + }); + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + return Column( + children: [ + for (final s in sessions) + _MenuRow( + LucideIcons.messageSquare, + s.title.isEmpty ? (l?.coachUntitledChat ?? 'Untitled chat') : s.title, + sub: s.preview, + onTap: locked ? null : () => onOpen(s.id), + onRemove: locked ? null : () => onDelete(s.id), + ), + ], + ); + } +} + class _MenuRow extends StatelessWidget { final IconData icon; final String title; diff --git a/test/coach_chat_list_lock_test.dart b/test/coach_chat_list_lock_test.dart new file mode 100644 index 000000000..ba177b8dc --- /dev/null +++ b/test/coach_chat_list_lock_test.dart @@ -0,0 +1,48 @@ +// While a reply is coming in, the coach menu's past chats can be neither +// opened nor deleted: the running send writes into the open chat, and either +// would put its reply in the wrong chat or save a deleted one back. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/coach/coach_engine.dart'; +import 'package:openstrap_edge/ui2/screens/coach.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future<(List, List)> _pump(WidgetTester t, + {required bool locked}) async { + final opened = [], deleted = []; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: CoachChatList( + sessions: [CoachSessionMeta('1', 'Sleep this week', 0, 'how did I')], + locked: locked, + onOpen: opened.add, + onDelete: (id) async => deleted.add(id), + ), + ), + )); + await t.tap(find.text('Sleep this week'), warnIfMissed: false); + final delete = find.bySemanticsLabel(RegExp('^Delete')); + if (delete.evaluate().isNotEmpty) await t.tap(delete); + await t.pumpAndSettle(); + return (opened, deleted); +} + +void main() { + testWidgets('past chats open and delete when no reply is coming in', + (t) async { + final (opened, deleted) = await _pump(t, locked: false); + expect(find.bySemanticsLabel(RegExp('^Delete')), findsOneWidget); + expect(opened, ['1']); + expect(deleted, ['1']); + }); + + testWidgets('past chats neither open nor delete while a reply is coming in', + (t) async { + final (opened, deleted) = await _pump(t, locked: true); + expect(opened, isEmpty); + expect(deleted, isEmpty); + expect(find.bySemanticsLabel(RegExp('^Delete')), findsNothing); + }); +} diff --git a/test/coach_delete_session_test.dart b/test/coach_delete_session_test.dart index 59514b85e..dafae31fc 100644 --- a/test/coach_delete_session_test.dart +++ b/test/coach_delete_session_test.dart @@ -1,8 +1,8 @@ // Deleting a chat and the history budget, both against the real engine. // -// A chat deleted while its reply was still coming in was written back by the -// save that followed the reply, and a delete could interleave with a save -// already writing. The history budget counted characters while the limit and +// A chat deleted from one screen was written back by another screen's engine +// still holding it, and a delete could interleave with a save already +// writing. The history budget counted characters while the limit and // the request check are in bytes, so non-ASCII history slipped through. import 'dart:async'; @@ -99,30 +99,24 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); - test('a chat deleted while its reply is coming in is not saved back', + test('a chat deleted from one screen is not saved back by another', () async { final dir = await _documents(); - final answer = Completer(); - var calls = 0; - final engine = await _engine(MockClient((_) { - calls++; - return calls == 1 ? Future.value(_reply('first')) : answer.future; - })); - - // A saved chat, then a second question still waiting on its reply. + // Two screens, two engines, the same saved chat. + final a = await _engine(MockClient((_) async => _reply('first'))); final sends = >[]; - _send(engine, 'how did I sleep', sends); + _send(a, 'how did I sleep', sends); await sends.single; - await engine.persist(); - final id = engine.sessionId; - expect(_coachFiles(dir), hasLength(1)); - - _send(engine, 'and last week', sends); - await Future.delayed(Duration.zero); - await engine.deleteSession(id); - answer.complete(_reply('the tail of a deleted chat')); - await sends.last; - await engine.persist(); // what the screen's finally does + await a.persist(); + final id = a.sessionId; + final b = await _engine(MockClient((_) async => _reply('unused'))); + await b.openSession(id); + expect(b.sessionId, id); + + await b.deleteSession(id); + expect(_coachFiles(dir), isEmpty); + // The first screen's save after its reply lands, late. + await a.persist(); expect(_coachFiles(dir), isEmpty); expect(await _indexIds(dir), isEmpty); From 20f3d63efa5c8e0b69e30244bea2b63a1ab439f9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 18:50:34 +0530 Subject: [PATCH 49/53] coach chat saves: unlock after the save, unique ids, no save from mid-reset the menu unlocked before the reply's save had run, so starting or opening a chat could change the engine's open chat under it. chat ids gain a random suffix, since two screens can start one in the same millisecond, and deleted ids are remembered per account. an engine opened while a reset is still running never saves, because the reset does not block the ui and its generation would otherwise look current once the reset ends. --- lib/coach/coach_engine.dart | 16 +++++++--- lib/ui2/screens/coach.dart | 17 ++++++---- test/coach_delete_session_test.dart | 49 +++++++++++++++++++++++++++-- test/coach_reset_delete_test.dart | 28 +++++++++++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 69c386b87..6f3be1537 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -173,7 +173,10 @@ class CoachEngine { /// The data generation this engine started under. A reply still in flight /// when "Delete everything" runs lands after the chat files were swept, and /// saving it would write the deleted conversation back. - final int _resetGen = ResetGate.generation; + /// An engine built while a reset is still running (the UI is not blocked + /// during one) would otherwise take the new generation and save after the + /// sweep; -1 never matches, so it never saves. + final int _resetGen = ResetGate.active ? -1 : ResetGate.generation; final http.Client _http; /// Count of [send] calls currently inside their provider call(s). A local @@ -367,7 +370,11 @@ class CoachEngine { Future _dir() => getApplicationDocumentsDirectory(); Future _sessionFile(String id) async => File('${(await _dir()).path}/coach_s_${storageKey}_$id.json'); Future _indexFile() async => File('${(await _dir()).path}/coach_idx_$storageKey.json'); - String _newId() => '${DateTime.now().millisecondsSinceEpoch}'; + /// Millisecond stamp first so ids still read and sort by time, then a random + /// suffix: two screens' engines can start a chat in the same millisecond. + String _newId() => '${DateTime.now().millisecondsSinceEpoch}-' + '${_idRandom.nextInt(1 << 32).toRadixString(16).padLeft(8, '0')}'; + static final _idRandom = Random(); /// Deletes every saved chat for every storage key. The transcripts live as /// files beside the database, not in it, so wiping the database leaves @@ -386,6 +393,7 @@ class CoachEngine { /// second screen on the same chat still holds its id; this is what stops /// that engine's next save. Ids are minted fresh per chat and only reopened /// from the saved list, so a deleted one never legitimately comes back. + /// Keyed `storageKey/id`, since chats of different accounts sit side by side. static final Set _deletedSessionIds = {}; /// Test seam: awaited by a save right after its reset check passes, so a @@ -483,7 +491,7 @@ class CoachEngine { if (transcript.isEmpty || ResetGate.changedSince(_resetGen)) return; // Deleted from another screen's engine on the same chat: its late save // must not write the file and index entry back. - if (_deletedSessionIds.contains(_sessionId)) return; + if (_deletedSessionIds.contains('$storageKey/$_sessionId')) return; await debugAfterSaveCheck?.call(); if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; @@ -530,7 +538,7 @@ class CoachEngine { _serialFiles(() => _deleteSession(id)); Future _deleteSession(String id) async { - _deletedSessionIds.add(id); + _deletedSessionIds.add('$storageKey/$id'); try { final f = await _sessionFile(id); if (await f.exists()) await f.delete(); diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index e247380d2..6b003bcfe 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -227,13 +227,18 @@ class _CoachScreenState extends State { }); } } finally { - if (mounted) { - setState(() { - _busy = false; - _status = null; - }); + // Unlocked only once the save is done: the menu's new / open / delete + // change the engine's open chat, and the save is still reading it. + try { + await engine.persist(); // survive reopen + } finally { + if (mounted) { + setState(() { + _busy = false; + _status = null; + }); + } } - await engine.persist(); // survive reopen // A confirmed write can change what the reminders should say — a dose // marked or added, a day rated — and they are armed ahead of time. if (wrote) await app.refreshAiReminders(); diff --git a/test/coach_delete_session_test.dart b/test/coach_delete_session_test.dart index dafae31fc..1195efac7 100644 --- a/test/coach_delete_session_test.dart +++ b/test/coach_delete_session_test.dart @@ -58,7 +58,8 @@ http.Response _reply(String text) => http.Response( 200, ); -Future _engine(http.Client client) async { +Future _engine(http.Client client, + {String storageKey = 'local'}) async { final config = CoachConfig(); await config.save(model: 'gpt-6.1-sol'); addTearDown(config.dispose); @@ -66,7 +67,7 @@ Future _engine(http.Client client) async { config: config, api: _FakeRepo(), client: client, - storageKey: 'local', + storageKey: storageKey, ); addTearDown(engine.dispose); return engine; @@ -171,4 +172,48 @@ void main() { expect(engine.debugHistory.any((m) => m['content'] == old), isFalse); expect(engine.debugHistory.first['content'], 'and today'); }); + + test('chat ids stay apart even when minted in the same millisecond', + () async { + await _documents(); + final engine = await _engine(MockClient((_) async => _reply('ok'))); + final ids = {}; + for (var i = 0; i < 50; i++) { + engine.newSession(); + ids.add(engine.sessionId); + } + expect(ids, hasLength(50)); + expect(ids.first, matches(RegExp(r'^\d+-[0-9a-f]{8}$'))); + }); + + test("a deleted chat id only blocks saves under the account it was in", + () async { + final dir = await _documents(); + final local = await _engine(MockClient((_) async => _reply('ok'))); + final sends = >[]; + _send(local, 'how did I sleep', sends); + await sends.single; + await local.persist(); + final id = local.sessionId; + await local.deleteSession(id); + + // Another account's chat that happens to carry the same id. + await File('${dir.path}/coach_s_other_$id.json').writeAsString(jsonEncode({ + 'title': 'theirs', + 'createdAt': 1, + 'updatedAt': 1, + 'history': [], + 'transcript': [CoachItem.user('their question').toJson()], + })); + final other = await _engine(MockClient((_) async => _reply('ok')), + storageKey: 'other'); + await other.openSession(id); + expect(other.sessionId, id); + other.transcript.add(CoachItem.user('one more')); + await other.persist(); + + final saved = jsonDecode( + await File('${dir.path}/coach_s_other_$id.json').readAsString()) as Map; + expect((saved['transcript'] as List), hasLength(2)); + }); } diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart index c137210b2..5b296322c 100644 --- a/test/coach_reset_delete_test.dart +++ b/test/coach_reset_delete_test.dart @@ -123,4 +123,32 @@ void main() { .where((n) => n.startsWith('coach_')); expect(left, isEmpty); }); + + test('a chat opened while a reset is still running is never saved', + () async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + final dir = await Directory.systemTemp.createTemp('coach-reset-'); + final original = PathProviderPlatform.instance; + PathProviderPlatform.instance = _DocumentsProvider(dir.path); + addTearDown(() async { + PathProviderPlatform.instance = original; + await dir.delete(recursive: true); + }); + final config = CoachConfig(); + addTearDown(config.dispose); + + // The reset does not block the UI, so the coach can be opened mid-wipe. + ResetGate.enter(); + final engine = CoachEngine(config: config, api: _FakeRepo(), storageKey: 'local'); + addTearDown(engine.dispose); + await CoachEngine.deleteAllSavedSessions(); + ResetGate.leave(); + + engine.transcript.add(CoachItem.user('how did I sleep')); + await engine.persist(); + final left = dir.listSync().map((e) => e.uri.pathSegments.last) + .where((n) => n.startsWith('coach_')); + expect(left, isEmpty); + }); } From def14fac331987ecd8855cdb1392ff12ef316b3a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 22:28:02 +0530 Subject: [PATCH 50/53] add the past-chats list to the gallery --- lib/ui2/profile/gallery.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 4d97bf051..960216945 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -33,6 +33,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:share_plus/share_plus.dart'; import '../../coach/coach_config.dart'; +import '../../coach/coach_engine.dart' show CoachSessionMeta; import '../../data/day_label.dart'; import '../../ecg/ecg_controller.dart'; import '../../ecg/ecg_models.dart'; @@ -402,6 +403,24 @@ Map _coachFigureCases() => { '![chart](https://example.invalid/hr.png)\n\n' '- sleep held at 7 h 12 min\n- strain eased off midweek', ), + // The past-chats list, open and while a reply is still coming in (locked: + // no open, no delete control). + 'coach_chat_list': CoachChatList( + sessions: [ + CoachSessionMeta('a', 'Why was my HRV low?', 0, + 'Two short nights in a row pulled it under your usual.'), + CoachSessionMeta('b', '', 0, 'Plan a lighter week'), + ], + locked: false, + onOpen: (_) {}, + onDelete: (_) async {}, + ), + 'coach_chat_list_locked': CoachChatList( + sessions: [CoachSessionMeta('a', 'Why was my HRV low?', 0, '')], + locked: true, + onOpen: (_) {}, + onDelete: (_) async {}, + ), }; /// Charts, framed. Every one of these is captured with the thing that was From 4fdab7e1f91b21a712777ca94068e80824c50fef Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 22:45:05 +0530 Subject: [PATCH 51/53] let a restored mi band re-pair at the sensor limit a restored single-slot sensor's row has no handle, so it was dropped before the same-adapter check and its own re-pair was refused once two other sensors filled the limit. --- lib/ui2/profile/devices.dart | 15 +++++++++------ test/restore_sensor_handle_test.dart | 12 ++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 56c86d985..623e7cd0f 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1298,17 +1298,20 @@ bool sensorCapReached(Iterable> deviceRows, // A row with no `remote_id` holds no link — a backup restore brings a // sensor's row across without the old phone's handle — so it cannot take // a slot, and counting it would refuse the very re-pair that replaces it. - final secondary = deviceRows - .where((r) => - r['id'] != LocalDb.kPrimaryDeviceId && - ((r['remote_id'] as String?) ?? '').isNotEmpty) - .toList(); final sameAdapterRewrites = adapterId != null && (anyOfAdapter || adapterId == kOura.id || adapterId == kMiBand234.id); - for (final r in secondary) { + final others = deviceRows + .where((r) => r['id'] != LocalDb.kPrimaryDeviceId) + .toList(); + // The exemption looks at every row of the adapter, linked or not: a + // restored single-slot sensor's row is exactly the one its pair rewrites. + for (final r in others) { if (repairingId != null && r['id'] == repairingId) return false; if (sameAdapterRewrites && r['adapter_id'] == adapterId) return false; } + final secondary = others + .where((r) => ((r['remote_id'] as String?) ?? '').isNotEmpty) + .toList(); return secondary.length >= kMaxConcurrentSecondaryLinks; } diff --git a/test/restore_sensor_handle_test.dart b/test/restore_sensor_handle_test.dart index cd133bc3a..18a9fcdb8 100644 --- a/test/restore_sensor_handle_test.dart +++ b/test/restore_sensor_handle_test.dart @@ -105,4 +105,16 @@ void main() { ]), isFalse); }); + + test('a restored Mi Band can re-pair while two live sensors fill the cap', + () { + final rows = [ + {'id': 'ble_hrs-1', 'adapter_id': kBleHrs.id, 'remote_id': 'A'}, + {'id': 'polar_pmd-2', 'adapter_id': kPolarPmd.id, 'remote_id': 'B'}, + {'id': 'mi-3', 'adapter_id': kMiBand234.id, 'remote_id': ''}, + ]; + expect(sensorCapReached(rows, adapterId: kMiBand234.id), isFalse); + expect(sensorCapReached(rows, adapterId: kBleHrs.id), isTrue, + reason: 'a new strap still adds a row'); + }); } From e5b186b51ba02584d8a81fd4f0de4babe2a8fa67 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 22:56:05 +0530 Subject: [PATCH 52/53] keep counting a restored sensor's re-pair against the limit this reverts the previous change. the limit is on sensors linked at the same time, and re-pairing a restored ring or band links it, so with two others already linked it would make three. --- lib/ui2/profile/devices.dart | 15 ++++++--------- test/restore_sensor_handle_test.dart | 12 ------------ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 623e7cd0f..56c86d985 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1298,20 +1298,17 @@ bool sensorCapReached(Iterable> deviceRows, // A row with no `remote_id` holds no link — a backup restore brings a // sensor's row across without the old phone's handle — so it cannot take // a slot, and counting it would refuse the very re-pair that replaces it. + final secondary = deviceRows + .where((r) => + r['id'] != LocalDb.kPrimaryDeviceId && + ((r['remote_id'] as String?) ?? '').isNotEmpty) + .toList(); final sameAdapterRewrites = adapterId != null && (anyOfAdapter || adapterId == kOura.id || adapterId == kMiBand234.id); - final others = deviceRows - .where((r) => r['id'] != LocalDb.kPrimaryDeviceId) - .toList(); - // The exemption looks at every row of the adapter, linked or not: a - // restored single-slot sensor's row is exactly the one its pair rewrites. - for (final r in others) { + for (final r in secondary) { if (repairingId != null && r['id'] == repairingId) return false; if (sameAdapterRewrites && r['adapter_id'] == adapterId) return false; } - final secondary = others - .where((r) => ((r['remote_id'] as String?) ?? '').isNotEmpty) - .toList(); return secondary.length >= kMaxConcurrentSecondaryLinks; } diff --git a/test/restore_sensor_handle_test.dart b/test/restore_sensor_handle_test.dart index 18a9fcdb8..cd133bc3a 100644 --- a/test/restore_sensor_handle_test.dart +++ b/test/restore_sensor_handle_test.dart @@ -105,16 +105,4 @@ void main() { ]), isFalse); }); - - test('a restored Mi Band can re-pair while two live sensors fill the cap', - () { - final rows = [ - {'id': 'ble_hrs-1', 'adapter_id': kBleHrs.id, 'remote_id': 'A'}, - {'id': 'polar_pmd-2', 'adapter_id': kPolarPmd.id, 'remote_id': 'B'}, - {'id': 'mi-3', 'adapter_id': kMiBand234.id, 'remote_id': ''}, - ]; - expect(sensorCapReached(rows, adapterId: kMiBand234.id), isFalse); - expect(sensorCapReached(rows, adapterId: kBleHrs.id), isTrue, - reason: 'a new strap still adds a row'); - }); } From e2cc40b147c12bffe8d27f2bfa9adc9d539ee484 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 22:56:31 +0530 Subject: [PATCH 53/53] pin the sensor limit for a restored ring or band --- test/restore_sensor_handle_test.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/restore_sensor_handle_test.dart b/test/restore_sensor_handle_test.dart index cd133bc3a..b27e057ab 100644 --- a/test/restore_sensor_handle_test.dart +++ b/test/restore_sensor_handle_test.dart @@ -105,4 +105,16 @@ void main() { ]), isFalse); }); + + test('re-pairing a restored ring still counts when two sensors are linked', + () { + // The pair links the ring, so letting it through would leave three. + final rows = [ + {'id': 'ble_hrs-1', 'adapter_id': kBleHrs.id, 'remote_id': 'A'}, + {'id': 'polar_pmd-2', 'adapter_id': kPolarPmd.id, 'remote_id': 'B'}, + {'id': 'oura-3', 'adapter_id': kOura.id, 'remote_id': ''}, + ]; + expect(sensorCapReached(rows, adapterId: kOura.id), isTrue); + expect(sensorCapReached(rows, adapterId: kMiBand234.id), isTrue); + }); }