Commit 0a340fd3b6341aa5e7f36d7a38b0f7a7e50d2502

Authored by 权海
1 parent c187bd36

feat(ui):修复长时间不回调,触发计算时推送多条通知

(cherry picked from commit 85d12a8b)

# Conflicts:
#	lib/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart
#	test/core/services/health_raw_data_core_service_test.dart
#	test/core/services/health_raw_local_notification_test.dart
@@ -4,7 +4,7 @@ import 'dart:io'; @@ -4,7 +4,7 @@ import 'dart:io';
4 import 'package:path_provider/path_provider.dart'; 4 import 'package:path_provider/path_provider.dart';
5 5
6 import '../../../../l10n/gen/app_localizations.dart'; 6 import '../../../../l10n/gen/app_localizations.dart';
7 -import '../../../platform/pigeon_api_facade.dart'; 7 +import '../../../../pigeon/platform_api.g.dart';
8 import '../health_raw_models.dart'; 8 import '../health_raw_models.dart';
9 9
10 const healthRawTodayLink = 'doublefeel://flutter/home?tab=today'; 10 const healthRawTodayLink = 'doublefeel://flutter/home?tab=today';
@@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType { @@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType {
35 realtimeStress, 35 realtimeStress,
36 } 36 }
37 37
  38 +typedef HealthRawSleepInterval = ({int startTime, int endTime});
  39 +
