diff --git a/.gitignore b/.gitignore index f1331ae5..b61a91a4 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,11 @@ lib/firebase_options.dart # Local agent worktrees and execution ledgers. /.worktrees/ /.superpowers/ + +.codegraph/ + +# Rendered previews of docs/*.svg — regenerate from the svg rather than +# committing them. The svg IS the source; these are just what gets pasted +# into Discord/issues, and they're ~750kb a piece. +docs/*.gif +docs/*.mp4 diff --git a/docs/calorie-heatmap-ios-dark.png b/docs/calorie-heatmap-ios-dark.png new file mode 100644 index 00000000..169d2761 Binary files /dev/null and b/docs/calorie-heatmap-ios-dark.png differ diff --git a/docs/calorie-heatmap-ios-light.png b/docs/calorie-heatmap-ios-light.png new file mode 100644 index 00000000..c138f66a Binary files /dev/null and b/docs/calorie-heatmap-ios-light.png differ diff --git a/docs/calorie-heatmap-preview.svg b/docs/calorie-heatmap-preview.svg new file mode 100644 index 00000000..0efae41b --- /dev/null +++ b/docs/calorie-heatmap-preview.svg @@ -0,0 +1,419 @@ + +OpenStrap — Activity heatmap (proposal) + + + + + + + + + + + + + + + + +ACTIVITY · 13 WEEKS + +i + + + + 0 + 5,400 + 10,200 + 14,600 + 17,900 + 19,900 + 21,000 + 21,340 + + kcal + 54 sessions · 5-day streak + + + + 780 + kcal + Fri 23 May · Run · 52 min + + + +Mar +Apr +May +Jun + +M +W +F + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Less + + + + + +More +kcal per day + +Shade = calories burned that day, scaled to your own 90th-percentile day. + + diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index de857c8b..7fbf5c1f 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -54,8 +54,16 @@ abstract class LocalRepository { throw UnimplementedError('re-layer: getSleep'); Future>> getStrain({int? from, int? to}) => throw UnimplementedError('re-layer: getStrain'); - Future>> getSessions({int? from, int? to}) => - throw UnimplementedError('re-layer: getSessions'); + /// Saved sessions in the window, merged with unconfirmed auto-detected bouts. + /// + /// Pass `includeDetected: false` when only saved sessions are wanted: the + /// detected half has to read every recent day bundle to find them, and those + /// rows carry the full hr_curve/hypnogram/HRV payload. + Future>> getSessions({ + int? from, + int? to, + bool includeDetected = true, + }) => throw UnimplementedError('re-layer: getSessions'); Future> getHistory({String range = '30d'}) => throw UnimplementedError('re-layer: getHistory'); diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index c83995c3..543add71 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -1237,7 +1237,11 @@ class LocalRepositoryImpl extends LocalRepository { } @override - Future>> getSessions({int? from, int? to}) async { + Future>> getSessions({ + int? from, + int? to, + bool includeDetected = true, + }) async { // Manual/live sessions (the sessions table) MERGED with auto-detected // workouts from the per-day bundle. Manual/saved WINS on overlap: a detected // bout overlapping a manual session is dropped here (and is already dropped @@ -1253,6 +1257,14 @@ class LocalRepositoryImpl extends LocalRepository { final manualRows = await LocalDb.sessionsInRange(fromSec, toSec); final manual = [for (final r in manualRows) _workoutOf(r)]; + // Finding the detected half means reading every recent day bundle, and + // recentDayResults does SELECT r.* — the whole hr_curve/hypnogram/HRV + // payload, tens of KB a day, across the isolate boundary and back through + // jsonDecode. A caller that only wants saved sessions must not pay that. + // Already newest-first — sessionsInRange orders by start_ts DESC, the same + // order the merged path sorts into below. + if (!includeDetected) return manual; + // Saved spans (manual) for overlap-dedup of detected bouts. final savedSpans = >[]; for (final w in manual) { diff --git a/lib/ui/workouts/calorie_heatmap.dart b/lib/ui/workouts/calorie_heatmap.dart new file mode 100644 index 00000000..dddc1874 --- /dev/null +++ b/lib/ui/workouts/calorie_heatmap.dart @@ -0,0 +1,768 @@ +// The workouts activity heatmap — 13 weeks of daily workout calorie burn as a +// Monday-aligned day grid. It answers the one question the reverse-chron +// session feed structurally cannot: where the gaps are, and what the training +// rhythm actually looks like. +// +// This file's pure layer (below) is deliberately separable from the widget: day +// bucketing, the intensity scale and the streak are all calendar arithmetic, +// and calendar arithmetic is where this kind of grid goes wrong. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +// design.dart re-exports the theme tokens (Palette, AppColors, Sp, Motion) and +// AppText alongside the bento/controls widgets. +import '../design/design.dart'; + +/// The fill for a 0..4 intensity level, resolved against a SPECIFIC palette. +/// +/// One construction serves both modes because `ink` flips with the palette: +/// blending it into `coralDeep` DEEPENS the top bucket on paper and BRIGHTENS +/// it on char, so "hotter" reads correctly either way without a second ramp. +/// +/// Level 0 is `surfaceSunk` — a well, deliberately not on the coral ramp at +/// all, so a rest day never looks like a faint training day. +/// +/// Guarded by the contrast tests in test/calorie_heatmap_test.dart, which +/// assert monotonic luminance and a minimum separation between every adjacent +/// pair in both palettes. +Color heatColorIn(Palette p, int level) { + // The interpolation factors differ by mode, and they have to. On paper, + // coralSoft is a pale tint sitting close to coral; on char it's a deep ember + // fill sitting far below it — so the same t lands at a very different + // perceptual position. Sharing one set of factors produces a ramp that + // spends most of its range between levels 0 and 1 and almost none between 3 + // and 4 (measured 2.48:1 vs 1.29:1 on char). These are tuned per palette for + // even spacing; the evenness test is what pins them. + final dark = p.isDark; + switch (level.clamp(0, 4)) { + case 0: + return p.surfaceSunk; + case 1: + return Color.lerp(p.coralSoft, p.coral, dark ? 0.25 : 0.38)!; + case 2: + return Color.lerp(p.coralSoft, p.coral, dark ? 0.62 : 0.70)!; + case 3: + return p.coral; + default: + return Color.lerp(p.coralDeep, p.ink, dark ? 0.30 : 0.18)!; + } +} + +/// The fill for a 0..4 intensity level in the CURRENT theme. +Color heatColor(int level) => heatColorIn(AppColors.active, level); + +/// One cell of the grid — a single LOCAL calendar day. +class HeatDay { + /// Local midnight for this day. + final DateTime date; + + /// Calories burned across every session that STARTED on this day. + final int kcal; + + /// How many sessions landed here (a day can hold more than one). + final int sessions; + + /// Summed active minutes for the day. + final int durationMin; + + /// This day hasn't happened yet (the tail of the current week). The grid + /// draws nothing here — an empty well would read as a day you skipped. + final bool isFuture; + + const HeatDay({ + required this.date, + this.kcal = 0, + this.sessions = 0, + this.durationMin = 0, + this.isFuture = false, + }); +} + +/// The kcal value that saturates the ramp — the 90th percentile of days you +/// actually trained, never below [floor]. +/// +/// Relative rather than absolute on purpose. Fixed thresholds leave a beginner's +/// grid permanently cold and an endurance athlete's permanently maxed; scaling +/// to your own distribution makes the shading mean the same thing for both. The +/// percentile (not the max) keeps one exceptional session from flattening the +/// quarter, and the floor stops three easy walks from all reading as max effort. +int heatScale(List days, {int floor = 250}) { + final trained = [ + for (final d in days) + if (d.kcal > 0) d.kcal, + ]..sort(); + if (trained.isEmpty) return floor; + // Nearest-rank percentile: the smallest value with >= 90% of samples at or + // below it. + final rank = ((0.9 * trained.length).ceil() - 1).clamp(0, trained.length - 1); + final p90 = trained[rank]; + return p90 < floor ? floor : p90; +} + +/// Bucket a day's burn into 0..4 against [scale] from [heatScale]. +/// +/// 0 is reserved for "didn't train" and is never reachable by a nonzero burn: +/// a rest day and a very light day must read differently or the grid stops +/// answering the question it exists for. 1..4 split the scale into quarters, +/// with everything past the p90 absorbed by the top bucket. +int heatLevel(int kcal, int scale) { + if (kcal <= 0) return 0; + if (scale <= 0) return 4; // degenerate scale — don't divide, just saturate + final t = kcal / scale; + if (t <= 0.25) return 1; + if (t <= 0.50) return 2; + if (t <= 0.75) return 3; + return 4; +} + +/// Consecutive trained days ending at [today] — or at yesterday, when today +/// hasn't been trained yet. +/// +/// The yesterday allowance matters: a streak that resets at midnight would tell +/// you you'd broken it before you'd had a chance to train that day. +int currentStreak(List days, {required DateTime today}) { + final t = DateTime(today.year, today.month, today.day); + final trained = { + for (final d in days) + if (d.kcal > 0) d.date, + }; + + // Anchor on today if it's trained, else on yesterday; if neither, no streak. + final yesterday = DateTime(t.year, t.month, t.day - 1); + var cursor = trained.contains(t) + ? t + : trained.contains(yesterday) + ? yesterday + : null; + if (cursor == null) return 0; + + var n = 0; + while (trained.contains(cursor)) { + n++; + cursor = DateTime(cursor!.year, cursor.month, cursor.day - 1); + } + return n; +} + +/// A month name pinned to the grid column where that month starts. +class MonthLabel { + /// Zero-based week column. + final int column; + + /// Short month name, e.g. 'Mar'. + final String text; + + const MonthLabel(this.column, this.text); +} + +/// Where to write month names above the grid. +/// +/// The board shows rhythm but not *when*: a three-week gap is meaningless if +/// you can't tell whether it was March or May. A label is emitted for the first +/// column of each month — keyed off each column's Monday, so a month is named +/// once, at the week it takes over, and never repeated. +List monthLabels(List days) { + final out = []; + int? lastMonth; + for (var col = 0; col * 7 < days.length; col++) { + final monday = days[col * 7].date; + if (monday.month != lastMonth) { + out.add(MonthLabel(col, _mon[monday.month - 1])); + lastMonth = monday.month; + } + } + return out; +} + +/// Comma-group an integer: 22140 -> "22,140". +/// +/// Local rather than a package: `intl` isn't a dependency here, and every other +/// stat in the app is small enough to render as a bare int. A 13-week calorie +/// total is the first five-figure number on a card, and an ungrouped run of +/// digits is measurably harder to read at a glance. +String groupThousands(int n) { + final neg = n < 0; + final digits = n.abs().toString(); + final buf = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + // Comma before every group of three counted from the RIGHT — i.e. wherever + // the remaining digit count is a positive multiple of three. + if (i > 0 && (digits.length - i) % 3 == 0) buf.write(','); + buf.write(digits[i]); + } + return neg ? '-$buf' : buf.toString(); +} + +/// Narrow a `getSessions()` result to the sessions the grid should shade. +/// +/// That call deliberately merges unconfirmed auto-detected bouts in with saved +/// ones, which is right for the suggestions flow and wrong here: shading a +/// detection would put a day on the grid that the feed and the training summary +/// both omit, while the "Suggested workouts" card for it is still sitting above +/// asking to be confirmed. Live sessions are held back for a different reason — +/// their calorie tally isn't final, so they'd shade in as an artificially cold +/// day and then jump when the session ends. +/// +/// The test is `status`, NOT `source`. `source: 'auto'` records that a workout +/// ORIGINATED in the detector, and that stays true once the user confirms it: +/// the row `_logDetectedSession` saves carries `source: 'auto'` with +/// `status: 'done'`, and the feed renders it with an `auto` tag. Filtering on +/// source therefore drops real logged workouts, producing the very +/// disagreement this filter exists to prevent, only inverted — the day sits in +/// the feed and in the training summary but reads as a rest day on the grid. +/// Only the synthetic shape built for an UNCONFIRMED bout carries +/// `status: 'detected'`, so status alone is the complete test. +List> loggedForHeatmap( + List> sessions) => + [ + for (final w in sessions) + if (w['status'] != 'detected' && w['status'] != 'live') w, + ]; + +/// Build the [weeks]x7 grid ending on the Sunday that closes [today]'s week. +/// +/// Days are stepped via `DateTime(y, m, d + i)` rather than +/// `add(Duration(days: 1))` on purpose: Duration arithmetic is absolute, so +/// across a DST transition it lands at 23:00 or 01:00 of the neighbouring day +/// and the grid silently repeats or skips a date. The constructor normalises +/// overflowing day numbers and always yields local midnight. +List buildHeatDays( + List> workouts, { + required DateTime today, + int weeks = 13, +}) { + final t = DateTime(today.year, today.month, today.day); + // weekday: Mon=1 .. Sun=7, so (7 - weekday) reaches this week's Sunday. + final end = DateTime(t.year, t.month, t.day + (7 - t.weekday)); + final start = DateTime(end.year, end.month, end.day - (weeks * 7 - 1)); + + // Tally by local day key first, so multiple sessions on one date collapse + // into a single cell rather than the last one winning. + final kcal = {}; + final count = {}; + final mins = {}; + + for (final w in workouts) { + final startTs = (w['start_ts'] as num?)?.toInt(); + if (startTs == null || startTs == 0) continue; + final local = + DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(); + final key = DateTime(local.year, local.month, local.day); + kcal[key] = (kcal[key] ?? 0) + ((w['calories'] as num?)?.round() ?? 0); + count[key] = (count[key] ?? 0) + 1; + mins[key] = (mins[key] ?? 0) + ((w['duration_min'] as num?)?.round() ?? 0); + } + + final out = []; + for (var i = 0; i < weeks * 7; i++) { + final d = DateTime(start.year, start.month, start.day + i); + out.add(HeatDay( + date: d, + kcal: kcal[d] ?? 0, + sessions: count[d] ?? 0, + durationMin: mins[d] ?? 0, + isFuture: d.isAfter(t), + )); + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Widget +// ───────────────────────────────────────────────────────────────────────────── + +/// Widest a 3-letter month label gets at 9px — reserved so a label pinned +/// to the right edge stays fully on the card. +const double _monthLabelWidth = 24.0; + +const _dow = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const _mon = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +String _readoutDate(DateTime d) => + '${_dow[d.weekday - 1]} ${d.day} ${_mon[d.month - 1]}'; + +String _cellKey(DateTime d) => 'heat-${d.year.toString().padLeft(4, '0')}' + '-${d.month.toString().padLeft(2, '0')}' + '-${d.day.toString().padLeft(2, '0')}'; + +/// The activity heatmap tile — a Monday-aligned day grid of workout calorie +/// burn, with a header that swaps between the window total and a tapped day. +/// +/// Takes an already-built [days] grid rather than raw workouts so the whole +/// calendar layer stays independently testable. +class CalorieHeatmapCard extends StatefulWidget { + final List days; + final DateTime today; + + const CalorieHeatmapCard({ + super.key, + required this.days, + required this.today, + }); + + @override + State createState() => _CalorieHeatmapCardState(); +} + +class _CalorieHeatmapCardState extends State + with TickerProviderStateMixin { + DateTime? _selected; + + /// Staggered reveal — runs once, then holds. + late final AnimationController _reveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + + /// The peak day's ember breath. Repeats for as long as the card is mounted, + /// so it is created ONLY when motion is allowed — see [_syncPulse]. + AnimationController? _pulse; + bool _startedReveal = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduce = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (!_startedReveal) { + _startedReveal = true; + reduce ? _reveal.value = 1.0 : _reveal.forward(); + } + _syncPulse(reduce); + } + + @override + void didUpdateWidget(covariant CalorieHeatmapCard old) { + super.didUpdateWidget(old); + // A refresh can slide the window past the selected day. build() already + // falls back to the summary, but the selection has to be released too or + // the peak pulse stays suppressed forever waiting on a day that is gone. + if (_selected != null && + !widget.days.any((d) => d.date == _selected)) { + _selected = null; + } + _syncPulse(MediaQuery.maybeDisableAnimationsOf(context) ?? false); + } + + void _syncPulse(bool reduce) { + // Silent while a cell is selected: the selection ring is the thing to look + // at then, and two competing attractors read as noise. + final wants = !reduce && _selected == null && _peak != null; + if (wants && _pulse == null) { + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } else if (!wants && _pulse != null) { + _pulse!.dispose(); + _pulse = null; + } + } + + @override + void dispose() { + _reveal.dispose(); + _pulse?.dispose(); + super.dispose(); + } + + List get _drawn => + [for (final d in widget.days) if (!d.isFuture) d]; + + /// The single hottest day in the window, if anything was logged. + HeatDay? get _peak { + HeatDay? best; + for (final d in _drawn) { + if (d.kcal > 0 && (best == null || d.kcal > best.kcal)) best = d; + } + return best; + } + + void _toggle(HeatDay d) { + setState(() { + _selected = _selected == d.date ? null : d.date; + _syncPulse(MediaQuery.maybeDisableAnimationsOf(context) ?? false); + }); + } + + @override + Widget build(BuildContext context) { + final drawn = _drawn; + final total = drawn.fold(0, (a, d) => a + d.kcal); + final sessions = drawn.fold(0, (a, d) => a + d.sessions); + final streak = currentStreak(widget.days, today: widget.today); + final scale = heatScale(widget.days); + final peak = _peak; + // Derived, not hardcoded: `days` is a constructor argument, so a caller + // that builds a different span must not be described as 13 weeks. Pluralised + // because the whole point is serving spans other than 13 — "1 weeks" would + // be the derivation announcing itself. + final weeks = (widget.days.length / 7).ceil(); + final weekLabel = weeks == 1 ? '1 week' : '$weeks weeks'; + + // Resolved by lookup rather than firstWhere: the window slides forward as + // days pass and on a refresh that crosses midnight the selected date can + // fall off the back of the board. Falling back to the summary is right — + // a phantom readout for a day no longer shown would be worse. + HeatDay? sel; + if (_selected != null) { + for (final d in widget.days) { + if (d.date == _selected) { + sel = d; + break; + } + } + } + + return BentoTile( + tone: BentoTone.paper, + accent: DomainAccent.strain, + padding: const EdgeInsets.all(Sp.x4), + child: Builder(builder: (context) { + final tone = ToneScope.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: TileHeader('Activity · $weekLabel')), + InfoDot( + title: 'Activity heatmap', + body: 'Every day of the last $weekLabel, shaded by the ' + 'calories you burned in logged workouts. Empty squares ' + 'are days you did not train.', + methodNote: 'Shade is scaled to your own 90th-percentile ' + 'day (currently $scale kcal), so it means the same ' + 'thing whatever your usual session looks like.', + ), + ], + ), + const SizedBox(height: Sp.x2), + AnimatedSwitcher( + duration: Motion.fast, + child: sel == null + ? _summary(tone, total, sessions, streak) + : _dayReadout(tone, sel), + ), + const SizedBox(height: Sp.x4), + _HeatGrid( + days: widget.days, + today: widget.today, + scale: scale, + selected: _selected, + peak: peak?.date, + reveal: _reveal, + pulse: _pulse, + onTap: _toggle, + ), + const SizedBox(height: Sp.x3), + _Legend(tone: tone), + ], + ); + }), + ); + } + + Widget _summary(ToneColors tone, int total, int sessions, int streak) => + Column( + key: const ValueKey('summary'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _bigValue(groupThousands(total), tone), + Text( + '$sessions sessions' + '${streak >= 2 ? ' · $streak-day streak' : ''}', + style: AppText.caption.copyWith(color: tone.fgMuted), + ), + ], + ); + + Widget _dayReadout(ToneColors tone, HeatDay d) => Column( + key: ValueKey('day-${d.date}'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _bigValue(d.kcal > 0 ? groupThousands(d.kcal) : '—', tone), + Text( + d.sessions == 0 + ? '${_readoutDate(d.date)} · No workout' + : '${_readoutDate(d.date)} · ' + '${d.sessions > 1 ? '${d.sessions} sessions · ' : ''}' + '${d.durationMin} min', + style: AppText.caption.copyWith(color: tone.fgMuted), + ), + ], + ); + + Widget _bigValue(String v, ToneColors tone) => Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(v, style: AppText.metric.copyWith(color: tone.fg)), + const SizedBox(width: Sp.x2), + Text('kcal', style: AppText.label.copyWith(color: tone.fgMuted)), + ], + ); +} + +/// The 13x7 board. Weeks are columns (oldest left), weekdays are rows. +class _HeatGrid extends StatelessWidget { + final List days; + final DateTime today; + final int scale; + final DateTime? selected; + final DateTime? peak; + final Animation reveal; + final Animation? pulse; + final void Function(HeatDay) onTap; + + const _HeatGrid({ + required this.days, + required this.today, + required this.scale, + required this.selected, + required this.peak, + required this.reveal, + required this.pulse, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final weeks = (days.length / 7).ceil(); + final t = DateTime(today.year, today.month, today.day); + + return LayoutBuilder(builder: (context, c) { + // Day-label gutter + inter-cell gaps come out of the available width + // first; whatever is left divides evenly across the week columns. + const gutter = 22.0, gap = 4.0; + final cell = + ((c.maxWidth - gutter - gap * (weeks - 1)) / weeks).clamp(9.0, 26.0); + final radius = cell * 0.3; + + final months = monthLabels(days); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Month names, pinned to the left edge of the column each month + // starts in. A Stack rather than a Row because the labels are sparse + // and positional — they must line up with a specific week, not + // distribute across the row. + SizedBox( + width: c.maxWidth, + height: 13, + child: Stack( + children: [ + for (final m in months) + Positioned( + // Clamped, not dropped. A month that starts in the last + // column would otherwise run off the card — but skipping it + // leaves the weeks nearest today as the only unnamed + // stretch, which is the opposite of useful. Pinning it to + // the right edge keeps every month named. + left: math.min( + gutter + m.column * (cell + gap), + (c.maxWidth - _monthLabelWidth).clamp(0.0, c.maxWidth), + ), + top: 0, + child: Text( + m.text, + style: AppText.captionMuted + .copyWith(fontSize: 9, height: 1.2), + ), + ), + ], + ), + ), + const SizedBox(height: Sp.x1), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: gutter, + child: Column( + children: [ + for (var row = 0; row < 7; row++) + SizedBox( + height: cell + gap, + // Label alternate rows only (M/W/F/S) — seven stacked + // labels at this cell size is denser than the grid itself. + child: row.isOdd + ? const SizedBox.shrink() + : Align( + alignment: Alignment.centerLeft, + child: Text( + _dow[row][0], + style: AppText.captionMuted + .copyWith(fontSize: 9, height: 1), + ), + ), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + for (var row = 0; row < 7; row++) + Padding( + padding: const EdgeInsets.only(bottom: gap), + child: Row( + children: [ + // weeks rounds UP, so a caller passing a part-week + // list would index past the end. buildHeatDays + // always emits whole weeks, but the widget takes + // any List. + for (var col = 0; col < weeks; col++) + if (col * 7 + row < days.length) + Padding( + padding: EdgeInsets.only( + right: col == weeks - 1 ? 0 : gap), + child: _cell( + days[col * 7 + row], + cell.toDouble(), + radius.toDouble(), + col, + row, + t, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ); + }); + } + + Widget _cell( + HeatDay d, double size, double radius, int col, int row, DateTime t) { + // A day that hasn't happened leaves a hole, not an empty well — an empty + // well would read as a day you skipped. + if (d.isFuture) return SizedBox(width: size, height: size); + + final isSelected = selected == d.date; + final isToday = d.date == t; + final isPeak = peak == d.date; + + // Diagonal reveal: later weeks and later weekdays start fractionally + // later, so the board lights up as one wave rather than all at once. + final delay = (col * 0.045 + row * 0.012).clamp(0.0, 0.6); + + // Only the peak cell reads pulse.value, and the pulse repeats for as long + // as the card is mounted. Merging it into all 91 cells would rebuild the + // entire board every frame, forever, to breathe one square — each rebuild + // reallocating the Opacity/Transform/AnimatedScale/Container chain. `reveal` + // stops notifying once it settles, so subscribing the rest to it alone + // leaves them genuinely idle after the intro. + final animation = + isPeak && pulse != null ? Listenable.merge([reveal, pulse]) : reveal; + + return AnimatedBuilder( + animation: animation, + builder: (context, _) { + final p = ((reveal.value - delay) / 0.35).clamp(0.0, 1.0); + final eased = Curves.easeOutCubic.transform(p); + final glow = isPeak && pulse != null ? pulse!.value : 0.0; + + return Opacity( + opacity: eased, + child: Transform.scale( + scale: 0.55 + 0.45 * eased, + child: GestureDetector( + key: ValueKey(_cellKey(d.date)), + behavior: HitTestBehavior.opaque, + onTap: () => onTap(d), + child: Semantics( + button: true, + selected: isSelected, + // Colour IS the content here, so a screen reader gets nothing + // from the grid without this. Read the day and its burn rather + // than the intensity level — "level 3 of 4" describes the + // rendering, not the training. + label: d.sessions == 0 + ? '${_readoutDate(d.date)}, no workout' + : '${_readoutDate(d.date)}, ${d.kcal} kcal, ' + '${d.sessions} ' + '${d.sessions == 1 ? 'session' : 'sessions'}, ' + '${d.durationMin} minutes', + child: AnimatedScale( + scale: isSelected ? 1.14 : 1.0, + duration: Motion.springy.d, + curve: Motion.springy.c, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: heatColor(heatLevel(d.kcal, scale)), + borderRadius: BorderRadius.circular(radius), + border: isSelected + ? Border.all(color: AppColors.ink, width: 2) + : isToday + ? Border.all( + color: AppColors.ink.withValues(alpha: 0.9), + width: 1.4) + : null, + boxShadow: glow > 0 + ? [ + BoxShadow( + color: AppColors.coral + .withValues(alpha: 0.10 + 0.28 * glow), + blurRadius: 4 + 10 * glow, + spreadRadius: 1 + 2 * glow, + ), + ] + : null, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Less -> More swatches, plus the unit. +class _Legend extends StatelessWidget { + final ToneColors tone; + const _Legend({required this.tone}); + + @override + Widget build(BuildContext context) { + final style = AppText.caption.copyWith(fontSize: 10, color: tone.fgMuted); + return Row( + children: [ + Text('Less', style: style), + const SizedBox(width: Sp.x2), + for (var l = 0; l <= 4; l++) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: heatColor(l), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + const SizedBox(width: Sp.x1), + Text('More', style: style), + const Spacer(), + Text('kcal per day', style: style), + ], + ); + } +} diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index fd1876b0..81c1c871 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -17,6 +17,7 @@ import '../../models/payloads.dart'; import '../../data/day_label.dart'; import '../../compute/manual_session.dart' show ManualWindowException; import '../../data/db.dart'; +import 'calorie_heatmap.dart'; import '../activity/live_session_screen.dart'; import '../activity/workout_share_card.dart'; import '../../theme/theme_switcher.dart'; @@ -165,6 +166,18 @@ class _WorkoutsScreenState extends State { RecordsData? _records; // for inline PR badges in the feed bool _loading = true; + /// The activity heatmap's own 13-week window — deliberately independent of + /// the range selector above, so the board stays a fixed reference point + /// while the feed below it filters. + List? _heat; + + /// The day `_heat` was built against. The card has to be handed THIS, not a + /// fresh clock read: the two agree only until the next local midnight, and a + /// screen left open across it would flag the new day as still-in-the-future, + /// draw no cell for it, put the "today" ring on nothing, and count the streak + /// against a day the board does not contain. + DateTime? _heatToday; + @override void initState() { super.initState(); @@ -185,11 +198,55 @@ class _WorkoutsScreenState extends State { try { recs = RecordsData.fromJson(await api.getRecords()); } catch (_) {} + + // The heatmap needs 13 Monday-aligned weeks — up to 97 days once the + // alignment is counted — which getWorkouts(range: 'quarter') cannot + // reach: that tops out at 90 and would leave the oldest column silently + // short of data. getSessions takes an explicit window and skips the + // per-session HR enrichment the grid has no use for. Fetched wide; days + // outside the board simply match no cell. + // + // includeDetected: false because the grid shades saved sessions only. + // Leaving it on would read every recent day bundle — the whole hr_curve / + // hypnogram / HRV payload — on every load and every pull-to-refresh, just + // to build detections loggedForHeatmap discards a line later. + // + // Read on EVERY load, including a range-selector tap. The window doesn't + // depend on the selector but the table does, and a tap is one of the few + // paths that re-reads at all: a suggestion confirmed from the pushed + // "did you work out?" screen never reloads this one, so skipping the read + // would refresh the feed and leave the board still calling that day a + // rest day — the feed/board disagreement loggedForHeatmap exists to + // prevent, arriving from the caching side instead. What remains is one + // indexed query over sessions; the expensive part was the day-bundle + // decode, and includeDetected: false already removed it. + List? heat; + DateTime? heatToday; + try { + final now = DateTime.now(); + final from = DateTime(now.year, now.month, now.day - 105); + final sessions = await api.getSessions( + from: from.millisecondsSinceEpoch ~/ 1000, + to: now.millisecondsSinceEpoch ~/ 1000, + includeDetected: false, + ); + heat = buildHeatDays(loggedForHeatmap(sessions), today: now); + heatToday = now; + } catch (_) { + /* the board is an enrichment — the log still renders without it */ + } + if (mounted) { setState(() { _data = d; _suggestions = sug; _records = recs; + // Grid and anchor move together or not at all — a board carrying one + // day's future flags under another day's clock is the bug. + if (heat != null) { + _heat = heat; + _heatToday = heatToday; + } _loading = false; }); } @@ -330,6 +387,19 @@ class _WorkoutsScreenState extends State { ).dsEnter(), const SizedBox(height: Sp.x4), ], + // Sits above the feed and OUTSIDE the range filter, so on the + // Today tab you still see the quarter's rhythm rather than only + // an empty state. Hidden entirely until there is something to + // shade — an all-empty board says nothing. + if (_heat != null && + _heatToday != null && + _heat!.any((d) => d.kcal > 0)) ...[ + CalorieHeatmapCard( + days: _heat!, + today: _heatToday!, + ).dsEnter(), + const SizedBox(height: Sp.x4), + ], if (list.isEmpty && _filter.isNarrowing) StateCard( icon: OsIcon.run, diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index 9d4bd260..61540d84 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -31,8 +31,11 @@ class _FakeRepo extends LocalRepository { @override Future> getDaySleep(String date) async => daySleep; @override - Future>> getSessions({int? from, int? to}) async => - sessions; + Future>> getSessions({ + int? from, + int? to, + bool includeDetected = true, + }) async => sessions; } Map _sampleToday() => { diff --git a/test/calorie_heatmap_test.dart b/test/calorie_heatmap_test.dart new file mode 100644 index 00000000..6f742800 --- /dev/null +++ b/test/calorie_heatmap_test.dart @@ -0,0 +1,597 @@ +// The workouts activity heatmap's pure layer — day bucketing, the intensity +// scale, and the streak count. All of it is calendar arithmetic over LOCAL +// days, which is exactly where this kind of widget goes wrong: a grid keyed off +// UTC drifts a day for anyone east of Greenwich, and a grid built by adding +// Duration(days: 1) silently loses or repeats a day across a DST transition. +// These tests pin the geometry with an injected `today` so they don't depend on +// when the suite runs. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/workouts/calorie_heatmap.dart'; + +/// WCAG 2.1 relative luminance. +double _luminance(Color c) { + double channel(double v) { + final s = v / 255.0; + return s <= 0.03928 + ? s / 12.92 + : math.pow((s + 0.055) / 1.055, 2.4) as double; + } + + return 0.2126 * channel((c.r * 255).roundToDouble()) + + 0.7152 * channel((c.g * 255).roundToDouble()) + + 0.0722 * channel((c.b * 255).roundToDouble()); +} + +double _contrast(Color a, Color b) { + final la = _luminance(a), lb = _luminance(b); + return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05); +} + +void main() { + test('grid is weeks x 7 local days, Monday-aligned, ending on this Sunday', + () { + final today = DateTime(2026, 6, 10); // a Wednesday + final days = buildHeatDays(const [], today: today, weeks: 13); + + expect(days.length, 13 * 7); + expect(days.first.date.weekday, DateTime.monday); + expect(days.last.date.weekday, DateTime.sunday); + // Full weeks: the grid runs to the END of today's week, not to today. + expect(days.last.date, DateTime(2026, 6, 14)); + expect(days.first.date, DateTime(2026, 3, 16)); + }); + + test('sessions bucket into their LOCAL day; same-day sessions sum', () { + final today = DateTime(2026, 6, 10); + int ts(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + + final days = buildHeatDays([ + // Two efforts on the Monday — a morning run and an evening lift. + { + 'start_ts': ts(DateTime(2026, 6, 8, 7, 30)), + 'calories': 420, + 'duration_min': 45, + 'type': 'run', + }, + { + 'start_ts': ts(DateTime(2026, 6, 8, 18, 5)), + 'calories': 260, + 'duration_min': 30, + 'type': 'lift', + }, + { + 'start_ts': ts(DateTime(2026, 6, 9, 6, 0)), + 'calories': 310, + 'duration_min': 33, + 'type': 'ride', + }, + ], today: today, weeks: 13); + + final byDate = {for (final d in days) d.date: d}; + + final mon = byDate[DateTime(2026, 6, 8)]!; + expect(mon.kcal, 680, reason: 'both Monday sessions counted'); + expect(mon.sessions, 2); + expect(mon.durationMin, 75); + + final tue = byDate[DateTime(2026, 6, 9)]!; + expect(tue.kcal, 310); + expect(tue.sessions, 1); + + // A rest day is a real zero, not a gap in the list. + expect(byDate[DateTime(2026, 6, 7)]!.kcal, 0); + expect(byDate[DateTime(2026, 6, 7)]!.sessions, 0); + }); + + test('days after today are flagged future, today itself is not', () { + final today = DateTime(2026, 6, 10); // Wednesday + final days = buildHeatDays(const [], today: today, weeks: 13); + final byDate = {for (final d in days) d.date: d}; + + // Thu/Fri/Sat/Sun of the current week haven't happened. Drawing them as + // empty wells would read as "you skipped Saturday" — so they carry a flag + // the grid uses to render nothing at all. + expect(byDate[DateTime(2026, 6, 11)]!.isFuture, isTrue); + expect(byDate[DateTime(2026, 6, 14)]!.isFuture, isTrue); + + expect(byDate[DateTime(2026, 6, 10)]!.isFuture, isFalse, + reason: 'today is drawn — it is in progress, not unreachable'); + expect(byDate[DateTime(2026, 6, 9)]!.isFuture, isFalse); + expect(days.where((d) => d.isFuture).length, 4); + }); + + test('the grid keeps exactly one cell per date across a DST transition', () { + // Late March / early November span the EU and US clock changes. Whatever + // zone the suite runs in, every date must appear exactly once and the + // spacing must stay one calendar day. + for (final today in [DateTime(2026, 4, 8), DateTime(2026, 11, 11)]) { + final days = buildHeatDays(const [], today: today, weeks: 13); + final dates = days.map((d) => d.date).toList(); + + expect(dates.toSet().length, dates.length, reason: 'no repeated date'); + for (final d in dates) { + expect(d.hour, 0, reason: 'every cell sits at local midnight'); + } + for (var i = 1; i < dates.length; i++) { + final prev = dates[i - 1]; + expect(dates[i], DateTime(prev.year, prev.month, prev.day + 1), + reason: 'consecutive calendar days, no skips'); + } + } + }); + + test('scale is the p90 of TRAINED days, with a floor', () { + HeatDay d(int kcal) => HeatDay(date: DateTime(2026, 1, 1), kcal: kcal); + + // Ten trained days, 100..1000. Nearest-rank p90 lands on 900 — the scale + // deliberately ignores the single hardest day so one outlier session + // doesn't flatten the whole quarter into pale tints. + expect( + heatScale([for (var i = 1; i <= 10; i++) d(i * 100)]), + 900, + ); + + // Rest days must not drag the percentile down; only trained days count. + expect( + heatScale([...List.generate(50, (_) => d(0)), for (var i = 1; i <= 10; i++) d(i * 100)]), + 900, + ); + + // A beginner's light week would otherwise make a 70 kcal walk "max red". + expect(heatScale([d(50), d(60), d(70)]), 250, reason: 'floored'); + + // Nothing logged at all — still a usable divisor, never zero. + expect(heatScale(const []), 250); + expect(heatScale([d(0), d(0)]), 250); + }); + + test('level 0 means rest; 1..4 split the scale into quarters', () { + // Zero is its OWN bucket, not "the bottom of the ramp" — a rest day and a + // very light day must never paint the same, or the grid stops answering + // "where are my gaps". + expect(heatLevel(0, 800), 0); + expect(heatLevel(1, 800), 1); + + expect(heatLevel(200, 800), 1); // 25% — boundary belongs to the lower band + expect(heatLevel(201, 800), 2); + expect(heatLevel(400, 800), 2); + expect(heatLevel(401, 800), 3); + expect(heatLevel(600, 800), 3); + expect(heatLevel(601, 800), 4); + + // Above the p90 there is nowhere left to go — the top bucket absorbs it. + expect(heatLevel(5000, 800), 4); + + // A degenerate scale must not divide by zero. + expect(heatLevel(100, 0), 4); + expect(heatLevel(0, 0), 0); + }); + + test('streak counts consecutive trained days ending today or yesterday', () { + final today = DateTime(2026, 6, 10); // Wednesday + int ts(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + Map w(DateTime d) => + {'start_ts': ts(d), 'calories': 300, 'duration_min': 30}; + + // Mon, Tue, Wed(today) trained; Sunday off. + var days = buildHeatDays([ + w(DateTime(2026, 6, 8, 7)), + w(DateTime(2026, 6, 9, 7)), + w(DateTime(2026, 6, 10, 7)), + ], today: today); + expect(currentStreak(days, today: today), 3); + + // Today not trained YET, but yesterday was — the streak is still alive. + // Ending it at midnight would tell you that you broke it before you've + // even had a chance to train. + days = buildHeatDays([ + w(DateTime(2026, 6, 8, 7)), + w(DateTime(2026, 6, 9, 7)), + ], today: today); + expect(currentStreak(days, today: today), 2); + + // Neither today nor yesterday — the streak really is over. + days = buildHeatDays([ + w(DateTime(2026, 6, 6, 7)), + w(DateTime(2026, 6, 7, 7)), + ], today: today); + expect(currentStreak(days, today: today), 0); + + expect(currentStreak(buildHeatDays(const [], today: today), today: today), 0); + }); + + test('thousands are grouped so a 13-week total stays readable', () { + // A quarter's burn is a five-figure number. Every other stat in the app is + // three digits or fewer and renders as a bare int, which is why there was + // no grouping helper to reach for — at 22140 the run of digits is genuinely + // hard to parse at a glance. + expect(groupThousands(0), '0'); + expect(groupThousands(7), '7'); + expect(groupThousands(780), '780'); + expect(groupThousands(999), '999'); + expect(groupThousands(1000), '1,000'); + expect(groupThousands(1080), '1,080'); + expect(groupThousands(22140), '22,140'); + expect(groupThousands(1000000), '1,000,000'); + // Defensive: a negative can't arise from summed calories, but the helper + // must not mangle the sign if one ever does. + expect(groupThousands(-1080), '-1,080'); + }); + + test('month labels mark the column where each new month begins', () { + // Without these the grid shows rhythm but not WHEN — you can see a + // three-week gap and have no idea whether it was March or May. + final today = DateTime(2026, 6, 10); // grid: Mon 16 Mar .. Sun 14 Jun + final labels = monthLabels(buildHeatDays(const [], today: today)); + + // Column Mondays: 16/23/30 Mar, 6/13/20/27 Apr, 4/11/18/25 May, 1/8 Jun. + expect(labels.map((l) => l.column).toList(), [0, 3, 7, 11]); + expect(labels.map((l) => l.text).toList(), ['Mar', 'Apr', 'May', 'Jun']); + }); + + test('a month label is not repeated when a month spans the grid edge', () { + // A 4-week window sitting entirely inside one month must produce exactly + // one label, not one per column. + final labels = monthLabels( + buildHeatDays(const [], today: DateTime(2026, 6, 24), weeks: 3), + ); + expect(labels.length, 1); + expect(labels.single.text, 'Jun'); + expect(labels.single.column, 0); + }); + + test('only LOGGED sessions reach the grid — not detections or live', () { + // getSessions() merges unconfirmed auto-detected bouts in alongside saved + // ones. If those reached the grid it would disagree with both the feed and + // TrainingSummaryCard while the "Suggested workouts" cards still sat above + // it waiting to be confirmed — the same day would be shaded here and + // absent there. A live session has no final calorie tally yet, so it is + // held back too rather than shaded as a near-zero day. + final kept = loggedForHeatmap([ + {'id': 'a', 'calories': 300, 'status': 'complete'}, + {'id': 'auto_2026-06-08_123', 'source': 'auto', 'status': 'detected'}, + {'id': 'b', 'calories': 400, 'status': 'live'}, + {'id': 'c', 'calories': 500}, + ]); + + expect(kept.map((w) => w['id']), ['a', 'c']); + }); + + test('a CONFIRMED auto-detection is a logged workout and reaches the grid', + () { + // The row _logDetectedSession saves when the user taps Confirm on a + // suggestion. `source` stays 'auto' forever — it records where the workout + // came from, not whether it is still a proposal — while `status` moves to + // 'done'. Filtering on source would drop it, which is how a real workout + // ends up in the feed (tagged `auto`) and in TrainingSummaryCard while the + // grid shows that day as rest. Someone whose training is mostly + // detected-then-confirmed would get a near-empty board, and since the card + // is hidden until a day has kcal, no board at all. + final kept = loggedForHeatmap([ + { + 'id': 'auto:1749000000', + 'source': 'auto', + 'status': 'done', + 'calories': 480, + }, + {'id': 'manual-1', 'source': 'manual', 'status': 'done', 'calories': 300}, + ]); + + expect(kept.map((w) => w['id']), ['auto:1749000000', 'manual-1']); + }); + + // The ramp is the whole feature: if two adjacent buckets read as one colour, + // the grid is decoration. Asserted numerically in BOTH palettes so a future + // token edit can't quietly collapse a step — the same reasoning as + // zone_contrast_test.dart. + group('intensity ramp stays readable', () { + // Below roughly this, two swatches sitting side by side stop being + // separable at a ~20px cell. + const minStep = 1.25; + + for (final entry in { + 'paper': kLightPalette, + 'char': kDarkPalette, + }.entries) { + final mode = entry.key; + final palette = entry.value; + + test('$mode: luminance is monotonic across levels 0..4', () { + final lums = [ + for (var l = 0; l <= 4; l++) _luminance(heatColorIn(palette, l)), + ]; + // Hotter reads DARKER on paper and BRIGHTER on char — one construction, + // inverted by the palette, because ink flips with the mode. + final ordered = palette.isDark + ? List.of(lums) + : List.of(lums.reversed); + for (var i = 1; i < ordered.length; i++) { + expect(ordered[i], greaterThan(ordered[i - 1]), + reason: '$mode ramp is not monotonic: $lums'); + } + }); + + for (var l = 1; l <= 4; l++) { + test('$mode: level ${l - 1} and $l are distinguishable', () { + final ratio = + _contrast(heatColorIn(palette, l), heatColorIn(palette, l - 1)); + expect(ratio, greaterThanOrEqualTo(minStep), + reason: '$mode L${l - 1}->L$l is only ' + '${ratio.toStringAsFixed(2)}:1 apart'); + }); + } + + test('$mode: steps are evenly spaced, not bunched at one end', () { + // A minimum step isn't sufficient on its own: a ramp can clear it and + // still be lopsided, spending most of its range between levels 0 and 1 + // and almost none between 3 and 4. Equal kcal differences should look + // like equal colour differences, so hold the spread between the widest + // and narrowest step. + final steps = [ + for (var l = 1; l <= 4; l++) + _contrast(heatColorIn(palette, l), heatColorIn(palette, l - 1)), + ]; + final spread = + steps.reduce(math.max) / steps.reduce(math.min); + expect(spread, lessThanOrEqualTo(1.55), + reason: '$mode ramp is lopsided — steps ' + '${steps.map((s) => s.toStringAsFixed(2)).toList()}'); + }); + + test('$mode: an empty day is distinct from the card behind it', () { + // Level 0 is a well, not the card — you have to be able to see the + // grid's shape even where nothing was logged. + final ratio = _contrast( + heatColorIn(palette, 0), + palette.isDark ? palette.surfaceAlt : palette.surface, + ); + expect(ratio, greaterThan(1.0), + reason: '$mode empty cell is invisible against the tile'); + }); + } + }); + + group('CalorieHeatmapCard', () { + tearDown(() => AppColors.active = kLightPalette); + + final today = DateTime(2026, 6, 10); // Wednesday + int ts(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + + List sample() => buildHeatDays([ + { + 'start_ts': ts(DateTime(2026, 6, 8, 7)), + 'calories': 300, + 'duration_min': 30, + }, + { + 'start_ts': ts(DateTime(2026, 6, 9, 7)), + 'calories': 780, + 'duration_min': 52, + }, + ], today: today); + + Widget shell(List days) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard(days: days, today: today), + ), + ), + ); + + // NOT pumpAndSettle: the peak-day ember pulse repeats for as long as the + // card is on screen, by design, so "settle" never arrives. Pump past the + // reveal and the header cross-fade instead. + Future settleAnimated(WidgetTester t) async { + await t.pump(); + await t.pump(const Duration(milliseconds: 900)); + } + + testWidgets('opens on the summary, not on a selected day', (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + expect(find.text('1,080'), findsOneWidget, reason: '13-week total'); + expect(find.textContaining('2 sessions'), findsOneWidget); + expect(find.textContaining('2-day streak'), findsOneWidget); + }); + + testWidgets('tapping a day swaps the header to that day, and back', + (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + await t.tap(find.byKey(const ValueKey('heat-2026-06-09'))); + await settleAnimated(t); + + expect(find.text('780'), findsOneWidget); + expect(find.textContaining('Tue 9 Jun'), findsOneWidget); + expect(find.text('1,080'), findsNothing, + reason: 'the summary yields to the day readout'); + + // Tapping the same cell again releases the selection. + await t.tap(find.byKey(const ValueKey('heat-2026-06-09'))); + await settleAnimated(t); + expect(find.text('1,080'), findsOneWidget); + }); + + testWidgets('a rest day reads as rest, not as a missing readout', + (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + await t.tap(find.byKey(const ValueKey('heat-2026-06-07'))); + await settleAnimated(t); + + expect(find.textContaining('Sun 7 Jun'), findsOneWidget); + expect(find.textContaining('No workout'), findsOneWidget); + }); + + testWidgets('month names are rendered above the grid', (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + // Grid runs Mon 16 Mar .. Sun 14 Jun 2026 for a today of 10 Jun. + for (final m in ['Mar', 'Apr', 'May', 'Jun']) { + expect(find.text(m), findsOneWidget, reason: '$m label missing'); + } + }); + + testWidgets('a month starting in the final column is still labelled', + (t) async { + // Wed 3 Jun 2026: the grid's last column opens on Mon 1 Jun, so June + // starts in the rightmost week. Dropping that label to avoid overflowing + // the card would leave the MOST RECENT weeks — the ones nearest today — + // as the only unnamed stretch of the board. + final today = DateTime(2026, 6, 3); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard( + days: buildHeatDays(const [], today: today), + today: today, + ), + ), + ), + )); + await settleAnimated(t); + + expect(find.text('Jun'), findsOneWidget); + expect(t.takeException(), isNull, reason: 'and it must not overflow'); + }); + + testWidgets('future days are not rendered at all', (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + // Today exists... + expect(find.byKey(const ValueKey('heat-2026-06-10')), findsOneWidget); + // ...but the rest of the week hasn't happened, so there is no cell to + // mistake for a skipped day. + expect(find.byKey(const ValueKey('heat-2026-06-11')), findsNothing); + expect(find.byKey(const ValueKey('heat-2026-06-14')), findsNothing); + }); + + testWidgets('a selection that scrolls out of the window does not crash', + (t) async { + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + await t.tap(find.byKey(const ValueKey('heat-2026-06-08'))); + await settleAnimated(t); + expect(find.textContaining('Mon 8 Jun'), findsOneWidget); + + // The card is rebuilt with a LATER window — a refresh crossing midnight, + // or simply a later session. The previously selected day is no longer on + // the board, and looking it up must not throw. + final later = DateTime(2026, 12, 9); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard( + days: buildHeatDays(const [], today: later), + today: later, + ), + ), + ), + )); + await settleAnimated(t); + + expect(t.takeException(), isNull); + // Falls back to the summary rather than showing a phantom day. + expect(find.textContaining('sessions'), findsOneWidget); + }); + + testWidgets('a part-week list renders, and the header says its own span', + (t) async { + // `days` is a constructor argument, so the widget has to survive a list + // buildHeatDays didn't produce. The column count rounds UP, so the last + // column indexes past the end of a part-week list, and a header that + // hardcoded 13 would be describing a window the caller never asked for. + final days = buildHeatDays(const [], today: today, weeks: 3) + .take(17) // two whole weeks + 3 days + .toList(); + + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard(days: days, today: today), + ), + ), + )); + await settleAnimated(t); + + expect(t.takeException(), isNull); + // TileHeader uppercases its label. + expect(find.textContaining('3 WEEKS'), findsOneWidget); + expect(find.textContaining('13 WEEKS'), findsNothing); + }); + + testWidgets('a single-week board says "1 week", not "1 weeks"', (t) async { + // Three days still round up to one column, and deriving the count is + // pointless if the only spans it serves read as broken English. + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard( + days: buildHeatDays(const [], today: today, weeks: 1) + .take(3) + .toList(), + today: today, + ), + ), + ), + )); + await settleAnimated(t); + + expect(t.takeException(), isNull); + expect(find.textContaining('1 WEEK'), findsOneWidget); + expect(find.textContaining('1 WEEKS'), findsNothing); + }); + + testWidgets('the board fits without overflowing a narrow phone', + (t) async { + // Cell size is derived from the available width but clamped at both + // ends, and a clamped-up minimum can push 13 columns wider than the row + // that holds them. Overflow paints a yellow-and-black banner over the + // card, so pin the narrow end. + t.view.physicalSize = const Size(320 * 3, 700 * 3); + t.view.devicePixelRatio = 3.0; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + + await t.pumpWidget(shell(sample())); + await settleAnimated(t); + + expect(t.takeException(), isNull); + expect(find.byKey(const ValueKey('heat-2026-06-09')), findsOneWidget); + }); + + testWidgets('reduced motion leaves no animation running', (t) async { + await t.pumpWidget(MaterialApp( + home: Builder( + // copyWith, not a bare MediaQueryData: constructing one from scratch + // resets size to zero and drops textScaler, padding and brightness, + // so the test would quietly stop describing a real device the moment + // the card reads any other MediaQuery field. + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: Scaffold( + body: SingleChildScrollView( + child: CalorieHeatmapCard(days: sample(), today: today), + ), + ), + ), + ), + )); + // pumpAndSettle times out if any ticker keeps scheduling frames — the + // peak-day pulse repeats forever, so this is the assertion that it is + // genuinely suppressed rather than merely started at zero opacity. + await t.pumpAndSettle(); + expect(find.text('1,080'), findsOneWidget); + }); + }); +} diff --git a/test/get_sessions_merge_test.dart b/test/get_sessions_merge_test.dart index 498c5479..71618655 100644 --- a/test/get_sessions_merge_test.dart +++ b/test/get_sessions_merge_test.dart @@ -98,6 +98,14 @@ void main() { // Sorted newest-first by start_ts: the later detected bout comes first. expect(sessions.first['start_ts'], base + 2000); + + // includeDetected: false skips the day-bundle scan entirely — saved + // sessions only, still newest-first. Callers with no use for the detected + // half must not pay to read every recent day's full payload. + final savedOnly = await repo.getSessions(includeDetected: false); + expect(savedOnly, hasLength(1)); + expect(savedOnly.single['id'], 'manual1'); + expect(savedOnly.any((s) => s['status'] == 'detected'), isFalse); }); }