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 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/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/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index fed12ddbd..6f3be1537 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -18,10 +18,12 @@ 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'; import '../models/payloads.dart' show todayHeadlineOf; +import '../sync/reset_gate.dart'; import 'coach_actions.dart'; import 'coach_chat_completions.dart'; import 'coach_config.dart'; @@ -167,6 +169,14 @@ 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. + /// 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 @@ -256,6 +266,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; @@ -272,7 +287,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; } @@ -296,6 +314,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') { @@ -319,7 +370,60 @@ 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 + /// 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() => + _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(); + + /// 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. + /// 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 + /// 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: (_) {}); + return run; + } + + static Future _deleteAllSavedSessions() async { + _deletedSessionIds.clear(); + 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 { @@ -379,8 +483,16 @@ class CoachEngine { } /// Persist the current session (caps history, updates the index). - Future persist() async { - if (transcript.isEmpty) return; + 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; + // 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('$storageKey/$_sessionId')) return; + await debugAfterSaveCheck?.call(); if (_sessionId.isEmpty) _sessionId = _newId(); final now = DateTime.now().millisecondsSinceEpoch; if (_createdAt == 0) _createdAt = now; @@ -422,7 +534,11 @@ 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 { + _deletedSessionIds.add('$storageKey/$id'); try { final f = await _sessionFile(id); if (await f.exists()) await f.delete(); @@ -858,8 +974,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/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 563fb7455..0a8002c1b 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3059,6 +3059,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', @@ -7232,7 +7253,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/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 95b395a93..1dfb259b8 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; @@ -1696,6 +1697,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; @@ -4048,6 +4095,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. @@ -9132,6 +9184,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 @@ -9301,25 +9400,48 @@ 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 /// 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); if (await f.exists()) await f.delete(); // VACUUM INTO requires a fresh path Database? copy; try { - await db.execute('VACUUM INTO ?', [dest]); - if (!includeSubstrateArchive) { + var archiveCopied = true; + if (!debugForceAttachExport && await _vacuumIntoSupported(db)) { + await db.execute('VACUUM INTO ?', [dest]); + } else { + // 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 && archiveCopied) { copy = await openDatabase(dest, singleInstance: false); if (debugFailExportStrip) throw StateError('test: strip failed'); await copy.delete(SubstrateArchive.table); @@ -9345,6 +9467,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); @@ -10209,6 +10355,35 @@ 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'), + // 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'), + // 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), + }; + @visibleForTesting static List get restoreTablesForTest => _restoreTables; @@ -10399,6 +10574,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 @@ -10409,6 +10600,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 @@ -10512,6 +10731,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' || @@ -10576,6 +10812,35 @@ 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)) { + 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']}', @@ -10792,11 +11057,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'), @@ -11616,9 +11877,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(); @@ -11641,7 +11944,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 @@ -11705,9 +12010,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/lib/data/export_retention.dart b/lib/data/export_retention.dart new file mode 100644 index 000000000..e7b35d709 --- /dev/null +++ b/lib/data/export_retention.dart @@ -0,0 +1,154 @@ +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. [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 || 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(currentStamp == null ? keep : keep - 1)) { + 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. + } +} + +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. +/// [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(current == null ? keep : keep - 1)) { + 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|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 +/// 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/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/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/data/local_repository.dart b/lib/data/local_repository.dart index 212fcf96d..24453af85 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'); @@ -346,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 e57f87d56..30e75281e 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -3707,10 +3707,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 @@ -3826,12 +3856,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, @@ -3842,7 +3881,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}; } @@ -3852,8 +3891,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) @@ -3926,9 +3968,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. @@ -3957,6 +4008,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 @@ -4236,17 +4290,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; @@ -4263,8 +4322,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, }); } @@ -4313,6 +4372,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/data/med_store.dart b/lib/data/med_store.dart index 83b777407..e3c198691 100644 --- a/lib/data/med_store.dart +++ b/lib/data/med_store.dart @@ -309,6 +309,75 @@ 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, + ), + ); + } + + /// 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/data/nutrition_store.dart b/lib/data/nutrition_store.dart index f0ca3d2ff..7ff2137a4 100644 --- a/lib/data/nutrition_store.dart +++ b/lib/data/nutrition_store.dart @@ -456,6 +456,50 @@ 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; +} + +/// 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]; + 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++; + } + return n == 0 ? null : EnergyBalance(eaten / n, burned / n, n); +} + // ══════════════════ STORE ══════════════════ class NutritionDb { 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/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, -); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1605f31b5..eaa2c6aaf 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", @@ -2789,5 +2789,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: 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 2cf7ca953..94e908c58 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" @@ -15741,6 +15741,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 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." + }, "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 29be05423..fb44a28f1 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", @@ -2693,5 +2693,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}: fuera de rango, así que no se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d7a377c37..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é est la moyenne sur {days} jours complets. Le brûlé est celui d'aujourd'hui seulement.", + "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", @@ -2664,5 +2664,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} : hors limites, rien n'a donc été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ffa8ee646..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": "अभी तक कुछ भी दर्ज नहीं हुआ", @@ -2664,5 +2664,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 c2c980120..671d7e5d0 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": { @@ -7495,6 +7495,15 @@ }, "reportDelete": "Удалить", "reportDeleteFailed": "Не удалось удалить. Попробуйте позже.", + "reportDeleteConfirmTitle": "Удалить сообщение {id}?", + "@reportDeleteConfirmTitle": { + "placeholders": { + "id": { + "type": "String" + } + } + }, + "reportDeleteConfirmBody": "Оно сразу удаляется с сервера, и открыть его по ID больше не получится. Отменить нельзя.", "reportSaveFailedTitle": "Не удалось поделиться сообщением", "reportSaveFailedBody": "Не удалось собрать или передать файл. Тикет не создан.", "reportSaveRetry": "Повторить", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7b9b51220..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": "还没有任何记录", @@ -2664,5 +2664,7 @@ "dataReanalyzeFailed": "重新分析失败:{error}", "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "reportDeleteConfirmTitle": "删除报告 {id}?", + "reportDeleteConfirmBody": "它会立即从服务器上删除,之后无法再通过 ID 打开。此操作无法撤销。", "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。" } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index be270de04..477d305e9 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; @@ -89,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'; @@ -1395,8 +1397,13 @@ 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). + // 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(); // Surfaces outside the database that were still showing it. Neither // may stop the reset short of [signOut]: the primary band is still @@ -1417,6 +1424,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 @@ -2778,7 +2792,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 @@ -5512,13 +5529,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/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/data.dart b/lib/ui2/profile/data.dart index 5524a2e0f..dec608392 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, current: path); + 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/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 48c4be5fc..56c86d985 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1281,22 +1281,30 @@ 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}) { - final secondary = - deviceRows.where((r) => r['id'] != LocalDb.kPrimaryDeviceId).toList(); - final sameAdapterRewrites = - adapterId != null && (anyOfAdapter || adapterId == kOura.id); + // 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) { if (repairingId != null && r['id'] == repairingId) return false; if (sameAdapterRewrites && r['adapter_id'] == adapterId) return false; @@ -1335,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/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 53cf6fc1f..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'; @@ -395,6 +396,31 @@ 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', + ), + // 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 diff --git a/lib/ui2/profile/report_issue.dart b/lib/ui2/profile/report_issue.dart index e8ccdb233..d924331ca 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,12 +150,16 @@ class _ReportIssueScreenState extends State { try { final r = await collectReport(context.read(), _draft(), onStage: (st) => _setStep(_stageText(l, st))); + await pruneSharedReports(r.zip.parent, current: r.zip); _setStep(l.reportStageSharing); await Share.shareXFiles([XFile(r.zip.path)], subject: 'OpenStrap issue report', sharePositionOrigin: origin); } 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; @@ -174,6 +179,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/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 18aa06773..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(); @@ -329,6 +334,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 +353,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 +399,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); + }, ); }, ), @@ -712,7 +718,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 +746,69 @@ 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, + ), + ); + } +} + +/// 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/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/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 296220310..ec0bef8fd 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1507,13 +1507,33 @@ 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. + /// 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; + } + 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 range = m?.rangeFor(sex); - final inRange = v == null || m == null ? null : m.inRange(v, sex: sex); + 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; + 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 @@ -1561,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 ? '' : (m?.format(v) ?? v.toString()), + // 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), @@ -1590,7 +1619,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. @@ -1603,8 +1632,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/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 6c2b6c7ec..d29953cc5 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -58,6 +58,12 @@ 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 {}; + 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. @@ -87,6 +93,17 @@ 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}; + // 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; @@ -101,6 +118,8 @@ class _NutritionScreenState extends State with RevisionReload { setState(() { _week = week; _burned = burned; + _burnedByDate = burnedByDate; + _wornMinByDate = wornMinByDate; _waterMl = water; _waterDate = date; _profile = {...?app.user}; @@ -157,24 +176,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 @@ -369,6 +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, _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`). @@ -414,7 +435,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 +444,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/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index eba6aca69..a06af8f18 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'; @@ -445,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); @@ -1008,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, @@ -1022,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(); } @@ -1061,15 +1071,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(); } @@ -1718,11 +1726,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/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 new file mode 100644 index 000000000..1195efac7 --- /dev/null +++ b/test/coach_delete_session_test.dart @@ -0,0 +1,219 @@ +// Deleting a chat and the history budget, both against the real engine. +// +// 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'; +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, + {String storageKey = 'local'}) 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: storageKey, + ); + 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 from one screen is not saved back by another', + () async { + final dir = await _documents(); + // Two screens, two engines, the same saved chat. + final a = await _engine(MockClient((_) async => _reply('first'))); + final sends = >[]; + _send(a, 'how did I sleep', sends); + await sends.single; + 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); + }); + + 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'); + }); + + 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_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', () { 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":'))); + }); + }); } 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); + }); +} diff --git a/test/coach_reset_delete_test.dart b/test/coach_reset_delete_test.dart new file mode 100644 index 000000000..5b296322c --- /dev/null +++ b/test/coach_reset_delete_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +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; + + @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); + } + }); + + 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); + }); + + 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); + 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); + }); + 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 is past its reset check and holding when the reset starts. + final saving = engine.persist(); + await paused.future; + ResetGate.enter(); + 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(); + + final left = dir.listSync().map((e) => e.uri.pathSegments.last) + .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); + }); +} 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 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); + }); +} 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); + }); +} diff --git a/test/export_retention_test.dart b/test/export_retention_test.dart new file mode 100644 index 000000000..b0756b7e7 --- /dev/null +++ b/test/export_retention_test.dart @@ -0,0 +1,214 @@ +// 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('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'); + 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, + ); + }); + }); + + 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', + ]); + }); + + 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', () { + 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', + '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); + } + await purgeSharedHealthCopies(dir); + expect(_names(dir), ['osm_tiles', 'unrelated.db']); + }); + }); +} diff --git a/test/issue_report_test.dart b/test/issue_report_test.dart index 23eafbcc6..7a850b3f5 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'); @@ -235,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 { 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']); + }); +} 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']!; diff --git a/test/local_repository_p0_test.dart b/test/local_repository_p0_test.dart index 430ba4af3..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'); @@ -311,6 +312,111 @@ 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( + '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); + 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', 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'); + }); +} 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); + }); +} diff --git a/test/nutrition_energy_balance_test.dart b/test/nutrition_energy_balance_test.dart new file mode 100644 index 000000000..572fc3a89 --- /dev/null +++ b/test/nutrition_energy_balance_test.dart @@ -0,0 +1,89 @@ +// 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, + }, _worn(['2026-08-13', '2026-08-14', today]))!; + 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}, _worn(['2026-08-12', '2026-08-13']))!; + 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}, _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}; 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, + ); + }); +} diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index 6b3e1524f..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('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('LocalDb.wipeAll()'); + final wipe = body.indexOf('await LocalDb.wipeAll();'); expect(stop, greaterThanOrEqualTo(0)); expect(stop, lessThan(wipe)); expect(ids, greaterThanOrEqualTo(0)); diff --git a/test/restore_keeps_newer_edits_test.dart b/test/restore_keeps_newer_edits_test.dart new file mode 100644 index 000000000..9c7eec8ce --- /dev/null +++ b/test/restore_keeps_newer_edits_test.dart @@ -0,0 +1,261 @@ +// 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', + 'med_dose', + 'med_def', + 'food_entry', + 'step_calibration', + ]) { + 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', + 'dose.db', + 'schedule.db', + 'calib.db', + 'calib_new.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); + }); + + 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); + }); + + 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); + }); + + 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); + }); +} diff --git a/test/restore_sensor_handle_test.dart b/test/restore_sensor_handle_test.dart new file mode 100644 index 000000000..b27e057ab --- /dev/null +++ b/test/restore_sensor_handle_test.dart @@ -0,0 +1,120 @@ +// 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); + }); + + 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); + }); +} 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); + }); +} diff --git a/test/sensor_cap_test.dart b/test/sensor_cap_test.dart index 17901546b..7bb6af557 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,13 +56,34 @@ 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. 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), diff --git a/test/substrate_archive_db_test.dart b/test/substrate_archive_db_test.dart index 523e586f0..d007a39bc 100644 --- a/test/substrate_archive_db_test.dart +++ b/test/substrate_archive_db_test.dart @@ -1194,6 +1194,94 @@ 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('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 [ diff --git a/test/ui2_labs_delete_test.dart b/test/ui2_labs_delete_test.dart index 37df7fe9d..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, @@ -158,6 +158,43 @@ 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\u2009mmol/L'), findsOneWidget); + expect(find.text('5\u2009mmol/L'), findsNothing); + + 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('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', (t) async { final labs = await _seed(t, _ferritinAndHba1c); @@ -193,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'), @@ -241,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); }); }); 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); + }); +}