38 class HealthRawLocalNotificationBuilder { 40 class HealthRawLocalNotificationBuilder {
39 const HealthRawLocalNotificationBuilder(this.l10n); 41 const HealthRawLocalNotificationBuilder(this.l10n);
40 42
@@ -49,24 +51,50 @@ class HealthRawLocalNotificationBuilder { @@ -49,24 +51,50 @@ class HealthRawLocalNotificationBuilder {
49 required bool hasExistingSleep, 51 required bool hasExistingSleep,
50 required List<HealthRawRealtimeStressPoint> realtimeWindow, 52 required List<HealthRawRealtimeStressPoint> realtimeWindow,
51 required HealthRawLocalNotificationRecord? record, 53 required HealthRawLocalNotificationRecord? record,
  54 + List<HealthRawSleepInterval> realtimeSleepIntervals = const [],
  55 + int? lastRealtimeStressPushSendTime,
  56 + int? notificationBuildTime,
52 }) { 57 }) {
53 - return [ 58 + final buildTime =
  59 + notificationBuildTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
  60 + final latestRealtimeStressPoint = _latestRealtimeStressPoint(
  61 + result.realtimeStressPoints,
  62 + );
  63 + final resolvedRealtimeSleepIntervals =
  64 + realtimeSleepIntervals.isEmpty && latestRealtimeStressPoint != null
  65 + ? [_defaultSleepInterval(latestRealtimeStressPoint.rawEndTime)]
  66 + : realtimeSleepIntervals;
  67 + return _latestNotificationsByRecordType([
54 if (_sleepNotification(result.sleepResults, hasExistingSleep) 68 if (_sleepNotification(result.sleepResults, hasExistingSleep)
55 case final notification?) 69 case final notification?)
56 notification, 70 notification,
57 - if (_hrvNotification(result.hrvStressPoints, hasExistingHrv)  
58 - case final notification?)  
59 - notification, 71 + ..._hrvNotifications(result.hrvStressPoints, hasExistingHrv),
60 if (_realtimeStressNotification( 72 if (_realtimeStressNotification(
61 realtimeWindow, 73 realtimeWindow,
62 record, 74 record,
63 - latestRealtimeStressPoint: _latestRealtimeStressPoint(  
64 - result.realtimeStressPoints,  
65 - ), 75 + latestRealtimeStressPoint: latestRealtimeStressPoint,
  76 + sleepIntervals: resolvedRealtimeSleepIntervals,
  77 + lastPushSendTime: lastRealtimeStressPushSendTime,
  78 + notificationBuildTime: buildTime,
66 ) 79 )
67 case final notification?) 80 case final notification?)
68 notification, 81 notification,
69 - ]; 82 + ]);
  83 + }
  84 +
  85 + List<HealthRawLocalNotification> _latestNotificationsByRecordType(
  86 + List<HealthRawLocalNotification> notifications,
  87 + ) {
  88 + if (notifications.length < 2) return notifications;
  89 + final latestByType =
  90 + <HealthRawLocalNotificationRecordType, HealthRawLocalNotification>{};
  91 + for (final notification in notifications) {
  92 + final existing = latestByType[notification.recordType];
  93 + if (existing == null || notification.recordTime > existing.recordTime) {
  94 + latestByType[notification.recordType] = notification;
  95 + }
  96 + }
  97 + return latestByType.values.toList(growable: false);
70 } 98 }
71 99
72 HealthRawLocalNotification? _sleepNotification( 100 HealthRawLocalNotification? _sleepNotification(
@@ -89,33 +117,37 @@ class HealthRawLocalNotificationBuilder { @@ -89,33 +117,37 @@ class HealthRawLocalNotificationBuilder {
89 ); 117 );
90 } 118 }
91 119
92 - HealthRawLocalNotification? _hrvNotification( 120 + List<HealthRawLocalNotification> _hrvNotifications(
93 List<HealthRawHrvStressPoint> hrvPoints, 121 List<HealthRawHrvStressPoint> hrvPoints,
94 bool hasExistingHrv, 122 bool hasExistingHrv,
95 ) { 123 ) {
96 - if (!hasExistingHrv) return null;  
97 - if (hrvPoints.isEmpty) return null; 124 + if (!hasExistingHrv) return const <HealthRawLocalNotification>[];
  125 + if (hrvPoints.isEmpty) return const <HealthRawLocalNotification>[];
98 final sorted = [...hrvPoints] 126 final sorted = [...hrvPoints]
99 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 127 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
100 - final latest = sorted.last;  
101 -  
102 - return HealthRawLocalNotification(  
103 - title: l10n.healthLocalNotificationHrvTitle(  
104 - latest.result.floor(),  
105 - _stressStateLabel(latest.state),  
106 - _timeText(latest.rawEndTime),  
107 - ),  
108 - content: _hrvContent(latest),  
109 - link: healthRawHrvChangeLink,  
110 - recordType: HealthRawLocalNotificationRecordType.hrv,  
111 - recordTime: latest.rawEndTime,  
112 - ); 128 + return [
  129 + for (final point in sorted)
  130 + HealthRawLocalNotification(
  131 + title: l10n.healthLocalNotificationHrvTitle(
  132 + point.result.floor(),
  133 + _stressStateLabel(point.state),
  134 + _timeText(point.rawEndTime),
  135 + ),
  136 + content: _hrvContent(point),
  137 + link: healthRawHrvChangeLink,
  138 + recordType: HealthRawLocalNotificationRecordType.hrv,
  139 + recordTime: point.rawEndTime,
  140 + ),
  141 + ];
113 } 142 }
114 143
115 HealthRawLocalNotification? _realtimeStressNotification( 144 HealthRawLocalNotification? _realtimeStressNotification(
116 List<HealthRawRealtimeStressPoint> realtimeWindow, 145 List<HealthRawRealtimeStressPoint> realtimeWindow,
117 HealthRawLocalNotificationRecord? record, { 146 HealthRawLocalNotificationRecord? record, {
118 required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, 147 required HealthRawRealtimeStressPoint? latestRealtimeStressPoint,
  148 + required List<HealthRawSleepInterval> sleepIntervals,
  149 + required int? lastPushSendTime,
  150 + required int notificationBuildTime,
119 }) { 151 }) {
120 if (latestRealtimeStressPoint == null) return null; 152 if (latestRealtimeStressPoint == null) return null;
121 if (latestRealtimeStressPoint.isWorkout || 153 if (latestRealtimeStressPoint.isWorkout ||
@@ -123,16 +155,20 @@ class HealthRawLocalNotificationBuilder { @@ -123,16 +155,20 @@ class HealthRawLocalNotificationBuilder {
123 return null; 155 return null;
124 } 156 }
125 157
  158 + final latest = latestRealtimeStressPoint;
  159 + if (_sleepIntervalContaining(latest.rawEndTime, sleepIntervals) != null) {
  160 + return null;
  161 + }
  162 +
126 final valid = realtimeWindow 163 final valid = realtimeWindow
127 .where((e) => e.result >= 1 && e.result <= 100) 164 .where((e) => e.result >= 1 && e.result <= 100)
128 .toList() 165 .toList()
129 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 166 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
130 if (valid.length < _realtimeMinPointCount) return null; 167 if (valid.length < _realtimeMinPointCount) return null;
131 168
132 - final latest = latestRealtimeStressPoint;  
133 if (latest.isSleepLikely) return null; 169 if (latest.isSleepLikely) return null;
134 - if (record?.lastRealtimeStressTime case final lastPushTime?) {  
135 - if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { 170 + if (lastPushSendTime case final lastPushTime?) {
  171 + if (notificationBuildTime - lastPushTime < _realtimeWindowSeconds) {
136 return null; 172 return null;
137 } 173 }
138 } 174 }
@@ -162,6 +198,29 @@ class HealthRawLocalNotificationBuilder { @@ -162,6 +198,29 @@ class HealthRawLocalNotificationBuilder {
162 return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); 198 return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b);
163 } 199 }
164 200
  201 + HealthRawSleepInterval? _sleepIntervalContaining(
  202 + int time,
  203 + List<HealthRawSleepInterval> intervals,
  204 + ) {
  205 + for (final interval in intervals) {
  206 + if (time >= interval.startTime && time <= interval.endTime) {
  207 + return interval;
  208 + }
  209 + }
  210 + return null;
  211 + }
  212 +
  213 + HealthRawSleepInterval _defaultSleepInterval(int time) {
  214 + final day = _localDayStart(time);
  215 + return (startTime: day, endTime: day + 6 * 60 * 60);
  216 + }
  217 +
  218 + int _localDayStart(int time) {
  219 + final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
  220 + return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch ~/
  221 + 1000;
  222 + }
  223 +
165 String _sleepDurationText(int minutes) { 224 String _sleepDurationText(int minutes) {
166 final hours = minutes ~/ 60; 225 final hours = minutes ~/ 60;
167 final remainingMinutes = minutes % 60; 226 final remainingMinutes = minutes % 60;
@@ -227,12 +286,12 @@ class HealthRawLocalNotificationBuilder { @@ -227,12 +286,12 @@ class HealthRawLocalNotificationBuilder {
227 286
228 class HealthRawLocalNotificationDispatcher { 287 class HealthRawLocalNotificationDispatcher {
229 HealthRawLocalNotificationDispatcher({ 288 HealthRawLocalNotificationDispatcher({
230 - AppPlatformHostApi? platformApi, 289 + PlatformHostApi? platformApi,
231 HealthRawLocalNotificationRecordStore? recordStore, 290 HealthRawLocalNotificationRecordStore? recordStore,
232 - }) : _platformApi = platformApi ?? AppPlatformHostApi(), 291 + }) : _platformApi = platformApi ?? PlatformHostApi(),
233 _recordStore = recordStore ?? HealthRawLocalNotificationRecordStore(); 292 _recordStore = recordStore ?? HealthRawLocalNotificationRecordStore();
234 293
235 - final AppPlatformHostApi _platformApi; 294 + final PlatformHostApi _platformApi;
236 final HealthRawLocalNotificationRecordStore _recordStore; 295 final HealthRawLocalNotificationRecordStore _recordStore;
237 296
238 Future<List<HealthRawLocalNotification>> sendAll({ 297 Future<List<HealthRawLocalNotification>> sendAll({
@@ -289,6 +348,28 @@ class HealthRawLocalNotificationDispatcher { @@ -289,6 +348,28 @@ class HealthRawLocalNotificationDispatcher {
289 record.copyWith(lastRealtimeStressTime: recordTime), 348 record.copyWith(lastRealtimeStressTime: recordTime),
290 ); 349 );
291 } 350 }
  351 +
  352 + Future<void> updateProcessedDataTimes({
  353 + required int userId,
  354 + int? latestHrvDataTime,
  355 + int? latestRealtimeStressDataTime,
  356 + int? latestSleepDataTime,
  357 + }) async {
  358 + if (latestHrvDataTime == null &&
  359 + latestRealtimeStressDataTime == null &&
  360 + latestSleepDataTime == null) {
  361 + return;
  362 + }
  363 + final record = await _recordStore.read(userId);
  364 + await _recordStore.write(
  365 + userId,
  366 + record.copyWith(
  367 + latestHrvDataTime: latestHrvDataTime,
  368 + latestRealtimeStressDataTime: latestRealtimeStressDataTime,
  369 + latestSleepDataTime: latestSleepDataTime,
  370 + ),
  371 + );
  372 + }
292 } 373 }
293 374
294 class HealthRawLocalNotificationRecord { 375 class HealthRawLocalNotificationRecord {
@@ -296,11 +377,17 @@ class HealthRawLocalNotificationRecord { @@ -296,11 +377,17 @@ class HealthRawLocalNotificationRecord {
296 this.lastSleepTime, 377 this.lastSleepTime,
297 this.lastHrvTime, 378 this.lastHrvTime,
298 this.lastRealtimeStressTime, 379 this.lastRealtimeStressTime,
  380 + this.latestHrvDataTime,
  381 + this.latestRealtimeStressDataTime,
  382 + this.latestSleepDataTime,
299 }); 383 });
300 384
301 final int? lastSleepTime; 385 final int? lastSleepTime;
302 final int? lastHrvTime; 386 final int? lastHrvTime;
303 final int? lastRealtimeStressTime; 387 final int? lastRealtimeStressTime;
  388 + final int? latestHrvDataTime;
  389 + final int? latestRealtimeStressDataTime;
  390 + final int? latestSleepDataTime;
304 391
305 factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) { 392 factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) {
306 return HealthRawLocalNotificationRecord( 393 return HealthRawLocalNotificationRecord(
@@ -308,6 +395,10 @@ class HealthRawLocalNotificationRecord { @@ -308,6 +395,10 @@ class HealthRawLocalNotificationRecord {
308 lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(), 395 lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(),
309 lastRealtimeStressTime: 396 lastRealtimeStressTime:
310 (json['last_realtime_stress_time'] as num?)?.toInt(), 397 (json['last_realtime_stress_time'] as num?)?.toInt(),
  398 + latestHrvDataTime: (json['latest_hrv_data_time'] as num?)?.toInt(),
  399 + latestRealtimeStressDataTime:
  400 + (json['latest_realtime_stress_data_time'] as num?)?.toInt(),
  401 + latestSleepDataTime: (json['latest_sleep_data_time'] as num?)?.toInt(),
311 ); 402 );
312 } 403 }
313 404
@@ -317,6 +408,11 @@ class HealthRawLocalNotificationRecord { @@ -317,6 +408,11 @@ class HealthRawLocalNotificationRecord {
317 if (lastHrvTime != null) 'last_hrv_time': lastHrvTime, 408 if (lastHrvTime != null) 'last_hrv_time': lastHrvTime,
318 if (lastRealtimeStressTime != null) 409 if (lastRealtimeStressTime != null)
319 'last_realtime_stress_time': lastRealtimeStressTime, 410 'last_realtime_stress_time': lastRealtimeStressTime,
  411 + if (latestHrvDataTime != null) 'latest_hrv_data_time': latestHrvDataTime,
  412 + if (latestRealtimeStressDataTime != null)
  413 + 'latest_realtime_stress_data_time': latestRealtimeStressDataTime,
  414 + if (latestSleepDataTime != null)
  415 + 'latest_sleep_data_time': latestSleepDataTime,
320 }; 416 };
321 } 417 }
322 418
@@ -340,12 +436,19 @@ class HealthRawLocalNotificationRecord { @@ -340,12 +436,19 @@ class HealthRawLocalNotificationRecord {
340 int? lastSleepTime, 436 int? lastSleepTime,
341 int? lastHrvTime, 437 int? lastHrvTime,
342 int? lastRealtimeStressTime, 438 int? lastRealtimeStressTime,
  439 + int? latestHrvDataTime,
  440 + int? latestRealtimeStressDataTime,
  441 + int? latestSleepDataTime,
343 }) { 442 }) {
344 return HealthRawLocalNotificationRecord( 443 return HealthRawLocalNotificationRecord(
345 lastSleepTime: lastSleepTime ?? this.lastSleepTime, 444 lastSleepTime: lastSleepTime ?? this.lastSleepTime,
346 lastHrvTime: lastHrvTime ?? this.lastHrvTime, 445 lastHrvTime: lastHrvTime ?? this.lastHrvTime,
347 lastRealtimeStressTime: 446 lastRealtimeStressTime:
348 lastRealtimeStressTime ?? this.lastRealtimeStressTime, 447 lastRealtimeStressTime ?? this.lastRealtimeStressTime,
  448 + latestHrvDataTime: latestHrvDataTime ?? this.latestHrvDataTime,
  449 + latestRealtimeStressDataTime:
  450 + latestRealtimeStressDataTime ?? this.latestRealtimeStressDataTime,
  451 + latestSleepDataTime: latestSleepDataTime ?? this.latestSleepDataTime,
349 ); 452 );
350 } 453 }
351 } 454 }
@@ -439,6 +439,10 @@ void main() { @@ -439,6 +439,10 @@ void main() {
439 expect(firstRows.sleepRows.single['update_time'], greaterThan(0)); 439 expect(firstRows.sleepRows.single['update_time'], greaterThan(0));
440 440
441 await store.markHrvStressUploaded(rawEndTimes: [base + 10], userId: 42); 441 await store.markHrvStressUploaded(rawEndTimes: [base + 10], userId: 42);
  442 + final uploadedRows = await store.debugQueryAllRows(42);
  443 + final hrvUploadTime = uploadedRows.hrvRows.single['upload_time'] as int;
  444 + expect(hrvUploadTime, greaterThan(0));
  445 +
442 await store.upsertResult( 446 await store.upsertResult(
443 HealthRawStressCalculationResult( 447 HealthRawStressCalculationResult(
444 userId: 42, 448 userId: 42,
@@ -454,6 +458,7 @@ void main() { @@ -454,6 +458,7 @@ void main() {
454 greaterThan(firstHrvUpdateTime), 458 greaterThan(firstHrvUpdateTime),
455 ); 459 );
456 expect(secondRows.hrvRows.single['uploaded'], 1); 460 expect(secondRows.hrvRows.single['uploaded'], 1);
  461 + expect(secondRows.hrvRows.single['upload_time'], hrvUploadTime);
457 }); 462 });
458 463
459 test( 464 test(
@@ -567,13 +572,14 @@ void main() { @@ -567,13 +572,14 @@ void main() {
567 ); 572 );
568 final eventFuture = service.healthDataUpdatedStream.first; 573 final eventFuture = service.healthDataUpdatedStream.first;
569 574
570 - await service.onHealthDataUpdated( 575 + final hasNewResult = await service.onHealthDataUpdated(
571 dataTypes: [ 576 dataTypes: [
572 HealthDataUploadType.hrv.type, 577 HealthDataUploadType.hrv.type,
573 HealthDataUploadType.heartRate.type, 578 HealthDataUploadType.heartRate.type,
574 ], 579 ],
575 ); 580 );
576 581
  582 + expect(hasNewResult, isTrue);
577 final event = await eventFuture; 583 final event = await eventFuture;
578 expect( 584 expect(
579 event.dataTypes, 585 event.dataTypes,
@@ -581,6 +587,24 @@ void main() { @@ -581,6 +587,24 @@ void main() {
581 ); 587 );
582 }); 588 });
583 589
  590 + test('onHealthDataUpdated returns false after an empty calculation',
  591 + () async {
  592 + final api = _FakeHealthKitRawDataHostApi();
  593 + final service = AppleHealthRawDataCoreService(
  594 + healthApi: _FakeHealthKitHostApi(status: 0),
  595 + rawDataApi: api,
  596 + localStore: _MemoryHealthRawStressLocalStore(),
  597 + userIdProvider: () => 42,
  598 + uploadResultsAfterCalculation: false,
  599 + );
  600 +
  601 + final hasNewResult = await service.onHealthDataUpdated(
  602 + dataTypes: [HealthDataUploadType.hrv.type],
  603 + );
  604 +
  605 + expect(hasNewResult, isFalse);
  606 + });
  607 +
584 test('startCoreCaculate calculates and stores sleep results', () async { 608 test('startCoreCaculate calculates and stores sleep results', () async {
585 final now = DateTime.now(); 609 final now = DateTime.now();
586 final day = DateTime(now.year, now.month, now.day); 610 final day = DateTime(now.year, now.month, now.day);
@@ -670,7 +694,7 @@ void main() { @@ -670,7 +694,7 @@ void main() {
670 }); 694 });
671 695
672 test( 696 test(
673 - 'startCoreCaculate sends hrv and sleep notifications for new non-first results', 697 + 'startCoreCaculate sends only latest hrv notification for new non-first results',
674 () async { 698 () async {
675 final now = DateTime.now(); 699 final now = DateTime.now();
676 final day = DateTime(now.year, now.month, now.day); 700 final day = DateTime(now.year, now.month, now.day);
@@ -727,7 +751,11 @@ void main() { @@ -727,7 +751,11 @@ void main() {
727 ), 751 ),
728 ], 752 ],
729 ); 753 );
730 - final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher(); 754 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  755 + ..record = HealthRawLocalNotificationRecord(
  756 + latestHrvDataTime: base + 10,
  757 + latestSleepDataTime: base - Duration.secondsPerDay,
  758 + );
731 final service = AppleHealthRawDataCoreService( 759 final service = AppleHealthRawDataCoreService(
732 healthApi: _FakeHealthKitHostApi(), 760 healthApi: _FakeHealthKitHostApi(),
733 rawDataApi: api, 761 rawDataApi: api,
@@ -742,17 +770,44 @@ void main() { @@ -742,17 +770,44 @@ void main() {
742 readChunkDays: 1, 770 readChunkDays: 1,
743 ); 771 );
744 772
745 - expect(  
746 - notificationDispatcher.sentNotifications.where(  
747 - (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),  
748 - hasLength(1),  
749 - ); 773 + final hrvNotifications = notificationDispatcher.sentNotifications
  774 + .where((e) => e.recordType == HealthRawLocalNotificationRecordType.hrv)
  775 + .toList();
  776 + expect(hrvNotifications, hasLength(1));
  777 + expect(hrvNotifications.single.recordTime, base + 720);
750 expect( 778 expect(
751 notificationDispatcher.sentNotifications.where( 779 notificationDispatcher.sentNotifications.where(
752 (e) => e.recordType == HealthRawLocalNotificationRecordType.sleep, 780 (e) => e.recordType == HealthRawLocalNotificationRecordType.sleep,
753 ), 781 ),
754 hasLength(1), 782 hasLength(1),
755 ); 783 );
  784 + expect(notificationDispatcher.record.latestHrvDataTime, base + 720);
  785 + expect(notificationDispatcher.record.latestSleepDataTime,
  786 + LocalHealthDataConvert.unixSeconds(sleepEnd));
  787 + final rows = await store.debugQueryAllRows(42);
  788 + expect(
  789 + rows.hrvRows.singleWhere(
  790 + (row) => row['raw_end_time'] == base + 10)['push_send_time'],
  791 + isNull,
  792 + );
  793 + expect(
  794 + rows.hrvRows
  795 + .where(
  796 + (row) => [base + 120, base + 420].contains(row['raw_end_time']))
  797 + .every((row) => row['push_send_time'] == null),
  798 + isTrue,
  799 + );
  800 + expect(
  801 + rows.hrvRows.singleWhere(
  802 + (row) => row['raw_end_time'] == base + 720)['push_send_time'],
  803 + isNotNull,
  804 + );
  805 + expect(
  806 + rows.sleepRows.singleWhere(
  807 + (row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd),
  808 + )['push_send_time'],
  809 + isNotNull,
  810 + );
756 }); 811 });
757 812
758 test( 813 test(
@@ -784,7 +839,11 @@ void main() { @@ -784,7 +839,11 @@ void main() {
784 ), 839 ),
785 ], 840 ],
786 ); 841 );
787 - final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher(); 842 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  843 + ..record = const HealthRawLocalNotificationRecord(
  844 + latestHrvDataTime: base + 100,
  845 + latestSleepDataTime: base + 100,
  846 + );
788 final service = AppleHealthRawDataCoreService( 847 final service = AppleHealthRawDataCoreService(
789 healthApi: _FakeHealthKitHostApi(), 848 healthApi: _FakeHealthKitHostApi(),
790 rawDataApi: api, 849 rawDataApi: api,
@@ -807,6 +866,177 @@ void main() { @@ -807,6 +866,177 @@ void main() {
807 ), 866 ),
808 isEmpty, 867 isEmpty,
809 ); 868 );
  869 + expect(notificationDispatcher.record.latestHrvDataTime, base + 100);
  870 + expect(notificationDispatcher.record.latestSleepDataTime, base + 100);
  871 + });
  872 +
  873 + test(
  874 + 'startCoreCaculate sends realtime notification when database latest is newer than latest data time',
  875 + () async {
  876 + const base = 1800000000;
  877 + final api = _FakeHealthKitRawDataHostApi();
  878 + final store = _MemoryHealthRawStressLocalStore();
  879 + await store.upsertResult(
  880 + HealthRawStressCalculationResult(
  881 + userId: 42,
  882 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  883 + realtimeStressPoints: [
  884 + for (var i = 0; i < 10; i += 1)
  885 + _realtimeStressPoint(base + i * 300, result: 70),
  886 + ],
  887 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  888 + ),
  889 + );
  890 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  891 + ..record = const HealthRawLocalNotificationRecord(
  892 + latestRealtimeStressDataTime: base,
  893 + );
  894 + final service = AppleHealthRawDataCoreService(
  895 + healthApi: _FakeHealthKitHostApi(),
  896 + rawDataApi: api,
  897 + localStore: store,
  898 + userIdProvider: () => 42,
  899 + uploadResultsAfterCalculation: false,
  900 + localNotificationDispatcher: notificationDispatcher,
  901 + );
  902 +
  903 + await service.startCoreCaculate(
  904 + endTime: base + 3000,
  905 + readChunkDays: 1,
  906 + );
  907 +
  908 + expect(
  909 + notificationDispatcher.sentNotifications.where(
  910 + (e) =>
  911 + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
  912 + ),
  913 + hasLength(1),
  914 + );
  915 + expect(
  916 + notificationDispatcher.record.latestRealtimeStressDataTime,
  917 + base + 2700,
  918 + );
  919 + final rows = await store.debugQueryAllRows(42);
  920 + expect(rows.realtimeRows.last['push_send_time'], isNotNull);
  921 + });
  922 +
  923 + test('startCoreCaculate suppresses realtime notification inside sleep result',
  924 + () async {
  925 + final now = DateTime.now();
  926 + final day = DateTime(now.year, now.month, now.day).subtract(
  927 + const Duration(days: 7),
  928 + );
  929 + final dayStart = LocalHealthDataConvert.unixSeconds(day);
  930 + final latestTime = dayStart + 5 * 3600 + 30 * 60;
  931 + final firstTime = latestTime - 9 * 300;
  932 + final api = _FakeHealthKitRawDataHostApi();
  933 + final store = _MemoryHealthRawStressLocalStore();
  934 + await store.upsertResult(
  935 + HealthRawStressCalculationResult(
  936 + userId: 42,
  937 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  938 + realtimeStressPoints: [
  939 + for (var i = 0; i < 10; i += 1)
  940 + _realtimeStressPoint(firstTime + i * 300, result: 70),
  941 + ],
  942 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  943 + ),
  944 + );
  945 + await store.upsertSleepResults(
  946 + userId: 42,
  947 + results: [
  948 + HealthRawSleepResult(
  949 + userId: 42,
  950 + date: dayStart + 6 * 3600,
  951 + startDate: dayStart + 5 * 3600,
  952 + sleepScore: 80,
  953 + sleepState: 2,
  954 + inBedMinutes: 60,
  955 + awakMinutes: 0,
  956 + sleepMinutes: 60,
  957 + ),
  958 + ],
  959 + );
  960 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  961 + ..record = HealthRawLocalNotificationRecord(
  962 + latestRealtimeStressDataTime: firstTime - 1,
  963 + latestSleepDataTime: dayStart + 6 * 3600,
  964 + );
  965 + final service = AppleHealthRawDataCoreService(
  966 + healthApi: _FakeHealthKitHostApi(),
  967 + rawDataApi: api,
  968 + localStore: store,
  969 + userIdProvider: () => 42,
  970 + uploadResultsAfterCalculation: false,
  971 + localNotificationDispatcher: notificationDispatcher,
  972 + );
  973 +
  974 + await service.startCoreCaculate(
  975 + endTime: latestTime,
  976 + readChunkDays: 1,
  977 + );
  978 +
  979 + expect(
  980 + notificationDispatcher.sentNotifications.where(
  981 + (e) =>
  982 + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
  983 + ),
  984 + isEmpty,
  985 + );
  986 + final rows = await store.debugQueryAllRows(42);
  987 + expect(rows.realtimeRows.last['push_send_time'], isNull);
  988 + });
  989 +
  990 + test(
  991 + 'startCoreCaculate uses default sleep interval for realtime notification',
  992 + () async {
  993 + final now = DateTime.now();
  994 + final day = DateTime(now.year, now.month, now.day).subtract(
  995 + const Duration(days: 7),
  996 + );
  997 + final dayStart = LocalHealthDataConvert.unixSeconds(day);
  998 + final latestTime = dayStart + 5 * 3600 + 30 * 60;
  999 + final firstTime = latestTime - 9 * 300;
  1000 + final api = _FakeHealthKitRawDataHostApi();
  1001 + final store = _MemoryHealthRawStressLocalStore();
  1002 + await store.upsertResult(
  1003 + HealthRawStressCalculationResult(
  1004 + userId: 42,
  1005 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  1006 + realtimeStressPoints: [
  1007 + for (var i = 0; i < 10; i += 1)
  1008 + _realtimeStressPoint(firstTime + i * 300, result: 70),
  1009 + ],
  1010 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  1011 + ),
  1012 + );
  1013 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  1014 + ..record = HealthRawLocalNotificationRecord(
  1015 + latestRealtimeStressDataTime: firstTime - 1,
  1016 + );
  1017 + final service = AppleHealthRawDataCoreService(
  1018 + healthApi: _FakeHealthKitHostApi(),
  1019 + rawDataApi: api,
  1020 + localStore: store,
  1021 + userIdProvider: () => 42,
  1022 + uploadResultsAfterCalculation: false,
  1023 + localNotificationDispatcher: notificationDispatcher,
  1024 + );
  1025 +
  1026 + await service.startCoreCaculate(
  1027 + endTime: latestTime,
  1028 + readChunkDays: 1,
  1029 + );
  1030 +
  1031 + expect(
  1032 + notificationDispatcher.sentNotifications.where(
  1033 + (e) =>
  1034 + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
  1035 + ),
  1036 + isEmpty,
  1037 + );
  1038 + final rows = await store.debugQueryAllRows(42);
  1039 + expect(rows.realtimeRows.last['push_send_time'], isNull);
810 }); 1040 });
811 1041
812 test( 1042 test(
@@ -862,6 +1092,9 @@ void main() { @@ -862,6 +1092,9 @@ void main() {
862 ), 1092 ),
863 isEmpty, 1093 isEmpty,
864 ); 1094 );
  1095 + expect(notificationDispatcher.record.latestHrvDataTime, base + 420);
  1096 + expect(notificationDispatcher.record.latestSleepDataTime,
  1097 + LocalHealthDataConvert.unixSeconds(sleepEnd));
