From 8ba9ee93fa3b665bdcca78a1222d4d5a3adf397f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 16 Oct 2026 15:36:01 +0200 Subject: [PATCH] workout_app: log manual workouts for future dates, planned list + delete The manual form takes a workout date from today to today+7 (never past). The budget is read as of that date: the fullest 7-/30-day window containing it, past and planned entries alike. History lists future manual entries under PLANNED (off the calendar) and deletes them until their day via a crdt-sync tombstone. Re-logging onto a tombstoned id is refused; a failed push keeps the form open with the reason. Shared fixture manual_budget_merge.json gains a 'planned' scenario (top-level expectations unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT --- .../lib/models/manual_workout_budget.dart | 56 +++-- .../lib/models/manual_workout_sync.dart | 20 +- .../lib/screens/history_screen.dart | 39 +-- .../lib/screens/history_screen_data.dart | 43 +++- .../lib/screens/history_screen_planned.dart | 152 ++++++++++++ .../lib/screens/manual_workout_actions.dart | 101 ++++++++ .../screens/manual_workout_form_parts.dart | 43 +++- .../lib/screens/manual_workout_screen.dart | 72 +++--- .../lib/services/workout_sync_service.dart | 16 ++ .../services/workout_sync_service_read.dart | 44 +++- .../manual_budget_merge_fixture_test.dart | 39 +++ .../models/manual_workout_budget_test.dart | 52 +++- .../models/manual_workout_tombstone_test.dart | 36 +++ .../test/screens/_history_test_fixtures.dart | 8 +- .../test/screens/_manual_screen_fixtures.dart | 80 +++++++ .../test/screens/history_planned_test.dart | 223 ++++++++++++++++++ .../screens/manual_workout_date_test.dart | 178 ++++++++++++++ .../screens/manual_workout_screen_test.dart | 94 ++------ .../services/workout_sync_ledger_test.dart | 72 ++++++ .../services/workout_sync_service_test.dart | 46 ++++ 20 files changed, 1257 insertions(+), 157 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_planned.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/manual_workout_actions.dart create mode 100644 stronglift_replacement/workout_app/test/models/manual_workout_tombstone_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/_manual_screen_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/screens/history_planned_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/workout_sync_ledger_test.dart diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart index 24da919..2f6d5bb 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart @@ -3,7 +3,8 @@ // See manual_workout_validation.dart for why these are `part` files. part of 'manual_workout.dart'; -/// Counts DAYS with a manual workout in the rolling 7-/30-day windows. +/// Counts manual workouts against the rolling 7-/30-day caps, as seen from +/// the workout date [date]. /// /// The budget is counted per ENTRY, not per day: each self-report consumes its /// own slot, so three workouts logged on one day cost three. That matches the @@ -16,13 +17,21 @@ part of 'manual_workout.dart'; /// its 7-day cap — but 1/2 here, and the phone kept accepting. Callers pass /// one payload per synced record (`manual:T`), so same-day /// workouts at different times arrive as distinct payloads. +/// +/// Since 2026-10-16 a workout can be PLANNED up to a week ahead, so the count +/// is no longer "the trailing window ending today". Each figure is the +/// fullest window of that length that contains [date], counting past and +/// future entries alike: adding an entry on [date] is only allowed if every +/// such window stays within its cap, and the fullest one is the binding +/// constraint. With no future entries and [date] = today this is exactly the +/// old trailing count, since every other window holds a subset of it. ManualBudget countManualBudget( Iterable> payloads, - DateTime now, + DateTime date, ) { - final today = DateTime(now.year, now.month, now.day); - var week = 0; - var month = 0; + final target = _dayNumber(date); + // Offsets (in days) of every counted entry relative to [date]. + final offsets = []; for (final payload in payloads) { // Skip evidence-less `{type, kind, date}` stubs. The PC refuses to ingest // these ("no workout data to ingest; skipping permanently"), so counting @@ -32,14 +41,33 @@ ManualBudget countManualBudget( if (payload['start_time'] is! String) continue; final dateStr = payload['date']; if (dateStr is! String) continue; - final date = DateTime.tryParse(dateStr); - if (date == null) continue; - final days = today - .difference(DateTime(date.year, date.month, date.day)) - .inDays; - if (days < 0) continue; - if (days < 7) week++; - if (days < 30) month++; + final entryDate = DateTime.tryParse(dateStr); + if (entryDate == null) continue; + offsets.add(_dayNumber(entryDate) - target); } - return ManualBudget(week: week, month: month); + return ManualBudget( + week: _fullestWindow(offsets, 7), + month: _fullestWindow(offsets, 30), + ); +} + +/// Days since the epoch for [day]'s calendar date. +/// +/// Built in UTC so a DST change between two local midnights (a 23- or 25-hour +/// "day") cannot truncate a one-day difference to zero. +int _dayNumber(DateTime day) => + DateTime.utc(day.year, day.month, day.day).millisecondsSinceEpoch ~/ + Duration.millisecondsPerDay; + +/// The most entries any [length]-day window containing offset 0 holds. +int _fullestWindow(List offsets, int length) { + var fullest = 0; + // A window containing day 0 starts anywhere from `length - 1` days before + // it up to day 0 itself. + for (var start = 1 - length; start <= 0; start++) { + final end = start + length - 1; + final count = offsets.where((o) => o >= start && o <= end).length; + if (count > fullest) fullest = count; + } + return fullest; } diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart index c5ff803..bd47b9d 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart @@ -22,6 +22,9 @@ String manualSyncRecordId(String date, String startTime) => 'manual:${date}T${startTime.trim()}'; /// Builds the crdt-sync [Record] for a manual workout, stamped with [hlc]. +/// +/// [date] is the WORKOUT date, which may be up to a week ahead of the day it +/// is logged on: the PC credits a planned entry on its own date, never early. Record buildManualRecord( ManualWorkoutDraft draft, String date, { @@ -33,15 +36,28 @@ Record buildManualRecord( ); } +/// The tombstone that deletes the manual workout [recordId] on every device. +/// +/// Carries no fields: [mergeRecord] keeps whatever payload this device's log +/// already holds and only flips `deleted`, which is sticky. A record logged +/// on another device gets a field-less tombstone here, and both readers +/// (this app's and the PC's `_tombstoned_ids`) suppress across the whole +/// device union, so that is enough. Deleting `log.json` locally instead would +/// just be re-ingested on the next sync. +Record buildManualTombstone(String recordId, {required Hlc hlc}) => + Record(id: recordId, fields: const {}, deleted: true, deletedHlc: hlc); + /// Manual-workout counts in the rolling 7-/30-day windows (the shared budget). class ManualBudget { /// Creates a budget snapshot. const ManualBudget({required this.week, required this.month}); - /// Manual workouts in the last 7 days. + /// Manual workouts in the fullest 7-day window containing the workout + /// date (see [countManualBudget]). final int week; - /// Manual workouts in the last 30 days. + /// Manual workouts in the fullest 30-day window containing the workout + /// date. final int month; /// Whether either window has reached its limit. diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index 6d769a3..4eebda7 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -11,6 +11,9 @@ import 'dart:math'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:workout_app/models/manual_workout.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/done_today.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -19,6 +22,7 @@ import 'package:workout_app/widgets/calendar_widget.dart'; part 'history_screen_charts.dart'; part 'history_screen_data.dart'; part 'history_screen_painter.dart'; +part 'history_screen_planned.dart'; part 'history_screen_tiles.dart'; const _kTotal = 'Total (all workouts)'; @@ -35,7 +39,12 @@ class HistoryScreen extends StatefulWidget { /// and it asks Firebase that question directly. On a desktop that has a /// cached refresh token the real factory answers yes, so the guard opens and /// the fetch goes to the network even with the sync token faked away. - const HistoryScreen({super.key, this.httpClient, this.firebaseFactory}); + const HistoryScreen({ + super.key, + this.httpClient, + this.firebaseFactory, + this.clock, + }); /// Overrides the HTTP client used to read synced workouts (tests only). final http.Client? httpClient; @@ -43,21 +52,18 @@ class HistoryScreen extends StatefulWidget { /// Overrides how a Firebase account is discovered (tests only). final Future Function()? firebaseFactory; + /// Decides which manual entries are still PLANNED (dated after today); + /// defaults to [DateTime.now], injected by tests. + final DateTime Function()? clock; + @override State createState() => _HistoryScreenState(); } -/// Synced kinds this phone has no local record of, so showing them can't -/// double up an existing session. -/// -/// A `phone_verified` record is the PC's mirror of a StrongLifts session this -/// phone already stores in full (and renders from `_rows`), so it is -/// deliberately excluded — otherwise the same workout would appear twice. -const _kSyncedOnlyKinds = {'runnerup_verified', 'manual_workout'}; - class _HistoryScreenState extends State { List> _rows = []; List> _syncedRows = []; + List> _plannedRows = []; bool _loading = true; String _selected = _kTotal; List _exerciseNames = []; @@ -96,11 +102,14 @@ class _HistoryScreenState extends State { state = await StorageService.instance.getExerciseState(_selected); // coverage:ignore-end } - final synced = _syncedOnly(remote); + final today = dayKey((widget.clock ?? DateTime.now)()); + final synced = _syncedOnly(remote, today); + final planned = _plannedOnly(remote, today); if (mounted) { setState(() { _rows = rows; _syncedRows = synced; + _plannedRows = planned; _exerciseNames = names; _selectedState = state; _loading = false; @@ -113,10 +122,7 @@ class _HistoryScreenState extends State { /// Read once per load and used twice: to restore this device's own missing /// sessions into local history, and to list the PC-only records below them. Future>> _readSyncedPayloads() => - WorkoutSyncService( - httpClient: widget.httpClient, - firebaseFactory: widget.firebaseFactory, - ).readMergedWorkoutPayloads(); + _syncService().readMergedWorkoutPayloads(); Future _pickExercise(String name) async { ExerciseState? state; @@ -147,7 +153,9 @@ class _HistoryScreenState extends State { ), body: _loading ? const Center(child: CircularProgressIndicator()) - : _rows.isEmpty + // Synced and planned entries count too: a phone with no local + // sessions must still list (and let the user delete) its plans. + : _rows.isEmpty && _syncedRows.isEmpty && _plannedRows.isEmpty ? Center( child: Text( 'No workouts yet.', @@ -173,6 +181,7 @@ class _HistoryScreenState extends State { } List _buildTotalView() => [ + ..._plannedSection(), const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const SizedBox(height: 6), _WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))), diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart index 96f5e67..792f34a 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart @@ -125,6 +125,14 @@ Set _allWorkoutDates( ...syncedRows.map((r) => '${r['date']}'), }; +/// Synced kinds this phone has no local record of, so showing them can't +/// double up an existing session. +/// +/// A `phone_verified` record is the PC's mirror of a StrongLifts session this +/// phone already stores in full (and renders from `_rows`), so it is +/// deliberately excluded — otherwise the same workout would appear twice. +const _kSyncedOnlyKinds = {'runnerup_verified', 'manual_workout'}; + /// Workouts the PC published that this phone has no local record of. /// /// The PC pushes its whole `log.json` (RunnerUp runs and manual @@ -132,6 +140,37 @@ Set _allWorkoutDates( /// the SAME history. Sorted newest-first to match the local session list. /// StrongLifts sessions are deliberately excluded: they are restored into /// local history instead, so listing them here too would double them up. -List> _syncedOnly(List> payloads) => - payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList() +/// +/// Planned manual entries (see [_isPlanned]) are excluded as well: they have +/// not happened, so they belong in their own list and never on the calendar, +/// which is built from these rows. +List> _syncedOnly( + List> payloads, + String today, +) => + payloads + .where((p) => _kSyncedOnlyKinds.contains(p['kind'])) + .where((p) => !_isPlanned(p, today)) + .toList() ..sort((a, b) => '${b['date']}'.compareTo('${a['date']}')); + +/// Manual entries dated after [today] (`YYYY-MM-DD`), soonest first. +List> _plannedOnly( + List> payloads, + String today, +) => + payloads.where((p) => _isPlanned(p, today)).toList() + ..sort((a, b) => '${a['date']}'.compareTo('${b['date']}')); + +/// Whether [payload] is a manual workout dated after [today]. +/// +/// A plain string comparison is a date comparison for `YYYY-MM-DD` keys. Only +/// manual entries can be planned; anything else dated ahead is a clock skew, +/// not a plan, and is left where it was. +/// +/// A stub with no `start_time` is never a plan: its record id cannot be +/// derived, so a delete would tombstone nothing and the tile would return. +bool _isPlanned(Map payload, String today) => + payload['kind'] == kManualWorkoutSyncKind && + payload['start_time'] is String && + '${payload['date']}'.compareTo(today) > 0; diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_planned.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_planned.dart new file mode 100644 index 0000000..13ad9ce --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_planned.dart @@ -0,0 +1,152 @@ +// Planned (future-dated) manual workouts: their section and their delete, +// plus the sync service both this and the load use. +// +// A `part` so the extension keeps reaching the private state. Nothing here +// calls `setState`: a delete ends in `_load`, which applies the reload. +part of 'history_screen.dart'; + +/// The PLANNED section of the history view. +extension _HistoryPlanned on _HistoryScreenState { + /// The sync service, wired to the test seams this screen was built with. + WorkoutSyncService _syncService() => WorkoutSyncService( + httpClient: widget.httpClient, + firebaseFactory: widget.firebaseFactory, + ); + + /// The planned entries, above everything that already happened; nothing + /// when there are none. + List _plannedSection() => [ + if (_plannedRows.isNotEmpty) ...[ + const _SectionLabel('PLANNED (not done yet)'), + const SizedBox(height: 8), + ..._plannedRows.map( + (row) => _PlannedWorkoutTile( + payload: row, + onDelete: () => _deletePlanned(row), + ), + ), + const SizedBox(height: 16), + ], + ]; + + /// Deletes the planned manual entry [payload] on every device, after a + /// confirmation, then re-reads history from sync. + /// + /// Re-reading rather than dropping the tile: the tile only goes once the + /// tombstone is really in the log. A failed push keeps it and says why. + Future _deletePlanned(Map payload) async { + final date = '${payload['date']}'; + final id = manualSyncRecordId(date, '${payload['start_time']}'); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete planned workout?'), + content: Text( + '${payload['source'] ?? 'Manual workout'} on $date at ' + '${payload['start_time']}. It will not be credited.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('KEEP'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('DELETE'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + // Re-checked against the clock: "planned" was decided when History + // loaded, and a delete confirmed after midnight would cancel a workout + // that is now today's -- one the PC may already have credited. + if (!_isPlanned(payload, dayKey((widget.clock ?? DateTime.now)()))) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Not deleted — $date has started, so it is no longer ' + 'a planned workout.', + ), + ), + ); + await _load(); + return; + } + final result = await _syncService().tombstoneManual(id); + SandboxLog.event('manual planned delete', { + 'id': id, + 'pushed': result.pushed, + 'reason': result.reason, + }); + if (!mounted) return; + if (!result.pushed) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Not deleted — ${result.reason}'))); + return; + } + await _load(); + } +} + +/// A manual workout dated after today: listed, but not as done. +/// +/// The only tile with a delete button. A plan can be cancelled until its day +/// comes; anything that already happened stays history. +class _PlannedWorkoutTile extends StatelessWidget { + const _PlannedWorkoutTile({required this.payload, required this.onDelete}); + + final Map payload; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final detail = '${payload['source'] ?? ''}'; + final time = '${payload['start_time'] ?? ''}'; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(left: 14, top: 4, bottom: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: colorScheme.outline), + ), + child: Row( + children: [ + Icon(Icons.event, color: colorScheme.onSurfaceVariant, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${payload['date']} $time · Planned', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + if (detail.isNotEmpty) + Text( + detail, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + ), + IconButton( + tooltip: 'Delete planned workout', + icon: Icon(Icons.delete_outline, color: colorScheme.error), + onPressed: onDelete, + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_actions.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_actions.dart new file mode 100644 index 0000000..c5534b9 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_actions.dart @@ -0,0 +1,101 @@ +// What the manual-workout form DOES: load the ledger, pick a date, submit. +// +// A `part` so the extension keeps reaching the private state; it mutates it +// through the `_applyState` shim because `setState` is `@protected` and +// cannot be called from an extension (same pattern as home_screen_sync.dart). +part of 'manual_workout_screen.dart'; + +/// Loading, date selection and submission for the manual-workout form. +extension _ManualWorkoutActions on _ManualWorkoutScreenState { + Future _loadBudget() async { + final ledger = await _sync.readManualLedger(); + if (!mounted) return; + _applyState(() { + _payloads = ledger.payloads; + _tombstoned = ledger.tombstoned; + _budgetLoaded = true; + }); + } + + Future _pickDate() async { + final today = _today(); + final picked = await showDatePicker( + context: context, + // Clamped: a form left open across midnight holds yesterday, and the + // picker asserts its initial date is inside the range. + initialDate: _date.isBefore(today) ? today : _date, + firstDate: today, + lastDate: today.add(const Duration(days: kManualWorkoutMaxDaysAhead)), + helpText: 'Workout date', + ); + if (picked == null || !mounted) return; + SandboxLog.event('manual date picked', {'date': dayKey(picked)}); + _applyState(() { + _date = DateTime(picked.year, picked.month, picked.day); + _error = null; + }); + } + + /// Why [_date] can no longer be submitted, or null when it can. + /// + /// Re-checked at submit because the clock moves: a form opened at 23:58 + /// with "today" selected is dated yesterday by the time it is sent. + String? _dateError() { + final today = _today(); + if (_date.isBefore(today)) { + return 'The workout date ${dayKey(_date)} is in the past — pick ' + 'today or a day ahead.'; + } + final last = today.add(const Duration(days: kManualWorkoutMaxDaysAhead)); + if (_date.isAfter(last)) { + return 'A workout can be planned at most $kManualWorkoutMaxDaysAhead ' + 'days ahead.'; + } + return null; + } + + Future _submit() async { + final draft = _draft(); + final date = dayKey(_date); + final recordId = manualSyncRecordId(date, draft.startTime); + final error = + validateManualWorkout(draft) ?? + _dateError() ?? + // A deleted record's id stays deleted forever (tombstones are + // sticky), so re-logging the same date and start time would vanish + // on every device. Refuse it here, where the user can still act. + (_tombstoned.contains(recordId) + ? 'A workout at ${draft.startTime.trim()} on $date was deleted ' + 'earlier and cannot be logged again — use a different ' + 'start time.' + : null); + if (error != null) { + SandboxLog.event('manual submit refused', {'reason': error}); + _applyState(() => _error = error); + return; + } + _applyState(() { + _error = null; + _submitting = true; + }); + final result = await _sync.pushManual( + buildManualRecord(draft, date, hlc: Hlc.newTick(currentSyncDeviceId)), + ); + SandboxLog.event('manual submit', { + 'id': recordId, + 'pushed': result.pushed, + 'reason': result.reason, + }); + if (!mounted) return; + if (!result.pushed) { + // Popping here used to report a workout that never left the phone as + // logged. Stay on the filled form and say why, so it can be retried. + _applyState(() { + _submitting = false; + _error = 'Not saved — ${result.reason}'; + }); + return; + } + Navigator.of(context).pop(true); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart index 7b0a2a4..9307d05 100644 --- a/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart @@ -8,15 +8,54 @@ part of 'manual_workout_screen.dart'; /// The form's non-interactive building blocks. extension _ManualWorkoutFormParts on _ManualWorkoutScreenState { + /// The budget as seen from the selected date: the fullest 7- and 30-day + /// windows around it, planned entries included. Widget _budgetBanner(ManualBudget budget) => Text( - 'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · ' - '${budget.month}/$kManualWorkoutBudgetPer30Days this month', + 'Manual around ${dayKey(_date)}: ' + '${budget.week}/$kManualWorkoutBudgetPer7Days per 7 days · ' + '${budget.month}/$kManualWorkoutBudgetPer30Days per 30 days', style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: AppTextSize.body, ), ); + /// "today", "tomorrow" or "in N days" for the selected date. + String _relativeDay() { + // Rounded, not truncated: across a DST change two local midnights are + // 23 or 25 hours apart. + final days = (_date.difference(_today()).inHours / 24).round(); + return switch (days) { + 0 => 'today', + 1 => 'tomorrow', + _ => 'in $days days', + }; + } + + /// The workout date and the button that changes it. + Widget _dateRow() => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Expanded( + child: Text( + 'Workout date: ${dayKey(_date)} (${_relativeDay()})', + key: const Key('mw_date'), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: AppTextSize.body, + ), + ), + ), + TextButton( + key: const Key('mw_pick_date'), + onPressed: _submitting ? null : _pickDate, + child: const Text('CHANGE DATE'), + ), + ], + ), + ); + Widget _section(String title) => Padding( padding: const EdgeInsets.only(top: 16, bottom: 4), child: Text( diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart index ebc1b44..e32330a 100644 --- a/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart @@ -5,6 +5,11 @@ /// (2/week, 5/month) computed over the merged cross-device record set. On /// submit it builds the shared sync payload and pushes it to the phone's device /// log; the PC ingests it as a counted `manual_workout`. +/// +/// The workout date defaults to today and may be set up to +/// [kManualWorkoutMaxDaysAhead] days ahead (never back): a planned session is +/// logged now and credited by the PC on its own date. The budget is read as +/// of the chosen date, so moving the date re-evaluates it. library; import 'dart:async'; @@ -12,11 +17,19 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:workout_app/models/manual_workout.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/done_today.dart'; +import 'package:workout_app/services/sync_device_id.dart'; import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; +part 'manual_workout_actions.dart'; part 'manual_workout_form_parts.dart'; +/// How many days ahead a manual workout may be dated. Past dates are never +/// offered: a manual entry is either today's or a planned one. +const int kManualWorkoutMaxDaysAhead = 7; + /// A screen that logs an off-app workout and syncs it to the PC. class ManualWorkoutScreen extends StatefulWidget { /// Creates the manual-workout form. [syncService]/[clock] are injectable for @@ -69,9 +82,22 @@ class _ManualWorkoutScreenState extends State { double _rpe = 5; String? _error; bool _submitting = false; - ManualBudget? _budget; + List> _payloads = const []; + Set _tombstoned = const {}; bool _budgetLoaded = false; + /// The workout date (a calendar day, no time). Starts as today. + late DateTime _date = _today(); + + DateTime _today() { + final now = _clock(); + return DateTime(now.year, now.month, now.day); + } + + /// Read fresh on every build, so a date change re-evaluates it without + /// another round trip to the sync backend. + ManualBudget get _budget => countManualBudget(_payloads, _date); + @override void initState() { super.initState(); @@ -86,43 +112,15 @@ class _ManualWorkoutScreenState extends State { super.dispose(); } - Future _loadBudget() async { - final payloads = await _sync.readMergedManualPayloads(); - if (!mounted) return; - setState(() { - _budget = countManualBudget(payloads, _clock()); - _budgetLoaded = true; - }); - } - - Future _submit() async { - final draft = _draft(); - final error = validateManualWorkout(draft); - if (error != null) { - setState(() => _error = error); - return; - } - setState(() { - _error = null; - _submitting = true; - }); - final now = _clock(); - final date = - '${now.year.toString().padLeft(4, '0')}-' - '${now.month.toString().padLeft(2, '0')}-' - '${now.day.toString().padLeft(2, '0')}'; - await _sync.pushManual( - buildManualRecord(draft, date, hlc: Hlc.newTick('phone')), - ); - if (!mounted) return; - Navigator.of(context).pop(true); - } + /// Runs [fn] inside `setState` on behalf of this library's extensions, + /// which cannot call the `@protected` original. + void _applyState(VoidCallback fn) => setState(fn); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final budget = _budget; - final exhausted = budget?.exhausted ?? false; + final exhausted = budget.exhausted; return Scaffold( appBar: AppBar( backgroundColor: colorScheme.surfaceContainerHigh, @@ -136,12 +134,16 @@ class _ManualWorkoutScreenState extends State { : ListView( padding: const EdgeInsets.all(16), children: [ - _budgetBanner(budget!), + // Above the exhausted branch on purpose: a full window around + // today must not stop the user picking a free day later on. + _dateRow(), + _budgetBanner(budget), if (exhausted) Padding( padding: const EdgeInsets.only(top: 8), child: Text( - 'Manual-workout budget exhausted for this window.', + 'Manual-workout budget exhausted for a window ' + 'containing ${dayKey(_date)} — pick another date.', style: TextStyle(color: colorScheme.error), ), ) diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index 0390ca6..a25ca6d 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -212,6 +212,22 @@ class WorkoutSyncService { Future>> readMergedManualPayloads() => _readMergedPayloads(kind: kManualWorkoutSyncKind); + /// [readMergedManualPayloads] plus every tombstoned id, from ONE read. + /// + /// The manual-workout form needs both: the payloads for the budget, the + /// ids to refuse re-logging onto a deleted record (see [ManualLedger]). + Future readManualLedger() => + _readMergedLedger(kind: kManualWorkoutSyncKind); + + /// Deletes the manual workout [recordId] everywhere: a crdt-sync + /// tombstone through [pushManual]. Only planned (future-dated) entries + /// are offered for deletion; the caller enforces that. Same never-throw + /// contract as [push]: a failed tombstone leaves the record live, and + /// says so. + Future tombstoneManual(String recordId) => pushManual( + buildManualTombstone(recordId, hlc: Hlc.newTick(currentSyncDeviceId)), + ); + /// Every synced workout, whatever kind — manual, StrongLifts or RunnerUp. /// /// The PC publishes its whole `log.json` (including verified runs), diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart index 68a2832..fe578b3 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart @@ -3,18 +3,37 @@ // An `extension` in a `part` -- see storage_service_backup.dart for why. part of 'workout_sync_service.dart'; +/// What the sync backends hold: the live payloads plus every tombstoned id. +/// +/// The ids matter to the manual-workout form because a manual record's id is +/// derived (`manual:T`) and deletion is sticky: re-logging the +/// same date and start time after deleting a planned entry would land on the +/// tombstone and vanish on every device without a word. +typedef ManualLedger = ({ + List> payloads, + Set tombstoned, +}); + +/// The ledger an unreadable or unconfigured backend answers with. +const ManualLedger _emptyLedger = ( + payloads: >[], + tombstoned: {}, +); + /// Reads merged workout/manual payloads across every device log. extension WorkoutSyncServiceRead on WorkoutSyncService { + Future>> _readMergedPayloads({ + String? kind, + }) async => (await _readMergedLedger(kind: kind)).payloads; - - Future>> _readMergedPayloads({String? kind}) async { + Future _readMergedLedger({String? kind}) async { final settings = await SyncSettings.load(); if (!settings.isConfigured && !await _hasFirebaseAccount()) { debugPrint( 'WorkoutSyncService: NOT reading synced workouts — no sync backend ' 'configured in Settings, so this phone cannot see the PC history.', ); - return const []; + return _emptyLedger; } try { @@ -42,14 +61,11 @@ extension WorkoutSyncServiceRead on WorkoutSyncService { 'WorkoutSyncService: FAILED reading synced workouts ' '(kind=$which) from $owner/$repo: $failure — history may be incomplete.', ); - return const []; + return _emptyLedger; } } - Future>> _fetchPayloads( - String token, - String? kind, - ) async { + Future _fetchPayloads(String token, String? kind) async { final github = GitHubClient( owner: owner, repo: repo, @@ -79,9 +95,14 @@ extension WorkoutSyncServiceRead on WorkoutSyncService { // delete it, and a per-file skip would just let the live copy win the // merge. Mirrors the PC's `_tombstoned_ids` (screen_locker/_sync_records.py). tombstoned.forEach(merged.remove); - return merged.values - .map((r) => (r.fields['payload']!.$1! as Map).cast()) - .toList(); + return ( + payloads: merged.values + .map( + (r) => (r.fields['payload']!.$1! as Map).cast(), + ) + .toList(), + tombstoned: tombstoned, + ); } finally { github.close(); firebase?.close(); @@ -118,5 +139,4 @@ extension WorkoutSyncServiceRead on WorkoutSyncService { } } } - } diff --git a/stronglift_replacement/workout_app/test/models/manual_budget_merge_fixture_test.dart b/stronglift_replacement/workout_app/test/models/manual_budget_merge_fixture_test.dart index cb309d9..89080d2 100644 --- a/stronglift_replacement/workout_app/test/models/manual_budget_merge_fixture_test.dart +++ b/stronglift_replacement/workout_app/test/models/manual_budget_merge_fixture_test.dart @@ -110,4 +110,43 @@ void main() { expect(budget.week, 0); expect(budget.month, 0); }); + + // The `planned` key (2026-10-16): future-dated entries. The top-level + // expectations above are the PC's trailing-window reading and stay as-is; + // this block pins the phone's admission rule for a workout dated D -- the + // fullest 7-/30-day window CONTAINING D, past and planned entries alike. + group('planned (future-dated) entries', () { + Map planned() => + _fixture()['planned'] as Map; + List allRecords() => [ + ..._fixture()['records'] as List, + ...planned()['extra_records'] as List, + ]; + + test('budget for each date matches the shared expectation', () { + final expected = planned()['expected_by_date'] as Map; + final payloads = _livePayloads(_deviceLogJson(allRecords())); + expect(expected, isNotEmpty); + for (final entry in expected.entries) { + final want = entry.value as Map; + final budget = countManualBudget(payloads, DateTime.parse(entry.key)); + expect(budget.week, want['week'], reason: entry.key); + expect(budget.month, want['month'], reason: entry.key); + expect(budget.exhausted, want['exhausted'], reason: entry.key); + } + }); + + test('a cancelled plan frees its weekly slot', () { + final resurrected = [ + for (final entry in allRecords().cast>()) + {...entry, 'deleted': false}, + ]; + final budget = countManualBudget( + _livePayloads(_deviceLogJson(resurrected)), + DateTime(2026, 8, 28), + ); + expect(budget.week, 2); + expect(budget.exhausted, isTrue); + }); + }); } diff --git a/stronglift_replacement/workout_app/test/models/manual_workout_budget_test.dart b/stronglift_replacement/workout_app/test/models/manual_workout_budget_test.dart index e5e0b09..82aec39 100644 --- a/stronglift_replacement/workout_app/test/models/manual_workout_budget_test.dart +++ b/stronglift_replacement/workout_app/test/models/manual_workout_budget_test.dart @@ -143,15 +143,63 @@ void main() { expect(budget.week, 3); }); - test('ignores dates outside the 30-day window and future dates', () { + test('ignores dates outside every window containing the date', () { final budget = countManualBudget([ payload('2026-05-01', '10:00'), - payload('2026-07-20', '10:00'), + payload('2026-09-20', '10:00'), ], now); expect(budget.week, 0); expect(budget.month, 0); }); + // Planned entries (2026-10-16): the count is the fullest window that + // CONTAINS the workout date, so a future entry costs budget too. + test('a future entry counts against a window containing the date', () { + final budget = countManualBudget([payload('2026-07-20', '10:00')], now); + expect(budget.week, 1); + expect(budget.month, 1); + }); + + test('a past and a future entry six days apart exhaust the week', () { + // 07-13 .. 07-19 is one 7-day window holding both, and it contains + // 07-16; the trailing window alone would have read 1/2. + final budget = countManualBudget([ + payload('2026-07-13', '10:00'), + payload('2026-07-19', '10:00'), + ], now); + expect(budget.week, 2); + expect(budget.exhausted, isTrue); + }); + + test('moving the date away from a full week frees it', () { + final payloads = [ + payload('2026-07-13', '10:00'), + payload('2026-07-19', '10:00'), + ]; + // 07-26: windows containing it start 07-20 or later, past both. + final later = countManualBudget(payloads, DateTime(2026, 7, 26)); + expect(later.week, 0); + expect(later.month, 2); + // 07-23: the window 07-17 .. 07-23 still holds 07-19. + expect(countManualBudget(payloads, DateTime(2026, 7, 23)).week, 1); + }); + + test('the date may carry a time of day', () { + final budget = countManualBudget([ + payload('2026-07-16', '10:00'), + ], DateTime(2026, 7, 16, 23, 59)); + expect(budget.week, 1); + }); + + test('a DST change between dates does not shift the day count', () { + // Europe's 2026 autumn change is 10-25; 10-19 .. 10-25 is one window. + final budget = countManualBudget([ + payload('2026-10-19', '10:00'), + payload('2026-10-25', '10:00'), + ], DateTime(2026, 10, 25)); + expect(budget.week, 2); + }); + test('skips payloads with missing or unparsable date', () { final budget = countManualBudget([ {'start_time': '10:00'}, diff --git a/stronglift_replacement/workout_app/test/models/manual_workout_tombstone_test.dart b/stronglift_replacement/workout_app/test/models/manual_workout_tombstone_test.dart new file mode 100644 index 0000000..4a250f5 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/manual_workout_tombstone_test.dart @@ -0,0 +1,36 @@ +// The tombstone that deletes a planned manual workout on every device. +// +// Split from manual_workout_test.dart for the 250-line cap. +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/manual_workout.dart'; + +const _draft = ManualWorkoutDraft( + sport: kSportTableTennis, + startTime: '18:00', + endTime: '19:30', + locationName: 'Solec', + transportMethod: 'bike', + cost: '40 PLN', + rpe: 6, + wentWell: 'Served consistently and moved feet well', + toImprove: 'Backhand topspin needs more consistency', + overallFeeling: 'Felt strong focused and in good rhythm', +); + +void main() { + test('buildManualTombstone is a field-less, deleted record', () { + final hlc = Hlc.newTick('phone'); + final tombstone = buildManualTombstone('manual:2026-07-13T18:00', hlc: hlc); + expect(tombstone.id, 'manual:2026-07-13T18:00'); + expect(tombstone.fields, isEmpty); + expect(tombstone.deleted, isTrue); + expect(tombstone.deletedHlc, hlc); + // Merged onto the live record it keeps the payload and stays deleted. + final live = buildManualRecord(_draft, '2026-07-13', hlc: hlc); + final merged = mergeRecord(live, tombstone); + expect(merged.deleted, isTrue); + expect(merged.fields['payload'], live.fields['payload']); + expect(tombstone.toJson()['deleted_hlc'], hlc.toStr()); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart index 809ecce..0f2fe0d 100644 --- a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart @@ -34,20 +34,20 @@ Future pumpHistory(WidgetTester tester, Widget w) async { Widget wrapHistory({ http.Client? httpClient, Future Function()? firebaseFactory, + DateTime Function()? clock, }) => MaterialApp( theme: buildAppTheme(), home: HistoryScreen( httpClient: httpClient, firebaseFactory: firebaseFactory ?? () async => null, + clock: clock, ), ); /// A GitHub mock serving one PC device log holding a run and a manual. http.Client historySyncMock() { - String file(String body) => jsonEncode({ - 'content': base64Encode(utf8.encode(body)), - 'sha': 'sha', - }); + String file(String body) => + jsonEncode({'content': base64Encode(utf8.encode(body)), 'sha': 'sha'}); Record rec(String id, String kind, int ms) => Record( id: id, fields: { diff --git a/stronglift_replacement/workout_app/test/screens/_manual_screen_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_manual_screen_fixtures.dart new file mode 100644 index 0000000..ab72303 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/_manual_screen_fixtures.dart @@ -0,0 +1,80 @@ +// Shared fixtures for the manual-workout form tests. +// +// Hoisted to top level so the split test files import one definition (see +// _history_test_fixtures.dart for why local copies drift). +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +class FakeManualSync extends WorkoutSyncService { + FakeManualSync({ + this.payloads = const [], + this.tombstoned = const {}, + this.result = const PushResult(pushed: true, reason: 'pushed'), + }); + + final List> payloads; + final Set tombstoned; + final PushResult result; + Record? pushed; + + @override + Future readManualLedger() async => + (payloads: payloads, tombstoned: tombstoned); + + @override + Future pushManual(Record record) async { + pushed = record; + return result; + } +} + +Map manualEntry(String date) => { + 'kind': 'manual_workout', + 'date': date, + 'start_time': '18:00', + 'end_time': '19:00', +}; + +/// Opens the date picker, taps [day] of the shown month, confirms. +Future pickDay(WidgetTester tester, String day) async { + await tester.tap(find.text('CHANGE DATE')); + await tester.pumpAndSettle(); + await tester.tap(find.text(day)); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); +} + +DateTime fixedManualNow() => DateTime(2026, 7, 13, 20); + +Future pumpManual(WidgetTester tester, Widget child) async { + // Tall viewport so the whole (long) form is laid out — a ListView only builds + // children within the viewport, so off-screen widgets aren't findable. + tester.view.physicalSize = const Size(500, 2600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(home: child)); + await tester.pumpAndSettle(); // let the budget future resolve +} + +Future fillValidTt(WidgetTester tester) async { + Future enter(String key, String value) => + tester.enterText(find.byKey(Key('mw_$key')), value); + await enter('start_time', '18:00'); + await enter('end_time', '19:30'); + await enter('location_name', 'Solec'); + await enter('transport_method', 'bike'); + await enter('cost', '40 PLN'); + await enter('matches_won', '3'); + await enter('matches_lost', '1'); + await enter('sets_won', '7'); + await enter('sets_lost', '4'); + await enter('racket', 'Butterfly'); + await enter('balls', 'Nittaku'); + await enter('pain_or_injury', 'sore knee'); + await enter('went_well', 'Served consistently and moved feet well'); + await enter('to_improve', 'Backhand topspin needs more consistency'); + await enter('overall_feeling', 'Felt strong focused and in good rhythm'); +} diff --git a/stronglift_replacement/workout_app/test/screens/history_planned_test.dart b/stronglift_replacement/workout_app/test/screens/history_planned_test.dart new file mode 100644 index 0000000..6e314af --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/history_planned_test.dart @@ -0,0 +1,223 @@ +// Planned (future-dated) manual workouts in the history view: listed apart +// from what was done, kept off the calendar, and deletable until their day +// through a crdt-sync tombstone. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/calendar_widget.dart'; + +import '../fake_path_provider.dart'; +import '../fake_secure_storage.dart'; +import '_history_test_fixtures.dart'; + +const _pcLog = 'screen-locker-sync/devices/pc/log.json'; + +Map _manual(String date, String? start, String source) => { + 'kind': 'manual_workout', + 'type': 'manual_workout', + 'date': date, + if (start != null) 'start_time': start, + 'source': source, +}; + +String _log(Map> byId) { + final hlc = Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'pc'); + return jsonEncode({ + for (final e in byId.entries) + e.key: Record(id: e.key, fields: {'payload': (e.value, hlc)}).toJson(), + }); +} + +/// A GitHub Contents API that remembers what was PUT, so a reload after a +/// delete sees the tombstone. [failPuts] answers every write with a 500. +({http.Client client, Map files}) _github( + Map files, { + bool failPuts = false, +}) { + final client = MockClient((req) async { + final path = req.url.path; + if (!path.contains('/contents/')) return http.Response('{}', 200); + final key = Uri.decodeComponent(path.split('/contents/').last); + if (req.method == 'PUT') { + if (failPuts) return http.Response('boom', 500); + final body = jsonDecode(req.body) as Map; + files[key] = utf8.decode(base64.decode(body['content'] as String)); + return http.Response('{"content":{"sha":"s"}}', 200); + } + final text = files[key]; + if (text != null) { + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode(text)), 'sha': 's'}), + 200, + ); + } + final children = { + for (final k in files.keys) + if (k.startsWith('$key/')) k.substring(key.length + 1).split('/').first, + }; + if (children.isEmpty) return http.Response('not found', 404); + return http.Response( + jsonEncode([ + for (final c in children) {'name': c, 'type': 'dir'}, + ]), + 200, + ); + }); + return (client: client, files: files); +} + +Map _seed() => { + _pcLog: _log({ + 'manual:2026-10-14T18:00': _manual('2026-10-14', '18:00', 'squash'), + 'manual:2026-10-16T07:00': _manual('2026-10-16', '07:00', 'swim today'), + 'manual:2026-10-17T10:00': _manual('2026-10-17', '10:00', 'padel'), + // A future stub has no start time, hence no derivable id: never a plan. + 'manual_workout:2026-10-18': _manual('2026-10-18', null, 'stub'), + }), +}; + +DateTime _now() => DateTime(2026, 10, 16, 15); + +/// Lets the real-I/O sync calls finish, then rebuilds. +Future _settle(WidgetTester tester, [int rounds = 1]) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pump(); + } +} + +Future _open( + WidgetTester tester, + http.Client client, { + DateTime Function() clock = _now, +}) async { + tester.view.physicalSize = const Size(500, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await pumpHistory(tester, wrapHistory(httpClient: client, clock: clock)); +} + +Future _confirmDelete(WidgetTester tester, String button) async { + await tester.tap(find.byTooltip('Delete planned workout')); + await tester.pumpAndSettle(); + await tester.tap(find.text(button)); + await tester.pump(); +} + +Record _phoneRecord(Map files, String id) { + final log = + jsonDecode(files['screen-locker-sync/devices/phone/log.json']!) + as Map; + return Record.fromJson(log[id] as Map); +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakePathProvider(); + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + }); + + testWidgets('a future manual is listed as planned, not as done', ( + tester, + ) async { + await _open(tester, _github(_seed()).client); + + // No local sessions, but synced and planned entries still show. + expect(find.text('No workouts yet.'), findsNothing); + expect(find.text('PLANNED (not done yet)'), findsOneWidget); + expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget); + expect(find.byTooltip('Delete planned workout'), findsOneWidget); + // Today's entry already happened: done, and not deletable. + expect(find.text('2026-10-16 · Manual'), findsOneWidget); + expect(find.text('2026-10-14 · Manual'), findsOneWidget); + expect(find.text('2026-10-18 · Manual'), findsOneWidget); + final calendar = tester.widget( + find.byType(WorkoutCalendar), + ); + expect(calendar.workoutDates, isNot(contains('2026-10-17'))); + expect(calendar.workoutDates, contains('2026-10-16')); + }); + + testWidgets('deleting a plan tombstones it and drops the tile', ( + tester, + ) async { + final github = _github(_seed()); + await _open(tester, github.client); + + await _confirmDelete(tester, 'DELETE'); + await _settle(tester, 4); + + final record = _phoneRecord(github.files, 'manual:2026-10-17T10:00'); + expect(record.deleted, isTrue); + expect(record.deletedHlc, isNotNull); + // The tile goes only once the reload has read the tombstone back. + await _settle(tester, 4); + await tester.pumpAndSettle(); + expect(find.text('PLANNED (not done yet)'), findsNothing); + expect(find.text('2026-10-17 10:00 · Planned'), findsNothing); + }); + + testWidgets('KEEP leaves the plan alone', (tester) async { + final github = _github(_seed()); + await _open(tester, github.client); + + await _confirmDelete(tester, 'KEEP'); + await _settle(tester); + + expect( + github.files.containsKey('screen-locker-sync/devices/phone/log.json'), + isFalse, + ); + expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget); + }); + + testWidgets('a failed delete keeps the tile and says why', (tester) async { + await _open(tester, _github(_seed(), failPuts: true).client); + + await _confirmDelete(tester, 'DELETE'); + await _settle(tester, 2); + + expect(find.textContaining('Not deleted — push failed'), findsOneWidget); + expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget); + }); + + testWidgets('a delete confirmed once the day has started is refused', ( + tester, + ) async { + var now = _now(); + final github = _github(_seed()); + await _open(tester, github.client, clock: () => now); + + await tester.tap(find.byTooltip('Delete planned workout')); + await tester.pumpAndSettle(); + now = DateTime(2026, 10, 17, 0, 1); + await tester.tap(find.text('DELETE')); + await tester.pump(); + await _settle(tester, 2); + + expect(find.textContaining('no longer a planned workout'), findsOneWidget); + expect( + github.files.containsKey('screen-locker-sync/devices/phone/log.json'), + isFalse, + ); + // Reloaded against the new day: it is done now, not planned. + await tester.pumpAndSettle(); + expect(find.text('2026-10-17 · Manual'), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart b/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart new file mode 100644 index 0000000..e0bdfc4 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart @@ -0,0 +1,178 @@ +// The workout date on the manual form: today up to a week ahead, the budget +// read as of that date, and the refusals around it. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/screens/manual_workout_screen.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +import '_manual_screen_fixtures.dart'; + +void main() { + group('workout date', () { + testWidgets('a future date is pushed under that date', (tester) async { + final sync = FakeManualSync(); + await pumpManual( + tester, + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), + ); + await pickDay(tester, '14'); + expect(find.text('Workout date: 2026-07-14 (tomorrow)'), findsOneWidget); + await fillValidTt(tester); + await tester.tap(find.text('SUBMIT')); + await tester.pumpAndSettle(); + expect(sync.pushed!.id, 'manual:2026-07-14T18:00'); + final payload = sync.pushed!.fields['payload']!.$1! as Map; + expect(payload['date'], '2026-07-14'); + }); + + testWidgets('the picker offers today to today+7 only', (tester) async { + await pumpManual( + tester, + ManualWorkoutScreen( + syncService: FakeManualSync(), + clock: fixedManualNow, + ), + ); + await tester.tap(find.text('CHANGE DATE')); + await tester.pumpAndSettle(); + final picker = tester.widget( + find.byType(DatePickerDialog), + ); + expect(picker.firstDate, DateTime(2026, 7, 13)); + expect(picker.lastDate, DateTime(2026, 7, 20)); + expect(picker.initialDate, DateTime(2026, 7, 13)); + await tester.tap(find.text('20')); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Workout date: 2026-07-20 (in 7 days)'), findsOneWidget); + }); + + testWidgets('cancelling the picker keeps the date', (tester) async { + await pumpManual( + tester, + ManualWorkoutScreen( + syncService: FakeManualSync(), + clock: fixedManualNow, + ), + ); + await tester.tap(find.text('CHANGE DATE')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget); + }); + + testWidgets('the budget follows the selected date', (tester) async { + // 07-12 and a planned 07-18 share the window 07-12..07-18, which + // contains today: today is full, 07-20 is not. + await pumpManual( + tester, + ManualWorkoutScreen( + syncService: FakeManualSync( + payloads: [manualEntry('2026-07-12'), manualEntry('2026-07-18')], + ), + clock: fixedManualNow, + ), + ); + expect(find.textContaining('2/2 per 7 days'), findsOneWidget); + expect(find.textContaining('budget exhausted'), findsOneWidget); + expect(find.text('SUBMIT'), findsNothing); + // The date row stays reachable while the window is full. + await pickDay(tester, '20'); + expect( + find.text( + 'Manual around 2026-07-20: 1/2 per 7 days · 2/10 per 30 days', + ), + findsOneWidget, + ); + expect(find.textContaining('budget exhausted'), findsNothing); + expect(find.text('SUBMIT'), findsOneWidget); + }); + + testWidgets('re-logging onto a deleted record is refused', (tester) async { + final sync = FakeManualSync(tombstoned: {'manual:2026-07-13T18:00'}); + await pumpManual( + tester, + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), + ); + await fillValidTt(tester); + await tester.tap(find.text('SUBMIT')); + await tester.pump(); + expect(find.textContaining('was deleted earlier'), findsOneWidget); + expect(sync.pushed, isNull); + // A different start time is a different record id. + await tester.enterText(find.byKey(const Key('mw_start_time')), '17:30'); + await tester.tap(find.text('SUBMIT')); + await tester.pumpAndSettle(); + expect(sync.pushed!.id, 'manual:2026-07-13T17:30'); + }); + + testWidgets('a failed push keeps the form open and says why', ( + tester, + ) async { + final sync = FakeManualSync( + result: const PushResult(pushed: false, reason: 'push failed: 500'), + ); + await pumpManual( + tester, + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), + ); + await fillValidTt(tester); + await tester.tap(find.text('SUBMIT')); + await tester.pumpAndSettle(); + expect(sync.pushed, isNotNull); + expect(find.text('Not saved — push failed: 500'), findsOneWidget); + expect(find.text('Log Manual Workout'), findsOneWidget); + final submit = tester.widget( + find.widgetWithText(ElevatedButton, 'SUBMIT'), + ); + expect(submit.onPressed, isNotNull); + }); + + testWidgets('a date left behind by midnight is refused, then clamped', ( + tester, + ) async { + var now = DateTime(2026, 7, 13, 23, 59); + final sync = FakeManualSync(); + await pumpManual( + tester, + ManualWorkoutScreen(syncService: sync, clock: () => now), + ); + await fillValidTt(tester); + now = DateTime(2026, 7, 14, 0, 1); + await tester.tap(find.text('SUBMIT')); + await tester.pump(); + expect(find.textContaining('is in the past'), findsOneWidget); + expect(sync.pushed, isNull); + // The picker would assert on an initial date before its range. + await tester.tap(find.text('CHANGE DATE')); + await tester.pumpAndSettle(); + final picker = tester.widget( + find.byType(DatePickerDialog), + ); + expect(picker.initialDate, DateTime(2026, 7, 14)); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Workout date: 2026-07-14 (today)'), findsOneWidget); + }); + + testWidgets('a date beyond the window is refused at submit', ( + tester, + ) async { + // Only a clock moving backwards can get here; refuse rather than push. + var now = DateTime(2026, 7, 13, 12); + final sync = FakeManualSync(); + await pumpManual( + tester, + ManualWorkoutScreen(syncService: sync, clock: () => now), + ); + await pickDay(tester, '20'); + await fillValidTt(tester); + now = DateTime(2026, 7, 12, 12); + await tester.tap(find.text('SUBMIT')); + await tester.pump(); + expect(find.textContaining('at most 7 days ahead'), findsOneWidget); + expect(sync.pushed, isNull); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart index 5c9ce14..88030fb 100644 --- a/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart @@ -5,65 +5,21 @@ import 'package:workout_app/models/manual_workout.dart'; import 'package:workout_app/screens/manual_workout_screen.dart'; import 'package:workout_app/services/workout_sync_service.dart'; -class _FakeSync extends WorkoutSyncService { - _FakeSync({this.payloads = const []}); - - final List> payloads; - Record? pushed; - - @override - Future>> readMergedManualPayloads() async => - payloads; - - @override - Future pushManual(Record record) async { - pushed = record; - return const PushResult(pushed: true, reason: 'pushed'); - } -} - -DateTime _fixedNow() => DateTime(2026, 7, 13, 20); - -Future _pump(WidgetTester tester, Widget child) async { - // Tall viewport so the whole (long) form is laid out — a ListView only builds - // children within the viewport, so off-screen widgets aren't findable. - tester.view.physicalSize = const Size(500, 2600); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(MaterialApp(home: child)); - await tester.pumpAndSettle(); // let the budget future resolve -} - -Future _fillValidTt(WidgetTester tester) async { - Future enter(String key, String value) => - tester.enterText(find.byKey(Key('mw_$key')), value); - await enter('start_time', '18:00'); - await enter('end_time', '19:30'); - await enter('location_name', 'Solec'); - await enter('transport_method', 'bike'); - await enter('cost', '40 PLN'); - await enter('matches_won', '3'); - await enter('matches_lost', '1'); - await enter('sets_won', '7'); - await enter('sets_lost', '4'); - await enter('racket', 'Butterfly'); - await enter('balls', 'Nittaku'); - await enter('pain_or_injury', 'sore knee'); - await enter('went_well', 'Served consistently and moved feet well'); - await enter('to_improve', 'Backhand topspin needs more consistency'); - await enter('overall_feeling', 'Felt strong focused and in good rhythm'); -} +import '_manual_screen_fixtures.dart'; void main() { testWidgets('shows the form and budget when not exhausted', (tester) async { - await _pump( + await pumpManual( tester, - ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow), + ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow), ); expect(find.text('Log Manual Workout'), findsOneWidget); expect(find.text('SUBMIT'), findsOneWidget); - expect(find.textContaining('0/2 this week'), findsOneWidget); + expect( + find.text('Manual around 2026-07-13: 0/2 per 7 days · 0/10 per 30 days'), + findsOneWidget, + ); + expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget); }); testWidgets('hides the form when the budget is exhausted', (tester) async { @@ -84,11 +40,11 @@ void main() { 'end_time': '19:00', }, ]; - await _pump( + await pumpManual( tester, ManualWorkoutScreen( - syncService: _FakeSync(payloads: payloads), - clock: _fixedNow, + syncService: FakeManualSync(payloads: payloads), + clock: fixedManualNow, ), ); expect(find.textContaining('budget exhausted'), findsOneWidget); @@ -96,10 +52,10 @@ void main() { }); testWidgets('shows a validation error and does not push', (tester) async { - final sync = _FakeSync(); - await _pump( + final sync = FakeManualSync(); + await pumpManual( tester, - ManualWorkoutScreen(syncService: sync, clock: _fixedNow), + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), ); await tester.tap(find.text('SUBMIT')); await tester.pump(); @@ -108,12 +64,12 @@ void main() { }); testWidgets('valid table-tennis workout pushes and pops', (tester) async { - final sync = _FakeSync(); - await _pump( + final sync = FakeManualSync(); + await pumpManual( tester, - ManualWorkoutScreen(syncService: sync, clock: _fixedNow), + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), ); - await _fillValidTt(tester); + await fillValidTt(tester); await tester.tap(find.text('SUBMIT')); await tester.pumpAndSettle(); expect(sync.pushed, isNotNull); @@ -124,9 +80,9 @@ void main() { }); testWidgets('switching sport reveals the other-sport fields', (tester) async { - await _pump( + await pumpManual( tester, - ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow), + ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow), ); expect(find.byKey(const Key('mw_matches_won')), findsOneWidget); await tester.tap(find.byType(DropdownButton)); @@ -138,9 +94,9 @@ void main() { }); testWidgets('RPE slider updates the label', (tester) async { - await _pump( + await pumpManual( tester, - ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow), + ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow), ); expect(find.text('RPE 5'), findsOneWidget); await tester.drag(find.byType(Slider), const Offset(300, 0)); @@ -149,10 +105,10 @@ void main() { }); testWidgets('other-sport validation error pushes nothing', (tester) async { - final sync = _FakeSync(); - await _pump( + final sync = FakeManualSync(); + await pumpManual( tester, - ManualWorkoutScreen(syncService: sync, clock: _fixedNow), + ManualWorkoutScreen(syncService: sync, clock: fixedManualNow), ); await tester.tap(find.byType(DropdownButton)); await tester.pumpAndSettle(); diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_ledger_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_ledger_test.dart new file mode 100644 index 0000000..f0aba22 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_ledger_test.dart @@ -0,0 +1,72 @@ +// readManualLedger: the manual payloads plus every tombstoned id, which the +// form needs to refuse re-logging onto a deleted record. +// +// Split from workout_sync_read_test.dart for the 250-line cap. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; + +import '../fake_secure_storage.dart'; +import '_sync_test_fixtures.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('readManualLedger', () { + test('returns live manuals and every tombstoned id', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final deleted = Record( + id: 'manual:gone', + fields: const {}, + deleted: true, + deletedHlc: Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'phone'), + ); + final phoneLog = jsonEncode({'manual:gone': deleted.toJson()}); + final pcLog = jsonEncode({ + 'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(), + // Live on the PC, deleted on the phone: the tombstone wins. + 'manual:gone': manualRecord('manual:gone', 'X', 1000).toJson(), + }); + final (:httpClient, putCalls: _) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, [ + {'name': 'phone'}, + {'name': 'pc'}, + ]), + 'screen-locker-sync/devices/phone/log.json': fileContaining(phoneLog), + 'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog), + }, + ); + final ledger = await syncService( + httpClient: httpClient, + ).readManualLedger(); + expect(ledger.payloads.map((p) => p['cost']), ['NEW']); + expect(ledger.tombstoned, {'manual:gone'}); + }); + + test('is empty without a token', () async { + installFakeSecureStorage(); + final (:httpClient, putCalls: _) = mockGitHub(); + final ledger = await syncService( + httpClient: httpClient, + ).readManualLedger(); + expect(ledger.payloads, isEmpty); + expect(ledger.tombstoned, isEmpty); + }); + + test('is empty when the backend fails', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final httpClient = http_testing.MockClient( + (request) async => http.Response('', 500), + ); + final ledger = await syncService( + httpClient: httpClient, + ).readManualLedger(); + expect(ledger.payloads, isEmpty); + expect(ledger.tombstoned, isEmpty); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart index 7f436e9..2deca7c 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -180,6 +180,52 @@ void main() { ); }); + test('tombstoneManual pushes a sticky delete for the id', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final existing = jsonEncode({ + 'manual:2026-07-20T10:00': manualRecord( + 'manual:2026-07-20T10:00', + '40', + 1000, + ).toJson(), + }); + final (:httpClient, :putCalls) = mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': syncResponse(200, []), + 'screen-locker-sync/devices/phone/log.json': fileContaining(existing), + }, + ); + final result = await syncService( + httpClient: httpClient, + ).tombstoneManual('manual:2026-07-20T10:00'); + expect(result.pushed, isTrue); + final pushedLog = + jsonDecode( + utf8.decode( + base64.decode(logPut(putCalls).body['content'] as String), + ), + ) + as Map; + final record = Record.fromJson( + pushedLog['manual:2026-07-20T10:00'] as Map, + ); + expect(record.deleted, isTrue); + expect(record.deletedHlc, isNotNull); + // The payload survives the merge; only the delete flag is added. + expect((record.fields['payload']!.$1! as Map)['cost'], '40'); + }); + + test('tombstoneManual reports why when sync is not set up', () async { + installFakeSecureStorage(); + final (:httpClient, :putCalls) = mockGitHub(); + final result = await syncService( + httpClient: httpClient, + ).tombstoneManual('manual:2026-07-20T10:00'); + expect(result.pushed, isFalse); + expect(result.reason, contains('not configured')); + expect(putCalls, isEmpty); + }); + test('pushManual reports a failure instead of hiding it', () async { installFakeSecureStorage(initial: {'sync.token': 'tok'}); final httpClient = http_testing.MockClient(