Commit 2a94da76826907c4940dcd9855a661c311460ac1

Authored by 权海
1 parent c16d74a6

feat(ui):优化计算数据和推送发送

@@ -160,6 +160,8 @@ class HealthRawHrvStressPoint { @@ -160,6 +160,8 @@ class HealthRawHrvStressPoint {
160 required this.baselineRestingHr, 160 required this.baselineRestingHr,
161 this.flags = const HealthRawPointFlags.none(), 161 this.flags = const HealthRawPointFlags.none(),
162 this.uploaded = false, 162 this.uploaded = false,
  163 + this.pushSendTime,
  164 + this.uploadTime,
163 }); 165 });
164 166
165 final int userId; 167 final int userId;
@@ -175,6 +177,8 @@ class HealthRawHrvStressPoint { @@ -175,6 +177,8 @@ class HealthRawHrvStressPoint {
175 final double baselineRestingHr; 177 final double baselineRestingHr;
176 final HealthRawPointFlags flags; 178 final HealthRawPointFlags flags;
177 final bool uploaded; 179 final bool uploaded;
  180 + final int? pushSendTime;
  181 + final int? uploadTime;
178 182
179 factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) { 183 factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) {
180 final result = (row['result'] as num).toDouble(); 184 final result = (row['result'] as num).toDouble();
@@ -196,6 +200,8 @@ class HealthRawHrvStressPoint { @@ -196,6 +200,8 @@ class HealthRawHrvStressPoint {
196 baselineRestingHr: _positiveDouble(row['baseline_resting_hr']) ?? 65, 200 baselineRestingHr: _positiveDouble(row['baseline_resting_hr']) ?? 65,
197 flags: _flagsFromDb(row), 201 flags: _flagsFromDb(row),
198 uploaded: (row['uploaded'] as int) == 1, 202 uploaded: (row['uploaded'] as int) == 1,
  203 + pushSendTime: (row['push_send_time'] as num?)?.toInt(),
  204 + uploadTime: (row['upload_time'] as num?)?.toInt(),
199 ); 205 );
200 } 206 }
201 } 207 }
@@ -210,6 +216,8 @@ class HealthRawRealtimeStressPoint { @@ -210,6 +216,8 @@ class HealthRawRealtimeStressPoint {
210 required this.sourceEndTime, 216 required this.sourceEndTime,
211 this.flags = const HealthRawPointFlags.none(), 217 this.flags = const HealthRawPointFlags.none(),
212 this.uploaded = false, 218 this.uploaded = false,
  219 + this.pushSendTime,
  220 + this.uploadTime,
213 }); 221 });
214 222
215 final int userId; 223 final int userId;
@@ -220,6 +228,8 @@ class HealthRawRealtimeStressPoint { @@ -220,6 +228,8 @@ class HealthRawRealtimeStressPoint {
220 final int sourceEndTime; 228 final int sourceEndTime;
221 final HealthRawPointFlags flags; 229 final HealthRawPointFlags flags;
222 final bool uploaded; 230 final bool uploaded;
  231 + final int? pushSendTime;
  232 + final int? uploadTime;
223 233
224 HealthRawStressState get state => healthRawRealtimeStressState(result); 234 HealthRawStressState get state => healthRawRealtimeStressState(result);
225 bool get isWorkout => flags.isWorkout; 235 bool get isWorkout => flags.isWorkout;
@@ -237,6 +247,8 @@ class HealthRawRealtimeStressPoint { @@ -237,6 +247,8 @@ class HealthRawRealtimeStressPoint {
237 sourceEndTime: row['source_end_time'] as int, 247 sourceEndTime: row['source_end_time'] as int,
238 flags: _flagsFromDb(row), 248 flags: _flagsFromDb(row),
239 uploaded: (row['uploaded'] as int) == 1, 249 uploaded: (row['uploaded'] as int) == 1,
  250 + pushSendTime: (row['push_send_time'] as num?)?.toInt(),
  251 + uploadTime: (row['upload_time'] as num?)?.toInt(),
240 ); 252 );
241 } 253 }
242 } 254 }
@@ -250,6 +262,7 @@ class HealthRawDailyStressPoint { @@ -250,6 +262,7 @@ class HealthRawDailyStressPoint {
250 required this.state, 262 required this.state,
251 required this.dataTime, 263 required this.dataTime,
252 this.uploaded = false, 264 this.uploaded = false,
  265 + this.uploadTime,
253 }); 266 });
254 267
255 final int userId; 268 final int userId;
@@ -259,6 +272,7 @@ class HealthRawDailyStressPoint { @@ -259,6 +272,7 @@ class HealthRawDailyStressPoint {
259 final HealthRawStressState state; 272 final HealthRawStressState state;
260 final int dataTime; 273 final int dataTime;
261 final bool uploaded; 274 final bool uploaded;
  275 + final int? uploadTime;
262 276
263 factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) { 277 factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) {
264 final stressScore = row['stress_score'] as int; 278 final stressScore = row['stress_score'] as int;
@@ -271,6 +285,7 @@ class HealthRawDailyStressPoint { @@ -271,6 +285,7 @@ class HealthRawDailyStressPoint {
271 healthRawRealtimeStressState(stressScore), 285 healthRawRealtimeStressState(stressScore),
272 dataTime: row['data_time'] as int, 286 dataTime: row['data_time'] as int,
273 uploaded: (row['uploaded'] as int) == 1, 287 uploaded: (row['uploaded'] as int) == 1,
  288 + uploadTime: (row['upload_time'] as num?)?.toInt(),
274 ); 289 );
275 } 290 }
276 } 291 }
@@ -286,6 +301,8 @@ class HealthRawSleepResult { @@ -286,6 +301,8 @@ class HealthRawSleepResult {
286 required this.awakMinutes, 301 required this.awakMinutes,
287 required this.sleepMinutes, 302 required this.sleepMinutes,
288 this.uploaded = false, 303 this.uploaded = false,
  304 + this.pushSendTime,
  305 + this.uploadTime,
289 }); 306 });
290 307
291 final int userId; 308 final int userId;
@@ -297,6 +314,8 @@ class HealthRawSleepResult { @@ -297,6 +314,8 @@ class HealthRawSleepResult {
297 final int awakMinutes; 314 final int awakMinutes;
298 final int sleepMinutes; 315 final int sleepMinutes;
299 final bool uploaded; 316 final bool uploaded;
  317 + final int? pushSendTime;
  318 + final int? uploadTime;
300 319
301 factory HealthRawSleepResult.fromDb(Map<String, Object?> row) { 320 factory HealthRawSleepResult.fromDb(Map<String, Object?> row) {
302 return HealthRawSleepResult( 321 return HealthRawSleepResult(
@@ -309,6 +328,8 @@ class HealthRawSleepResult { @@ -309,6 +328,8 @@ class HealthRawSleepResult {
309 awakMinutes: row['awak_minutes'] as int, 328 awakMinutes: row['awak_minutes'] as int,
310 sleepMinutes: row['sleep_minutes'] as int, 329 sleepMinutes: row['sleep_minutes'] as int,
311 uploaded: (row['uploaded'] as int? ?? 0) == 1, 330 uploaded: (row['uploaded'] as int? ?? 0) == 1,
  331 + pushSendTime: (row['push_send_time'] as num?)?.toInt(),
  332 + uploadTime: (row['upload_time'] as num?)?.toInt(),
312 ); 333 );
313 } 334 }
314 335
@@ -17,7 +17,6 @@ import '../../../logging/app_logger.dart'; @@ -17,7 +17,6 @@ import '../../../logging/app_logger.dart';
17 import '../../../network/api/health_api.dart'; 17 import '../../../network/api/health_api.dart';
18 import '../../../result/app_result.dart'; 18 import '../../../result/app_result.dart';
19 import '../health_raw_models.dart'; 19 import '../health_raw_models.dart';
20 -import 'apple_health_raw_local_notification_debug_store.dart';  
21 import 'apple_health_raw_local_notification.dart'; 20 import 'apple_health_raw_local_notification.dart';
22 import '../health_raw_stress_calculator.dart'; 21 import '../health_raw_stress_calculator.dart';
23 import '../health_sleep_calculator.dart'; 22 import '../health_sleep_calculator.dart';
@@ -39,7 +38,6 @@ class AppleHealthRawDataCoreService { @@ -39,7 +38,6 @@ class AppleHealthRawDataCoreService {
39 int Function()? userIdProvider, 38 int Function()? userIdProvider,
40 bool uploadResultsAfterCalculation = true, 39 bool uploadResultsAfterCalculation = true,
41 HealthRawLocalNotificationDispatcher? localNotificationDispatcher, 40 HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
42 - HealthRawLocalNotificationDebugStore? localNotificationDebugStore,  
43 HealthApi? serverHealthApi, 41 HealthApi? serverHealthApi,
44 }) : _healthApi = healthApi ?? HealthKitHostApi(), 42 }) : _healthApi = healthApi ?? HealthKitHostApi(),
45 _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), 43 _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
@@ -49,8 +47,6 @@ class AppleHealthRawDataCoreService { @@ -49,8 +47,6 @@ class AppleHealthRawDataCoreService {
49 _uploadResultsAfterCalculation = uploadResultsAfterCalculation, 47 _uploadResultsAfterCalculation = uploadResultsAfterCalculation,
50 _localNotificationDispatcher = localNotificationDispatcher ?? 48 _localNotificationDispatcher = localNotificationDispatcher ??
51 HealthRawLocalNotificationDispatcher(), 49 HealthRawLocalNotificationDispatcher(),
52 - _localNotificationDebugStore = localNotificationDebugStore ??  
53 - HealthRawLocalNotificationDebugStore(),  
54 _serverHealthApi = serverHealthApi; 50 _serverHealthApi = serverHealthApi;
55 51
56 final HealthKitHostApi _healthApi; 52 final HealthKitHostApi _healthApi;
@@ -60,7 +56,6 @@ class AppleHealthRawDataCoreService { @@ -60,7 +56,6 @@ class AppleHealthRawDataCoreService {
60 final int Function()? _userIdProvider; 56 final int Function()? _userIdProvider;
61 final bool _uploadResultsAfterCalculation; 57 final bool _uploadResultsAfterCalculation;
62 final HealthRawLocalNotificationDispatcher _localNotificationDispatcher; 58 final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
63 - final HealthRawLocalNotificationDebugStore _localNotificationDebugStore;  
64 final HealthApi? _serverHealthApi; 59 final HealthApi? _serverHealthApi;
65 final StreamController<HealthRawDataUpdatedEvent> 60 final StreamController<HealthRawDataUpdatedEvent>
66 _healthDataUpdatedController = 61 _healthDataUpdatedController =
@@ -219,7 +214,7 @@ class AppleHealthRawDataCoreService { @@ -219,7 +214,7 @@ class AppleHealthRawDataCoreService {
219 } 214 }
220 215
221 Future<String> readLocalNotificationDebugLogText() { 216 Future<String> readLocalNotificationDebugLogText() {
222 - return _localNotificationDebugStore.readText(); 217 + return Future.value('');
223 } 218 }
224 219
225 Future<bool> shareLocalNotificationDebugRecord() async { 220 Future<bool> shareLocalNotificationDebugRecord() async {
@@ -537,6 +532,12 @@ class AppleHealthRawDataCoreService { @@ -537,6 +532,12 @@ class AppleHealthRawDataCoreService {
537 ); 532 );
538 var notificationResult = candidateResult; 533 var notificationResult = candidateResult;
539 var realtimeWindow = const <HealthRawRealtimeStressPoint>[]; 534 var realtimeWindow = const <HealthRawRealtimeStressPoint>[];
  535 + final lastRealtimeStressPushSendTime =
  536 + await _localStore.latestRealtimeStressPushSendTime(
  537 + candidateResult.userId,
  538 + );
  539 + final notificationBuildTime = _unixSecondsNow();
  540 + var realtimeSleepIntervals = const <HealthRawSleepInterval>[];
540 if (latestRealtimeStressPoint != null) { 541 if (latestRealtimeStressPoint != null) {
541 final shouldResetRealtimeStressPushTime = 542 final shouldResetRealtimeStressPushTime =
542 latestRealtimeStressPoint.isWorkout || 543 latestRealtimeStressPoint.isWorkout ||
@@ -551,10 +552,6 @@ class AppleHealthRawDataCoreService { @@ -551,10 +552,6 @@ class AppleHealthRawDataCoreService {
551 _realtimeStressPointPayload(latestRealtimeStressPoint), 552 _realtimeStressPointPayload(latestRealtimeStressPoint),
552 }, 553 },
553 ); 554 );
554 - await _recordRealtimeStressTimeSafely(  
555 - userId: candidateResult.userId,  
556 - recordTime: latestRealtimeStressPoint.rawEndTime,  
557 - );  
558 notificationResult = candidateResult.copyWith( 555 notificationResult = candidateResult.copyWith(
559 realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], 556 realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
560 ); 557 );
@@ -563,6 +560,11 @@ class AppleHealthRawDataCoreService { @@ -563,6 +560,11 @@ class AppleHealthRawDataCoreService {
563 userId: candidateResult.userId, 560 userId: candidateResult.userId,
564 latestRawEndTime: latestRealtimeStressPoint.rawEndTime, 561 latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
565 ); 562 );
  563 + realtimeSleepIntervals =
  564 + await _queryRealtimeStressSleepIntervalsForNotificationSafely(
  565 + userId: candidateResult.userId,
  566 + latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
  567 + );
566 } 568 }
567 } 569 }
568 570
@@ -576,7 +578,10 @@ class AppleHealthRawDataCoreService { @@ -576,7 +578,10 @@ class AppleHealthRawDataCoreService {
576 hasExistingHrv: candidateData.hasExistingHrvDataTime, 578 hasExistingHrv: candidateData.hasExistingHrvDataTime,
577 hasExistingSleep: candidateData.hasExistingSleepDataTime, 579 hasExistingSleep: candidateData.hasExistingSleepDataTime,
578 realtimeWindow: realtimeWindow, 580 realtimeWindow: realtimeWindow,
  581 + realtimeSleepIntervals: realtimeSleepIntervals,
579 record: record, 582 record: record,
  583 + lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime,
  584 + notificationBuildTime: notificationBuildTime,
580 ), 585 ),
581 }, 586 },
582 ); 587 );
@@ -585,7 +590,10 @@ class AppleHealthRawDataCoreService { @@ -585,7 +590,10 @@ class AppleHealthRawDataCoreService {
585 hasExistingHrv: candidateData.hasExistingHrvDataTime, 590 hasExistingHrv: candidateData.hasExistingHrvDataTime,
586 hasExistingSleep: candidateData.hasExistingSleepDataTime, 591 hasExistingSleep: candidateData.hasExistingSleepDataTime,
587 realtimeWindow: realtimeWindow, 592 realtimeWindow: realtimeWindow,
  593 + realtimeSleepIntervals: realtimeSleepIntervals,
588 record: record, 594 record: record,
  595 + lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime,
  596 + notificationBuildTime: notificationBuildTime,
589 ); 597 );
590 await _saveLocalNotificationDebugEvent( 598 await _saveLocalNotificationDebugEvent(
591 <String, Object?>{ 599 <String, Object?>{
@@ -634,6 +642,10 @@ class AppleHealthRawDataCoreService { @@ -634,6 +642,10 @@ class AppleHealthRawDataCoreService {
634 ); 642 );
635 if (sent) { 643 if (sent) {
636 sentNotifications.add(notification); 644 sentNotifications.add(notification);
  645 + await _recordLocalNotificationPushSendTimeSafely(
  646 + userId: candidateResult.userId,
  647 + notification: notification,
  648 + );
637 } 649 }
638 } 650 }
639 _scheduleRealtimeStressServerPush(sentNotifications); 651 _scheduleRealtimeStressServerPush(sentNotifications);
@@ -759,17 +771,20 @@ class AppleHealthRawDataCoreService { @@ -759,17 +771,20 @@ class AppleHealthRawDataCoreService {
759 } 771 }
760 } 772 }
761 773
762 - Future<void> _recordRealtimeStressTimeSafely({ 774 + Future<void> _recordLocalNotificationPushSendTimeSafely({
763 required int userId, 775 required int userId,
764 - required int recordTime, 776 + required HealthRawLocalNotification notification,
765 }) async { 777 }) async {
766 try { 778 try {
767 - await _localNotificationDispatcher.recordRealtimeStressTime( 779 + await _localStore.markLocalNotificationPushed(
768 userId: userId, 780 userId: userId,
769 - recordTime: recordTime, 781 + recordType: notification.recordType,
  782 + recordTime: notification.recordTime,
  783 + pushSendTime: _unixSecondsNow(),
770 ); 784 );
771 } catch (error, stackTrace) { 785 } catch (error, stackTrace) {
772 - _logError('record realtime stress push time failed', error, stackTrace); 786 + _logError(
  787 + 'record local notification push send time failed', error, stackTrace);
773 } 788 }
774 } 789 }
775 790
@@ -815,6 +830,73 @@ class AppleHealthRawDataCoreService { @@ -815,6 +830,73 @@ class AppleHealthRawDataCoreService {
815 } 830 }
816 } 831 }
817 832
  833 + Future<List<HealthRawSleepInterval>>
  834 + _queryRealtimeStressSleepIntervalsForNotificationSafely({
  835 + required int userId,
  836 + required int latestRawEndTime,
  837 + }) async {
  838 + try {
  839 + final intervals = await _queryRealtimeStressSleepIntervalsForNotification(
  840 + userId: userId,
  841 + latestRawEndTime: latestRawEndTime,
  842 + );
  843 + await _saveLocalNotificationDebugEvent(
  844 + <String, Object?>{
  845 + 'event': 'flutterRealtimeStressSleepIntervalsQueried',
  846 + 'user_id': userId,
  847 + 'latest_raw_end_time': latestRawEndTime,
  848 + 'sleep_intervals': intervals.map(_sleepIntervalPayload).toList(),
  849 + },
  850 + );
  851 + return intervals;
  852 + } catch (error, stackTrace) {
  853 + _logError(
  854 + 'query realtime stress sleep intervals failed',
  855 + error,
  856 + stackTrace,
  857 + );
  858 + final fallback = [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)];
  859 + await _saveLocalNotificationDebugEvent(
  860 + <String, Object?>{
  861 + 'event': 'flutterRealtimeStressSleepIntervalsQueryFailed',
  862 + 'user_id': userId,
  863 + 'latest_raw_end_time': latestRawEndTime,
  864 + 'sleep_intervals': fallback.map(_sleepIntervalPayload).toList(),
  865 + 'error': error.toString(),
  866 + 'stack_trace': stackTrace.toString(),
  867 + },
  868 + );
  869 + return fallback;
  870 + }
  871 + }
  872 +
  873 + Future<List<HealthRawSleepInterval>>
  874 + _queryRealtimeStressSleepIntervalsForNotification({
  875 + required int userId,
  876 + required int latestRawEndTime,
  877 + }) async {
  878 + final dayRange = _dayRangeFromUnixSeconds(latestRawEndTime);
  879 + final sleepResults = await _localStore.querySleepResults(
  880 + userId: userId,
  881 + startTime: dayRange.$1,
  882 + endTime: dayRange.$2,
  883 + );
  884 + final intervals = sleepResults
  885 + .where((result) => result.startDate <= result.date)
  886 + .map(
  887 + (result) => (
  888 + startTime: result.startDate,
  889 + endTime: result.date,
  890 + ),
  891 + )
  892 + .toList()
  893 + ..sort((a, b) => a.startTime.compareTo(b.startTime));
  894 + if (intervals.isNotEmpty) {
  895 + return intervals;
  896 + }
  897 + return [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)];
  898 + }
  899 +