865 }); 1098 });
866 1099
867 test( 1100 test(
@@ -934,6 +1167,10 @@ void main() { @@ -934,6 +1167,10 @@ void main() {
934 expect(rows.realtimeRows.single['uploaded'], 1); 1167 expect(rows.realtimeRows.single['uploaded'], 1);
935 expect(rows.dailyStressRows.single['uploaded'], 1); 1168 expect(rows.dailyStressRows.single['uploaded'], 1);
936 expect(rows.sleepRows.single['uploaded'], 1); 1169 expect(rows.sleepRows.single['uploaded'], 1);
  1170 + expect(rows.hrvRows.single['upload_time'], isNotNull);
  1171 + expect(rows.realtimeRows.single['upload_time'], isNotNull);
  1172 + expect(rows.dailyStressRows.single['upload_time'], isNotNull);
  1173 + expect(rows.sleepRows.single['upload_time'], isNotNull);
937 }); 1174 });
938 1175
939 test('startCoreCaculate does not wait for pending uploads', () async { 1176 test('startCoreCaculate does not wait for pending uploads', () async {
@@ -972,6 +1209,8 @@ void main() { @@ -972,6 +1209,8 @@ void main() {
972 var rows = await store.debugQueryAllRows(42); 1209 var rows = await store.debugQueryAllRows(42);
973 expect(rows.hrvRows.single['uploaded'], 0); 1210 expect(rows.hrvRows.single['uploaded'], 0);
974 expect(rows.realtimeRows.single['uploaded'], 0); 1211 expect(rows.realtimeRows.single['uploaded'], 0);
  1212 + expect(rows.hrvRows.single['upload_time'], isNull);
  1213 + expect(rows.realtimeRows.single['upload_time'], isNull);
975 1214
976 api.hrvUploadCompleter!.complete(base + 10); 1215 api.hrvUploadCompleter!.complete(base + 10);
977 api.hrUploadCompleter!.complete(base + 20); 1216 api.hrUploadCompleter!.complete(base + 20);
@@ -980,6 +1219,8 @@ void main() { @@ -980,6 +1219,8 @@ void main() {
980 1219
981 expect(rows.hrvRows.single['uploaded'], 1); 1220 expect(rows.hrvRows.single['uploaded'], 1);
982 expect(rows.realtimeRows.single['uploaded'], 1); 1221 expect(rows.realtimeRows.single['uploaded'], 1);
  1222 + expect(rows.hrvRows.single['upload_time'], isNotNull);
  1223 + expect(rows.realtimeRows.single['upload_time'], isNotNull);
983 }); 1224 });
984 1225
985 test('local data source returns earliest local hr start time', () async { 1226 test('local data source returns earliest local hr start time', () async {
@@ -1033,12 +1274,15 @@ HealthKitRawDataPoint _point( @@ -1033,12 +1274,15 @@ HealthKitRawDataPoint _point(
1033 ); 1274 );
1034 } 1275 }
1035 1276
1036 -HealthRawRealtimeStressPoint _realtimeStressPoint(int time) { 1277 +HealthRawRealtimeStressPoint _realtimeStressPoint(
  1278 + int time, {
  1279 + double result = 30,
  1280 +}) {
1037 return HealthRawRealtimeStressPoint( 1281 return HealthRawRealtimeStressPoint(
1038 userId: 42, 1282 userId: 42,
1039 rawEndTime: time, 1283 rawEndTime: time,
1040 rawHr: 70, 1284 rawHr: 70,
1041 - result: 30, 1285 + result: result,
1042 sourceStartTime: time, 1286 sourceStartTime: time,
1043 sourceEndTime: time, 1287 sourceEndTime: time,
1044 ); 1288 );
@@ -1232,6 +1476,20 @@ class _FakeHealthRawLocalNotificationDispatcher @@ -1232,6 +1476,20 @@ class _FakeHealthRawLocalNotificationDispatcher
1232 }) async { 1476 }) async {
1233 record = record.copyWith(lastRealtimeStressTime: recordTime); 1477 record = record.copyWith(lastRealtimeStressTime: recordTime);
1234 } 1478 }
  1479 +
  1480 + @override
  1481 + Future<void> updateProcessedDataTimes({
  1482 + required int userId,
  1483 + int? latestHrvDataTime,
  1484 + int? latestRealtimeStressDataTime,
  1485 + int? latestSleepDataTime,
  1486 + }) async {
  1487 + record = record.copyWith(
  1488 + latestHrvDataTime: latestHrvDataTime,
  1489 + latestRealtimeStressDataTime: latestRealtimeStressDataTime,
  1490 + latestSleepDataTime: latestSleepDataTime,
  1491 + );
  1492 + }
1235 } 1493 }
1236 1494
1237 class _ReadCall { 1495 class _ReadCall {
@@ -1254,8 +1512,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1254,8 +1512,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1254 final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; 1512 final Map<int, Map<int, int>> _realtimeUpdateTimes = {};
1255 final Map<int, Map<int, int>> _dailyUpdateTimes = {}; 1513 final Map<int, Map<int, int>> _dailyUpdateTimes = {};
1256 final Map<int, Map<int, int>> _sleepUpdateTimes = {}; 1514 final Map<int, Map<int, int>> _sleepUpdateTimes = {};
  1515 + final Map<int, Map<int, int>> _hrvPushSendTimes = {};
  1516 + final Map<int, Map<int, int>> _realtimePushSendTimes = {};
  1517 + final Map<int, Map<int, int>> _sleepPushSendTimes = {};
  1518 + final Map<int, Map<int, int>> _hrvUploadTimes = {};
  1519 + final Map<int, Map<int, int>> _realtimeUploadTimes = {};
  1520 + final Map<int, Map<int, int>> _dailyUploadTimes = {};
  1521 + final Map<int, Map<int, int>> _sleepUploadTimes = {};
1257 final List<String> operationLog; 1522 final List<String> operationLog;
1258 var _updateTimeClock = 1; 1523 var _updateTimeClock = 1;
  1524 + var _uploadTimeClock = 1000;
1259 var realtimeNotificationWindowQueryCount = 0; 1525 var realtimeNotificationWindowQueryCount = 0;
1260 1526
1261 void insertDailyStress(HealthRawDailyStressPoint point) { 1527 void insertDailyStress(HealthRawDailyStressPoint point) {
@@ -1282,6 +1548,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1282,6 +1548,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1282 @override 1548 @override
1283 Future<void> upsertResult(HealthRawStressCalculationResult result) async { 1549 Future<void> upsertResult(HealthRawStressCalculationResult result) async {
1284 operationLog.add('upsertResult'); 1550 operationLog.add('upsertResult');
  1551 + final updateTime = _nextUpdateTime();
1285 final hrvByTime = <int, HealthRawHrvStressPoint>{ 1552 final hrvByTime = <int, HealthRawHrvStressPoint>{
1286 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) 1553 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[])
1287 point.rawEndTime: point, 1554 point.rawEndTime: point,
@@ -1303,10 +1570,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1303,10 +1570,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1303 baselineRestingHr: point.baselineRestingHr, 1570 baselineRestingHr: point.baselineRestingHr,
1304 flags: point.flags, 1571 flags: point.flags,
1305 uploaded: same ? existing.uploaded : false, 1572 uploaded: same ? existing.uploaded : false,
  1573 + pushSendTime: existing?.pushSendTime,
  1574 + uploadTime: same ? existing.uploadTime : null,
1306 ); 1575 );
1307 _hrvUpdateTimes.putIfAbsent( 1576 _hrvUpdateTimes.putIfAbsent(
1308 - point.userId, () => <int, int>{})[point.rawEndTime] =  
1309 - _nextUpdateTime(); 1577 + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
  1578 + if (!same) {
  1579 + _hrvUploadTimes[point.userId]?.remove(point.rawEndTime);
  1580 + }
1310 } 1581 }
1311 _hrv[result.userId] = hrvByTime.values.toList() 1582 _hrv[result.userId] = hrvByTime.values.toList()
1312 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 1583 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
@@ -1329,10 +1600,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1329,10 +1600,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1329 sourceEndTime: point.sourceEndTime, 1600 sourceEndTime: point.sourceEndTime,
1330 flags: point.flags, 1601 flags: point.flags,
1331 uploaded: same ? existing.uploaded : false, 1602 uploaded: same ? existing.uploaded : false,
  1603 + pushSendTime: existing?.pushSendTime,
  1604 + uploadTime: same ? existing.uploadTime : null,
1332 ); 1605 );
1333 _realtimeUpdateTimes.putIfAbsent( 1606 _realtimeUpdateTimes.putIfAbsent(
1334 - point.userId, () => <int, int>{})[point.rawEndTime] =  
1335 - _nextUpdateTime(); 1607 + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
  1608 + if (!same) {
  1609 + _realtimeUploadTimes[point.userId]?.remove(point.rawEndTime);
  1610 + }
1336 } 1611 }
1337 _realtime[result.userId] = realtimeByTime.values.toList() 1612 _realtime[result.userId] = realtimeByTime.values.toList()
1338 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 1613 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
@@ -1344,6 +1619,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1344,6 +1619,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1344 required Iterable<HealthRawDailyStressPoint> points, 1619 required Iterable<HealthRawDailyStressPoint> points,
1345 }) async { 1620 }) async {
1346 operationLog.add('upsertDailyStressPoints'); 1621 operationLog.add('upsertDailyStressPoints');
  1622 + final updateTime = _nextUpdateTime();
1347 final byDate = <int, HealthRawDailyStressPoint>{ 1623 final byDate = <int, HealthRawDailyStressPoint>{
1348 for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) 1624 for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[])
1349 point.date: point, 1625 point.date: point,
@@ -1364,9 +1640,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1364,9 +1640,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1364 state: point.state, 1640 state: point.state,
1365 dataTime: point.dataTime, 1641 dataTime: point.dataTime,
1366 uploaded: isToday ? false : (same ? existing.uploaded : false), 1642 uploaded: isToday ? false : (same ? existing.uploaded : false),
  1643 + uploadTime: isToday || !same ? null : existing.uploadTime,
1367 ); 1644 );
1368 _dailyUpdateTimes.putIfAbsent( 1645 _dailyUpdateTimes.putIfAbsent(
1369 - point.userId, () => <int, int>{})[point.date] = _nextUpdateTime(); 1646 + point.userId, () => <int, int>{})[point.date] = updateTime;
  1647 + if (isToday || !same) {
  1648 + _dailyUploadTimes[point.userId]?.remove(point.date);
  1649 + }
