diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index 6a0bd7535..96e298fa1 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -16,12 +16,14 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../ui2/activity/catalogue.dart' show exerciseByKey; import 'db.dart'; +import 'series_codec.dart'; /// One exportable table: a filename stem, a header, and the query behind it. class CsvExportSet { @@ -405,14 +407,7 @@ Future exportCsvFiles( DateTime? now, }) async { final db = await LocalDb.instance; - final root = await getTemporaryDirectory(); - final parent = Directory(p.join(root.path, _csvDirName)); - await parent.create(recursive: true); - - final stamp = (now ?? DateTime.now()).millisecondsSinceEpoch; - final dir = Directory(p.join(parent.path, '$stamp')); - await dir.create(recursive: true); - await _pruneOldRuns(parent, keep: _csvRunsKept); + final (dir, stamp) = await _newRunDir(now); final paths = []; final failed = []; @@ -446,6 +441,92 @@ Future exportCsvFiles( return CsvExportResult(paths: paths, failed: failed); } +Future<(Directory, int)> _newRunDir(DateTime? now) async { + final root = await getTemporaryDirectory(); + final parent = Directory(p.join(root.path, _csvDirName)); + await parent.create(recursive: true); + final stamp = (now ?? DateTime.now()).millisecondsSinceEpoch; + final dir = Directory(p.join(parent.path, '$stamp')); + await dir.create(recursive: true); + await _pruneOldRuns(parent, keep: _csvRunsKept); + return (dir, stamp); +} + +const kHeartRateCsvColumns = ['timestamp', 'local_time', 'bpm', 'source']; + +/// One CSV row per minute of a day's stored heart-rate curve. +/// +/// The curve is the per-minute MEAN of the band's 1 Hz readings, written when +/// the day is derived and kept for as long as the day is — unlike the 1 Hz +/// rows themselves, which are pruned a few days behind the newest sync. So +/// this is every minute the app still knows, at the resolution it still has. +/// [source] is the day's provenance stamp, empty when unknown (never guessed). +List> heartRateMinuteRows( + List curve, String? source) { + String two(int v) => v.toString().padLeft(2, '0'); + return [ + for (final e in curve) + if (e is Map && e['t'] is num && e['v'] is num && (e['v'] as num) > 0) + () { + final t = (e['t'] as num).toInt(); + final d = DateTime.fromMillisecondsSinceEpoch(t * 1000); + return { + 'timestamp': t, + 'local_time': '${d.year}-${two(d.month)}-${two(d.day)} ' + '${two(d.hour)}:${two(d.minute)}', + 'bpm': e['v'], + 'source': source, + }; + }(), + ]; +} + +/// Write minute-by-minute heart rate for derived days [from]..[to] +/// (inclusive, `yyyy-MM-dd`) to one CSV. Null when there was nothing in the +/// range — no header-only file, same rule as [exportCsvFiles]. +Future exportHeartRateCsv(String from, String to, + {DateTime? now}) async { + final db = await LocalDb.instance; + final days = [ + for (final d in await LocalDb.availableDayIds()) + if (d.compareTo(from) >= 0 && d.compareTo(to) <= 0) d, + ]..sort(); + // Provenance is keyed by the version that wrote it: a day's stamp counts + // only against the day_result of that same version, so a re-derive landing + // between these reads leaves the source empty (unknown), never another + // version's. + final source = { + for (final r in await db.rawQuery( + 'SELECT date, algo_version, source FROM metric_series_version ' + 'WHERE date >= ? AND date <= ?', + [from, to])) + (r['date'] as String, (r['algo_version'] as num?)?.toInt()): + r['source'] as String?, + }; + final rows = >[]; + for (final day in days) { + final res = await LocalDb.dayResult(day); + final b = SeriesCodec.decodePayloadJson(res?['payload_json']); + final curve = (b?['series'] as Map?)?['hr_curve']; + final v = (res?['algo_version'] as num?)?.toInt(); + if (curve is List) rows.addAll(heartRateMinuteRows(curve, source[(day, v)])); + } + if (rows.isEmpty) return null; + // A long range is hundreds of thousands of rows: render off the UI isolate. + final csv = + await Isolate.run(() => renderCsv(kHeartRateCsvColumns, rows)); + final (dir, stamp) = await _newRunDir(now); + final file = + File(p.join(dir.path, 'openstrap_heart_rate_${from}_${to}_$stamp.csv')); + await file.writeAsBytes([ + 0xEF, + 0xBB, + 0xBF, + ...utf8.encode(csv), + ]); + return file.path; +} + /// Delete all but the [keep] newest run directories under [parent]. /// /// Run directories are named by millisecond timestamp, so a lexicographic sort diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index ca7dcff26..a8a757f7c 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -1784,6 +1784,10 @@ class LocalRepositoryImpl extends LocalRepository { 'resp': respLine, 'skin_temp': tempLine, 'activity': b['activity_curve'] ?? const [], + // Who owned each stretch of the merged HR curve (only on a day more + // than one device measured) — the zoomed chart's per-second read keeps + // the same device per second the stored curve did. + 'hr_owner': (_sub(b, 'series')?['coverage'] as Map?)?['hr1Hz'], // The DISPLAYED day (bundle date) — when a partial "today" fell back to // the latest complete day this differs from the requested date, and the // screen must window/axis by THIS date, not "now". diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7cdb11fde..c58aeca6e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14526,5 +14526,19 @@ "type": "int" } } - } + }, + "dayTimelineZoomNoHr": "No heart rate in this stretch", + "@dayTimelineZoomNoHr": {"description": "Shown in place of the zoomed day chart when the chosen window has no heart rate."}, + "dayTimelineZoomPerMinute": "1-minute averages. Drag the handles to zoom; two hours or less shows every second while it is still kept.", + "@dayTimelineZoomPerMinute": {"description": "Footnote under the day heart-rate chart at per-minute resolution."}, + "dayTimelineZoomPerMinuteOnly": "1-minute averages. Second-by-second readings are no longer kept for this stretch.", + "@dayTimelineZoomPerMinuteOnly": {"description": "Footnote when zoomed in but the per-second rows have been pruned."}, + "dayTimelineZoomPerSecond": "Every second the band recorded. Second-by-second readings are kept for recent days only.", + "@dayTimelineZoomPerSecond": {"description": "Footnote when the zoomed day chart shows per-second readings."}, + "dataExportHeartRate": "Export heart rate, minute by minute", + "@dataExportHeartRate": {"description": "Data screen row: export per-minute heart rate as CSV."}, + "dataExportHeartRateSub": "One CSV for the days you pick: a 1-minute average for every minute the band recorded. Second-by-second readings are kept for recent days only and are in the database export", + "@dataExportHeartRateSub": {"description": "Subtitle for the per-minute heart rate CSV export row."}, + "dataHeartRateRangeHelp": "Heart rate is kept from {first} to {last}", + "@dataHeartRateRangeHelp": {"description": "Date range picker heading for the heart rate export, naming the available range.", "placeholders": {"first": {"type": "String"}, "last": {"type": "String"}}} } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 94e5f2eb1..ddac71b51 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -6834,5 +6834,13 @@ "devicesOuraCheckpointUnconfirmed": "Не удалось завершить синхронизацию. Попробуйте синхронизировать ещё раз.", "investigateRmssdNightly": "RMSSD, ночной (среднее по 5-минутным окнам)", "investigateRmssdStored": "RMSSD, ночной (прежняя оценка)", - "devicesOuraWriteRefused": "Не удалось отправить команду кольцу. Попробуйте синхронизировать ещё раз." + "devicesOuraWriteRefused": "Не удалось отправить команду кольцу. Попробуйте синхронизировать ещё раз.", + "dayTimelineZoomNoHr": "На этом отрезке нет данных о пульсе", + "dayTimelineZoomPerMinute": "Средние за минуту. Потяните ползунки, чтобы приблизить; при окне до двух часов показывается каждая секунда, пока она хранится.", + "dayTimelineZoomPerMinuteOnly": "Средние за минуту. Посекундные данные для этого отрезка уже не хранятся.", + "dayTimelineZoomPerSecond": "Каждая секунда, записанная браслетом. Посекундные данные хранятся только за последние дни.", + "dataExportHeartRate": "Экспортировать пульс по минутам", + "dataExportHeartRateSub": "Один CSV за выбранные дни: средний пульс за каждую минуту, записанную браслетом. Посекундные данные хранятся только за последние дни и входят в экспорт базы данных", + "dataHeartRateRangeHelp": "Пульс хранится с {first} по {last}", + "@dataHeartRateRangeHelp": {"placeholders": {"first": {"type": "String"}, "last": {"type": "String"}}} } diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index f9ab14872..0a6e56a7b 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -24,6 +24,7 @@ import 'package:share_plus/share_plus.dart'; import '../../data/auto_backup.dart'; import '../../data/csv_export.dart'; +import '../../data/day_label.dart' show dayLabelOf; import '../../data/db.dart'; import '../../import/backup_crypto.dart'; import '../../l10n/app_localizations.dart'; @@ -122,6 +123,44 @@ class _DataScreenState extends State { ); } + /// Minute-by-minute heart rate for a chosen range. The picker is bounded to + /// the derived days that exist, and its heading says which those are, so + /// "what range is there" is answered before anything is chosen. + Future<_Note> _exportHeartRate() async { + final days = await LocalDb.availableDayIds(); + if (!mounted) return ('', false); + final l = AppLocalizations.of(context); + if (days.isEmpty) { + return (l?.dataNothingToExportYet ?? 'Nothing to export yet.', false); + } + DateTime parse(String d) => DateTime.parse(d); + final first = parse(days.last), last = parse(days.first); + final range = await showDateRangePicker( + context: context, + firstDate: first, + lastDate: last, + initialDateRange: DateTimeRange( + start: () { + final week = DateTime(last.year, last.month, last.day - 6); + return week.isBefore(first) ? first : week; + }(), + end: last, + ), + helpText: l?.dataHeartRateRangeHelp(days.last, days.first) ?? + 'Heart rate is kept from ${days.last} to ${days.first}', + ); + if (range == null || !mounted) return ('', false); + final origin = shareOrigin(context); + final path = await exportHeartRateCsv( + dayLabelOf(range.start), dayLabelOf(range.end)); + if (path == null) { + return (l?.dataNothingToExportYet ?? 'Nothing to export yet.', false); + } + await Share.shareXFiles([XFile(path)], + subject: 'OpenStrap heart rate', sharePositionOrigin: origin); + return (l?.dataFilesShared(1) ?? '1 file shared.', false); + } + Future<_Note> _exportDb() async { final l = AppLocalizations.of(context); final origin = shareOrigin(context); @@ -296,6 +335,14 @@ class _DataScreenState extends State { 'typed in. Each day carries where it came from and ' 'which algorithm version scored it', onTap: _busy ? null : () => _run(_exportCsv)), + SetRow(LucideIcons.heartPulse, C.red, + l?.dataExportHeartRate ?? 'Export heart rate, minute by minute', + sub: l?.dataExportHeartRateSub ?? + 'One CSV for the days you pick: a 1-minute average ' + 'for every minute the band recorded. Second-by-' + 'second readings are kept for recent days only ' + 'and are in the database export', + onTap: _busy ? null : () => _run(_exportHeartRate)), SetRow(LucideIcons.database, C.blue, l?.dataExportDatabase ?? 'Export the database', sub: l?.dataExportDatabaseSub ?? diff --git a/lib/ui2/screens/day_timeline.dart b/lib/ui2/screens/day_timeline.dart index 71e7cb12a..5d625e3df 100644 --- a/lib/ui2/screens/day_timeline.dart +++ b/lib/ui2/screens/day_timeline.dart @@ -30,6 +30,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ble/adapters/signals.dart' show InputSignal; +import '../../data/coverage_resolver.dart' show OwnedSpan, coverageFromJson, spanAt; import '../../data/day_label.dart' show localDayEndSec; import '../../data/db.dart'; import '../../data/journal_fields.dart'; @@ -416,8 +417,13 @@ class DayGraph { this.movement = const [], this.rest = const [], this.work = const [], + this.dayStart, }); + /// Epoch second of slot 0 — local midnight for a whole day, the window's + /// first minute for a [window]. Null for a graph built without a day. + final int? dayStart; + /// Beats per minute, one slot per minute of the day, `null` where nothing /// was recorded. final List hr; @@ -437,6 +443,25 @@ class DayGraph { bool get hasCurve => hr.any((v) => v != null); + /// Minutes [lo, hi) of this graph, re-based so slot 0 is minute [lo]. Spans + /// are clipped to the window, not dropped — a night that runs past the + /// window's edge was still a night inside it. + DayGraph window(int lo, int hi) { + List cut(List l) => + l.sublist(lo.clamp(0, l.length), hi.clamp(0, l.length)); + List<(int, int, Color)> clip(List<(int, int, Color)> spans) => [ + for (final (a, b, col) in spans) + if (b > lo && a < hi) (a.clamp(lo, hi) - lo, b.clamp(lo, hi) - lo, col), + ]; + return DayGraph( + hr: cut(hr), + movement: cut(movement), + rest: clip(rest), + work: clip(work), + dayStart: dayStart == null ? null : dayStart! + lo * 60, + ); + } + bool get isEmpty => !hasCurve && rest.isEmpty && @@ -552,7 +577,8 @@ DayGraph dayGraph(Map timeline, {List? hrOverride}) { if (s is Map) ?span(s['start_ts'], s['end_ts'], C.orange), ]; - return DayGraph(hr: hr, movement: movement, rest: rest, work: work); + return DayGraph( + hr: hr, movement: movement, rest: rest, work: work, dayStart: dayStart); } // ═══════════════════ the screen ═══════════════════ @@ -869,7 +895,7 @@ class _DayTimelineScreenState extends State { ), const SizedBox(height: S.x2), ], - ...timelineBody(c, d), + ...timelineBody(c, d, fineDetail: _device == null), ], ]); } @@ -881,14 +907,22 @@ class _DayTimelineScreenState extends State { /// for, and a frame with an axis and no line under it reads as a measurement /// of zero. A day like that is entirely carried by the list underneath, which /// is the right shape for it — a handful of things that happened, in order. -Widget? dayGraphCard(BuildContext c, DayGraph g) { - if (!g.hasCurve) return null; +/// +/// [zoomed] draws a window of the day: clock-time x labels, and a frame that +/// says "no heart rate here" instead of vanishing, so the zoom control under +/// it stays reachable. [fine] is a per-second curve over the same window, +/// drawn instead of the per-minute one when the raw rows still exist. +Widget? dayGraphCard(BuildContext c, DayGraph g, + {bool zoomed = false, List? fine, String? footnote}) { + if (!g.hasCurve && !zoomed) return null; final p = P.of(c); final l = AppLocalizations.of(c); final n = g.slots; double at(int m) => n <= 0 ? 0 : m / n; - final axis = AxisSpec.of([for (final v in g.hr) ?v], ticks: 3); - if (axis == null) return null; + final curve = fine ?? g.hr; + final axis = AxisSpec.of([for (final v in curve) ?v], ticks: 3); + if (axis == null && !zoomed) return null; + final start = g.dayStart; final asleep = p.on(C.blue), workout = p.on(C.orange); final gaps = g.unmeasured; @@ -901,11 +935,22 @@ Widget? dayGraphCard(BuildContext c, DayGraph g) { // Three, and only three, because ChartFrame lays the first flush left, // the last flush right and the rest centred — which puts a middle label // exactly on the middle of the plot and a five-label row 5 % out. - xLabels: [ - l?.dayTimelineMidnight ?? 'Midnight', - l?.dayTimelineNoon ?? 'Noon', - l?.dayTimelineMidnight ?? 'Midnight', - ], + xLabels: zoomed && start != null + ? [ + clockOfTs(start), + clockOfTs(start + n * 30), + clockOfTs(start + n * 60), + ] + : [ + l?.dayTimelineMidnight ?? 'Midnight', + l?.dayTimelineNoon ?? 'Noon', + l?.dayTimelineMidnight ?? 'Midnight', + ], + footnote: footnote, + empty: axis == null + ? NoData( + message: l?.dayTimelineZoomNoHr ?? 'No heart rate in this stretch') + : null, legend: [ if (g.rest.isNotEmpty) (l?.dayTimelineAsleep ?? 'Asleep', asleep), if (g.work.isNotEmpty) (l?.dayTimelineWorkout ?? 'Workout', workout), @@ -913,7 +958,7 @@ Widget? dayGraphCard(BuildContext c, DayGraph g) { (l?.dayTimelineMoving ?? 'Moving', p.on(C.domMove)), if (gaps.isNotEmpty) (l?.dayTimelineNotRecorded ?? 'Not recorded', p.card2), ], - series: g.hr, + series: curve, child: Stack(children: [ Positioned.fill( child: CustomPaint( @@ -937,7 +982,7 @@ Widget? dayGraphCard(BuildContext c, DayGraph g) { // No fill under the line: the area would swallow the bands behind // it, and the bands are the half of this picture the curve cannot // say on its own. - painter: LineChart(g.hr, p.on(C.red), fill: false, axis: axis), + painter: LineChart(curve, p.on(C.red), fill: false, axis: axis), ), ), ], @@ -946,12 +991,190 @@ Widget? dayGraphCard(BuildContext c, DayGraph g) { ); } +/// Per-second rows longer than this are not fetched: the chart is a few +/// hundred pixels wide, and past two hours a second-by-second line is drawn +/// at the same density as the stored per-minute one. +const int kFineWindowMinutes = 120; + +/// [rows] (`rec_ts`, `hr`, `device_id`) as one slot per second of +/// [fromSec, toSec), `null` where nothing was recorded. hr 0 is "no lock", +/// never a reading. [owners] is the day's stored HR ownership: a second some +/// device owns takes only that device's row, the same pick the stored curve +/// made, so two devices on one second never race for the slot. +List perSecondHr( + List> rows, int fromSec, int toSec, + {List owners = const []}) { + final out = List.filled(toSec > fromSec ? toSec - fromSec : 0, null); + for (final r in rows) { + final t = (r['rec_ts'] as num?)?.toInt(); + final v = (r['hr'] as num?)?.toDouble(); + if (t == null || v == null || v <= 0) continue; + final owner = owners.isEmpty ? null : spanAt(owners, t)?.deviceId; + if (owner != null && + owner != ((r['device_id'] as String?) ?? LocalDb.kPrimaryDeviceId)) { + continue; + } + final i = t - fromSec; + if (i >= 0 && i < out.length) out[i] = v; + } + return out; +} + +/// The day chart plus a range control under it. The whole day is the stored +/// per-minute curve, which is kept for every derived day. Zoomed to two hours +/// or less it re-reads the band's per-second rows — those are pruned a few +/// days behind the newest sync, so on an older day it says it is still +/// showing minutes rather than pretending to more detail. +class _DayGraphZoom extends StatefulWidget { + const _DayGraphZoom(this.graph, + {super.key, this.fineDetail = true, this.owners = const []}); + + final DayGraph graph; + final bool fineDetail; + + /// The day's HR ownership spans, empty on a one-device day. + final List owners; + + @override + State<_DayGraphZoom> createState() => _ZoomState(); +} + +class _ZoomState extends State<_DayGraphZoom> { + RangeValues? _range; + List? _fine; + + /// Set once a fine read for the current range came back empty. + bool _fineMissing = false; + int _token = 0; + + @override + void didUpdateWidget(_DayGraphZoom old) { + super.didUpdateWidget(old); + if (!identical(old.graph, widget.graph) || + old.fineDetail != widget.fineDetail) { + _range = null; + _fine = null; + _fineMissing = false; + _token++; + } + } + + (int, int) _bounds() { + final n = widget.graph.slots; + final r = _range; + if (r == null) return (0, n); + return (r.start.round().clamp(0, n), r.end.round().clamp(0, n)); + } + + Future _readFine() async { + final token = ++_token; + final (lo, hi) = _bounds(); + final start = widget.graph.dayStart; + if (!widget.fineDetail || + start == null || + hi - lo > kFineWindowMinutes || + hi <= lo) { + return; + } + final from = start + lo * 60, to = start + hi * 60; + List? fine; + try { + final db = await LocalDb.instance; + final rows = await db.rawQuery( + 'SELECT rec_ts, hr, device_id FROM decoded_onehz ' + 'WHERE rec_ts >= ? AND rec_ts < ? AND hr > 0 AND ${derivableSourceSql()} ' + 'ORDER BY rec_ts ASC', + [from, to], + ); + final s = perSecondHr(rows, from, to, owners: widget.owners); + if (s.any((v) => v != null)) fine = s; + } catch (_) { + // No per-second rows is the same answer as pruned ones: minutes it is. + } + if (!mounted || token != _token) return; + setState(() { + _fine = fine; + _fineMissing = fine == null; + }); + } + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final p = P.of(c); + final n = widget.graph.slots; + final (lo, hi) = _bounds(); + final zoomed = lo > 0 || hi < n; + final g = zoomed ? widget.graph.window(lo, hi) : widget.graph; + final String note; + if (_fine != null) { + note = l?.dayTimelineZoomPerSecond ?? + 'Every second the band recorded. Second-by-second readings are kept ' + 'for recent days only.'; + } else if (_fineMissing) { + note = l?.dayTimelineZoomPerMinuteOnly ?? + '1-minute averages. Second-by-second readings are no longer kept ' + 'for this stretch.'; + } else { + note = l?.dayTimelineZoomPerMinute ?? + '1-minute averages. Drag the handles to zoom; two hours or less ' + 'shows every second while it is still kept.'; + } + final card = + dayGraphCard(c, g, zoomed: zoomed, fine: _fine, footnote: note); + if (card == null || n < 2) return card ?? const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + card, + RangeSlider( + values: RangeValues(lo.toDouble(), hi.toDouble()), + min: 0, + max: n.toDouble(), + // Quarter-hour steps: fine enough to land on a workout, coarse + // enough that a thumb can hit a two-hour window. + divisions: n ~/ 15 > 0 ? n ~/ 15 : null, + activeColor: p.on(C.red), + labels: widget.graph.dayStart == null + ? null + : RangeLabels( + clockOfTs(widget.graph.dayStart! + lo * 60), + clockOfTs(widget.graph.dayStart! + hi * 60), + ), + semanticFormatterCallback: (v) => widget.graph.dayStart == null + ? '${v.round()}' + : clockOfTs(widget.graph.dayStart! + v.round() * 60), + onChanged: (r) { + if (r.end - r.start < 15) return; + _token++; + setState(() { + _range = r; + _fine = null; + _fineMissing = false; + }); + }, + onChangeEnd: (_) => _readFine(), + ), + ], + ); + } +} + /// The page's body, given loaded data. Split out so the gallery can build every /// state of it without a repository. -List timelineBody(BuildContext c, TimelineData d) { +/// +/// [fineDetail] allows the zoomed chart to read per-second rows; off while a +/// single device is selected, because those rows are not split by device. +List timelineBody(BuildContext c, TimelineData d, + {bool fineDetail = true}) { final p = P.of(c); final l = AppLocalizations.of(c); - final graph = dayGraphCard(c, d.graph); + final graph = d.graph.hasCurve + ? _DayGraphZoom(d.graph, + fineDetail: fineDetail, + owners: coverageFromJson(d.raw?['hr_owner']), + key: ValueKey(d.day)) + : null; return [ ?graph, if (d.moments.isEmpty && d.notes.isEmpty && graph == null) diff --git a/test/all_hr_data_546_test.dart b/test/all_hr_data_546_test.dart new file mode 100644 index 000000000..c72dbe572 --- /dev/null +++ b/test/all_hr_data_546_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/csv_export.dart'; +import 'package:openstrap_edge/ui2/screens/day_timeline.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +void main() { + group('DayGraph.window', () { + final g = DayGraph( + dayStart: 1000, + hr: [for (var i = 0; i < 10; i++) i.toDouble()], + movement: [for (var i = 0; i < 10; i++) i.isEven ? i / 10 : null], + rest: const [(0, 4, C.blue)], + work: const [(6, 9, C.orange), (9, 10, C.orange)], + ); + + test('slices every lane and re-bases the clock', () { + final w = g.window(3, 8); + expect(w.hr, [3, 4, 5, 6, 7]); + expect(w.movement, [null, 0.4, null, 0.6, null]); + expect(w.slots, 5); + expect(w.dayStart, 1000 + 3 * 60); + }); + + test('clips spans to the window instead of dropping them', () { + final w = g.window(3, 8); + expect(w.rest, [(0, 1, C.blue)]); + expect(w.work, [(3, 5, C.orange)]); + }); + }); + + group('perSecondHr', () { + test('one slot per second, null where unrecorded, 0 is no lock', () { + final s = perSecondHr([ + {'rec_ts': 100, 'hr': 60}, + {'rec_ts': 102, 'hr': 0}, + {'rec_ts': 103, 'hr': 62}, + {'rec_ts': 99, 'hr': 70}, // before the window + {'rec_ts': 104, 'hr': 70}, // at the exclusive end + ], 100, 104); + expect(s, [60, null, null, 62]); + }); + + test('a contended second takes its owner, as the stored curve did', () { + final rows = [ + {'rec_ts': 100, 'hr': 60, 'device_id': ''}, + {'rec_ts': 100, 'hr': 90, 'device_id': 'ring'}, + {'rec_ts': 101, 'hr': 61, 'device_id': ''}, + {'rec_ts': 101, 'hr': 91, 'device_id': 'ring'}, + {'rec_ts': 102, 'hr': 92, 'device_id': 'ring'}, // gap span: kept + ]; + final owners = [ + (start: 100, end: 101, deviceId: 'ring'), + (start: 101, end: 102, deviceId: ''), + (start: 102, end: 103, deviceId: null), + ]; + expect(perSecondHr(rows, 100, 103, owners: owners), [90, 61, 92]); + }); + }); + + group('heartRateMinuteRows', () { + test('one row per stored minute, source carried, no lock skipped', () { + final t = DateTime(2026, 8, 14, 7, 5).millisecondsSinceEpoch ~/ 1000; + final rows = heartRateMinuteRows([ + {'t': t, 'v': 58}, + {'t': t + 60, 'v': 0}, + {'t': t + 120}, + 'junk', + ], 'band'); + expect(rows, [ + { + 'timestamp': t, + 'local_time': '2026-08-14 07:05', + 'bpm': 58, + 'source': 'band', + }, + ]); + }); + + test('unknown provenance stays an empty cell', () { + final rows = heartRateMinuteRows([ + {'t': 0, 'v': 60}, + ], null); + expect(renderCsv(kHeartRateCsvColumns, rows).split('\n')[1], + endsWith(',60,')); + }); + }); + + testWidgets('the day chart carries a zoom control and a resolution note', + (t) async { + final start = DateTime(2026, 8, 14).millisecondsSinceEpoch ~/ 1000; + final g = DayGraph( + dayStart: start, + hr: [for (var i = 0; i < 1440; i++) 60 + (i % 20).toDouble()], + movement: List.filled(1440, null), + ); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Builder( + builder: (c) => Scaffold( + body: ListView( + children: timelineBody(c, TimelineData(day: '2026-08-14', graph: g))))), + )); + expect(find.byType(RangeSlider), findsOneWidget); + expect(find.textContaining('1-minute averages'), findsOneWidget); + }); +}