818 Future<bool> _sendLocalNotificationSafely({ 900 Future<bool> _sendLocalNotificationSafely({
819 required int userId, 901 required int userId,
820 required HealthRawLocalNotification notification, 902 required HealthRawLocalNotification notification,
@@ -844,7 +926,10 @@ class AppleHealthRawDataCoreService { @@ -844,7 +926,10 @@ class AppleHealthRawDataCoreService {
844 required bool hasExistingHrv, 926 required bool hasExistingHrv,
845 required bool hasExistingSleep, 927 required bool hasExistingSleep,
846 required List<HealthRawRealtimeStressPoint> realtimeWindow, 928 required List<HealthRawRealtimeStressPoint> realtimeWindow,
  929 + required List<HealthRawSleepInterval> realtimeSleepIntervals,
847 required HealthRawLocalNotificationRecord record, 930 required HealthRawLocalNotificationRecord record,
  931 + required int? lastRealtimeStressPushSendTime,
  932 + required int notificationBuildTime,
848 }) { 933 }) {
849 return <Map<String, Object?>>[ 934 return <Map<String, Object?>>[
850 _sleepNotificationDecisionPayload( 935 _sleepNotificationDecisionPayload(
@@ -859,7 +944,10 @@ class AppleHealthRawDataCoreService { @@ -859,7 +944,10 @@ class AppleHealthRawDataCoreService {
859 _realtimeStressNotificationDecisionPayload( 944 _realtimeStressNotificationDecisionPayload(
860 result.realtimeStressPoints, 945 result.realtimeStressPoints,
861 realtimeWindow: realtimeWindow, 946 realtimeWindow: realtimeWindow,
  947 + realtimeSleepIntervals: realtimeSleepIntervals,
862 record: record, 948 record: record,
  949 + lastPushSendTime: lastRealtimeStressPushSendTime,
  950 + notificationBuildTime: notificationBuildTime,
863 ), 951 ),
864 ]; 952 ];
865 } 953 }
@@ -937,20 +1025,23 @@ class AppleHealthRawDataCoreService { @@ -937,20 +1025,23 @@ class AppleHealthRawDataCoreService {
937 } 1025 }
938 final sorted = [...hrvPoints] 1026 final sorted = [...hrvPoints]
939 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 1027 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
940 - final latest = sorted.last;  
941 return <String, Object?>{ 1028 return <String, Object?>{
942 'type': HealthRawLocalNotificationRecordType.hrv.name, 1029 'type': HealthRawLocalNotificationRecordType.hrv.name,
943 'will_build': true, 1030 'will_build': true,
944 'reason': 'candidate', 1031 'reason': 'candidate',
945 'last_hrv_time': record.lastHrvTime, 1032 'last_hrv_time': record.lastHrvTime,
946 - 'latest_hrv': _hrvStressPointPayload(latest), 1033 + 'candidate_count': sorted.length,
  1034 + 'latest_hrv': _hrvStressPointPayload(sorted.last),
947 }; 1035 };
948 } 1036 }
949 1037
950 Map<String, Object?> _realtimeStressNotificationDecisionPayload( 1038 Map<String, Object?> _realtimeStressNotificationDecisionPayload(
951 List<HealthRawRealtimeStressPoint> realtimePoints, { 1039 List<HealthRawRealtimeStressPoint> realtimePoints, {
952 required List<HealthRawRealtimeStressPoint> realtimeWindow, 1040 required List<HealthRawRealtimeStressPoint> realtimeWindow,
  1041 + required List<HealthRawSleepInterval> realtimeSleepIntervals,
953 required HealthRawLocalNotificationRecord record, 1042 required HealthRawLocalNotificationRecord record,
  1043 + required int? lastPushSendTime,
  1044 + required int notificationBuildTime,
954 }) { 1045 }) {
955 if (realtimePoints.isEmpty) { 1046 if (realtimePoints.isEmpty) {
956 return <String, Object?>{ 1047 return <String, Object?>{
@@ -970,6 +1061,19 @@ class AppleHealthRawDataCoreService { @@ -970,6 +1061,19 @@ class AppleHealthRawDataCoreService {
970 'latest_realtime': _realtimeStressPointPayload(latest), 1061 'latest_realtime': _realtimeStressPointPayload(latest),
971 }; 1062 };
972 } 1063 }
  1064 + final matchedSleepInterval =
  1065 + _sleepIntervalContaining(latest.rawEndTime, realtimeSleepIntervals);
  1066 + if (matchedSleepInterval != null) {
  1067 + return <String, Object?>{
  1068 + 'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
  1069 + 'will_build': false,
  1070 + 'reason': 'sleep_interval',
  1071 + 'matched_sleep_interval': _sleepIntervalPayload(matchedSleepInterval),
  1072 + 'sleep_intervals':
  1073 + realtimeSleepIntervals.map(_sleepIntervalPayload).toList(),
  1074 + 'latest_realtime': _realtimeStressPointPayload(latest),
  1075 + };
  1076 + }
973 final valid = realtimeWindow 1077 final valid = realtimeWindow
974 .where((e) => e.result >= 1 && e.result <= 100) 1078 .where((e) => e.result >= 1 && e.result <= 100)
975 .toList() 1079 .toList()
@@ -991,14 +1095,15 @@ class AppleHealthRawDataCoreService { @@ -991,14 +1095,15 @@ class AppleHealthRawDataCoreService {
991 'latest_realtime': _realtimeStressPointPayload(latest), 1095 'latest_realtime': _realtimeStressPointPayload(latest),
992 }; 1096 };
993 } 1097 }
994 - final lastPushTime = record.lastRealtimeStressTime; 1098 + final lastPushTime = lastPushSendTime;
995 if (lastPushTime != null && 1099 if (lastPushTime != null &&
996 - latest.rawEndTime - lastPushTime < Duration.secondsPerHour) { 1100 + notificationBuildTime - lastPushTime < Duration.secondsPerHour) {
997 return <String, Object?>{ 1101 return <String, Object?>{
998 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, 1102 'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
999 'will_build': false, 1103 'will_build': false,
1000 'reason': 'within_realtime_interval', 1104 'reason': 'within_realtime_interval',
1001 - 'last_realtime_stress_time': lastPushTime, 1105 + 'last_realtime_stress_push_send_time': lastPushTime,
  1106 + 'notification_build_time': notificationBuildTime,
1002 'latest_realtime': _realtimeStressPointPayload(latest), 1107 'latest_realtime': _realtimeStressPointPayload(latest),
1003 }; 1108 };
1004 } 1109 }
@@ -1006,12 +1111,36 @@ class AppleHealthRawDataCoreService { @@ -1006,12 +1111,36 @@ class AppleHealthRawDataCoreService {
1006 'type': HealthRawLocalNotificationRecordType.realtimeStress.name, 1111 'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
1007 'will_build': true, 1112 'will_build': true,
1008 'reason': 'candidate', 1113 'reason': 'candidate',
1009 - 'last_realtime_stress_time': lastPushTime, 1114 + 'last_realtime_stress_push_send_time': lastPushTime,
  1115 + 'notification_build_time': notificationBuildTime,
1010 'valid_window_count': valid.length, 1116 'valid_window_count': valid.length,
  1117 + 'sleep_intervals':
  1118 + realtimeSleepIntervals.map(_sleepIntervalPayload).toList(),
1011 'latest_realtime': _realtimeStressPointPayload(latest), 1119 'latest_realtime': _realtimeStressPointPayload(latest),
1012 }; 1120 };
1013 } 1121 }
1014 1122
  1123 + HealthRawSleepInterval? _sleepIntervalContaining(
  1124 + int time,
  1125 + List<HealthRawSleepInterval> intervals,
  1126 + ) {
  1127 + for (final interval in intervals) {
  1128 + if (time >= interval.startTime && time <= interval.endTime) {
  1129 + return interval;
  1130 + }
  1131 + }
  1132 + return null;
  1133 + }
  1134 +
  1135 + Map<String, Object?> _sleepIntervalPayload(
  1136 + HealthRawSleepInterval interval,
  1137 + ) {
  1138 + return <String, Object?>{
  1139 + 'start_time': interval.startTime,
  1140 + 'end_time': interval.endTime,
  1141 + };
  1142 + }
  1143 +
1015 Map<String, Object?> _localNotificationPayload( 1144 Map<String, Object?> _localNotificationPayload(
1016 HealthRawLocalNotification notification, 1145 HealthRawLocalNotification notification,
1017 ) { 1146 ) {
@@ -1802,6 +1931,10 @@ class AppleHealthRawDataCoreService { @@ -1802,6 +1931,10 @@ class AppleHealthRawDataCoreService {
1802 return date.year * 10000 + date.month * 100 + date.day; 1931 return date.year * 10000 + date.month * 100 + date.day;
1803 } 1932 }
1804 1933
  1934 + static (int startTime, int endTime) _dayRangeFromUnixSeconds(int seconds) {
  1935 + return _dayRangeFromDateKey(_dateKeyFromUnixSeconds(seconds));
  1936 + }
  1937 +
1805 static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) { 1938 static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) {
1806 final year = dateKey ~/ 10000; 1939 final year = dateKey ~/ 10000;
1807 final month = (dateKey ~/ 100) % 100; 1940 final month = (dateKey ~/ 100) % 100;
@@ -1813,6 +1946,16 @@ class AppleHealthRawDataCoreService { @@ -1813,6 +1946,16 @@ class AppleHealthRawDataCoreService {
1813 ); 1946 );
1814 } 1947 }
1815 1948
  1949 + static HealthRawSleepInterval _defaultSleepIntervalForUnixSeconds(
  1950 + int seconds,
  1951 + ) {
  1952 + final dayRange = _dayRangeFromUnixSeconds(seconds);
  1953 + return (
  1954 + startTime: dayRange.$1,
  1955 + endTime: dayRange.$1 + 6 * 60 * 60,
  1956 + );
  1957 + }
  1958 +
1816 static String _formatDateTimeMilliseconds(DateTime dateTime) { 1959 static String _formatDateTimeMilliseconds(DateTime dateTime) {
1817 String two(int value) => value.toString().padLeft(2, '0'); 1960 String two(int value) => value.toString().padLeft(2, '0');
1818 String three(int value) => value.toString().padLeft(3, '0'); 1961 String three(int value) => value.toString().padLeft(3, '0');
@@ -1911,6 +2054,8 @@ class _HealthRawLocalNotificationCandidateData { @@ -1911,6 +2054,8 @@ class _HealthRawLocalNotificationCandidateData {
1911 2054
1912 double _integerDouble(num value) => value.toInt().toDouble(); 2055 double _integerDouble(num value) => value.toInt().toDouble();
1913 2056
  2057 +int _unixSecondsNow() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
  2058 +
1914 class HealthRawStressLocalStore { 2059 class HealthRawStressLocalStore {
1915 HealthRawStressLocalStore({ 2060 HealthRawStressLocalStore({
1916 Directory? rootDirectory, 2061 Directory? rootDirectory,
@@ -1932,19 +2077,20 @@ class HealthRawStressLocalStore { @@ -1932,19 +2077,20 @@ class HealthRawStressLocalStore {
1932 2077
1933 Future<void> upsertResult(HealthRawStressCalculationResult result) async { 2078 Future<void> upsertResult(HealthRawStressCalculationResult result) async {
1934 final db = await _database(result.userId); 2079 final db = await _database(result.userId);
  2080 + final updateTime = _currentUnixSeconds();
1935 await db.transaction((txn) async { 2081 await db.transaction((txn) async {
1936 for (final point in result.hrvStressPoints) { 2082 for (final point in result.hrvStressPoints) {
1937 await _upsertResettingUploaded( 2083 await _upsertResettingUploaded(
1938 txn, 2084 txn,
1939 hrvResultsTable, 2085 hrvResultsTable,
1940 - _hrvRow(point), 2086 + _hrvRow(point, updateTime),
1941 ); 2087 );
1942 } 2088 }
1943 for (final point in result.realtimeStressPoints) { 2089 for (final point in result.realtimeStressPoints) {
1944 await _upsertResettingUploaded( 2090 await _upsertResettingUploaded(
1945 txn, 2091 txn,
1946 realtimeStressResultsTable, 2092 realtimeStressResultsTable,
1947 - _realtimeRow(point), 2093 + _realtimeRow(point, updateTime),
1948 ); 2094 );
1949 } 2095 }
1950 }); 2096 });
@@ -1955,9 +2101,13 @@ class HealthRawStressLocalStore { @@ -1955,9 +2101,13 @@ class HealthRawStressLocalStore {
1955 required Iterable<HealthRawDailyStressPoint> points, 2101 required Iterable<HealthRawDailyStressPoint> points,
1956 }) async { 2102 }) async {
1957 final db = await _database(userId); 2103 final db = await _database(userId);
  2104 + final updateTime = _currentUnixSeconds();
1958 await db.transaction((txn) async { 2105 await db.transaction((txn) async {
1959 for (final point in points) { 2106 for (final point in points) {
1960 - await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point)); 2107 + await _upsertDailyStressResettingUploaded(
  2108 + txn,
  2109 + _dailyStressRow(point, updateTime),
  2110 + );
1961 } 2111 }
1962 }); 2112 });
1963 } 2113 }
@@ -1967,9 +2117,13 @@ class HealthRawStressLocalStore { @@ -1967,9 +2117,13 @@ class HealthRawStressLocalStore {
1967 required Iterable<HealthRawSleepResult> results, 2117 required Iterable<HealthRawSleepResult> results,
1968 }) async { 2118 }) async {
1969 final db = await _database(userId); 2119 final db = await _database(userId);
  2120 + final updateTime = _currentUnixSeconds();
1970 await db.transaction((txn) async { 2121 await db.transaction((txn) async {
1971 for (final result in results) { 2122 for (final result in results) {
1972 - await _upsertSleepResultResettingUploaded(txn, _sleepRow(result)); 2123 + await _upsertSleepResultResettingUploaded(
  2124 + txn,
  2125 + _sleepRow(result, updateTime),
  2126 + );
1973 } 2127 }
1974 }); 2128 });
1975 } 2129 }
@@ -2145,10 +2299,43 @@ class HealthRawStressLocalStore { @@ -2145,10 +2299,43 @@ class HealthRawStressLocalStore {
2145 return _latestRawEndTime(userId, realtimeStressResultsTable); 2299 return _latestRawEndTime(userId, realtimeStressResultsTable);
2146 } 2300 }
2147 2301
  2302 + Future<int?> latestRealtimeStressPushSendTime(int userId) {
  2303 + return _latestPushSendTime(userId, realtimeStressResultsTable);
  2304 + }
  2305 +
2148 Future<int?> earliestRealtimeRawEndTime(int userId) { 2306 Future<int?> earliestRealtimeRawEndTime(int userId) {
2149 return _earliestRawEndTime(userId, realtimeStressResultsTable); 2307 return _earliestRawEndTime(userId, realtimeStressResultsTable);
2150 } 2308 }
2151 2309
  2310 + Future<void> markLocalNotificationPushed({
  2311 + required int userId,
  2312 + required HealthRawLocalNotificationRecordType recordType,
  2313 + required int recordTime,
  2314 + required int pushSendTime,
  2315 + }) async {
  2316 + final (table, timeColumn) = switch (recordType) {
  2317 + HealthRawLocalNotificationRecordType.hrv => (
  2318 + hrvResultsTable,
  2319 + 'raw_end_time',
  2320 + ),
  2321 + HealthRawLocalNotificationRecordType.realtimeStress => (
  2322 + realtimeStressResultsTable,
  2323 + 'raw_end_time',
  2324 + ),
  2325 + HealthRawLocalNotificationRecordType.sleep => (
  2326 + sleepResultsTable,
  2327 + 'date',
  2328 + ),
  2329 + };
  2330 + final db = await _database(userId);
  2331 + await db.update(
  2332 + table,
  2333 + <String, Object?>{'push_send_time': pushSendTime},
  2334 + where: '$timeColumn = ?',
  2335 + whereArgs: [recordTime],
  2336 + );
  2337 + }
  2338 +
2152 Future<int?> latestSleepResultTime(int userId) async { 2339 Future<int?> latestSleepResultTime(int userId) async {
2153 final db = await _database(userId); 2340 final db = await _database(userId);
2154 final rows = await db.query( 2341 final rows = await db.query(
@@ -2288,7 +2475,7 @@ class HealthRawStressLocalStore { @@ -2288,7 +2475,7 @@ class HealthRawStressLocalStore {
2288 final db = await factory.openDatabase( 2475 final db = await factory.openDatabase(
2289 path, 2476 path,
2290 options: OpenDatabaseOptions( 2477 options: OpenDatabaseOptions(
2291 - version: 8, 2478 + version: 10,
2292 onCreate: (db, version) async { 2479 onCreate: (db, version) async {
2293 await _createTables(db); 2480 await _createTables(db);
2294 }, 2481 },
@@ -2315,6 +2502,12 @@ class HealthRawStressLocalStore { @@ -2315,6 +2502,12 @@ class HealthRawStressLocalStore {
2315 if (oldVersion < 8) { 2502 if (oldVersion < 8) {
2316 await _addUpdateTimeColumns(db); 2503 await _addUpdateTimeColumns(db);
2317 } 2504 }
  2505 + if (oldVersion < 9) {
  2506 + await _addPushSendTimeColumns(db);
  2507 + }
  2508 + if (oldVersion < 10) {
  2509 + await _addUploadTimeColumns(db);
  2510 + }
2318 }, 2511 },
2319 ), 2512 ),
2320 ); 2513 );
@@ -2341,7 +2534,9 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable ( @@ -2341,7 +2534,9 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable (
2341 is_sleep_likely INTEGER NOT NULL DEFAULT 0, 2534 is_sleep_likely INTEGER NOT NULL DEFAULT 0,
2342 is_suspected_activity INTEGER NOT NULL DEFAULT 0, 2535 is_suspected_activity INTEGER NOT NULL DEFAULT 0,
2343 uploaded INTEGER NOT NULL DEFAULT 0, 2536 uploaded INTEGER NOT NULL DEFAULT 0,
2344 - update_time INTEGER NOT NULL DEFAULT 0 2537 + update_time INTEGER NOT NULL DEFAULT 0,
  2538 + push_send_time INTEGER,
  2539 + upload_time INTEGER
2345 ) 2540 )
2346 '''); 2541 ''');
2347 await db.execute(''' 2542 await db.execute('''
@@ -2357,7 +2552,9 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -2357,7 +2552,9 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
2357 is_sleep_likely INTEGER NOT NULL DEFAULT 0, 2552 is_sleep_likely INTEGER NOT NULL DEFAULT 0,
2358 is_suspected_activity INTEGER NOT NULL DEFAULT 0, 2553 is_suspected_activity INTEGER NOT NULL DEFAULT 0,
2359 uploaded INTEGER NOT NULL DEFAULT 0, 2554 uploaded INTEGER NOT NULL DEFAULT 0,
2360 - update_time INTEGER NOT NULL DEFAULT 0 2555 + update_time INTEGER NOT NULL DEFAULT 0,
  2556 + push_send_time INTEGER,
  2557 + upload_time INTEGER
2361 ) 2558 )
2362 '''); 2559 ''');
2363 await _createDailyStressTable(db); 2560 await _createDailyStressTable(db);
@@ -2374,7 +2571,8 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( @@ -2374,7 +2571,8 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
2374 state INTEGER NOT NULL, 2571 state INTEGER NOT NULL,
2375 data_time INTEGER NOT NULL, 2572 data_time INTEGER NOT NULL,
2376 uploaded INTEGER NOT NULL DEFAULT 0, 2573 uploaded INTEGER NOT NULL DEFAULT 0,
2377 - update_time INTEGER NOT NULL DEFAULT 0 2574 + update_time INTEGER NOT NULL DEFAULT 0,
  2575 + upload_time INTEGER
2378 ) 2576 )
2379 '''); 2577 ''');
2380 } 2578 }
@@ -2391,7 +2589,9 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2391,7 +2589,9 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2391 awak_minutes INTEGER NOT NULL, 2589 awak_minutes INTEGER NOT NULL,
2392 sleep_minutes INTEGER NOT NULL, 2590 sleep_minutes INTEGER NOT NULL,
2393 uploaded INTEGER NOT NULL DEFAULT 0, 2591 uploaded INTEGER NOT NULL DEFAULT 0,
2394 - update_time INTEGER NOT NULL DEFAULT 0 2592 + update_time INTEGER NOT NULL DEFAULT 0,
  2593 + push_send_time INTEGER,
  2594 + upload_time INTEGER
2395 ) 2595 )
2396 '''); 2596 ''');
2397 } 2597 }
@@ -2413,6 +2613,37 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2413,6 +2613,37 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2413 } 2613 }
2414 } 2614 }
2415 2615
  2616 + Future<void> _addPushSendTimeColumns(DatabaseExecutor db) async {
  2617 + for (final table in const [
  2618 + hrvResultsTable,
  2619 + realtimeStressResultsTable,
  2620 + sleepResultsTable,
  2621 + ]) {
  2622 + try {
  2623 + await db.execute(
  2624 + 'ALTER TABLE $table ADD COLUMN push_send_time INTEGER',
  2625 + );
  2626 + } on DatabaseException catch (error) {
  2627 + if (!error.isDuplicateColumnError()) rethrow;
  2628 + }
  2629 + }
  2630 + }
  2631 +
  2632 + Future<void> _addUploadTimeColumns(DatabaseExecutor db) async {
  2633 + for (final table in const [
  2634 + hrvResultsTable,
  2635 + realtimeStressResultsTable,
  2636 + dailyStressResultsTable,
  2637 + sleepResultsTable,
  2638 + ]) {
  2639 + try {
  2640 + await db.execute('ALTER TABLE $table ADD COLUMN upload_time INTEGER');
  2641 + } on DatabaseException catch (error) {
  2642 + if (!error.isDuplicateColumnError()) rethrow;
  2643 + }
  2644 + }
  2645 + }
  2646 +
2416 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { 2647 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
2417 for (final column in const [ 2648 for (final column in const [
2418 'is_workout', 2649 'is_workout',
@@ -2496,6 +2727,19 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2496,6 +2727,19 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2496 return rows.first['raw_end_time'] as int; 2727 return rows.first['raw_end_time'] as int;
2497 } 2728 }
2498 2729
  2730 + Future<int?> _latestPushSendTime(int userId, String table) async {
  2731 + final db = await _database(userId);
  2732 + final rows = await db.query(
  2733 + table,
  2734 + columns: ['push_send_time'],
  2735 + where: 'push_send_time IS NOT NULL',
  2736 + orderBy: 'push_send_time DESC',
  2737 + limit: 1,
  2738 + );
  2739 + if (rows.isEmpty) return null;
  2740 + return rows.first['push_send_time'] as int?;
  2741 + }
  2742 +
2499 Future<int?> _earliestRawEndTime(int userId, String table) async { 2743 Future<int?> _earliestRawEndTime(int userId, String table) async {
2500 final db = await _database(userId); 2744 final db = await _database(userId);
2501 final rows = await db.query( 2745 final rows = await db.query(
@@ -2516,9 +2760,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2516,9 +2760,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2516 final times = rawEndTimes.toList(); 2760 final times = rawEndTimes.toList();
2517 if (times.isEmpty) return; 2761 if (times.isEmpty) return;
2518 final db = await _database(userId); 2762 final db = await _database(userId);
  2763 + final uploadTime = _currentUnixSeconds();
2519 await db.update( 2764 await db.update(
2520 table, 2765 table,
2521 - {'uploaded': 1}, 2766 + {'uploaded': 1, 'upload_time': uploadTime},
2522 where: 'raw_end_time IN (${List.filled(times.length, '?').join(',')})', 2767 where: 'raw_end_time IN (${List.filled(times.length, '?').join(',')})',
2523 whereArgs: times, 2768 whereArgs: times,
2524 ); 2769 );
@@ -2532,9 +2777,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2532,9 +2777,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2532 }) async { 2777 }) async {
2533 if (time <= 0) return; 2778 if (time <= 0) return;
2534 final db = await _database(userId); 2779 final db = await _database(userId);
  2780 + final uploadTime = _currentUnixSeconds();
2535 await db.update( 2781 await db.update(
2536 table, 2782 table,
2537 - {'uploaded': 1}, 2783 + {'uploaded': 1, 'upload_time': uploadTime},
2538 where: '$timeColumn <= ? AND uploaded != 1', 2784 where: '$timeColumn <= ? AND uploaded != 1',
2539 whereArgs: [time], 2785 whereArgs: [time],
2540 ); 2786 );
@@ -2559,7 +2805,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2559,7 +2805,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2559 return now.year * 10000 + now.month * 100 + now.day; 2805 return now.year * 10000 + now.month * 100 + now.day;
2560 } 2806 }
2561 2807
2562 - Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) { 2808 + Map<String, Object?> _hrvRow(
  2809 + HealthRawHrvStressPoint point,
  2810 + int updateTime,
  2811 + ) {
2563 return <String, Object?>{ 2812 return <String, Object?>{
2564 'raw_end_time': point.rawEndTime, 2813 'raw_end_time': point.rawEndTime,
2565 'user_id': point.userId, 2814 'user_id': point.userId,
@@ -2574,11 +2823,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2574,11 +2823,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2574 'baseline_resting_hr': point.baselineRestingHr, 2823 'baseline_resting_hr': point.baselineRestingHr,
2575 ..._flagsRow(point.flags), 2824 ..._flagsRow(point.flags),
2576 'uploaded': point.uploaded ? 1 : 0, 2825 'uploaded': point.uploaded ? 1 : 0,
2577 - 'update_time': _currentUnixSeconds(), 2826 + 'update_time': updateTime,
  2827 + 'upload_time': point.uploadTime,
2578 }; 2828 };
2579 } 2829 }
2580 2830
2581 - Map<String, Object?> _realtimeRow(HealthRawRealtimeStressPoint point) { 2831 + Map<String, Object?> _realtimeRow(
  2832 + HealthRawRealtimeStressPoint point,
  2833 + int updateTime,
  2834 + ) {
2582 return <String, Object?>{ 2835 return <String, Object?>{
2583 'raw_end_time': point.rawEndTime, 2836 'raw_end_time': point.rawEndTime,
2584 'user_id': point.userId, 2837 'user_id': point.userId,
@@ -2588,11 +2841,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2588,11 +2841,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2588 'source_end_time': point.sourceEndTime, 2841 'source_end_time': point.sourceEndTime,
2589 ..._flagsRow(point.flags), 2842 ..._flagsRow(point.flags),
2590 'uploaded': point.uploaded ? 1 : 0, 2843 'uploaded': point.uploaded ? 1 : 0,
2591 - 'update_time': _currentUnixSeconds(), 2844 + 'update_time': updateTime,
  2845 + 'upload_time': point.uploadTime,
2592 }; 2846 };
2593 } 2847 }
2594 2848
2595 - Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) { 2849 + Map<String, Object?> _dailyStressRow(
  2850 + HealthRawDailyStressPoint point,
  2851 + int updateTime,
  2852 + ) {
2596 return <String, Object?>{ 2853 return <String, Object?>{
2597 'date': point.date, 2854 'date': point.date,
2598 'user_id': point.userId, 2855 'user_id': point.userId,
@@ -2601,11 +2858,12 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2601,11 +2858,12 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2601 'state': point.state.value, 2858 'state': point.state.value,
2602 'data_time': point.dataTime, 2859 'data_time': point.dataTime,
2603 'uploaded': point.uploaded ? 1 : 0, 2860 'uploaded': point.uploaded ? 1 : 0,
2604 - 'update_time': _currentUnixSeconds(), 2861 + 'update_time': updateTime,
  2862 + 'upload_time': point.uploadTime,
2605 }; 2863 };
2606 } 2864 }
2607 2865
2608 - Map<String, Object?> _sleepRow(HealthRawSleepResult result) { 2866 + Map<String, Object?> _sleepRow(HealthRawSleepResult result, int updateTime) {
2609 return <String, Object?>{ 2867 return <String, Object?>{
2610 'date': result.date, 2868 'date': result.date,
2611 'user_id': result.userId, 2869 'user_id': result.userId,
@@ -2616,7 +2874,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2616,7 +2874,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2616 'awak_minutes': result.awakMinutes, 2874 'awak_minutes': result.awakMinutes,
2617 'sleep_minutes': result.sleepMinutes, 2875 'sleep_minutes': result.sleepMinutes,
2618 'uploaded': result.uploaded ? 1 : 0, 2876 'uploaded': result.uploaded ? 1 : 0,
2619 - 'update_time': _currentUnixSeconds(), 2877 + 'update_time': updateTime,
  2878 + 'upload_time': result.uploadTime,
2620 }; 2879 };
2621 } 2880 }
2622 2881
@@ -2683,6 +2942,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2683,6 +2942,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2683 'is_sleep_likely': row['is_sleep_likely'], 2942 'is_sleep_likely': row['is_sleep_likely'],
2684 'is_suspected_activity': row['is_suspected_activity'], 2943 'is_suspected_activity': row['is_suspected_activity'],
2685 'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'], 2944 'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'],
  2945 + 'upload_time': uploadPayloadChanged ? null : existingRow['upload_time'],
2686 'update_time': row['update_time'], 2946 'update_time': row['update_time'],
2687 }, 2947 },
2688 where: 'raw_end_time = ?', 2948 where: 'raw_end_time = ?',
@@ -2697,6 +2957,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2697,6 +2957,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2697 final isToday = row['date'] == _todayDateKey(); 2957 final isToday = row['date'] == _todayDateKey();
2698 if (isToday) { 2958 if (isToday) {
2699 row['uploaded'] = 0; 2959 row['uploaded'] = 0;
  2960 + row['upload_time'] = null;
2700 } 2961 }
2701 final existing = await db.query( 2962 final existing = await db.query(
2702 dailyStressResultsTable, 2963 dailyStressResultsTable,
@@ -2723,6 +2984,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2723,6 +2984,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2723 'state': row['state'], 2984 'state': row['state'],
2724 'data_time': row['data_time'], 2985 'data_time': row['data_time'],
2725 'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']), 2986 'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']),
  2987 + 'upload_time':
  2988 + isToday || valueChanged ? null : existingRow['upload_time'],