1370 } 1650 }
1371 _daily[userId] = byDate.values.toList() 1651 _daily[userId] = byDate.values.toList()
1372 ..sort((a, b) => a.date.compareTo(b.date)); 1652 ..sort((a, b) => a.date.compareTo(b.date));
@@ -1377,14 +1657,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1377,14 +1657,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1377 required int userId, 1657 required int userId,
1378 required Iterable<HealthRawSleepResult> results, 1658 required Iterable<HealthRawSleepResult> results,
1379 }) async { 1659 }) async {
  1660 + final updateTime = _nextUpdateTime();
1380 final byDate = <int, HealthRawSleepResult>{ 1661 final byDate = <int, HealthRawSleepResult>{
1381 for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) 1662 for (final result in _sleep[userId] ?? <HealthRawSleepResult>[])
1382 result.date: result, 1663 result.date: result,
1383 }; 1664 };
1384 for (final result in results) { 1665 for (final result in results) {
1385 - byDate[result.date] = result; 1666 + final existing = byDate[result.date];
  1667 + final same = existing != null &&
  1668 + existing.userId == result.userId &&
  1669 + existing.startDate == result.startDate &&
  1670 + existing.sleepScore == result.sleepScore &&
  1671 + existing.sleepState == result.sleepState &&
  1672 + existing.inBedMinutes == result.inBedMinutes &&
  1673 + existing.awakMinutes == result.awakMinutes &&
  1674 + existing.sleepMinutes == result.sleepMinutes;
  1675 + byDate[result.date] = HealthRawSleepResult(
  1676 + userId: result.userId,
  1677 + date: result.date,
  1678 + startDate: result.startDate,
  1679 + sleepScore: result.sleepScore,
  1680 + sleepState: result.sleepState,
  1681 + inBedMinutes: result.inBedMinutes,
  1682 + awakMinutes: result.awakMinutes,
  1683 + sleepMinutes: result.sleepMinutes,
  1684 + uploaded: same ? existing.uploaded : result.uploaded,
  1685 + pushSendTime: existing?.pushSendTime,
  1686 + uploadTime: same ? existing.uploadTime : result.uploadTime,
  1687 + );
1386 _sleepUpdateTimes.putIfAbsent( 1688 _sleepUpdateTimes.putIfAbsent(
1387 - result.userId, () => <int, int>{})[result.date] = _nextUpdateTime(); 1689 + result.userId, () => <int, int>{})[result.date] = updateTime;
  1690 + if (!same) {
  1691 + _sleepUploadTimes[result.userId]?.remove(result.date);
  1692 + }
1388 } 1693 }
1389 _sleep[userId] = byDate.values.toList() 1694 _sleep[userId] = byDate.values.toList()
1390 ..sort((a, b) => a.date.compareTo(b.date)); 1695 ..sort((a, b) => a.date.compareTo(b.date));
@@ -1514,6 +1819,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1514,6 +1819,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1514 } 1819 }
1515 1820
1516 @override 1821 @override
  1822 + Future<int?> latestRealtimeStressPushSendTime(int userId) async {
  1823 + final times = (_realtimePushSendTimes[userId] ?? <int, int>{}).values;
  1824 + if (times.isEmpty) return null;
  1825 + return times.reduce((a, b) => a >= b ? a : b);
  1826 + }
  1827 +
  1828 + @override
  1829 + Future<void> markLocalNotificationPushed({
  1830 + required int userId,
  1831 + required HealthRawLocalNotificationRecordType recordType,
  1832 + required int recordTime,
  1833 + required int pushSendTime,
  1834 + }) async {
  1835 + switch (recordType) {
  1836 + case HealthRawLocalNotificationRecordType.hrv:
  1837 + _hrvPushSendTimes.putIfAbsent(userId, () => <int, int>{})[recordTime] =
  1838 + pushSendTime;
  1839 + _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[])
  1840 + .map((point) => point.rawEndTime == recordTime
  1841 + ? HealthRawHrvStressPoint(
  1842 + userId: point.userId,
  1843 + rawEndTime: point.rawEndTime,
  1844 + rawHrv: point.rawHrv,
  1845 + result: point.result,
  1846 + sourceStartTime: point.sourceStartTime,
  1847 + sourceEndTime: point.sourceEndTime,
  1848 + state: point.state,
  1849 + baselineHrv: point.baselineHrv,
  1850 + baselineAwakeHrv: point.baselineAwakeHrv,
  1851 + baselineSleepHrv: point.baselineSleepHrv,
  1852 + baselineRestingHr: point.baselineRestingHr,
  1853 + flags: point.flags,
  1854 + uploaded: point.uploaded,
  1855 + pushSendTime: pushSendTime,
  1856 + uploadTime: point.uploadTime,
  1857 + )
  1858 + : point)
  1859 + .toList();
  1860 + case HealthRawLocalNotificationRecordType.realtimeStress:
  1861 + _realtimePushSendTimes.putIfAbsent(
  1862 + userId, () => <int, int>{})[recordTime] = pushSendTime;
  1863 + _realtime[userId] =
  1864 + (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
  1865 + .map((point) => point.rawEndTime == recordTime
  1866 + ? HealthRawRealtimeStressPoint(
  1867 + userId: point.userId,
  1868 + rawEndTime: point.rawEndTime,
  1869 + rawHr: point.rawHr,
  1870 + result: point.result,
  1871 + sourceStartTime: point.sourceStartTime,
  1872 + sourceEndTime: point.sourceEndTime,
  1873 + flags: point.flags,
  1874 + uploaded: point.uploaded,
  1875 + pushSendTime: pushSendTime,
  1876 + uploadTime: point.uploadTime,
  1877 + )
  1878 + : point)
  1879 + .toList();
  1880 + case HealthRawLocalNotificationRecordType.sleep:
  1881 + _sleepPushSendTimes.putIfAbsent(
  1882 + userId, () => <int, int>{})[recordTime] = pushSendTime;
  1883 + _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[])
  1884 + .map((result) => result.date == recordTime
  1885 + ? HealthRawSleepResult(
  1886 + userId: result.userId,
  1887 + date: result.date,
  1888 + startDate: result.startDate,
  1889 + sleepScore: result.sleepScore,
  1890 + sleepState: result.sleepState,
  1891 + inBedMinutes: result.inBedMinutes,
  1892 + awakMinutes: result.awakMinutes,
  1893 + sleepMinutes: result.sleepMinutes,
  1894 + uploaded: result.uploaded,
  1895 + pushSendTime: pushSendTime,
  1896 + uploadTime: result.uploadTime,
  1897 + )
  1898 + : result)
  1899 + .toList();
  1900 + }
  1901 + }
  1902 +
  1903 + @override
