diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart index 313f690c3..8dc16c838 100644 --- a/lib/ai/briefing_engine.dart +++ b/lib/ai/briefing_engine.dart @@ -22,6 +22,7 @@ import '../coach/coach_engine.dart'; import '../data/day_label.dart'; import '../data/local_repository.dart'; import '../models/metric.dart' show whyFromNote; +import '../models/payloads.dart' show todayHeadlineOf; import '../ui2/screens/home_screen.dart' as ring show readinessBand; import 'briefing.dart'; import 'nightly_sweep.dart'; @@ -108,7 +109,14 @@ Future> collectBriefingInputs( } if (period == BriefingPeriod.morning) { - take('readiness', daily['readiness'], round: 0); + // The same headline Home shows: no number until today's night is final, + // never a held-over or still-draining one presented as this morning's. + take( + 'readiness', + todayHeadlineOf(t)['recovery_state'] == 'final' + ? daily['readiness'] + : null, + round: 0); take('resting_hr', daily['resting_hr'], round: 0); final hrv = _map(t['hrv']); take('hrv_rmssd', hrv?['rmssd'], round: 1); diff --git a/lib/coach/coach_db.dart b/lib/coach/coach_db.dart index 4a571fc1d..1fc024dc9 100644 --- a/lib/coach/coach_db.dart +++ b/lib/coach/coach_db.dart @@ -481,18 +481,83 @@ class CoachDb { static Future debugAssertAllowedBtrees(String sql) async => _assertAllowedBtrees(await _readonly(), sql); + /// Shadow v_daily / v_metric with TEMP views whose today readiness is the + /// headline's. Unqualified names resolve to temp first, the temp schema is + /// writable on a read-only handle, and the views read the same btrees, so + /// neither guard layer changes. Rebuilt per query; dropped when [today] is + /// null. + static Future _serveToday( + Database db, ({String day, num? readiness})? today) async { + await db.execute('DROP VIEW IF EXISTS temp.v_daily'); + await db.execute('DROP VIEW IF EXISTS temp.v_metric'); + if (today == null) return; + final day = today.day.replaceAll("'", "''"); + final v = today.readiness?.toString() ?? 'NULL'; + final names = [ + for (final r in await db.rawQuery('PRAGMA main.table_info(v_daily)')) + '${r['name']}', + ]; + final cols = [ + for (final n in names) + n == 'readiness' + ? "CASE WHEN date = '$day' THEN $v ELSE readiness END AS readiness" + : '"$n"', + ]; + // A pinned headline whose day has no stored readiness (the live composite + // abstained on a later derive) still has to read the same as Home. + final added = [ + for (final n in names) + n == 'date' ? "'$day'" : (n == 'readiness' ? v : 'NULL'), + ]; + await db.execute( + 'CREATE TEMP VIEW v_daily AS SELECT ${cols.join(', ')} FROM main.v_daily ' + "UNION ALL SELECT ${added.join(', ')} WHERE $v IS NOT NULL AND NOT " + "EXISTS (SELECT 1 FROM main.v_daily WHERE date = '$day')"); + await db.execute('CREATE TEMP VIEW v_metric AS SELECT date, key, ' + "CASE WHEN date = '$day' AND key = 'readiness' THEN $v ELSE value END " + 'AS value FROM main.v_metric ' + "UNION ALL SELECT '$day', 'readiness', $v WHERE $v IS NOT NULL AND NOT " + "EXISTS (SELECT 1 FROM main.v_metric WHERE date = '$day' " + "AND key = 'readiness')"); + } + /// Run an LLM SELECT and return compact JSON for the tool result. On a guard /// rejection, returns the reason (so the model fixes its query) — never throws. - static Future runCoachSql(String llmSql, {int rowCap = 200}) async { + /// + /// [today] is today's readiness as the Home headline serves it (null until + /// the night is final, the pin when there is one). Today's row in v_daily / + /// v_metric is otherwise whatever the last derive wrote, which mid-sync is a + /// partial night — the 2 Home once froze on. + static Future runCoachSql(String llmSql, + {int rowCap = 200, ({String day, num? readiness})? today}) async { String sql; try { sql = guardAndPrepare(llmSql, rowCap: rowCap); } on SqlGuardError catch (e) { return jsonEncode({'error': e.reason}); } + // One query at a time: the TEMP views are per connection, so a second + // turn's _serveToday could otherwise swap them out between this one's + // setup and its query. + final previous = _sqlTail; + final turn = Completer(); + _sqlTail = turn.future; + await previous; + try { + return await _runServed(sql, rowCap, today); + } finally { + turn.complete(); + } + } + + static Future _sqlTail = Future.value(); + + static Future _runServed(String sql, int rowCap, + ({String day, num? readiness})? today) async { try { final db = await _readonly(); await LocalDb.refreshSessionScoreMask(); + await _serveToday(db, today); await _assertAllowedBtrees(db, sql); // ponytail: sqflite exposes no sqlite3_progress_handler, so a slow // query (e.g. a giant cross-join of allowed views) can't be cancelled diff --git a/lib/coach/coach_engine.dart b/lib/coach/coach_engine.dart index 068955b84..b38c8866d 100644 --- a/lib/coach/coach_engine.dart +++ b/lib/coach/coach_engine.dart @@ -21,6 +21,7 @@ import 'package:path_provider/path_provider.dart'; import '../data/day_label.dart'; import '../data/db.dart'; import '../data/local_repository.dart'; +import '../models/payloads.dart' show todayHeadlineOf; import 'coach_actions.dart'; import 'coach_chat_completions.dart'; import 'coach_config.dart'; @@ -787,6 +788,25 @@ class CoachEngine { } } + @visibleForTesting + Future debugRunTool(String name, Map args) => + _runTool(name, args, onItem: (_) {}, confirm: (_) async => false); + + /// Today's readiness under the headline rule, for run_sql's views: the + /// final number or nothing. If today can't be read, today's readiness is + /// masked rather than left as whatever a mid-sync derive stored. + Future<({String day, num? readiness})?> _todayReadiness() async { + try { + final h = todayHeadlineOf(await api.getToday()); + return ( + day: '${h['day'] ?? todayLabel()}', + readiness: h['recovery_state'] == 'final' ? h['recovery'] as num? : null, + ); + } catch (_) { + return (day: todayLabel(), readiness: null); + } + } + // ── tool execution ─────────────────────────────────────────────────────────── Future _runTool( String name, @@ -798,11 +818,14 @@ class CoachEngine { switch (name) { // data — one read-only SQL tool over the derived views case 'run_sql': - return await CoachDb.runCoachSql('${args['sql'] ?? ''}'); + return await CoachDb.runCoachSql('${args['sql'] ?? ''}', + today: await _todayReadiness()); // data — the two stores that are NOT in the SQL views. Widening // `coach_db`'s allow-list to reach them would trade a structural btree // gate for a text-level one; a typed read tool costs nothing. + case 'get_today': + return jsonEncode(todayHeadlineOf(await api.getToday())); case 'get_nutrition': return await CoachActions.nutritionDay( await LocalDb.instance, args['date']); @@ -948,6 +971,7 @@ class CoachEngine { String _statusFor(String name, Map args) { switch (name) { case 'run_sql': return 'Querying your data…'; + case 'get_today': return 'Reading today…'; case 'get_nutrition': return 'Reading your food log…'; case 'get_medications': return 'Reading your medications…'; case 'plot_chart': return 'Plotting…'; @@ -1048,6 +1072,12 @@ class CoachEngine { 'app computes them (a total over an entry with no numbers is a FLOOR ' 'and says so). Food is NOT in run_sql — use this.', {'date': {'type': 'string', 'description': 'YYYY-MM-DD, default today'}}), + _fn('get_today', + 'TODAY\'s recovery, strain and sleep EXACTLY as the Home screen shows ' + 'them. Use this, not v_daily, for any question about today: v_daily\'s ' + 'today readiness is NULL until the night is final. recovery_state: final | ' + 'provisional (still finishing, may move) | night_in_progress (asleep, ' + 'no score yet) | none.', {}), _fn('get_medications', 'Read the medication/supplement schedule and today\'s doses ' '(taken/skipped/missed/upcoming). Not in run_sql — use this.', {}), diff --git a/lib/coach/coach_prompt.dart b/lib/coach/coach_prompt.dart index 980249570..a248a0496 100644 --- a/lib/coach/coach_prompt.dart +++ b/lib/coach/coach_prompt.dart @@ -96,6 +96,9 @@ results cap at 200. If a query is rejected, read the reason and fix it. missing_segments) — WHOOP MG ECG readings, summary only; `category` is the band's. The waveform is in `get_ecg_reading(reading_id)`. +TODAY's recovery, strain and sleep: call `get_today()`. It is exactly what the +Home screen shows. Today's readiness in v_daily/v_metric is that same final +number, or NULL while last night is still syncing; get_today says which. Food and medications are NOT in SQL. Use `get_nutrition(date)` and `get_medications()`. diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index a21de00f7..459ab6475 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2101,6 +2101,62 @@ bool overnightSettled({ (nowSec != null && nowSec >= sleepOffsetSec + kOvernightGiveUpSec); } +/// How long the band's data edge must run past a detected wake before that +/// wake counts as CONFIRMED rather than "where the sync happened to stop". +/// Mid-drain the stager closes the window at the newest record, so a wake +/// sitting at the edge is no wake at all. +// ponytail: fixed 30 min; tune against real drain timings if provisional flickers. +const int kWakeConfirmMarginSec = 30 * 60; + +/// A pin is only trusted for the night whose wake is within this of its own. +const int kPinWakeToleranceSec = 60 * 60; + +/// A night whose wake is before this local hour, or that slept less than +/// [kMinPinSleepSec], is never frozen: the reported 2-instead-of-28 morning was +/// a pin taken off the first three hours of a night still in progress. +// ponytail: population heuristics; a night-shift sleeper never pins and reads +// the live final value instead, which is the safe side. +const int kMinPinWakeHour = 3; +const int kMinPinSleepSec = 3 * 60 * 60; + +/// Today's recovery headline, in the one vocabulary every surface reads. +enum RecoveryState { nightInProgress, provisional, finalReady } + +extension RecoveryStateWire on RecoveryState { + String get wire => switch (this) { + RecoveryState.nightInProgress => 'night_in_progress', + RecoveryState.provisional => 'provisional', + RecoveryState.finalReady => 'final', + }; +} + +/// Where today's night stands: a wake the edge has not yet run +/// [kWakeConfirmMarginSec] past is still in progress; a confirmed wake whose +/// window has not settled ([overnightSettled]) is provisional; settled is +/// final. No window at all and not settled is null: nothing says the user is +/// asleep (an unworn night reads the same until noon), so no state is claimed. +RecoveryState? recoveryStateOf({ + required int? wakeSec, + required int dataEdgeSec, + int? nowSec, +}) { + if (overnightSettled( + sleepOffsetSec: wakeSec, dataEdgeSec: dataEdgeSec, nowSec: nowSec)) { + return RecoveryState.finalReady; + } + if (wakeSec == null) return null; + return dataEdgeSec >= wakeSec + kWakeConfirmMarginSec + ? RecoveryState.provisional + : RecoveryState.nightInProgress; +} + +/// Whether a night is a plausible MAIN night to freeze (see [kMinPinWakeHour]). +bool pinnableNight({required int wakeSec, int? onsetSec}) { + final wake = DateTime.fromMillisecondsSinceEpoch(wakeSec * 1000); + if (wake.hour < kMinPinWakeHour) return false; + return onsetSec == null || wakeSec - onsetSec >= kMinPinSleepSec; +} + /// The cross-day inputs an importer fills with its vendor's own scores. const _crossDayVendorScoreKeys = [ 'rhr', @@ -2133,14 +2189,21 @@ const _crossDayVendorScoreKeys = [ required int? liveReadiness, required ({String day, int value, int? wakeSec})? current, int? wakeSec, + int? onsetSec, }) { + // A pin with no wake (written by an older build) cannot say which night it + // is, so a night that does know its wake replaces it once that night settles. final sameNight = current != null && current.day == today && - (current.wakeSec == null || - wakeSec == null || - (wakeSec - current.wakeSec!).abs() < _headlineFreezeMarginSec); - if (sameNight) return current; // pinned; hold - if (overnightComplete && liveReadiness != null) { + (wakeSec == null || + (current.wakeSec != null && + (wakeSec - current.wakeSec!).abs() < kPinWakeToleranceSec)); + final pinnable = + wakeSec == null || pinnableNight(wakeSec: wakeSec, onsetSec: onsetSec); + // A re-derive can shrink the same night below a main night (onset moved); + // its old pin no longer describes a pinnable night, so it is not held. + if (sameNight) return pinnable ? current : null; // pinned; hold + if (overnightComplete && pinnable && liveReadiness != null) { // first complete settle of this night → pin return (day: today, value: liveReadiness, wakeSec: wakeSec); } @@ -5924,6 +5987,7 @@ class DerivationEngine { liveReadiness: readiness?.round(), current: current, wakeSec: day.sleepOffsetSec, + onsetSec: day.sleepOnsetSec, ); if (next == null) return; // Already pinned to this exact value → skip the redundant write. @@ -6485,14 +6549,16 @@ class DerivationEngine { // and tested; this only reads its inputs and emits what it returns. today ??= LocalDb.localDayLabelNow(); final anchor = exceptionAnchor(cd); - final pin = await LocalDb.frozenHeadline(); + // Only a pin taken on the anchor day's own night (LocalDb.headlinePinFor). + final pinValue = + anchor == null ? null : await LocalDb.headlinePinFor(anchor.date); final notices = planExceptionNotices( cd, today: today, irregularFlag: anchor == null ? null : await LocalDb.metricValueOn(anchor.date, 'irregular_rhythm_flag'), - pin: pin == null ? null : (day: pin.day, value: pin.value), + pin: pinValue == null ? null : (day: anchor!.date, value: pinValue), storedReadiness: anchor == null ? null : await LocalDb.metricValueOn(anchor.date, 'readiness'), diff --git a/lib/data/db.dart b/lib/data/db.dart index 5ee9fa51e..c651d0e3f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -29,7 +29,15 @@ import '../compute/substrate.dart' show beatTimesMs; // version, which every day_result read applies as a CEILING (see [dayResult]). // `show` keeps the rest of the engine out of this namespace. import '../coach/coach_db.dart' show CoachDb; -import '../compute/derivation_engine.dart' show kAlgoVersion, kOvernightGiveUpSec, overnightSettled; +import '../compute/derivation_engine.dart' + show + kAlgoVersion, + kOvernightGiveUpSec, + kPinWakeToleranceSec, + pinnableNight, + overnightSettled, + recoveryStateOf, + RecoveryStateWire; import '../compute/sleep_profile_policy.dart' show SleepProfilePolicy; import '../ble/adapters/_registry.dart' show kAdapterSignals; import '../ble/adapters/adapter.dart' show NeutralSample; @@ -3850,8 +3858,8 @@ class LocalDb { } /// Read a sync-cursor value (null if unset). - static Future getCursor(String name) async { - final db = await instance; + static Future getCursor(String name, {DatabaseExecutor? txn}) async { + final db = txn ?? await instance; final rows = await db.query( 'sync_cursor', columns: ['value'], @@ -3968,6 +3976,73 @@ class LocalDb { return null; } + /// The pinned headline for [day], or null unless the pin describes [day]'s + /// CURRENT night: its wake within [kPinWakeToleranceSec] of the stored + /// window's. A pin with no wake (older builds) never qualifies, so the live + /// final value shows instead of a possibly partial-night pin. + static Future headlinePinFor(String day) async { + final pin = await frozenHeadline(); + final pinWake = pin?.wakeSec; + if (pin == null || pin.day != day || pinWake == null) return null; + int? wake, onset; + try { + final raw = jsonDecode(await sleepWindowJsonFor(day) ?? '{}'); + // Bare SleepWindow JSON today; older rows wrap it in a `value` envelope. + final w = raw is Map && raw['value'] is Map ? raw['value'] : raw; + final ms = w is Map ? w['offset_ms'] : null; + final on = w is Map ? w['onset_ms'] : null; + wake = ms is num ? ms ~/ 1000 : null; + onset = on is num ? on ~/ 1000 : null; + } catch (_) {/* malformed window → no wake → no pin */} + if (wake == null || + (wake - pinWake).abs() >= kPinWakeToleranceSec || + !pinnableNight(wakeSec: wake, onsetSec: onset)) { + return null; + } + return pin.value; + } + + /// Cursor holding the last FINAL headline readiness shown for a day, plus the + /// one it replaced, so a changed number is announced instead of silently + /// swapped ("Updated 8:12 · 2 → 28"). + static const String kHeadlineShownCursor = 'headline_shown'; + + /// Record that [value] is the final headline served for [day]; returns the + /// day's latest change `{from, to, at}` (at = epoch ms) or null if the number + /// has not changed since it was first shown. + static Future?> noteHeadlineShown( + String day, int value) async => + // One transaction: overlapping getToday calls must not write back a + // stale read and walk the "from" side of the note backwards. + (await instance).transaction((txn) async { + Map? cur; + try { + final d = jsonDecode( + await getCursor(kHeadlineShownCursor, txn: txn) ?? 'null'); + if (d is Map && d['day'] == day && d['value'] is num) cur = d; + } catch (_) {/* malformed → start over */} + if (cur != null && (cur['value'] as num).round() == value) { + final prev = cur['prev'], at = cur['at']; + return prev is num && at is num + ? {'from': prev.round(), 'to': value, 'at': at.toInt()} + : null; + } + final now = DateTime.now().millisecondsSinceEpoch; + await setCursor( + kHeadlineShownCursor, + jsonEncode({ + 'day': day, + 'value': value, + if (cur != null) 'prev': (cur['value'] as num).round(), + if (cur != null) 'at': now, + }), + txn: txn, + ); + return cur == null + ? null + : {'from': (cur['value'] as num).round(), 'to': value, 'at': now}; + }); + /// Pin [value] as the frozen readiness headline for [day] (overwrites any /// prior pin — first-complete-settle-per-day is enforced by the caller). /// [wakeSec] is the wake of the night it was pinned on. @@ -11101,8 +11176,9 @@ class LocalDb { final latestRawTs = (raw['max_rec_ts'] as num?)?.toInt(); final todayWake = await wakeDayFeatures(today); final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // The band's edge, same as the readiness freeze: a peripheral streaming - // this morning says nothing about how far the band's night has drained. + // The band's edge: a peripheral streaming this morning says nothing about + // how far the band's night has drained. Only for legacy rows without a + // stored `data_edge_sec`. final bandEdgeSec = await lastDecodedRecTs() ?? 0; String? latestOvernightDay; int? latestOvernightComputedAt; @@ -11110,6 +11186,7 @@ class LocalDb { int? latestRecoveryComputedAt; Map? todayRow; int? overnightRecheckAt; + String? recoveryState; for (final row in recent) { final dayId = row['day_id']?.toString(); if (dayId == null || dayId.isEmpty) continue; @@ -11129,15 +11206,29 @@ class LocalDb { final windowVal = windowMap is Map ? windowMap['value'] : null; final offsetMs = windowVal is Map ? windowVal['offset_ms'] : null; final wakeSec = offsetMs is num ? offsetMs ~/ 1000 : null; + // A night is judged on the edge its row was derived against + // (`data_edge_sec`), so wake and edge share one substrate: a later band + // sync must not settle a row still holding the partial night, and an + // unworn band's edge says nothing about a wearable's night. Legacy rows + // without the stamp fall back to the band's edge. + final edgeSec = + (decoded['data_edge_sec'] as num?)?.toInt() ?? bandEdgeSec; + if (dayId == today) { + recoveryState = recoveryStateOf( + wakeSec: wakeSec, + dataEdgeSec: edgeSec, + nowSec: nowSec, + )?.wire; + } if (dayId == today && !overnightSettled( sleepOffsetSec: wakeSec, - dataEdgeSec: bandEdgeSec, + dataEdgeSec: edgeSec, nowSec: nowSec, )) { // When the give-up lands; getToday re-checks then, since a quiet // strap triggers no derive to do it. - overnightRecheckAt = (wakeSec ?? bandEdgeSec) + kOvernightGiveUpSec; + overnightRecheckAt = (wakeSec ?? edgeSec) + kOvernightGiveUpSec; continue; } final scalars = ((decoded['scalars'] as Map?) ?? const {}) @@ -11198,6 +11289,9 @@ class LocalDb { 'overnight_state': overnightState, 'overnight_computed_at': latestOvernightComputedAt, 'overnight_recheck_at': overnightRecheckAt, + // Today's own night: night_in_progress / provisional / final, or null + // when today has no derived row yet. See [recoveryStateOf]. + 'recovery_state': recoveryState, 'recovery_day': latestRecoveryDay, 'recovery_computed_at': latestRecoveryComputedAt, 'showing_prior_overnight': diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index e7dd10f66..978a87b9c 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -421,12 +421,27 @@ class LocalRepositoryImpl extends LocalRepository { // ONLY the headline is pinned — every other metric below still reflects the // latest re-derive. Gated to today's OWN overnight (`ready`, matching day) // so a prior-night fallback or a stale yesterday pin can never leak in. + // + // The pin is only trusted for the night it was taken on (its wake within an + // hour of today's window, see LocalDb.headlinePinFor); a pin from an older + // build or an earlier sleep block yields to the live final value. + final recoveryState = todayFresh?['recovery_state']?.toString(); + Map? readinessUpdate; if (overnightState == 'ready') { - final pin = await LocalDb.frozenHeadline(); - if (pin != null && pin.day == todayDay) { - readinessScalar = pin.value.toDouble(); + final pin = await LocalDb.headlinePinFor(todayDay); + if (pin != null) readinessScalar = pin.toDouble(); + if (readinessScalar != null) { + readinessUpdate = await LocalDb.noteHeadlineShown( + todayDay, readinessScalar.round()); } } + // A confirmed wake whose window has not settled yet: today's own number, + // served marked provisional (greyed, "Finishing up") — never pinned, and + // never in place of a settled one. + final provisionalReadiness = + recoveryState == 'provisional' && overnightState != 'ready' + ? _scalar(todayBundle, 'readiness') + : null; // Everything on the overnight side of Home is absent for ONE reason when // there is no night to read: there is no scored night. Said once here so // readiness and resting HR stop going absent with nothing at all — measured @@ -469,6 +484,9 @@ class LocalRepositoryImpl extends LocalRepository { final daily = { 'readiness': readinessMetric, 'recovery': readinessMetric, + if (provisionalReadiness != null) + 'readiness_provisional': _scalarMetric(provisionalReadiness, 'HIGH'), + 'readiness_update': ?readinessUpdate, 'resting_hr': _scalarMetric( showOvernight ? _scalar(sleepBundle, 'rhr')?.round() : null, 'HIGH', @@ -597,6 +615,7 @@ class LocalRepositoryImpl extends LocalRepository { 'overnight_state': overnightState, 'overnight_day': todayFresh?['overnight_day'], 'overnight_computed_at': todayFresh?['overnight_computed_at'], + 'recovery_state': recoveryState, 'showing_prior_overnight': todayFresh?['showing_prior_overnight'] == true, }, @@ -2010,7 +2029,10 @@ class LocalRepositoryImpl extends LocalRepository { // which is the pin's whole reason for existing — so Readiness detail drew // 74 in the ring and 69 as today's point in the chart underneath it. One // day, one readiness number. - final pin = key == 'readiness' ? await LocalDb.frozenHeadline() : null; + final pinDay = _todayLocalLabel(); + final pinValue = + key == 'readiness' ? await LocalDb.headlinePinFor(pinDay) : null; + final pin = pinValue == null ? null : (day: pinDay, value: pinValue); // #448: every derive writes today's readiness, partial night or not, and a // held night has no pin yet. Leave today's point out while getToday holds // it back, or the chart plots the number the ring above it refuses. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd38be31a..aeed5e054 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12707,6 +12707,34 @@ "@tabHome": { "description": "Bottom tab bar labels (tabHome..tabWellness). Keep short; the five must read as different words." }, + "homeRecoverySleeping": "Sleeping…", + "@homeRecoverySleeping": { + "description": "Recovery ring value while last night is still in progress (no confirmed wake yet)." + }, + "homeRecoveryAfterWake": "Recovery is scored after you wake.", + "@homeRecoveryAfterWake": { + "description": "Why the recovery ring has no number while the night is still in progress." + }, + "homeRecoveryFinishingUp": "Finishing up", + "@homeRecoveryFinishingUp": { + "description": "Caption under a greyed, provisional recovery number: wake detected, the night's data is still arriving." + }, + "homeRecoveryUpdated": "Updated {time} · {from} → {to}", + "@homeRecoveryUpdated": { + "description": "Quiet note when today's recovery changed after it was first shown (re-analysis or late sync).", + "placeholders": { + "time": {"type": "String"}, + "from": {"type": "int"}, + "to": {"type": "int"} + } + }, + "dataRecoveryChanged": "Recovery changed: {list}", + "@dataRecoveryChanged": { + "description": "After re-analysis, the days whose recovery changed, e.g. 'Oct 8 2 → 28, Oct 6 40 → 44'.", + "placeholders": { + "list": {"type": "String"} + } + }, "activityCatalogueName": "{key, select, other{{name}}}", "@activityCatalogueName": { "description": "Display name only. Never persist translated activity or group names.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index afb082214..8aba812f8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -6823,6 +6823,23 @@ "investigateRmssdNightly": "RMSSD, ночной (среднее по 5-минутным окнам)", "investigateRmssdStored": "RMSSD, ночной (прежняя оценка)", "devicesOuraWriteRefused": "Не удалось отправить команду кольцу. Попробуйте синхронизировать ещё раз.", + "homeRecoverySleeping": "Сон…", + "homeRecoveryAfterWake": "Восстановление оценивается после пробуждения.", + "homeRecoveryFinishingUp": "Почти готово", + "homeRecoveryUpdated": "Обновлено {time} · {from} → {to}", + "@homeRecoveryUpdated": { + "placeholders": { + "time": {"type": "String"}, + "from": {"type": "int"}, + "to": {"type": "int"} + } + }, + "dataRecoveryChanged": "Восстановление изменилось: {list}", + "@dataRecoveryChanged": { + "placeholders": { + "list": {"type": "String"} + } + }, "devicePickerBlurbPebble": "Только Pebble 2 или Pebble 2 SE. Синхронизирует шаги и сон, а на Pebble 2 и пульс (у SE нет датчика пульса).", "devicePickerBlurbUltrahuman": "Читает данные напрямую с кольца — без аккаунта и обмена ключами.", "devicesWhatItDoesBeats": "Интервалы между ударами сохраняются и приписываются этому датчику. Он записывает во время тренировки и несколько минут после её окончания. Оценка тренировки по нему экспериментальна и выключена, пока не проверена на реальном устройстве.", diff --git a/lib/models/payloads.dart b/lib/models/payloads.dart index 099c82027..c45be9fc8 100644 --- a/lib/models/payloads.dart +++ b/lib/models/payloads.dart @@ -140,13 +140,25 @@ class TodayData { /// value is settled so the hero shows its honest empty/loading state instead of /// a bogus figure. Null when readiness is absent OR not yet settled for today. int? get settledReadinessScore { - if (readiness.isEmpty) return null; // getToday always stamps `status`; when it says the overnight has NOT // settled (building / missing) any readiness present is a held-over prior // night, so withhold it. Absent status (synthetic payloads) → show. - final s = status; - if (s != null && !s.overnightReady) return null; - return readiness.value!.round(); + final h = todayHeadlineOf( + {'daily': _daily, 'sleep': _sleep, 'status': ?_status}); + return h['recovery_state'] == 'final' + ? (h['recovery'] as num?)?.round() + : null; + } + + /// Today's own number while its night is `provisional` (wake confirmed, the + /// band still draining past it), else null. Surfaces that can grey a value + /// show it greyed; the rest show nothing until it is final. + int? get provisionalReadinessScore { + final h = todayHeadlineOf( + {'daily': _daily, 'sleep': _sleep, 'status': ?_status}); + return h['recovery_state'] == 'provisional' + ? (h['recovery'] as num?)?.round() + : null; } Metric get fitness => metricOf(_daily, 'fitness'); @@ -582,3 +594,51 @@ class ChartSeries { bool get isEmpty => points.isEmpty; } + +/// TODAY'S HEADLINE — recovery, strain and sleep exactly as Home shows them, +/// read off one `getToday()` result. Home, the widgets/Watch, the briefing and +/// the Coach's `get_today` tool all go through this, so no two of them can +/// report a different recovery for the same morning. +/// +/// `recovery_state`: +/// * `final` — today's night is complete; `recovery` is the number. +/// * `provisional` — wake detected, the band is still draining past it; +/// `recovery` is today's own number, shown greyed ("Finishing up"). +/// * `night_in_progress` — no confirmed wake yet; no number at all. +/// * `none` — no night of today's has reached the app. +/// `recovery_update` is `{from, to, at}` when the final number changed after it +/// was first shown (re-analysis, late sync). +Map todayHeadlineOf(Map today) { + final daily = today['daily'] is Map ? today['daily'] as Map : const {}; + final sleep = today['sleep'] is Map ? today['sleep'] as Map : const {}; + final st = today['status'] is Map ? today['status'] as Map : null; + num? v(Object? env) { + final m = Metric.parse(env); + return m.isEmpty ? null : m.value; + } + + // A payload without `overnight_state` is synthetic (getToday always stamps + // it) and reads as final, as it always has. + final ns = st?['overnight_state']; + final state = ns == null || ns == 'ready' + ? 'final' + : switch (st?['recovery_state']) { + 'provisional' => 'provisional', + 'night_in_progress' => 'night_in_progress', + _ => 'none', + }; + final ownNight = st == null || st['showing_prior_overnight'] != true; + final upd = daily['readiness_update']; + return { + 'day': st?['today_day'], + 'recovery_state': state, + 'recovery': switch (state) { + 'final' => ownNight ? v(daily['readiness']) : null, + 'provisional' => v(daily['readiness_provisional']), + _ => null, + }, + 'strain': v(daily['strain']), + 'sleep_min': ownNight ? v(sleep['duration_min']) : null, + if (state == 'final' && upd is Map) 'recovery_update': upd, + }; +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 258fa0559..091f5bf1f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2347,6 +2347,17 @@ class AppState extends ChangeNotifier { reanalyzeProgress = 'Analyzing…'; notifyListeners(); try { + // The user asked for a fresh answer: today's morning pin would otherwise + // hold the old number in the ring while the re-analysis summary reports + // the new one. The re-derive below re-pins once the night is final. + // run() drops a call while another pass holds the latch, so wait it out + // and release right before ours takes it (as _reanalyzeForOverride). + do { + while (_derive.running) { + await Future.delayed(const Duration(milliseconds: 250)); + } + await LocalDb.releaseFrozenHeadline(LocalDb.localDayLabelNow()); + } while (_derive.running); final n = await _derive.run( _profile, heavy: true, diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index 0a6e56a7b..702866666 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -211,9 +211,31 @@ class _DataScreenState extends State { Future<_Note> _reanalyze(AppState app) async { final l = AppLocalizations.of(context); + final ml = MaterialLocalizations.of(context); + Future> recovery() async => { + for (final r in await LocalDb.metricSeries('readiness')) + r['date'] as String: (r['value'] as num).round(), + }; + final before = await recovery(); final n = await app.reanalyzeAll(); + final after = await recovery(); + // Which days' recovery a re-analysis actually moved, newest first. + // ponytail: first 5 only; the note is a heads-up, not an audit log. + final changed = [ + for (final e in after.entries) + if (before[e.key] != null && before[e.key] != e.value) + (day: e.key, from: before[e.key]!, to: e.value), + ].reversed.take(5).map((c) { + final d = DateTime.tryParse(c.day); + final label = d == null ? c.day : ml.formatShortMonthDay(d); + return '$label ${c.from} → ${c.to}'; + }).join(', '); + final done = + l?.dataDaysReanalyzed(n) ?? '$n day${n == 1 ? '' : 's'} re-analyzed.'; return ( - l?.dataDaysReanalyzed(n) ?? '$n day${n == 1 ? '' : 's'} re-analyzed.', + changed.isEmpty + ? done + : '$done ${l?.dataRecoveryChanged(changed) ?? 'Recovery changed: $changed'}', false ); } diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 3218353f6..883df10d0 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -19,6 +19,7 @@ import '../../data/local_repository.dart'; import '../../import/journal_csv_import.dart' show isValidDayLabel; import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; +import '../../models/payloads.dart' show todayHeadlineOf; import '../profile/wearable_numbers.dart' show WearableCells, kHealthWearableRows; import '../ui2.dart'; @@ -863,6 +864,13 @@ class _HealthScreenState extends State with RevisionReload { const EcgEntryCard(), const SizedBox(height: S.x3), ], + // Where today's recovery stands, in Home's words: the overnight rows + // below can be a held-over night while Home greys today's own number. + if (recoveryStateLine(todayHeadlineOf(d.today), l) case final note?) ...[ + Text('${l?.homeRingRecovery ?? 'Recovery'} · $note', + style: F.cap.copyWith(color: p.ink3)), + const SizedBox(height: S.x2), + ], if (rows.isNotEmpty) Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index ae381c674..50b849c50 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -54,6 +54,7 @@ import '../../l10n/decimal_text.dart'; import '../../state/locale_controller.dart'; import 'package:intl/intl.dart'; import '../../models/metric.dart'; +import '../../models/payloads.dart' show todayHeadlineOf; import '../../notify/notification_prefs.dart' show NotificationPrefs; import '../../state/app_state.dart'; import '../../state/clock_format.dart' show formatClockOf; @@ -860,6 +861,10 @@ class RingTrio extends StatelessWidget { Divider(color: p.line, height: 1), _GapRow(r, onTap: _open(r.kind)), ], + if (recoveryUpdatedLine(d.readinessUpdate, l) case final note?) ...[ + const SizedBox(height: S.x2), + Text(note, style: F.cap.copyWith(color: p.ink3)), + ], if (d.readiness.value != null && d.drivers.isNotEmpty) ...[ const SizedBox(height: S.x3), Divider(color: p.line, height: 1), @@ -895,6 +900,30 @@ class RingTrio extends StatelessWidget { } } +/// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. +String? recoveryUpdatedLine(Object? update, AppLocalizations? l) { + if (update case { + 'from': final int from, + 'to': final int to, + 'at': final int at, + }) { + final time = formatClockOf(DateTime.fromMillisecondsSinceEpoch(at)); + return l?.homeRecoveryUpdated(time, from, to) ?? + 'Updated $time · $from → $to'; + } + return null; +} + +/// The short line every screen showing today's recovery puts beside it when +/// it is not simply final: still asleep, finishing up, or changed since shown. +/// Read off one `todayHeadlineOf` result so Home, Readiness and Health agree. +String? recoveryStateLine(Map head, AppLocalizations? l) => + switch (head['recovery_state']) { + 'night_in_progress' => l?.homeRecoverySleeping ?? 'Sleeping…', + 'provisional' => l?.homeRecoveryFinishingUp ?? 'Finishing up', + _ => recoveryUpdatedLine(head['recovery_update'], l), + }; + /// Which ring. The three the app can stand behind on a home screen: what the /// night gave back, what the day has cost, and what the night was made of. enum HomeRingKind { recovery, strain, sleep } @@ -933,14 +962,18 @@ class _RingState { this.have, this.need, this.why, + this.provisional = false, }); + /// A number that may still move (the night is still draining), drawn grey. + final bool provisional; + /// A number the ring is actually reporting. Calibration is progress, not a /// reading, so it is not one. bool get measured => why == null && !calibrating; - Color arc(P p) => calibrating ? p.ink3 : p.on(color); - Color ink(P p) => measured ? p.on(color) : p.ink3; + Color arc(P p) => calibrating || provisional ? p.ink3 : p.on(color); + Color ink(P p) => measured && !provisional ? p.on(color) : p.ink3; String get spoken => [ label, @@ -955,6 +988,21 @@ _RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { case HomeRingKind.recovery: final v = d.readiness.value; final band = readinessBand(v, l); + if (d.recoveryState == 'night_in_progress') { + return _RingState(k, l?.homeRingRecovery ?? 'Recovery', + LucideIcons.batteryCharging, C.green, + value: l?.homeRecoverySleeping ?? 'Sleeping…', + why: l?.homeRecoveryAfterWake ?? + 'Recovery is scored after you wake.'); + } + if (d.recoveryState == 'provisional' && v != null) { + return _RingState(k, l?.homeRingRecovery ?? 'Recovery', + LucideIcons.batteryCharging, band.color, + value: '${v.round()}', + sub: l?.homeRecoveryFinishingUp ?? 'Finishing up', + frac: v / 100, + provisional: true); + } return v == null ? _gap(k, l?.homeRingRecovery ?? 'Recovery', LucideIcons.batteryCharging, C.green, d.readiness, l?.homeReadinessNotScored ?? 'Not scored', l, @@ -1139,7 +1187,7 @@ class _RingText extends StatelessWidget { Text( presentationText(AppLocalizations.of(c), r.value), style: r.measured - ? F.n24.copyWith(color: p.ink) + ? F.n24.copyWith(color: r.provisional ? p.ink3 : p.ink) : F.body.copyWith(color: p.ink2), textAlign: align, ), @@ -1258,6 +1306,13 @@ class HomeData { /// from the SAME diagnostic that screen's does, or the two can disagree. final Map? absentDiag; + /// `todayHeadlineOf`'s recovery_state for today (null for a past day): + /// night_in_progress → no number, provisional → greyed number, final. + final String? recoveryState; + + /// `{from, to, at}` when today's final recovery changed after it was shown. + final Map? readinessUpdate; + const HomeData({ this.name, this.dayId, @@ -1279,6 +1334,8 @@ class HomeData { this.illnessZ, this.insightsStale, this.absentDiag, + this.recoveryState, + this.readinessUpdate, }); /// The three illness fields, replaced together. Test-facing sugar, and they @@ -1305,6 +1362,8 @@ class HomeData { illnessZ: z, insightsStale: insightsStale, absentDiag: absentDiag, + recoveryState: recoveryState, + readinessUpdate: readinessUpdate, ); /// A day OTHER than today, for the Home day switcher. @@ -1376,7 +1435,13 @@ class HomeData { // not today's cannot arrive wearing today's clothes — see // [overnightMetric]. Steps, active energy and strain are today's own and // are read straight. - final readiness = overnightMetric(today, d('readiness'), l); + // ONE headline source (todayHeadlineOf): the state Coach and the widgets + // read too. A provisional night shows its own number, greyed. + final head = todayHeadlineOf(today); + final recoveryState = head['recovery_state'] as String?; + final readiness = recoveryState == 'provisional' + ? metricOf(d('readiness_provisional')) + : overnightMetric(today, d('readiness'), l); final absentDiag = readiness.value != null ? null : await LocalDb.readinessAbsentDiag( @@ -1391,6 +1456,8 @@ class HomeData { illnessZ: illness is Map ? (illness['z'] as num?)?.toDouble() : null, readiness: readiness, absentDiag: absentDiag, + recoveryState: recoveryState, + readinessUpdate: (head['recovery_update'] as Map?)?.cast(), drivers: [ for (final e in (gbDrivers is List ? gbDrivers : const [])) if (e is Map) e.cast(), diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 7027cabea..1fe7ee9b8 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -16,6 +16,7 @@ import '../../data/db.dart'; import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; +import '../../models/payloads.dart' show todayHeadlineOf; import '../ui2.dart'; import 'home_screen.dart'; import 'investigate.dart'; @@ -50,7 +51,14 @@ class ReadinessData { /// as five consecutive days. final List series; + /// `todayHeadlineOf` off the same `getToday()`: the state and update note + /// Home shows beside this number. + final Map head; + + bool get provisional => head['recovery_state'] == 'provisional'; + const ReadinessData({ + this.head = const {}, this.readiness = Metric.empty, this.breakdown = const [], this.inputsUsed = 0, @@ -69,9 +77,15 @@ class ReadinessData { final v = envValue(gb) ?? const {}; final bd = v['breakdown']; - final readiness = overnightMetric(today, daily is Map ? daily['readiness'] : null); + // Same rule as Home: a provisional night shows its own number, greyed. + final head = todayHeadlineOf(today); + final readiness = head['recovery_state'] == 'provisional' + ? metricOf(daily is Map ? daily['readiness_provisional'] : null) + : overnightMetric( + today, daily is Map ? daily['readiness'] : null); return ReadinessData( + head: head, readiness: readiness, // `narrative` and the glass-box `score` are DELIBERATELY not read. Both // belong to the deprecated percentile score, which bands at 70/40 while @@ -224,16 +238,23 @@ class _ReadinessDetailState extends State { child: Stack(alignment: Alignment.center, children: [ CustomPaint( size: const Size(150, 150), - painter: Ring(d.readiness.normalized(100), p.on(band.color), + painter: Ring(d.readiness.normalized(100), + d.provisional ? p.ink3 : p.on(band.color), p.track, stroke: 14, t: animate(c, 1)), ), Column(mainAxisSize: MainAxisSize.min, children: [ - Text('${v.round()}', style: F.n48.copyWith(color: p.ink)), + Text('${v.round()}', + style: F.n48.copyWith( + color: d.provisional ? p.ink3 : p.ink)), Text(band.label, style: F.cap.copyWith(color: p.ink3)), ]), ]), ), + if (recoveryStateLine(d.head, l) case final note?) ...[ + const SizedBox(height: S.x2), + Text(note, style: F.cap.copyWith(color: p.ink3)), + ], ]), ), diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index 4cf99426f..902e528e9 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -255,8 +255,18 @@ class WidgetService { // Dart rather than three times in Swift, Kotlin and Watch Swift for the // reason `readiness_tier` already exists: a rule copied into four build // targets is four rules. + // A provisional night rides the calibrating state, which every native + // reader already draws muted: the number, greyed, "Finishing up". The + // Watch and Siri read the bare `readiness` int and keep -1 until final. + final provisional = rv == null ? t.provisionalReadinessScore : null; final rings = [ - rv == null + provisional != null + ? _Ring('recovery', + state: 1, + value: '$provisional', + sub: 'Finishing up', + frac: provisional / 100) + : rv == null ? _gapRing('recovery', readiness, 'Not scored') : _Ring('recovery', value: '${rv.round()}', diff --git a/test/coach_views_test.dart b/test/coach_views_test.dart index 47fd57514..0b0de66ff 100644 --- a/test/coach_views_test.dart +++ b/test/coach_views_test.dart @@ -66,4 +66,56 @@ void main() { final badDec = jsonDecode(bad) as Map; expect(badDec.containsKey('error'), isTrue); }); + + test("today's readiness in v_daily / v_metric follows the headline rule", + () async { + final db = await LocalDb.instance; + // Mid-sync the last derive wrote a partial night's 2 for today. + await db.insert('metric_series', + {'date': '2026-10-08', 'key': 'readiness', 'value': 2.0}); + await db.insert('metric_series', + {'date': '2026-10-07', 'key': 'readiness', 'value': 40.0}); + Future> rows(String sql, + ({String day, num? readiness})? today) async { + final out = jsonDecode(await CoachDb.runCoachSql(sql, today: today)) + as Map; + expect(out.containsKey('error'), isFalse, reason: '$out'); + return { + for (final r in (out['rows'] as List).cast()) + r['date'] as String: r.values.last, + }; + } + + const daily = + 'SELECT date, readiness FROM v_daily WHERE readiness IS NOT NULL ' + 'OR date >= \'2026-10-07\' ORDER BY date'; + const metric = + "SELECT date, value FROM v_metric WHERE key='readiness' ORDER BY date"; + // Not final yet: no number for today, earlier days untouched. + const pending = (day: '2026-10-08', readiness: null); + expect((await rows(daily, pending))['2026-10-08'], isNull); + expect((await rows(daily, pending))['2026-10-07'], 40.0); + expect((await rows(metric, pending))['2026-10-08'], isNull); + // Final: the headline's number (the pin), not the series value. + const done = (day: '2026-10-08', readiness: 28); + expect((await rows(daily, done))['2026-10-08'], 28); + expect((await rows(metric, done))['2026-10-08'], 28); + // No headline read: the stored views as before. + expect((await rows(daily, null))['2026-10-08'], 2.0); + // A pinned day with no stored readiness row still reads as the headline. + const pinnedOnly = (day: '2026-10-09', readiness: 31); + expect((await rows(daily, pinnedOnly))['2026-10-09'], 31); + expect((await rows(metric, pinnedOnly))['2026-10-09'], 31); + const noPin = (day: '2026-10-09', readiness: null); + expect((await rows(metric, noPin)).containsKey('2026-10-09'), isFalse); + // Concurrent turns each read their own headline: one turn's view swap + // never lands between another's setup and query. + final both = await Future.wait([ + for (var i = 0; i < 6; i++) + rows(metric, i.isEven ? done : pending), + ]); + for (var i = 0; i < 6; i++) { + expect(both[i]['2026-10-08'], i.isEven ? 28 : isNull, reason: 'turn $i'); + } + }); } diff --git a/test/low_readiness_agreement_test.dart b/test/low_readiness_agreement_test.dart index 5854750a3..dde95b3ad 100644 --- a/test/low_readiness_agreement_test.dart +++ b/test/low_readiness_agreement_test.dart @@ -21,10 +21,11 @@ import 'package:openstrap_edge/data/local_repository_impl.dart'; import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_edge/ui2/screens/health_screen.dart' show HealthData; -/// The frozen headline as the planner takes it: day and value. -Future<({String day, int value})?> _pin() async { - final p = await LocalDb.frozenHeadline(); - return p == null ? null : (day: p.day, value: p.value); +/// The frozen headline as the engine hands it to the planner: only a pin taken +/// on [day]'s own night (`LocalDb.headlinePinFor`). +Future<({String day, int value})?> _pin(String day) async { + final v = await LocalDb.headlinePinFor(day); + return v == null ? null : (day: day, value: v); } void main() { @@ -51,6 +52,8 @@ void main() { // Today's night, scored (so the ring shows today's overnight), with NO // readiness of its own; the crossday rollup says today is settled. Future seed({required bool nullRow}) async { + final wakeSec = + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 13 * 3600; final db = await LocalDb.instance; await db.delete('day_result'); await db.delete('metric_series'); @@ -67,7 +70,9 @@ void main() { }, }, }), - windowJson: '{}', + // The night's wake, which the pin must match to be trusted. Far enough + // back that the night has settled on the give-up clock. + windowJson: jsonEncode({'offset_ms': wakeSec * 1000}), // `putDayResult` writes the series map as given; with no readiness key // the day has no readiness row at all. series: nullRow ? {'readiness': null} : const {}, @@ -82,7 +87,7 @@ void main() { ], }), ); - await LocalDb.setFrozenHeadline(today, 22); + await LocalDb.setFrozenHeadline(today, 22, wakeSec: wakeSec); await LocalDb.refreshComputeFreshness(); } @@ -96,7 +101,7 @@ void main() { final notices = planExceptionNotices( cd, today: today, - pin: await _pin(), + pin: await _pin(today), storedReadiness: await LocalDb.metricValueOn(today, 'readiness'), ); final pushed = [ @@ -143,7 +148,7 @@ void main() { final notices = planExceptionNotices( cd, today: today, - pin: await _pin(), + pin: await _pin(day), storedReadiness: await LocalDb.metricValueOn(day, 'readiness'), ); expect( diff --git a/test/low_readiness_notification_test.dart b/test/low_readiness_notification_test.dart index 1f606c76b..c0930106b 100644 --- a/test/low_readiness_notification_test.dart +++ b/test/low_readiness_notification_test.dart @@ -69,6 +69,19 @@ void main() { ); } + // A pin only counts for the night it was taken on, so the pin tests give + // today a stored sleep window and pin on its wake. + const wakeSec = 1700000000; + Future pinOnTodaysNight(int value) async { + await LocalDb.putDayResult( + dayId: todayLabel(), + algoVersion: kAlgoVersion, + payloadJson: '{}', + windowJson: jsonEncode({'offset_ms': wakeSec * 1000}), + ); + await LocalDb.setFrozenHeadline(todayLabel(), value, wakeSec: wakeSec); + } + test('a low glass-box score under a normal ring does not buzz', () async { await seed(glassBox: 20, readiness: 70); await DerivationEngine().runNotificationsForTest(); @@ -86,7 +99,7 @@ void main() { // Pin 30 (ring shows 30, "Take it easy"); a later re-derive drifted the // series to 24. Must not buzz. await seed(glassBox: 70, readiness: 24); - await LocalDb.setFrozenHeadline(todayLabel(), 30); + await pinOnTodaysNight(30); await DerivationEngine().runNotificationsForTest(); expect(shown, isEmpty); }); @@ -94,7 +107,7 @@ void main() { test('a low pin buzzes even after the series drifted above the line', () async { await seed(glassBox: 70, readiness: 40); - await LocalDb.setFrozenHeadline(todayLabel(), 22); + await pinOnTodaysNight(22); await DerivationEngine().runNotificationsForTest(); expect(shown, hasLength(1)); }); diff --git a/test/overnight_settled_test.dart b/test/overnight_settled_test.dart index 9c68d9bd2..7f3a80abb 100644 --- a/test/overnight_settled_test.dart +++ b/test/overnight_settled_test.dart @@ -10,6 +10,7 @@ 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:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/models/payloads.dart' show todayHeadlineOf; import 'package:openstrap_edge/state/app_state.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -39,12 +40,13 @@ void main() { required int edgeSec, String? day, num? rmssd, + num? readiness, }) async { await db.insert('day_result', { 'day_id': day ?? todayLabel(), 'algo_version': kAlgoVersion, 'payload_json': jsonEncode({ - if (rmssd != null) 'scalars': {'rmssd': rmssd}, + 'scalars': {'rmssd': ?rmssd, 'readiness': ?readiness}, 'sleep': { 'window': { 'value': {'offset_ms': wakeSec * 1000}, @@ -54,7 +56,7 @@ void main() { }, }, }), - 'window_json': '{}', + 'window_json': jsonEncode({'offset_ms': wakeSec * 1000}), 'computed_at': 1, 'finalized': 0, }); @@ -240,6 +242,66 @@ void main() { expect(today['hrv']?['rmssd'], isNot(77)); }); + test('a night the active wearable supplied settles on its own edge', + () async { + // The band was left on the charger: its edge sits before the night. The + // ring's row was derived against the ring's edge, two hours past wake. + final wake = nowSec - 3 * 3600; + await seed(wakeSec: wake, edgeSec: wake - 8 * 3600); + await db.update( + 'day_result', + { + 'payload_json': jsonEncode({ + 'sleep': { + 'window': { + 'value': {'offset_ms': wake * 1000}, + }, + 'accounting': { + 'value': {'tst_sec': 6 * 3600}, + }, + }, + 'data_edge_sec': wake + 2 * 3600, + }), + }, + where: 'day_id = ?', + whereArgs: [todayLabel()], + ); + expect(await overnightDay(), todayLabel()); + final row = await LocalDb.computeFreshness('today'); + expect(jsonDecode(row!['payload_json'] as String)['recovery_state'], + 'final'); + }); + + test('a row derived mid-drain stays unsettled after the band edge moves on', + () async { + // Derived with the edge at the wake; the band has since synced past it, + // but the row (and its score) is still the partial night. + final wake = nowSec - 3 * 3600; + await seed(wakeSec: wake, edgeSec: wake + 2 * 3600); + await db.update( + 'day_result', + { + 'payload_json': jsonEncode({ + 'sleep': { + 'window': { + 'value': {'offset_ms': wake * 1000}, + }, + 'accounting': { + 'value': {'tst_sec': 6 * 3600}, + }, + }, + 'data_edge_sec': wake + 60, + }), + }, + where: 'day_id = ?', + whereArgs: [todayLabel()], + ); + expect(await overnightDay(), isNot(todayLabel())); + final row = await LocalDb.computeFreshness('today'); + expect(jsonDecode(row!['payload_json'] as String)['recovery_state'], + 'night_in_progress'); + }); + test('readiness chart leaves out the night getToday holds back', () async { final wake = nowSec - 20 * 60; await seed(wakeSec: wake, edgeSec: wake + 60); @@ -308,4 +370,48 @@ void main() { isFalse, ); }); + + group('recovery_state through getToday', () { + Future> today() async { + await LocalDb.refreshComputeFreshness(); + return LocalRepositoryImpl(getProfileMap: () => const {}).getToday(); + } + + test('edge at the window close → night in progress, no number', () async { + final wake = nowSec - 10 * 60; + await seed(wakeSec: wake, edgeSec: wake, readiness: 2); + final t = await today(); + expect(t['status']['recovery_state'], 'night_in_progress'); + expect(todayHeadlineOf(t)['recovery'], isNull); + }); + + test('wake confirmed, not settled → provisional number', () async { + final wake = nowSec - 50 * 60; + await seed(wakeSec: wake, edgeSec: wake + 40 * 60, readiness: 27.6); + final h = todayHeadlineOf(await today()); + expect(h['recovery_state'], 'provisional'); + expect(h['recovery'], 27.6); + }); + + test('final night: a legacy pin without wake yields to the live value', + () async { + final wake = nowSec - 3 * 3600; + await seed(wakeSec: wake, edgeSec: wake + 2 * 3600, readiness: 27.6); + await LocalDb.setFrozenHeadline(todayLabel(), 2); + final h = todayHeadlineOf(await today()); + expect(h['recovery_state'], 'final'); + expect(h['recovery'], 27.6); + // A pin taken on this night's wake is honoured. + await LocalDb.setFrozenHeadline(todayLabel(), 28, wakeSec: wake); + final h2 = todayHeadlineOf(await today()); + // A wake before kMinPinWakeHour local is never pinnable, so when this + // test runs in the small hours the pin is ignored and the live value + // shows. Assert whichever the clock makes correct. + final pinnable = DateTime.fromMillisecondsSinceEpoch(wake * 1000).hour >= + kMinPinWakeHour; + expect(h2['recovery'], pinnable ? 28 : 27.6); + // 27.6 was shown as 28 already: same number, no "Updated" note. + expect(h2['recovery_update'], isNull); + }); + }); } diff --git a/test/readiness_freeze_test.dart b/test/readiness_freeze_test.dart index 3b3a4661a..832f33940 100644 --- a/test/readiness_freeze_test.dart +++ b/test/readiness_freeze_test.dart @@ -137,8 +137,10 @@ void main() { test( 'a later sleep block becomes the night after a long mid-night wake → ' 're-pins on that night, not the first block', () { - // 22:30–01:45 settles at 02:45 (edge a margin past wake) and pins 70. - const firstWake = 1000000; + // A first block waking 04:00 settles and pins 70. Local wall-clock + // times: a pin needs a wake at or after kMinPinWakeHour LOCAL, so a raw + // epoch passes or fails depending on the machine's zone. + final firstWake = DateTime(2026, 10, 8, 4).millisecondsSinceEpoch ~/ 1000; var frozen = nextFrozenHeadline( today: d1, overnightComplete: true, @@ -148,9 +150,9 @@ void main() { ); expect(frozen!.value, 70); - // Asleep again 03:00–07:00 (75 min gap, not bridged): the new block is + // Asleep again until 09:15 (a long gap, not bridged): the new block is // in progress, not settled → the old pin holds for now. - const realWake = firstWake + (5 * 3600 + 15 * 60); + final realWake = firstWake + (5 * 3600 + 15 * 60); frozen = nextFrozenHeadline( today: d1, overnightComplete: false, diff --git a/test/recovery_headline_ui_test.dart b/test/recovery_headline_ui_test.dart new file mode 100644 index 000000000..780b60ba4 --- /dev/null +++ b/test/recovery_headline_ui_test.dart @@ -0,0 +1,154 @@ +// The recovery ring's three states and the "Updated" note, plus the contract +// that the Coach's `get_today` tool and Home read the same recovery. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +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/l10n/app_localizations.dart'; +import 'package:openstrap_edge/models/metric.dart'; +import 'package:openstrap_edge/state/clock_format.dart' show formatClockOf; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Map _env(num v) => + {'value': v, 'confidence': .8, 'tier': 'HIGH'}; + +class _Repo extends LocalRepository { + final Map today; + _Repo(this.today); + @override + Future> getToday() async => today; + @override + Future> getInsights() async => const {}; + @override + Future> getProfile() async => const {}; +} + +Future _pump(WidgetTester t, HomeData d) => t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold(body: SingleChildScrollView(child: RingTrio(d: d))), + )); + +void main() { + testWidgets('night in progress: no number, says it scores after wake', + (t) async { + await _pump( + t, + const HomeData( + recoveryState: 'night_in_progress', + readiness: Metric(value: 2, confidence: .8))); + expect(find.text('Sleeping…'), findsOneWidget); + expect(find.textContaining('after you wake'), findsOneWidget); + expect(find.text('2'), findsNothing); + }); + + testWidgets('provisional: the number, marked finishing up', (t) async { + await _pump( + t, + const HomeData( + recoveryState: 'provisional', + readiness: Metric(value: 27.6, confidence: .8))); + expect(find.text('28'), findsOneWidget); + expect(find.text('Finishing up'), findsOneWidget); + }); + + testWidgets('final: the number, no provisional caption, update note', + (t) async { + final at = DateTime(2026, 10, 8, 8, 12).millisecondsSinceEpoch; + await _pump( + t, + HomeData( + recoveryState: 'final', + readiness: const Metric(value: 28, confidence: .8), + readinessUpdate: {'from': 2, 'to': 28, 'at': at})); + expect(find.text('28'), findsOneWidget); + expect(find.text('Finishing up'), findsNothing); + final clock = formatClockOf(DateTime.fromMillisecondsSinceEpoch(at)); + expect(find.text('Updated $clock · 2 → 28'), findsOneWidget); + }); + + group('Coach get_today and Home read the same recovery', () { + Future check(Map today, num? want) async { + final repo = _Repo(today); + final home = await HomeData.load(repo); + final engine = CoachEngine(config: CoachConfig(), api: repo); + final coach = jsonDecode(await engine.debugRunTool('get_today', {})); + expect(home.readiness.value, want); + expect(coach['recovery'], want); + expect(coach['recovery_state'], home.recoveryState); + } + + test('final (pinned) night', () async { + await check({ + 'daily': {'readiness': _env(28), 'strain': _env(4.2)}, + 'status': {'today_day': '2026-10-08', 'overnight_state': 'ready'}, + }, 28); + }); + + test('provisional night', () async { + await check({ + 'daily': { + // The held-over prior night must not leak into either reader. + 'readiness': _env(61), + 'readiness_provisional': _env(27.6), + }, + 'status': { + 'today_day': '2026-10-08', + 'overnight_state': 'building', + 'recovery_state': 'provisional', + 'showing_prior_overnight': true, + 'overnight_day': '2026-10-07', + }, + }, 27.6); + }); + }); + + group('Readiness detail and Health say what Home says', () { + Future pumpDetail(WidgetTester t, ReadinessData d) => + t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ReadinessDetail(data: d), + )); + + testWidgets('provisional: the number, finishing up', (t) async { + await pumpDetail( + t, + const ReadinessData( + head: {'recovery_state': 'provisional', 'recovery': 27.6}, + readiness: Metric(value: 27.6, confidence: .8))); + await t.pumpAndSettle(); + expect(find.text('28'), findsOneWidget); + expect(find.text('Finishing up'), findsOneWidget); + }); + + testWidgets('final with a changed number: the update note', (t) async { + final at = DateTime(2026, 10, 8, 8, 12).millisecondsSinceEpoch; + await pumpDetail( + t, + ReadinessData(head: { + 'recovery_state': 'final', + 'recovery': 28, + 'recovery_update': {'from': 2, 'to': 28, 'at': at}, + }, readiness: const Metric(value: 28, confidence: .8))); + await t.pumpAndSettle(); + final clock = formatClockOf(DateTime.fromMillisecondsSinceEpoch(at)); + expect(find.text('Updated $clock · 2 → 28'), findsOneWidget); + expect(find.text('Finishing up'), findsNothing); + }); + + test('the shared line (Health reads it too) per state', () { + expect(recoveryStateLine({'recovery_state': 'night_in_progress'}, null), + 'Sleeping…'); + expect(recoveryStateLine({'recovery_state': 'provisional'}, null), + 'Finishing up'); + expect(recoveryStateLine({'recovery_state': 'final'}, null), isNull); + expect(recoveryStateLine({'recovery_state': 'none'}, null), isNull); + }); + }); +} diff --git a/test/recovery_states_test.dart b/test/recovery_states_test.dart new file mode 100644 index 000000000..4001eb6da --- /dev/null +++ b/test/recovery_states_test.dart @@ -0,0 +1,206 @@ +// Today's recovery headline: three explicit states, a pin that only freezes a +// FINAL main night, and a read side that only trusts a pin for its own night. +// +// The reported case: a night 01:05–07:56 local (IST on the device) had +// `{"day": …, "value": 2}` pinned at 04:04, three hours into the night, and +// Home showed 2 all day while Coach (reading day_result) said 27.6. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:shared_preferences/shared_preferences.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'; + +int sec(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // Local wall-clock times, so the 03:00 guard reads the same in any TZ. + const day = '2026-10-08'; + final onset = sec(DateTime(2026, 10, 8, 1, 5)); + final wake = sec(DateTime(2026, 10, 8, 7, 56)); + final at0404 = sec(DateTime(2026, 10, 8, 4, 4)); + + group('recoveryStateOf', () { + test('edge still at the window close → night in progress', () { + // Mid-drain at 04:04 the stager closes the window at the newest record. + expect(recoveryStateOf(wakeSec: at0404, dataEdgeSec: at0404), + RecoveryState.nightInProgress); + }); + + test('no window yet claims no state (could be an unworn night)', () { + expect(recoveryStateOf(wakeSec: null, dataEdgeSec: at0404), isNull); + }); + + test('wake confirmed but not settled → provisional', () { + expect( + recoveryStateOf( + wakeSec: wake, dataEdgeSec: wake + kWakeConfirmMarginSec), + RecoveryState.provisional); + }); + + test('edge an hour past wake → final', () { + expect(recoveryStateOf(wakeSec: wake, dataEdgeSec: wake + 3600), + RecoveryState.finalReady); + }); + }); + + group('pin guards (reported case replay)', () { + test('pin attempt at 04:04 mid-night is refused', () { + // Not settled: the edge is the window close. + expect( + nextFrozenHeadline( + today: day, + overnightComplete: overnightSettled( + sleepOffsetSec: at0404, dataEdgeSec: at0404), + liveReadiness: 2, + current: null, + wakeSec: at0404, + onsetSec: onset, + ), + isNull); + // Even if the edge claimed it settled, 2h59m of sleep is no main night. + expect( + nextFrozenHeadline( + today: day, + overnightComplete: true, + liveReadiness: 2, + current: null, + wakeSec: at0404, + onsetSec: onset, + ), + isNull); + }); + + test('a wake before 03:00 local is never pinned', () { + expect( + nextFrozenHeadline( + today: day, + overnightComplete: true, + liveReadiness: 40, + current: null, + wakeSec: sec(DateTime(2026, 10, 8, 2, 30)), + onsetSec: sec(DateTime(2026, 10, 7, 21, 0)), + ), + isNull); + }); + + test('after wake the final value pins', () { + final pin = nextFrozenHeadline( + today: day, + overnightComplete: + overnightSettled(sleepOffsetSec: wake, dataEdgeSec: wake + 3600), + liveReadiness: 28, + current: null, + wakeSec: wake, + onsetSec: onset, + ); + expect(pin?.value, 28); + expect(pin?.wakeSec, wake); + }); + + test('an old pin without wakeSec is replaced by the final night', () { + final pin = nextFrozenHeadline( + today: day, + overnightComplete: true, + liveReadiness: 28, + current: (day: day, value: 2, wakeSec: null), + wakeSec: wake, + onsetSec: onset, + ); + expect(pin?.value, 28); + }); + + test('a same-wake pin is not held once the night is too short', () { + final pin = nextFrozenHeadline( + today: day, + overnightComplete: true, + liveReadiness: 28, + current: (day: day, value: 2, wakeSec: wake), + wakeSec: wake, + onsetSec: wake - 3600, // re-derived down to an hour + ); + expect(pin, isNull); + }); + }); + + group('read side', () { + late String dir; + setUp(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_recovery_states_test.db'; + dir = await databaseFactory.getDatabasesPath(); + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'readiness': 27.6}}), + windowJson: jsonEncode({ + 'onset_ms': onset * 1000, + 'offset_ms': wake * 1000, + }), + ); + }); + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a pin is trusted only for the night it was taken on', () async { + await LocalDb.setFrozenHeadline(day, 2); // old build: no wake + expect(await LocalDb.headlinePinFor(day), isNull); + await LocalDb.setFrozenHeadline(day, 2, wakeSec: at0404); + expect(await LocalDb.headlinePinFor(day), isNull, + reason: 'pinned on a wake 4 h before the real one'); + await LocalDb.setFrozenHeadline(day, 28, wakeSec: wake + 600); + expect(await LocalDb.headlinePinFor(day), 28); + expect(await LocalDb.headlinePinFor('2026-10-07'), isNull); + }); + + test('a pin is not served for a night too short to pin', () async { + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({'scalars': {'readiness': 27.6}}), + windowJson: jsonEncode({ + 'onset_ms': (wake - 3600) * 1000, + 'offset_ms': wake * 1000, + }), + ); + await LocalDb.setFrozenHeadline(day, 28, wakeSec: wake); + expect(await LocalDb.headlinePinFor(day), isNull); + }); + + test('a manual re-analyse releases today\'s pin so the re-derive re-pins', + () async { + SharedPreferences.setMockInitialValues({}); + // A same-night pin would hold the old number through the re-derive + // while the re-analysis summary reported the new one. + await LocalDb.setFrozenHeadline(todayLabel(), 2, wakeSec: wake); + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.reanalyzeAll(); + // Nothing here to re-derive, so nothing re-pins; the stale 2 is gone. + expect(await LocalDb.frozenHeadline(), isNull); + }); + + test('a changed final headline is remembered as from → to', () async { + expect(await LocalDb.noteHeadlineShown(day, 2), isNull); + expect(await LocalDb.noteHeadlineShown(day, 2), isNull); + final u = await LocalDb.noteHeadlineShown(day, 28); + expect(u?['from'], 2); + expect(u?['to'], 28); + // Stable on later reads, so the card keeps saying it. + expect((await LocalDb.noteHeadlineShown(day, 28))?['from'], 2); + // A new day starts clean. + expect(await LocalDb.noteHeadlineShown('2026-10-09', 50), isNull); + }); + }); +} diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart index 16a014b2a..13bfbf428 100644 --- a/test/widget_service_sentinels_test.dart +++ b/test/widget_service_sentinels_test.dart @@ -341,6 +341,28 @@ void main() { expect(written['ring_recovery_why'], ''); }); + test('a provisional night publishes its own number greyed, the held-over ' + 'prior night never', () async { + await WidgetService.push(TodayData.fromJson({ + 'daily': { + 'readiness': 61, // the prior night, held over + 'readiness_provisional': 27.6, + }, + 'status': { + 'today_day': todayLabel(), + 'overnight_state': 'building', + 'recovery_state': 'provisional', + 'showing_prior_overnight': true, + 'overnight_day': '2000-01-01', + }, + })); + expect(written['ring_recovery_state'], 1); // drawn muted natively + expect(written['ring_recovery_value'], '28'); + expect(written['ring_recovery_sub'], 'Finishing up'); + // Watch / Siri read the bare int: nothing until it is final. + expect(written['readiness'], -1); + }); + // The one absence that is PROGRESS rather than a gap, and the only one a // ring may honestly draw an arc for. test('a baseline still filling is calibration progress, not a low score',