2726 'update_time': row['update_time'], 2989 'update_time': row['update_time'],
2727 }, 2990 },
2728 where: 'date = ?', 2991 where: 'date = ?',
@@ -2769,6 +3032,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( @@ -2769,6 +3032,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
2769 'awak_minutes': row['awak_minutes'], 3032 'awak_minutes': row['awak_minutes'],
2770 'sleep_minutes': row['sleep_minutes'], 3033 'sleep_minutes': row['sleep_minutes'],
2771 'uploaded': valueChanged ? 0 : existingRow['uploaded'], 3034 'uploaded': valueChanged ? 0 : existingRow['uploaded'],
  3035 + 'upload_time': valueChanged ? null : existingRow['upload_time'],
2772 'update_time': row['update_time'], 3036 'update_time': row['update_time'],
2773 }, 3037 },
2774 where: 'date = ?', 3038 where: 'date = ?',
@@ -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,20 +51,31 @@ class HealthRawLocalNotificationBuilder { @@ -49,20 +51,31 @@ 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 }) {
  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;
53 return [ 67 return [
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,
@@ -89,33 +102,37 @@ class HealthRawLocalNotificationBuilder { @@ -89,33 +102,37 @@ class HealthRawLocalNotificationBuilder {
89 ); 102 );
90 } 103 }
91 104
92 - HealthRawLocalNotification? _hrvNotification( 105 + List<HealthRawLocalNotification> _hrvNotifications(
93 List<HealthRawHrvStressPoint> hrvPoints, 106 List<HealthRawHrvStressPoint> hrvPoints,
94 bool hasExistingHrv, 107 bool hasExistingHrv,
95 ) { 108 ) {
96 - if (!hasExistingHrv) return null;  
97 - if (hrvPoints.isEmpty) return null; 109 + if (!hasExistingHrv) return const <HealthRawLocalNotification>[];
  110 + if (hrvPoints.isEmpty) return const <HealthRawLocalNotification>[];
98 final sorted = [...hrvPoints] 111 final sorted = [...hrvPoints]
99 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 112 ..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 - ); 113 + return [
  114 + for (final point in sorted)
  115 + HealthRawLocalNotification(
  116 + title: l10n.healthLocalNotificationHrvTitle(
  117 + point.result.floor(),
  118 + _stressStateLabel(point.state),
  119 + _timeText(point.rawEndTime),
  120 + ),
  121 + content: _hrvContent(point),
  122 + link: healthRawHrvChangeLink,
  123 + recordType: HealthRawLocalNotificationRecordType.hrv,
  124 + recordTime: point.rawEndTime,
  125 + ),
  126 + ];
113 } 127 }
114 128
115 HealthRawLocalNotification? _realtimeStressNotification( 129 HealthRawLocalNotification? _realtimeStressNotification(
116 List<HealthRawRealtimeStressPoint> realtimeWindow, 130 List<HealthRawRealtimeStressPoint> realtimeWindow,
117 HealthRawLocalNotificationRecord? record, { 131 HealthRawLocalNotificationRecord? record, {
118 required HealthRawRealtimeStressPoint? latestRealtimeStressPoint, 132 required HealthRawRealtimeStressPoint? latestRealtimeStressPoint,
  133 + required List<HealthRawSleepInterval> sleepIntervals,
  134 + required int? lastPushSendTime,
  135 + required int notificationBuildTime,
119 }) { 136 }) {
120 if (latestRealtimeStressPoint == null) return null; 137 if (latestRealtimeStressPoint == null) return null;
121 if (latestRealtimeStressPoint.isWorkout || 138 if (latestRealtimeStressPoint.isWorkout ||
@@ -123,16 +140,20 @@ class HealthRawLocalNotificationBuilder { @@ -123,16 +140,20 @@ class HealthRawLocalNotificationBuilder {
123 return null; 140 return null;
124 } 141 }
125 142
  143 + final latest = latestRealtimeStressPoint;
  144 + if (_sleepIntervalContaining(latest.rawEndTime, sleepIntervals) != null) {
  145 + return null;
  146 + }
  147 +
126 final valid = realtimeWindow 148 final valid = realtimeWindow
127 .where((e) => e.result >= 1 && e.result <= 100) 149 .where((e) => e.result >= 1 && e.result <= 100)
128 .toList() 150 .toList()
129 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 151 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
130 if (valid.length < _realtimeMinPointCount) return null; 152 if (valid.length < _realtimeMinPointCount) return null;
131 153
132 - final latest = latestRealtimeStressPoint;  
133 if (latest.isSleepLikely) return null; 154 if (latest.isSleepLikely) return null;
134 - if (record?.lastRealtimeStressTime case final lastPushTime?) {  
135 - if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) { 155 + if (lastPushSendTime case final lastPushTime?) {
  156 + if (notificationBuildTime - lastPushTime < _realtimeWindowSeconds) {
136 return null; 157 return null;
137 } 158 }
138 } 159 }
@@ -162,6 +183,29 @@ class HealthRawLocalNotificationBuilder { @@ -162,6 +183,29 @@ class HealthRawLocalNotificationBuilder {
162 return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b); 183 return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b);
163 } 184 }
164 185
  186 + HealthRawSleepInterval? _sleepIntervalContaining(
  187 + int time,
  188 + List<HealthRawSleepInterval> intervals,
  189 + ) {
  190 + for (final interval in intervals) {
  191 + if (time >= interval.startTime && time <= interval.endTime) {
  192 + return interval;
  193 + }
  194 + }
  195 + return null;
  196 + }
  197 +
  198 + HealthRawSleepInterval _defaultSleepInterval(int time) {
  199 + final day = _localDayStart(time);
  200 + return (startTime: day, endTime: day + 6 * 60 * 60);
  201 + }
  202 +
  203 + int _localDayStart(int time) {
  204 + final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
  205 + return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch ~/
  206 + 1000;
  207 + }
  208 +
165 String _sleepDurationText(int minutes) { 209 String _sleepDurationText(int minutes) {
166 final hours = minutes ~/ 60; 210 final hours = minutes ~/ 60;
167 final remainingMinutes = minutes % 60; 211 final remainingMinutes = minutes % 60;
1 -import 'dart:convert';  
2 -import 'dart:io';  
3 -  
4 -import 'package:path_provider/path_provider.dart';  
5 -  
6 -class HealthRawLocalNotificationDebugStore {  
7 - HealthRawLocalNotificationDebugStore({Directory? rootDirectory})  
8 - : _rootDirectory = rootDirectory;  
9 -  
10 - static const _fileName = 'health_raw_local_notification_debug_events.json';  
11 - static const _maxEventCount = 500;  
12 -  
13 - final Directory? _rootDirectory;  
14 -  
15 - Future<void> append(Map<String, Object?> event) async {  
16 - final file = await _file();  
17 - await file.parent.create(recursive: true);  
18 - final eventsByMinute = await _readEventsByMinute(file);  
19 - final now = DateTime.now();  
20 - final minuteKey = _minuteKey(now);  
21 - final enrichedEvent = <String, Object?>{  
22 - ..._sanitizeMap(event),  
23 - 'saved_at': now.toIso8601String(),  
24 - 'saved_at_readable': _readableTime(now),  
25 - 'saved_at_minute': minuteKey,  
26 - 'saved_at_unix': now.millisecondsSinceEpoch / 1000,  
27 - };  
28 - eventsByMinute.putIfAbsent(minuteKey, () => <Map<String, Object?>>[]);  
29 - eventsByMinute[minuteKey]!.add(enrichedEvent);  
30 - final limitedEventsByMinute = _limitedEventsByMinute(eventsByMinute);  
31 - const encoder = JsonEncoder.withIndent(' ');  
32 - await file.writeAsString(  
33 - encoder.convert(limitedEventsByMinute),  
34 - flush: true,  
35 - );  
36 - }  
37 -  
38 - Future<String> readText() async {  
39 - final file = await _file();  
40 - if (!await file.exists()) {  
41 - return '暂无本地推送诊断日志';  
42 - }  
43 - final text = await file.readAsString();  
44 - if (text.trim().isEmpty) {  
45 - return '暂无本地推送诊断日志';  
46 - }  
47 - return text;  
48 - }  
49 -  
50 - Future<File> _file() async {  
51 - final rootDirectory =  
52 - _rootDirectory ?? await getApplicationDocumentsDirectory();  
53 - return File('${rootDirectory.path}/$_fileName');  
54 - }  
55 -  
56 - Future<Map<String, List<Map<String, Object?>>>> _readEventsByMinute(  
57 - File file,  
58 - ) async {  
59 - if (!await file.exists()) {  
60 - return <String, List<Map<String, Object?>>>{};  
61 - }  
62 - final text = await file.readAsString();  
63 - if (text.trim().isEmpty) {  
64 - return <String, List<Map<String, Object?>>>{};  
65 - }  
66 - final json = jsonDecode(text);  
67 - if (json is Map) {  
68 - return json.map((key, value) {  
69 - final events = value is List  
70 - ? value  
71 - .whereType<Map>()  
72 - .map((e) => _sanitizeMap(e.cast<String, Object?>()))  
73 - .toList()  
74 - : <Map<String, Object?>>[];  
75 - return MapEntry(key.toString(), events);  
76 - });  
77 - }  
78 - if (json is List) {  
79 - final events = json  
80 - .whereType<Map>()  
81 - .map((e) => _sanitizeMap(e.cast<String, Object?>()))  
82 - .toList();  
83 - final grouped = <String, List<Map<String, Object?>>>{};  
84 - for (final event in events) {  
85 - final key =  
86 - (event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'legacy')  
87 - .toString();  
88 - grouped.putIfAbsent(key, () => <Map<String, Object?>>[]);  
89 - grouped[key]!.add(event);  
90 - }  
91 - return grouped;  
92 - }  
93 - return <String, List<Map<String, Object?>>>{};  
94 - }  
95 -  
96 - Map<String, List<Map<String, Object?>>> _limitedEventsByMinute(  
97 - Map<String, List<Map<String, Object?>>> eventsByMinute,  
98 - ) {  
99 - final events = eventsByMinute.values.expand((e) => e).toList()  
100 - ..sort((a, b) {  
101 - final aTime = (a['saved_at_unix'] as num?)?.toDouble() ?? 0;  
102 - final bTime = (b['saved_at_unix'] as num?)?.toDouble() ?? 0;  
103 - return aTime.compareTo(bTime);  
104 - });  
105 - final limitedEvents = events.length > _maxEventCount  
106 - ? events.sublist(events.length - _maxEventCount)  
107 - : events;  
108 - final grouped = <String, List<Map<String, Object?>>>{};  
109 - for (final event in limitedEvents) {  
110 - final key =  
111 - (event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'unknown')  
112 - .toString();  
113 - grouped.putIfAbsent(key, () => <Map<String, Object?>>[]);  
114 - grouped[key]!.add(event);  
115 - }  
116 - return grouped;  
117 - }  
118 -  
119 - static Map<String, Object?> _sanitizeMap(Map<String, Object?> map) {  
120 - return map.map((key, value) => MapEntry(key, _sanitizeValue(value)));  
121 - }  
122 -  
123 - static Object? _sanitizeValue(Object? value) {  
124 - return switch (value) {  
125 - null => null,  
126 - String() => value,  
127 - num() => value,  
128 - bool() => value,  
129 - DateTime() => value.toIso8601String(),  
130 - List() => value.map(_sanitizeValue).toList(),  
131 - Map() => value.map(  
132 - (key, value) => MapEntry(key.toString(), _sanitizeValue(value)),  
133 - ),  
134 - _ => value.toString(),  
135 - };  
136 - }  
137 -  
138 - static String _minuteKey(DateTime time) {  
139 - return '${time.year.toString().padLeft(4, '0')}-'  
140 - '${time.month.toString().padLeft(2, '0')}-'  
141 - '${time.day.toString().padLeft(2, '0')} '  
142 - '${time.hour.toString().padLeft(2, '0')}:'  
143 - '${time.minute.toString().padLeft(2, '0')}';  
144 - }  
145 -  
146 - static String _readableTime(DateTime time) {  
147 - return '${time.year.toString().padLeft(4, '0')}-'  
148 - '${time.month.toString().padLeft(2, '0')}-'  
149 - '${time.day.toString().padLeft(2, '0')} '  
150 - '${time.hour.toString().padLeft(2, '0')}:'  
151 - '${time.minute.toString().padLeft(2, '0')}:'  
152 - '${time.second.toString().padLeft(2, '0')}';  
153 - }  
154 -}  
@@ -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(
@@ -768,7 +773,7 @@ void main() { @@ -768,7 +773,7 @@ void main() {
768 expect( 773 expect(
769 notificationDispatcher.sentNotifications.where( 774 notificationDispatcher.sentNotifications.where(
770 (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv), 775 (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),
771 - hasLength(1), 776 + hasLength(3),
772 ); 777 );
773 expect( 778 expect(
774 notificationDispatcher.sentNotifications.where( 779 notificationDispatcher.sentNotifications.where(
@@ -779,6 +784,25 @@ void main() { @@ -779,6 +784,25 @@ void main() {
779 expect(notificationDispatcher.record.latestHrvDataTime, base + 720); 784 expect(notificationDispatcher.record.latestHrvDataTime, base + 720);
780 expect(notificationDispatcher.record.latestSleepDataTime, 785 expect(notificationDispatcher.record.latestSleepDataTime,
781 LocalHealthDataConvert.unixSeconds(sleepEnd)); 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((row) => [base + 120, base + 420, base + 720]
  796 + .contains(row['raw_end_time']))
  797 + .every((row) => row['push_send_time'] != null),
  798 + isTrue,
  799 + );
  800 + expect(
  801 + rows.sleepRows.singleWhere(
  802 + (row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd),
  803 + )['push_send_time'],
  804 + isNotNull,
  805 + );
782 }); 806 });
783 807
784 test( 808 test(
@@ -887,6 +911,127 @@ void main() { @@ -887,6 +911,127 @@ void main() {
887 notificationDispatcher.record.latestRealtimeStressDataTime, 911 notificationDispatcher.record.latestRealtimeStressDataTime,
888 base + 2700, 912 base + 2700,
889 ); 913 );
  914 + final rows = await store.debugQueryAllRows(42);
  915 + expect(rows.realtimeRows.last['push_send_time'], isNotNull);
  916 + });
  917 +
  918 + test('startCoreCaculate suppresses realtime notification inside sleep result',
  919 + () async {
  920 + final now = DateTime.now();
  921 + final day = DateTime(now.year, now.month, now.day).subtract(
  922 + const Duration(days: 7),
  923 + );
  924 + final dayStart = LocalHealthDataConvert.unixSeconds(day);
  925 + final latestTime = dayStart + 5 * 3600 + 30 * 60;
  926 + final firstTime = latestTime - 9 * 300;
  927 + final api = _FakeHealthKitRawDataHostApi();
  928 + final store = _MemoryHealthRawStressLocalStore();
  929 + await store.upsertResult(
  930 + HealthRawStressCalculationResult(
  931 + userId: 42,
  932 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  933 + realtimeStressPoints: [
  934 + for (var i = 0; i < 10; i += 1)
  935 + _realtimeStressPoint(firstTime + i * 300, result: 70),
  936 + ],
  937 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  938 + ),
  939 + );
  940 + await store.upsertSleepResults(
  941 + userId: 42,
  942 + results: [
  943 + HealthRawSleepResult(
  944 + userId: 42,
  945 + date: dayStart + 6 * 3600,
  946 + startDate: dayStart + 5 * 3600,
  947 + sleepScore: 80,
  948 + sleepState: 2,
  949 + inBedMinutes: 60,
  950 + awakMinutes: 0,
  951 + sleepMinutes: 60,
  952 + ),
  953 + ],
  954 + );
  955 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  956 + ..record = HealthRawLocalNotificationRecord(
  957 + latestRealtimeStressDataTime: firstTime - 1,
  958 + latestSleepDataTime: dayStart + 6 * 3600,
  959 + );
  960 + final service = AppleHealthRawDataCoreService(
  961 + healthApi: _FakeHealthKitHostApi(),
  962 + rawDataApi: api,
  963 + localStore: store,
  964 + userIdProvider: () => 42,
  965 + uploadResultsAfterCalculation: false,
  966 + localNotificationDispatcher: notificationDispatcher,
  967 + );
  968 +
  969 + await service.startCoreCaculate(
  970 + endTime: latestTime,
  971 + readChunkDays: 1,
  972 + );
  973 +
  974 + expect(
  975 + notificationDispatcher.sentNotifications.where(
  976 + (e) =>
  977 + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
  978 + ),
  979 + isEmpty,
  980 + );
  981 + final rows = await store.debugQueryAllRows(42);
  982 + expect(rows.realtimeRows.last['push_send_time'], isNull);
  983 + });
  984 +
  985 + test(
  986 + 'startCoreCaculate uses default sleep interval for realtime notification',
  987 + () async {
  988 + final now = DateTime.now();
  989 + final day = DateTime(now.year, now.month, now.day).subtract(
  990 + const Duration(days: 7),
  991 + );
  992 + final dayStart = LocalHealthDataConvert.unixSeconds(day);
  993 + final latestTime = dayStart + 5 * 3600 + 30 * 60;
  994 + final firstTime = latestTime - 9 * 300;
  995 + final api = _FakeHealthKitRawDataHostApi();
  996 + final store = _MemoryHealthRawStressLocalStore();
  997 + await store.upsertResult(
  998 + HealthRawStressCalculationResult(
  999 + userId: 42,
  1000 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  1001 + realtimeStressPoints: [
  1002 + for (var i = 0; i < 10; i += 1)
  1003 + _realtimeStressPoint(firstTime + i * 300, result: 70),
  1004 + ],
  1005 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  1006 + ),
  1007 + );
  1008 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
  1009 + ..record = HealthRawLocalNotificationRecord(
  1010 + latestRealtimeStressDataTime: firstTime - 1,
  1011 + );
  1012 + final service = AppleHealthRawDataCoreService(
  1013 + healthApi: _FakeHealthKitHostApi(),
  1014 + rawDataApi: api,
  1015 + localStore: store,
  1016 + userIdProvider: () => 42,
  1017 + uploadResultsAfterCalculation: false,
  1018 + localNotificationDispatcher: notificationDispatcher,
  1019 + );
  1020 +
  1021 + await service.startCoreCaculate(
  1022 + endTime: latestTime,
  1023 + readChunkDays: 1,
  1024 + );
  1025 +
  1026 + expect(
  1027 + notificationDispatcher.sentNotifications.where(
  1028 + (e) =>
  1029 + e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
  1030 + ),
  1031 + isEmpty,
  1032 + );
  1033 + final rows = await store.debugQueryAllRows(42);
  1034 + expect(rows.realtimeRows.last['push_send_time'], isNull);
890 }); 1035 });
891 1036
892 test( 1037 test(
@@ -1017,6 +1162,10 @@ void main() { @@ -1017,6 +1162,10 @@ void main() {
1017 expect(rows.realtimeRows.single['uploaded'], 1); 1162 expect(rows.realtimeRows.single['uploaded'], 1);
1018 expect(rows.dailyStressRows.single['uploaded'], 1); 1163 expect(rows.dailyStressRows.single['uploaded'], 1);
1019 expect(rows.sleepRows.single['uploaded'], 1); 1164 expect(rows.sleepRows.single['uploaded'], 1);
  1165 + expect(rows.hrvRows.single['upload_time'], isNotNull);
  1166 + expect(rows.realtimeRows.single['upload_time'], isNotNull);
  1167 + expect(rows.dailyStressRows.single['upload_time'], isNotNull);
  1168 + expect(rows.sleepRows.single['upload_time'], isNotNull);
1020 }); 1169 });
1021 1170
1022 test('startCoreCaculate does not wait for pending uploads', () async { 1171 test('startCoreCaculate does not wait for pending uploads', () async {
@@ -1055,6 +1204,8 @@ void main() { @@ -1055,6 +1204,8 @@ void main() {
1055 var rows = await store.debugQueryAllRows(42); 1204 var rows = await store.debugQueryAllRows(42);
1056 expect(rows.hrvRows.single['uploaded'], 0); 1205 expect(rows.hrvRows.single['uploaded'], 0);
1057 expect(rows.realtimeRows.single['uploaded'], 0); 1206 expect(rows.realtimeRows.single['uploaded'], 0);
  1207 + expect(rows.hrvRows.single['upload_time'], isNull);
  1208 + expect(rows.realtimeRows.single['upload_time'], isNull);
1058 1209
1059 api.hrvUploadCompleter!.complete(base + 10); 1210 api.hrvUploadCompleter!.complete(base + 10);
1060 api.hrUploadCompleter!.complete(base + 20); 1211 api.hrUploadCompleter!.complete(base + 20);
@@ -1063,6 +1214,8 @@ void main() { @@ -1063,6 +1214,8 @@ void main() {
1063 1214
1064 expect(rows.hrvRows.single['uploaded'], 1); 1215 expect(rows.hrvRows.single['uploaded'], 1);
1065 expect(rows.realtimeRows.single['uploaded'], 1); 1216 expect(rows.realtimeRows.single['uploaded'], 1);
  1217 + expect(rows.hrvRows.single['upload_time'], isNotNull);
  1218 + expect(rows.realtimeRows.single['upload_time'], isNotNull);
1066 }); 1219 });
1067 1220
1068 test('local data source returns earliest local hr start time', () async { 1221 test('local data source returns earliest local hr start time', () async {
@@ -1354,8 +1507,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1354,8 +1507,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1354 final Map<int, Map<int, int>> _realtimeUpdateTimes = {}; 1507 final Map<int, Map<int, int>> _realtimeUpdateTimes = {};
1355 final Map<int, Map<int, int>> _dailyUpdateTimes = {}; 1508 final Map<int, Map<int, int>> _dailyUpdateTimes = {};
1356 final Map<int, Map<int, int>> _sleepUpdateTimes = {}; 1509 final Map<int, Map<int, int>> _sleepUpdateTimes = {};
  1510 + final Map<int, Map<int, int>> _hrvPushSendTimes = {};
  1511 + final Map<int, Map<int, int>> _realtimePushSendTimes = {};
  1512 + final Map<int, Map<int, int>> _sleepPushSendTimes = {};
  1513 + final Map<int, Map<int, int>> _hrvUploadTimes = {};
  1514 + final Map<int, Map<int, int>> _realtimeUploadTimes = {};
  1515 + final Map<int, Map<int, int>> _dailyUploadTimes = {};
  1516 + final Map<int, Map<int, int>> _sleepUploadTimes = {};
1357 final List<String> operationLog; 1517 final List<String> operationLog;
1358 var _updateTimeClock = 1; 1518 var _updateTimeClock = 1;
  1519 + var _uploadTimeClock = 1000;
1359 var realtimeNotificationWindowQueryCount = 0; 1520 var realtimeNotificationWindowQueryCount = 0;
1360 1521
1361 void insertDailyStress(HealthRawDailyStressPoint point) { 1522 void insertDailyStress(HealthRawDailyStressPoint point) {
@@ -1382,6 +1543,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1382,6 +1543,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1382 @override 1543 @override
1383 Future<void> upsertResult(HealthRawStressCalculationResult result) async { 1544 Future<void> upsertResult(HealthRawStressCalculationResult result) async {
1384 operationLog.add('upsertResult'); 1545 operationLog.add('upsertResult');
  1546 + final updateTime = _nextUpdateTime();
1385 final hrvByTime = <int, HealthRawHrvStressPoint>{ 1547 final hrvByTime = <int, HealthRawHrvStressPoint>{
1386 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) 1548 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[])
1387 point.rawEndTime: point, 1549 point.rawEndTime: point,
@@ -1403,10 +1565,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1403,10 +1565,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1403 baselineRestingHr: point.baselineRestingHr, 1565 baselineRestingHr: point.baselineRestingHr,
1404 flags: point.flags, 1566 flags: point.flags,
1405 uploaded: same ? existing.uploaded : false, 1567 uploaded: same ? existing.uploaded : false,
  1568 + pushSendTime: existing?.pushSendTime,
  1569 + uploadTime: same ? existing.uploadTime : null,
1406 ); 1570 );
1407 _hrvUpdateTimes.putIfAbsent( 1571 _hrvUpdateTimes.putIfAbsent(
1408 - point.userId, () => <int, int>{})[point.rawEndTime] =  
1409 - _nextUpdateTime(); 1572 + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
  1573 + if (!same) {
  1574 + _hrvUploadTimes[point.userId]?.remove(point.rawEndTime);
  1575 + }
1410 } 1576 }
1411 _hrv[result.userId] = hrvByTime.values.toList() 1577 _hrv[result.userId] = hrvByTime.values.toList()
1412 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 1578 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
@@ -1429,10 +1595,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1429,10 +1595,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1429 sourceEndTime: point.sourceEndTime, 1595 sourceEndTime: point.sourceEndTime,
1430 flags: point.flags, 1596 flags: point.flags,
1431 uploaded: same ? existing.uploaded : false, 1597 uploaded: same ? existing.uploaded : false,
  1598 + pushSendTime: existing?.pushSendTime,
  1599 + uploadTime: same ? existing.uploadTime : null,
1432 ); 1600 );
1433 _realtimeUpdateTimes.putIfAbsent( 1601 _realtimeUpdateTimes.putIfAbsent(
1434 - point.userId, () => <int, int>{})[point.rawEndTime] =  
1435 - _nextUpdateTime(); 1602 + point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
  1603 + if (!same) {
  1604 + _realtimeUploadTimes[point.userId]?.remove(point.rawEndTime);
  1605 + }
1436 } 1606 }
1437 _realtime[result.userId] = realtimeByTime.values.toList() 1607 _realtime[result.userId] = realtimeByTime.values.toList()
1438 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 1608 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
@@ -1444,6 +1614,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1444,6 +1614,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1444 required Iterable<HealthRawDailyStressPoint> points, 1614 required Iterable<HealthRawDailyStressPoint> points,
1445 }) async { 1615 }) async {
1446 operationLog.add('upsertDailyStressPoints'); 1616 operationLog.add('upsertDailyStressPoints');
  1617 + final updateTime = _nextUpdateTime();
1447 final byDate = <int, HealthRawDailyStressPoint>{ 1618 final byDate = <int, HealthRawDailyStressPoint>{
1448 for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) 1619 for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[])
1449 point.date: point, 1620 point.date: point,
@@ -1464,9 +1635,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1464,9 +1635,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1464 state: point.state, 1635 state: point.state,
1465 dataTime: point.dataTime, 1636 dataTime: point.dataTime,
1466 uploaded: isToday ? false : (same ? existing.uploaded : false), 1637 uploaded: isToday ? false : (same ? existing.uploaded : false),
  1638 + uploadTime: isToday || !same ? null : existing.uploadTime,
1467 ); 1639 );
1468 _dailyUpdateTimes.putIfAbsent( 1640 _dailyUpdateTimes.putIfAbsent(
1469 - point.userId, () => <int, int>{})[point.date] = _nextUpdateTime(); 1641 + point.userId, () => <int, int>{})[point.date] = updateTime;
  1642 + if (isToday || !same) {
  1643 + _dailyUploadTimes[point.userId]?.remove(point.date);
  1644 + }
1470 } 1645 }
1471 _daily[userId] = byDate.values.toList() 1646 _daily[userId] = byDate.values.toList()
1472 ..sort((a, b) => a.date.compareTo(b.date)); 1647 ..sort((a, b) => a.date.compareTo(b.date));
@@ -1477,14 +1652,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1477,14 +1652,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1477 required int userId, 1652 required int userId,
1478 required Iterable<HealthRawSleepResult> results, 1653 required Iterable<HealthRawSleepResult> results,
1479 }) async { 1654 }) async {
  1655 + final updateTime = _nextUpdateTime();
1480 final byDate = <int, HealthRawSleepResult>{ 1656 final byDate = <int, HealthRawSleepResult>{
1481 for (final result in _sleep[userId] ?? <HealthRawSleepResult>[]) 1657 for (final result in _sleep[userId] ?? <HealthRawSleepResult>[])
1482 result.date: result, 1658 result.date: result,
1483 }; 1659 };
1484 for (final result in results) { 1660 for (final result in results) {
1485 - byDate[result.date] = result; 1661 + final existing = byDate[result.date];
  1662 + final same = existing != null &&
  1663 + existing.userId == result.userId &&
  1664 + existing.startDate == result.startDate &&
  1665 + existing.sleepScore == result.sleepScore &&
  1666 + existing.sleepState == result.sleepState &&
  1667 + existing.inBedMinutes == result.inBedMinutes &&
  1668 + existing.awakMinutes == result.awakMinutes &&
  1669 + existing.sleepMinutes == result.sleepMinutes;
  1670 + byDate[result.date] = HealthRawSleepResult(
  1671 + userId: result.userId,
  1672 + date: result.date,
  1673 + startDate: result.startDate,
  1674 + sleepScore: result.sleepScore,
  1675 + sleepState: result.sleepState,
  1676 + inBedMinutes: result.inBedMinutes,
  1677 + awakMinutes: result.awakMinutes,
  1678 + sleepMinutes: result.sleepMinutes,
  1679 + uploaded: same ? existing.uploaded : result.uploaded,
  1680 + pushSendTime: existing?.pushSendTime,
  1681 + uploadTime: same ? existing.uploadTime : result.uploadTime,
  1682 + );
1486 _sleepUpdateTimes.putIfAbsent( 1683 _sleepUpdateTimes.putIfAbsent(
1487 - result.userId, () => <int, int>{})[result.date] = _nextUpdateTime(); 1684 + result.userId, () => <int, int>{})[result.date] = updateTime;
  1685 + if (!same) {
  1686 + _sleepUploadTimes[result.userId]?.remove(result.date);
  1687 + }
1488 } 1688 }
1489 _sleep[userId] = byDate.values.toList() 1689 _sleep[userId] = byDate.values.toList()
1490 ..sort((a, b) => a.date.compareTo(b.date)); 1690 ..sort((a, b) => a.date.compareTo(b.date));
@@ -1614,6 +1814,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1614,6 +1814,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1614 } 1814 }
1615 1815
1616 @override 1816 @override
  1817 + Future<int?> latestRealtimeStressPushSendTime(int userId) async {
  1818 + final times = (_realtimePushSendTimes[userId] ?? <int, int>{}).values;
  1819 + if (times.isEmpty) return null;
  1820 + return times.reduce((a, b) => a >= b ? a : b);
  1821 + }
  1822 +
  1823 + @override
  1824 + Future<void> markLocalNotificationPushed({
  1825 + required int userId,
  1826 + required HealthRawLocalNotificationRecordType recordType,
  1827 + required int recordTime,
  1828 + required int pushSendTime,
  1829 + }) async {
  1830 + switch (recordType) {
  1831 + case HealthRawLocalNotificationRecordType.hrv:
  1832 + _hrvPushSendTimes.putIfAbsent(userId, () => <int, int>{})[recordTime] =
  1833 + pushSendTime;
  1834 + _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[])
  1835 + .map((point) => point.rawEndTime == recordTime
  1836 + ? HealthRawHrvStressPoint(
  1837 + userId: point.userId,
  1838 + rawEndTime: point.rawEndTime,
  1839 + rawHrv: point.rawHrv,
  1840 + result: point.result,
  1841 + sourceStartTime: point.sourceStartTime,
  1842 + sourceEndTime: point.sourceEndTime,
  1843 + state: point.state,
  1844 + baselineHrv: point.baselineHrv,
  1845 + baselineAwakeHrv: point.baselineAwakeHrv,
  1846 + baselineSleepHrv: point.baselineSleepHrv,
  1847 + baselineRestingHr: point.baselineRestingHr,
  1848 + flags: point.flags,
  1849 + uploaded: point.uploaded,
  1850 + pushSendTime: pushSendTime,
  1851 + uploadTime: point.uploadTime,
  1852 + )
  1853 + : point)
  1854 + .toList();
  1855 + case HealthRawLocalNotificationRecordType.realtimeStress:
  1856 + _realtimePushSendTimes.putIfAbsent(
  1857 + userId, () => <int, int>{})[recordTime] = pushSendTime;
  1858 + _realtime[userId] =
  1859 + (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
  1860 + .map((point) => point.rawEndTime == recordTime
  1861 + ? HealthRawRealtimeStressPoint(
  1862 + userId: point.userId,
  1863 + rawEndTime: point.rawEndTime,
  1864 + rawHr: point.rawHr,
  1865 + result: point.result,
  1866 + sourceStartTime: point.sourceStartTime,
  1867 + sourceEndTime: point.sourceEndTime,
  1868 + flags: point.flags,
  1869 + uploaded: point.uploaded,
  1870 + pushSendTime: pushSendTime,
  1871 + uploadTime: point.uploadTime,
  1872 + )
  1873 + : point)
  1874 + .toList();
  1875 + case HealthRawLocalNotificationRecordType.sleep:
  1876 + _sleepPushSendTimes.putIfAbsent(
  1877 + userId, () => <int, int>{})[recordTime] = pushSendTime;
  1878 + _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[])
  1879 + .map((result) => result.date == recordTime
  1880 + ? HealthRawSleepResult(
  1881 + userId: result.userId,
  1882 + date: result.date,
  1883 + startDate: result.startDate,
  1884 + sleepScore: result.sleepScore,
  1885 + sleepState: result.sleepState,
  1886 + inBedMinutes: result.inBedMinutes,
  1887 + awakMinutes: result.awakMinutes,
  1888 + sleepMinutes: result.sleepMinutes,
  1889 + uploaded: result.uploaded,
  1890 + pushSendTime: pushSendTime,
  1891 + uploadTime: result.uploadTime,
  1892 + )
  1893 + : result)
  1894 + .toList();
  1895 + }
  1896 + }
  1897 +
  1898 + @override
1617 Future<int?> earliestRealtimeRawEndTime(int userId) async { 1899 Future<int?> earliestRealtimeRawEndTime(int userId) async {
1618 final points = _realtime[userId]; 1900 final points = _realtime[userId];
1619 if (points == null || points.isEmpty) return null; 1901 if (points == null || points.isEmpty) return null;
@@ -1633,8 +1915,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1633,8 +1915,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1633 required Iterable<int> rawEndTimes, 1915 required Iterable<int> rawEndTimes,
1634 }) async { 1916 }) async {
1635 final timeSet = rawEndTimes.toSet(); 1917 final timeSet = rawEndTimes.toSet();
  1918 + final uploadTime = _nextUploadTime();
1636 _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) { 1919 _hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) {
1637 if (!timeSet.contains(point.rawEndTime)) return point; 1920 if (!timeSet.contains(point.rawEndTime)) return point;
  1921 + _hrvUploadTimes.putIfAbsent(
  1922 + userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
1638 return HealthRawHrvStressPoint( 1923 return HealthRawHrvStressPoint(
1639 userId: point.userId, 1924 userId: point.userId,
1640 rawEndTime: point.rawEndTime, 1925 rawEndTime: point.rawEndTime,
@@ -1649,6 +1934,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1649,6 +1934,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1649 baselineRestingHr: point.baselineRestingHr, 1934 baselineRestingHr: point.baselineRestingHr,
1650 flags: point.flags, 1935 flags: point.flags,
1651 uploaded: true, 1936 uploaded: true,
  1937 + pushSendTime: point.pushSendTime,
  1938 + uploadTime: uploadTime,
1652 ); 1939 );
1653 }).toList(); 1940 }).toList();
1654 } 1941 }
@@ -1670,9 +1957,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1670,9 +1957,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1670 required Iterable<int> rawEndTimes, 1957 required Iterable<int> rawEndTimes,
1671 }) async { 1958 }) async {
1672 final timeSet = rawEndTimes.toSet(); 1959 final timeSet = rawEndTimes.toSet();
  1960 + final uploadTime = _nextUploadTime();
1673 _realtime[userId] = 1961 _realtime[userId] =
1674 (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) { 1962 (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) {
1675 if (!timeSet.contains(point.rawEndTime)) return point; 1963 if (!timeSet.contains(point.rawEndTime)) return point;
  1964 + _realtimeUploadTimes.putIfAbsent(
  1965 + userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
1676 return HealthRawRealtimeStressPoint( 1966 return HealthRawRealtimeStressPoint(
1677 userId: point.userId, 1967 userId: point.userId,
1678 rawEndTime: point.rawEndTime, 1968 rawEndTime: point.rawEndTime,
@@ -1682,6 +1972,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1682,6 +1972,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1682 sourceEndTime: point.sourceEndTime, 1972 sourceEndTime: point.sourceEndTime,
1683 flags: point.flags, 1973 flags: point.flags,
1684 uploaded: true, 1974 uploaded: true,
  1975 + pushSendTime: point.pushSendTime,
  1976 + uploadTime: uploadTime,
1685 ); 1977 );
1686 }).toList(); 1978 }).toList();
1687 } 1979 }
@@ -1702,9 +1994,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1702,9 +1994,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1702 required int userId, 1994 required int userId,
1703 required int date, 1995 required int date,
1704 }) async { 1996 }) async {
  1997 + final uploadTime = _nextUploadTime();
1705 _daily[userId] = 1998 _daily[userId] =
1706 (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) { 1999 (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) {
1707 if (point.date > date) return point; 2000 if (point.date > date) return point;
  2001 + _dailyUploadTimes.putIfAbsent(userId, () => <int, int>{})[point.date] =
  2002 + uploadTime;
1708 return HealthRawDailyStressPoint( 2003 return HealthRawDailyStressPoint(
1709 userId: point.userId, 2004 userId: point.userId,
1710 date: point.date, 2005 date: point.date,
@@ -1713,6 +2008,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1713,6 +2008,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1713 state: point.state, 2008 state: point.state,
1714 dataTime: point.dataTime, 2009 dataTime: point.dataTime,
1715 uploaded: true, 2010 uploaded: true,
  2011 + uploadTime: uploadTime,
1716 ); 2012 );
1717 }).toList(); 2013 }).toList();
1718 } 2014 }
@@ -1722,8 +2018,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1722,8 +2018,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1722 required int userId, 2018 required int userId,
1723 required int date, 2019 required int date,
1724 }) async { 2020 }) async {
  2021 + final uploadTime = _nextUploadTime();
1725 _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) { 2022 _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) {
1726 if (result.date > date) return result; 2023 if (result.date > date) return result;
  2024 + _sleepUploadTimes.putIfAbsent(userId, () => <int, int>{})[result.date] =
  2025 + uploadTime;
1727 return HealthRawSleepResult( 2026 return HealthRawSleepResult(
1728 userId: result.userId, 2027 userId: result.userId,
1729 date: result.date, 2028 date: result.date,
@@ -1734,6 +2033,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1734,6 +2033,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1734 awakMinutes: result.awakMinutes, 2033 awakMinutes: result.awakMinutes,
1735 sleepMinutes: result.sleepMinutes, 2034 sleepMinutes: result.sleepMinutes,
1736 uploaded: true, 2035 uploaded: true,
  2036 + pushSendTime: result.pushSendTime,
  2037 + uploadTime: uploadTime,
1737 ); 2038 );
1738 }).toList(); 2039 }).toList();
1739 } 2040 }
@@ -1813,6 +2114,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1813,6 +2114,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1813 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, 2114 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
1814 'uploaded': point.uploaded ? 1 : 0, 2115 'uploaded': point.uploaded ? 1 : 0,
1815 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, 2116 'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
  2117 + 'push_send_time': _hrvPushSendTimes[point.userId]?[point.rawEndTime],
  2118 + 'upload_time':
  2119 + _hrvUploadTimes[point.userId]?[point.rawEndTime] ?? point.uploadTime,