1517 Future<int?> earliestRealtimeRawEndTime(int userId) async { 1904 Future<int?> earliestRealtimeRawEndTime(int userId) async {
1518 final points = _realtime[userId]; 1905 final points = _realtime[userId];
1519 if (points == null || points.isEmpty) return null; 1906 if (points == null || points.isEmpty) return null;
@@ -1533,8 +1920,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1533,8 +1920,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1533 required Iterable<int> rawEndTimes, 1920 required Iterable<int> rawEndTimes,
1534 }) async { 1921 }) async {
1535 final timeSet = rawEndTimes.toSet(); 1922 final timeSet = rawEndTimes.toSet();
  1923 + final uploadTime = _nextUploadTime();
1536 _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { 1924 _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) {
1537 if (!timeSet.contains(point.rawEndTime)) return point; 1925 if (!timeSet.contains(point.rawEndTime)) return point;
  1926 + _hrvUploadTimes.putIfAbsent(
  1927 + userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
1538 return HealthRawHrvStressPoint( 1928 return HealthRawHrvStressPoint(
1539 userId: point.userId, 1929 userId: point.userId,
1540 rawEndTime: point.rawEndTime, 1930 rawEndTime: point.rawEndTime,
@@ -1549,6 +1939,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1549,6 +1939,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1549 baselineRestingHr: point.baselineRestingHr, 1939 baselineRestingHr: point.baselineRestingHr,
1550 flags: point.flags, 1940 flags: point.flags,
1551 uploaded: true, 1941 uploaded: true,
  1942 + pushSendTime: point.pushSendTime,
  1943 + uploadTime: uploadTime,
1552 ); 1944 );
1553 }).toList(); 1945 }).toList();
1554 } 1946 }
@@ -1570,9 +1962,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1570,9 +1962,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1570 required Iterable<int> rawEndTimes, 1962 required Iterable<int> rawEndTimes,
1571 }) async { 1963 }) async {
1572 final timeSet = rawEndTimes.toSet(); 1964 final timeSet = rawEndTimes.toSet();
  1965 + final uploadTime = _nextUploadTime();
1573 _realtime[userId] = 1966 _realtime[userId] =
1574 (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { 1967 (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) {
1575 if (!timeSet.contains(point.rawEndTime)) return point; 1968 if (!timeSet.contains(point.rawEndTime)) return point;
  1969 + _realtimeUploadTimes.putIfAbsent(
  1970 + userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
1576 return HealthRawRealtimeStressPoint( 1971 return HealthRawRealtimeStressPoint(
1577 userId: point.userId, 1972 userId: point.userId,
1578 rawEndTime: point.rawEndTime, 1973 rawEndTime: point.rawEndTime,
@@ -1582,6 +1977,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1582,6 +1977,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1582 sourceEndTime: point.sourceEndTime, 1977 sourceEndTime: point.sourceEndTime,
1583 flags: point.flags, 1978 flags: point.flags,
1584 uploaded: true, 1979 uploaded: true,
  1980 + pushSendTime: point.pushSendTime,
  1981 + uploadTime: uploadTime,
1585 ); 1982 );
1586 }).toList(); 1983 }).toList();
1587 } 1984 }
@@ -1602,9 +1999,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1602,9 +1999,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1602 required int userId, 1999 required int userId,
1603 required int date, 2000 required int date,
1604 }) async { 2001 }) async {
  2002 + final uploadTime = _nextUploadTime();
1605 _daily[userId] = 2003 _daily[userId] =
1606 (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { 2004 (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) {
1607 if (point.date > date) return point; 2005 if (point.date > date) return point;
  2006 + _dailyUploadTimes.putIfAbsent(userId, () => <int, int>{})[point.date] =
  2007 + uploadTime;
1608 return HealthRawDailyStressPoint( 2008 return HealthRawDailyStressPoint(
1609 userId: point.userId, 2009 userId: point.userId,
1610 date: point.date, 2010 date: point.date,
@@ -1613,6 +2013,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1613,6 +2013,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1613 state: point.state, 2013 state: point.state,
1614 dataTime: point.dataTime, 2014 dataTime: point.dataTime,
1615 uploaded: true, 2015 uploaded: true,
  2016 + uploadTime: uploadTime,
1616 ); 2017 );
1617 }).toList(); 2018 }).toList();
1618 } 2019 }
@@ -1622,8 +2023,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1622,8 +2023,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1622 required int userId, 2023 required int userId,
1623 required int date, 2024 required int date,
1624 }) async { 2025 }) async {
  2026 + final uploadTime = _nextUploadTime();
1625 _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { 2027 _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) {
1626 if (result.date > date) return result; 2028 if (result.date > date) return result;
  2029 + _sleepUploadTimes.putIfAbsent(userId, () => <int, int>{})[result.date] =
  2030 + uploadTime;
1627 return HealthRawSleepResult( 2031 return HealthRawSleepResult(
1628 userId: result.userId, 2032 userId: result.userId,
1629 date: result.date, 2033 date: result.date,
@@ -1634,6 +2038,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1634,6 +2038,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1634 awakMinutes: result.awakMinutes, 2038 awakMinutes: result.awakMinutes,
1635 sleepMinutes: result.sleepMinutes, 2039 sleepMinutes: result.sleepMinutes,
1636 uploaded: true, 2040 uploaded: true,
  2041 + pushSendTime: result.pushSendTime,
  2042 + uploadTime: uploadTime,
1637 ); 2043 );
1638 }).toList(); 2044 }).toList();
1639 } 2045 }
@@ -1713,6 +2119,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1713,6 +2119,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1713 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, 2119 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
1714 'uploaded': point.uploaded ? 1 : 0, 2120 'uploaded': point.uploaded ? 1 : 0,
1715 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, 2121 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
  2122 + 'push_send_time': _hrvPushSendTimes[point.userId]?[point.rawEndTime],
  2123 + 'upload_time':
  2124 + _hrvUploadTimes[point.userId]?[point.rawEndTime] ?? point.uploadTime,
