Commit 60476ea8d1a8026a843837cf2c41b50c257982aa

Authored by 权海
1 parent a7f33c69

feat(ui):增加综合压力表

@@ -139,7 +139,7 @@ final class HealthKitQueryReader { @@ -139,7 +139,7 @@ final class HealthKitQueryReader {
139 return try await fetchRawQuantitySamples( 139 return try await fetchRawQuantitySamples(
140 identifier: .appleStandTime, 140 identifier: .appleStandTime,
141 dataType: .stand, 141 dataType: .stand,
142 - unit: .second(), 142 + unit: .hour(),
143 startDate: startDate, 143 startDate: startDate,
144 endDate: endDate 144 endDate: endDate
145 ) 145 )
@@ -216,8 +216,8 @@ final class HealthKitQueryReader { @@ -216,8 +216,8 @@ final class HealthKitQueryReader {
216 appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), 216 appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
217 appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()), 217 appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
218 exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), 218 exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
219 - appleStandHours: Self.standSeconds(from: summary.appleStandHours),  
220 - standHoursGoal: Self.standHoursGoalValue(from: summary) 219 + appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
  220 + standHoursGoal: summary.standHoursGoal?.doubleValue(for: .count()),
221 ) 221 )
222 } 222 }
223 223
@@ -249,17 +249,6 @@ final class HealthKitQueryReader { @@ -249,17 +249,6 @@ final class HealthKitQueryReader {
249 return nil 249 return nil
250 } 250 }
251 251
252 - private static func standHoursGoalValue(from summary: HKActivitySummary) -> Double? {  
253 - if #available(iOS 16.0, *) {  
254 - return summary.standHoursGoal.map { Self.standSeconds(from: $0) }  
255 - }  
256 - return nil  
257 - }  
258 -  
259 - private static func standSeconds(from quantity: HKQuantity) -> Double {  
260 - quantity.doubleValue(for: .count()) * 60 * 60  
261 - }  
262 -  
263 private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] { 252 private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
264 async let heartRate = fetchQuantitySamples( 253 async let heartRate = fetchQuantitySamples(
265 identifier: .heartRate, 254 identifier: .heartRate,
@@ -139,7 +139,7 @@ struct NativeActivityTarget { @@ -139,7 +139,7 @@ struct NativeActivityTarget {
139 // 公里 139 // 公里
140 let appleMoveTime: Double? 140 let appleMoveTime: Double?
141 let appleMoveTimeGoal: Double? 141 let appleMoveTimeGoal: Double?
142 - // 分钟 142 + //
143 let appleExerciseTime: Double? 143 let appleExerciseTime: Double?
144 let exerciseTimeGoal: Double? 144 let exerciseTimeGoal: Double?
145 //小时 145 //小时
@@ -262,7 +262,7 @@ class TodayController extends GetMaterialController { @@ -262,7 +262,7 @@ class TodayController extends GetMaterialController {
262 if (success) { 262 if (success) {
263 // await LoadingService.instance.run(() async { 263 // await LoadingService.instance.run(() async {
264 _rawDataApi.performHealthDataUpload(); 264 _rawDataApi.performHealthDataUpload();
265 - _startCoreCaculate(); 265 + _startCoreCaculate(refreshAfterComplete: true);
266 // }); 266 // });
267 // result.status = 1; 267 // result.status = 1;
268 // AppToast.show('刷新完成'); 268 // AppToast.show('刷新完成');
@@ -278,7 +278,7 @@ class TodayController extends GetMaterialController { @@ -278,7 +278,7 @@ class TodayController extends GetMaterialController {
278 } else if (result.status == 1) { 278 } else if (result.status == 1) {
279 // await LoadingService.instance.run(() async { 279 // await LoadingService.instance.run(() async {
280 _rawDataApi.performHealthDataUpload(); 280 _rawDataApi.performHealthDataUpload();
281 - _startCoreCaculate(); 281 + _startCoreCaculate(refreshAfterComplete: true);
282 // }); 282 // });
283 if (isFromNoPermissionPage) { 283 if (isFromNoPermissionPage) {
284 Get.back(); 284 Get.back();
@@ -376,11 +376,14 @@ class TodayController extends GetMaterialController { @@ -376,11 +376,14 @@ class TodayController extends GetMaterialController {
376 ); 376 );
377 } 377 }
378 378
379 - void _startCoreCaculate() { 379 + void _startCoreCaculate({bool refreshAfterComplete = false}) {
380 unawaited( 380 unawaited(
381 (() async { 381 (() async {
382 try { 382 try {
383 await _healthRawDataCoreService.startCoreCaculate(); 383 await _healthRawDataCoreService.startCoreCaculate();
  384 + if (refreshAfterComplete) {
  385 + await loadDataForDate(selectedDate.value);
  386 + }
384 } catch (error, stackTrace) { 387 } catch (error, stackTrace) {
385 AppLogger.e('Apple Health calculate failed', error, stackTrace); 388 AppLogger.e('Apple Health calculate failed', error, stackTrace);
386 } 389 }
@@ -450,13 +453,11 @@ class TodayController extends GetMaterialController { @@ -450,13 +453,11 @@ class TodayController extends GetMaterialController {
450 case AppFailure(): 453 case AppFailure():
451 stressChartData.clear(); 454 stressChartData.clear();
452 } 455 }
453 - if (v2ActivityTarget.value == null) {  
454 - switch (await _healthApi.getV2ActivityTarget(friendUserId)) {  
455 - case AppSuccess(:final data):  
456 - v2ActivityTarget.value = data;  
457 - case AppFailure():  
458 - v2ActivityTarget.value = null;  
459 - } 456 + switch (await _healthApi.getV2ActivityTarget(friendUserId)) {
  457 + case AppSuccess(:final data):
  458 + v2ActivityTarget.value = data;
  459 + case AppFailure():
  460 + v2ActivityTarget.value = null;
460 } 461 }
461 } 462 }
462 463
@@ -225,7 +225,7 @@ class TodayActivityCard extends StatelessWidget { @@ -225,7 +225,7 @@ class TodayActivityCard extends StatelessWidget {
225 ); 225 );
226 } 226 }
227 227
228 - // stand 单位为秒,转换为小时+分钟 228 + // stand 单位为小时
229 Widget standWidget; 229 Widget standWidget;
230 if (stand > 0 && (activityTarget?.stand ?? 0) > 0) { 230 if (stand > 0 && (activityTarget?.stand ?? 0) > 0) {
231 standWidget = Text.rich( 231 standWidget = Text.rich(
@@ -180,11 +180,14 @@ class HealthRawDataCoreService { @@ -180,11 +180,14 @@ class HealthRawDataCoreService {
180 userId: userId, 180 userId: userId,
181 hrvStressPoints: const <HealthRawHrvStressPoint>[], 181 hrvStressPoints: const <HealthRawHrvStressPoint>[],
182 realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], 182 realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
  183 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
183 ); 184 );
184 } 185 }
185 - final totalWatch = Stopwatch()..start(); 186 + final calculateStartedAt = DateTime.now();
  187 + _logInfo(
  188 + 'calculateStartAt=${_formatDateTimeMilliseconds(calculateStartedAt)}',
  189 + );
186 await _localStore.ensureReadable(userId); 190 await _localStore.ensureReadable(userId);
187 - final readWatch = Stopwatch()..start();  
188 final effectiveEndTime = 191 final effectiveEndTime =
189 endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; 192 endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
190 final earliestStartTime = DateTime.now() 193 final earliestStartTime = DateTime.now()
@@ -215,8 +218,6 @@ class HealthRawDataCoreService { @@ -215,8 +218,6 @@ class HealthRawDataCoreService {
215 earliestStartTime, 218 earliestStartTime,
216 ); 219 );
217 final restingHeartRateStartTime = heartRateStartTime; 220 final restingHeartRateStartTime = heartRateStartTime;
218 - final readStartTime =  
219 - math.min(math.min(hrvStartTime, heartRateStartTime), effectiveEndTime);  
220 221
221 final hrvPoints = await _fetchRawDataInChunks( 222 final hrvPoints = await _fetchRawDataInChunks(
222 HealthDataUploadType.hrv.type, 223 HealthDataUploadType.hrv.type,
@@ -246,11 +247,7 @@ class HealthRawDataCoreService { @@ -246,11 +247,7 @@ class HealthRawDataCoreService {
246 effectiveEndTime, 247 effectiveEndTime,
247 readChunkDays: readChunkDays, 248 readChunkDays: readChunkDays,
248 ); 249 );
249 - readWatch.stop();  
250 250
251 - final calculateAndStoreWatch = Stopwatch()..start();  
252 - final calculateStartedAt = DateTime.now();  
253 - final calculateWatch = Stopwatch()..start();  
254 final result = calculate( 251 final result = calculate(
255 hrvPoints: hrvPoints, 252 hrvPoints: hrvPoints,
256 heartRatePoints: heartRatePoints, 253 heartRatePoints: heartRatePoints,
@@ -260,38 +257,21 @@ class HealthRawDataCoreService { @@ -260,38 +257,21 @@ class HealthRawDataCoreService {
260 startTime: math.min(hrvStartTime, heartRateStartTime), 257 startTime: math.min(hrvStartTime, heartRateStartTime),
261 endTime: effectiveEndTime, 258 endTime: effectiveEndTime,
262 ); 259 );
263 - calculateWatch.stop();  
264 - final calculateFinishedAt = DateTime.now();  
265 await _localStore.upsertResult(result); 260 await _localStore.upsertResult(result);
266 - calculateAndStoreWatch.stop();  
267 - totalWatch.stop(); 261 + final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
  262 + userId: userId,
  263 + realtimePoints: result.realtimeStressPoints,
  264 + nowSeconds: effectiveEndTime,
  265 + );
  266 + final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints);
268 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { 267 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
269 - AppToast.show(  
270 - '首次计算完成, 总耗时${_formatDurationMinutesSeconds(totalWatch.elapsed)}',  
271 - ); 268 + AppToast.show('首次计算完成');
272 } 269 }
273 - 270 + final calculateFinishedAt = DateTime.now();
274 _logInfo( 271 _logInfo(
275 - 'timing userId=$userId '  
276 - 'rangeStart=$readStartTime rangeStartAt=${_formatUnixSeconds(readStartTime)} '  
277 - 'rangeEnd=$effectiveEndTime rangeEndAt=${_formatUnixSeconds(effectiveEndTime)} '  
278 - 'cursorHrvSourceStart=$hrvContextStart '  
279 - 'cursorRealtimeSourceStart=$realtimeContextStart '  
280 - 'hrvStart=$hrvStartTime realtimeStart=$realtimeStartTime '  
281 - 'hrStart=$heartRateStartTime restingHrStart=$restingHeartRateStartTime '  
282 - 'calculateStartAt=${calculateStartedAt.toIso8601String()} '  
283 - 'calculateEndAt=${calculateFinishedAt.toIso8601String()} '  
284 - 'raw(hrv=${hrvPoints.length}, hr=${heartRatePoints.length}, '  
285 - 'restingHr=${restingHeartRatePoints.length}, '  
286 - 'sleep=${sleepIntervals.length}, workout=${workoutIntervals.length}) '  
287 - 'result(hrv=${result.hrvStressPoints.length}, '  
288 - 'realtime=${result.realtimeStressPoints.length}) '  
289 - 'readMs=${readWatch.elapsedMilliseconds} '  
290 - 'calculateMs=${calculateWatch.elapsedMilliseconds} '  
291 - 'calculateAndStoreMs=${calculateAndStoreWatch.elapsedMilliseconds} '  
292 - 'totalMs=${totalWatch.elapsedMilliseconds}', 272 + 'calculateEndAt=${_formatDateTimeMilliseconds(calculateFinishedAt)}',
293 ); 273 );
294 - return result; 274 + return storedResult;
295 } 275 }
296 276
297 Future<bool> _hasHealthReadAuthorization() async { 277 Future<bool> _hasHealthReadAuthorization() async {
@@ -392,6 +372,17 @@ class HealthRawDataCoreService { @@ -392,6 +372,17 @@ class HealthRawDataCoreService {
392 ); 372 );
393 } 373 }
394 374
  375 + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
  376 + required int startDate,
  377 + required int endDate,
  378 + }) {
  379 + return _localStore.queryDailyStressPoints(
  380 + userId: _userId,
  381 + startDate: startDate,
  382 + endDate: endDate,
  383 + );
  384 + }
  385 +
395 Future<List<HealthKitRawDataPoint>> queryRawDataPoints({ 386 Future<List<HealthKitRawDataPoint>> queryRawDataPoints({
396 required int dataType, 387 required int dataType,
397 required int startTime, 388 required int startTime,
@@ -481,6 +472,57 @@ class HealthRawDataCoreService { @@ -481,6 +472,57 @@ class HealthRawDataCoreService {
481 ); 472 );
482 } 473 }
483 474
  475 + Future<List<HealthRawDailyStressPoint>> _calculateAndStoreDailyStressPoints({
  476 + required int userId,
  477 + required List<HealthRawRealtimeStressPoint> realtimePoints,
  478 + required int nowSeconds,
  479 + }) async {
  480 + final todayDate = _dateKeyFromUnixSeconds(nowSeconds);
  481 + final affectedDates = <int>{
  482 + todayDate,
  483 + for (final point in realtimePoints)
  484 + _dateKeyFromUnixSeconds(point.rawEndTime),
  485 + }.toList()
  486 + ..sort();
  487 + final existingDates = await _localStore.existingDailyStressDates(
  488 + userId: userId,
  489 + dates: affectedDates,
  490 + );
  491 + final dailyStressPoints = <HealthRawDailyStressPoint>[];
  492 + final emptyDates = <int>[];
  493 + for (final date in affectedDates) {
  494 + if (date != todayDate && existingDates.contains(date)) {
  495 + continue;
  496 + }
  497 + final (startTime, endTime) = _dayRangeFromDateKey(date);
  498 + final dayRealtimePoints = await _localStore.queryRealtimeStressPoints(
  499 + userId: userId,
  500 + startTime: startTime,
  501 + endTime: endTime,
  502 + );
  503 + final point = HealthRawDailyStressCalculator.calculate(
  504 + userId: userId,
  505 + date: date,
  506 + realtimePoints: dayRealtimePoints,
  507 + dataTime: date == todayDate ? nowSeconds : startTime,
  508 + );
  509 + if (point == null) {
  510 + emptyDates.add(date);
  511 + continue;
  512 + }
  513 + dailyStressPoints.add(point);
  514 + }
  515 + await _localStore.upsertDailyStressPoints(
  516 + userId: userId,
  517 + points: dailyStressPoints,
  518 + );
  519 + await _localStore.deleteDailyStressDates(
  520 + userId: userId,
  521 + dates: emptyDates,
  522 + );
  523 + return dailyStressPoints;
  524 + }
  525 +
484 Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( 526 Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
485 int dataType, 527 int dataType,
486 int startTime, 528 int startTime,
@@ -540,26 +582,37 @@ class HealthRawDataCoreService { @@ -540,26 +582,37 @@ class HealthRawDataCoreService {
540 return math.min(a, b); 582 return math.min(a, b);
541 } 583 }
542 584
543 - static String _formatUnixSeconds(int seconds) {  
544 - return DateTime.fromMillisecondsSinceEpoch(seconds * 1000)  
545 - .toIso8601String(); 585 + static int _dateKeyFromUnixSeconds(int seconds) {
  586 + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
  587 + return date.year * 10000 + date.month * 100 + date.day;
546 } 588 }
547 589
548 - static String _formatDurationMinutesSeconds(Duration duration) {  
549 - final minutes = duration.inMinutes;  
550 - final seconds = duration.inSeconds.remainder(60);  
551 - return '$minutes分钟$seconds秒'; 590 + static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) {
  591 + final year = dateKey ~/ 10000;
  592 + final month = (dateKey ~/ 100) % 100;
  593 + final day = dateKey % 100;
  594 + final start = DateTime(year, month, day);
  595 + return (
  596 + start.millisecondsSinceEpoch ~/ 1000,
  597 + start.add(const Duration(days: 1)).millisecondsSinceEpoch ~/ 1000 - 1,
  598 + );
  599 + }
  600 +
  601 + static String _formatDateTimeMilliseconds(DateTime dateTime) {
  602 + String two(int value) => value.toString().padLeft(2, '0');
  603 + String three(int value) => value.toString().padLeft(3, '0');
  604 + return '${dateTime.year.toString().padLeft(4, '0')}-'
  605 + '${two(dateTime.month)}-'
  606 + '${two(dateTime.day)}T'
  607 + '${two(dateTime.hour)}:'
  608 + '${two(dateTime.minute)}:'
  609 + '${two(dateTime.second)}.'
  610 + '${three(dateTime.millisecond)}';
552 } 611 }
553 612
554 static void _logInfo(String message) { 613 static void _logInfo(String message) {
555 final tagged = 'HealthRawDataCoreService $message'; 614 final tagged = 'HealthRawDataCoreService $message';
556 - developer.log(tagged, name: 'HealthRawDataCoreService');  
557 debugPrint(tagged); 615 debugPrint(tagged);
558 - try {  
559 - AppLogger.i(tagged);  
560 - } catch (_) {  
561 - // Logger may be unavailable in isolated unit tests.  
562 - }  
563 } 616 }
564 617
565 static void _logError( 618 static void _logError(
@@ -588,11 +641,26 @@ class HealthRawStressCalculationResult { @@ -588,11 +641,26 @@ class HealthRawStressCalculationResult {
588 required this.userId, 641 required this.userId,
589 required this.hrvStressPoints, 642 required this.hrvStressPoints,
590 required this.realtimeStressPoints, 643 required this.realtimeStressPoints,
  644 + required this.dailyStressPoints,
591 }); 645 });
592 646
593 final int userId; 647 final int userId;
594 final List<HealthRawHrvStressPoint> hrvStressPoints; 648 final List<HealthRawHrvStressPoint> hrvStressPoints;
595 final List<HealthRawRealtimeStressPoint> realtimeStressPoints; 649 final List<HealthRawRealtimeStressPoint> realtimeStressPoints;
  650 + final List<HealthRawDailyStressPoint> dailyStressPoints;
  651 +
  652 + HealthRawStressCalculationResult copyWith({
  653 + List<HealthRawHrvStressPoint>? hrvStressPoints,
  654 + List<HealthRawRealtimeStressPoint>? realtimeStressPoints,
  655 + List<HealthRawDailyStressPoint>? dailyStressPoints,
  656 + }) {
  657 + return HealthRawStressCalculationResult(
  658 + userId: userId,
  659 + hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints,
  660 + realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints,
  661 + dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints,
  662 + );
  663 + }
596 } 664 }
597 665
598 class HealthRawDataUpdatedEvent { 666 class HealthRawDataUpdatedEvent {
@@ -799,6 +867,140 @@ class HealthRawRealtimeStressPoint { @@ -799,6 +867,140 @@ class HealthRawRealtimeStressPoint {
799 } 867 }
800 } 868 }
801 869
  870 +class HealthRawDailyStressPoint {
  871 + const HealthRawDailyStressPoint({
  872 + required this.userId,
  873 + required this.date,
  874 + required this.stressValue,
  875 + required this.stressScore,
  876 + required this.state,
  877 + required this.dataTime,
  878 + this.uploaded = false,
  879 + });
  880 +
  881 + final int userId;
  882 + final int date;
  883 + final double stressValue;
  884 + final int stressScore;
  885 + final HealthRawStressState state;
  886 + final int dataTime;
  887 + final bool uploaded;
  888 +
  889 + factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) {
  890 + final stressScore = row['stress_score'] as int;
  891 + return HealthRawDailyStressPoint(
  892 + userId: row['user_id'] as int,
  893 + date: row['date'] as int,
  894 + stressValue: (row['stress_value'] as num).toDouble(),
  895 + stressScore: stressScore,
  896 + state: _stressStateFromDb(row['state']) ??
  897 + healthRawRealtimeStressState(stressScore),
  898 + dataTime: row['data_time'] as int,
  899 + uploaded: (row['uploaded'] as int) == 1,
  900 + );
  901 + }
  902 +}
  903 +
  904 +class HealthRawDailyStressCalculator {
  905 + const HealthRawDailyStressCalculator._();
  906 +
  907 + static const int _confirmedActivityBufferSeconds = 12 * 60;
  908 +
  909 + static HealthRawDailyStressPoint? calculate({
  910 + required int userId,
  911 + required int date,
  912 + required List<HealthRawRealtimeStressPoint> realtimePoints,
  913 + required int dataTime,
  914 + }) {
  915 + final validValuePoints =
  916 + realtimePoints.where((e) => _isValidStressValue(e.result)).toList();
  917 + final activityBuffers = _confirmedActivityBuffers(realtimePoints);
  918 + final values = validValuePoints
  919 + .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
  920 + .map((e) => e.result)
  921 + .toList()
  922 + ..sort();
  923 + if (values.isEmpty) return null;
  924 + final stressValue = _average(values);
  925 + final stressScore = _dailyStressScore(values).round().clamp(1, 100).toInt();
  926 + return HealthRawDailyStressPoint(
  927 + userId: userId,
  928 + date: date,
  929 + stressValue: stressValue,
  930 + stressScore: stressScore,
  931 + state: healthRawRealtimeStressState(stressScore),
  932 + dataTime: dataTime,
  933 + );
  934 + }
  935 +
  936 + static double _dailyStressScore(List<double> sortedValues) {
  937 + final median = _median(sortedValues);
  938 + final p75 = _percentile75(sortedValues);
  939 + return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100));
  940 + }
  941 +
  942 + static List<({int start, int end})> _confirmedActivityBuffers(
  943 + List<HealthRawRealtimeStressPoint> realtimePoints,
  944 + ) {
  945 + final ranges = realtimePoints
  946 + .where((e) =>
  947 + _isValidStressValue(e.result) &&
  948 + (e.isWorkout || e.isWorkoutRecovery))
  949 + .map((e) => (
  950 + start: e.rawEndTime - _confirmedActivityBufferSeconds,
  951 + end: e.rawEndTime + _confirmedActivityBufferSeconds,
  952 + ))
  953 + .toList()
  954 + ..sort((a, b) => a.start.compareTo(b.start));
  955 + if (ranges.isEmpty) return const <({int start, int end})>[];
  956 +
  957 + final merged = <({int start, int end})>[];
  958 + var current = ranges.first;
  959 + for (final range in ranges.skip(1)) {
  960 + if (range.start <= current.end) {
  961 + current = (
  962 + start: current.start,
  963 + end: math.max(current.end, range.end),
  964 + );
  965 + } else {
  966 + merged.add(current);
  967 + current = range;
  968 + }
  969 + }
  970 + merged.add(current);
  971 + return merged;
  972 + }
  973 +
  974 + static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {
  975 + return ranges.any((e) => time >= e.start && time <= e.end);
  976 + }
  977 +
  978 + static bool _isValidStressValue(num value) => value >= 1 && value <= 100;
  979 +
  980 + static double _average(List<double> values) =>
  981 + values.reduce((a, b) => a + b) / values.length;
  982 +
  983 + static double _median(List<double> sortedValues) {
  984 + final length = sortedValues.length;
  985 + final middle = length ~/ 2;
  986 + if (length.isOdd) return sortedValues[middle];
  987 + return (sortedValues[middle - 1] + sortedValues[middle]) / 2;
  988 + }
  989 +
  990 + static double _percentile75(List<double> sortedValues) {
  991 + final rank = (sortedValues.length * 0.75).ceil().clamp(
  992 + 1,
  993 + sortedValues.length,
  994 + );
  995 + return sortedValues[rank - 1];
  996 + }
  997 +
  998 + static double _clamp(num value, num min, num max) =>
  999 + math.max(min.toDouble(), math.min(max.toDouble(), value.toDouble()));
  1000 +
  1001 + static double _round(double value) => (value * 10).round() / 10;
  1002 +}
  1003 +
802 HealthRawStressState healthRawRealtimeStressState(num value) { 1004 HealthRawStressState healthRawRealtimeStressState(num value) {
803 if (value >= 81) return HealthRawStressState.overload; 1005 if (value >= 81) return HealthRawStressState.overload;
804 if (value >= 61) return HealthRawStressState.attention; 1006 if (value >= 61) return HealthRawStressState.attention;
@@ -834,10 +1036,12 @@ class HealthRawStressDbRows { @@ -834,10 +1036,12 @@ class HealthRawStressDbRows {
834 const HealthRawStressDbRows({ 1036 const HealthRawStressDbRows({
835 required this.hrvRows, 1037 required this.hrvRows,
836 required this.realtimeRows, 1038 required this.realtimeRows,
  1039 + required this.dailyStressRows,
837 }); 1040 });
838 1041
839 final List<Map<String, Object?>> hrvRows; 1042 final List<Map<String, Object?>> hrvRows;
840 final List<Map<String, Object?>> realtimeRows; 1043 final List<Map<String, Object?>> realtimeRows;
  1044 + final List<Map<String, Object?>> dailyStressRows;
841 } 1045 }
842 1046
843 class HealthRawStressLocalStore { 1047 class HealthRawStressLocalStore {
@@ -849,6 +1053,7 @@ class HealthRawStressLocalStore { @@ -849,6 +1053,7 @@ class HealthRawStressLocalStore {
849 1053
850 static const hrvResultsTable = 'hrv_results'; 1054 static const hrvResultsTable = 'hrv_results';
851 static const realtimeStressResultsTable = 'realtime_stress_results'; 1055 static const realtimeStressResultsTable = 'realtime_stress_results';
  1056 + static const dailyStressResultsTable = 'daily_stress_results';
852 1057
853 final Directory? _rootDirectory; 1058 final Directory? _rootDirectory;
854 final DatabaseFactory? _databaseFactory; 1059 final DatabaseFactory? _databaseFactory;
@@ -874,6 +1079,48 @@ class HealthRawStressLocalStore { @@ -874,6 +1079,48 @@ class HealthRawStressLocalStore {
874 }); 1079 });
875 } 1080 }
876 1081
  1082 + Future<void> upsertDailyStressPoints({
  1083 + required int userId,
  1084 + required Iterable<HealthRawDailyStressPoint> points,
  1085 + }) async {
  1086 + final db = await _database(userId);
  1087 + await db.transaction((txn) async {
  1088 + for (final point in points) {
  1089 + await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point));
  1090 + }
  1091 + });
  1092 + }
  1093 +
  1094 + Future<Set<int>> existingDailyStressDates({
  1095 + required int userId,
  1096 + required Iterable<int> dates,
  1097 + }) async {
  1098 + final dateList = dates.toSet().toList();
  1099 + if (dateList.isEmpty) return const <int>{};
  1100 + final db = await _database(userId);
  1101 + final rows = await db.query(
  1102 + dailyStressResultsTable,
  1103 + columns: ['date'],
  1104 + where: 'date IN (${List.filled(dateList.length, '?').join(',')})',
  1105 + whereArgs: dateList,
  1106 + );
  1107 + return rows.map((row) => row['date'] as int).toSet();
  1108 + }
  1109 +
  1110 + Future<void> deleteDailyStressDates({
  1111 + required int userId,
  1112 + required Iterable<int> dates,
  1113 + }) async {
  1114 + final dateList = dates.toSet().toList();
  1115 + if (dateList.isEmpty) return;
  1116 + final db = await _database(userId);
  1117 + await db.delete(
  1118 + dailyStressResultsTable,
  1119 + where: 'date IN (${List.filled(dateList.length, '?').join(',')})',
  1120 + whereArgs: dateList,
  1121 + );
  1122 + }
  1123 +
877 Future<void> ensureReadable(int userId) async { 1124 Future<void> ensureReadable(int userId) async {
878 final db = await _database(userId); 1125 final db = await _database(userId);
879 await db.query( 1126 await db.query(
@@ -929,6 +1176,21 @@ class HealthRawStressLocalStore { @@ -929,6 +1176,21 @@ class HealthRawStressLocalStore {
929 return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); 1176 return rows.map(HealthRawRealtimeStressPoint.fromDb).toList();
930 } 1177 }
931 1178
  1179 + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
  1180 + required int userId,
  1181 + required int startDate,
  1182 + required int endDate,
  1183 + }) async {
  1184 + final db = await _database(userId);
  1185 + final rows = await db.query(
  1186 + dailyStressResultsTable,
  1187 + where: 'date >= ? AND date <= ?',
  1188 + whereArgs: [startDate, endDate],
  1189 + orderBy: 'date ASC',
  1190 + );
  1191 + return rows.map(HealthRawDailyStressPoint.fromDb).toList();
  1192 + }
  1193 +
932 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { 1194 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
933 final db = await _database(userId); 1195 final db = await _database(userId);
934 final hrvRows = await db.query( 1196 final hrvRows = await db.query(
@@ -939,9 +1201,14 @@ class HealthRawStressLocalStore { @@ -939,9 +1201,14 @@ class HealthRawStressLocalStore {
939 realtimeStressResultsTable, 1201 realtimeStressResultsTable,
940 orderBy: 'raw_end_time ASC', 1202 orderBy: 'raw_end_time ASC',
941 ); 1203 );
  1204 + final dailyStressRows = await db.query(
  1205 + dailyStressResultsTable,
  1206 + orderBy: 'date ASC',
  1207 + );
942 return HealthRawStressDbRows( 1208 return HealthRawStressDbRows(
943 hrvRows: hrvRows, 1209 hrvRows: hrvRows,
944 realtimeRows: realtimeRows, 1210 realtimeRows: realtimeRows,
  1211 + dailyStressRows: dailyStressRows,
945 ); 1212 );
946 } 1213 }
947 1214
@@ -1015,7 +1282,7 @@ class HealthRawStressLocalStore { @@ -1015,7 +1282,7 @@ class HealthRawStressLocalStore {
1015 final db = await factory.openDatabase( 1282 final db = await factory.openDatabase(
1016 path, 1283 path,
1017 options: OpenDatabaseOptions( 1284 options: OpenDatabaseOptions(
1018 - version: 4, 1285 + version: 5,
1019 onCreate: (db, version) async { 1286 onCreate: (db, version) async {
1020 await _createTables(db); 1287 await _createTables(db);
1021 }, 1288 },
@@ -1030,6 +1297,9 @@ class HealthRawStressLocalStore { @@ -1030,6 +1297,9 @@ class HealthRawStressLocalStore {
1030 if (oldVersion < 4) { 1297 if (oldVersion < 4) {
1031 await _addHrvRawAndStateColumns(db); 1298 await _addHrvRawAndStateColumns(db);
1032 } 1299 }
  1300 + if (oldVersion < 5) {
  1301 + await _createDailyStressTable(db);
  1302 + }
1033 }, 1303 },
1034 ), 1304 ),
1035 ); 1305 );
@@ -1072,6 +1342,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1072,6 +1342,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1072 uploaded INTEGER NOT NULL DEFAULT 0 1342 uploaded INTEGER NOT NULL DEFAULT 0
1073 ) 1343 )
1074 '''); 1344 ''');
  1345 + await _createDailyStressTable(db);
  1346 + }
  1347 +
  1348 + Future<void> _createDailyStressTable(DatabaseExecutor db) async {
  1349 + await db.execute('''
  1350 +CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
  1351 + date INTEGER PRIMARY KEY,
  1352 + user_id INTEGER NOT NULL,
  1353 + stress_value REAL NOT NULL,
  1354 + stress_score INTEGER NOT NULL,
  1355 + state INTEGER NOT NULL,
  1356 + data_time INTEGER NOT NULL,
  1357 + uploaded INTEGER NOT NULL DEFAULT 0
  1358 +)
  1359 +''');
1075 } 1360 }
1076 1361
1077 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { 1362 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
@@ -1181,6 +1466,18 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1181,6 +1466,18 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1181 }; 1466 };
1182 } 1467 }
1183 1468
  1469 + Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) {
  1470 + return <String, Object?>{
  1471 + 'date': point.date,
  1472 + 'user_id': point.userId,
  1473 + 'stress_value': point.stressValue,
  1474 + 'stress_score': point.stressScore,
  1475 + 'state': point.state.value,
  1476 + 'data_time': point.dataTime,
  1477 + 'uploaded': point.uploaded ? 1 : 0,
  1478 + };
  1479 + }
  1480 +
1184 Map<String, Object?> _flagsRow(HealthRawPointFlags flags) { 1481 Map<String, Object?> _flagsRow(HealthRawPointFlags flags) {
1185 return <String, Object?>{ 1482 return <String, Object?>{
1186 'is_workout': flags.isWorkout ? 1 : 0, 1483 'is_workout': flags.isWorkout ? 1 : 0,
@@ -1227,4 +1524,28 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1227,4 +1524,28 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1227 whereArgs: [row['raw_end_time']], 1524 whereArgs: [row['raw_end_time']],
1228 ); 1525 );
1229 } 1526 }
  1527 +
  1528 + Future<void> _upsertDailyStressResettingUploaded(
  1529 + DatabaseExecutor db,
  1530 + Map<String, Object?> row,
  1531 + ) async {
  1532 + await db.insert(
  1533 + dailyStressResultsTable,
  1534 + row,
  1535 + conflictAlgorithm: ConflictAlgorithm.ignore,
  1536 + );
  1537 + await db.update(
  1538 + dailyStressResultsTable,
  1539 + <String, Object?>{
  1540 + 'user_id': row['user_id'],
  1541 + 'stress_value': row['stress_value'],
  1542 + 'stress_score': row['stress_score'],
  1543 + 'state': row['state'],
  1544 + 'data_time': row['data_time'],
  1545 + 'uploaded': 0,
  1546 + },
  1547 + where: 'date = ?',
  1548 + whereArgs: [row['date']],
  1549 + );
  1550 + }
1230 } 1551 }
@@ -59,6 +59,7 @@ class HealthRawStressCalculator { @@ -59,6 +59,7 @@ class HealthRawStressCalculator {
59 userId: userId, 59 userId: userId,
60 hrvStressPoints: hrvStressPoints, 60 hrvStressPoints: hrvStressPoints,
61 realtimeStressPoints: realtimeStressPoints, 61 realtimeStressPoints: realtimeStressPoints,
  62 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
62 ); 63 );
63 } 64 }
64 65
@@ -16,7 +16,6 @@ const _sleepTypeAsleepDeep = 4; @@ -16,7 +16,6 @@ const _sleepTypeAsleepDeep = 4;
16 const _sleepTypeAsleepRem = 5; 16 const _sleepTypeAsleepRem = 5;
17 const _sleepGoalMinutes = 8 * 60.0; 17 const _sleepGoalMinutes = 8 * 60.0;
18 const _sleepContinuityToleranceSeconds = 1; 18 const _sleepContinuityToleranceSeconds = 1;
19 -const _stressConfirmedActivityBufferSeconds = 12 * 60;  
20 19
21 class LocalHealthDataConvert { 20 class LocalHealthDataConvert {
22 const LocalHealthDataConvert._(); 21 const LocalHealthDataConvert._();
@@ -230,7 +229,7 @@ class LocalHealthDataConvert { @@ -230,7 +229,7 @@ class LocalHealthDataConvert {
230 final data = ActivityBurnStatisticsDataV2( 229 final data = ActivityBurnStatisticsDataV2(
231 totalMove: _sum(daily.map((e) => e.move).toList()), 230 totalMove: _sum(daily.map((e) => e.move).toList()),
232 totalSteps: _sum(daily.map((e) => e.steps).toList()), 231 totalSteps: _sum(daily.map((e) => e.steps).toList()),
233 - totalStand: _sum(daily.map((e) => e.standSeconds).toList()), 232 + totalStand: _sum(daily.map((e) => e.standHours).toList()),
234 totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()), 233 totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()),
235 avgMove: _averageOrNull(daily.map((e) => e.move).toList()), 234 avgMove: _averageOrNull(daily.map((e) => e.move).toList()),
236 qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()), 235 qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()),
@@ -248,7 +247,7 @@ class LocalHealthDataConvert { @@ -248,7 +247,7 @@ class LocalHealthDataConvert {
248 timeKey: dateKey(item.day), 247 timeKey: dateKey(item.day),
249 value: Value( 248 value: Value(
250 move: item.move, 249 move: item.move,
251 - stand: item.standSeconds, 250 + stand: item.standHours,
252 exercise: item.exerciseSeconds, 251 exercise: item.exerciseSeconds,
253 ), 252 ),
254 ), 253 ),
@@ -281,17 +280,17 @@ class LocalHealthDataConvert { @@ -281,17 +280,17 @@ class LocalHealthDataConvert {
281 required List<DateTime> days, 280 required List<DateTime> days,
282 required List<DateTime> previousDays, 281 required List<DateTime> previousDays,
283 required List<HealthRawHrvStressPoint> hrvPoints, 282 required List<HealthRawHrvStressPoint> hrvPoints,
284 - required List<HealthRawRealtimeStressPoint> realtimePoints, 283 + required List<HealthRawDailyStressPoint> dailyStressPoints,
285 }) { 284 }) {
286 final daily = <_HrvDaySummary>[]; 285 final daily = <_HrvDaySummary>[];
287 for (final day in days) { 286 for (final day in days) {
288 - final summary = _hrvDaySummary(day, hrvPoints, realtimePoints); 287 + final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints);
289 if (summary.hasData) daily.add(summary); 288 if (summary.hasData) daily.add(summary);
290 } 289 }
291 final validDaily = daily.where((e) => e.hrvAverage != null).toList(); 290 final validDaily = daily.where((e) => e.hrvAverage != null).toList();
292 final previousDaily = <_HrvDaySummary>[]; 291 final previousDaily = <_HrvDaySummary>[];
293 for (final day in previousDays) { 292 for (final day in previousDays) {
294 - final summary = _hrvDaySummary(day, hrvPoints, realtimePoints); 293 + final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints);
295 if (summary.hasData) previousDaily.add(summary); 294 if (summary.hasData) previousDaily.add(summary);
296 } 295 }
297 final trendList = dateRangeType == 2 296 final trendList = dateRangeType == 2
@@ -414,7 +413,7 @@ class LocalHealthDataConvert { @@ -414,7 +413,7 @@ class LocalHealthDataConvert {
414 )?.round(), 413 )?.round(),
415 move: activitySummary.move.round(), 414 move: activitySummary.move.round(),
416 exercise: activitySummary.exerciseSeconds.round(), 415 exercise: activitySummary.exerciseSeconds.round(),
417 - stand: activitySummary.standSeconds.round(), 416 + stand: activitySummary.standHours.round(),
418 steps: activitySummary.steps.round(), 417 steps: activitySummary.steps.round(),
419 sleepDuration: sleepSummary.durationSeconds.round(), 418 sleepDuration: sleepSummary.durationSeconds.round(),
420 sleepScore: sleepSummary.score?.toDouble(), 419 sleepScore: sleepSummary.score?.toDouble(),
@@ -471,83 +470,14 @@ class LocalHealthDataConvert { @@ -471,83 +470,14 @@ class LocalHealthDataConvert {
471 ); 470 );
472 } 471 }
473 472
474 - static V2StressScore v2StressScore(  
475 - List<HealthRawRealtimeStressPoint> realtimePoints, {  
476 - required int startTime,  
477 - required int endTime,  
478 - }) {  
479 - final dayPoints = realtimePoints  
480 - .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)  
481 - .toList();  
482 - final validValuePoints =  
483 - dayPoints.where((e) => _isValidStressValue(e.result)).toList();  
484 - final activityBuffers = _confirmedActivityBuffers(realtimePoints);  
485 - final validStressValues = validValuePoints  
486 - .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))  
487 - .map((e) => e.result)  
488 - .toList()  
489 - ..sort();  
490 - final dailyStress = _dailyStress(validStressValues);  
491 - final score = dailyStress?.round(); 473 + static V2StressScore v2StressScore(HealthRawDailyStressPoint? dailyStress) {
  474 + final score = dailyStress?.stressScore;
492 return V2StressScore( 475 return V2StressScore(
493 - state: score == null ? 0 : healthRawRealtimeStressState(score).value, 476 + state: dailyStress?.state.value ?? 0,
494 comprehensiveScore: score, 477 comprehensiveScore: score,
495 ); 478 );
496 } 479 }
497 480
498 - static double? _dailyStress(List<double> sortedValues) {  
499 - if (sortedValues.isEmpty) return null;  
500 - final median = _median(sortedValues)!;  
501 - final p75 = _percentile75(sortedValues);  
502 - return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100), 1);  
503 - }  
504 -  
505 - static List<({int start, int end})> _confirmedActivityBuffers(  
506 - List<HealthRawRealtimeStressPoint> realtimePoints,  
507 - ) {  
508 - final ranges = realtimePoints  
509 - .where((e) =>  
510 - _isValidStressValue(e.result) &&  
511 - (e.isWorkout || e.isWorkoutRecovery))  
512 - .map((e) => (  
513 - start: e.rawEndTime - _stressConfirmedActivityBufferSeconds,  
514 - end: e.rawEndTime + _stressConfirmedActivityBufferSeconds,  
515 - ))  
516 - .toList()  
517 - ..sort((a, b) => a.start.compareTo(b.start));  
518 - if (ranges.isEmpty) return const <({int start, int end})>[];  
519 -  
520 - final merged = <({int start, int end})>[];  
521 - var current = ranges.first;  
522 - for (final range in ranges.skip(1)) {  
523 - if (range.start <= current.end) {  
524 - current = (  
525 - start: current.start,  
526 - end: math.max(current.end, range.end),  
527 - );  
528 - } else {  
529 - merged.add(current);  
530 - current = range;  
531 - }  
532 - }  
533 - merged.add(current);  
534 - return merged;  
535 - }  
536 -  
537 - static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {  
538 - return ranges.any((e) => time >= e.start && time <= e.end);  
539 - }  
540 -  
541 - static bool _isValidStressValue(num value) => value >= 1 && value <= 100;  
542 -  
543 - static double _percentile75(List<double> sortedValues) {  
544 - final rank = (sortedValues.length * 0.75).ceil().clamp(  
545 - 1,  
546 - sortedValues.length,  
547 - );  
548 - return sortedValues[rank - 1];  
549 - }  
550 -  
551 static _SleepDaySummary _sleepDaySummary( 481 static _SleepDaySummary _sleepDaySummary(
552 DateTime day, 482 DateTime day,
553 List<HealthKitRawDataPoint> sleepIntervals, 483 List<HealthKitRawDataPoint> sleepIntervals,
@@ -598,7 +528,7 @@ class LocalHealthDataConvert { @@ -598,7 +528,7 @@ class LocalHealthDataConvert {
598 day: day, 528 day: day,
599 move: latest?.activeEnergyBurned ?? 0, 529 move: latest?.activeEnergyBurned ?? 0,
600 steps: latest?.appleMoveTime ?? 0, 530 steps: latest?.appleMoveTime ?? 0,
601 - standSeconds: latest?.appleStandHours ?? 0, 531 + standHours: latest?.appleStandHours ?? 0,
602 exerciseSeconds: latest?.appleExerciseTime ?? 0, 532 exerciseSeconds: latest?.appleExerciseTime ?? 0,
603 moveGoal: latest?.activeEnergyBurnedGoal, 533 moveGoal: latest?.activeEnergyBurnedGoal,
604 stepGoal: latest?.appleMoveTimeGoal, 534 stepGoal: latest?.appleMoveTimeGoal,
@@ -648,30 +578,33 @@ class LocalHealthDataConvert { @@ -648,30 +578,33 @@ class LocalHealthDataConvert {
648 static _HrvDaySummary _hrvDaySummary( 578 static _HrvDaySummary _hrvDaySummary(
649 DateTime day, 579 DateTime day,
650 List<HealthRawHrvStressPoint> hrv, 580 List<HealthRawHrvStressPoint> hrv,
651 - List<HealthRawRealtimeStressPoint> realtime, 581 + List<HealthRawDailyStressPoint> dailyStress,
652 ) { 582 ) {
653 final start = unixSeconds(day); 583 final start = unixSeconds(day);
654 final end = unixSeconds(day.add(const Duration(days: 1))); 584 final end = unixSeconds(day.add(const Duration(days: 1)));
655 final dayHrv = 585 final dayHrv =
656 hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList(); 586 hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList();
657 - final dayRealtime = realtime  
658 - .where((e) => e.rawEndTime >= start && e.rawEndTime < end)  
659 - .toList(); 587 + final stress = _dailyStressPointForDay(day, dailyStress);
660 final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList()); 588 final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
661 - final stressState = _dailyStressState(  
662 - realtime,  
663 - startTime: start,  
664 - endTime: end - 1,  
665 - );  
666 return _HrvDaySummary( 589 return _HrvDaySummary(
667 day: day, 590 day: day,
668 hrvAverage: hrvAverage?.toDouble(), 591 hrvAverage: hrvAverage?.toDouble(),
669 - hrAverage:  
670 - _averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),  
671 - state: stressState, 592 + hrAverage: stress?.stressScore.toDouble(),
  593 + state: stress?.state.value,
672 ); 594 );
673 } 595 }
674 596
  597 + static HealthRawDailyStressPoint? _dailyStressPointForDay(
  598 + DateTime day,
  599 + List<HealthRawDailyStressPoint> dailyStress,
  600 + ) {
  601 + final key = dateKey(day);
  602 + for (final item in dailyStress) {
  603 + if (item.date == key) return item;
  604 + }
  605 + return null;
  606 + }
  607 +
675 static List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) { 608 static List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) {
676 final byMonth = <int, List<_HrvDaySummary>>{}; 609 final byMonth = <int, List<_HrvDaySummary>>{};
677 for (final item in daily) { 610 for (final item in daily) {
@@ -702,28 +635,6 @@ class LocalHealthDataConvert { @@ -702,28 +635,6 @@ class LocalHealthDataConvert {
702 return result; 635 return result;
703 } 636 }
704 637
705 - static int? _dailyStressState(  
706 - List<HealthRawRealtimeStressPoint> realtimePoints, {  
707 - required int startTime,  
708 - required int endTime,  
709 - }) {  
710 - final dayPoints = realtimePoints  
711 - .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)  
712 - .toList();  
713 - final validValuePoints =  
714 - dayPoints.where((e) => _isValidStressValue(e.result)).toList();  
715 - final activityBuffers = _confirmedActivityBuffers(realtimePoints);  
716 - final validStressValues = validValuePoints  
717 - .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))  
718 - .map((e) => e.result)  
719 - .toList()  
720 - ..sort();  
721 - final dailyStress = _dailyStress(validStressValues);  
722 - return dailyStress == null  
723 - ? null  
724 - : healthRawRealtimeStressState(dailyStress).value;  
725 - }  
726 -  
727 static _HrvDaySummary? _extremeHrv( 638 static _HrvDaySummary? _extremeHrv(
728 List<_HrvDaySummary> daily, { 639 List<_HrvDaySummary> daily, {
729 required bool min, 640 required bool min,
@@ -943,16 +854,6 @@ class LocalHealthDataConvert { @@ -943,16 +854,6 @@ class LocalHealthDataConvert {
943 return _sum(values) / values.length; 854 return _sum(values) / values.length;
944 } 855 }
945 856
946 - static double? _median(List<double> values) {  
947 - if (values.isEmpty) return null;  
948 - final sorted = [...values]..sort();  
949 - final middle = sorted.length ~/ 2;  
950 - if (sorted.length.isEven) {  
951 - return (sorted[middle - 1] + sorted[middle]) / 2;  
952 - }  
953 - return sorted[middle];  
954 - }  
955 -  
956 static num? _maxOrNull(List<num> values) { 857 static num? _maxOrNull(List<num> values) {
957 if (values.isEmpty) return null; 858 if (values.isEmpty) return null;
958 return values.reduce(math.max); 859 return values.reduce(math.max);
@@ -962,15 +863,6 @@ class LocalHealthDataConvert { @@ -962,15 +863,6 @@ class LocalHealthDataConvert {
962 if (values.isEmpty) return null; 863 if (values.isEmpty) return null;
963 return values.reduce(math.min); 864 return values.reduce(math.min);
964 } 865 }
965 -  
966 - static double _clamp(double value, double lower, double upper) {  
967 - return math.min(math.max(value, lower), upper).toDouble();  
968 - }  
969 -  
970 - static double _round(double value, int places) {  
971 - final scale = math.pow(10, places).toDouble();  
972 - return (value * scale).round() / scale;  
973 - }  
974 } 866 }
975 867
976 class _SleepDaySummary { 868 class _SleepDaySummary {
@@ -1022,7 +914,7 @@ class _ActivityDaySummary { @@ -1022,7 +914,7 @@ class _ActivityDaySummary {
1022 required this.day, 914 required this.day,
1023 required this.move, 915 required this.move,
1024 required this.steps, 916 required this.steps,
1025 - required this.standSeconds, 917 + required this.standHours,
1026 required this.exerciseSeconds, 918 required this.exerciseSeconds,
1027 required this.moveGoal, 919 required this.moveGoal,
1028 required this.stepGoal, 920 required this.stepGoal,
@@ -1033,7 +925,7 @@ class _ActivityDaySummary { @@ -1033,7 +925,7 @@ class _ActivityDaySummary {
1033 final DateTime day; 925 final DateTime day;
1034 final num move; 926 final num move;
1035 final num steps; 927 final num steps;
1036 - final num standSeconds; 928 + final num standHours;
1037 final num exerciseSeconds; 929 final num exerciseSeconds;
1038 final num? moveGoal; 930 final num? moveGoal;
1039 final num? stepGoal; 931 final num? stepGoal;
@@ -117,22 +117,16 @@ class LocalHealthDataSource implements HealthDataSource { @@ -117,22 +117,16 @@ class LocalHealthDataSource implements HealthDataSource {
117 dateRangeType, 117 dateRangeType,
118 startDate, 118 startDate,
119 ); 119 );
120 - final allDays = [...previousDays, ...days];  
121 - final queryStart =  
122 - LocalHealthDataConvert.unixSeconds(allDays.first) - 12 * 60;  
123 - final queryEnd = LocalHealthDataConvert.unixSeconds(  
124 - days.last.add(const Duration(days: 1)),  
125 - ) +  
126 - 12 * 60;  
127 final hrvPoints = await coreService.queryHrvStressPoints( 120 final hrvPoints = await coreService.queryHrvStressPoints(
128 startTime: LocalHealthDataConvert.unixSeconds(days.first), 121 startTime: LocalHealthDataConvert.unixSeconds(days.first),
129 endTime: LocalHealthDataConvert.unixSeconds( 122 endTime: LocalHealthDataConvert.unixSeconds(
130 days.last.add(const Duration(days: 1)), 123 days.last.add(const Duration(days: 1)),
131 ), 124 ),
132 ); 125 );
133 - final realtimePoints = await coreService.queryRealtimeStressPoints(  
134 - startTime: queryStart,  
135 - endTime: queryEnd, 126 + final dailyStressPoints = await coreService.queryDailyStressPoints(
  127 + startDate: LocalHealthDataConvert.dateKey(
  128 + previousDays.isEmpty ? days.first : previousDays.first),
  129 + endDate: LocalHealthDataConvert.dateKey(days.last),
136 ); 130 );
137 131
138 return AppSuccess( 132 return AppSuccess(
@@ -141,7 +135,7 @@ class LocalHealthDataSource implements HealthDataSource { @@ -141,7 +135,7 @@ class LocalHealthDataSource implements HealthDataSource {
141 days: days, 135 days: days,
142 previousDays: previousDays, 136 previousDays: previousDays,
143 hrvPoints: hrvPoints, 137 hrvPoints: hrvPoints,
144 - realtimePoints: realtimePoints, 138 + dailyStressPoints: dailyStressPoints,
145 ), 139 ),
146 ); 140 );
147 } catch (error) { 141 } catch (error) {
@@ -288,16 +282,13 @@ class LocalHealthDataSource implements HealthDataSource { @@ -288,16 +282,13 @@ class LocalHealthDataSource implements HealthDataSource {
288 Future<AppResult<V2StressScore>> getV2StressScore( 282 Future<AppResult<V2StressScore>> getV2StressScore(
289 int? queryUserId, int intDate) async { 283 int? queryUserId, int intDate) async {
290 try { 284 try {
291 - final (startTime, endTime) = _dayRange(intDate);  
292 - final realtimePoints = await coreService.queryRealtimeStressPoints(  
293 - startTime: startTime - 12 * 60,  
294 - endTime: endTime + 12 * 60, 285 + final dailyStressPoints = await coreService.queryDailyStressPoints(
  286 + startDate: intDate,
  287 + endDate: intDate,
295 ); 288 );
296 return AppSuccess( 289 return AppSuccess(
297 LocalHealthDataConvert.v2StressScore( 290 LocalHealthDataConvert.v2StressScore(
298 - realtimePoints,  
299 - startTime: startTime,  
300 - endTime: endTime, 291 + dailyStressPoints.isEmpty ? null : dailyStressPoints.last,
301 ), 292 ),
302 ); 293 );
303 } catch (error) { 294 } catch (error) {
@@ -124,6 +124,110 @@ void main() { @@ -124,6 +124,110 @@ void main() {
124 expect(rows.hrvRows.first['baseline_hrv'], greaterThan(0)); 124 expect(rows.hrvRows.first['baseline_hrv'], greaterThan(0));
125 expect(rows.hrvRows.first['baseline_awake_hrv'], greaterThan(0)); 125 expect(rows.hrvRows.first['baseline_awake_hrv'], greaterThan(0));
126 expect(rows.hrvRows.first['baseline_resting_hr'], greaterThan(0)); 126 expect(rows.hrvRows.first['baseline_resting_hr'], greaterThan(0));
  127 + expect(
  128 + store.operationLog.indexOf('upsertResult'),
  129 + lessThan(store.operationLog.indexOf('queryRealtimeStressPoints')),
  130 + );
  131 + expect(
  132 + store.operationLog.indexOf('queryRealtimeStressPoints'),
  133 + lessThan(store.operationLog.indexOf('upsertDailyStressPoints')),
  134 + );
  135 + });
  136 +
  137 + test('daily stress keeps existing historical rows and does not recalculate',
  138 + () async {
  139 + final oldDay = DateTime.now().subtract(const Duration(days: 2));
  140 + final oldStart = DateTime(oldDay.year, oldDay.month, oldDay.day)
  141 + .millisecondsSinceEpoch ~/
  142 + 1000;
  143 + final oldDate = oldDay.year * 10000 + oldDay.month * 100 + oldDay.day;
  144 + final api = _FakeHealthKitRawDataHostApi();
  145 + api.setPoints(HealthDataUploadType.heartRate.type, [
  146 + _point(oldStart + 60, 68),
  147 + _point(oldStart + 120, 70),
  148 + _point(oldStart + 180, 72),
  149 + ]);
  150 + api.setPoints(HealthDataUploadType.restingHeartRate.type, [
  151 + _point(oldStart + 30, 60),
  152 + _point(oldStart + 90, 61),
  153 + ]);
  154 +
  155 + final store = _MemoryHealthRawStressLocalStore();
  156 + store.insertDailyStress(
  157 + HealthRawDailyStressPoint(
  158 + userId: 42,
  159 + date: oldDate,
  160 + stressValue: 11,
  161 + stressScore: 11,
  162 + state: HealthRawStressState.excellent,
  163 + dataTime: oldStart,
  164 + uploaded: true,
  165 + ),
  166 + );
  167 + final service = HealthRawDataCoreService(
  168 + healthApi: _FakeHealthKitHostApi(),
  169 + rawDataApi: api,
  170 + localStore: store,
  171 + userIdProvider: () => 42,
  172 + );
  173 +
  174 + final result = await service.syncAndStore(
  175 + startTime: oldStart,
  176 + endTime: oldStart + Duration.secondsPerDay + 300,
  177 + readChunkDays: 1,
  178 + );
  179 +
  180 + expect(result.realtimeStressPoints, isNotEmpty);
  181 + expect(result.dailyStressPoints, isEmpty);
  182 + final daily = await service.queryDailyStressPoints(
  183 + startDate: oldDate,
  184 + endDate: oldDate,
  185 + );
  186 + expect(daily, hasLength(1));
  187 + expect(daily.single.stressScore, 11);
  188 + expect(daily.single.uploaded, isTrue);
  189 + });
  190 +
  191 + test('daily stress table upsert overwrites by date and resets uploaded',
  192 + () async {
  193 + final store = _MemoryHealthRawStressLocalStore();
  194 + store.insertDailyStress(
  195 + const HealthRawDailyStressPoint(
  196 + userId: 42,
  197 + date: 20260720,
  198 + stressValue: 10,
  199 + stressScore: 10,
  200 + state: HealthRawStressState.excellent,
  201 + dataTime: 1000,
  202 + uploaded: true,
  203 + ),
  204 + );
  205 +
  206 + await store.upsertDailyStressPoints(
  207 + userId: 42,
  208 + points: const [
  209 + HealthRawDailyStressPoint(
  210 + userId: 42,
  211 + date: 20260720,
  212 + stressValue: 66,
  213 + stressScore: 70,
  214 + state: HealthRawStressState.attention,
  215 + dataTime: 2000,
  216 + ),
  217 + ],
  218 + );
  219 +
  220 + final daily = await store.queryDailyStressPoints(
  221 + userId: 42,
  222 + startDate: 20260720,
  223 + endDate: 20260720,
  224 + );
  225 + expect(daily, hasLength(1));
  226 + expect(daily.single.stressValue, 66);
  227 + expect(daily.single.stressScore, 70);
  228 + expect(daily.single.state, HealthRawStressState.attention);
  229 + expect(daily.single.dataTime, 2000);
  230 + expect(daily.single.uploaded, isFalse);
127 }); 231 });
128 232
129 test('startCoreCaculate skips raw reads when health auth is missing', 233 test('startCoreCaculate skips raw reads when health auth is missing',
@@ -266,6 +370,18 @@ class _ReadCall { @@ -266,6 +370,18 @@ class _ReadCall {
266 class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { 370 class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
267 final Map<int, List<HealthRawHrvStressPoint>> _hrv = {}; 371 final Map<int, List<HealthRawHrvStressPoint>> _hrv = {};
268 final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {}; 372 final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {};
  373 + final Map<int, List<HealthRawDailyStressPoint>> _daily = {};
  374 + final List<String> operationLog = [];
  375 +
  376 + void insertDailyStress(HealthRawDailyStressPoint point) {
  377 + final byDate = <int, HealthRawDailyStressPoint>{
  378 + for (final item in _daily[point.userId] ?? <HealthRawDailyStressPoint>[])
  379 + item.date: item,
  380 + };
  381 + byDate[point.date] = point;
  382 + _daily[point.userId] = byDate.values.toList()
  383 + ..sort((a, b) => a.date.compareTo(b.date));
  384 + }
269 385
270 @override 386 @override
271 Future<void> ensureReadable(int userId) async {} 387 Future<void> ensureReadable(int userId) async {}
@@ -275,6 +391,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -275,6 +391,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
275 391
276 @override 392 @override
277 Future<void> upsertResult(HealthRawStressCalculationResult result) async { 393 Future<void> upsertResult(HealthRawStressCalculationResult result) async {
  394 + operationLog.add('upsertResult');
278 final hrvByTime = <int, HealthRawHrvStressPoint>{ 395 final hrvByTime = <int, HealthRawHrvStressPoint>{
279 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) 396 for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[])
280 point.rawEndTime: point, 397 point.rawEndTime: point,
@@ -318,6 +435,53 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -318,6 +435,53 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
318 } 435 }
319 436
320 @override 437 @override
  438 + Future<void> upsertDailyStressPoints({
  439 + required int userId,
  440 + required Iterable<HealthRawDailyStressPoint> points,
  441 + }) async {
  442 + operationLog.add('upsertDailyStressPoints');
  443 + final byDate = <int, HealthRawDailyStressPoint>{
  444 + for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[])
  445 + point.date: point,
  446 + };
  447 + for (final point in points) {
  448 + byDate[point.date] = HealthRawDailyStressPoint(
  449 + userId: point.userId,
  450 + date: point.date,
  451 + stressValue: point.stressValue,
  452 + stressScore: point.stressScore,
  453 + state: point.state,
  454 + dataTime: point.dataTime,
  455 + );
  456 + }
  457 + _daily[userId] = byDate.values.toList()
  458 + ..sort((a, b) => a.date.compareTo(b.date));
  459 + }
  460 +
  461 + @override
  462 + Future<Set<int>> existingDailyStressDates({
  463 + required int userId,
  464 + required Iterable<int> dates,
  465 + }) async {
  466 + final dateSet = dates.toSet();
  467 + return (_daily[userId] ?? <HealthRawDailyStressPoint>[])
  468 + .where((e) => dateSet.contains(e.date))
  469 + .map((e) => e.date)
  470 + .toSet();
  471 + }
  472 +
  473 + @override
  474 + Future<void> deleteDailyStressDates({
  475 + required int userId,
  476 + required Iterable<int> dates,
  477 + }) async {
  478 + final dateSet = dates.toSet();
  479 + _daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[])
  480 + .where((e) => !dateSet.contains(e.date))
  481 + .toList();
  482 + }
  483 +
  484 + @override
321 Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({ 485 Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({
322 required int userId, 486 required int userId,
323 required int startTime, 487 required int startTime,
@@ -334,12 +498,24 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -334,12 +498,24 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
334 required int startTime, 498 required int startTime,
335 required int endTime, 499 required int endTime,
336 }) async { 500 }) async {
  501 + operationLog.add('queryRealtimeStressPoints');
337 return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) 502 return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
338 .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime) 503 .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
339 .toList(); 504 .toList();
340 } 505 }
341 506
342 @override 507 @override
  508 + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
  509 + required int userId,
  510 + required int startDate,
  511 + required int endDate,
  512 + }) async {
  513 + return (_daily[userId] ?? <HealthRawDailyStressPoint>[])
  514 + .where((e) => e.date >= startDate && e.date <= endDate)
  515 + .toList();
  516 + }
  517 +
  518 + @override
343 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { 519 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
344 return HealthRawStressDbRows( 520 return HealthRawStressDbRows(
345 hrvRows: 521 hrvRows:
@@ -347,6 +523,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -347,6 +523,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
347 realtimeRows: (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) 523 realtimeRows: (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
348 .map(_realtimeRow) 524 .map(_realtimeRow)
349 .toList(), 525 .toList(),
  526 + dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[])
  527 + .map(_dailyStressRow)
  528 + .toList(),
350 ); 529 );
351 } 530 }
352 531
@@ -446,4 +625,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -446,4 +625,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
446 'uploaded': point.uploaded ? 1 : 0, 625 'uploaded': point.uploaded ? 1 : 0,
447 }; 626 };
448 } 627 }
  628 +
  629 + Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) {
  630 + return <String, Object?>{
  631 + 'date': point.date,
  632 + 'user_id': point.userId,
  633 + 'stress_value': point.stressValue,
  634 + 'stress_score': point.stressScore,
  635 + 'state': point.state.value,
  636 + 'data_time': point.dataTime,
  637 + 'uploaded': point.uploaded ? 1 : 0,
  638 + };
  639 + }
449 } 640 }
@@ -83,11 +83,10 @@ void main() { @@ -83,11 +83,10 @@ void main() {
83 }); 83 });
84 }); 84 });
85 85
86 - group('LocalHealthDataConvert v2StressScore', () { 86 + group('HealthRawDailyStressCalculator', () {
87 test('uses median and p75 after excluding confirmed activity buffer', () { 87 test('uses median and p75 after excluding confirmed activity buffer', () {
88 const userId = 1; 88 const userId = 1;
89 const dayStart = 100000; 89 const dayStart = 100000;
90 - const dayEnd = dayStart + Duration.secondsPerDay - 1;  
91 const flagsNone = HealthRawPointFlags.none(); 90 const flagsNone = HealthRawPointFlags.none();
92 const workoutFlags = HealthRawPointFlags( 91 const workoutFlags = HealthRawPointFlags(
93 isWorkout: true, 92 isWorkout: true,
@@ -117,8 +116,11 @@ void main() { @@ -117,8 +116,11 @@ void main() {
117 ); 116 );
118 } 117 }
119 118
120 - final score = LocalHealthDataConvert.v2StressScore(  
121 - [ 119 + final dailyStress = HealthRawDailyStressCalculator.calculate(
  120 + userId: userId,
  121 + date: 20260716,
  122 + dataTime: dayStart,
  123 + realtimePoints: [
122 point(600, 10), 124 point(600, 10),
123 point(1200, 20, flags: suspectedFlags), 125 point(1200, 20, flags: suspectedFlags),
124 point(1800, 30), 126 point(1800, 30),
@@ -128,18 +130,17 @@ void main() { @@ -128,18 +130,17 @@ void main() {
128 point(4200, 80), 130 point(4200, 80),
129 point(4800, 110), 131 point(4800, 110),
130 ], 132 ],
131 - startTime: dayStart,  
132 - endTime: dayEnd,  
133 ); 133 );
  134 + final score = LocalHealthDataConvert.v2StressScore(dailyStress);
134 135
135 expect(score.comprehensiveScore, 34); 136 expect(score.comprehensiveScore, 34);
136 expect(score.state, HealthRawStressState.normal.value); 137 expect(score.state, HealthRawStressState.normal.value);
  138 + expect(dailyStress?.stressValue, 36);
137 }); 139 });
138 140
139 test('uses activity buffer from adjacent day', () { 141 test('uses activity buffer from adjacent day', () {
140 const userId = 1; 142 const userId = 1;
141 const dayStart = 100000; 143 const dayStart = 100000;
142 - const dayEnd = dayStart + Duration.secondsPerDay - 1;  
143 const workoutFlags = HealthRawPointFlags( 144 const workoutFlags = HealthRawPointFlags(
144 isWorkout: true, 145 isWorkout: true,
145 isWorkoutRecovery: false, 146 isWorkoutRecovery: false,
@@ -147,8 +148,11 @@ void main() { @@ -147,8 +148,11 @@ void main() {
147 isSuspectedActivity: false, 148 isSuspectedActivity: false,
148 ); 149 );
149 150
150 - final score = LocalHealthDataConvert.v2StressScore(  
151 - [ 151 + final dailyStress = HealthRawDailyStressCalculator.calculate(
  152 + userId: userId,
  153 + date: 20260716,
  154 + dataTime: dayStart,
  155 + realtimePoints: [
152 HealthRawRealtimeStressPoint( 156 HealthRawRealtimeStressPoint(
153 userId: userId, 157 userId: userId,
154 rawEndTime: dayStart - 300, 158 rawEndTime: dayStart - 300,
@@ -172,9 +176,8 @@ void main() { @@ -172,9 +176,8 @@ void main() {
172 sourceEndTime: dayStart + 1000, 176 sourceEndTime: dayStart + 1000,
173 ), 177 ),
174 ], 178 ],
175 - startTime: dayStart,  
176 - endTime: dayEnd,  
177 ); 179 );
  180 + final score = LocalHealthDataConvert.v2StressScore(dailyStress);
178 181
179 expect(score.comprehensiveScore, 20); 182 expect(score.comprehensiveScore, 20);
180 expect(score.state, HealthRawStressState.excellent.value); 183 expect(score.state, HealthRawStressState.excellent.value);
@@ -186,7 +189,6 @@ void main() { @@ -186,7 +189,6 @@ void main() {
186 final currentDay = DateTime(2026, 7, 16); 189 final currentDay = DateTime(2026, 7, 16);
187 final previousDay = DateTime(2026, 7, 9); 190 final previousDay = DateTime(2026, 7, 9);
188 final currentStart = LocalHealthDataConvert.unixSeconds(currentDay); 191 final currentStart = LocalHealthDataConvert.unixSeconds(currentDay);
189 - final previousStart = LocalHealthDataConvert.unixSeconds(previousDay);  
190 192
191 HealthRawHrvStressPoint hrvPoint(int time, double value) { 193 HealthRawHrvStressPoint hrvPoint(int time, double value) {
192 return HealthRawHrvStressPoint( 194 return HealthRawHrvStressPoint(
@@ -204,13 +206,17 @@ void main() { @@ -204,13 +206,17 @@ void main() {
204 ); 206 );
205 } 207 }
206 208
207 - HealthRawRealtimeStressPoint stressPoint(int time, double value) {  
208 - return HealthRawRealtimeStressPoint( 209 + HealthRawDailyStressPoint dailyStressPoint(
  210 + DateTime day,
  211 + int score,
  212 + ) {
  213 + return HealthRawDailyStressPoint(
209 userId: 1, 214 userId: 1,
210 - rawEndTime: time,  
211 - result: value,  
212 - sourceStartTime: time,  
213 - sourceEndTime: time, 215 + date: LocalHealthDataConvert.dateKey(day),
  216 + stressValue: score.toDouble(),
  217 + stressScore: score,
  218 + state: healthRawRealtimeStressState(score),
  219 + dataTime: LocalHealthDataConvert.unixSeconds(day),
214 ); 220 );
215 } 221 }
216 222
@@ -221,10 +227,9 @@ void main() { @@ -221,10 +227,9 @@ void main() {
221 hrvPoints: [ 227 hrvPoints: [
222 hrvPoint(currentStart + 600, 80), 228 hrvPoint(currentStart + 600, 80),
223 ], 229 ],
224 - realtimePoints: [  
225 - stressPoint(currentStart + 600, 10),  
226 - stressPoint(currentStart + 1200, 80),  
227 - stressPoint(previousStart + 600, 90), 230 + dailyStressPoints: [
  231 + dailyStressPoint(currentDay, 34),
  232 + dailyStressPoint(previousDay, 90),
228 ], 233 ],
229 ); 234 );
230 235
@@ -244,27 +249,20 @@ void main() { @@ -244,27 +249,20 @@ void main() {
244 249
245 test('keeps yearly months that only have comprehensive stress data', () { 250 test('keeps yearly months that only have comprehensive stress data', () {
246 final mayDay = DateTime(2026, 5, 12); 251 final mayDay = DateTime(2026, 5, 12);
247 - final mayStart = LocalHealthDataConvert.unixSeconds(mayDay);  
248 252
249 final statistics = LocalHealthDataConvert.hrvStatistics( 253 final statistics = LocalHealthDataConvert.hrvStatistics(
250 dateRangeType: 2, 254 dateRangeType: 2,
251 days: LocalHealthDataConvert.rangeDays(2, 20260101), 255 days: LocalHealthDataConvert.rangeDays(2, 20260101),
252 previousDays: const [], 256 previousDays: const [],
253 hrvPoints: const [], 257 hrvPoints: const [],
254 - realtimePoints: [  
255 - HealthRawRealtimeStressPoint( 258 + dailyStressPoints: [
  259 + HealthRawDailyStressPoint(
256 userId: 1, 260 userId: 1,
257 - rawEndTime: mayStart + 600,  
258 - result: 70,  
259 - sourceStartTime: mayStart + 600,  
260 - sourceEndTime: mayStart + 600,  
261 - ),  
262 - HealthRawRealtimeStressPoint(  
263 - userId: 1,  
264 - rawEndTime: mayStart + 1200,  
265 - result: 80,  
266 - sourceStartTime: mayStart + 1200,  
267 - sourceEndTime: mayStart + 1200, 261 + date: 20260512,
  262 + stressValue: 75,
  263 + stressScore: 77,
  264 + state: HealthRawStressState.attention,
  265 + dataTime: LocalHealthDataConvert.unixSeconds(mayDay),
268 ), 266 ),
269 ], 267 ],
270 ); 268 );
@@ -272,7 +270,7 @@ void main() { @@ -272,7 +270,7 @@ void main() {
272 expect(statistics.hrvTrendList, hasLength(1)); 270 expect(statistics.hrvTrendList, hasLength(1));
273 expect(statistics.hrvTrendList?.single.timeKey, 5); 271 expect(statistics.hrvTrendList?.single.timeKey, 5);
274 expect(statistics.hrvTrendList?.single.hrvAverage, isNull); 272 expect(statistics.hrvTrendList?.single.hrvAverage, isNull);
275 - expect(statistics.hrvTrendList?.single.hrAverage, 75); 273 + expect(statistics.hrvTrendList?.single.hrAverage, 77);
276 expect( 274 expect(
277 statistics.hrvTrendList?.single.state, 275 statistics.hrvTrendList?.single.state,
278 HealthRawStressState.attention.value, 276 HealthRawStressState.attention.value,
@@ -289,7 +287,8 @@ void main() { @@ -289,7 +287,8 @@ void main() {
289 }); 287 });
290 288
291 group('LocalHealthDataConvert activity unit conversion', () { 289 group('LocalHealthDataConvert activity unit conversion', () {
292 - test('passes through exercise and stand seconds from raw activity data', 290 + test(
  291 + 'passes through exercise seconds and stand hours from raw activity data',
293 () { 292 () {
294 final day = DateTime(2026, 7, 16); 293 final day = DateTime(2026, 7, 16);
295 final endTime = LocalHealthDataConvert.unixSeconds( 294 final endTime = LocalHealthDataConvert.unixSeconds(
@@ -304,8 +303,8 @@ void main() { @@ -304,8 +303,8 @@ void main() {
304 appleMoveTimeGoal: 10000, 303 appleMoveTimeGoal: 10000,
305 appleExerciseTime: 1800, 304 appleExerciseTime: 1800,
306 exerciseTimeGoal: 3600, 305 exerciseTimeGoal: 3600,
307 - appleStandHours: 21600,  
308 - standHoursGoal: 43200, 306 + appleStandHours: 6,
  307 + standHoursGoal: 12,
309 ), 308 ),
310 ]; 309 ];
311 310
@@ -330,13 +329,13 @@ void main() { @@ -330,13 +329,13 @@ void main() {
330 ); 329 );
331 330
332 expect(healthData.exercise, 1800); 331 expect(healthData.exercise, 1800);
333 - expect(healthData.stand, 21600); 332 + expect(healthData.stand, 6);
334 expect(target.exercise, 3600); 333 expect(target.exercise, 3600);
335 - expect(target.stand, 43200); 334 + expect(target.stand, 12);
336 expect(statistics.totalExercise, 1800); 335 expect(statistics.totalExercise, 1800);
337 - expect(statistics.totalStand, 21600); 336 + expect(statistics.totalStand, 6);
338 expect(statistics.activityTargetInfo?.exercise, 3600); 337 expect(statistics.activityTargetInfo?.exercise, 3600);
339 - expect(statistics.activityTargetInfo?.stand, 43200); 338 + expect(statistics.activityTargetInfo?.stand, 12);
340 }); 339 });
341 }); 340 });
342 } 341 }