1816 }; 2120 };
1817 } 2121 }
1818 2122
@@ -1830,6 +2134,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1830,6 +2134,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1830 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, 2134 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
1831 'uploaded': point.uploaded ? 1 : 0, 2135 'uploaded': point.uploaded ? 1 : 0,
1832 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0, 2136 'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
  2137 + 'push_send_time': _realtimePushSendTimes[point.userId]?[point.rawEndTime],
  2138 + 'upload_time': _realtimeUploadTimes[point.userId]?[point.rawEndTime] ??
  2139 + point.uploadTime,
1833 }; 2140 };
1834 } 2141 }
1835 2142
@@ -1843,6 +2150,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1843,6 +2150,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1843 'data_time': point.dataTime, 2150 'data_time': point.dataTime,
1844 'uploaded': point.uploaded ? 1 : 0, 2151 'uploaded': point.uploaded ? 1 : 0,
1845 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0, 2152 'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0,
  2153 + 'upload_time':
  2154 + _dailyUploadTimes[point.userId]?[point.date] ?? point.uploadTime,
1846 }; 2155 };
1847 } 2156 }
1848 2157
@@ -1858,11 +2167,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -1858,11 +2167,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
1858 'sleep_minutes': result.sleepMinutes, 2167 'sleep_minutes': result.sleepMinutes,
1859 'uploaded': result.uploaded ? 1 : 0, 2168 'uploaded': result.uploaded ? 1 : 0,
1860 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0, 2169 'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0,
  2170 + 'push_send_time': _sleepPushSendTimes[result.userId]?[result.date],
  2171 + 'upload_time':
  2172 + _sleepUploadTimes[result.userId]?[result.date] ?? result.uploadTime,