1716 }; 2125 };
1717 } 2126 }
1718 2127
@@ -1730,6 +2139,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1730,6 +2139,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1730 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, 2139 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
1731 'uploaded': point.uploaded ? 1 : 0, 2140 'uploaded': point.uploaded ? 1 : 0,
1732 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, 2141 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
  2142 + 'push_send_time': _realtimePushSendTimes[point.userId]?[point.rawEndTime],
  2143 + 'upload_time': _realtimeUploadTimes[point.userId]?[point.rawEndTime] ??
  2144 + point.uploadTime,
1733 }; 2145 };
1734 } 2146 }
1735 2147
@@ -1743,6 +2155,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1743,6 +2155,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1743 'data_time': point.dataTime, 2155 'data_time': point.dataTime,
1744 'uploaded': point.uploaded ? 1 : 0, 2156 'uploaded': point.uploaded ? 1 : 0,
1745 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, 2157 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0,
  2158 + 'upload_time':
  2159 + _dailyUploadTimes[point.userId]?[point.date] ?? point.uploadTime,
1746 }; 2160 };
1747 } 2161 }
1748 2162
@@ -1758,11 +2172,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1758,11 +2172,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1758 'sleep_minutes': result.sleepMinutes, 2172 'sleep_minutes': result.sleepMinutes,
1759 'uploaded': result.uploaded ? 1 : 0, 2173 'uploaded': result.uploaded ? 1 : 0,
1760 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, 2174 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0,
  2175 + 'push_send_time': _sleepPushSendTimes[result.userId]?[result.date],
  2176 + 'upload_time':
  2177 + _sleepUploadTimes[result.userId]?[result.date] ?? result.uploadTime,
