Commit da69a8df1abc9da5066d5ca13d8422c84d408cb9

Authored by 权海
1 parent 356bf734

feat(ui):趋势显示压力综合值

@@ -16,6 +16,7 @@ const _sleepTypeAsleepDeep = 4; @@ -16,6 +16,7 @@ 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;
19 20
20 class LocalHealthDataConvert { 21 class LocalHealthDataConvert {
21 const LocalHealthDataConvert._(); 22 const LocalHealthDataConvert._();
@@ -38,6 +39,7 @@ class LocalHealthDataConvert { @@ -38,6 +39,7 @@ class LocalHealthDataConvert {
38 final previousStart = switch (dateRangeType) { 39 final previousStart = switch (dateRangeType) {
39 0 => start.subtract(const Duration(days: 7)), 40 0 => start.subtract(const Duration(days: 7)),
40 1 => DateTime(start.year, start.month - 1), 41 1 => DateTime(start.year, start.month - 1),
  42 + 2 => DateTime(start.year - 1),
41 _ => null, 43 _ => null,
42 }; 44 };
43 if (previousStart == null) return const <DateTime>[]; 45 if (previousStart == null) return const <DateTime>[];
@@ -277,15 +279,23 @@ class LocalHealthDataConvert { @@ -277,15 +279,23 @@ class LocalHealthDataConvert {
277 static HrvStatisticsDataV2 hrvStatistics({ 279 static HrvStatisticsDataV2 hrvStatistics({
278 required int dateRangeType, 280 required int dateRangeType,
279 required List<DateTime> days, 281 required List<DateTime> days,
  282 + required List<DateTime> previousDays,
280 required List<HealthRawHrvStressPoint> hrvPoints, 283 required List<HealthRawHrvStressPoint> hrvPoints,
281 required List<HealthRawRealtimeStressPoint> realtimePoints, 284 required List<HealthRawRealtimeStressPoint> realtimePoints,
282 }) { 285 }) {
283 - final daily = [  
284 - for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints),  
285 - ]; 286 + final daily = <_HrvDaySummary>[];
  287 + for (final day in days) {
  288 + final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
  289 + if (summary.hasData) daily.add(summary);
  290 + }
286 final validDaily = daily.where((e) => e.hrvAverage != null).toList(); 291 final validDaily = daily.where((e) => e.hrvAverage != null).toList();
  292 + final previousDaily = <_HrvDaySummary>[];
  293 + for (final day in previousDays) {
  294 + final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
  295 + if (summary.hasData) previousDaily.add(summary);
  296 + }
287 final trendList = dateRangeType == 2 297 final trendList = dateRangeType == 2
288 - ? _monthlyHrvTrend(validDaily) 298 + ? _monthlyHrvTrend(daily)
289 : [ 299 : [
290 for (final item in daily) 300 for (final item in daily)
291 HrvTrendList( 301 HrvTrendList(
@@ -296,7 +306,8 @@ class LocalHealthDataConvert { @@ -296,7 +306,8 @@ class LocalHealthDataConvert {
296 ), 306 ),
297 ]; 307 ];
298 308
299 - final distribution = _hrvDistribution(validDaily); 309 + final distribution = _stressStateDistribution(daily);
  310 + final previousDistribution = _stressStateDistribution(previousDaily);
300 final minDay = _extremeHrv(validDaily, min: true); 311 final minDay = _extremeHrv(validDaily, min: true);
301 final maxDay = _extremeHrv(validDaily, min: false); 312 final maxDay = _extremeHrv(validDaily, min: false);
302 313
@@ -309,13 +320,23 @@ class LocalHealthDataConvert { @@ -309,13 +320,23 @@ class LocalHealthDataConvert {
309 dayCounts: entry.value, 320 dayCounts: entry.value,
310 ), 321 ),
311 ], 322 ],
312 - dailyDistributionList: [  
313 - for (final item in validDaily)  
314 - DailyDistributionList(  
315 - date: dateKey(item.day),  
316 - hrvLevel: item.state, 323 + qoqHrvDistributionList: [
  324 + for (final entry in previousDistribution.entries)
  325 + QoqHrvDistributionList(
  326 + stressState: entry.key,
  327 + dayCounts: entry.value,
317 ), 328 ),
318 ], 329 ],
  330 + dailyDistributionList: dateRangeType == 2
  331 + ? [
  332 + for (final item in daily)
  333 + if (item.state != null)
  334 + DailyDistributionList(
  335 + date: dateKey(item.day),
  336 + hrvLevel: item.state,
  337 + ),
  338 + ]
  339 + : null,
319 hrvMin: minDay == null 340 hrvMin: minDay == null
320 ? null 341 ? null
321 : HrvMin( 342 : HrvMin(
@@ -451,18 +472,82 @@ class LocalHealthDataConvert { @@ -451,18 +472,82 @@ class LocalHealthDataConvert {
451 } 472 }
452 473
453 static V2StressScore v2StressScore( 474 static V2StressScore v2StressScore(
454 - List<HealthRawRealtimeStressPoint> realtimePoints,  
455 - ) {  
456 - final averageStress = _averageOrNull(  
457 - realtimePoints.map((e) => e.result).toList(),  
458 - );  
459 - final score = averageStress?.round(); 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();
460 return V2StressScore( 492 return V2StressScore(
461 state: score == null ? 0 : healthRawRealtimeStressState(score).value, 493 state: score == null ? 0 : healthRawRealtimeStressState(score).value,
462 comprehensiveScore: score, 494 comprehensiveScore: score,
463 ); 495 );
464 } 496 }
465 497
  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 +
466 static _SleepDaySummary _sleepDaySummary( 551 static _SleepDaySummary _sleepDaySummary(
467 DateTime day, 552 DateTime day,
468 List<HealthKitRawDataPoint> sleepIntervals, 553 List<HealthKitRawDataPoint> sleepIntervals,
@@ -480,7 +565,11 @@ class LocalHealthDataConvert { @@ -480,7 +565,11 @@ class LocalHealthDataConvert {
480 point.endTime > interval.startTime && 565 point.endTime > interval.startTime &&
481 point.endTime <= interval.endTime)) 566 point.endTime <= interval.endTime))
482 .toList(); 567 .toList();
483 - final sleepDuration = windowEnd - windowStart; 568 + final nullableScore = score == 0 ? null : score;
  569 + final sleepEvaluate = _sleepEvaluate(score);
  570 + final sleepDuration = nullableScore == null || sleepEvaluate == null
  571 + ? 0
  572 + : windowEnd - windowStart;
484 573
485 return _SleepDaySummary( 574 return _SleepDaySummary(
486 day: day, 575 day: day,
@@ -490,8 +579,8 @@ class LocalHealthDataConvert { @@ -490,8 +579,8 @@ class LocalHealthDataConvert {
490 asleepTime: intervals.isEmpty 579 asleepTime: intervals.isEmpty
491 ? null 580 ? null
492 : intervals.map((e) => e.startTime).reduce(math.min), 581 : intervals.map((e) => e.startTime).reduce(math.min),
493 - score: score == 0 ? null : score,  
494 - evaluate: _sleepEvaluate(score), 582 + score: nullableScore,
  583 + evaluate: sleepEvaluate,
495 sleepHr: sleepHr, 584 sleepHr: sleepHr,
496 ); 585 );
497 } 586 }
@@ -571,12 +660,17 @@ class LocalHealthDataConvert { @@ -571,12 +660,17 @@ class LocalHealthDataConvert {
571 .where((e) => e.rawEndTime >= start && e.rawEndTime < end) 660 .where((e) => e.rawEndTime >= start && e.rawEndTime < end)
572 .toList(); 661 .toList();
573 final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList()); 662 final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
  663 + final stressState = _dailyStressState(
  664 + realtime,
  665 + startTime: start,
  666 + endTime: end - 1,
  667 + );
574 return _HrvDaySummary( 668 return _HrvDaySummary(
575 day: day, 669 day: day,
576 hrvAverage: hrvAverage?.toDouble(), 670 hrvAverage: hrvAverage?.toDouble(),
577 hrAverage: 671 hrAverage:
578 _averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(), 672 _averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
579 - state: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage), 673 + state: stressState,
580 ); 674 );
581 } 675 }
582 676
@@ -600,7 +694,7 @@ class LocalHealthDataConvert { @@ -600,7 +694,7 @@ class LocalHealthDataConvert {
600 ]; 694 ];
601 } 695 }
602 696
603 - static Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) { 697 + static Map<int, int> _stressStateDistribution(List<_HrvDaySummary> daily) {
604 final result = <int, int>{}; 698 final result = <int, int>{};
605 for (final item in daily) { 699 for (final item in daily) {
606 final state = item.state; 700 final state = item.state;
@@ -610,6 +704,28 @@ class LocalHealthDataConvert { @@ -610,6 +704,28 @@ class LocalHealthDataConvert {
610 return result; 704 return result;
611 } 705 }
612 706
  707 + static int? _dailyStressState(
  708 + List<HealthRawRealtimeStressPoint> realtimePoints, {
  709 + required int startTime,
  710 + required int endTime,
  711 + }) {
  712 + final dayPoints = realtimePoints
  713 + .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
  714 + .toList();
  715 + final validValuePoints =
  716 + dayPoints.where((e) => _isValidStressValue(e.result)).toList();
  717 + final activityBuffers = _confirmedActivityBuffers(realtimePoints);
  718 + final validStressValues = validValuePoints
  719 + .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
  720 + .map((e) => e.result)
  721 + .toList()
  722 + ..sort();
  723 + final dailyStress = _dailyStress(validStressValues);
  724 + return dailyStress == null
  725 + ? null
  726 + : healthRawRealtimeStressState(dailyStress).value;
  727 + }
  728 +
613 static _HrvDaySummary? _extremeHrv( 729 static _HrvDaySummary? _extremeHrv(
614 List<_HrvDaySummary> daily, { 730 List<_HrvDaySummary> daily, {
615 required bool min, 731 required bool min,
@@ -790,13 +906,6 @@ class LocalHealthDataConvert { @@ -790,13 +906,6 @@ class LocalHealthDataConvert {
790 return 3; 906 return 3;
791 } 907 }
792 908
793 - static int _hrvStateFromValue(num value) {  
794 - if (value >= 30) return 4;  
795 - if (value >= 21) return 3;  
796 - if (value >= 17) return 2;  
797 - return 1;  
798 - }  
799 -  
800 static int? _modeState(Iterable<int> states) { 909 static int? _modeState(Iterable<int> states) {
801 final counts = <int, int>{}; 910 final counts = <int, int>{};
802 for (final state in states) { 911 for (final state in states) {
@@ -836,6 +945,16 @@ class LocalHealthDataConvert { @@ -836,6 +945,16 @@ class LocalHealthDataConvert {
836 return _sum(values) / values.length; 945 return _sum(values) / values.length;
837 } 946 }
838 947
  948 + static double? _median(List<double> values) {
  949 + if (values.isEmpty) return null;
  950 + final sorted = [...values]..sort();
  951 + final middle = sorted.length ~/ 2;
  952 + if (sorted.length.isEven) {
  953 + return (sorted[middle - 1] + sorted[middle]) / 2;
  954 + }
  955 + return sorted[middle];
  956 + }
  957 +
839 static num? _maxOrNull(List<num> values) { 958 static num? _maxOrNull(List<num> values) {
840 if (values.isEmpty) return null; 959 if (values.isEmpty) return null;
841 return values.reduce(math.max); 960 return values.reduce(math.max);
@@ -845,6 +964,15 @@ class LocalHealthDataConvert { @@ -845,6 +964,15 @@ class LocalHealthDataConvert {
845 if (values.isEmpty) return null; 964 if (values.isEmpty) return null;
846 return values.reduce(math.min); 965 return values.reduce(math.min);
847 } 966 }
  967 +
  968 + static double _clamp(double value, double lower, double upper) {
  969 + return math.min(math.max(value, lower), upper).toDouble();
  970 + }
  971 +
  972 + static double _round(double value, int places) {
  973 + final scale = math.pow(10, places).toDouble();
  974 + return (value * scale).round() / scale;
  975 + }
848 } 976 }
849 977
850 class _SleepDaySummary { 978 class _SleepDaySummary {
@@ -933,4 +1061,6 @@ class _HrvDaySummary { @@ -933,4 +1061,6 @@ class _HrvDaySummary {
933 final double? hrvAverage; 1061 final double? hrvAverage;
934 final double? hrAverage; 1062 final double? hrAverage;
935 final int? state; 1063 final int? state;
  1064 +
  1065 + bool get hasData => hrvAverage != null || hrAverage != null || state != null;
936 } 1066 }
@@ -113,13 +113,22 @@ class LocalHealthDataSource implements HealthDataSource { @@ -113,13 +113,22 @@ class LocalHealthDataSource implements HealthDataSource {
113 final days = LocalHealthDataConvert.rangeDays(dateRangeType, startDate); 113 final days = LocalHealthDataConvert.rangeDays(dateRangeType, startDate);
114 if (days.isEmpty) return AppSuccess(HrvStatisticsDataV2()); 114 if (days.isEmpty) return AppSuccess(HrvStatisticsDataV2());
115 115
116 - final queryStart = LocalHealthDataConvert.unixSeconds(days.first);  
117 - final queryEnd = LocalHealthDataConvert.unixSeconds(  
118 - days.last.add(const Duration(days: 1)), 116 + final previousDays = LocalHealthDataConvert.previousRangeDays(
  117 + dateRangeType,
  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;
120 final hrvPoints = await coreService.queryHrvStressPoints( 127 final hrvPoints = await coreService.queryHrvStressPoints(
121 - startTime: queryStart,  
122 - endTime: queryEnd, 128 + startTime: LocalHealthDataConvert.unixSeconds(days.first),
  129 + endTime: LocalHealthDataConvert.unixSeconds(
  130 + days.last.add(const Duration(days: 1)),
  131 + ),
123 ); 132 );
124 final realtimePoints = await coreService.queryRealtimeStressPoints( 133 final realtimePoints = await coreService.queryRealtimeStressPoints(
125 startTime: queryStart, 134 startTime: queryStart,
@@ -130,6 +139,7 @@ class LocalHealthDataSource implements HealthDataSource { @@ -130,6 +139,7 @@ class LocalHealthDataSource implements HealthDataSource {
130 LocalHealthDataConvert.hrvStatistics( 139 LocalHealthDataConvert.hrvStatistics(
131 dateRangeType: dateRangeType, 140 dateRangeType: dateRangeType,
132 days: days, 141 days: days,
  142 + previousDays: previousDays,
133 hrvPoints: hrvPoints, 143 hrvPoints: hrvPoints,
134 realtimePoints: realtimePoints, 144 realtimePoints: realtimePoints,
135 ), 145 ),
@@ -280,10 +290,16 @@ class LocalHealthDataSource implements HealthDataSource { @@ -280,10 +290,16 @@ class LocalHealthDataSource implements HealthDataSource {
280 try { 290 try {
281 final (startTime, endTime) = _dayRange(intDate); 291 final (startTime, endTime) = _dayRange(intDate);
282 final realtimePoints = await coreService.queryRealtimeStressPoints( 292 final realtimePoints = await coreService.queryRealtimeStressPoints(
283 - startTime: startTime,  
284 - endTime: endTime, 293 + startTime: startTime - 12 * 60,
  294 + endTime: endTime + 12 * 60,
  295 + );
  296 + return AppSuccess(
  297 + LocalHealthDataConvert.v2StressScore(
  298 + realtimePoints,
  299 + startTime: startTime,
  300 + endTime: endTime,
  301 + ),
285 ); 302 );
286 - return AppSuccess(LocalHealthDataConvert.v2StressScore(realtimePoints));  
287 } catch (error) { 303 } catch (error) {
288 return AppFailure(AppUnknownError(error)); 304 return AppFailure(AppUnknownError(error));
289 } 305 }
@@ -38,10 +38,13 @@ class HrvStatisticsDataV2 { @@ -38,10 +38,13 @@ class HrvStatisticsDataV2 {
38 } 38 }
39 39
40 List<HrvTrendList>? hrvTrendList; 40 List<HrvTrendList>? hrvTrendList;
  41 + /// 当前区间,每日综合压力
41 List<HrvDistributionList>? hrvDistributionList; 42 List<HrvDistributionList>? hrvDistributionList;
  43 + /// 环比(上周上月上年)区间,每日综合压力
42 List<QoqHrvDistributionList>? qoqHrvDistributionList; 44 List<QoqHrvDistributionList>? qoqHrvDistributionList;
43 HrvMin? hrvMin; 45 HrvMin? hrvMin;
44 HrvMax? hrvMax; 46 HrvMax? hrvMax;
  47 + /// 年,每日综合压力, 只有年需要塞这个数据
45 List<DailyDistributionList>? dailyDistributionList; 48 List<DailyDistributionList>? dailyDistributionList;
46 49
47 Map<String, dynamic> toJson() { 50 Map<String, dynamic> toJson() {
@@ -199,6 +202,7 @@ class HrvTrendList { @@ -199,6 +202,7 @@ class HrvTrendList {
199 Object? timeKey; 202 Object? timeKey;
200 double? hrvAverage; 203 double? hrvAverage;
201 double? hrAverage; 204 double? hrAverage;
  205 + /// 当天综合压力
202 int? state; 206 int? state;
203 207
204 Map<String, dynamic> toJson() { 208 Map<String, dynamic> toJson() {
  1 +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
1 import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart'; 2 import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
2 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 3 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
3 import 'package:flutter_test/flutter_test.dart'; 4 import 'package:flutter_test/flutter_test.dart';
@@ -56,5 +57,234 @@ void main() { @@ -56,5 +57,234 @@ void main() {
56 const Duration(hours: 7, minutes: 4).inSeconds, 57 const Duration(hours: 7, minutes: 4).inSeconds,
57 ); 58 );
58 }); 59 });
  60 +
  61 + test('sets sleep duration to zero when sleep score is unavailable', () {
  62 + final day = DateTime(2026, 7, 16);
  63 + final awakeStart = DateTime(2026, 7, 16, 1);
  64 + final awakeEnd = DateTime(2026, 7, 16, 2);
  65 + final statistics = LocalHealthDataConvert.sleepStatistics(
  66 + dateRangeType: 3,
  67 + days: [day],
  68 + previousDays: const [],
  69 + sleepIntervals: [
  70 + HealthKitRawDataPoint(
  71 + dataType: 2,
  72 + startTime: LocalHealthDataConvert.unixSeconds(awakeStart),
  73 + endTime: LocalHealthDataConvert.unixSeconds(awakeEnd),
  74 + ),
  75 + ],
  76 + heartRate: const [],
  77 + );
  78 +
  79 + expect(statistics.sleepTrendList?.single.totalTime, 0);
  80 + expect(statistics.sleepTrendList?.single.score, isNull);
  81 + expect(statistics.sleepTrendList?.single.sleepEvaluate, isNull);
  82 + expect(statistics.avgSleepDuration, isNull);
  83 + });
  84 + });
  85 +
  86 + group('LocalHealthDataConvert v2StressScore', () {
  87 + test('uses median and p75 after excluding confirmed activity buffer', () {
  88 + const userId = 1;
  89 + const dayStart = 100000;
  90 + const dayEnd = dayStart + Duration.secondsPerDay - 1;
  91 + const flagsNone = HealthRawPointFlags.none();
  92 + const workoutFlags = HealthRawPointFlags(
  93 + isWorkout: true,
  94 + isWorkoutRecovery: false,
  95 + isSleepLikely: false,
  96 + isSuspectedActivity: false,
  97 + );
  98 + const suspectedFlags = HealthRawPointFlags(
  99 + isWorkout: false,
  100 + isWorkoutRecovery: false,
  101 + isSleepLikely: false,
  102 + isSuspectedActivity: true,
  103 + );
  104 +
  105 + HealthRawRealtimeStressPoint point(
  106 + int offset,
  107 + double value, {
  108 + HealthRawPointFlags flags = flagsNone,
  109 + }) {
  110 + return HealthRawRealtimeStressPoint(
  111 + userId: userId,
  112 + rawEndTime: dayStart + offset,
  113 + result: value,
  114 + sourceStartTime: dayStart + offset,
  115 + sourceEndTime: dayStart + offset,
  116 + flags: flags,
  117 + );
  118 + }
  119 +
  120 + final score = LocalHealthDataConvert.v2StressScore(
  121 + [
  122 + point(600, 10),
  123 + point(1200, 20, flags: suspectedFlags),
  124 + point(1800, 30),
  125 + point(2000, 40),
  126 + point(3000, 90, flags: workoutFlags),
  127 + point(3300, 100),
  128 + point(4200, 80),
  129 + point(4800, 110),
  130 + ],
  131 + startTime: dayStart,
  132 + endTime: dayEnd,
  133 + );
  134 +
  135 + expect(score.comprehensiveScore, 34);
  136 + expect(score.state, HealthRawStressState.normal.value);
  137 + });
  138 +
  139 + test('uses activity buffer from adjacent day', () {
  140 + const userId = 1;
  141 + const dayStart = 100000;
  142 + const dayEnd = dayStart + Duration.secondsPerDay - 1;
  143 + const workoutFlags = HealthRawPointFlags(
  144 + isWorkout: true,
  145 + isWorkoutRecovery: false,
  146 + isSleepLikely: false,
  147 + isSuspectedActivity: false,
  148 + );
  149 +
  150 + final score = LocalHealthDataConvert.v2StressScore(
  151 + [
  152 + HealthRawRealtimeStressPoint(
  153 + userId: userId,
  154 + rawEndTime: dayStart - 300,
  155 + result: 80,
  156 + sourceStartTime: dayStart - 300,
  157 + sourceEndTime: dayStart - 300,
  158 + flags: workoutFlags,
  159 + ),
  160 + HealthRawRealtimeStressPoint(
  161 + userId: userId,
  162 + rawEndTime: dayStart + 100,
  163 + result: 90,
  164 + sourceStartTime: dayStart + 100,
  165 + sourceEndTime: dayStart + 100,
  166 + ),
  167 + HealthRawRealtimeStressPoint(
  168 + userId: userId,
  169 + rawEndTime: dayStart + 1000,
  170 + result: 20,
  171 + sourceStartTime: dayStart + 1000,
  172 + sourceEndTime: dayStart + 1000,
  173 + ),
  174 + ],
  175 + startTime: dayStart,
  176 + endTime: dayEnd,
  177 + );
  178 +
  179 + expect(score.comprehensiveScore, 20);
  180 + expect(score.state, HealthRawStressState.excellent.value);
  181 + });
  182 + });
  183 +
  184 + group('LocalHealthDataConvert hrvStatistics', () {
  185 + test('uses daily comprehensive stress for trend and distributions', () {
  186 + final currentDay = DateTime(2026, 7, 16);
  187 + final previousDay = DateTime(2026, 7, 9);
  188 + final currentStart = LocalHealthDataConvert.unixSeconds(currentDay);
  189 + final previousStart = LocalHealthDataConvert.unixSeconds(previousDay);
  190 +
  191 + HealthRawHrvStressPoint hrvPoint(int time, double value) {
  192 + return HealthRawHrvStressPoint(
  193 + userId: 1,
  194 + rawEndTime: time,
  195 + rawHrv: value,
  196 + result: value,
  197 + sourceStartTime: time,
  198 + sourceEndTime: time,
  199 + state: HealthRawStressState.excellent,
  200 + baselineHrv: 50,
  201 + baselineAwakeHrv: 50,
  202 + baselineSleepHrv: null,
  203 + baselineRestingHr: 65,
  204 + );
  205 + }
  206 +
  207 + HealthRawRealtimeStressPoint stressPoint(int time, double value) {
  208 + return HealthRawRealtimeStressPoint(
  209 + userId: 1,
  210 + rawEndTime: time,
  211 + result: value,
  212 + sourceStartTime: time,
  213 + sourceEndTime: time,
  214 + );
  215 + }
  216 +
  217 + final statistics = LocalHealthDataConvert.hrvStatistics(
  218 + dateRangeType: 0,
  219 + days: [currentDay],
  220 + previousDays: [previousDay],
  221 + hrvPoints: [
  222 + hrvPoint(currentStart + 600, 80),
  223 + ],
  224 + realtimePoints: [
  225 + stressPoint(currentStart + 600, 10),
  226 + stressPoint(currentStart + 1200, 80),
  227 + stressPoint(previousStart + 600, 90),
  228 + ],
  229 + );
  230 +
  231 + expect(statistics.hrvTrendList?.single.hrvAverage, 80);
  232 + expect(
  233 + statistics.hrvTrendList?.single.state,
  234 + HealthRawStressState.normal.value,
  235 + );
  236 + expect(statistics.hrvDistributionList?.single.stressId,
  237 + HealthRawStressState.normal.value);
  238 + expect(statistics.hrvDistributionList?.single.dayCounts, 1);
  239 + expect(statistics.qoqHrvDistributionList?.single.stressState,
  240 + HealthRawStressState.overload.value);
  241 + expect(statistics.qoqHrvDistributionList?.single.dayCounts, 1);
  242 + expect(statistics.dailyDistributionList, isNull);
  243 + });
  244 +
  245 + test('keeps yearly months that only have comprehensive stress data', () {
  246 + final mayDay = DateTime(2026, 5, 12);
  247 + final mayStart = LocalHealthDataConvert.unixSeconds(mayDay);
  248 +
  249 + final statistics = LocalHealthDataConvert.hrvStatistics(
  250 + dateRangeType: 2,
  251 + days: LocalHealthDataConvert.rangeDays(2, 20260101),
  252 + previousDays: const [],
  253 + hrvPoints: const [],
  254 + realtimePoints: [
  255 + HealthRawRealtimeStressPoint(
  256 + 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,
  268 + ),
  269 + ],
  270 + );
  271 +
  272 + expect(statistics.hrvTrendList, hasLength(1));
  273 + expect(statistics.hrvTrendList?.single.timeKey, 5);
  274 + expect(statistics.hrvTrendList?.single.hrvAverage, isNull);
  275 + expect(statistics.hrvTrendList?.single.hrAverage, 75);
  276 + expect(
  277 + statistics.hrvTrendList?.single.state,
  278 + HealthRawStressState.attention.value,
  279 + );
  280 + expect(statistics.dailyDistributionList, hasLength(1));
  281 + expect(statistics.dailyDistributionList?.single.date, 20260512);
  282 + expect(
  283 + statistics.dailyDistributionList?.map((e) => e.date),
  284 + isNot(contains(20260101)),
  285 + );
  286 + expect(statistics.hrvDistributionList?.single.stressId,
  287 + HealthRawStressState.attention.value);
  288 + });
59 }); 289 });
60 } 290 }