1861 }; 2173 };
1862 } 2174 }
1863 2175
1864 int _nextUpdateTime() => _updateTimeClock++; 2176 int _nextUpdateTime() => _updateTimeClock++;
1865 2177
  2178 + int _nextUploadTime() => _uploadTimeClock++;
  2179 +
1866 int _todayDateKey() { 2180 int _todayDateKey() {
1867 final now = DateTime.now(); 2181 final now = DateTime.now();
1868 return now.year * 10000 + now.month * 100 + now.day; 2182 return now.year * 10000 + now.month * 100 + now.day;
@@ -153,6 +153,30 @@ void main() { @@ -153,6 +153,30 @@ void main() {
153 expect(notifications.single.recordTime, latestTime); 153 expect(notifications.single.recordTime, latestTime);
154 }); 154 });
155 155
  156 + test('builds hrv notifications for every candidate including sleep likely',
  157 + () {
  158 + final firstTime = _seconds(DateTime(2026, 1, 1, 9));
  159 + final secondTime = _seconds(DateTime(2026, 1, 1, 10));
  160 + final notifications = builder.build(
  161 + result: HealthRawStressCalculationResult(
  162 + userId: 1,
  163 + hrvStressPoints: [
  164 + _hrvPoint(secondTime, isSleepLikely: true),
  165 + _hrvPoint(firstTime),
  166 + ],
  167 + realtimeStressPoints: const [],
  168 + dailyStressPoints: const [],
  169 + ),
  170 + hasExistingHrv: true,
  171 + hasExistingSleep: false,
  172 + realtimeWindow: const [],
  173 + record: const HealthRawLocalNotificationRecord(),
  174 + );
  175 +
  176 + expect(notifications, hasLength(2));
  177 + expect(notifications.map((e) => e.recordTime), [firstTime, secondTime]);
  178 + });
  179 +