1761 }; 2178 };
1762 } 2179 }
1763 2180
1764 int _nextUpdateTime() => _updateTimeClock++; 2181 int _nextUpdateTime() => _updateTimeClock++;
1765 2182
  2183 + int _nextUploadTime() => _uploadTimeClock++;
  2184 +
1766 int _todayDateKey() { 2185 int _todayDateKey() {
1767 final now = DateTime.now(); 2186 final now = DateTime.now();
1768 return now.year * 10000 + now.month * 100 + now.day; 2187 return now.year * 10000 + now.month * 100 + now.day;
@@ -153,6 +153,76 @@ void main() { @@ -153,6 +153,76 @@ void main() {
153 expect(notifications.single.recordTime, latestTime); 153 expect(notifications.single.recordTime, latestTime);
154 }); 154 });
155 155
  156 + test('builds only the latest hrv notification for multiple candidates', () {
  157 + final firstTime = _seconds(DateTime(2026, 1, 1, 9));
  158 + final secondTime = _seconds(DateTime(2026, 1, 1, 10));
  159 + final notifications = builder.build(
  160 + result: HealthRawStressCalculationResult(
  161 + userId: 1,
  162 + hrvStressPoints: [
  163 + _hrvPoint(secondTime, isSleepLikely: true),
  164 + _hrvPoint(firstTime),
  165 + ],
  166 + realtimeStressPoints: const [],
  167 + dailyStressPoints: const [],
  168 + ),
  169 + hasExistingHrv: true,
  170 + hasExistingSleep: false,
  171 + realtimeWindow: const [],
  172 + record: const HealthRawLocalNotificationRecord(),
  173 + );
  174 +
  175 + expect(notifications, hasLength(1));
  176 + expect(notifications.single.recordType,
  177 + HealthRawLocalNotificationRecordType.hrv);
  178 + expect(notifications.single.recordTime, secondTime);
  179 + });
  180 +
  181 + test('keeps the latest notification per type without dropping other types',
  182 + () {
  183 + final hrvFirstTime = _seconds(DateTime(2026, 1, 1, 9));
  184 + final hrvSecondTime = _seconds(DateTime(2026, 1, 1, 10));
  185 + final realtimeBase = _seconds(DateTime(2026, 1, 1, 11));
  186 + final notifications = builder.build(
  187 + result: HealthRawStressCalculationResult(
  188 + userId: 1,
  189 + hrvStressPoints: [
  190 + _hrvPoint(hrvFirstTime),
  191 + _hrvPoint(hrvSecondTime),
  192 + ],
  193 + realtimeStressPoints: [
  194 + _realtimePoint(realtimeBase + 9 * 300, 70),
  195 + ],
  196 + dailyStressPoints: const [],
  197 + ),
  198 + hasExistingHrv: true,
  199 + hasExistingSleep: false,
  200 + realtimeWindow: [
  201 + for (var i = 0; i < 10; i++) _realtimePoint(realtimeBase + i * 300, 70),
  202 + ],
  203 + record: const HealthRawLocalNotificationRecord(),
  204 + );
  205 +
  206 + expect(notifications, hasLength(2));
  207 + expect(
  208 + notifications
  209 + .where(
  210 + (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv)
  211 + .single
  212 + .recordTime,
  213 + hrvSecondTime,
  214 + );
  215 + expect(
  216 + notifications
  217 + .where((e) =>
  218 + e.recordType ==
  219 + HealthRawLocalNotificationRecordType.realtimeStress)
  220 + .single
  221 + .recordTime,
  222 + realtimeBase + 9 * 300,
  223 + );
  224 + });
  225 +
156 test('builds realtime stress notification from latest 60 minute window', () { 226 test('builds realtime stress notification from latest 60 minute window', () {
157 final base = _seconds(DateTime(2026, 1, 1, 9)); 227 final base = _seconds(DateTime(2026, 1, 1, 9));
158 final notifications = builder.build( 228 final notifications = builder.build(
@@ -208,6 +278,138 @@ void main() { @@ -208,6 +278,138 @@ void main() {
208 ); 278 );
209 }); 279 });
210 280
  281 + test('skips realtime stress notification within push send interval', () {
  282 + final base = _seconds(DateTime(2026, 1, 1, 9));
  283 + final notifications = builder.build(
  284 + result: HealthRawStressCalculationResult(
  285 + userId: 1,
  286 + hrvStressPoints: const [],
  287 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  288 + dailyStressPoints: const [],
  289 + ),
  290 + hasExistingHrv: false,
  291 + hasExistingSleep: false,
  292 + realtimeWindow: [
  293 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  294 + ],
  295 + record: const HealthRawLocalNotificationRecord(),
  296 + lastRealtimeStressPushSendTime: base + 100,
  297 + notificationBuildTime: base + 100 + 3599,
  298 + );
  299 +
  300 + expect(notifications, isEmpty);
  301 + });
  302 +
  303 + test('skips realtime stress notification inside supplied sleep interval', () {
  304 + final base = _seconds(DateTime(2026, 1, 1, 9));
  305 + final notifications = builder.build(
  306 + result: HealthRawStressCalculationResult(
  307 + userId: 1,
  308 + hrvStressPoints: const [],
  309 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  310 + dailyStressPoints: const [],
  311 + ),
  312 + hasExistingHrv: false,
  313 + hasExistingSleep: false,
  314 + realtimeWindow: [
  315 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  316 + ],
  317 + record: const HealthRawLocalNotificationRecord(),
  318 + realtimeSleepIntervals: [
  319 + (startTime: base + 8 * 300, endTime: base + 10 * 300),
  320 + ],
  321 + );
  322 +
  323 + expect(notifications, isEmpty);
  324 + });
  325 +
  326 + test('builds realtime stress notification outside supplied sleep interval',
  327 + () {
  328 + final base = _seconds(DateTime(2026, 1, 1, 9));
  329 + final notifications = builder.build(
  330 + result: HealthRawStressCalculationResult(
  331 + userId: 1,
  332 + hrvStressPoints: const [],
  333 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  334 + dailyStressPoints: const [],
  335 + ),
  336 + hasExistingHrv: false,
  337 + hasExistingSleep: false,
  338 + realtimeWindow: [
  339 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  340 + ],
  341 + record: const HealthRawLocalNotificationRecord(),
  342 + realtimeSleepIntervals: [
  343 + (startTime: base - 3 * 3600, endTime: base - 2 * 3600),
  344 + ],
  345 + );
  346 +
  347 + expect(notifications, hasLength(1));
  348 + });
  349 +
  350 + test('uses midnight to six default realtime sleep interval', () {
  351 + final dayStart = _seconds(DateTime(2026, 1, 1));
  352 + final sleepTime = dayStart + 5 * 3600 + 30 * 60;
  353 + final awakeTime = dayStart + 6 * 3600 + 60;
  354 +
  355 + final sleepNotifications = builder.build(
  356 + result: HealthRawStressCalculationResult(
  357 + userId: 1,
  358 + hrvStressPoints: const [],
  359 + realtimeStressPoints: [_realtimePoint(sleepTime, 70)],
  360 + dailyStressPoints: const [],
  361 + ),
  362 + hasExistingHrv: false,
  363 + hasExistingSleep: false,
  364 + realtimeWindow: [
  365 + for (var i = 0; i < 10; i++)
  366 + _realtimePoint(sleepTime - (9 - i) * 300, 70),
  367 + ],
  368 + record: const HealthRawLocalNotificationRecord(),
  369 + );
  370 + final awakeNotifications = builder.build(
  371 + result: HealthRawStressCalculationResult(
  372 + userId: 1,
  373 + hrvStressPoints: const [],
  374 + realtimeStressPoints: [_realtimePoint(awakeTime, 70)],
  375 + dailyStressPoints: const [],
  376 + ),
  377 + hasExistingHrv: false,
  378 + hasExistingSleep: false,
  379 + realtimeWindow: [
  380 + for (var i = 0; i < 10; i++)
  381 + _realtimePoint(awakeTime - (9 - i) * 300, 70),
  382 + ],
  383 + record: const HealthRawLocalNotificationRecord(),
  384 + );
  385 +
  386 + expect(sleepNotifications, isEmpty);
  387 + expect(awakeNotifications, hasLength(1));
  388 + });
  389 +
  390 + test('ignores legacy realtime record time for push send interval', () {
  391 + final base = _seconds(DateTime(2026, 1, 1, 9));
  392 + final notifications = builder.build(
  393 + result: HealthRawStressCalculationResult(
  394 + userId: 1,
  395 + hrvStressPoints: const [],
  396 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  397 + dailyStressPoints: const [],
  398 + ),
  399 + hasExistingHrv: false,
  400 + hasExistingSleep: false,
  401 + realtimeWindow: [
  402 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  403 + ],
  404 + record: HealthRawLocalNotificationRecord(
  405 + lastRealtimeStressTime: base + 9 * 300,
  406 + ),
  407 + notificationBuildTime: base + 9 * 300,
  408 + );
  409 +
  410 + expect(notifications, hasLength(1));
  411 + });
  412 +
211 test('skips realtime stress notification during likely sleep', () { 413 test('skips realtime stress notification during likely sleep', () {
212 final base = _seconds(DateTime(2026, 1, 1, 9)); 414 final base = _seconds(DateTime(2026, 1, 1, 9));
213 final notifications = builder.build( 415 final notifications = builder.build(
@@ -292,11 +494,57 @@ void main() { @@ -292,11 +494,57 @@ void main() {
292 final record = await dispatcher.readRecord(1); 494 final record = await dispatcher.readRecord(1);
293 expect(record.lastRealtimeStressTime, 1234); 495 expect(record.lastRealtimeStressTime, 1234);
294 }); 496 });
  497 +
  498 + test('reads old notification record json without latest data times', () {
  499 + final record = HealthRawLocalNotificationRecord.fromJson(
  500 + const <String, Object?>{
  501 + 'last_sleep_time': 100,
  502 + 'last_hrv_time': 200,
  503 + 'last_realtime_stress_time': 300,
  504 + },
  505 + );
  506 +
  507 + expect(record.lastSleepTime, 100);
  508 + expect(record.lastHrvTime, 200);
  509 + expect(record.lastRealtimeStressTime, 300);
  510 + expect(record.latestHrvDataTime, isNull);
  511 + expect(record.latestRealtimeStressDataTime, isNull);
  512 + expect(record.latestSleepDataTime, isNull);
  513 + });
  514 +
  515 + test('stores processed latest data times independently', () async {
  516 + final dir = await Directory.systemTemp.createTemp(
  517 + 'health_raw_notification_test_',
  518 + );
  519 + addTearDown(() => dir.delete(recursive: true));
  520 + final dispatcher = HealthRawLocalNotificationDispatcher(
  521 + recordStore: HealthRawLocalNotificationRecordStore(rootDirectory: dir),
  522 + );
  523 +
  524 + await dispatcher.updateProcessedDataTimes(
  525 + userId: 1,
  526 + latestHrvDataTime: 100,
  527 + latestRealtimeStressDataTime: 200,
  528 + latestSleepDataTime: 300,
  529 + );
  530 + await dispatcher.updateProcessedDataTimes(
  531 + userId: 1,
  532 + latestRealtimeStressDataTime: 250,
  533 + );
  534 +
  535 + final record = await dispatcher.readRecord(1);
  536 + expect(record.latestHrvDataTime, 100);
  537 + expect(record.latestRealtimeStressDataTime, 250);
  538 + expect(record.latestSleepDataTime, 300);
  539 + });
295 } 540 }
296 541
297 int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; 542 int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
298 543
299 -HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { 544 +HealthRawHrvStressPoint _hrvPoint(
  545 + int rawEndTime, {
  546 + bool isSleepLikely = false,
  547 +}) {
300 return HealthRawHrvStressPoint( 548 return HealthRawHrvStressPoint(
301 userId: 1, 549 userId: 1,
302 rawEndTime: rawEndTime, 550 rawEndTime: rawEndTime,
@@ -309,6 +557,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { @@ -309,6 +557,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
309 baselineAwakeHrv: 30, 557 baselineAwakeHrv: 30,
310 baselineSleepHrv: null, 558 baselineSleepHrv: null,
311 baselineRestingHr: 60, 559 baselineRestingHr: 60,
  560 + flags: HealthRawPointFlags(
  561 + isSleepLikely: isSleepLikely,
  562 + isWorkout: false,
  563 + isWorkoutRecovery: false,
  564 + isSuspectedActivity: false,
  565 + ),
312 ); 566 );
313 } 567 }
314 568