diff --git a/openhealth/lib/main.dart b/openhealth/lib/main.dart index 5e83274..5fe5b06 100644 --- a/openhealth/lib/main.dart +++ b/openhealth/lib/main.dart @@ -6,12 +6,19 @@ import 'package:openglucose/src/app_controller.dart'; import 'package:openglucose/src/dashboard_chart.dart'; import 'package:openglucose/src/display_preferences.dart'; import 'package:openglucose/src/driver_factory.dart'; +import 'package:openglucose/src/metrics_section.dart'; import 'package:openglucose/src/session_presentation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// When built with `--dart-define=OG_DEMO=true`, the app runs the in-memory +/// demo driver (see [buildDefaultDriver]) and auto-connects on launch so it +/// lands directly on the populated dashboard — used for simulator/feature +/// verification. Defaults to false; production builds are unaffected. +const bool kOgDemo = bool.fromEnvironment('OG_DEMO', defaultValue: false); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -35,9 +42,31 @@ Future _bootstrap() async { driver: buildDefaultDriver(), ); await controller.initialize(); + if (kOgDemo) { + // In demo mode (simulator/feature verification) the demo driver only + // surfaces its sensor after a scan, so auto-scan and auto-connect to land + // directly on the populated dashboard. Strictly gated behind OG_DEMO and + // skipped when a previous session was already restored from preferences. + unawaited(_autoConnectDemoSensor(controller)); + } return controller; } +/// Scans with the demo driver and connects to the first discovered sensor so +/// OG_DEMO builds open straight onto the populated dashboard. +Future _autoConnectDemoSensor(CgmAppController controller) async { + if (controller.snapshot != null) { + // A persisted session is already (re)connecting; don't interfere. + return; + } + await controller.scan(); + final sensors = controller.sensors; + if (sensors.isEmpty) { + return; + } + await controller.connect(sensors.first); +} + class _BootstrapApp extends StatefulWidget { const _BootstrapApp(); @@ -124,11 +153,7 @@ class _SpinningLogoState extends State<_SpinningLogo> Widget build(BuildContext context) { return RotationTransition( turns: _controller, - child: Image.asset( - 'assets/icon/logo.png', - width: 140, - height: 140, - ), + child: Image.asset('assets/icon/logo.png', width: 140, height: 140), ); } } @@ -555,6 +580,17 @@ class _DashboardView extends StatelessWidget { ), ), ), + // --- TASK-012 metrics pack (explainable wellness patterns) --- + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: MetricsSection( + readings: history, + preferences: preferences, + ), + ), + ), + // --- end TASK-012 metrics pack --- SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), diff --git a/openhealth/lib/src/driver_factory_io.dart b/openhealth/lib/src/driver_factory_io.dart index 082a6c9..ab0f92d 100644 --- a/openhealth/lib/src/driver_factory_io.dart +++ b/openhealth/lib/src/driver_factory_io.dart @@ -2,6 +2,17 @@ import 'package:cgm_aidex/cgm_aidex.dart'; import 'package:cgm_ble_flutter/cgm_ble_flutter.dart'; import 'package:cgm_core/cgm_core.dart'; +import 'demo_driver.dart'; + +/// When built with `--dart-define=OG_DEMO=true`, native/simulator builds use the +/// in-memory [DemoCgmDriver] instead of the real BLE driver, so the app can be +/// exercised in the iOS simulator (which has no Bluetooth). Defaults to false, +/// so production builds are unchanged and keep using the real Aidex driver. +const bool kOgDemo = bool.fromEnvironment('OG_DEMO', defaultValue: false); + CgmDriver buildPlatformDriver() { + if (kOgDemo) { + return DemoCgmDriver(); + } return AidexSensorDriver(const FlutterBluePlusTransport()); } diff --git a/openhealth/lib/src/metrics_section.dart b/openhealth/lib/src/metrics_section.dart new file mode 100644 index 0000000..1760e76 --- /dev/null +++ b/openhealth/lib/src/metrics_section.dart @@ -0,0 +1,217 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:flutter/material.dart'; + +import 'display_preferences.dart'; + +/// Explainable, wellness-framed metrics section for the dashboard. +/// +/// Renders Time-in-Range, variability (CV/SD), average, an estimated +/// GMI-style indicator and a spike count for a selectable timeframe, each with +/// a one-line plain-language explanation. These are observations and patterns +/// for self-experimentation, not medical metrics or diagnosis. +class MetricsSection extends StatefulWidget { + const MetricsSection({ + super.key, + required this.readings, + required this.preferences, + }); + + final List readings; + final DisplayPreferences preferences; + + @override + State createState() => _MetricsSectionState(); +} + +class _MetricsSectionState extends State { + AnalyticsTimeframe _timeframe = AnalyticsTimeframe.last24h; + + static const Color _muted = Color(0xFF5B6E6A); + + String _formatGlucose(double mgdl) { + final value = widget.preferences.unit.convertFromMgdl(mgdl); + final digits = widget.preferences.unit == GlucoseUnit.mgdl ? 0 : 1; + return '${value.toStringAsFixed(digits)} ${widget.preferences.unit.label}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final stats = GlucoseAnalytics.summarize( + widget.readings, + timeframe: _timeframe, + ); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Patterns', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + _TimeframeSelector( + value: _timeframe, + onChanged: (value) => setState(() => _timeframe = value), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Observations for self-experimentation, not medical metrics.', + style: theme.textTheme.bodySmall?.copyWith(color: _muted), + ), + const SizedBox(height: 14), + if (!stats.hasData) + Text( + 'Not enough readings in this window yet.', + style: theme.textTheme.bodyMedium?.copyWith(color: _muted), + ) + else + Column( + children: [ + _MetricRow( + label: 'Time in range', + value: '${stats.timeInRangePercent.round()}%', + explanation: + 'Share of readings between ' + '${_formatGlucose(stats.bounds.lowMgdl)} and ' + '${_formatGlucose(stats.bounds.highMgdl)}.', + ), + _MetricRow( + label: 'Below / above', + value: + '${stats.timeBelowRangePercent.round()}% / ' + '${stats.timeAboveRangePercent.round()}%', + explanation: + 'How often readings sat under the low or over ' + 'the high mark.', + ), + _MetricRow( + label: 'Average', + value: _formatGlucose(stats.averageMgdl!), + explanation: 'Mean of all readings in this window.', + ), + _MetricRow( + label: 'Variability (CV)', + value: + '${stats.coefficientOfVariationPercent!.toStringAsFixed(0)}%', + explanation: + 'How spread out readings are around the average ' + '(SD ${_formatGlucose(stats.standardDeviationMgdl!)}). ' + 'Lower looks steadier.', + ), + _MetricRow( + label: 'Estimated GMI', + value: '~${stats.estimatedGmiPercent!.toStringAsFixed(1)}%', + explanation: + 'A rough indicator derived from your average. Not a ' + 'lab result.', + ), + _MetricRow( + label: 'Spikes', + value: '${stats.spikeCount}', + explanation: + 'Times readings rose past ' + '${_formatGlucose(stats.bounds.highMgdl)}.', + isLast: true, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _TimeframeSelector extends StatelessWidget { + const _TimeframeSelector({required this.value, required this.onChanged}); + + final AnalyticsTimeframe value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + segments: >[ + for (final timeframe in AnalyticsTimeframe.values) + ButtonSegment( + value: timeframe, + label: Text(timeframe.label), + ), + ], + selected: {value}, + showSelectedIcon: false, + onSelectionChanged: (selection) => onChanged(selection.first), + ); + } +} + +class _MetricRow extends StatelessWidget { + const _MetricRow({ + required this.label, + required this.value, + required this.explanation, + this.isLast = false, + }); + + final String label; + final String value; + final String explanation; + final bool isLast; + + static const Color _muted = Color(0xFF5B6E6A); + static const Color _accent = Color(0xFF24443F); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + label, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w900, + color: _accent, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + explanation, + style: theme.textTheme.bodySmall?.copyWith(color: _muted), + ), + ], + ), + ); + } +} diff --git a/openhealth/test/og_demo_flag_test.dart b/openhealth/test/og_demo_flag_test.dart new file mode 100644 index 0000000..ac56312 --- /dev/null +++ b/openhealth/test/og_demo_flag_test.dart @@ -0,0 +1,23 @@ +import 'package:cgm_aidex/cgm_aidex.dart'; +import 'package:cgm_core/cgm_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openglucose/src/demo_driver.dart'; +import 'package:openglucose/src/driver_factory_io.dart'; + +void main() { + group('OG_DEMO driver selection', () { + test('selects DemoCgmDriver when OG_DEMO is set, real driver otherwise', () { + final CgmDriver driver = buildPlatformDriver(); + + // `kOgDemo` is a compile-time constant from --dart-define=OG_DEMO. + // Run with `flutter test --dart-define=OG_DEMO=true` to exercise the + // demo branch; without the define, production behavior is asserted. + if (kOgDemo) { + expect(driver, isA()); + } else { + expect(driver, isA()); + expect(driver, isNot(isA())); + } + }); + }); +} diff --git a/packages/cgm_core/lib/cgm_core.dart b/packages/cgm_core/lib/cgm_core.dart index 8c16240..c18a95c 100644 --- a/packages/cgm_core/lib/cgm_core.dart +++ b/packages/cgm_core/lib/cgm_core.dart @@ -2,3 +2,4 @@ library; export 'src/cgm_models.dart'; export 'src/cgm_session.dart'; +export 'src/glucose_analytics.dart'; diff --git a/packages/cgm_core/lib/src/glucose_analytics.dart b/packages/cgm_core/lib/src/glucose_analytics.dart new file mode 100644 index 0000000..1a14b21 --- /dev/null +++ b/packages/cgm_core/lib/src/glucose_analytics.dart @@ -0,0 +1,277 @@ +import 'dart:math' as math; + +import 'cgm_models.dart'; + +/// Analytics timeframe windows for summarising recent readings. +/// +/// These are wellness/self-experimentation observation windows, not clinical +/// reporting periods. +enum AnalyticsTimeframe { + last24h, + last7d, + last14d; + + /// Length of the window. + Duration get duration => switch (this) { + AnalyticsTimeframe.last24h => const Duration(hours: 24), + AnalyticsTimeframe.last7d => const Duration(days: 7), + AnalyticsTimeframe.last14d => const Duration(days: 14), + }; + + /// Short human label, e.g. "24h". + String get label => switch (this) { + AnalyticsTimeframe.last24h => '24h', + AnalyticsTimeframe.last7d => '7d', + AnalyticsTimeframe.last14d => '14d', + }; +} + +/// Inclusive low / exclusive-comparable range bounds (in mg/dL) used to bucket +/// readings into below / in-range / above. Defaults mirror the common +/// wellness "in range" band of 70–180 mg/dL (~3.9–10 mmol/L). +class GlucoseRangeBounds { + const GlucoseRangeBounds({this.lowMgdl = 70, this.highMgdl = 180}) + : assert(lowMgdl < highMgdl, 'lowMgdl must be below highMgdl'); + + final double lowMgdl; + final double highMgdl; + + static const GlucoseRangeBounds standard = GlucoseRangeBounds(); +} + +/// Immutable result of summarising a list of readings over a window. +/// +/// All glucose figures are kept in mg/dL; the UI layer converts to the user's +/// preferred unit. Percentages are 0..100. +class GlucoseStats { + const GlucoseStats({ + required this.timeframe, + required this.bounds, + required this.readingCount, + required this.timeInRangePercent, + required this.timeBelowRangePercent, + required this.timeAboveRangePercent, + required this.averageMgdl, + required this.standardDeviationMgdl, + required this.coefficientOfVariationPercent, + required this.estimatedGmiPercent, + required this.spikeCount, + required this.minMgdl, + required this.maxMgdl, + }); + + /// Empty result for when there are no readings in the window. + factory GlucoseStats.empty({ + required AnalyticsTimeframe timeframe, + required GlucoseRangeBounds bounds, + }) { + return GlucoseStats( + timeframe: timeframe, + bounds: bounds, + readingCount: 0, + timeInRangePercent: 0, + timeBelowRangePercent: 0, + timeAboveRangePercent: 0, + averageMgdl: null, + standardDeviationMgdl: null, + coefficientOfVariationPercent: null, + estimatedGmiPercent: null, + spikeCount: 0, + minMgdl: null, + maxMgdl: null, + ); + } + + final AnalyticsTimeframe timeframe; + final GlucoseRangeBounds bounds; + + /// Number of readings that fell inside the window. + final int readingCount; + + /// Share of readings inside [GlucoseRangeBounds] (0..100). + final double timeInRangePercent; + + /// Share of readings below the low bound (0..100). + final double timeBelowRangePercent; + + /// Share of readings above the high bound (0..100). + final double timeAboveRangePercent; + + /// Arithmetic mean of readings, or null when there are none. + final double? averageMgdl; + + /// Population standard deviation, or null when there are none. + final double? standardDeviationMgdl; + + /// Variability: SD / mean * 100, or null when not computable. + final double? coefficientOfVariationPercent; + + /// GMI-style estimate derived from the average. This is a rough + /// self-experimentation indicator, not a lab value. Null when no average. + final double? estimatedGmiPercent; + + /// Count of upward swings that crossed the high bound (a rise from + /// in/below-range up across [GlucoseRangeBounds.highMgdl]). + final int spikeCount; + + final double? minMgdl; + final double? maxMgdl; + + bool get hasData => readingCount > 0; +} + +/// Pure-Dart glucose analytics over [CgmReading] lists. +/// +/// Wellness framing: these surface observations and patterns for +/// self-experimentation. They are not medical metrics, diagnoses, or a +/// substitute for clinical measurement. +abstract final class GlucoseAnalytics { + /// Filters [readings] to those recorded within [timeframe] relative to [now] + /// (defaults to [DateTime.now]). Readings without a timestamp are excluded + /// because they cannot be placed in a window. + static List readingsInTimeframe( + List readings, + AnalyticsTimeframe timeframe, { + DateTime? now, + }) { + final reference = now ?? DateTime.now(); + final cutoff = reference.subtract(timeframe.duration); + return readings + .where((reading) { + final at = reading.recordedAt; + if (at == null) return false; + return !at.isBefore(cutoff) && !at.isAfter(reference); + }) + .toList(growable: false); + } + + /// Arithmetic mean of reading values in mg/dL, or null when empty. + static double? average(List readings) { + if (readings.isEmpty) return null; + var sum = 0.0; + for (final reading in readings) { + sum += reading.valueMgdl; + } + return sum / readings.length; + } + + /// Population standard deviation in mg/dL, or null when empty. + static double? standardDeviation(List readings) { + final mean = average(readings); + if (mean == null) return null; + var sumSq = 0.0; + for (final reading in readings) { + final delta = reading.valueMgdl - mean; + sumSq += delta * delta; + } + return math.sqrt(sumSq / readings.length); + } + + /// Coefficient of variation as a percentage (SD / mean * 100), or null. + static double? coefficientOfVariation(List readings) { + final mean = average(readings); + final sd = standardDeviation(readings); + if (mean == null || sd == null || mean == 0) return null; + return sd / mean * 100; + } + + /// GMI-style estimate from the mean glucose, using the widely cited + /// linear approximation `3.31 + 0.02392 * meanMgdl`. Returns a rough + /// self-experimentation indicator (percent), not a lab A1c. Null when empty. + static double? estimatedGmi(List readings) { + final mean = average(readings); + if (mean == null) return null; + return 3.31 + 0.02392 * mean; + } + + /// Counts upward swings that cross [bounds.highMgdl]: each transition from a + /// value at/below the high bound to a value above it counts once. Consecutive + /// above-range readings are a single spike until the value drops back to/below + /// the bound. + static int spikeCount( + List readings, { + GlucoseRangeBounds bounds = GlucoseRangeBounds.standard, + }) { + final ordered = _orderedByTime(readings); + var count = 0; + var wasAbove = false; + for (final reading in ordered) { + final isAbove = reading.valueMgdl > bounds.highMgdl; + if (isAbove && !wasAbove) count++; + wasAbove = isAbove; + } + return count; + } + + /// Computes the full [GlucoseStats] bundle for [readings] over [timeframe]. + /// + /// Pass already-windowed readings via [preFiltered] to skip the timeframe + /// filter (useful when the caller has its own windowing); otherwise the list + /// is filtered against [timeframe] relative to [now]. + static GlucoseStats summarize( + List readings, { + required AnalyticsTimeframe timeframe, + GlucoseRangeBounds bounds = GlucoseRangeBounds.standard, + DateTime? now, + bool preFiltered = false, + }) { + final windowed = preFiltered + ? readings + : readingsInTimeframe(readings, timeframe, now: now); + if (windowed.isEmpty) { + return GlucoseStats.empty(timeframe: timeframe, bounds: bounds); + } + + var below = 0; + var inRange = 0; + var above = 0; + var min = windowed.first.valueMgdl; + var max = windowed.first.valueMgdl; + for (final reading in windowed) { + final value = reading.valueMgdl; + if (value < bounds.lowMgdl) { + below++; + } else if (value > bounds.highMgdl) { + above++; + } else { + inRange++; + } + if (value < min) min = value; + if (value > max) max = value; + } + + final total = windowed.length; + final mean = average(windowed); + final sd = standardDeviation(windowed); + final cv = coefficientOfVariation(windowed); + + return GlucoseStats( + timeframe: timeframe, + bounds: bounds, + readingCount: total, + timeInRangePercent: inRange / total * 100, + timeBelowRangePercent: below / total * 100, + timeAboveRangePercent: above / total * 100, + averageMgdl: mean, + standardDeviationMgdl: sd, + coefficientOfVariationPercent: cv, + estimatedGmiPercent: estimatedGmi(windowed), + spikeCount: spikeCount(windowed, bounds: bounds), + minMgdl: min, + maxMgdl: max, + ); + } + + static List _orderedByTime(List readings) { + final timed = readings + .where((reading) => reading.recordedAt != null) + .toList(); + if (timed.length == readings.length) { + timed.sort((a, b) => a.recordedAt!.compareTo(b.recordedAt!)); + return timed; + } + // Fall back to input order when timestamps are missing (e.g. minute-indexed + // history) so spikes are still counted in arrival order. + return readings; + } +} diff --git a/packages/cgm_core/test/glucose_analytics_test.dart b/packages/cgm_core/test/glucose_analytics_test.dart new file mode 100644 index 0000000..dd9103f --- /dev/null +++ b/packages/cgm_core/test/glucose_analytics_test.dart @@ -0,0 +1,322 @@ +import 'package:cgm_core/cgm_core.dart'; +import 'package:test/test.dart'; + +/// Reference time for deterministic windowing in tests. +final DateTime _now = DateTime.utc(2026, 6, 22, 12); + +CgmReading _reading(double mgdl, {DateTime? at, int? minute}) { + return CgmReading( + valueMgdl: mgdl, + source: CgmRecordSource.standard, + recordedAt: at, + sensorMinute: minute, + ); +} + +/// Builds [count] readings, one per [stepMinutes], ending at [_now]. +List _series( + List values, { + int stepMinutes = 5, + DateTime? endingAt, +}) { + final end = endingAt ?? _now; + final readings = []; + for (var i = 0; i < values.length; i++) { + final offsetFromEnd = (values.length - 1 - i) * stepMinutes; + readings.add( + _reading(values[i], at: end.subtract(Duration(minutes: offsetFromEnd))), + ); + } + return readings; +} + +void main() { + group('readingsInTimeframe', () { + test('keeps readings inside the window and drops older ones', () { + final readings = [ + _reading(100, at: _now.subtract(const Duration(hours: 1))), + _reading(110, at: _now.subtract(const Duration(hours: 23))), + _reading(120, at: _now.subtract(const Duration(hours: 25))), + ]; + final windowed = GlucoseAnalytics.readingsInTimeframe( + readings, + AnalyticsTimeframe.last24h, + now: _now, + ); + expect(windowed.length, 2); + expect(windowed.map((r) => r.valueMgdl), containsAll([100, 110])); + }); + + test('excludes readings without a timestamp', () { + final readings = [ + _reading(100, at: _now.subtract(const Duration(hours: 1))), + _reading(110, minute: 42), + ]; + final windowed = GlucoseAnalytics.readingsInTimeframe( + readings, + AnalyticsTimeframe.last24h, + now: _now, + ); + expect(windowed.length, 1); + }); + + test('excludes readings in the future relative to now', () { + final readings = [ + _reading(100, at: _now.add(const Duration(minutes: 30))), + _reading(110, at: _now.subtract(const Duration(minutes: 30))), + ]; + final windowed = GlucoseAnalytics.readingsInTimeframe( + readings, + AnalyticsTimeframe.last7d, + now: _now, + ); + expect(windowed.length, 1); + expect(windowed.single.valueMgdl, 110); + }); + + test('7d and 14d windows widen inclusion', () { + final readings = [ + _reading(100, at: _now.subtract(const Duration(days: 6))), + _reading(110, at: _now.subtract(const Duration(days: 10))), + ]; + expect( + GlucoseAnalytics.readingsInTimeframe( + readings, + AnalyticsTimeframe.last7d, + now: _now, + ).length, + 1, + ); + expect( + GlucoseAnalytics.readingsInTimeframe( + readings, + AnalyticsTimeframe.last14d, + now: _now, + ).length, + 2, + ); + }); + }); + + group('average / SD / CV', () { + test('average of a known series', () { + final readings = _series([90, 100, 110]); + expect(GlucoseAnalytics.average(readings), closeTo(100, 1e-9)); + }); + + test('average is null for empty', () { + expect(GlucoseAnalytics.average(const []), isNull); + }); + + test('population standard deviation of a known series', () { + // values 2,4,4,4,5,5,7,9 -> mean 5, population SD 2. + final readings = _series([2, 4, 4, 4, 5, 5, 7, 9]); + expect(GlucoseAnalytics.standardDeviation(readings), closeTo(2.0, 1e-9)); + }); + + test('standard deviation is zero for a flat series', () { + final readings = _series([120, 120, 120, 120]); + expect(GlucoseAnalytics.standardDeviation(readings), closeTo(0, 1e-9)); + }); + + test('coefficient of variation is SD/mean*100', () { + final readings = _series([2, 4, 4, 4, 5, 5, 7, 9]); + // SD 2, mean 5 -> CV 40%. + expect( + GlucoseAnalytics.coefficientOfVariation(readings), + closeTo(40.0, 1e-9), + ); + }); + + test('coefficient of variation is null when empty', () { + expect( + GlucoseAnalytics.coefficientOfVariation(const []), + isNull, + ); + }); + }); + + group('estimated GMI', () { + test('uses the standard linear approximation', () { + // mean 154 -> 3.31 + 0.02392*154 = 6.99368. + final readings = _series([154]); + expect( + GlucoseAnalytics.estimatedGmi(readings), + closeTo(3.31 + 0.02392 * 154, 1e-9), + ); + }); + + test('is null when empty', () { + expect(GlucoseAnalytics.estimatedGmi(const []), isNull); + }); + }); + + group('spikeCount', () { + test('counts each upward crossing of the high bound once', () { + // 100 -> 200 (spike) -> 150 (back) -> 210 (spike) = 2. + final readings = _series([100, 200, 150, 210]); + expect(GlucoseAnalytics.spikeCount(readings), 2); + }); + + test('sustained above-range counts as a single spike', () { + final readings = _series([100, 200, 210, 220, 150]); + expect(GlucoseAnalytics.spikeCount(readings), 1); + }); + + test('no crossings yields zero', () { + final readings = _series([100, 120, 140, 160]); + expect(GlucoseAnalytics.spikeCount(readings), 0); + }); + + test('respects custom high bound', () { + final readings = _series([100, 150, 100, 150]); + expect( + GlucoseAnalytics.spikeCount( + readings, + bounds: const GlucoseRangeBounds(lowMgdl: 70, highMgdl: 140), + ), + 2, + ); + }); + + test('orders by timestamp before counting', () { + // Provide out-of-order; ascending values are 100,200,150,210 -> 2 spikes. + final readings = [ + _reading(210, at: _now), + _reading(100, at: _now.subtract(const Duration(minutes: 15))), + _reading(150, at: _now.subtract(const Duration(minutes: 5))), + _reading(200, at: _now.subtract(const Duration(minutes: 10))), + ]; + expect(GlucoseAnalytics.spikeCount(readings), 2); + }); + }); + + group('summarize', () { + test('time-in-range / below / above sum to 100', () { + // 2 below(<70), 4 in-range, 2 above(>180) of 8 -> 25/50/25. + final readings = _series([60, 65, 90, 120, 150, 170, 200, 210]); + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + ); + expect(stats.readingCount, 8); + expect(stats.timeBelowRangePercent, closeTo(25, 1e-9)); + expect(stats.timeInRangePercent, closeTo(50, 1e-9)); + expect(stats.timeAboveRangePercent, closeTo(25, 1e-9)); + expect( + stats.timeBelowRangePercent + + stats.timeInRangePercent + + stats.timeAboveRangePercent, + closeTo(100, 1e-9), + ); + }); + + test('boundary values are inclusive in range', () { + final readings = _series([70, 180]); + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + ); + expect(stats.timeInRangePercent, closeTo(100, 1e-9)); + expect(stats.timeBelowRangePercent, 0); + expect(stats.timeAboveRangePercent, 0); + }); + + test('reports min, max and average', () { + final readings = _series([80, 120, 200]); + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + ); + expect(stats.minMgdl, 80); + expect(stats.maxMgdl, 200); + expect(stats.averageMgdl, closeTo(133.3333, 1e-3)); + }); + + test('filters by timeframe before summarising', () { + final readings = [ + _reading(100, at: _now.subtract(const Duration(hours: 1))), + _reading(300, at: _now.subtract(const Duration(days: 3))), + ]; + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + ); + expect(stats.readingCount, 1); + expect(stats.maxMgdl, 100); + }); + + test('preFiltered skips windowing', () { + final readings = [ + _reading(300, at: _now.subtract(const Duration(days: 30))), + ]; + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + preFiltered: true, + ); + expect(stats.readingCount, 1); + }); + + test('empty window produces a null-metric empty result', () { + final stats = GlucoseAnalytics.summarize( + const [], + timeframe: AnalyticsTimeframe.last7d, + now: _now, + ); + expect(stats.hasData, isFalse); + expect(stats.readingCount, 0); + expect(stats.averageMgdl, isNull); + expect(stats.standardDeviationMgdl, isNull); + expect(stats.coefficientOfVariationPercent, isNull); + expect(stats.estimatedGmiPercent, isNull); + expect(stats.spikeCount, 0); + expect(stats.timeInRangePercent, 0); + }); + + test('custom bounds change bucketing', () { + final readings = _series([80, 100, 160]); + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + bounds: const GlucoseRangeBounds(lowMgdl: 90, highMgdl: 140), + ); + // 80 below, 100 in, 160 above. + expect(stats.timeBelowRangePercent, closeTo(33.3333, 1e-3)); + expect(stats.timeInRangePercent, closeTo(33.3333, 1e-3)); + expect(stats.timeAboveRangePercent, closeTo(33.3333, 1e-3)); + }); + + test('all-in-range synthetic day reads as 100% TIR with low CV', () { + // A tight, well-controlled synthetic day around 110 mg/dL. + final values = List.generate( + 288, + (i) => 110 + 8 * (i.isEven ? 1 : -1).toDouble(), + ); + final readings = _series(values); + final stats = GlucoseAnalytics.summarize( + readings, + timeframe: AnalyticsTimeframe.last24h, + now: _now, + ); + expect(stats.timeInRangePercent, closeTo(100, 1e-9)); + expect(stats.spikeCount, 0); + expect(stats.coefficientOfVariationPercent, lessThan(15)); + }); + }); + + group('GlucoseRangeBounds', () { + test('rejects inverted bounds', () { + expect( + () => GlucoseRangeBounds(lowMgdl: 200, highMgdl: 100), + throwsA(isA()), + ); + }); + }); +}