diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index a45faa779..36c3c9435 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6989,12 +6989,17 @@ class DerivationEngine { bool carrySleep = true, }) { var carried = false; + var wearCarried = false; for (final e in prev.entries) { if (e.key == 'scalars' || e.key == 'series') continue; + // Row metadata, not detail: the persist stamps the fresh edge, and the + // wear edge is stamped below only when wear itself came across. + if (e.key == 'data_edge_sec' || e.key == 'wear_edge_sec') continue; if (!carrySleep && kSleepOwnedDetailKeys.contains(e.key)) continue; if (next.containsKey(e.key) || e.value == null) continue; next[e.key] = e.value; carried = true; + if (e.key == 'wear') wearCarried = true; } // `scalars` and `series` are flat maps the second half patches INTO rather // than owning, so they merge per key instead of wholesale. @@ -7025,6 +7030,17 @@ class DerivationEngine { } if (merged != null) next[sub] = merged; } + // #450. A carried wear block was computed against the PREVIOUS row's + // edge, not this one's, and a second half that keeps failing would + // otherwise pass that morning off as the current day. Stamped only when + // WEAR was carried: a second half can fail after writing fresh wear (a + // later step throws) while other detail is carried, and fresh wear must + // not be labelled old. A chain of failures keeps the oldest edge: that is + // when wear was last actually computed. + if (wearCarried) { + final edge = prev['wear_edge_sec'] ?? prev['data_edge_sec']; + if (edge is num) next['wear_edge_sec'] = edge.toInt(); + } return carried; } diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 1c708a471..d05b39880 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -1469,6 +1469,14 @@ class LocalRepositoryImpl extends LocalRepository { 'first_on': w?['first_on'], 'last_on': w?['last_on'], 'longest_off_min': w?['longest_off_min'], + // #450. The data edge this wear was computed at, when it is older than + // the row it sits in: the derive's second half failed and the engine + // carried the previous wear forward. Absent on a complete derive, so a + // screen never shows an age that is not there. Not gated on the block: + // the stamp exists only when wear was carried, whichever field above + // the figure ends up read from. + if (b['wear_edge_sec'] is num) + 'as_of': (b['wear_edge_sec'] as num).toInt(), 'hourly': const [], }; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 65f18ac64..c956e0110 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3685,5 +3685,7 @@ "stepCalDoneHousehold": "Gespeichert. Dein Smartphone hat {count} der {band} Schritte gezählt, die das Band erkannt hat. Bewegung im Haushalt wird jetzt auf {gain} skaliert.", "whoopFirmwareWarningTitle": "WHOOP-Nutzer: Verbinde dich nicht wieder mit der WHOOP-App", "whoopFirmwareWarningAction": "Wenn du OpenStrap oder eine andere Open-Source-App nutzt oder später zu einer wechseln möchtest, verbinde dein Band nicht wieder mit der offiziellen WHOOP-App. Sie kann Firmware-Updates automatisch installieren. Deinstalliere oder deaktiviere die WHOOP-App, damit sie dein Band nicht aktualisiert.", - "whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe." + "whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe.", + "healthWearNotUpdatedSince": "nicht aktualisiert seit {time}", + "dayStrainWornUntil": "Getragen bis {time}" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b43507676..d2c33ca7b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16805,5 +16805,23 @@ "@strengthSaveFailed": {"description": "Strength set editor copy."}, "napAutoLogTitle": "Log clear naps automatically", "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it", - "napAutoLogSaveFailed": "That setting could not be saved. Try again." + "napAutoLogSaveFailed": "That setting could not be saved. Try again.", + "healthWearNotUpdatedSince": "not updated since {time}", + "@healthWearNotUpdatedSince": { + "description": "Wear-time row: the figure is from an earlier derive that the latest one could not refresh. time is a clock time, e.g. 11:05 AM", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "dayStrainWornUntil": "Worn until {time}", + "@dayStrainWornUntil": { + "description": "Day strain: label over a wear figure from an earlier derive that the latest one could not refresh. time is a clock time, e.g. 11:05 AM", + "placeholders": { + "time": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2cf903bc1..635aeb207 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3385,5 +3385,7 @@ "stepCalDoneHousehold": "Guardado. Tu teléfono contó {count} de los {band} pasos que vio la banda. El movimiento en casa ahora se escala a {gain}.", "whoopFirmwareWarningTitle": "Usuarios de WHOOP: no vuelvas a conectarte a la app de WHOOP", "whoopFirmwareWarningAction": "Si usas OpenStrap u otra app de código abierto, o piensas cambiarte a una más adelante, no vuelvas a conectar tu banda a la app oficial de WHOOP. Puede instalar actualizaciones de firmware automáticamente. Desinstala o desactiva la app de WHOOP para evitar que actualice tu banda.", - "whoopFirmwareWarningDetails": "Hay informes de que el firmware 50.43.1.0 y 50.43.2.0 de WHOOP 5.0 / MG bloquea el acceso a los datos. Es posible que el Bluetooth siga conectándose. OpenStrap no puede emitir el token de membresía firmado por WHOOP que se necesita para desbloquear las bandas afectadas. Estamos trabajando en una solución, pero todavía no hay ningún arreglo disponible." + "whoopFirmwareWarningDetails": "Hay informes de que el firmware 50.43.1.0 y 50.43.2.0 de WHOOP 5.0 / MG bloquea el acceso a los datos. Es posible que el Bluetooth siga conectándose. OpenStrap no puede emitir el token de membresía firmado por WHOOP que se necesita para desbloquear las bandas afectadas. Estamos trabajando en una solución, pero todavía no hay ningún arreglo disponible.", + "healthWearNotUpdatedSince": "sin actualizar desde {time}", + "dayStrainWornUntil": "Llevada hasta {time}" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 112586647..796ff9864 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3385,5 +3385,7 @@ "pairOuraExistingKeyGuidance": "Vous utilisez déjà l’application Oura ? Gardez l’anneau associé dans cette application et associez-le avec sa clé actuelle. Passez les étapes de réinitialisation ci-dessous. Réinitialisez-le uniquement si vous voulez une nouvelle clé.", "whoopFirmwareWarningTitle": "Utilisateurs WHOOP : ne vous reconnectez pas à l’application WHOOP", "whoopFirmwareWarningAction": "Si vous utilisez OpenStrap ou une autre application open source, ou si vous comptez y passer plus tard, ne reconnectez pas votre bracelet à l’application WHOOP officielle. Elle peut installer des mises à jour du firmware automatiquement. Désinstallez ou désactivez l’application WHOOP pour l’empêcher de mettre à jour votre bracelet.", - "whoopFirmwareWarningDetails": "Des signalements concernant les firmwares 50.43.1.0 et 50.43.2.0 des WHOOP 5.0 / MG décrivent un accès aux données bloqué. Le Bluetooth peut encore se connecter. OpenStrap ne peut pas émettre le jeton d’abonnement signé par WHOOP nécessaire pour déverrouiller les bracelets concernés. Nous travaillons sur une solution, mais aucun correctif n’est encore disponible." + "whoopFirmwareWarningDetails": "Des signalements concernant les firmwares 50.43.1.0 et 50.43.2.0 des WHOOP 5.0 / MG décrivent un accès aux données bloqué. Le Bluetooth peut encore se connecter. OpenStrap ne peut pas émettre le jeton d’abonnement signé par WHOOP nécessaire pour déverrouiller les bracelets concernés. Nous travaillons sur une solution, mais aucun correctif n’est encore disponible.", + "healthWearNotUpdatedSince": "pas mis à jour depuis {time}", + "dayStrainWornUntil": "Porté jusqu’à {time}" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9ed1242ae..09a57674f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3386,5 +3386,7 @@ "stepCalRoutineTooShort": "वह अभ्यास टाइमर ख़त्म होने से पहले रुक गया। पूरा होने तक चलते रहें।", "stepCalSaveFailed": "वह कैलिब्रेशन सहेजा नहीं जा सका।", "stepCalDoneGait": "सहेजा गया। आपने {count} कदम गिने और बैंड ने {band} कदम देखे। अब से इस तरह की चाल {pct}% से ठीक की जाएगी।", - "stepCalDoneHousehold": "सहेजा गया। बैंड द्वारा देखे गए {band} कदमों में से आपके फ़ोन ने {count} कदम गिने। घर के कामों की हलचल अब {gain} के अनुपात में समायोजित होगी।" + "stepCalDoneHousehold": "सहेजा गया। बैंड द्वारा देखे गए {band} कदमों में से आपके फ़ोन ने {count} कदम गिने। घर के कामों की हलचल अब {gain} के अनुपात में समायोजित होगी।", + "healthWearNotUpdatedSince": "{time} से अपडेट नहीं हुआ", + "dayStrainWornUntil": "{time} तक पहना" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index cb40872e8..31829102b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8159,5 +8159,7 @@ "pairTroubleDiscordSub": "Сообщество OpenStrap", "pairModeTitle": "Переведите в режим сопряжения", "pairModeNoSite": "Это универсальное устройство без единого производителя, поэтому инструкции производителя нет.", - "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа." + "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа.", + "healthWearNotUpdatedSince": "не обновлялось с {time}", + "dayStrainWornUntil": "Ношение до {time}" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 227ddbff6..6e8470302 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16627,5 +16627,7 @@ "@pairModeNoSite": { "description": "Shown instead of steps for a generic device with no maker’s page." }, - "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น" + "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น", + "healthWearNotUpdatedSince": "ไม่ได้อัปเดตตั้งแต่ {time}", + "dayStrainWornUntil": "สวมถึง {time}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 52eae2f7c..c11e381ec 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3385,5 +3385,7 @@ "stepCalRoutineTooShort": "这项测试在计时结束前就停止了。请坚持到它完成。", "stepCalSaveFailed": "无法保存此次校准。", "stepCalDoneGait": "已保存。你数了 {count} 步,腕带记录了 {band} 步。今后这类走路将按 {pct}% 进行校正。", - "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。" + "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。", + "healthWearNotUpdatedSince": "自 {time} 起未更新", + "dayStrainWornUntil": "佩戴至 {time}" } diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index 914938f53..8e4034c52 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -27,7 +27,7 @@ import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; import '../../l10n/decimal_text.dart'; import '../../models/metric.dart' show whyFromNote; -import '../screens/home_screen.dart' show repoOf; +import '../screens/home_screen.dart' show asOfText, repoOf; import '../screens/metric_detail.dart' show detailScaffold; import '../ui2.dart'; import 'catalogue.dart' show zonesWhy; @@ -72,6 +72,10 @@ class DayStrainData { final int? peakHr, wornMin, coveragePct; + /// When [wornMin] and [coveragePct] were last computed, if an earlier + /// derive's wear was carried forward (#450). Null when they are current. + final int? wearAsOf; + /// WHY the day has no strain, as the bundle said it — never a sentence /// written on this screen. Null means nothing came back with the absence, and /// the screen then says exactly that. @@ -89,11 +93,15 @@ class DayStrainData { this.peakHr, this.wornMin, this.coveragePct, + this.wearAsOf, this.note, }); bool get hasCurve => curve.any((v) => v != null); + /// The local label of the day this screen shows (today when [day] is null). + String get shownDay => day == null ? todayLabel() : dayLabelOf(day!); + /// Lays the curve's epoch-second points onto the fixed 00:00–24:00 axis, /// one slot per WALL-CLOCK minute of the first point's local day. /// @@ -174,6 +182,7 @@ class DayStrainData { peakHr: hr is Map ? (hr['max'] as num?)?.toInt() : null, wornMin: (wear['worn_min'] as num?)?.toInt(), coveragePct: (wear['coverage_pct'] as num?)?.toInt(), + wearAsOf: (wear['as_of'] as num?)?.toInt(), // The headline absence's reason, at the top level of the payload — the // same string as `absent.strain`. note: s['note'] as String?, @@ -328,7 +337,17 @@ class _DayStrainDetailState extends State { if (d.peakHr != null) (l?.dayStrainPeakHr ?? 'Peak HR', '${d.peakHr} bpm', C.red), if (d.wornMin != null) - (l?.dayStrainWorn ?? 'Worn', '${d.wornMin} min', C.teal), + // #450: a carried figure says where it stops, or a frozen + // morning reads as the whole day so far. + ( + d.wearAsOf == null + ? (l?.dayStrainWorn ?? 'Worn') + : (l?.dayStrainWornUntil( + asOfText(d.wearAsOf!, d.shownDay, l)) ?? + 'Worn until ${asOfText(d.wearAsOf!, d.shownDay, l)}'), + '${d.wornMin} min', + C.teal, + ), ]), ], ]), diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 7526cc22a..9cf0d76bc 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -1168,6 +1168,11 @@ class _HealthScreenState extends State with RevisionReload { final resp = respBlock is Map ? respBlock['value'] as num? : null; final worn = v.wear['worn_min'] as num?; final coverage = v.wear['coverage_pct'] as num?; + // #450: the latest derive could not refresh wear, so this is an earlier + // one's figure. Say so rather than let a frozen morning read as now. + final wearAsOf = v.wear['as_of'] as num?; + final wearAsOfText = + wearAsOf == null ? '' : asOfText(wearAsOf, v.day ?? todayLabel(), l); final skinTemp = metricOf(d.today['skin_temp']); // WHOOP 5 reads in real °C, so its row says degrees, not SDs. final tempDevC = ((d.today['skin_temp'] as Map?)?['dev_c'] as num?) @@ -1220,12 +1225,17 @@ class _HealthScreenState extends State with RevisionReload { MetricRow(LucideIcons.watch, C.green, l?.healthRowWearTime ?? 'Wear time', hm(worn), // `83.33333333333333% of the day` shipped. It is a percentage. - sub: coverage == null - ? dayWord - : (l?.healthCoverageOf(coverage.round(), - isToday ? (l.healthTheDay) : dayWord) ?? - '${coverage.round()}% of ' - '${isToday ? 'the day' : dayWord}'), + sub: [ + coverage == null + ? dayWord + : (l?.healthCoverageOf(coverage.round(), + isToday ? (l.healthTheDay) : dayWord) ?? + '${coverage.round()}% of ' + '${isToday ? 'the day' : dayWord}'), + if (wearAsOf != null) + l?.healthWearNotUpdatedSince(wearAsOfText) ?? + 'not updated since $wearAsOfText', + ].join(' · '), onTap: () => go(c, const MetricDetail('wear'))), ]; diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index d5ff203e9..0eff81ee4 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -43,7 +43,8 @@ import '../../compute/findings.dart' kReadinessEasyBelow, kReadinessGoodFrom, kReadinessRestBelow; -import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; +import '../../data/day_label.dart' + show calendarDaysBetween, dayLabelOf, todayLabel; import '../../compute/onehz_pipeline.dart' show readinessInputShortfallNote, readinessUnstableBaselineNote; import '../../data/db.dart' show DbRebuild, LocalDb; @@ -685,6 +686,17 @@ String clock(num? minOfDay) => minOfDay == null ? '' : formatMinuteOfDay(minOfDay.round()); /// Epoch seconds → "11:08 PM" / "23:08" in the device zone. +/// When a carried wear figure was last computed (#450), as seen from the day +/// on screen ([shownDay], a local label): a clock alone when it fell on that +/// day, otherwise the date too. A past day's carried wear can date from a +/// later day's sync, and "08:00" under that past day would name the wrong one. +String asOfText(num ts, String shownDay, [AppLocalizations? l]) { + final day = dayLabelOf(DateTime.fromMillisecondsSinceEpoch(ts.round() * 1000)); + return day == shownDay + ? clockOfTs(ts) + : '${prettyDay(day, l)} ${clockOfTs(ts)}'; +} + String clockOfTs(num? ts) { if (ts == null) return ''; return formatClockOf(DateTime.fromMillisecondsSinceEpoch(ts.round() * 1000)); diff --git a/test/carry_forward_detail_test.dart b/test/carry_forward_detail_test.dart index 773cb90b5..88153adef 100644 --- a/test/carry_forward_detail_test.dart +++ b/test/carry_forward_detail_test.dart @@ -105,6 +105,54 @@ void main() { expect(next['series'], same(series)); }); + // #450. Wear and the day's curves live in the second half, so a carried + // block was computed against an EARLIER data edge than the row it now sits + // in. The row says which, or a frozen morning reads as the current day. + group('carried wear names the edge it was computed at', () { + test('a first failure stamps the previous row\'s edge', () { + final prev = _storedCompleteRow() + ..['data_edge_sec'] = _t0 + 3600 + ..['wear'] = {'worn_min': 60}; + final next = _freshBundle(); + expect(DerivationEngine.carryForwardDetail(prev, next), isTrue); + expect(next['wear'], prev['wear']); + expect(next['wear_edge_sec'], _t0 + 3600); + }); + + test('a second failure in a row keeps the OLDEST edge', () { + final prev = _storedCompleteRow() + ..['data_edge_sec'] = _t0 + 7200 + ..['wear_edge_sec'] = _t0 + 3600 + ..['wear'] = {'worn_min': 60}; + final next = _freshBundle(); + DerivationEngine.carryForwardDetail(prev, next); + expect(next['wear_edge_sec'], _t0 + 3600); + }); + + test('fresh wear beside other carried detail is not stamped', () { + // The second half wrote wear, then a later step threw: the previous + // row's other detail is carried, but wear is this pass's own. + final prev = _storedCompleteRow() + ..['data_edge_sec'] = _t0 + 3600 + ..['wear'] = {'worn_min': 60}; + final next = _freshBundle()..['wear'] = {'worn_min': 95}; + expect(DerivationEngine.carryForwardDetail(prev, next), isTrue); + expect((next['wear'] as Map)['worn_min'], 95); + expect(next.containsKey('wear_edge_sec'), isFalse); + }); + + test('a previous edge alone is not detail, and stamps nothing', () { + final prev = SeriesCodec.decodePayloadJson( + SeriesCodec.encodePayloadJson(jsonEncode(_freshBundle())), + )! + ..['data_edge_sec'] = _t0 + 3600; + final next = _freshBundle(); + expect(DerivationEngine.carryForwardDetail(prev, next), isFalse); + expect(next.containsKey('wear_edge_sec'), isFalse); + expect(next.containsKey('data_edge_sec'), isFalse); + }); + }); + // A retimed or rejected night re-derives the day. If that pass's second half // fails, the previous row's sleep blocks describe the OLD night and must not // be grafted onto the new window. diff --git a/test/wear_stale_detail_test.dart b/test/wear_stale_detail_test.dart new file mode 100644 index 000000000..a20abc884 --- /dev/null +++ b/test/wear_stale_detail_test.dart @@ -0,0 +1,228 @@ +// #450: wear time froze for the rest of the day. +// +// Wear is computed in the derive's second half. When that half fails, the +// engine keeps the headline and carries the previous row's wear forward, so +// the figure stops moving while everything around it says "today". The row +// now records the edge the carried detail was computed at +// (`wear_edge_sec`), and the wear read hands it to the screen, which says +// "not updated since" instead of passing a frozen morning off as current. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.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'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Just the Vitals reads, with a fixed wear payload. +class _Repo extends LocalRepository { + _Repo(this.wear); + final Map wear; + static const day = '2026-07-14'; + + @override + Future> getToday() async => const { + 'status': {'today_day': day}, + }; + @override + Future> availableDays() async => const [day]; + @override + Future> getDayTimeline(String date) async => { + 'date': date, + }; + @override + Future> getDayLungs(String date) async => const {}; + @override + Future> getDayWear(String date) async => wear; + @override + Future> getDayHrv(String date) async => const {}; +} + +Future _vitals(WidgetTester t, Map wear) async { + t.view.physicalSize = const Size(800 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting()..repo = _Repo(wear); + addTearDown(app.dispose); + await t.pumpWidget( + MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HealthScreen()), + ), + ), + ); + for (var i = 0; i < 20; i++) { + await t.pump(); + } + await t.tap(find.text('Vitals')); + for (var i = 0; i < 20; i++) { + await t.pump(); + } +} + +void main() { + const day = '2026-07-14'; + final edge = DateTime(2026, 7, 14, 11, 5).millisecondsSinceEpoch ~/ 1000; + late LocalRepositoryImpl repo; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() async { + await LocalDb.close(); + LocalDb.dbName = 'wear_stale_detail_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + repo = LocalRepositoryImpl(getProfileMap: () => {}); + }); + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + Future store({int? wearEdge}) => LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'scalars': {'rhr': 53}, + 'wear': {'worn_min': 300, 'coverage_pct': 45, 'segments': []}, + 'data_edge_sec': edge + 4 * 3600, + 'wear_edge_sec': ?wearEdge, + }), + windowJson: '{}', + partial: wearEdge != null, + ); + + test('carried wear says how old it is', () async { + await store(wearEdge: edge); + final w = await repo.getDayWear(day); + expect(w['worn_min'], 300); + expect(w['as_of'], edge); + }); + + test('wear from a complete derive carries no age', () async { + await store(); + final w = await repo.getDayWear(day); + expect(w['worn_min'], 300); + expect(w.containsKey('as_of'), isFalse); + }); + + group('the Wear time row', () { + testWidgets('says when a carried figure was last updated', (t) async { + await _vitals(t, {'worn_min': 300, 'coverage_pct': 45, 'as_of': edge}); + expect(find.text('Wear time'), findsOneWidget); + expect( + find.textContaining('not updated since ${clockOfTs(edge)}'), + findsOneWidget, + ); + expect( + find.textContaining('45%'), + findsOneWidget, + reason: 'the coverage stays beside the age', + ); + }); + + testWidgets('names the day when the edge fell on another one', (t) async { + final nextDay = + DateTime(2026, 7, 15, 2, 30).millisecondsSinceEpoch ~/ 1000; + await _vitals(t, {'worn_min': 300, 'coverage_pct': 45, 'as_of': nextDay}); + final line = find.textContaining('not updated since'); + expect(line, findsOneWidget); + expect( + (t.widget(line)).data, + contains('${prettyDay('2026-07-15')} ${clockOfTs(nextDay)}'), + reason: 'a bare 2:30 under the 14th would read as that day', + ); + }); + + testWidgets('a complete figure says nothing about age', (t) async { + await _vitals(t, {'worn_min': 300, 'coverage_pct': 45}); + expect(find.text('Wear time'), findsOneWidget); + expect(find.textContaining('not updated since'), findsNothing); + }); + }); + + group('Day Strain', () { + Future show(WidgetTester t, DayStrainData d, double scale) async { + t.view.physicalSize = const Size(390 * 3, 2200 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final curve = List.filled(1440, null); + for (var m = 7 * 60; m < 11 * 60; m++) { + curve[m] = (m - 7 * 60) / 60; + } + await t.pumpWidget( + MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: DayStrainDetail( + data: DayStrainData( + day: DateTime(2026, 7, 14), + curve: curve, + strain: 9.2, + wornMin: d.wornMin, + coveragePct: d.coveragePct, + wearAsOf: d.wearAsOf, + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + } + + testWidgets('a carried wear figure says where it stops', (t) async { + await show( + t, + DayStrainData(wornMin: 300, coveragePct: 90, wearAsOf: edge), + 1, + ); + expect(find.text('Worn until ${clockOfTs(edge)}'), findsOneWidget); + expect(find.text('Worn'), findsNothing); + // The pump is the overflow assertion at the largest iOS text size. + await show( + t, + DayStrainData(wornMin: 300, coveragePct: 90, wearAsOf: edge), + 3.1, + ); + expect(t.takeException(), isNull); + }); + + testWidgets('names the day when the edge fell on another one', (t) async { + // The screen shows 14 July; the carried wear was computed on the 15th. + final nextDay = + DateTime(2026, 7, 15, 2, 30).millisecondsSinceEpoch ~/ 1000; + await show( + t, + DayStrainData(wornMin: 300, coveragePct: 90, wearAsOf: nextDay), + 1, + ); + expect( + find.text('Worn until ${prettyDay('2026-07-15')} ${clockOfTs(nextDay)}'), + findsOneWidget, + ); + }); + + testWidgets('a current one is plain "Worn"', (t) async { + await show(t, const DayStrainData(wornMin: 300, coveragePct: 90), 1); + expect(find.text('Worn'), findsOneWidget); + expect(find.textContaining('Worn until'), findsNothing); + }); + }); +}