156 test('builds realtime stress notification from latest 60 minute window', () { 180 test('builds realtime stress notification from latest 60 minute window', () {
157 final base = _seconds(DateTime(2026, 1, 1, 9)); 181 final base = _seconds(DateTime(2026, 1, 1, 9));
158 final notifications = builder.build( 182 final notifications = builder.build(
@@ -208,6 +232,138 @@ void main() { @@ -208,6 +232,138 @@ void main() {
208 ); 232 );
209 }); 233 });
210 234
  235 + test('skips realtime stress notification within push send interval', () {
  236 + final base = _seconds(DateTime(2026, 1, 1, 9));
  237 + final notifications = builder.build(
  238 + result: HealthRawStressCalculationResult(
  239 + userId: 1,
  240 + hrvStressPoints: const [],
  241 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  242 + dailyStressPoints: const [],
  243 + ),
  244 + hasExistingHrv: false,
  245 + hasExistingSleep: false,
  246 + realtimeWindow: [
  247 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  248 + ],
  249 + record: const HealthRawLocalNotificationRecord(),
  250 + lastRealtimeStressPushSendTime: base + 100,
  251 + notificationBuildTime: base + 100 + 3599,
  252 + );
  253 +
  254 + expect(notifications, isEmpty);
  255 + });
  256 +
  257 + test('skips realtime stress notification inside supplied sleep interval', () {
  258 + final base = _seconds(DateTime(2026, 1, 1, 9));
  259 + final notifications = builder.build(
  260 + result: HealthRawStressCalculationResult(
  261 + userId: 1,
  262 + hrvStressPoints: const [],
  263 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  264 + dailyStressPoints: const [],
  265 + ),
  266 + hasExistingHrv: false,
  267 + hasExistingSleep: false,
  268 + realtimeWindow: [
  269 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  270 + ],
  271 + record: const HealthRawLocalNotificationRecord(),
  272 + realtimeSleepIntervals: [
  273 + (startTime: base + 8 * 300, endTime: base + 10 * 300),
  274 + ],
  275 + );
  276 +
  277 + expect(notifications, isEmpty);
  278 + });
  279 +
  280 + test('builds realtime stress notification outside supplied sleep interval',
  281 + () {
  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 + realtimeSleepIntervals: [
  297 + (startTime: base - 3 * 3600, endTime: base - 2 * 3600),
  298 + ],
  299 + );
  300 +
  301 + expect(notifications, hasLength(1));
  302 + });
  303 +
  304 + test('uses midnight to six default realtime sleep interval', () {
  305 + final dayStart = _seconds(DateTime(2026, 1, 1));
  306 + final sleepTime = dayStart + 5 * 3600 + 30 * 60;
  307 + final awakeTime = dayStart + 6 * 3600 + 60;
  308 +
  309 + final sleepNotifications = builder.build(
  310 + result: HealthRawStressCalculationResult(
  311 + userId: 1,
  312 + hrvStressPoints: const [],
  313 + realtimeStressPoints: [_realtimePoint(sleepTime, 70)],
  314 + dailyStressPoints: const [],
  315 + ),
  316 + hasExistingHrv: false,
  317 + hasExistingSleep: false,
  318 + realtimeWindow: [
  319 + for (var i = 0; i < 10; i++)
  320 + _realtimePoint(sleepTime - (9 - i) * 300, 70),
  321 + ],
  322 + record: const HealthRawLocalNotificationRecord(),
  323 + );
  324 + final awakeNotifications = builder.build(
  325 + result: HealthRawStressCalculationResult(
  326 + userId: 1,
  327 + hrvStressPoints: const [],
  328 + realtimeStressPoints: [_realtimePoint(awakeTime, 70)],
  329 + dailyStressPoints: const [],
  330 + ),
  331 + hasExistingHrv: false,
  332 + hasExistingSleep: false,
  333 + realtimeWindow: [
  334 + for (var i = 0; i < 10; i++)
  335 + _realtimePoint(awakeTime - (9 - i) * 300, 70),
  336 + ],
  337 + record: const HealthRawLocalNotificationRecord(),
  338 + );
  339 +
  340 + expect(sleepNotifications, isEmpty);
  341 + expect(awakeNotifications, hasLength(1));
  342 + });
  343 +
  344 + test('ignores legacy realtime record time for push send interval', () {
  345 + final base = _seconds(DateTime(2026, 1, 1, 9));
  346 + final notifications = builder.build(
  347 + result: HealthRawStressCalculationResult(
  348 + userId: 1,
  349 + hrvStressPoints: const [],
  350 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  351 + dailyStressPoints: const [],
  352 + ),
  353 + hasExistingHrv: false,
  354 + hasExistingSleep: false,
  355 + realtimeWindow: [
  356 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  357 + ],
  358 + record: HealthRawLocalNotificationRecord(
  359 + lastRealtimeStressTime: base + 9 * 300,
  360 + ),
  361 + notificationBuildTime: base + 9 * 300,
  362 + );
  363 +
  364 + expect(notifications, hasLength(1));
  365 + });
  366 +
211 test('skips realtime stress notification during likely sleep', () { 367 test('skips realtime stress notification during likely sleep', () {
212 final base = _seconds(DateTime(2026, 1, 1, 9)); 368 final base = _seconds(DateTime(2026, 1, 1, 9));
213 final notifications = builder.build( 369 final notifications = builder.build(
@@ -339,7 +495,10 @@ void main() { @@ -339,7 +495,10 @@ void main() {
339 495
340 int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; 496 int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
341 497
342 -HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { 498 +HealthRawHrvStressPoint _hrvPoint(
  499 + int rawEndTime, {
  500 + bool isSleepLikely = false,
  501 +}) {
343 return HealthRawHrvStressPoint( 502 return HealthRawHrvStressPoint(
344 userId: 1, 503 userId: 1,
345 rawEndTime: rawEndTime, 504 rawEndTime: rawEndTime,
@@ -352,6 +511,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) { @@ -352,6 +511,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
352 baselineAwakeHrv: 30, 511 baselineAwakeHrv: 30,
353 baselineSleepHrv: null, 512 baselineSleepHrv: null,
354 baselineRestingHr: 60, 513 baselineRestingHr: 60,
  514 + flags: HealthRawPointFlags(
  515 + isSleepLikely: isSleepLikely,
  516 + isWorkout: false,
  517 + isWorkoutRecovery: false,
  518 + isSuspectedActivity: false,
  519 + ),
355 ); 520 );
356 } 521 }
357 522