From 957769ff0eb64c2482648a49b0f4f3575c2325f7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 21:31:45 +0200 Subject: [PATCH] Add budget-adherence calendar, streaks, and freely-editable synced budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both platforms gain a calendar tab/screen (green/yellow/red/not-logged day coloring, logging/adherence streaks, year-to-date tally), and tapping a day drills into that day's logged entries. This requires the daily budget to become a normal, freely-editable value synced between devices like the food log — replaces the previous chattr +i seal (deliberately, per user decision) with plain last-edit-wins sync via a new budget.json alongside food_log.json. The budget field defaults to 2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on both the PC gate's History tab and the phone app's Calendar screen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA --- CLAUDE.md | 29 +- app/lib/models/day_status.dart | 57 +++ app/lib/screens/calendar_screen.dart | 223 +++++++++ app/lib/screens/history_screen.dart | 15 +- app/lib/screens/log_meal_screen.dart | 14 + app/lib/services/app_settings_service.dart | 44 +- app/lib/services/background_sync_service.dart | 5 + app/lib/services/day_status_service.dart | 155 ++++++ app/lib/services/sync_merge.dart | 91 ++++ app/lib/services/sync_service.dart | 46 ++ app/lib/widgets/day_status_calendar.dart | 201 ++++++++ app/lib/widgets/streak_summary_row.dart | 53 ++ app/test/models/day_status_test.dart | 39 ++ app/test/screens/calendar_screen_test.dart | 175 +++++++ app/test/screens/history_screen_test.dart | 52 ++ app/test/screens/log_meal_screen_test.dart | 15 + .../services/app_settings_service_test.dart | 91 ++++ .../background_sync_service_test.dart | 10 +- .../services/day_status_service_test.dart | 221 +++++++++ app/test/services/sync_merge_test.dart | 72 +++ app/test/services/sync_service_test.dart | 145 +++++- .../widgets/day_status_calendar_test.dart | 242 +++++++++ app/test/widgets/streak_summary_row_test.dart | 64 +++ diet_guard/_budget.py | 272 +++++----- diet_guard/_calendar_view.py | 151 ++++++ diet_guard/_cli.py | 33 +- diet_guard/_constants.py | 23 +- diet_guard/_daystatus.py | 201 ++++++++ diet_guard/_gatelock.py | 24 +- diet_guard/_gatelock_calendar.py | 466 ++++++++++++++++++ diet_guard/_gatelock_mealflow.py | 4 +- diet_guard/_gatelock_nutrition.py | 2 +- diet_guard/_gatelock_ui.py | 2 + diet_guard/_mcp.py | 16 +- diet_guard/_state.py | 4 +- diet_guard/_sync.py | 67 ++- diet_guard/_sync_merge.py | 87 ++++ diet_guard/tests/conftest.py | 56 ++- diet_guard/tests/test_budget.py | 201 +++----- diet_guard/tests/test_calendar_view.py | 126 +++++ diet_guard/tests/test_cli.py | 44 +- diet_guard/tests/test_daystatus.py | 185 +++++++ diet_guard/tests/test_gatelock.py | 4 +- diet_guard/tests/test_gatelock_calendar.py | 208 ++++++++ diet_guard/tests/test_gatelock_mealflow.py | 8 +- diet_guard/tests/test_state.py | 10 +- diet_guard/tests/test_sync.py | 96 +++- diet_guard/tests/test_sync_merge.py | 82 ++- install.sh | 35 +- 49 files changed, 4025 insertions(+), 441 deletions(-) create mode 100644 app/lib/models/day_status.dart create mode 100644 app/lib/screens/calendar_screen.dart create mode 100644 app/lib/services/day_status_service.dart create mode 100644 app/lib/widgets/day_status_calendar.dart create mode 100644 app/lib/widgets/streak_summary_row.dart create mode 100644 app/test/models/day_status_test.dart create mode 100644 app/test/screens/calendar_screen_test.dart create mode 100644 app/test/services/day_status_service_test.dart create mode 100644 app/test/widgets/day_status_calendar_test.dart create mode 100644 app/test/widgets/streak_summary_row_test.dart create mode 100644 diet_guard/_calendar_view.py create mode 100644 diet_guard/_daystatus.py create mode 100644 diet_guard/_gatelock_calendar.py create mode 100644 diet_guard/tests/test_calendar_view.py create mode 100644 diet_guard/tests/test_daystatus.py create mode 100644 diet_guard/tests/test_gatelock_calendar.py diff --git a/CLAUDE.md b/CLAUDE.md index a6d4180..74c15c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,14 @@ A log-to-unlock gate: every ~30 minutes, `diet-guard-gate.timer` runs 16:00, 20:00) has elapsed without a logged meal. If so, it opens a fullscreen Tk window that blocks the desktop until the user logs what they ate (with autocomplete from a local food-name "bank", optionally seeded from Open Food -Facts via `requests`). It also tracks a daily calorie/macro budget, sealed at -init time and tamper-resistant via `chattr +i`. +Facts via `requests`). It also tracks a daily calorie/macro budget, seeded +from biometrics at `init` time but freely editable afterward on either +device (PC gate or phone app), synced last-edit-wins like the food log (see +"Cross-device sync"). A second tab in the gate window ("History", built by +`_gatelock_calendar.py`) and a matching screen in the phone app show a +budget-adherence calendar, logging/adherence streaks, and a year-to-date +tally, both derived from the same day-status classification +(`_daystatus.py` / `day_status_service.dart`). See `docs/design.md` for the original feature spec (meal-slot timing logic, the Tue/Wed/Thu "filled most of the day" catch-up rule, multi-item meals). @@ -88,11 +94,15 @@ silently does **not** reach the running service. ## Operational gotchas -- **The budget file is sealed immutable.** `~/.local/share/diet_guard/.budget` - gets `chattr +i` after `init` (see `install.sh` step 6). This is the actual - tamper-resistance mechanism — the budget can't be casually edited to "make - room" once locked. To intentionally change it: `sudo chattr -i` the file, - re-run `python -m diet_guard init`, then re-lock. +- **The budget is a plain, freely-editable synced file, by design.** + `~/.local/share/diet_guard/.budget` (`diet_guard/_budget.py`) has no seal + or signing — this replaces a previous `chattr +i` anti-impulse-editing + mechanism, removed deliberately so the same value can be edited from + either device. Edit it from the gate's History tab, the phone app's + Calendar screen, or `settings_screen.dart`; every write stamps an edit + timestamp so `diet-guard-sync.timer`'s `budget.json` sync (mirroring the + food-log sync) resolves concurrent edits last-writer-wins. Do not + reintroduce the seal as a "fix" — the removal is the feature. - **Biometrics are used once and discarded.** `init` asks for biometrics to compute the daily budget, then the only persisted output is the computed budget number — never the biometrics themselves. @@ -118,8 +128,9 @@ silently does **not** reach the running service. not accidental complexity. - Don't add a dependency without doing the production install-path check above. -- Don't remove the `chattr +i` step from `install.sh` — it's the actual - enforcement mechanism, not a formality. +- Don't reintroduce a seal/lock/`chattr +i` on the budget file as a "fix" — + it was deliberately removed so the budget is freely editable and synced + between devices; see "Operational gotchas" above. ## Flutter/Dart AI rules diff --git a/app/lib/models/day_status.dart b/app/lib/models/day_status.dart new file mode 100644 index 0000000..0226fa9 --- /dev/null +++ b/app/lib/models/day_status.dart @@ -0,0 +1,57 @@ +/// Per-day budget-adherence status and year-to-date tally value types. +library; + +import 'package:flutter/foundation.dart'; + +/// Qualitative per-day budget-adherence state, worst to best. +/// +/// Kept in sync with `diet_guard/_daystatus.py`'s `DayStatus`: not-logged is +/// considered worse than red, since an unlogged day carries no information +/// about whether it went well or badly. +enum DayStatus { + /// No valid, non-tombstoned entries were logged for the day. + notLogged, + + /// Logged, and more than 20% over budget. + red, + + /// Logged, and up to 20% over budget. + yellow, + + /// Logged, and at or under budget. + green, +} + +/// Year-to-date logging/adherence counts. +@immutable +class YtdTally { + /// Creates a tally from its three component counts. + const YtdTally({ + required this.loggedDays, + required this.elapsedDays, + required this.adherentDays, + }); + + /// Days this year with at least one valid log entry. + final int loggedDays; + + /// Days from Jan 1 through the reference date, inclusive. + final int elapsedDays; + + /// Of [loggedDays], how many were green or yellow. + final int adherentDays; + + @override + bool operator ==(Object other) => + other is YtdTally && + other.loggedDays == loggedDays && + other.elapsedDays == elapsedDays && + other.adherentDays == adherentDays; + + @override + int get hashCode => Object.hash(loggedDays, elapsedDays, adherentDays); + + @override + String toString() => + 'YtdTally(logged: $loggedDays/$elapsedDays, adherent: $adherentDays)'; +} diff --git a/app/lib/screens/calendar_screen.dart b/app/lib/screens/calendar_screen.dart new file mode 100644 index 0000000..aca3d04 --- /dev/null +++ b/app/lib/screens/calendar_screen.dart @@ -0,0 +1,223 @@ +/// Budget-adherence calendar, streaks, year-to-date tally, budget editing. +/// +/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py`): the +/// budget field starts read-only, defaulting to +/// [AppSettingsService.dailyKcalGoal]'s built-in 2200 kcal fallback when +/// nothing has ever been saved, and an "Edit"/"Save" toggle button unlocks +/// it for editing on the first click and validates+persists on the second. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/day_status_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/day_status_calendar.dart'; +import 'package:diet_guard_app/widgets/streak_summary_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Screen showing the budget-adherence calendar, streaks, and budget entry. +class CalendarScreen extends StatefulWidget { + /// Creates a [CalendarScreen]. + const CalendarScreen({super.key}); + + @override + State createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends State { + final _budgetController = TextEditingController(); + DateTime _month = DateTime.now(); + Map _statusByDate = {}; + int _loggingStreak = 0; + int _adherenceStreak = 0; + YtdTally _tally = const YtdTally( + loggedDays: 0, + elapsedDays: 0, + adherentDays: 0, + ); + bool _loading = true; + bool _editingBudget = false; + String? _budgetStatus; + bool _budgetError = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + @override + void dispose() { + _budgetController.dispose(); + super.dispose(); + } + + /// Loads the log, recomputes status/streaks/tally against the current + /// budget, and refreshes the (read-only) budget field -- unless the user + /// is mid-edit, in which case their unsaved text is left untouched. + Future _load() async { + final log = await LogStorageService.instance.readLog(); + if (!mounted) return; + final budget = AppSettingsService.dailyKcalGoal; + final statusByDate = statusMap(log, budget: budget); + setState(() { + _statusByDate = statusByDate; + _loggingStreak = loggingStreak(statusByDate); + _adherenceStreak = adherenceStreak(statusByDate); + _tally = yearToDateTally(statusByDate); + _loading = false; + if (!_editingBudget) { + _budgetController.text = budget.toString(); + } + }); + } + + void _onPrevMonth() { + setState(() => _month = DateTime(_month.year, _month.month - 1)); + } + + void _onNextMonth() { + setState(() => _month = DateTime(_month.year, _month.month + 1)); + } + + /// Opens [HistoryScreen] pre-filtered to the tapped day. + void _onDaySelected(DateTime day) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => HistoryScreen( + initialDateRange: DateTimeRange(start: day, end: day), + ), + ), + ), + ); + } + + /// First click: unlocks the entry for typing, relabels the button "Save". + /// Second click: validates and persists; a validation failure leaves + /// editing open so the user can correct the value instead of silently + /// discarding it. + Future _onEditOrSaveBudget() async { + if (!_editingBudget) { + setState(() { + _editingBudget = true; + _budgetStatus = null; + _budgetError = false; + }); + return; + } + final value = int.tryParse(_budgetController.text.trim()); + if (value == null) { + setState(() { + _budgetStatus = 'Enter a whole number of kcal.'; + _budgetError = true; + }); + return; + } + if (value <= 0) { + setState(() { + _budgetStatus = 'Budget must be a positive number.'; + _budgetError = true; + }); + return; + } + await AppSettingsService.instance.saveDailyKcalGoal(value); + if (!mounted) return; + setState(() { + _editingBudget = false; + _budgetStatus = 'Saved.'; + _budgetError = false; + }); + await _load(); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _BudgetRow( + controller: _budgetController, + editing: _editingBudget, + onEditOrSave: _onEditOrSaveBudget, + ), + if (_budgetStatus != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _budgetStatus!, + style: TextStyle( + color: _budgetError ? theme.colorScheme.error : null, + ), + ), + ), + const SizedBox(height: 12), + DayStatusCalendar( + statusByDate: _statusByDate, + month: _month, + today: DateTime.now(), + onPrevMonth: _onPrevMonth, + onNextMonth: _onNextMonth, + onDaySelected: _onDaySelected, + ), + const SizedBox(height: 16), + StreakSummaryRow( + loggingStreak: _loggingStreak, + adherenceStreak: _adherenceStreak, + tally: _tally, + ), + ], + ), + ), + ); + } +} + +class _BudgetRow extends StatelessWidget { + const _BudgetRow({ + required this.controller, + required this.editing, + required this.onEditOrSave, + }); + + final TextEditingController controller; + final bool editing; + final VoidCallback onEditOrSave; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Daily budget (kcal):'), + const SizedBox(width: 8), + SizedBox( + width: 90, + child: TextField( + controller: controller, + enabled: editing, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: onEditOrSave, + child: Text(editing ? 'Save' : 'Edit'), + ), + ], + ); + } +} diff --git a/app/lib/screens/history_screen.dart b/app/lib/screens/history_screen.dart index d651cf3..9ec6a8b 100644 --- a/app/lib/screens/history_screen.dart +++ b/app/lib/screens/history_screen.dart @@ -8,6 +8,7 @@ import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/edit_entry_screen.dart'; import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/day_status_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; @@ -230,7 +231,7 @@ List<_HistoryItem> _buildGroupedItems(List entries) { final items = <_HistoryItem>[]; for (final day in days) { final dayEntries = byDay[day]!; - final totalKcal = dayEntries.fold(0, (s, e) => s + e.kcal); + final totalKcal = sumKcal(dayEntries); final totalProtein = dayEntries.fold(0, (s, e) => s + e.proteinG); final totalCarbs = dayEntries.fold(0, (s, e) => s + e.carbsG); final totalFat = dayEntries.fold(0, (s, e) => s + e.fatG); @@ -286,7 +287,14 @@ String _formatDay(String dateKey) { /// filtering and sorting. class HistoryScreen extends StatefulWidget { /// Creates a [HistoryScreen]. - const HistoryScreen({super.key}); + /// + /// [initialDateRange], when given, pre-applies a date-range filter (e.g. + /// the Calendar screen navigating here after a day is tapped, filtering + /// to just that one day). + const HistoryScreen({this.initialDateRange, super.key}); + + /// A date-range filter applied on first load; null shows everything. + final DateTimeRange? initialDateRange; @override State createState() => _HistoryScreenState(); @@ -295,13 +303,14 @@ class HistoryScreen extends StatefulWidget { class _HistoryScreenState extends State { List? _allEntries; List _displayed = const []; - HistoryFilter _filter = HistoryFilter(); + late HistoryFilter _filter; HistorySortField _sortField = HistorySortField.date; bool _sortAscending = false; @override void initState() { super.initState(); + _filter = HistoryFilter(dateRange: widget.initialDateRange); unawaited(_load()); } diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 857e678..b7410f2 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -9,6 +9,7 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_suggestion.dart'; import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/screens/calendar_screen.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/meal_builder_screen.dart'; @@ -256,6 +257,14 @@ class _LogMealScreenState extends State ); } + void _onOpenCalendar() { + unawaited( + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CalendarScreen()), + ), + ); + } + void _onOpenFoodBank() { unawaited( Navigator.of(context).push( @@ -290,6 +299,11 @@ class _LogMealScreenState extends State tooltip: 'History', onPressed: _onOpenHistory, ), + IconButton( + icon: const Icon(Icons.calendar_month), + tooltip: 'Calendar', + onPressed: _onOpenCalendar, + ), IconButton( icon: const Icon(Icons.settings), tooltip: 'Sync settings', diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart index ade1c27..ca604bf 100644 --- a/app/lib/services/app_settings_service.dart +++ b/app/lib/services/app_settings_service.dart @@ -12,7 +12,11 @@ import 'package:path_provider/path_provider.dart'; /// /// The static [dailyKcalGoal] getter returns the default (2200) when the /// singleton has not been initialised — safe to read in widget tests that -/// never call [init]. +/// never call [init]. [dailyKcalGoalUpdatedAt] is null until the goal has +/// been explicitly set (by the user or a sync merge) at least once on this +/// device -- that null is what tells the sync layer this device has +/// nothing of its own to contribute yet, rather than silently syncing the +/// unset default. class AppSettingsService { AppSettingsService._(this._file); @@ -23,10 +27,15 @@ class AppSettingsService { final File _file; int _dailyKcalGoal = 2200; + DateTime? _dailyKcalGoalUpdatedAt; /// Returns the configured daily kcal goal, or 2200 when uninitialised. static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200; + /// Returns when the goal was last set on this device, or null if never. + static DateTime? get dailyKcalGoalUpdatedAt => + _instance?._dailyKcalGoalUpdatedAt; + /// Initialises the singleton, pointing at the app's documents directory. static Future init() async { if (_instance != null) return _instance!; @@ -73,15 +82,40 @@ class AppSettingsService { if (data is Map && data['daily_kcal_goal'] is int) { _dailyKcalGoal = data['daily_kcal_goal'] as int; } + if (data is Map && data['daily_kcal_goal_updated_at'] is String) { + _dailyKcalGoalUpdatedAt = DateTime.tryParse( + data['daily_kcal_goal_updated_at'] as String, + ); + } } on Exception { - // Ignore parse errors and keep default. + // Ignore parse errors and keep defaults. } } - /// Updates the in-memory value and persists [goal] to disk. - Future saveDailyKcalGoal(int goal) async { + /// Updates the in-memory value and persists [goal] to disk, stamping the + /// edit with the current time -- the timestamp a sync merge compares + /// against another device's edit to resolve last-writer-wins. + Future saveDailyKcalGoal(int goal) => _persist(goal, DateTime.now()); + + /// Applies a synced budget value without stamping a fresh edit time. + /// + /// Used only by the sync layer to write back a merge's winning value: it + /// persists [updatedAt] verbatim (the winning side's real edit time), not + /// "now", so re-syncing an unchanged value stays idempotent instead of + /// making this device's copy look newer than it actually is on every + /// tick. + Future applySyncedBudget(int goal, {DateTime? updatedAt}) => + _persist(goal, updatedAt); + + Future _persist(int goal, DateTime? updatedAt) async { _dailyKcalGoal = goal; + _dailyKcalGoalUpdatedAt = updatedAt; await _file.parent.create(recursive: true); - await _file.writeAsString(jsonEncode({'daily_kcal_goal': goal})); + await _file.writeAsString( + jsonEncode({ + 'daily_kcal_goal': goal, + 'daily_kcal_goal_updated_at': updatedAt?.toIso8601String(), + }), + ); } } diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart index 2bed973..e4fef2c 100644 --- a/app/lib/services/background_sync_service.dart +++ b/app/lib/services/background_sync_service.dart @@ -6,6 +6,7 @@ library; import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_service.dart'; @@ -26,9 +27,13 @@ const String syncPushTaskName = 'diet_guard.sync_push'; /// reason. The service singletons are (re)initialised here because a fresh /// background isolate has none; [LogStorageService.init] is idempotent, so /// this is a no-op when a test has already pointed it at a temp dir. +/// [AppSettingsService.init] must run too: [runSync] now also syncs the +/// budget, and applies a merge winner via `AppSettingsService.instance`, +/// which throws if the singleton was never initialised in this isolate. Future backgroundSyncPush({http.Client? httpClient}) async { await LogStorageService.init(); await FoodBankService.init(); + await AppSettingsService.init(); final SyncSettings settings; try { settings = await SyncSettings.load(); diff --git a/app/lib/services/day_status_service.dart b/app/lib/services/day_status_service.dart new file mode 100644 index 0000000..7cc83fd --- /dev/null +++ b/app/lib/services/day_status_service.dart @@ -0,0 +1,155 @@ +/// Per-day budget-adherence status, streaks, and year-to-date tallies. +/// +/// DAY STATUS SPEC (keep in sync with `diet_guard/_daystatus.py`): +/// +/// * green: `total_kcal <= budget` +/// * yellow: `budget < total_kcal <= budget * kOverBudgetYellowCeiling` +/// * red: `total_kcal > budget * kOverBudgetYellowCeiling` +/// * notLogged: the day is absent from the log entirely (no valid, +/// non-tombstoned entries). +/// * ordering, worst to best: notLogged > red > yellow > green. +/// * the logging streak breaks on a not-logged day. +/// * the adherence streak breaks on a red or a not-logged day; yellow and +/// green both keep it alive. +/// +/// Every function here is a pure function of an explicit `log`/`statusMap` +/// argument (never reaching into on-disk state itself), so the boundary +/// matrix above is trivially testable with synthetic data. +library; + +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; + +/// The multiplier defining the top of the "yellow" band above budget: a 20% +/// margin, mirroring `diet_guard._constants.BUDGET_WARN_FRACTION` (0.80)'s +/// 20% margin below 100% on the "approaching limit" side, so the two bands +/// are symmetric around the budget line. +const double kOverBudgetYellowCeiling = 1.20; + +const Set _loggedStatuses = { + DayStatus.green, + DayStatus.yellow, + DayStatus.red, +}; + +const Set _adherentStatuses = {DayStatus.green, DayStatus.yellow}; + +/// Returns the summed kcal across [entries]. +/// +/// Shared by [dayTotalKcal] and `history_screen.dart`'s day-header total, so +/// the "sum a day's entries" logic exists in exactly one place regardless of +/// whether the caller already has a day's entries grouped or is looking one +/// up by date key. +double sumKcal(Iterable entries) { + var total = 0.0; + for (final entry in entries) { + total += entry.kcal; + } + return total; +} + +/// Returns [log]'s entries for [day] with tombstoned (deleted) ones +/// removed -- `readLog()` returns the raw log including tombstones, so +/// every consumer that classifies a day (rather than just displaying an +/// already-filtered list) must drop them here first. +Iterable _validEntriesForDay(DayLog log, String day) => + (log[day] ?? const []).where((entry) => !entry.deleted); + +/// Returns the summed kcal for one `YYYY-MM-DD` key in [log], excluding +/// tombstoned entries. +double dayTotalKcal(DayLog log, String day) => + sumKcal(_validEntriesForDay(log, day)); + +/// Classifies one day's budget adherence. +/// +/// A day with no valid, non-tombstoned entries is [DayStatus.notLogged] -- +/// a day whose only entries were later deleted must not read as green at +/// zero kcal. +DayStatus dayStatus(DayLog log, String day, int budget) { + final entries = _validEntriesForDay(log, day).toList(); + if (entries.isEmpty) return DayStatus.notLogged; + final total = sumKcal(entries); + if (total <= budget) return DayStatus.green; + if (total <= budget * kOverBudgetYellowCeiling) return DayStatus.yellow; + return DayStatus.red; +} + +/// Returns a [DayStatus] for every date key present in [log]. +/// +/// Days with no log entries are simply absent from the result; a caller +/// rendering a full calendar treats a missing key as [DayStatus.notLogged]. +Map statusMap(DayLog log, {required int budget}) { + return {for (final day in log.keys) day: dayStatus(log, day, budget)}; +} + +DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + +String _dateKey(DateTime d) { + final y = d.year.toString().padLeft(4, '0'); + final m = d.month.toString().padLeft(2, '0'); + final day = d.day.toString().padLeft(2, '0'); + return '$y-$m-$day'; +} + +int _streak( + Map statusMap, + Set keeps, { + DateTime? today, +}) { + var day = _dateOnly(today ?? DateTime.now()); + if (statusMap[_dateKey(day)] == null) { + day = day.subtract(const Duration(days: 1)); + } + var count = 0; + while (keeps.contains(statusMap[_dateKey(day)])) { + count++; + day = day.subtract(const Duration(days: 1)); + } + return count; +} + +/// Returns the consecutive-day logging streak ending at [today]. +/// +/// A [today] absent from [statusMap] (not yet logged) is skipped rather +/// than treated as a break, so the streak does not appear broken every +/// morning before the user has eaten; counting resumes from yesterday in +/// that case. Any other day missing from [statusMap] ends the streak. +int loggingStreak(Map statusMap, {DateTime? today}) => + _streak(statusMap, _loggedStatuses, today: today); + +/// Returns the consecutive-day budget-adherence streak ending at [today]. +/// +/// Breaks on a red or not-logged day; green and yellow days both keep it +/// alive ("not too aggressively crossed"). +int adherenceStreak(Map statusMap, {DateTime? today}) => + _streak(statusMap, _adherentStatuses, today: today); + +int _dayOfYear(DateTime d) { + final start = DateTime.utc(d.year); + final day = DateTime.utc(d.year, d.month, d.day); + return day.difference(start).inDays + 1; +} + +/// Returns this year's logged/elapsed/adherent day counts. +/// +/// [today] is the reference date; defaults to the real current date. +/// [elapsedDays] is inclusive of [today]. +YtdTally yearToDateTally(Map statusMap, {DateTime? today}) { + final ref = _dateOnly(today ?? DateTime.now()); + final elapsedDays = _dayOfYear(ref); + var loggedDays = 0; + var adherentDays = 0; + for (final mapEntry in statusMap.entries) { + if (mapEntry.value == DayStatus.notLogged) continue; + final day = DateTime.parse(mapEntry.key); + if (day.year != ref.year || day.isAfter(ref)) continue; + loggedDays++; + if (_adherentStatuses.contains(mapEntry.value)) adherentDays++; + } + return YtdTally( + loggedDays: loggedDays, + elapsedDays: elapsedDays, + adherentDays: adherentDays, + ); +} diff --git a/app/lib/services/sync_merge.dart b/app/lib/services/sync_merge.dart index 9820059..7e13e73 100644 --- a/app/lib/services/sync_merge.dart +++ b/app/lib/services/sync_merge.dart @@ -20,6 +20,14 @@ /// tries the new Record-based wire format first and falls back to the old /// plain-DayLog format, converting old-format entries through the same /// adapter used for the local log. +/// +/// The budget adapters at the bottom of this file ([budgetToLog] etc.) +/// follow the same Record/Log shape, but a budget record is edited +/// repeatedly (not immutable-after-creation like a food-log entry), so its +/// [Hlc] is derived from a `t` edit timestamp +/// (`AppSettingsService.dailyKcalGoalUpdatedAt`) rather than a birth time +/// that never changes -- mirrors `diet_guard/_sync_merge.py`'s budget +/// adapters field-for-field. library; import 'dart:convert'; @@ -177,3 +185,86 @@ String encodeLogForPush(Log log) { }; return jsonEncode(encoded); } + +/// Stable id: exactly one budget record per device-pushed `budget.json`. +const budgetRecordId = 'budget'; + +/// Derives a deterministic [Hlc] for a raw budget record from its `t` field. +/// +/// Mirrors [entryHlc]'s determinism -- the same unedited record always +/// yields the same Hlc, so re-syncing an unchanged budget is a no-op -- but +/// reads `t` (bumped on every explicit edit) rather than a fixed birth +/// time, since a budget can be edited repeatedly and the *edit* time is +/// what last-writer-wins must compare. +Hlc budgetHlc(Map record) { + final wallTimeMs = + DateTime.tryParse( + record['t'] as String? ?? '', + )?.toUtc().millisecondsSinceEpoch ?? + 0; + return Hlc.newTick(_syncDeviceId, wallTimeMsOverride: wallTimeMs); +} + +/// Converts a raw local/remote budget record into a single-record [Log]. +/// +/// Returns an empty [Log] when [record] is null (this device has never +/// explicitly set a budget), so it contributes nothing to the merge rather +/// than clobbering another device's real value with the unset default. +Log budgetToLog(Map? record) { + if (record == null) return {}; + final hlc = budgetHlc(record); + final value = Map.from(record)..remove('t'); + return { + budgetRecordId: Record(id: budgetRecordId, fields: {'value': (value, hlc)}), + }; +} + +/// Converts a merged budget [Log] back into a raw budget record. +/// +/// Returns null when the log has no budget record at all (neither device +/// has ever set one yet) -- callers treat that as "nothing to apply +/// locally", not an error. +Map? logToBudget(Log log) { + final record = log[budgetRecordId]; + if (record == null) return null; + final field = record.fields['value']; + final value = field?.$1; + final result = value is Map + ? Map.from(value.cast()) + : {}; + final hlc = field?.$2; + if (hlc != null) { + result['t'] = DateTime.fromMillisecondsSinceEpoch( + hlc.wallTimeMs, + isUtc: true, + ).toLocal().toIso8601String(); + } + return result; +} + +/// Parses one device's pushed `budget.json` text into a crdt_sync [Log]. +/// +/// Throwing [FormatException] or [TypeError] is treated as unparsable by +/// the caller (`syncLog`'s `decode` callback), matching [parseRemoteLog]'s +/// tolerance for a corrupt/truncated push. There is no legacy plain-format +/// fallback here -- `budget.json` is a brand-new sync payload. +Log parseRemoteBudget(String text) { + final raw = jsonDecode(text); + if (raw is! Map) { + throw const FormatException( + 'top-level budget payload is not a JSON object', + ); + } + final rawMap = raw.cast(); + return rawMap.map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), + ); +} + +/// Serializes a merged budget [Log] for push. +String encodeBudgetForPush(Log log) { + final encoded = { + for (final entry in log.entries) entry.key: entry.value.toJson(), + }; + return jsonEncode(encoded); +} diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 20c09cc..7adff84 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -9,10 +9,16 @@ /// entry never carries `imagePath` (stripped before push, meaningless on /// another device), so it must not null out a local photo attachment for /// the same `id` (plan decision 10). +/// +/// The daily budget syncs alongside the food log in the same tick (see +/// [_syncBudget]): a sibling `budget.json` per device, merged +/// last-writer-wins by edit time rather than union-of-immutable-entries, +/// since a budget (unlike a food-log entry) can be edited repeatedly. library; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; @@ -51,9 +57,49 @@ Future runSync(GitHubClient client) async { await logService.writeLog(merged); await FoodBankService.instance.rebuildAndPersist(merged); + await _syncBudget(client); return merged; } +/// Pulls other devices' budgets, merges, applies the winner locally, pushes. +/// +/// Reuses [syncLog] (it always pushes the merged result, even an empty +/// one -- harmless and consistent with the food-log sync above). This +/// device contributes nothing to the merge until the goal has been +/// explicitly set at least once (see +/// [AppSettingsService.dailyKcalGoalUpdatedAt]), so a fresh install's +/// unset 2200 default can never spuriously outrank a real budget synced +/// from elsewhere. +Future _syncBudget(GitHubClient client) async { + final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt; + final localRecord = updatedAt == null + ? null + : { + 'v': 2, + 'b': AppSettingsService.dailyKcalGoal, + 't': updatedAt.toIso8601String(), + }; + + final mergedBudgetLog = await syncLog( + client: client, + deviceId: phoneDeviceId, + pathPrefix: _devicesDir, + localLog: budgetToLog(localRecord), + encode: encodeBudgetForPush, + decode: parseRemoteBudget, + filename: 'budget.json', + commitMessage: 'diet_guard_app sync', + ); + + final merged = logToBudget(mergedBudgetLog); + final mergedKcal = merged?['b']; + if (merged == null || mergedKcal is! int) return; + await AppSettingsService.instance.applySyncedBudget( + mergedKcal, + updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''), + ); +} + Map _imagePathsById(DayLog log) { final result = {}; for (final entries in log.values) { diff --git a/app/lib/widgets/day_status_calendar.dart b/app/lib/widgets/day_status_calendar.dart new file mode 100644 index 0000000..3181335 --- /dev/null +++ b/app/lib/widgets/day_status_calendar.dart @@ -0,0 +1,201 @@ +/// Monthly calendar colored by daily budget-adherence status. +library; + +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:flutter/material.dart'; + +/// Monthly calendar widget that colors each day by its [DayStatus]. +/// +/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py` / +/// `_calendar_view.py`): not-logged renders as a black cell with a visible +/// outline (so it doesn't vanish into the dark background); a day after +/// [today] -- nothing has happened yet to judge -- renders neutrally, +/// never as a false "not logged" black cell. +class DayStatusCalendar extends StatelessWidget { + /// Creates a [DayStatusCalendar]. + const DayStatusCalendar({ + required this.statusByDate, + required this.month, + required this.today, + required this.onPrevMonth, + required this.onNextMonth, + this.onDaySelected, + super.key, + }); + + /// Per-day status, keyed `YYYY-MM-DD`. A past-or-today date absent from + /// this map is treated as [DayStatus.notLogged]. + final Map statusByDate; + + /// Only the year and month of this DateTime are used. + final DateTime month; + + /// The reference "today"; days after this render neutrally. + final DateTime today; + + /// Called when the user taps the previous-month chevron. + final VoidCallback onPrevMonth; + + /// Called when the user taps the next-month chevron. + final VoidCallback onNextMonth; + + /// Called with a day's date when the user taps that day's cell. + /// + /// Null disables tapping (cells render but do not respond). A cell + /// outside the displayed month is never tappable regardless. + final void Function(DateTime day)? onDaySelected; + + static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + + static const _monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + String _dateKey(int year, int m, int day) => + '$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + + /// Returns the status to render for [year]/[m]/[day], or null for a + /// future day (nothing to judge yet). + DayStatus? _statusFor(int year, int m, int day) { + final date = DateTime(year, m, day); + final todayOnly = DateTime(today.year, today.month, today.day); + if (date.isAfter(todayOnly)) return null; + return statusByDate[_dateKey(year, m, day)] ?? DayStatus.notLogged; + } + + Color _cellColor(DayStatus? status) => switch (status) { + null => Colors.grey.shade800, + DayStatus.notLogged => Colors.black, + DayStatus.red => Colors.red.shade400, + DayStatus.yellow => Colors.amber.shade700, + DayStatus.green => Colors.green.shade700, + }; + + Color _textColor(DayStatus? status) => switch (status) { + null => Colors.white38, + DayStatus.notLogged => Colors.white, + DayStatus.red || DayStatus.yellow || DayStatus.green => Colors.black, + }; + + @override + Widget build(BuildContext context) { + final year = month.year; + final m = month.month; + final daysInMonth = DateTime(year, m + 1, 0).day; + // weekday: 1=Mon..7=Sun -> offset 0..6 + final firstWeekday = DateTime(year, m).weekday - 1; + final totalCells = firstWeekday + daysInMonth; + final rows = (totalCells / 7).ceil(); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, color: Colors.white70), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + onPressed: onPrevMonth, + ), + Text( + '${_monthNames[m - 1]} $year', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, color: Colors.white70), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + onPressed: onNextMonth, + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: _weekHeaders + .map( + (h) => SizedBox( + width: 30, + child: Text( + h, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 4), + ...List.generate(rows, (row) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(7, (col) { + final cell = row * 7 + col; + final day = cell - firstWeekday + 1; + if (day < 1 || day > daysInMonth) { + return const SizedBox(width: 30, height: 30); + } + final status = _statusFor(year, m, day); + final cellWidget = Container( + width: 30, + height: 30, + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _cellColor(status), + border: status == DayStatus.notLogged + ? Border.all(color: Colors.white70) + : null, + ), + alignment: Alignment.center, + child: Text( + '$day', + style: TextStyle( + color: _textColor(status), + fontSize: 12, + fontWeight: + status != null && status != DayStatus.notLogged + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ); + if (onDaySelected == null) return cellWidget; + return GestureDetector( + onTap: () => onDaySelected!(DateTime(year, m, day)), + child: cellWidget, + ); + }), + ); + }), + ], + ), + ); + } +} diff --git a/app/lib/widgets/streak_summary_row.dart b/app/lib/widgets/streak_summary_row.dart new file mode 100644 index 0000000..1ad6e22 --- /dev/null +++ b/app/lib/widgets/streak_summary_row.dart @@ -0,0 +1,53 @@ +/// Streak and year-to-date tally summary row for the History screen. +library; + +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:flutter/material.dart'; + +/// Displays the logging streak, adherence streak, and year-to-date tally. +/// +/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` formatting: +/// "Logging streak: N day(s) · Adherence streak: N day(s)" and +/// "This year: logged X/Y days · Z within budget". +class StreakSummaryRow extends StatelessWidget { + /// Creates a [StreakSummaryRow]. + const StreakSummaryRow({ + required this.loggingStreak, + required this.adherenceStreak, + required this.tally, + super.key, + }); + + /// Consecutive-day logging streak. + final int loggingStreak; + + /// Consecutive-day budget-adherence streak. + final int adherenceStreak; + + /// This year's logged/elapsed/adherent day counts. + final YtdTally tally; + + static String _plural(int count) => count == 1 ? 'day' : 'days'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Text( + 'Logging streak: $loggingStreak ${_plural(loggingStreak)} · ' + 'Adherence streak: $adherenceStreak ${_plural(adherenceStreak)}', + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + 'This year: logged ${tally.loggedDays}/${tally.elapsedDays} days · ' + '${tally.adherentDays} within budget', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + ], + ); + } +} diff --git a/app/test/models/day_status_test.dart b/app/test/models/day_status_test.dart new file mode 100644 index 0000000..b67d607 --- /dev/null +++ b/app/test/models/day_status_test.dart @@ -0,0 +1,39 @@ +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('YtdTally', () { + test('equal tallies compare equal and share hashCode', () { + const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3); + const b = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('differing tallies compare unequal', () { + const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3); + const b = YtdTally(loggedDays: 4, elapsedDays: 10, adherentDays: 3); + expect(a, isNot(equals(b))); + }); + + test('is not equal to an unrelated object', () { + const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3); + const Object other = 'not a tally'; + expect(a == other, isFalse); + }); + + test('toString is human-readable', () { + const a = YtdTally(loggedDays: 5, elapsedDays: 10, adherentDays: 3); + expect(a.toString(), 'YtdTally(logged: 5/10, adherent: 3)'); + }); + }); + + test('DayStatus has exactly the four expected values', () { + expect(DayStatus.values, [ + DayStatus.notLogged, + DayStatus.red, + DayStatus.yellow, + DayStatus.green, + ]); + }); +} diff --git a/app/test/screens/calendar_screen_test.dart b/app/test/screens/calendar_screen_test.dart new file mode 100644 index 0000000..e3afc07 --- /dev/null +++ b/app/test/screens/calendar_screen_test.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:diet_guard_app/screens/calendar_screen.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +String _label(DateTime d) => '${_monthNames[d.month - 1]} ${d.year}'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_'); + LogStorageService.resetForTesting(testDir: tempDir); + await AppSettingsService.initForTesting(tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + AppSettingsService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + Future pumpScreen(WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: CalendarScreen())); + await tester.pumpAndSettle(); + } + + /// `FilledButton.onPressed` only accepts a synchronous `VoidCallback`, so + /// `_onEditOrSaveBudget`'s real dart:io write is fired-and-forgotten by + /// `tester.tap()` -- `pumpAndSettle()` alone can return before it + /// resolves. Poll for the observable effect instead, mirroring + /// settings_screen_test.dart's `pumpUntil` for the same class of issue. + Future pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + testWidgets('shows the default 2200 kcal budget, read-only, with an Edit ' + 'button', (tester) async { + await pumpScreen(tester); + expect(find.widgetWithText(TextField, '2200'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + expect(field.enabled, isFalse); + }); + + testWidgets('tapping Edit unlocks the field and relabels the button Save', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.pump(); + + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + expect(field.enabled, isTrue); + }); + + testWidgets('entering an invalid value keeps editing open with an error', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.pump(); + await tester.enterText(find.byType(TextField), ''); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pump(); + + expect(find.text('Enter a whole number of kcal.'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + }); + + testWidgets('a zero value is rejected and editing stays open', ( + tester, + ) async { + await pumpScreen(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.pump(); + await tester.enterText(find.byType(TextField), '0'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pump(); + + expect(find.text('Budget must be a positive number.'), findsOneWidget); + }); + + testWidgets('saving a valid value persists it and reverts to read-only', ( + tester, + ) async { + // Real dart:io writes (AppSettingsService._persist, LogStorageService's + // subsequent re-read) complete on the real event loop, not a microtask + // pumpAndSettle alone drains -- runAsync interleaves real waits with + // frame pumps, mirroring settings_screen_test.dart's device-flow tests. + await tester.runAsync(() async { + await pumpScreen(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.pump(); + await tester.enterText(find.byType(TextField), '1800'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await pumpUntil(tester, () => find.text('Saved.').evaluate().isNotEmpty); + + expect(find.text('Saved.'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget); + expect(AppSettingsService.dailyKcalGoal, 1800); + final field = tester.widget(find.byType(TextField)); + expect(field.enabled, isFalse); + }); + }); + + testWidgets('shows streak and year-to-date summary text', (tester) async { + await pumpScreen(tester); + expect(find.textContaining('Logging streak:'), findsOneWidget); + expect(find.textContaining('This year:'), findsOneWidget); + }); + + testWidgets('month navigation steps the displayed month back and forward', ( + tester, + ) async { + await pumpScreen(tester); + final now = DateTime.now(); + expect(find.text(_label(now)), findsOneWidget); + + await tester.tap(find.byIcon(Icons.chevron_left)); + await tester.pump(); + expect( + find.text(_label(DateTime(now.year, now.month - 1))), + findsOneWidget, + ); + + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pump(); + expect(find.text(_label(now)), findsOneWidget); + }); + + testWidgets('tapping a day navigates to HistoryScreen filtered to that day', ( + tester, + ) async { + await pumpScreen(tester); + final today = DateTime.now(); + await tester.tap(find.text('${today.day}')); + await tester.pumpAndSettle(); + + expect(find.byType(HistoryScreen), findsOneWidget); + final screen = tester.widget(find.byType(HistoryScreen)); + final range = screen.initialDateRange!; + expect(range.start.year, today.year); + expect(range.start.month, today.month); + expect(range.start.day, today.day); + expect(range.end, range.start); + }); +} diff --git a/app/test/screens/history_screen_test.dart b/app/test/screens/history_screen_test.dart index 8f51425..81ecfd5 100644 --- a/app/test/screens/history_screen_test.dart +++ b/app/test/screens/history_screen_test.dart @@ -129,6 +129,58 @@ void main() { }); }); + testWidgets( + 'initialDateRange pre-filters to just that day, matching a Calendar ' + 'screen day tap', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [ + const FoodEntry( + id: 'other-day', + time: '2026-06-01T08:00:00+02:00', + desc: 'other day breakfast', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + '2026-06-22': [ + const FoodEntry( + id: 'target-day', + time: '2026-06-22T20:00:00+02:00', + desc: 'target day dinner', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget( + MaterialApp( + home: HistoryScreen( + initialDateRange: DateTimeRange( + start: DateTime(2026, 6, 22), + end: DateTime(2026, 6, 22), + ), + ), + ), + ); + await settle(tester); + + expect(find.text('target day dinner'), findsOneWidget); + expect(find.text('other day breakfast'), findsNothing); + }); + }, + ); + // --------------------------------------------------------------------------- // applyHistoryFilter — pure function tests (no widget required) // --------------------------------------------------------------------------- diff --git a/app/test/screens/log_meal_screen_test.dart b/app/test/screens/log_meal_screen_test.dart index 204b2cb..641ba8e 100644 --- a/app/test/screens/log_meal_screen_test.dart +++ b/app/test/screens/log_meal_screen_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/calendar_screen.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; @@ -146,6 +147,20 @@ void main() { }); }); + testWidgets('the calendar icon navigates to CalendarScreen', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.calendar_month)); + await settle(tester); + + expect(find.byType(CalendarScreen), findsOneWidget); + }); + }); + testWidgets('the settings icon navigates to SettingsScreen', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); diff --git a/app/test/services/app_settings_service_test.dart b/app/test/services/app_settings_service_test.dart index afd7325..b90d590 100644 --- a/app/test/services/app_settings_service_test.dart +++ b/app/test/services/app_settings_service_test.dart @@ -63,6 +63,52 @@ void main() { final data = jsonDecode(raw) as Map; expect(data['daily_kcal_goal'], 1800); }); + + test('stamps a fresh dailyKcalGoalUpdatedAt', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + final before = DateTime.now(); + await AppSettingsService.instance.saveDailyKcalGoal(1800); + final after = DateTime.now(); + + final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt; + expect(updatedAt, isNotNull); + expect(updatedAt!.isBefore(before), isFalse); + expect(updatedAt.isAfter(after), isFalse); + }); + }); + + group('applySyncedBudget', () { + test( + 'persists the given value and updatedAt verbatim, not "now"', + () async { + AppSettingsService.resetForTesting(testDir: tempDir); + final winningEdit = DateTime.utc(2020); + + await AppSettingsService.instance.applySyncedBudget( + 1700, + updatedAt: winningEdit, + ); + + expect(AppSettingsService.dailyKcalGoal, 1700); + expect(AppSettingsService.dailyKcalGoalUpdatedAt, winningEdit); + + final raw = await File( + '${tempDir.path}/app_settings.json', + ).readAsString(); + final data = jsonDecode(raw) as Map; + expect(data['daily_kcal_goal'], 1700); + expect( + data['daily_kcal_goal_updated_at'], + winningEdit.toIso8601String(), + ); + }, + ); + + test('a null updatedAt is accepted and persisted as null', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + await AppSettingsService.instance.applySyncedBudget(1700); + expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); + }); }); group('initForTesting (_load paths)', () { @@ -111,5 +157,50 @@ void main() { expect(AppSettingsService.dailyKcalGoal, 2200); }); + + test('loads daily_kcal_goal_updated_at from an existing file', () async { + await File('${tempDir.path}/app_settings.json').writeAsString( + jsonEncode({ + 'daily_kcal_goal': 1600, + 'daily_kcal_goal_updated_at': '2026-01-01T00:00:00.000Z', + }), + ); + + await AppSettingsService.initForTesting(tempDir); + + expect( + AppSettingsService.dailyKcalGoalUpdatedAt, + DateTime.parse('2026-01-01T00:00:00.000Z'), + ); + }); + + test( + 'dailyKcalGoalUpdatedAt stays null when file has no such key', + () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); + }, + ); + + test( + 'dailyKcalGoalUpdatedAt stays null when the field is not a string', + () async { + await File('${tempDir.path}/app_settings.json').writeAsString( + jsonEncode({ + 'daily_kcal_goal': 1600, + 'daily_kcal_goal_updated_at': 12345, + }), + ); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull); + }, + ); }); } diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart index ee3ec9e..2dd310a 100644 --- a/app/test/services/background_sync_service_test.dart +++ b/app/test/services/background_sync_service_test.dart @@ -5,6 +5,7 @@ import 'dart:io'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/background_sync_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -23,11 +24,16 @@ void main() { tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_sync_'); LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); + // Pre-seeds the singleton so backgroundSyncPush's own AppSettingsService + // .init() call short-circuits instead of hitting the real (unmocked in + // this test) path_provider channel. + AppSettingsService.resetForTesting(testDir: tempDir); }); tearDown(() async { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -68,7 +74,9 @@ void main() { final ok = await backgroundSyncPush(httpClient: mock); expect(ok, isTrue); - expect(puts, 1); + // One food_log.json push, plus one budget.json push (syncLog always + // pushes, even the empty merged budget when nothing's been set yet). + expect(puts, 2); }); test('reports failure (retry) when the push errors', () async { diff --git a/app/test/services/day_status_service_test.dart b/app/test/services/day_status_service_test.dart new file mode 100644 index 0000000..98923be --- /dev/null +++ b/app/test/services/day_status_service_test.dart @@ -0,0 +1,221 @@ +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/day_status_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +FoodEntry buildEntry({required double kcal, bool deleted = false}) => FoodEntry( + time: '2026-01-01T12:00:00', + desc: 'test', + grams: 100, + kcal: kcal, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + deleted: deleted, +); + +void main() { + group('dayTotalKcal', () { + test('sums entries for the day', () { + final log = { + '2026-01-01': [buildEntry(kcal: 100), buildEntry(kcal: 50)], + }; + expect(dayTotalKcal(log, '2026-01-01'), 150); + }); + + test('missing day is zero', () { + expect(dayTotalKcal(>{}, '2026-01-01'), 0); + }); + + test('excludes tombstoned entries', () { + final log = { + '2026-01-01': [ + buildEntry(kcal: 100), + buildEntry(kcal: 50, deleted: true), + ], + }; + expect(dayTotalKcal(log, '2026-01-01'), 100); + }); + }); + + group('dayStatus', () { + test('missing day is notLogged', () { + final log = >{}; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.notLogged); + }); + + test('a day with only tombstoned entries is notLogged, not green', () { + final log = { + '2026-01-01': [buildEntry(kcal: 100, deleted: true)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.notLogged); + }); + + test('exactly at budget is green', () { + final log = { + '2026-01-01': [buildEntry(kcal: 2000)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.green); + }); + + test('under budget is green', () { + final log = { + '2026-01-01': [buildEntry(kcal: 1000)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.green); + }); + + test('just over budget is yellow', () { + final log = { + '2026-01-01': [buildEntry(kcal: 2001)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.yellow); + }); + + test('exactly at yellow ceiling is yellow', () { + final log = { + '2026-01-01': [buildEntry(kcal: 2400)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.yellow); + }); + + test('just over yellow ceiling is red', () { + final log = { + '2026-01-01': [buildEntry(kcal: 2400.01)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.red); + }); + + test('way over budget is red', () { + final log = { + '2026-01-01': [buildEntry(kcal: 5000)], + }; + expect(dayStatus(log, '2026-01-01', 2000), DayStatus.red); + }); + }); + + group('statusMap', () { + test('maps every present day', () { + final log = { + '2026-01-01': [buildEntry(kcal: 1000)], + '2026-01-02': [buildEntry(kcal: 5000)], + }; + expect(statusMap(log, budget: 2000), { + '2026-01-01': DayStatus.green, + '2026-01-02': DayStatus.red, + }); + }); + + test('empty log is empty map', () { + final log = >{}; + expect(statusMap(log, budget: 2000), {}); + }); + }); + + group('loggingStreak', () { + test('empty map is zero', () { + expect(loggingStreak({}, today: DateTime(2026, 1, 5)), 0); + }); + + test('counts consecutive logged days including today', () { + final sm = { + '2026-01-03': DayStatus.green, + '2026-01-04': DayStatus.red, + '2026-01-05': DayStatus.yellow, + }; + expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 3); + }); + + test('breaks on a gap', () { + final sm = { + '2026-01-01': DayStatus.green, + '2026-01-03': DayStatus.green, + '2026-01-04': DayStatus.green, + }; + expect(loggingStreak(sm, today: DateTime(2026, 1, 4)), 2); + }); + + test('today not logged is not a break', () { + final sm = { + '2026-01-03': DayStatus.green, + '2026-01-04': DayStatus.yellow, + }; + expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 2); + }); + + test('yesterday not logged breaks streak even if today is', () { + final sm = { + '2026-01-03': DayStatus.green, + '2026-01-05': DayStatus.green, + }; + expect(loggingStreak(sm, today: DateTime(2026, 1, 5)), 1); + }); + }); + + group('adherenceStreak', () { + test('counts consecutive green and yellow', () { + final sm = { + '2026-01-03': DayStatus.green, + '2026-01-04': DayStatus.yellow, + '2026-01-05': DayStatus.green, + }; + expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 3); + }); + + test('red today breaks the streak immediately', () { + final sm = { + '2026-01-04': DayStatus.green, + '2026-01-05': DayStatus.red, + }; + expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 0); + }); + + test('not-logged today is not a break', () { + final sm = {'2026-01-04': DayStatus.green}; + expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1); + }); + + test('red in history breaks the streak', () { + final sm = { + '2026-01-03': DayStatus.green, + '2026-01-04': DayStatus.red, + '2026-01-05': DayStatus.green, + }; + expect(adherenceStreak(sm, today: DateTime(2026, 1, 5)), 1); + }); + }); + + group('yearToDateTally', () { + test('counts logged and adherent days this year only', () { + final sm = { + '2026-01-01': DayStatus.green, + '2026-01-02': DayStatus.red, + '2026-01-03': DayStatus.notLogged, + '2025-12-31': DayStatus.green, + }; + final tally = yearToDateTally(sm, today: DateTime(2026, 1, 3)); + expect(tally.loggedDays, 2); + expect(tally.elapsedDays, 3); + expect(tally.adherentDays, 1); + }); + + test('future day in map is excluded', () { + final sm = { + '2026-01-01': DayStatus.green, + '2026-06-01': DayStatus.green, + }; + final tally = yearToDateTally(sm, today: DateTime(2026)); + expect(tally.loggedDays, 1); + expect(tally.elapsedDays, 1); + expect(tally.adherentDays, 1); + }); + + test('empty map', () { + final tally = yearToDateTally({}, today: DateTime(2026, 3)); + expect(tally.loggedDays, 0); + expect(tally.elapsedDays, 60); + expect(tally.adherentDays, 0); + }); + }); +} diff --git a/app/test/services/sync_merge_test.dart b/app/test/services/sync_merge_test.dart index f75691b..935af07 100644 --- a/app/test/services/sync_merge_test.dart +++ b/app/test/services/sync_merge_test.dart @@ -311,4 +311,76 @@ void main() { expect(() => parseRemoteLog('not json{{{'), throwsFormatException); }); }); + + group('budget adapters', () { + Map budgetRecord({ + int b = 2000, + String t = '2026-06-22T08:00:00', + double? w, + }) => {'v': 2, 'b': b, 't': t, if (w != null) 'w': w}; + + group('budgetHlc', () { + test('same record always yields the same Hlc', () { + final record = budgetRecord(); + expect(budgetHlc(record), budgetHlc({...record})); + }); + + test('malformed t still yields a valid Hlc', () { + final record = budgetRecord(t: 'not-a-timestamp'); + expect(budgetHlc(record).wallTimeMs, 0); + }); + + test('a later t yields a greater Hlc', () { + final earlier = budgetRecord(t: '2020-01-01T00:00:00Z'); + final later = budgetRecord(t: '2030-01-01T00:00:00Z'); + expect(budgetHlc(later) > budgetHlc(earlier), isTrue); + }); + }); + + group('budgetToLog / logToBudget round trip', () { + test('null record yields an empty log', () { + expect(budgetToLog(null), isEmpty); + }); + + test('round trip preserves the budget and weight', () { + final record = budgetRecord(w: 80); + final roundTripped = logToBudget(budgetToLog(record)); + expect(roundTripped, isNotNull); + expect(roundTripped!['b'], 2000); + expect(roundTripped['w'], 80); + }); + + test('round-tripped t reflects the winning Hlc', () { + final record = budgetRecord(); + final roundTripped = logToBudget(budgetToLog(record)); + expect(roundTripped, isNotNull); + expect(roundTripped!['t'], isNotEmpty); + }); + + test('empty log has no budget', () { + expect(logToBudget({}), isNull); + }); + }); + + group('parseRemoteBudget', () { + test('parses pushed budget wire content', () { + final record = budgetRecord(); + final pushed = encodeBudgetForPush(budgetToLog(record)); + final log = parseRemoteBudget(pushed); + expect(log[budgetRecordId]!.id, budgetRecordId); + }); + + test('empty object parses as empty log', () { + expect(parseRemoteBudget('{}'), isEmpty); + }); + + test('non-object top level throws FormatException', () { + expect(() => parseRemoteBudget('[1, 2, 3]'), throwsFormatException); + }); + + test('invalid JSON throws FormatException', () { + expect(() => parseRemoteBudget('not json{{{'), throwsFormatException); + }); + }); + }); } diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 15cef88..6e856b5 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -1,7 +1,8 @@ -// Mirrors `test_sync.py`'s `TestRunSync` cases (own-id-skip, no-prior-push, -// non-object payload, corrupt JSON, remote merge, food bank rebuild), plus -// one Dart-specific case for the phone's `imagePath`-preserve-by-id step -// (plan decision 10) that has no PC-side equivalent. +// Mirrors `test_sync.py`'s `TestRunSync` and `TestSyncBudget` cases +// (own-id-skip, no-prior-push, non-object payload, corrupt JSON, remote +// merge, food bank rebuild, budget last-writer-wins), plus one Dart-specific +// case for the phone's `imagePath`-preserve-by-id step (plan decision 10) +// that has no PC-side equivalent. import 'dart:convert'; import 'dart:io'; @@ -9,13 +10,24 @@ import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/services/sync_merge.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +/// Builds the wire text a remote device would push for a given budget edit. +String _remoteBudgetJson({required int kcal, required String t}) { + final record = {'v': 2, 'b': kcal, 't': t}; + final log = budgetToLog(record); + return jsonEncode({ + for (final entry in log.entries) entry.key: entry.value.toJson(), + }); +} + const _manual = Nutrition( kcal: 200, proteinG: 10, @@ -44,6 +56,11 @@ class _FakeGitHub { /// Every PUT this fake received, decoded. final List> puts = []; + /// Same PUTs, keyed by the repo-relative path they targeted -- lets a + /// test pick out the food-log push from the budget push, now that a + /// sync tick does both. + final Map> putsByPath = {}; + GitHubClient buildClient() => GitHubClient( owner: 'o', repo: 'r', @@ -60,7 +77,9 @@ class _FakeGitHub { } final path = req.url.path.replaceFirst('/repos/o/r/contents/', ''); if (req.method == 'PUT') { - puts.add(jsonDecode(req.body) as Map); + final body = jsonDecode(req.body) as Map; + puts.add(body); + putsByPath[path] = body; return http.Response('{}', 200); } if (path == 'diet-guard-sync/devices') { @@ -104,11 +123,13 @@ void main() { tempDir = await Directory.systemTemp.createTemp('diet_guard_sync_test_'); LogStorageService.resetForTesting(testDir: tempDir); FoodBankService.resetForTesting(testDir: tempDir); + await AppSettingsService.initForTesting(tempDir); }); tearDown(() async { LogStorageService.resetForTesting(); FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -118,16 +139,26 @@ void main() { final merged = await runSync(fake.buildClient()); expect(merged.values.expand((e) => e).length, 1); - expect(fake.puts, hasLength(1)); + // One food_log.json push, plus one budget.json push (syncLog always + // pushes, even an empty merged budget when nothing's been set yet). + expect(fake.puts, hasLength(2)); }); test("skips its own device id ('phone') when listing", () async { final fake = _FakeGitHub( deviceDirs: const ['pc', 'phone'], - files: const {'diet-guard-sync/devices/pc/food_log.json': '{}'}, + files: const { + 'diet-guard-sync/devices/pc/food_log.json': '{}', + 'diet-guard-sync/devices/pc/budget.json': '{}', + }, ); await runSync(fake.buildClient()); - expect(fake.fileGets, ['diet-guard-sync/devices/pc/food_log.json']); + // Both the food-log and budget pulls skip "phone" (this device) and + // only ever read "pc"'s files. + expect(fake.fileGets, [ + 'diet-guard-sync/devices/pc/food_log.json', + 'diet-guard-sync/devices/pc/budget.json', + ]); }); test('skips a device with no pushed file yet', () async { @@ -237,7 +268,8 @@ void main() { final fake = _FakeGitHub(); await runSync(fake.buildClient()); - final pushed = fake.puts.single; + final pushed = + fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!; final pushedText = utf8.decode(base64.decode(pushed['content'] as String)); expect(pushedText, isNot(contains('imagePath'))); expect(pushedText, isNot(contains('hmac'))); @@ -248,7 +280,8 @@ void main() { final fake = _FakeGitHub(); await runSync(fake.buildClient()); - final pushed = fake.puts.single; + final pushed = + fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!; final pushedText = utf8.decode(base64.decode(pushed['content'] as String)); final decoded = jsonDecode(pushedText) as Map; final record = decoded.values.single as Map; @@ -261,7 +294,10 @@ void main() { files: const {'diet-guard-sync/devices/phone/food_log.json': '{}'}, ); await runSync(fake.buildClient()); - expect(fake.puts.single['sha'], 'f-phone'); + expect( + fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!['sha'], + 'f-phone', + ); }); test( @@ -310,4 +346,91 @@ void main() { expect(entry.imagePath, '/local/photo.jpg'); }, ); + + group('budget sync', () { + test( + 'pushes the local budget when no other devices have synced', + () async { + await AppSettingsService.instance.saveDailyKcalGoal(2000); + final fake = _FakeGitHub(); + await runSync(fake.buildClient()); + + expect( + fake.putsByPath.containsKey( + 'diet-guard-sync/devices/phone/budget.json', + ), + isTrue, + ); + }, + ); + + test('remote-only budget is adopted locally', () async { + final remoteJson = _remoteBudgetJson( + kcal: 1800, + t: '2026-01-01T09:00:00', + ); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1800); + }); + + test('a local edit later than a remote edit wins', () async { + await AppSettingsService.instance.saveDailyKcalGoal(1500); // now + final remoteJson = _remoteBudgetJson( + kcal: 1800, + t: '2020-01-01T09:00:00', + ); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1500); + }); + + test('a remote edit later than a local edit wins', () async { + await AppSettingsService.instance.saveDailyKcalGoal(1500); // now + final remoteJson = _remoteBudgetJson( + kcal: 1800, + t: '2999-01-01T09:00:00', + ); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: {'diet-guard-sync/devices/pc/budget.json': remoteJson}, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 1800); + }); + + test('a malformed remote budget is skipped, not a crash', () async { + await AppSettingsService.instance.saveDailyKcalGoal(2000); + final fake = _FakeGitHub( + deviceDirs: const ['pc'], + files: const { + 'diet-guard-sync/devices/pc/budget.json': '{not valid json', + }, + ); + await runSync(fake.buildClient()); + expect(AppSettingsService.dailyKcalGoal, 2000); + }); + + test( + 'a fresh install with no budget ever set contributes nothing', + () async { + final fake = _FakeGitHub(); + await runSync(fake.buildClient()); + + final pushed = + fake.putsByPath['diet-guard-sync/devices/phone/budget.json']!; + final pushedText = utf8.decode( + base64.decode(pushed['content'] as String), + ); + expect(jsonDecode(pushedText), isEmpty); + expect(AppSettingsService.dailyKcalGoal, 2200); + }, + ); + }); } diff --git a/app/test/widgets/day_status_calendar_test.dart b/app/test/widgets/day_status_calendar_test.dart new file mode 100644 index 0000000..3022a2c --- /dev/null +++ b/app/test/widgets/day_status_calendar_test.dart @@ -0,0 +1,242 @@ +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/widgets/day_status_calendar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +Color _colorOfDay(WidgetTester tester, String day) { + final container = tester.widget( + find.ancestor(of: find.text(day), matching: find.byType(Container)).first, + ); + final decoration = container.decoration! as BoxDecoration; + return decoration.color!; +} + +void main() { + group('DayStatusCalendar', () { + final june2026 = DateTime(2026, 6); + final juneToday = DateTime(2026, 6, 20); + + testWidgets('shows month and year in header', (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('June 2026'), findsOneWidget); + }); + + testWidgets('shows day-of-week headers', (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('Mo'), findsOneWidget); + expect(find.text('Su'), findsOneWidget); + }); + + testWidgets('calls onPrevMonth when left arrow tapped', (tester) async { + var called = false; + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () => called = true, + onNextMonth: () {}, + ), + ), + ); + await tester.tap(find.byIcon(Icons.chevron_left)); + expect(called, isTrue); + }); + + testWidgets('calls onNextMonth when right arrow tapped', (tester) async { + var called = false; + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () => called = true, + ), + ), + ); + await tester.tap(find.byIcon(Icons.chevron_right)); + expect(called, isTrue); + }); + + testWidgets('colors a green day', (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {'2026-06-15': DayStatus.green}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(_colorOfDay(tester, '15'), Colors.green.shade700); + }); + + testWidgets('colors a yellow day', (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {'2026-06-15': DayStatus.yellow}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(_colorOfDay(tester, '15'), Colors.amber.shade700); + }); + + testWidgets('colors a red day', (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {'2026-06-15': DayStatus.red}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(_colorOfDay(tester, '15'), Colors.red.shade400); + }); + + testWidgets( + 'a past day absent from statusByDate renders black (not logged)', + (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(_colorOfDay(tester, '15'), Colors.black); + }, + ); + + testWidgets( + 'a future day renders neutrally, never as a false not-logged cell', + (tester) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: DateTime(2026, 6, 10), + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + // Day 25 is after the reference "today" of June 10. + expect(_colorOfDay(tester, '25'), Colors.grey.shade800); + }, + ); + + testWidgets("today itself is classified, not treated as future", ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {'2026-06-20': DayStatus.red}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(_colorOfDay(tester, '20'), Colors.red.shade400); + }); + + testWidgets('renders a month starting on Sunday correctly', ( + tester, + ) async { + final sep2026 = DateTime(2026, 9); + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: sep2026, + today: DateTime(2026, 9, 1), + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + expect(find.text('September 2026'), findsOneWidget); + }); + + testWidgets('tapping a day calls onDaySelected with that date', ( + tester, + ) async { + DateTime? selected; + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {'2026-06-15': DayStatus.green}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + onDaySelected: (day) => selected = day, + ), + ), + ); + await tester.tap(find.text('15')); + expect(selected, DateTime(2026, 6, 15)); + }); + + testWidgets('no onDaySelected means day cells are not tappable', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + DayStatusCalendar( + statusByDate: const {}, + month: june2026, + today: juneToday, + onPrevMonth: () {}, + onNextMonth: () {}, + ), + ), + ); + // No GestureDetector wraps the day cells when onDaySelected is null; + // tapping must not throw. + await tester.tap(find.text('15')); + }); + }); +} diff --git a/app/test/widgets/streak_summary_row_test.dart b/app/test/widgets/streak_summary_row_test.dart new file mode 100644 index 0000000..bef76ba --- /dev/null +++ b/app/test/widgets/streak_summary_row_test.dart @@ -0,0 +1,64 @@ +import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/widgets/streak_summary_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('StreakSummaryRow', () { + testWidgets('renders plural streak counts', (tester) async { + await tester.pumpWidget( + _wrap( + const StreakSummaryRow( + loggingStreak: 3, + adherenceStreak: 2, + tally: YtdTally(loggedDays: 10, elapsedDays: 14, adherentDays: 8), + ), + ), + ); + expect( + find.text( + 'Logging streak: 3 days · Adherence streak: 2 days', + ), + findsOneWidget, + ); + expect( + find.text('This year: logged 10/14 days · 8 within budget'), + findsOneWidget, + ); + }); + + testWidgets('renders singular streak counts', (tester) async { + await tester.pumpWidget( + _wrap( + const StreakSummaryRow( + loggingStreak: 1, + adherenceStreak: 1, + tally: YtdTally(loggedDays: 1, elapsedDays: 1, adherentDays: 1), + ), + ), + ); + expect( + find.text('Logging streak: 1 day · Adherence streak: 1 day'), + findsOneWidget, + ); + }); + + testWidgets('renders zero streaks', (tester) async { + await tester.pumpWidget( + _wrap( + const StreakSummaryRow( + loggingStreak: 0, + adherenceStreak: 0, + tally: YtdTally(loggedDays: 0, elapsedDays: 5, adherentDays: 0), + ), + ), + ); + expect( + find.text('Logging streak: 0 days · Adherence streak: 0 days'), + findsOneWidget, + ); + }); + }); +} diff --git a/diet_guard/_budget.py b/diet_guard/_budget.py index 1e4d1e9..5471aaa 100644 --- a/diet_guard/_budget.py +++ b/diet_guard/_budget.py @@ -1,85 +1,74 @@ -"""Hidden, tamper-hardened daily calorie budget for diet_guard. +"""Freely-editable daily calorie budget for diet_guard. -This mirrors how ``phone_focus_mode`` hides the home GPS coordinates: the real -number never lives in committed source and is never printed. It is computed -once from biometrics at ``init`` time, written to a runtime-only file in the -XDG data dir (git-ignored, exactly like the phone's ``config_secrets.sh``), and -locked with ``chattr +i`` so changing it costs a deliberate ``sudo`` step. +The budget is computed once from biometrics at ``init`` time (via the same +Mifflin-St Jeor formula as before) and written to a plain JSON file in the +XDG data dir, but it is no longer sealed: it can be changed at any time, on +this machine or the phone app, with no special ritual. It syncs like the +food log (see :mod:`diet_guard._sync`), so the same current value is +available on both devices, last-edit-wins. -Honest threat model (the same one the phone accepts): - -* The point is **friction in a weak moment**, not cryptographic secrecy. The - Mifflin-St Jeor formula is right here in source and you know your own weight, - so a determined you can always recompute the number -- just as a determined - you can root the phone and read the coordinates. -* The shared HMAC key at ``/etc/workout-locker/hmac.key`` is world-readable, so - the signature defeats a *naive* edit (and detects disk corruption) but not - someone who reads the key and re-signs. The signature is tamper-*evidence*, - not a tamper-*lock*. -* The real lock is ``chattr +i``: removing the immutable bit needs root, which - is the actual speed bump. The strongest layer of all is simply that the - value is never displayed, so there is no on-screen anchor to fixate on. +This is a deliberate design change from the file's previous ``chattr +i`` +seal, which existed specifically to make impulsive "make room" edits +require a deliberate root step. That friction is gone by design; nothing in +this module tries to reintroduce it. """ from __future__ import annotations -import base64 -import binascii from dataclasses import dataclass -import hmac +from datetime import datetime, timezone import json import logging -from gatelock.log_integrity import compute_entry_hmac - from diet_guard._constants import BUDGET_FILE _logger = logging.getLogger(__name__) -# Schema version stored inside the sealed blob, so a future format change can be -# detected rather than silently misread. v2 adds the optional body weight (``w``) -# used to derive a protein target; v1 seals (budget only) still read correctly. -_SEAL_VERSION = 2 +# Schema version stored in the file, so a future format change can be +# detected rather than silently misread. v2 adds the optional body weight +# (``w``) used to derive a protein target; v1 files (budget only) still +# read correctly. +_FILE_VERSION = 2 -# A medically sane lower bound. Even an aggressive deficit must not seal a -# starvation-level target, so the computed value is floored here. + +def _now_local() -> datetime: + """Return the current time as a timezone-aware local datetime. + + Duplicated from :func:`diet_guard._state.now_local` rather than + imported: ``_state`` imports :func:`daily_budget` from this module, so + importing back from ``_state`` here would be circular. + """ + return datetime.now(tz=timezone.utc).astimezone() + + +# A medically sane lower bound. Even an aggressive deficit must not compute +# a starvation-level target, so the value is floored here. _MIN_SANE_BUDGET = 1200 -# Daily protein target for an active adult holding muscle on a deficit, in grams -# per kg of body weight. Used only to show a target in the dashboard; it has no -# part in the sealed calorie budget maths. +# Daily protein target for an active adult holding muscle on a deficit, in +# grams per kg of body weight. Used only to show a target in the dashboard; +# it has no part in the calorie budget maths. PROTEIN_G_PER_KG = 1.8 -# Probe payload used only to check whether the shared HMAC key can be loaded. -_KEY_PROBE: dict[str, object] = {"_probe": True} - class BudgetError(Exception): """Base class for all budget-access failures.""" class BudgetNotInitializedError(BudgetError): - """Raised when no sealed budget exists yet (``init`` never run).""" + """Raised when no budget has been set yet (``init`` never run).""" def __init__(self) -> None: """Initialize with a fixed, side-effect-free message.""" super().__init__("daily budget has not been initialized") -class BudgetSealBrokenError(BudgetError): - """Raised when the sealed budget is unreadable, corrupt, or tampered.""" +class BudgetFileCorruptError(BudgetError): + """Raised when the budget file exists but cannot be read or parsed.""" def __init__(self) -> None: """Initialize with a fixed, side-effect-free message.""" - super().__init__("daily budget seal is broken (tampered or corrupted)") - - -class BudgetLockedError(BudgetError): - """Raised when the budget file is immutable and cannot be rewritten.""" - - def __init__(self) -> None: - """Initialize with a fixed, side-effect-free message.""" - super().__init__("daily budget file is locked (chattr +i)") + super().__init__("daily budget file is corrupt") @dataclass(frozen=True) @@ -141,112 +130,45 @@ def compute_target_budget( return max(target, _MIN_SANE_BUDGET) -def _hmac_key_available() -> bool: - """Return True if the shared HMAC key can be loaded for signing.""" - return compute_entry_hmac(_KEY_PROBE) is not None - - def is_initialized() -> bool: - """Return True if a sealed budget file exists on disk.""" + """Return True if a budget file exists on disk.""" return BUDGET_FILE.exists() -def lock_command() -> str: - """Return the shell command that makes the sealed budget immutable.""" - return f"sudo chattr +i {BUDGET_FILE}" +def write_budget(value: int, *, weight_kg: float | None = None) -> None: + """Write ``value`` as the daily kcal budget, plainly (no seal, no signing). - -def unlock_command() -> str: - """Return the shell command that clears the immutable bit before re-init.""" - return f"sudo chattr -i {BUDGET_FILE}" - - -def seal_budget(value: int, *, weight_kg: float | None = None) -> None: - """Write ``value`` to the runtime budget file, base64-wrapped and signed. - - The value is JSON-encoded, base64-wrapped (so a casual ``cat`` shows no - recognizable number) and HMAC-signed (so a naive edit is detectable). The - file is *not* made immutable here -- that needs root; the caller prints - :func:`lock_command` for the user (or install.sh) to run. + Stamps a ``t`` edit timestamp on every write (unlike a food-log entry, + the budget can be edited repeatedly, so :mod:`diet_guard._sync` needs to + know *when* this write happened to resolve a last-edit-wins merge + against another device's write). Args: - value: The computed daily budget in kcal. - weight_kg: Body weight in kg to store alongside the budget, so a protein - target can later be derived. Optional; omitting it seals a - budget-only blob that reads back with no protein target. - - Raises: - BudgetLockedError: If the existing file is immutable (run - :func:`unlock_command` first). + value: The daily budget in kcal. + weight_kg: Body weight in kg to store alongside the budget, so a + protein target can later be derived. Optional; omitting it + writes a budget-only record that reads back with no protein + target. """ - inner: dict[str, object] = {"v": _SEAL_VERSION, "b": int(value)} + record: dict[str, object] = { + "v": _FILE_VERSION, + "b": int(value), + "t": _now_local().isoformat(timespec="seconds"), + } if weight_kg is not None: - inner["w"] = round(float(weight_kg), 1) - blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode() - record: dict[str, object] = {"data": base64.b64encode(blob).decode("ascii")} - signature = compute_entry_hmac(inner) - if signature is not None: - record["hmac"] = signature - else: - _logger.warning("HMAC key unavailable - sealing budget unsigned") - - BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True) - try: - with BUDGET_FILE.open("w") as handle: - json.dump(record, handle) - except PermissionError as exc: - raise BudgetLockedError from exc + record["w"] = round(float(weight_kg), 1) + write_raw_record(record) -def _decode_inner(record: object) -> dict[str, object]: - """Return the inner payload dict from a parsed seal record. - - Raises: - BudgetSealBrokenError: If the record shape or base64 is invalid. - """ - if not isinstance(record, dict): - raise BudgetSealBrokenError - data = record.get("data") - if not isinstance(data, str): - raise BudgetSealBrokenError - try: - inner = json.loads(base64.b64decode(data, validate=True)) - except (binascii.Error, ValueError) as exc: - raise BudgetSealBrokenError from exc - if not isinstance(inner, dict): - raise BudgetSealBrokenError - return inner - - -def _verify_signature(record: dict[str, object], inner: dict[str, object]) -> None: - """Check the seal's HMAC, mirroring the food log's degradation rules. - - A present signature must verify. A missing signature is tolerated only on a - system with no key at all; a stripped signature where a key *is* available - means someone removed it to cheat. - - Raises: - BudgetSealBrokenError: If the signature is missing-but-keyed, or wrong. - """ - stored = record.get("hmac") - if isinstance(stored, str): - expected = compute_entry_hmac(inner) - if expected is None or not hmac.compare_digest(stored, expected): - raise BudgetSealBrokenError - return - if _hmac_key_available(): - raise BudgetSealBrokenError - - -def _load_verified_inner() -> dict[str, object]: - """Read, decode, and integrity-check the sealed blob, returning its payload. +def _read_record() -> dict[str, object]: + """Read and parse the budget file. Returns: - The inner payload dict (carrying ``b`` and, for v2 seals, ``w``). + The parsed record dict (carrying ``b`` and, optionally, ``w``). Raises: - BudgetNotInitializedError: If no budget has been sealed yet. - BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered. + BudgetNotInitializedError: If no budget has been set yet. + BudgetFileCorruptError: If the file exists but cannot be parsed. """ if not BUDGET_FILE.exists(): raise BudgetNotInitializedError @@ -254,30 +176,26 @@ def _load_verified_inner() -> dict[str, object]: with BUDGET_FILE.open() as handle: record = json.load(handle) except (OSError, json.JSONDecodeError) as exc: - raise BudgetSealBrokenError from exc - inner = _decode_inner(record) - # ``record`` is a dict here: _decode_inner rejects any non-dict already. - _verify_signature(record, inner) - return inner + raise BudgetFileCorruptError from exc + if not isinstance(record, dict): + raise BudgetFileCorruptError + return record def daily_budget() -> int: - """Return the sealed daily budget, verifying integrity first. - - This is the only way callers obtain the number, and they must use it for an - internal decision (over/under) without printing it. + """Return the current daily kcal budget. Returns: The daily kcal budget. Raises: - BudgetNotInitializedError: If no budget has been sealed yet. - BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered. + BudgetNotInitializedError: If no budget has been set yet. + BudgetFileCorruptError: If the file exists but cannot be parsed. """ - inner = _load_verified_inner() - value = inner.get("b") + record = _read_record() + value = record.get("b") if isinstance(value, bool) or not isinstance(value, int): - raise BudgetSealBrokenError + raise BudgetFileCorruptError return value @@ -285,14 +203,14 @@ def budget_weight() -> float | None: """Return the body weight stored with the budget, or None if unavailable. Returns: - The stored weight in kg, or None for a pre-v2 (budget-only) seal. + The stored weight in kg, or None for a pre-v2 (budget-only) record. Raises: - BudgetNotInitializedError: If no budget has been sealed yet. - BudgetSealBrokenError: If the file is corrupt, mis-typed, or tampered. + BudgetNotInitializedError: If no budget has been set yet. + BudgetFileCorruptError: If the file exists but cannot be parsed. """ - inner = _load_verified_inner() - value = inner.get("w") + record = _read_record() + value = record.get("w") if isinstance(value, bool) or not isinstance(value, (int, float)): return None return float(value) @@ -302,9 +220,9 @@ def protein_target_g() -> float | None: """Return the daily protein target in grams, or None if it cannot be derived. Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns - None -- rather than raising -- whenever the target is simply unavailable (no - budget sealed, a pre-v2 seal without weight, or a broken seal), so the - dashboard can show calories and quietly omit the protein line. + None -- rather than raising -- whenever the target is simply unavailable + (no budget set, a pre-v2 record without weight, or a corrupt file), so + the dashboard can show calories and quietly omit the protein line. Returns: The protein target in grams, or None when weight is unknown. @@ -316,3 +234,35 @@ def protein_target_g() -> float | None: if weight is None: return None return round(weight * PROTEIN_G_PER_KG, 1) + + +def read_raw_record() -> dict[str, object] | None: + """Return the on-disk budget record verbatim, or None if unset/corrupt. + + Public, sync-only counterpart to :func:`_read_record`: + :mod:`diet_guard._sync` must treat "not yet set" and "unreadable" alike + as "nothing of this device's to contribute to the merge" rather than an + error, so unlike :func:`daily_budget` this never raises. + """ + if not BUDGET_FILE.exists(): + return None + try: + with BUDGET_FILE.open() as handle: + record = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict): + return None + return record + + +def write_raw_record(record: dict[str, object]) -> None: + """Persist ``record`` verbatim, overwriting the file on disk. + + Public counterpart to :func:`read_raw_record`, used by both + :func:`write_budget` and :mod:`diet_guard._sync` (to write back a + merged record, carrying the winning side's ``t`` edit timestamp). + """ + BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True) + with BUDGET_FILE.open("w") as handle: + json.dump(record, handle) diff --git a/diet_guard/_calendar_view.py b/diet_guard/_calendar_view.py new file mode 100644 index 0000000..064c1f9 --- /dev/null +++ b/diet_guard/_calendar_view.py @@ -0,0 +1,151 @@ +"""Pure month-grid math and status text for the diet_guard History tab. + +Split out of :mod:`._gatelock_calendar` so the calendar math -- month-grid +construction, per-cell styling, and the streak/YTD summary lines -- carries no +Tk widget dependency and is unit-tested directly, with no fake Tk in the +loop. Also keeps :mod:`._gatelock_calendar` (the Tk-facing half) under the +repo's 500-line limit. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from datetime import date + +from diet_guard._daystatus import ( + DayStatus, + adherence_streak, + logging_streak, + year_to_date_tally, +) +from diet_guard._gatelock_ui import BG, FG +from diet_guard._state import now_local + +_NOT_LOGGED_FILL = "#000000" +_NOT_LOGGED_OUTLINE = "#e0e0e0" +_STATUS_COLORS = { + DayStatus.GREEN: "#33cc66", + DayStatus.YELLOW: "#e0c33c", + DayStatus.RED: "#ff5555", +} +_STATUS_TEXT_COLOR = "#003322" +_MUTED = "#9a9a9a" + + +@dataclass(frozen=True) +class CalendarCell: + """One day cell in a rendered month grid. + + ``day`` is None for a blank cell outside the displayed month. ``status`` + is None for a blank cell, a future day (nothing to judge yet), or when no + budget is set (nothing to color by) -- callers render all three the same + neutral way, never as a false "not logged" black cell. + """ + + day: int | None + status: DayStatus | None + + +def _cell_status( + day: date, + status_map_: dict[str, DayStatus] | None, + today: date, +) -> DayStatus | None: + """Return the status to render for ``day``, or None for nothing to show. + + A day after ``today`` has not happened yet, and ``status_map_`` being + None means no budget is set to judge by -- both render neutrally rather + than as a false "not logged" black cell. + """ + if day > today or status_map_ is None: + return None + return status_map_.get(day.isoformat(), DayStatus.NOT_LOGGED) + + +def build_month_cells( + year: int, + month: int, + status_map_: dict[str, DayStatus] | None, + *, + today: date | None = None, +) -> list[list[CalendarCell]]: + """Return the month's weeks as a grid of :class:`CalendarCell`. + + Mirrors :func:`calendar.monthcalendar`'s shape (a list of weeks, each + seven day-of-month ints with 0 for days outside the month), translated + into cells carrying the status to render. + + Args: + year: The displayed year. + month: The displayed month (1-12). + status_map_: Per-day statuses as returned by + :func:`diet_guard._daystatus.status_map`, or None if no budget is + set to classify days by. + today: The reference "today"; defaults to the real current date. + + Returns: + A list of weeks, each a list of seven :class:`CalendarCell`. + """ + ref = today if today is not None else now_local().date() + grid: list[list[CalendarCell]] = [] + for week in calendar.monthcalendar(year, month): + row: list[CalendarCell] = [] + for day_num in week: + if day_num == 0: + row.append(CalendarCell(day=None, status=None)) + continue + row.append( + CalendarCell( + day=day_num, + status=_cell_status(date(year, month, day_num), status_map_, ref), + ), + ) + grid.append(row) + return grid + + +def cell_style(status: DayStatus | None) -> tuple[str, str, str]: + """Return ``(bg, fg, highlightbackground)`` for a cell's status.""" + if status is None: + return BG, _MUTED, BG + if status is DayStatus.NOT_LOGGED: + return _NOT_LOGGED_FILL, FG, _NOT_LOGGED_OUTLINE + color = _STATUS_COLORS[status] + return color, _STATUS_TEXT_COLOR, color + + +def _plural(count: int) -> str: + """Return "day" or "days" for ``count``.""" + return "day" if count == 1 else "days" + + +def streaks_text( + status_map_: dict[str, DayStatus], + *, + today: date | None = None, +) -> str: + """Render the logging and adherence streak line. + + ``today`` defaults to the real current date for live UI use; tests pass + it explicitly for deterministic streak boundaries. + """ + logging = logging_streak(status_map_, today=today) + adherence = adherence_streak(status_map_, today=today) + return ( + f"Logging streak: {logging} {_plural(logging)} · " + f"Adherence streak: {adherence} {_plural(adherence)}" + ) + + +def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str: + """Render the year-to-date logged/elapsed/adherent tally line. + + ``today`` defaults to the real current date for live UI use; tests pass + it explicitly for a deterministic elapsed-day count. + """ + tally = year_to_date_tally(status_map_, today=today) + return ( + f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · " + f"{tally.adherent_days} within budget" + ) diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 93ae6f7..de33469 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -21,15 +21,12 @@ import sys from diet_guard._budget import ( Biometrics, - BudgetLockedError, + BudgetFileCorruptError, BudgetNotInitializedError, - BudgetSealBrokenError, compute_target_budget, daily_budget, - lock_command, protein_target_g, - seal_budget, - unlock_command, + write_budget, ) from diet_guard._cli_gate import cmd_gate from diet_guard._cli_sync import cmd_sync, register_sync_subparser @@ -124,7 +121,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: sub.add_parser( "init", - help="Compute your daily budget from biometrics and seal it (hidden).", + help="Compute your starting daily budget from biometrics.", ) ate = sub.add_parser("ate", help="Log a meal you just ate.") @@ -210,8 +207,8 @@ def _print_summary() -> None: f"today: {total:g} kcal (budget not set - run: python -m diet_guard init)", ) return - except BudgetSealBrokenError: - _emit(f"today: {total:g} kcal (budget seal broken - re-run init)") + except BudgetFileCorruptError: + _emit(f"today: {total:g} kcal (budget file corrupt - re-run init)") return remaining = round(budget - total, 1) _emit(f"today: {total:g} kcal - {remaining:g} kcal left of {budget:g}") @@ -232,7 +229,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None: """Prompt for biometrics on stdin; return (bio, activity, deficit) or None. Returns None (after printing why) on any unparsable or out-of-range input, - so a typo never seals a wrong budget. + so a typo never sets a wrong budget. """ try: weight = float(_ask("weight in kg:")) @@ -247,7 +244,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None: ) deficit = float(_ask("daily deficit in kcal (e.g. 200):")) except ValueError: - _emit("that was not a number; nothing was sealed.") + _emit("that was not a number; nothing was set.") return None if sex_raw in _MALE_ANSWERS: @@ -255,7 +252,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None: elif sex_raw in _FEMALE_ANSWERS: is_male = False else: - _emit('sex must be "m" or "f"; nothing was sealed.') + _emit('sex must be "m" or "f"; nothing was set.') return None bio = Biometrics( @@ -268,7 +265,7 @@ def _read_init_inputs() -> tuple[Biometrics, float, float] | None: def _cmd_init() -> int: - """Compute the budget from biometrics and seal it, printing no number.""" + """Compute the starting budget from biometrics and write it.""" inputs = _read_init_inputs() if inputs is None: return 2 @@ -278,15 +275,9 @@ def _cmd_init() -> int: activity_factor=activity, deficit_kcal=deficit, ) - try: - seal_budget(budget, weight_kg=bio.weight_kg) - except BudgetLockedError: - _emit("the budget is locked; unlock it first, then re-run init:") - _emit(f" {unlock_command()}") - return 1 - _emit("budget computed from your biometrics and sealed - the number is") - _emit("intentionally not shown.") - _emit(f"to lock it against casual edits, run: {lock_command()}") + write_budget(budget, weight_kg=bio.weight_kg) + _emit(f"budget computed from your biometrics: {budget:g} kcal/day.") + _emit("edit it any time from the gate's calendar tab or the phone app.") return 0 diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index dfa4ee6..9d2eba5 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -5,15 +5,15 @@ from __future__ import annotations from pathlib import Path # --- Daily target ----------------------------------------------------------- -# There is deliberately NO budget number here. Like the home GPS coordinates in -# phone_focus_mode (which live only in the git-ignored config_secrets.sh on the -# device, never in committed source), the real budget is computed once from -# biometrics at ``init`` time and sealed into BUDGET_FILE below. It is read via -# diet_guard._budget.daily_budget() for over/under decisions only and -# is never printed -- see _budget.py for the full threat model. +# There is deliberately NO budget number here. It is computed once from +# biometrics at ``init`` time, written to BUDGET_FILE below, and freely +# editable and synced afterward from either device -- see _budget.py and +# _sync.py. Read via diet_guard._budget.daily_budget() for over/under +# decisions; freely shown in local CLI/GUI/app output. # # Fraction of the budget at which status flips from "on track" to "approaching -# limit". Surfaced as a label, so the threshold leaks only by boundary-probing. +# limit". Also mirrored above 100% as the day-status yellow/red boundary for +# the calendar -- see _daystatus.py. BUDGET_WARN_FRACTION: float = 0.80 # --- Storage ---------------------------------------------------------------- @@ -26,10 +26,11 @@ FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json" # searches -- Open Food Facts is used to *fill* a new food's macros, never to # search. Local-only, git-ignored. FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json" -# The sealed budget: a dotfile alongside the log, base64-wrapped + HMAC-signed, -# made immutable with ``chattr +i``. Git-ignored, never committed. "Hidden" -# here means never-online (it lives outside the repo) -- the number is still -# shown freely in local CLI/GUI output; the seal only makes *cheating* hard. +# The budget: a plain JSON dotfile alongside the log, freely editable on this +# device or the phone app and synced between them (see _sync.py). +# Git-ignored, never committed. "Hidden" here means never-online (it lives +# outside the repo) -- the number itself is shown freely in local CLI/GUI/app +# output. BUDGET_FILE: Path = DATA_DIR / ".budget" # --- Estimator (Open Food Facts) ------------------------------------------- diff --git a/diet_guard/_daystatus.py b/diet_guard/_daystatus.py new file mode 100644 index 0000000..646b05a --- /dev/null +++ b/diet_guard/_daystatus.py @@ -0,0 +1,201 @@ +"""Per-day budget-adherence status, streaks, and year-to-date tallies. + +DAY STATUS SPEC (keep in sync with ``app/lib/services/day_status_service.dart``): + +* green: ``total_kcal <= budget`` +* yellow: ``budget < total_kcal <= budget * (1 + (1 - BUDGET_WARN_FRACTION))`` +* red: ``total_kcal > budget * (1 + (1 - BUDGET_WARN_FRACTION))`` +* not_logged: the day is absent from a filtered log (no valid, + non-tombstoned entries) -- see :func:`diet_guard._state.load_log`. +* ordering, worst to best: not_logged > red > yellow > green. +* the logging streak breaks on a not_logged day. +* the adherence streak breaks on a red or a not_logged day; yellow and green + both keep it alive. + +Every function here is a pure function of an explicit ``log``/``status_map`` +argument (never reaching into on-disk state itself), so the boundary matrix +above is trivially testable with synthetic data. :func:`current_status_map` +is the one exception: a thin convenience wrapper for real UI callers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import enum +from typing import TYPE_CHECKING + +from diet_guard._budget import daily_budget +from diet_guard._constants import BUDGET_WARN_FRACTION +from diet_guard._state import entry_kcal, load_log, now_local + +if TYPE_CHECKING: + from diet_guard._state import DayLog + + +class DayStatus(enum.Enum): + """Qualitative per-day budget-adherence state, worst to best.""" + + NOT_LOGGED = "not_logged" + RED = "red" + YELLOW = "yellow" + GREEN = "green" + + +# The top of the "yellow" band above budget: a margin as wide as +# BUDGET_WARN_FRACTION's margin below 100% (the existing "approaching limit" +# band), so the two bands are symmetric around the budget line. +_OVER_BUDGET_YELLOW_CEILING = 1.0 + (1.0 - BUDGET_WARN_FRACTION) + +_LOGGED_STATUSES = frozenset( + {DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED}, +) +_ADHERENT_STATUSES = frozenset({DayStatus.GREEN, DayStatus.YELLOW}) + + +@dataclass(frozen=True) +class YtdTally: + """Year-to-date logging/adherence counts. + + Attributes: + logged_days: Days this year with at least one valid log entry. + elapsed_days: Days from Jan 1 through the reference date, inclusive. + adherent_days: Of ``logged_days``, how many were green or yellow. + """ + + logged_days: int + elapsed_days: int + adherent_days: int + + +def day_total_kcal(log: DayLog, day: str) -> float: + """Return the summed kcal for one ``YYYY-MM-DD`` key in ``log``.""" + return sum(entry_kcal(entry) for entry in log.get(day, [])) + + +def day_status(log: DayLog, day: str, budget: int) -> DayStatus: + """Classify one day's budget adherence. + + Args: + log: A filtered log as returned by + :func:`diet_guard._state.load_log` (only valid, non-tombstoned + entries; a day with none is simply absent). + day: The ``YYYY-MM-DD`` date key to classify. + budget: The daily kcal budget to compare against. + + Returns: + The day's :class:`DayStatus`. + """ + if day not in log: + return DayStatus.NOT_LOGGED + total = day_total_kcal(log, day) + if total <= budget: + return DayStatus.GREEN + if total <= budget * _OVER_BUDGET_YELLOW_CEILING: + return DayStatus.YELLOW + return DayStatus.RED + + +def status_map(log: DayLog, *, budget: int) -> dict[str, DayStatus]: + """Return a :class:`DayStatus` for every date key present in ``log``. + + Days with no log entries are simply absent from the result; a caller + rendering a full calendar treats a missing key as + :attr:`DayStatus.NOT_LOGGED`. + """ + return {day: day_status(log, day, budget) for day in log} + + +def _streak( + status_map_: dict[str, DayStatus], + keeps: frozenset[DayStatus], + *, + today: date | None = None, +) -> int: + """Count consecutive days ending at ``today`` whose status is in ``keeps``. + + A ``today`` absent from ``status_map_`` (not yet logged) is skipped + rather than treated as a break, so the streak does not appear broken + every morning before the user has eaten; counting resumes from + yesterday in that case. Any other day missing from ``keeps`` -- logged + or not -- ends the streak normally. + """ + day = today if today is not None else now_local().date() + if status_map_.get(day.isoformat()) is None: + day -= timedelta(days=1) + count = 0 + while status_map_.get(day.isoformat()) in keeps: + count += 1 + day -= timedelta(days=1) + return count + + +def logging_streak( + status_map_: dict[str, DayStatus], + *, + today: date | None = None, +) -> int: + """Return the consecutive-day logging streak ending at ``today``.""" + return _streak(status_map_, _LOGGED_STATUSES, today=today) + + +def adherence_streak( + status_map_: dict[str, DayStatus], + *, + today: date | None = None, +) -> int: + """Return the consecutive-day budget-adherence streak ending at ``today``. + + Breaks on a red or not-logged day; green and yellow days both keep it + alive ("not too aggressively crossed"). + """ + return _streak(status_map_, _ADHERENT_STATUSES, today=today) + + +def year_to_date_tally( + status_map_: dict[str, DayStatus], + *, + today: date | None = None, +) -> YtdTally: + """Return this year's logged/elapsed/adherent day counts. + + Args: + status_map_: Per-day statuses as returned by :func:`status_map`. + today: The reference "today"; defaults to the real current date. + + Returns: + Counts of logged days this year, elapsed days so far this year + (inclusive of ``today``), and how many logged days were adherent. + """ + ref = today if today is not None else now_local().date() + elapsed_days = (ref - date(ref.year, 1, 1)).days + 1 + logged_days = 0 + adherent_days = 0 + for key, status in status_map_.items(): + if status is DayStatus.NOT_LOGGED: + continue + day = date.fromisoformat(key) + if day.year != ref.year or day > ref: + continue + logged_days += 1 + if status in _ADHERENT_STATUSES: + adherent_days += 1 + return YtdTally( + logged_days=logged_days, + elapsed_days=elapsed_days, + adherent_days=adherent_days, + ) + + +def current_status_map() -> dict[str, DayStatus]: + """Return :func:`status_map` for the real on-disk log and budget. + + Convenience wrapper for UI callers; the pure functions above take + explicit ``log``/``budget``/``status_map_`` arguments so they stay + trivially testable with synthetic data. + + Raises: + BudgetNotInitializedError: If no budget has been set yet. + BudgetFileCorruptError: If the budget file is corrupt. + """ + return status_map(load_log(), budget=daily_budget()) diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index eb2c741..32b5853 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -31,10 +31,13 @@ Building ``MealGate`` spans several sibling modules to keep each under the repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf widget/field helpers and state (``_GateCore``, ``_GateState``); :mod:`._gatelock_nutrition` provides the reference->total nutrition maths and -food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the -submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``). -``MealGate`` wires these mixins together, owns the ``gatelock.LockWindow``, -and handles construction, layout, and event binding. +food lookup (``_GateNutrition``); :mod:`._gatelock_mealflow` provides the +submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``); +and :mod:`._gatelock_calendar` provides the second ``ttk.Notebook`` tab -- +the budget-adherence calendar, streaks, and YTD tally from +:mod:`._daystatus`, plus budget editing (``_GateCalendar``). ``MealGate`` +wires these mixins together, owns the ``gatelock.LockWindow``, and handles +construction, layout, and event binding. """ from __future__ import annotations @@ -49,12 +52,11 @@ from gatelock import GateRoot, LockConfig, LockWindow from diet_guard._constants import GATE_LOCK_FILE from diet_guard._gate import due_slots +from diet_guard._gatelock_calendar import _GateCalendar from diet_guard._gatelock_core import _GateState -from diet_guard._gatelock_mealflow import _GateMealFlow from diet_guard._gatelock_ui import ( BG, GateCallbacks, - build_layout, make_vars, ) from diet_guard._slots import current_slot, day_slots @@ -122,7 +124,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]: return [] -class MealGate(_GateMealFlow): +class MealGate(_GateCalendar): """A fullscreen lock that dismisses only once every missing slot is logged.""" def __init__(self, *, demo_mode: bool = True) -> None: @@ -157,17 +159,13 @@ class MealGate(_GateMealFlow): on_add_item=self._on_add_item, on_fetch_sync=self._on_fetch_sync, ) - self._widgets = build_layout( - self.root, - self._vars, - callbacks, - demo_mode=self.demo_mode, - ) + self._widgets = self._build_tabs(callbacks) self._wire_events() self._relabel_basis() self._refresh_slot_header() self._refresh_dashboard() self._refresh_projection() + self._refresh_calendar() self._lock.grab_input() self._widgets.desc_text.focus_set() diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py new file mode 100644 index 0000000..d7fad88 --- /dev/null +++ b/diet_guard/_gatelock_calendar.py @@ -0,0 +1,466 @@ +"""History tab: budget-adherence calendar, streaks, YTD tally, budget edit. + +Split out of :mod:`._gatelock` (and kept out of :mod:`._gatelock_ui`) to keep +every gate module under the repo's 500-line limit. ``_GateCalendar`` extends +:class:`~diet_guard._gatelock_mealflow._GateMealFlow` with a second +``ttk.Notebook`` tab: the calendar/streak/tally view built from +:mod:`diet_guard._calendar_view`'s pure grid math, plus a budget-edit field +writing through :func:`diet_guard._budget.write_budget` -- the one place +either device edits the now freely-editable, synced daily budget. + +The month-grid math itself lives in :mod:`diet_guard._calendar_view` (no Tk +dependency), so the boundary cases (varying month length/first weekday, +every :class:`~diet_guard._daystatus.DayStatus`, future days, no budget set) +are unit-tested directly without a fake Tk in the loop. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +from diet_guard._budget import ( + BudgetError, + BudgetFileCorruptError, + BudgetNotInitializedError, + budget_weight, + daily_budget, + write_budget, +) +from diet_guard._calendar_view import ( + CalendarCell, + build_month_cells, + cell_style, + streaks_text, + ytd_text, +) +from diet_guard._daystatus import DayStatus, status_map +from diet_guard._gatelock_mealflow import _GateMealFlow +from diet_guard._gatelock_ui import ( + BG, + ERR, + FG, + GateCallbacks, + GateWidgets, + build_layout, +) +from diet_guard._state import load_log, now_local + +if TYPE_CHECKING: + from collections.abc import Callable + +# Palette additions specific to the calendar (BG/FG/ERR come from +# _gatelock_ui's public exports; the rest are private to that module, so +# equivalents are defined locally rather than reaching across the boundary). +_MUTED = "#9a9a9a" +_FIELD_BG = "#2a2a2a" +_ACCENT = "#00ff88" + +# calendar.monthcalendar never returns more than 6 weeks for any month. +_MONTH_ROWS = 6 +_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su") +_JANUARY = 1 +_DECEMBER = 12 +_MONTH_AFTER_DECEMBER = 13 + +# Shown (and classified against) before any budget has ever been set -- +# mirrors the phone app's AppSettingsService default, so a fresh install +# shows a meaningful calendar on both platforms with no setup ritual. +_DEFAULT_BUDGET_KCAL = 2200 + + +@dataclass +class CalendarVars: + """Tk string variables bound to the History tab's live fields.""" + + month_label: tk.StringVar + streaks: tk.StringVar + ytd: tk.StringVar + budget_status: tk.StringVar + + +@dataclass +class CalendarWidgets: + """Interactive widgets of the History tab.""" + + frame: tk.Frame + day_cells: list[tk.Label] + budget_entry: tk.Entry + budget_edit_button: tk.Button + budget_status_label: tk.Label + + +@dataclass +class CalendarCallbacks: + """Construction-time commands the History tab's widgets fire.""" + + on_prev_month: Callable[[], None] + on_next_month: Callable[[], None] + on_edit_or_save_budget: Callable[[], None] + + +def make_calendar_vars(root: tk.Misc) -> CalendarVars: + """Create the History tab's string variables, all mastered to ``root``.""" + return CalendarVars( + month_label=tk.StringVar(master=root, value=""), + streaks=tk.StringVar(master=root, value=""), + ytd=tk.StringVar(master=root, value=""), + budget_status=tk.StringVar(master=root, value=""), + ) + + +def _build_budget_row( + parent: tk.Frame, + vars_: CalendarVars, + on_edit_or_save_budget: Callable[[], None], +) -> tuple[tk.Entry, tk.Button, tk.Label]: + """Build the budget row; return the entry, its edit button, and status label. + + The entry starts read-only (``state="readonly"``): the budget is + displayed but not directly typeable. The button on the right starts + labelled "Edit"; clicking it makes the entry editable and relabels + itself "Save" -- a second click validates and persists, then reverts + both back to their read-only defaults. + """ + row = tk.Frame(parent, bg=BG) + row.pack(pady=(8, 4)) + tk.Label( + row, + text="Daily budget (kcal):", + font=("Arial", 12), + bg=BG, + fg=FG, + ).pack(side="left") + entry = tk.Entry( + row, + font=("Arial", 13), + width=8, + bg=_FIELD_BG, + fg=FG, + insertbackground=FG, + justify="center", + state="readonly", + readonlybackground=_FIELD_BG, + ) + entry.pack(side="left", padx=(6, 6), ipady=2) + edit_button = tk.Button( + row, + text="Edit", + font=("Arial", 12, "bold"), + bg=_ACCENT, + fg="#003322", + activebackground="#00cc66", + cursor="hand2", + command=on_edit_or_save_budget, + ) + edit_button.pack(side="left") + status_label = tk.Label( + parent, + textvariable=vars_.budget_status, + font=("Arial", 11), + bg=BG, + fg=FG, + ) + status_label.pack(pady=(0, 4)) + return entry, edit_button, status_label + + +def _build_month_nav( + parent: tk.Frame, + vars_: CalendarVars, + callbacks: CalendarCallbacks, +) -> None: + """Build the prev/month-label/next header row.""" + row = tk.Frame(parent, bg=BG) + row.pack(pady=(4, 2)) + tk.Button( + row, + text="◀", + font=("Arial", 12, "bold"), + bg=_FIELD_BG, + fg=FG, + command=callbacks.on_prev_month, + cursor="hand2", + ).pack(side="left", padx=4) + tk.Label( + row, + textvariable=vars_.month_label, + font=("Arial", 14, "bold"), + bg=BG, + fg=FG, + width=16, + justify="center", + ).pack(side="left") + tk.Button( + row, + text="▶", + font=("Arial", 12, "bold"), + bg=_FIELD_BG, + fg=FG, + command=callbacks.on_next_month, + cursor="hand2", + ).pack(side="left", padx=4) + + +def _build_grid(parent: tk.Frame) -> list[tk.Label]: + """Build the fixed 6x7 day-cell grid and return the flat cell list.""" + weekday_row = tk.Frame(parent, bg=BG) + weekday_row.pack() + for col, label in enumerate(_WEEKDAY_LABELS): + tk.Label( + weekday_row, + text=label, + font=("Arial", 10, "bold"), + bg=BG, + fg=_MUTED, + width=4, + ).grid(row=0, column=col, padx=1) + + grid_frame = tk.Frame(parent, bg=BG) + grid_frame.pack(pady=(2, 4)) + day_cells: list[tk.Label] = [] + for row in range(_MONTH_ROWS): + for col in range(7): + cell = tk.Label( + grid_frame, + text="", + font=("Arial", 11), + width=4, + height=2, + bg=BG, + fg=FG, + highlightthickness=2, + highlightbackground=BG, + ) + cell.grid(row=row, column=col, padx=1, pady=1) + day_cells.append(cell) + return day_cells + + +def build_calendar_frame( + root: tk.Misc, + vars_: CalendarVars, + callbacks: CalendarCallbacks, +) -> CalendarWidgets: + """Lay out the History tab and return the widgets the controller drives.""" + frame = tk.Frame(root, bg=BG) + tk.Label( + frame, + text="📅 History", + font=("Arial", 22, "bold"), + bg=BG, + fg=_ACCENT, + ).pack(pady=(10, 0)) + + budget_entry, budget_edit_button, budget_status_label = _build_budget_row( + frame, + vars_, + callbacks.on_edit_or_save_budget, + ) + _build_month_nav(frame, vars_, callbacks) + day_cells = _build_grid(frame) + + tk.Label( + frame, + textvariable=vars_.streaks, + font=("Arial", 13, "bold"), + bg=BG, + fg=FG, + ).pack(pady=(6, 0)) + tk.Label( + frame, + textvariable=vars_.ytd, + font=("Arial", 12), + bg=BG, + fg=_MUTED, + ).pack(pady=(2, 8)) + + return CalendarWidgets( + frame=frame, + day_cells=day_cells, + budget_entry=budget_entry, + budget_edit_button=budget_edit_button, + budget_status_label=budget_status_label, + ) + + +class _GateCalendar(_GateMealFlow): + """History tab: calendar, streaks, YTD tally, and budget editing.""" + + _cal_vars: CalendarVars + _cal_widgets: CalendarWidgets + _cal_year: int + _cal_month: int + _cal_editing_budget: bool + _notebook: ttk.Notebook + + def _build_tabs(self, callbacks: GateCallbacks) -> GateWidgets: + """Build the ttk.Notebook, wire both tabs, and return the meal widgets. + + The controller stores the returned bundle as ``self._widgets`` exactly + as it did with the bare ``build_layout`` result before this tab + existed; the calendar's own vars/widgets are stashed on self. + """ + today = now_local().date() + self._cal_year = today.year + self._cal_month = today.month + self._cal_editing_budget = False + self._notebook = ttk.Notebook(self.root) + self._notebook.place(relx=0, rely=0, relwidth=1, relheight=1) + + widgets = build_layout( + self._notebook, self._vars, callbacks, demo_mode=self.demo_mode + ) + self._notebook.add(widgets.frame, text="Log Meal") + + self._cal_vars = make_calendar_vars(self.root) + cal_callbacks = CalendarCallbacks( + on_prev_month=self._on_prev_month, + on_next_month=self._on_next_month, + on_edit_or_save_budget=self._on_edit_or_save_budget, + ) + self._cal_widgets = build_calendar_frame( + self._notebook, self._cal_vars, cal_callbacks + ) + self._notebook.add(self._cal_widgets.frame, text="History") + return widgets + + # -- refresh -------------------------------------------------------------- + + def _refresh_calendar(self) -> None: + """Recompute the calendar grid, streaks, YTD tally, and budget field. + + A budget that was never set defaults to + :data:`_DEFAULT_BUDGET_KCAL`, matching the phone app, so the tab is + meaningful from a fresh install with no setup ritual. A genuinely + corrupt budget file is a real problem, not just "unset" -- that + still degrades to a neutral grid and an error message, instead of + raising through the tab and crashing (or failing open) the lock. + """ + log = load_log() + try: + budget: int | None = daily_budget() + except BudgetNotInitializedError: + budget = _DEFAULT_BUDGET_KCAL + except BudgetFileCorruptError: + budget = None + status_map_ = status_map(log, budget=budget) if budget is not None else None + self._render_month(status_map_) + if status_map_ is None: + self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.") + self._cal_vars.ytd.set("") + else: + self._cal_vars.streaks.set(streaks_text(status_map_)) + self._cal_vars.ytd.set(ytd_text(status_map_)) + if not self._cal_editing_budget: + self._refresh_budget_field(budget) + + def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None: + """Redraw the day-cell grid and month label for the displayed month.""" + weeks = build_month_cells(self._cal_year, self._cal_month, status_map_) + self._cal_vars.month_label.set( + f"{calendar.month_name[self._cal_month]} {self._cal_year}", + ) + cells = self._cal_widgets.day_cells + for index, cell_widget in enumerate(cells): + row, col = divmod(index, 7) + spec = weeks[row][col] if row < len(weeks) else CalendarCell(None, None) + bg, fg, outline = cell_style(spec.status) + cell_widget.config( + text=str(spec.day) if spec.day else "", + bg=bg, + fg=fg, + highlightbackground=outline, + ) + + def _refresh_budget_field(self, budget: int | None) -> None: + """Show ``budget`` in the (read-only) entry, or leave it blank if unset. + + Temporarily switches to the "normal" Tk state to mutate the text -- + a real ``readonly`` Entry rejects ``.insert``/``.delete`` the same + as direct typing -- then restores read-only. + """ + entry = self._cal_widgets.budget_entry + entry.config(state="normal") + entry.delete(0, tk.END) + if budget is not None: + entry.insert(0, str(budget)) + entry.config(state="readonly") + + # -- month navigation ------------------------------------------------------- + + def _on_prev_month(self) -> None: + """Step the displayed month back one, wrapping the year at January.""" + self._cal_month -= 1 + if self._cal_month < _JANUARY: + self._cal_month = _DECEMBER + self._cal_year -= 1 + self._refresh_calendar() + + def _on_next_month(self) -> None: + """Step the displayed month forward one, wrapping the year at December.""" + self._cal_month += 1 + if self._cal_month == _MONTH_AFTER_DECEMBER: + self._cal_month = _JANUARY + self._cal_year += 1 + self._refresh_calendar() + + # -- budget editing ----------------------------------------------------- + + def _set_budget_status(self, text: str, *, error: bool) -> None: + """Update the budget-edit status line, red for errors.""" + self._cal_vars.budget_status.set(text) + self._cal_widgets.budget_status_label.config(fg=ERR if error else FG) + + def _on_edit_or_save_budget(self) -> None: + """Toggle the budget row between read-only display and editing. + + First click: unlock the entry for typing and relabel the button + "Save" -- nothing is persisted yet. Second click: validate and + persist; on success, lock the entry back to read-only and relabel + the button "Edit". A validation failure leaves editing open so the + user can correct the value instead of silently discarding it. + """ + if not self._cal_editing_budget: + self._cal_editing_budget = True + self._cal_widgets.budget_entry.config(state="normal") + self._cal_widgets.budget_edit_button.config(text="Save") + self._set_budget_status("", error=False) + return + if not self._save_budget_entry(): + return + self._cal_editing_budget = False + self._cal_widgets.budget_edit_button.config(text="Edit") + self._refresh_calendar() + self._refresh_dashboard() + self._refresh_projection() + + def _save_budget_entry(self) -> bool: + """Validate and persist the entry's current text. + + Preserves any body weight already stored alongside the budget (used + to derive the protein target) -- a bare ``write_budget(value)`` would + silently drop it, since the file holds one record, not a diff. + + Returns: + Whether the value was valid and persisted. + """ + raw = self._cal_widgets.budget_entry.get().strip() + try: + value = int(raw) + except ValueError: + self._set_budget_status("Enter a whole number of kcal.", error=True) + return False + if value <= 0: + self._set_budget_status("Budget must be a positive number.", error=True) + return False + try: + weight = budget_weight() + except BudgetError: + weight = None + write_budget(value, weight_kg=weight) + self._set_budget_status("Saved.", error=False) + return True diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index 93c9ff6..5faeafd 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -253,8 +253,8 @@ class _GateMealFlow(_GateNutrition): def _unlock(self, logged: str) -> None: """Confirm the final log and tear the window down. - Teardown is scheduled *before* the budget is looked up, so a broken - budget seal (which raises) can never re-trap the user at unlock time. + Teardown is scheduled *before* the budget is looked up, so a corrupt + budget file (which raises) can never re-trap the user at unlock time. """ self._set_status(f"{logged} — all meals logged, unlocking…") self.root.after(_UNLOCK_DELAY_MS, self.close) diff --git a/diet_guard/_gatelock_nutrition.py b/diet_guard/_gatelock_nutrition.py index d2f472c..88c23ec 100644 --- a/diet_guard/_gatelock_nutrition.py +++ b/diet_guard/_gatelock_nutrition.py @@ -87,7 +87,7 @@ class _GateNutrition(_GateCore): This answers, as the calories are typed, the four numbers the user asked to see together: how much is already eaten today, the day's goal, how much is left now, and how much would be left *after* logging the food - currently in the form. With no budget sealed it degrades to the running + currently in the form. With no budget set it degrades to the running total plus this item's calories, so it is always informative. """ consumed = today_total_kcal() diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index 96f7d22..2048e2a 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -70,6 +70,7 @@ class GateVars: class GateWidgets: """Interactive widgets the controller reads back after the UI is built.""" + frame: tk.Frame desc_text: tk.Text amount_entry: tk.Entry per_entry: tk.Entry @@ -461,6 +462,7 @@ def build_layout( ).place(x=10, y=10) return GateWidgets( + frame=frame, desc_text=desc_text, amount_entry=amount_entry, per_entry=per_entry, diff --git a/diet_guard/_mcp.py b/diet_guard/_mcp.py index c5c6245..b83d065 100644 --- a/diet_guard/_mcp.py +++ b/diet_guard/_mcp.py @@ -16,11 +16,13 @@ Safety invariants (do not break when adding tools): calls must never write to stdout. All logging is routed to STDERR below, and tools call only stdout-free leaf helpers (never the ``_cmd_*`` / ``main`` CLI handlers, which write to stdout, read stdin, and ``sys.exit``). - * **The daily budget number never leaves.** No tool returns the raw budget, - the remaining-budget number, the stored body weight, the protein target, or - the sealed ``.budget`` file: those would let a caller back out the - deliberately-hidden budget. Read tools expose only today's *consumed* kcal / - macros and the qualitative :func:`consumption_band` string. + * **The daily budget number never leaves via this interface.** No tool + returns the raw budget, the remaining-budget number, the stored body + weight, the protein target, or the ``.budget`` file: an automated caller + should not be handed the exact number to reason about or help game, + even though the number itself is freely visible to the human via the + CLI/GUI and the phone app. Read tools expose only today's *consumed* + kcal / macros and the qualitative :func:`consumption_band` string. * **No secret ever leaves.** There is no tool that reads the shared HMAC key, the sync token, or any file under ``/etc``. * **Writes are gated.** The write tool defaults to a dry-run preview and @@ -102,7 +104,7 @@ def get_status() -> dict[str, Any]: Reports the calories and macros *consumed* so far, the qualitative :func:`consumption_band` (``"on track"`` / ``"approaching limit"`` / - ``"OVER BUDGET"``, or ``None`` when no budget is sealed yet), and the + ``"OVER BUDGET"``, or ``None`` when no budget has been set yet), and the meal-slot picture (which slots are due, which are already logged, and the current slot). The raw budget number is intentionally withheld -- only the band is exposed, mirroring how the CLI status shows a label to an automated @@ -111,7 +113,7 @@ def get_status() -> dict[str, Any]: try: band: str | None = consumption_band() except BudgetError: - # No budget sealed (or a broken seal): surface the absence, not a number. + # No budget set (or a corrupt file): surface the absence, not a number. band = None return { "consumed_kcal": today_total_kcal(), diff --git a/diet_guard/_state.py b/diet_guard/_state.py index a235f90..a34970e 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -245,10 +245,10 @@ def logged_slots_today() -> set[int]: def remaining_budget() -> float: - """Return kcal remaining against the sealed budget (may be negative). + """Return kcal remaining against the daily budget (may be negative). Raises: - BudgetError: If the budget is uninitialized or its seal is broken; + BudgetError: If the budget is uninitialized or its file is corrupt; the caller decides whether to guide the user or fail closed. """ return round(daily_budget() - today_total_kcal(), 1) diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 826fec8..aa45740 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -6,6 +6,12 @@ Pulls every other device's pushed log from GitHub-backed dumb storage diet_guard's entries to/from ``crdt_sync.Record``), re-signs every persisted entry, rebuilds the food bank, and pushes this device's own merged log back up in the new Record-based wire format. + +The daily budget syncs alongside the food log in the same tick (see +:func:`_sync_budget`, called from :func:`run_sync`): a sibling +``budget.json`` per device, merged the same way but last-writer-wins per +edit rather than union-of-immutable-entries, since a budget (unlike a +food-log entry) can be edited repeatedly. """ from __future__ import annotations @@ -15,6 +21,7 @@ import logging from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs +from diet_guard._budget import read_raw_record, write_raw_record from diet_guard._constants import ( SYNC_DEVICE_ID, SYNC_REPO_NAME, @@ -24,7 +31,14 @@ from diet_guard._constants import ( ) from diet_guard._foodbank import rebuild_food_bank from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log -from diet_guard._sync_merge import daylog_to_log, log_to_daylog, parse_remote_log +from diet_guard._sync_merge import ( + budget_to_log, + daylog_to_log, + log_to_budget, + log_to_daylog, + parse_remote_budget, + parse_remote_log, +) _logger = logging.getLogger(__name__) @@ -40,6 +54,11 @@ def _device_log_path(device_id: str) -> str: return f"{_DEVICES_DIR}/{device_id}/food_log.json" +def _device_budget_path(device_id: str) -> str: + """Return the repo-relative path a device's budget is pushed to.""" + return f"{_DEVICES_DIR}/{device_id}/budget.json" + + def _read_token() -> str: """Return the saved sync PAT, stripped of trailing whitespace. @@ -84,6 +103,48 @@ def _pull_remote_logs(client: GitHubSyncClient) -> list[Log]: return remote_logs +def _sync_budget(client: GitHubSyncClient) -> None: + """Pull other devices' budgets, merge, write locally, push this device's. + + Runs in the same tick as the food-log sync, reusing the already + authenticated ``client``. Merging is last-writer-wins by edit time (see + :mod:`diet_guard._sync_merge`'s budget adapters), not the food log's + union-of-immutable-entries -- a budget can be edited repeatedly. A + device that has never run ``init`` neither contributes a local record + to the merge nor overwrites a real budget pulled from elsewhere, and if + *no* device has ever set one, nothing is written or pushed. + """ + merged = budget_to_log(read_raw_record()) + for device_id in client.list_directory(_DEVICES_DIR): + if device_id == SYNC_DEVICE_ID: + continue + text = client.get_file_text(_device_budget_path(device_id)) + if text is None: + continue + try: + merged = merge_logs(merged, parse_remote_budget(text)) + except (TypeError, KeyError, ValueError, json.JSONDecodeError): + _logger.warning( + "Unparsable budget pushed by device %r, skipping", + device_id, + ) + + merged_record = log_to_budget(merged) + if merged_record is None: + return + write_raw_record(merged_record) + + push_json = json.dumps( + {record_id: record.to_dict() for record_id, record in merged.items()}, + indent=2, + ) + client.put_file_text( + _device_budget_path(SYNC_DEVICE_ID), + push_json, + message="diet_guard sync", + ) + + def run_sync() -> DayLog: """Run one full sync tick: pull, merge, re-sign, persist, push. @@ -91,7 +152,8 @@ def run_sync() -> DayLog: phone-origin ones): a signature computed on another device cannot be trusted as this device's shared key sees it, and an inbound entry with no signature at all would otherwise be silently dropped on the very next - read by :func:`diet_guard._state.load_log`. + read by :func:`diet_guard._state.load_log`. The daily budget syncs in + the same tick (see :func:`_sync_budget`), reusing this same client. Returns: The merged log as it now sits on disk locally, post re-sign. @@ -118,6 +180,7 @@ def run_sync() -> DayLog: } write_raw_log(resigned) rebuild_food_bank(resigned) + _sync_budget(client) push_log = daylog_to_log(resigned) push_json = json.dumps( diff --git a/diet_guard/_sync_merge.py b/diet_guard/_sync_merge.py index 38dd985..748f2d8 100644 --- a/diet_guard/_sync_merge.py +++ b/diet_guard/_sync_merge.py @@ -20,6 +20,16 @@ Backward compatible with devices not yet migrated (the phone app, for now): falls back to the old plain-DayLog format, converting old-format entries through the same adapters used for the local log. Push always writes the new format -- there is no code path left that ever *writes* the old one. + +The budget adapters at the bottom of this module (:func:`budget_to_log` +etc.) follow the same ``Record``/``Log`` shape, but differ in one important +way: a budget record is edited repeatedly (not immutable-after-creation +like a food-log entry), so its ``Hlc`` is derived from a ``t`` edit +timestamp that :func:`diet_guard._budget.write_budget` bumps on every +write, rather than from a birth time that never changes. There is no +legacy plain-``DayLog``-shaped fallback for budgets -- ``budget.json`` is a +brand-new sync payload, so every device pushing it already speaks the +Record-based wire format. """ from __future__ import annotations @@ -176,3 +186,80 @@ def parse_remote_log(text: str) -> Log: raise TypeError(msg) daylog[date_key] = entries return daylog_to_log(daylog) + + +# Stable id: exactly one budget record per device-pushed budget.json. +_BUDGET_RECORD_ID = "budget" + + +def _budget_hlc(record: dict[str, object]) -> Hlc: + """Derive a deterministic Hlc for a raw budget record from its ``t`` field. + + Mirrors :func:`_entry_hlc`'s determinism -- the same unedited record + always yields the same Hlc, so re-syncing an unchanged budget is a + no-op -- but reads ``t`` (bumped on every :func:`diet_guard._budget. + write_budget` call) rather than a fixed birth time, since a budget can + be edited repeatedly and the *edit* time is what last-writer-wins must + compare. + """ + try: + dt = datetime.fromisoformat(str(record.get("t", ""))) + except ValueError: + dt = _EPOCH + wall_time_ms = int(dt.timestamp() * 1000) + return Hlc.new_tick(SYNC_DEVICE_ID, wall_time_ms=wall_time_ms) + + +def budget_to_log(record: dict[str, object] | None) -> Log: + """Convert a raw local/remote budget record into a single-record Log. + + Returns an empty ``Log`` when ``record`` is None (this device has never + run ``init``), so an uninitialized device contributes nothing to the + merge rather than clobbering another device's real value. + """ + if record is None: + return {} + hlc = _budget_hlc(record) + value = {k: v for k, v in record.items() if k != "t"} + rec = Record(id=_BUDGET_RECORD_ID, fields={"value": (value, hlc)}) + return {rec.id: rec} + + +def log_to_budget(log: Log) -> dict[str, object] | None: + """Convert a merged budget ``Log`` back into a raw budget record. + + Returns None when the log has no budget record at all (neither device + has ever run ``init`` yet) -- callers treat that as "nothing to write + locally", not an error. + """ + record = log.get(_BUDGET_RECORD_ID) + if record is None: + return None + value, hlc = record.fields.get("value", ({}, None)) + result: dict[str, object] = dict(value) if isinstance(value, dict) else {} + if hlc is not None: + winning_time = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) + result["t"] = winning_time.astimezone().isoformat(timespec="seconds") + return result + + +def parse_remote_budget(text: str) -> Log: + """Parse one device's pushed ``budget.json`` text into a ``crdt_sync.Log``. + + Raises on malformed data; the caller + (:func:`diet_guard._sync._pull_remote_budgets`) logs-and-skips on that, + matching :func:`parse_remote_log`'s tolerance for a bad device file. + + Raises: + TypeError: If the top-level JSON isn't an object, or a record value + isn't shaped as expected. + KeyError: Via ``Record.from_dict``, if a record is missing an + expected key. + ValueError: Via ``json.loads`` on invalid JSON, or ``Hlc.from_str`` + on a malformed clock string. + """ + raw = json.loads(text) + if not isinstance(raw, dict): + msg = f"top-level budget payload is not a JSON object: {raw!r}" + raise TypeError(msg) + return {record_id: Record.from_dict(data) for record_id, data in raw.items()} diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 56f0aa3..aa0c917 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -29,6 +29,7 @@ import pytest from diet_guard import ( _gatelock, + _gatelock_calendar, _gatelock_core, _gatelock_mealflow, _gatelock_nutrition, @@ -135,10 +136,16 @@ class FakeVar: class FakeEntry: - """A functional one-line entry (delete clears, insert appends).""" + """A functional one-line entry (delete clears, insert appends). + + ``configure``/``config`` record their kwargs into ``configured``, so a + test can assert on a read-only/editable state toggle the same way it + would with :class:`FakeWidget`. + """ def __init__(self, *args: object, **kwargs: object) -> None: self._value = "" + self.configured: dict[str, object] = dict(kwargs) def get(self) -> str: return self._value @@ -156,7 +163,7 @@ class FakeEntry: pass def configure(self, *args: object, **kwargs: object) -> None: - pass + self.configured.update(kwargs) config = configure @@ -204,10 +211,15 @@ class FakeListbox: class FakeWidget: - """A generic no-op widget for Frame/Label/Button/OptionMenu.""" + """A generic no-op widget for Frame/Label/Button/OptionMenu. + + ``configure``/``config`` record their kwargs into ``configured`` (rather + than discarding them) so a test can assert on a widget's last-set color + or text, e.g. the calendar's per-cell status coloring. + """ def __init__(self, *args: object, **kwargs: object) -> None: - pass + self.configured: dict[str, object] = dict(kwargs) def pack(self, *args: object, **kwargs: object) -> FakeWidget: return self @@ -215,7 +227,11 @@ class FakeWidget: def place(self, *args: object, **kwargs: object) -> FakeWidget: return self + def grid(self, *args: object, **kwargs: object) -> FakeWidget: + return self + def configure(self, *args: object, **kwargs: object) -> FakeWidget: + self.configured.update(kwargs) return self config = configure @@ -223,6 +239,34 @@ class FakeWidget: def bind(self, *args: object, **kwargs: object) -> None: pass + def register(self, func: object) -> object: + """Stand in for Tk's Tcl-command registration: return the callable as-is.""" + return func + + +class FakeNotebook(FakeWidget): + """A functional ``ttk.Notebook``: tracks added tabs and the selection.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.tabs: list[tuple[object, str]] = [] + self._selected = 0 + + def add(self, child: object, *, text: str = "") -> None: + self.tabs.append((child, text)) + + def select(self, tab_id: object = None) -> int | None: + if tab_id is None: + return self._selected + if isinstance(tab_id, int): + self._selected = tab_id + else: + for index, (child, _label) in enumerate(self.tabs): + if child is tab_id: + self._selected = index + break + return None + _FAKE_TK = SimpleNamespace( END="end", @@ -238,11 +282,14 @@ _FAKE_TK = SimpleNamespace( Event=object, ) +_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook) + # Every mixin module the gate window is built from imports ``tkinter`` # independently; all of them must see the fake so ``tk.TclError`` etc. are the # catchable ``_FakeTclError`` everywhere a test raises it. _GATE_TK_MODULES = ( _gatelock, + _gatelock_calendar, _gatelock_core, _gatelock_nutrition, _gatelock_mealflow, @@ -256,6 +303,7 @@ def gate() -> Iterator[MealGate]: with ExitStack() as stack: for module in _GATE_TK_MODULES: stack.enter_context(patch.object(module, "tk", _FAKE_TK)) + stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK)) yield MealGate(demo_mode=True) diff --git a/diet_guard/tests/test_budget.py b/diet_guard/tests/test_budget.py index 3f8b19d..cf8fcc4 100644 --- a/diet_guard/tests/test_budget.py +++ b/diet_guard/tests/test_budget.py @@ -1,8 +1,7 @@ -"""Tests for _budget.py — the hidden, tamper-hardened daily budget.""" +"""Tests for _budget.py — the freely-editable, synced daily budget.""" from __future__ import annotations -import base64 import json from pathlib import Path from typing import TYPE_CHECKING, cast @@ -13,18 +12,15 @@ import pytest from diet_guard import _budget from diet_guard._budget import ( Biometrics, - BudgetLockedError, + BudgetFileCorruptError, BudgetNotInitializedError, - BudgetSealBrokenError, budget_weight, compute_target_budget, daily_budget, is_initialized, - lock_command, mifflin_st_jeor_bmr, protein_target_g, - seal_budget, - unlock_command, + write_budget, ) if TYPE_CHECKING: @@ -35,17 +31,16 @@ _BIO = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=True) def _write_record(record: object) -> None: - """Write an arbitrary object as the seal file (for tamper tests).""" + """Write an arbitrary object as the budget file (for corruption tests).""" _budget.BUDGET_FILE.write_text(json.dumps(record), encoding="utf-8") def _budget_open_raises(exc: type[BaseException]) -> object: - """Patch ``Path.open`` to raise ``exc`` ONLY for the sealed-budget file. + """Patch ``Path.open`` to raise ``exc`` ONLY for the budget file. ``Path`` instances use ``__slots__`` so ``patch.object(BUDGET_FILE, "open")`` - fails; and patching ``Path.open`` wholesale would also break the unrelated - HMAC-key read inside ``compute_entry_hmac``. Routing every other path to the - real ``open`` keeps the failure surgically on the budget file. + fails; routing every other path to the real ``open`` keeps the failure + surgically on the budget file. Args: exc: The exception type to raise when the budget file is opened. @@ -53,8 +48,6 @@ def _budget_open_raises(exc: type[BaseException]) -> object: Returns: An unstarted ``patch`` context manager. """ - # Capture the real opener as a permissive callable so forwarding the - # patched-through args (typed ``object`` here) is not rejected on arg types. real_open = cast("Callable[..., Iterator[str]]", Path.open) def fake_open(self: Path, *args: object, **kwargs: object) -> Iterator[str]: @@ -89,7 +82,7 @@ class TestComputeTargetBudget: assert result == 2200 def test_floored_to_minimum(self) -> None: - """An absurd deficit cannot seal a starvation-level budget.""" + """An absurd deficit cannot compute a starvation-level budget.""" result = compute_target_budget(_BIO, activity_factor=1.0, deficit_kcal=5000) assert result == _budget._MIN_SANE_BUDGET @@ -100,42 +93,34 @@ class TestExceptions: def test_messages(self) -> None: """Constructors set a non-empty message with no arguments.""" assert str(BudgetNotInitializedError()) - assert str(BudgetSealBrokenError()) - assert str(BudgetLockedError()) + assert str(BudgetFileCorruptError()) -class TestSealAndRead: - """Round-tripping the sealed budget.""" +class TestWriteAndRead: + """Round-tripping the plainly-written budget.""" def test_roundtrip(self) -> None: - """A sealed value reads back exactly.""" - seal_budget(2000) + """A written value reads back exactly.""" + write_budget(2000) assert daily_budget() == 2000 def test_is_initialized(self) -> None: """is_initialized reflects whether the file exists.""" assert not is_initialized() - seal_budget(2000) + write_budget(2000) assert is_initialized() - def test_file_is_not_plaintext(self) -> None: - """The number is base64-wrapped, not stored as a bare integer.""" - seal_budget(2345) - raw = _budget.BUDGET_FILE.read_text(encoding="utf-8") - assert "2345" not in raw + def test_file_is_plain_json(self) -> None: + """The number is stored in the open, readable format -- no wrapping.""" + write_budget(2345) + record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8")) + assert record["b"] == 2345 - def test_unsigned_accepted_when_no_key(self) -> None: - """With no HMAC key, an unsigned seal is written and accepted.""" - with patch.object(_budget, "compute_entry_hmac", return_value=None): - seal_budget(1800) - record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8")) - assert "hmac" not in record - assert daily_budget() == 1800 - - def test_locked_file_raises(self) -> None: - """An unwritable (immutable) file surfaces as BudgetLockedError.""" - with _budget_open_raises(PermissionError), pytest.raises(BudgetLockedError): - seal_budget(2000) + def test_overwrite_replaces_the_value(self) -> None: + """Writing again replaces the previous value, with no extra ritual.""" + write_budget(2000) + write_budget(1800) + assert daily_budget() == 1800 class TestReadFailures: @@ -147,126 +132,90 @@ class TestReadFailures: daily_budget() def test_unreadable_file(self) -> None: - """An OSError while reading surfaces as a broken seal.""" - seal_budget(2000) - with _budget_open_raises(OSError), pytest.raises(BudgetSealBrokenError): + """An OSError while reading surfaces as a corrupt file.""" + write_budget(2000) + with _budget_open_raises(OSError), pytest.raises(BudgetFileCorruptError): daily_budget() def test_invalid_json(self) -> None: - """Garbage content -> broken seal.""" + """Garbage content -> corrupt file.""" _budget.BUDGET_FILE.write_text("not json", encoding="utf-8") - with pytest.raises(BudgetSealBrokenError): + with pytest.raises(BudgetFileCorruptError): daily_budget() def test_record_not_dict(self) -> None: - """A non-object top level -> broken seal.""" + """A non-object top level -> corrupt file.""" _write_record([1, 2, 3]) - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_data_not_string(self) -> None: - """A non-string data field -> broken seal.""" - _write_record({"data": 123}) - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_bad_base64(self) -> None: - """Undecodable base64 -> broken seal.""" - _write_record({"data": "!!!not base64!!!"}) - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_inner_not_dict(self) -> None: - """base64 that decodes to a non-object -> broken seal.""" - inner = base64.b64encode(b"[1,2,3]").decode("ascii") - _write_record({"data": inner}) - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_tampered_signature(self) -> None: - """A forged value with a bad signature is rejected.""" - forged = base64.b64encode(b'{"b":9999,"v":1}').decode("ascii") - _write_record({"data": forged, "hmac": "deadbeef"}) - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_unsigned_rejected_when_key_available(self) -> None: - """A stripped signature on a keyed system means tampering.""" - valid = base64.b64encode(b'{"b":2000,"v":1}').decode("ascii") - _write_record({"data": valid}) # no hmac, but a key exists - with pytest.raises(BudgetSealBrokenError): - daily_budget() - - def test_signature_present_but_key_missing(self) -> None: - """A signed seal cannot be verified once the key is gone.""" - seal_budget(2000) - with ( - patch.object( - _budget, - "compute_entry_hmac", - return_value=None, - ), - pytest.raises(BudgetSealBrokenError), - ): + with pytest.raises(BudgetFileCorruptError): daily_budget() def test_non_integer_value(self) -> None: """A non-integer budget (here a bool) is rejected.""" - # Sign a record whose inner "b" is a bool, so the signature is valid but - # the value type is wrong. - inner = {"v": 1, "b": True} - blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode() - record = { - "data": base64.b64encode(blob).decode("ascii"), - "hmac": _budget.compute_entry_hmac(inner), - } - _write_record(record) - with pytest.raises(BudgetSealBrokenError): + _write_record({"v": 2, "b": True}) + with pytest.raises(BudgetFileCorruptError): daily_budget() class TestWeightAndProtein: - """The v2 stored weight and the protein target derived from it.""" + """The stored weight and the protein target derived from it.""" - def test_seal_with_weight_roundtrips(self) -> None: - """A weight sealed alongside the budget reads back.""" - seal_budget(2200, weight_kg=80.0) + def test_write_with_weight_roundtrips(self) -> None: + """A weight written alongside the budget reads back.""" + write_budget(2200, weight_kg=80.0) assert daily_budget() == 2200 assert budget_weight() == pytest.approx(80.0) def test_protein_target_from_weight(self) -> None: """The protein target is weight x the per-kg constant.""" - seal_budget(2200, weight_kg=80.0) + write_budget(2200, weight_kg=80.0) expected = round(80.0 * _budget.PROTEIN_G_PER_KG, 1) assert protein_target_g() == pytest.approx(expected) - def test_v1_seal_has_no_weight(self) -> None: - """A budget sealed without a weight exposes no weight or protein target.""" - seal_budget(2000) + def test_no_weight_has_no_protein_target(self) -> None: + """A budget written without a weight exposes no weight or target.""" + write_budget(2000) assert budget_weight() is None assert protein_target_g() is None def test_protein_target_none_when_uninitialized(self) -> None: - """With nothing sealed, the protein target is quietly None, not an error.""" + """With no budget written, the protein target is quietly None.""" assert protein_target_g() is None def test_budget_weight_rejects_non_numeric(self) -> None: - """A validly-signed but non-numeric weight yields None, not a crash.""" - inner = {"v": 2, "b": 2000, "w": True} - blob = json.dumps(inner, sort_keys=True, separators=(",", ":")).encode() - record = { - "data": base64.b64encode(blob).decode("ascii"), - "hmac": _budget.compute_entry_hmac(inner), - } - _write_record(record) + """A non-numeric weight yields None, not a crash.""" + _write_record({"v": 2, "b": 2000, "w": True}) assert budget_weight() is None -class TestCommands: - """The chattr helper strings.""" +class TestRawRecord: + """The sync-only raw-record read/write pair, which never raises.""" - def test_lock_unlock_commands(self) -> None: - """Both reference the budget path with the right chattr flag.""" - assert lock_command().startswith("sudo chattr +i ") - assert unlock_command().startswith("sudo chattr -i ") - assert str(_budget.BUDGET_FILE) in lock_command() + def test_missing_file_returns_none(self) -> None: + assert _budget.read_raw_record() is None + + def test_roundtrips_a_written_record(self) -> None: + _budget.write_raw_record({"v": 2, "b": 1900, "t": "2026-01-01T00:00:00"}) + assert _budget.read_raw_record() == { + "v": 2, + "b": 1900, + "t": "2026-01-01T00:00:00", + } + + def test_unreadable_file_returns_none(self) -> None: + write_budget(2000) + with _budget_open_raises(OSError): + assert _budget.read_raw_record() is None + + def test_invalid_json_returns_none(self) -> None: + _budget.BUDGET_FILE.write_text("not json", encoding="utf-8") + assert _budget.read_raw_record() is None + + def test_non_dict_record_returns_none(self) -> None: + _write_record([1, 2, 3]) + assert _budget.read_raw_record() is None + + def test_write_budget_stamps_an_edit_timestamp(self) -> None: + write_budget(2000) + record = _budget.read_raw_record() + assert record is not None + assert record["t"] diff --git a/diet_guard/tests/test_calendar_view.py b/diet_guard/tests/test_calendar_view.py new file mode 100644 index 0000000..6f1151b --- /dev/null +++ b/diet_guard/tests/test_calendar_view.py @@ -0,0 +1,126 @@ +"""Tests for the pure (no-Tk) calendar grid math and status text.""" + +from __future__ import annotations + +from datetime import date + +from diet_guard._calendar_view import ( + CalendarCell, + build_month_cells, + cell_style, + streaks_text, + ytd_text, +) +from diet_guard._daystatus import DayStatus + + +class TestBuildMonthCells: + """Boundary matrix for :func:`build_month_cells`.""" + + def test_blank_cells_outside_the_month_have_no_day_or_status(self) -> None: + # July 2026 starts on a Wednesday, so the first row has leading blanks. + weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31)) + assert weeks[0][0] == CalendarCell(day=None, status=None) + + def test_a_day_present_in_the_status_map_carries_its_status(self) -> None: + weeks = build_month_cells( + 2026, + 7, + {"2026-07-15": DayStatus.GREEN}, + today=date(2026, 7, 31), + ) + cell = next(c for week in weeks for c in week if c.day == 15) + assert cell.status is DayStatus.GREEN + + def test_a_past_day_absent_from_the_status_map_is_not_logged(self) -> None: + weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31)) + cell = next(c for week in weeks for c in week if c.day == 1) + assert cell.status is DayStatus.NOT_LOGGED + + def test_a_future_day_is_neutral_not_not_logged(self) -> None: + weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 10)) + cell = next(c for week in weeks for c in week if c.day == 15) + assert cell.status is None + + def test_today_itself_is_classified_not_treated_as_future(self) -> None: + weeks = build_month_cells( + 2026, + 7, + {"2026-07-10": DayStatus.RED}, + today=date(2026, 7, 10), + ) + cell = next(c for week in weeks for c in week if c.day == 10) + assert cell.status is DayStatus.RED + + def test_none_status_map_renders_every_past_day_neutral(self) -> None: + weeks = build_month_cells(2026, 7, None, today=date(2026, 7, 31)) + cell = next(c for week in weeks for c in week if c.day == 1) + assert cell.status is None + + def test_defaults_today_to_the_real_current_date(self) -> None: + # No explicit `today` -- exercises the now_local() fallback branch. + weeks = build_month_cells(2020, 1, {}) + assert weeks[0][0].day in (None, 1) + + def test_a_28_day_february_produces_four_or_five_weeks(self) -> None: + weeks = build_month_cells(2026, 2, {}, today=date(2026, 2, 28)) + assert len(weeks) in (4, 5) + + def test_a_31_day_month_starting_on_saturday_produces_six_weeks(self) -> None: + # August 2026 starts on a Saturday and has 31 days -> 6 rows. + weeks = build_month_cells(2026, 8, {}, today=date(2026, 8, 31)) + assert len(weeks) == 6 + + def test_every_day_of_the_month_is_present_exactly_once(self) -> None: + weeks = build_month_cells(2026, 7, {}, today=date(2026, 7, 31)) + days = [cell.day for week in weeks for cell in week if cell.day is not None] + assert days == list(range(1, 32)) + + +class TestCellStyle: + """Boundary matrix for :func:`cell_style`.""" + + def test_none_status_is_neutral(self) -> None: + bg, _fg, outline = cell_style(None) + assert bg == outline + + def test_not_logged_is_black_with_a_visible_outline(self) -> None: + bg, _fg, outline = cell_style(DayStatus.NOT_LOGGED) + assert bg == "#000000" + assert outline != bg + + def test_green_yellow_red_each_style_distinctly(self) -> None: + styles = { + status: cell_style(status) + for status in (DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED) + } + assert len({style[0] for style in styles.values()}) == 3 + + +class TestStreaksText: + """:func:`streaks_text` boundary cases (singular/plural, zero).""" + + def test_zero_streaks_render_as_zero_days(self) -> None: + text = streaks_text({}, today=date(2026, 7, 13)) + assert "0 days" in text + + def test_a_one_day_streak_uses_the_singular(self) -> None: + text = streaks_text( + {"2026-07-13": DayStatus.GREEN}, + today=date(2026, 7, 13), + ) + assert "Logging streak: 1 day " in text + assert "Adherence streak: 1 day" in text + + +class TestYtdText: + """:func:`ytd_text` renders the tally's three counts.""" + + def test_reports_logged_elapsed_and_adherent_counts(self) -> None: + status_map_ = { + "2026-01-01": DayStatus.GREEN, + "2026-01-02": DayStatus.RED, + } + text = ytd_text(status_map_, today=date(2026, 1, 10)) + assert "logged 2/10 days" in text + assert "1 within budget" in text diff --git a/diet_guard/tests/test_cli.py b/diet_guard/tests/test_cli.py index d59cf87..29aa72b 100644 --- a/diet_guard/tests/test_cli.py +++ b/diet_guard/tests/test_cli.py @@ -13,10 +13,9 @@ from unittest.mock import patch from diet_guard import _cli from diet_guard._budget import ( - BudgetLockedError, + BudgetFileCorruptError, BudgetNotInitializedError, - BudgetSealBrokenError, - seal_budget, + write_budget, ) from diet_guard._cli import _eaten_grams, _Portion, main from diet_guard._estimator import Nutrition @@ -60,15 +59,15 @@ class TestEatenGrams: class TestInit: - """The budget-sealing init command.""" + """The budget-setting init command.""" def test_valid_male( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """Valid inputs seal a budget and print the lock hint, not the number.""" + """Valid inputs write a budget and print the computed value.""" _feed(monkeypatch, _VALID_INIT) assert main(["init"]) == 0 - assert "sealed" in capsys.readouterr().out + assert "computed" in capsys.readouterr().out def test_valid_female(self, monkeypatch: pytest.MonkeyPatch) -> None: """The female sex branch is accepted.""" @@ -76,24 +75,15 @@ class TestInit: assert main(["init"]) == 0 def test_non_number_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A non-numeric input seals nothing and returns the error code.""" + """A non-numeric input sets nothing and returns the error code.""" _feed(monkeypatch, "heavy\n") assert main(["init"]) == 2 def test_bad_sex_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None: - """An unrecognised sex answer seals nothing.""" + """An unrecognised sex answer sets nothing.""" _feed(monkeypatch, "80\n169\n26\nx\n1.375\n180\n") assert main(["init"]) == 2 - def test_locked_budget( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """A locked file surfaces the unlock instructions and a failure code.""" - _feed(monkeypatch, _VALID_INIT) - with patch.object(_cli, "seal_budget", side_effect=BudgetLockedError): - assert main(["init"]) == 1 - assert "locked" in capsys.readouterr().out - class TestSummary: """The budget-remaining summary line.""" @@ -104,15 +94,15 @@ class TestSummary: _cli._print_summary() assert "budget not set" in capsys.readouterr().out - def test_seal_broken(self, capsys: pytest.CaptureFixture[str]) -> None: - """A broken seal is reported plainly.""" - with patch.object(_cli, "daily_budget", side_effect=BudgetSealBrokenError): + def test_file_corrupt(self, capsys: pytest.CaptureFixture[str]) -> None: + """A corrupt budget file is reported plainly.""" + with patch.object(_cli, "daily_budget", side_effect=BudgetFileCorruptError): _cli._print_summary() - assert "seal broken" in capsys.readouterr().out + assert "corrupt" in capsys.readouterr().out def test_remaining_shown(self, capsys: pytest.CaptureFixture[str]) -> None: """A valid budget prints how much is left.""" - seal_budget(2000) + write_budget(2000) _cli._print_summary() assert "left" in capsys.readouterr().out @@ -122,7 +112,7 @@ class TestAte: def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None: """A resolved meal is logged, banked, and summarized.""" - seal_budget(2000) + write_budget(2000) with patch.object(_cli, "resolve_nutrition", return_value=_NUT): assert main(["ate", "big mac"]) == 0 assert "logged:" in capsys.readouterr().out @@ -131,7 +121,7 @@ class TestAte: self, capsys: pytest.CaptureFixture[str] ) -> None: """An assumed per-item weight prints its caveat.""" - seal_budget(2000) + write_budget(2000) with patch.object(_cli, "resolve_nutrition", return_value=_NUT): main(["ate", "mystery", "--count", "3"]) assert "assumed" in capsys.readouterr().out @@ -148,7 +138,7 @@ class TestStatus: def test_status_with_entries(self, capsys: pytest.CaptureFixture[str]) -> None: """Logged entries, slots, summary, and macros all print.""" - seal_budget(2000) + write_budget(2000) main(["ate", "lunch", "--kcal", "500"]) capsys.readouterr() assert main(["status"]) == 0 @@ -158,7 +148,7 @@ class TestStatus: def test_status_empty(self, capsys: pytest.CaptureFixture[str]) -> None: """With nothing logged, status still prints the slot/summary lines.""" - seal_budget(2000) + write_budget(2000) assert main(["status"]) == 0 assert "slots:" in capsys.readouterr().out @@ -201,7 +191,7 @@ class TestUndo: def test_undo_removes_entry(self, capsys: pytest.CaptureFixture[str]) -> None: """Undo removes and reports the last entry.""" - seal_budget(2000) + write_budget(2000) main(["ate", "snack", "--kcal", "100"]) capsys.readouterr() assert main(["undo"]) == 0 diff --git a/diet_guard/tests/test_daystatus.py b/diet_guard/tests/test_daystatus.py new file mode 100644 index 0000000..ae72705 --- /dev/null +++ b/diet_guard/tests/test_daystatus.py @@ -0,0 +1,185 @@ +"""Tests for diet_guard._daystatus: status classification, streaks, tallies.""" + +from __future__ import annotations + +from datetime import date + +from diet_guard import _budget, _daystatus, _state +from diet_guard._daystatus import DayStatus +from diet_guard._estimator import Nutrition + + +def _entry(kcal: float) -> dict[str, object]: + return {"kcal": kcal} + + +class TestDayTotalKcal: + def test_sums_entries(self) -> None: + log = {"2026-01-01": [_entry(100), _entry(50)]} + assert _daystatus.day_total_kcal(log, "2026-01-01") == 150 + + def test_missing_day_is_zero(self) -> None: + assert _daystatus.day_total_kcal({}, "2026-01-01") == 0 + + +class TestDayStatus: + def test_missing_day_is_not_logged(self) -> None: + assert _daystatus.day_status({}, "2026-01-01", 2000) == DayStatus.NOT_LOGGED + + def test_exactly_at_budget_is_green(self) -> None: + log = {"2026-01-01": [_entry(2000)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.GREEN + + def test_under_budget_is_green(self) -> None: + log = {"2026-01-01": [_entry(1000)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.GREEN + + def test_just_over_budget_is_yellow(self) -> None: + log = {"2026-01-01": [_entry(2001)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.YELLOW + + def test_exactly_at_yellow_ceiling_is_yellow(self) -> None: + # 2400 sits right at the yellow ceiling for a 2000 budget (120%). + log = {"2026-01-01": [_entry(2400)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.YELLOW + + def test_just_over_yellow_ceiling_is_red(self) -> None: + log = {"2026-01-01": [_entry(2400.01)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.RED + + def test_way_over_budget_is_red(self) -> None: + log = {"2026-01-01": [_entry(5000)]} + assert _daystatus.day_status(log, "2026-01-01", 2000) == DayStatus.RED + + def test_day_with_only_tombstoned_entries_is_not_logged(self) -> None: + # A tombstoned entry is kept on disk but load_log() drops it, so the + # day is entirely absent from the filtered log rather than present + # with an empty list -- confirm that reads as NOT_LOGGED, not GREEN. + _state.log_meal("snack", Nutrition(300, 5, 40, 10, 100, "manual")) + _state.undo_last_today() + today = _state._today() + assert today not in _state.load_log() + assert ( + _daystatus.day_status(_state.load_log(), today, 2000) + == DayStatus.NOT_LOGGED + ) + + +class TestStatusMap: + def test_maps_every_present_day(self) -> None: + log = { + "2026-01-01": [_entry(1000)], + "2026-01-02": [_entry(5000)], + } + result = _daystatus.status_map(log, budget=2000) + assert result == { + "2026-01-01": DayStatus.GREEN, + "2026-01-02": DayStatus.RED, + } + + def test_empty_log_is_empty_map(self) -> None: + assert _daystatus.status_map({}, budget=2000) == {} + + +class TestLoggingStreak: + def test_empty_map_is_zero(self) -> None: + assert _daystatus.logging_streak({}, today=date(2026, 1, 5)) == 0 + + def test_counts_consecutive_logged_days_including_today(self) -> None: + sm = { + "2026-01-03": DayStatus.GREEN, + "2026-01-04": DayStatus.RED, + "2026-01-05": DayStatus.YELLOW, + } + assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 3 + + def test_breaks_on_gap(self) -> None: + sm = { + "2026-01-01": DayStatus.GREEN, + "2026-01-03": DayStatus.GREEN, + "2026-01-04": DayStatus.GREEN, + } + assert _daystatus.logging_streak(sm, today=date(2026, 1, 4)) == 2 + + def test_today_not_logged_is_not_a_break(self) -> None: + sm = { + "2026-01-03": DayStatus.GREEN, + "2026-01-04": DayStatus.YELLOW, + } + # today (01-05) has no entry yet -- should not zero out the streak. + assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 2 + + def test_yesterday_not_logged_breaks_streak_even_if_today_is(self) -> None: + sm = { + "2026-01-03": DayStatus.GREEN, + "2026-01-05": DayStatus.GREEN, + } + assert _daystatus.logging_streak(sm, today=date(2026, 1, 5)) == 1 + + +class TestAdherenceStreak: + def test_counts_consecutive_green_and_yellow(self) -> None: + sm = { + "2026-01-03": DayStatus.GREEN, + "2026-01-04": DayStatus.YELLOW, + "2026-01-05": DayStatus.GREEN, + } + assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 3 + + def test_red_today_breaks_streak_immediately(self) -> None: + sm = { + "2026-01-04": DayStatus.GREEN, + "2026-01-05": DayStatus.RED, + } + assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 0 + + def test_not_logged_today_is_not_a_break(self) -> None: + sm = {"2026-01-04": DayStatus.GREEN} + assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 1 + + def test_red_in_history_breaks_streak(self) -> None: + sm = { + "2026-01-03": DayStatus.GREEN, + "2026-01-04": DayStatus.RED, + "2026-01-05": DayStatus.GREEN, + } + assert _daystatus.adherence_streak(sm, today=date(2026, 1, 5)) == 1 + + +class TestYearToDateTally: + def test_counts_logged_and_adherent_days_this_year_only(self) -> None: + sm = { + "2026-01-01": DayStatus.GREEN, + "2026-01-02": DayStatus.RED, + "2026-01-03": DayStatus.NOT_LOGGED, + "2025-12-31": DayStatus.GREEN, # previous year, excluded + } + tally = _daystatus.year_to_date_tally(sm, today=date(2026, 1, 3)) + assert tally.logged_days == 2 + assert tally.elapsed_days == 3 + assert tally.adherent_days == 1 + + def test_future_day_in_map_excluded(self) -> None: + sm = { + "2026-01-01": DayStatus.GREEN, + "2026-06-01": DayStatus.GREEN, + } + tally = _daystatus.year_to_date_tally(sm, today=date(2026, 1, 1)) + assert tally.logged_days == 1 + assert tally.elapsed_days == 1 + assert tally.adherent_days == 1 + + def test_empty_map(self) -> None: + tally = _daystatus.year_to_date_tally({}, today=date(2026, 3, 1)) + assert tally.logged_days == 0 + assert tally.elapsed_days == 60 + assert tally.adherent_days == 0 + + +class TestCurrentStatusMap: + def test_reads_real_log_and_budget(self) -> None: + _budget.write_budget(2000) + _state.log_meal("lunch", Nutrition(500, 30, 40, 10, 100, "manual")) + result = _daystatus.current_status_map() + assert len(result) == 1 + assert next(iter(result.values())) == DayStatus.GREEN diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index 128daf5..b4f1ad8 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -20,7 +20,7 @@ from diet_guard import ( _gatelock_support, _gatelock_ui, ) -from diet_guard._budget import seal_budget +from diet_guard._budget import write_budget from diet_guard._gatelock import ( MealGate, _pending_slots, @@ -206,7 +206,7 @@ class TestBasisAndAmount: def test_projection_else_without_item(self, gate: MealGate) -> None: """With a budget but no priced item, no after-this-item is shown.""" - seal_budget(2000) + write_budget(2000) gate._refresh_projection() text = gate._vars.projection.get() assert "left" in text diff --git a/diet_guard/tests/test_gatelock_calendar.py b/diet_guard/tests/test_gatelock_calendar.py new file mode 100644 index 0000000..d3b4a0e --- /dev/null +++ b/diet_guard/tests/test_gatelock_calendar.py @@ -0,0 +1,208 @@ +"""Tests for the History tab (_GateCalendar): notebook wiring, no-bypass +regression, budget-degrade, month navigation, and budget editing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from diet_guard import _budget +from diet_guard._budget import budget_weight, daily_budget, write_budget +from diet_guard._state import log_meal +from diet_guard.tests.conftest import _nutrition + +if TYPE_CHECKING: + from diet_guard._gatelock import MealGate + + +class TestNotebookWiring: + """The gate builds a two-tab notebook without breaking construction.""" + + def test_builds_a_notebook_with_both_tabs(self, gate: MealGate) -> None: + labels = [text for _child, text in gate._notebook.tabs] + assert labels == ["Log Meal", "History"] + + def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None: + assert 1 <= gate._cal_month <= 12 + assert gate._cal_year >= 2024 + + +class TestNoBypassRegression: + """Switching to the History tab must never affect the lock's dismissal.""" + + def test_switching_to_the_history_tab_does_not_touch_pending_or_close( + self, + gate: MealGate, + ) -> None: + pending_before = list(gate._pending) + history_frame = gate._cal_widgets.frame + with patch.object(gate, "close") as mock_close: + gate._notebook.select(history_frame) + assert gate._notebook.select() == 1 + assert gate._pending == pending_before + mock_close.assert_not_called() + + def test_switching_back_to_the_log_meal_tab_is_also_a_no_op( + self, + gate: MealGate, + ) -> None: + pending_before = list(gate._pending) + with patch.object(gate, "close") as mock_close: + gate._notebook.select(gate._cal_widgets.frame) + gate._notebook.select(gate._widgets.frame) + assert gate._notebook.select() == 0 + assert gate._pending == pending_before + mock_close.assert_not_called() + + +class TestBudgetDefaultAndDegradation: + """No budget ever set defaults to 2200; a corrupt file still degrades.""" + + def test_no_budget_set_computes_real_streaks_against_the_default( + self, + gate: MealGate, + ) -> None: + gate._refresh_calendar() + assert "Logging streak" in gate._cal_vars.streaks.get() + assert "This year" in gate._cal_vars.ytd.get() + + def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None: + gate._refresh_calendar() + assert gate._cal_widgets.budget_entry.get() == "2200" + + def test_entry_starts_read_only(self, gate: MealGate) -> None: + assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" + + def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None: + assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" + + def test_no_budget_set_still_renders_every_grid_cell( + self, + gate: MealGate, + ) -> None: + gate._refresh_calendar() + for cell in gate._cal_widgets.day_cells: + assert "bg" in cell.configured + + def test_a_real_budget_set_shows_in_the_entry(self, gate: MealGate) -> None: + write_budget(1800) + log_meal("oatmeal", _nutrition(kcal=300), None) + gate._refresh_calendar() + assert gate._cal_widgets.budget_entry.get() == "1800" + assert "Logging streak" in gate._cal_vars.streaks.get() + + def test_a_corrupt_budget_file_degrades_with_an_error_message( + self, + gate: MealGate, + ) -> None: + _budget.BUDGET_FILE.write_text("not json") + gate._refresh_calendar() + assert "corrupt" in gate._cal_vars.streaks.get() + assert gate._cal_vars.ytd.get() == "" + + +class TestMonthNavigation: + """Prev/next steps the displayed month, wrapping the year correctly.""" + + def test_prev_month_steps_back_within_the_year(self, gate: MealGate) -> None: + gate._cal_year, gate._cal_month = 2026, 7 + gate._on_prev_month() + assert (gate._cal_year, gate._cal_month) == (2026, 6) + + def test_prev_month_wraps_the_year_at_january(self, gate: MealGate) -> None: + gate._cal_year, gate._cal_month = 2026, 1 + gate._on_prev_month() + assert (gate._cal_year, gate._cal_month) == (2025, 12) + + def test_next_month_steps_forward_within_the_year(self, gate: MealGate) -> None: + gate._cal_year, gate._cal_month = 2026, 6 + gate._on_next_month() + assert (gate._cal_year, gate._cal_month) == (2026, 7) + + def test_next_month_wraps_the_year_at_december(self, gate: MealGate) -> None: + gate._cal_year, gate._cal_month = 2026, 12 + gate._on_next_month() + assert (gate._cal_year, gate._cal_month) == (2027, 1) + + +class TestBudgetEditToggle: + """Edit/Save toggle: first click unlocks, second click validates+saves.""" + + def _type(self, gate: MealGate, value: str) -> None: + """Replace the budget entry's text (as if the user retyped it).""" + gate._cal_widgets.budget_entry.delete(0, "end") + gate._cal_widgets.budget_entry.insert(0, value) + + def test_first_click_unlocks_editing_without_persisting( + self, + gate: MealGate, + ) -> None: + gate._on_edit_or_save_budget() + assert gate._cal_editing_budget is True + assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" + assert gate._cal_widgets.budget_entry.configured.get("state") == "normal" + + def test_second_click_with_non_numeric_input_stays_in_edit_mode( + self, + gate: MealGate, + ) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "not a number") + gate._on_edit_or_save_budget() + assert gate._cal_editing_budget is True + assert "whole number" in gate._cal_vars.budget_status.get() + assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" + + def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode( + self, + gate: MealGate, + ) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "0") + gate._on_edit_or_save_budget() + assert gate._cal_editing_budget is True + assert "positive" in gate._cal_vars.budget_status.get() + + def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "-500") + gate._on_edit_or_save_budget() + assert "positive" in gate._cal_vars.budget_status.get() + + def test_second_click_with_a_valid_value_saves_and_exits_edit_mode( + self, + gate: MealGate, + ) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "1900") + gate._on_edit_or_save_budget() + assert gate._cal_editing_budget is False + assert gate._cal_vars.budget_status.get() == "Saved." + assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" + assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" + assert daily_budget() == 1900 + + def test_saving_preserves_a_previously_stored_weight( + self, + gate: MealGate, + ) -> None: + write_budget(2000, weight_kg=80) + gate._on_edit_or_save_budget() + self._type(gate, "2100") + gate._on_edit_or_save_budget() + assert budget_weight() == 80 + + def test_saving_refreshes_the_dashboard_headline(self, gate: MealGate) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "1700") + gate._on_edit_or_save_budget() + assert "1700" in gate._vars.cal_headline.get() + + def test_navigating_months_mid_edit_does_not_overwrite_the_typed_value( + self, + gate: MealGate, + ) -> None: + gate._on_edit_or_save_budget() + self._type(gate, "1234") + gate._on_prev_month() + assert gate._cal_widgets.budget_entry.get() == "1234" diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py index cf0cc55..79a3cc1 100644 --- a/diet_guard/tests/test_gatelock_mealflow.py +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING from unittest.mock import patch from diet_guard import _gatelock_mealflow -from diet_guard._budget import seal_budget +from diet_guard._budget import write_budget from diet_guard._meal import MealItem from diet_guard._state import log_meal from diet_guard.tests.conftest import _nutrition @@ -235,7 +235,7 @@ class TestDashboard: def test_headline_with_budget(self, gate: MealGate) -> None: """A sealed budget shows consumed/target/remaining.""" - seal_budget(2000) + write_budget(2000) gate._refresh_dashboard() assert "left" in gate._vars.cal_headline.get() @@ -246,7 +246,7 @@ class TestDashboard: def test_dashboard_lists_entries(self, gate: MealGate) -> None: """Logged entries appear in the detail panel.""" - seal_budget(2000, weight_kg=80) + write_budget(2000, weight_kg=80) log_meal("apple", _nutrition(95, 100), 8) gate._refresh_dashboard() text = gate._vars.dashboard.get() @@ -272,7 +272,7 @@ class TestDashboard: def test_projection_with_budget(self, gate: MealGate) -> None: """The projection shows the after-this-item remaining when priced.""" - seal_budget(2000) + write_budget(2000) gate._widgets.macros.kcal.insert(0, "300") gate._refresh_projection() assert "after this item" in gate._vars.projection.get() diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index dd953b8..2d514aa 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -13,7 +13,7 @@ from unittest.mock import patch import pytest from diet_guard import _state -from diet_guard._budget import BudgetNotInitializedError, seal_budget +from diet_guard._budget import BudgetNotInitializedError, write_budget from diet_guard._estimator import Nutrition from diet_guard._state import ( consumption_band, @@ -205,25 +205,25 @@ class TestBudgetViews: def test_remaining_value(self) -> None: """Remaining is budget minus today's total.""" - seal_budget(2000) + write_budget(2000) log_meal("lunch", _nut(500), slot=12) assert remaining_budget() == 1500.0 def test_band_on_track(self) -> None: """Well under the warn fraction is 'on track'.""" - seal_budget(2000) + write_budget(2000) log_meal("a", _nut(500), slot=8) assert consumption_band() == "on track" def test_band_approaching(self) -> None: """At or above the warn fraction but under budget is 'approaching limit'.""" - seal_budget(2000) + write_budget(2000) log_meal("a", _nut(1700), slot=8) assert consumption_band() == "approaching limit" def test_band_over(self) -> None: """At or above budget is 'OVER BUDGET'.""" - seal_budget(2000) + write_budget(2000) log_meal("a", _nut(2100), slot=8) assert consumption_band() == "OVER BUDGET" diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index 45ecc7b..5e8dc5e 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -13,9 +13,20 @@ from unittest.mock import MagicMock, patch import pytest from diet_guard import _sync +from diet_guard._budget import daily_budget, write_budget from diet_guard._estimator import Nutrition from diet_guard._foodbank import lookup_food from diet_guard._state import load_log, log_meal +from diet_guard._sync_merge import budget_to_log + + +def _remote_budget_json(*, kcal: int, t: str, weight_kg: float | None = None) -> str: + """Build the wire text a remote device would push for a given budget edit.""" + record: dict[str, object] = {"v": 2, "b": kcal, "t": t} + if weight_kg is not None: + record["w"] = weight_kg + log = budget_to_log(record) + return json.dumps({rid: rec.to_dict() for rid, rec in log.items()}, indent=2) def _nutrition(kcal: float = 200.0) -> Nutrition: @@ -96,9 +107,13 @@ class TestRunSync: ) with patch.object(_sync, "GitHubSyncClient", return_value=client): _sync.run_sync() - client.get_file_text.assert_called_once_with( + # Both the food-log and budget pulls skip "pc" (this device) and + # only ever read "phone"'s files -- never "pc"'s. + requested_paths = [call.args[0] for call in client.get_file_text.call_args_list] + assert requested_paths == [ "diet-guard-sync/devices/phone/food_log.json", - ) + "diet-guard-sync/devices/phone/budget.json", + ] def test_skips_a_device_with_no_pushed_file_yet(self) -> None: _write_token() @@ -204,6 +219,83 @@ class TestRunSync: assert lookup_food("oatmeal") is not None +class TestSyncBudget: + """The daily budget's last-writer-wins sync, folded into run_sync().""" + + def test_pushes_local_budget_when_no_other_devices_have_synced(self) -> None: + _write_token() + write_budget(2000) + client = _mock_client(devices=()) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list} + assert "diet-guard-sync/devices/pc/budget.json" in pushed_paths + + def test_nothing_pushed_when_no_budget_ever_set(self) -> None: + """An uninitialized device contributes nothing -- no push, no crash.""" + _write_token() + client = _mock_client(devices=()) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list} + assert "diet-guard-sync/devices/pc/budget.json" not in pushed_paths + + def test_remote_only_budget_is_adopted_locally(self) -> None: + """Only the phone has ever set a budget -- the PC adopts it.""" + _write_token() + remote_json = _remote_budget_json(kcal=1800, t="2026-01-01T09:00:00") + client = _mock_client( + devices=("phone",), + files={"diet-guard-sync/devices/phone/budget.json": remote_json}, + ) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + assert daily_budget() == 1800 + + def test_local_edit_later_than_remote_wins(self) -> None: + """A fresh local write beats a much older remote edit.""" + _write_token() + write_budget(1500) # stamped with "now" + remote_json = _remote_budget_json(kcal=1800, t="2020-01-01T09:00:00") + client = _mock_client( + devices=("phone",), + files={"diet-guard-sync/devices/phone/budget.json": remote_json}, + ) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + assert daily_budget() == 1500 + + def test_remote_edit_later_than_local_wins(self) -> None: + """A remote edit far in the future beats a stale local write. + + Confirms this is genuinely edit-time (not sync-time or push-order) + LWW: whichever side has the later ``t`` wins regardless of which + device happens to run its sync tick first. + """ + _write_token() + write_budget(1500) # stamped with "now" + remote_json = _remote_budget_json(kcal=1800, t="2999-01-01T09:00:00") + client = _mock_client( + devices=("phone",), + files={"diet-guard-sync/devices/phone/budget.json": remote_json}, + ) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + assert daily_budget() == 1800 + + def test_malformed_remote_budget_is_skipped(self) -> None: + """A corrupt remote budget.json is skipped, not a crash.""" + _write_token() + write_budget(2000) + client = _mock_client( + devices=("phone",), + files={"diet-guard-sync/devices/phone/budget.json": "{not valid json"}, + ) + with patch.object(_sync, "GitHubSyncClient", return_value=client): + _sync.run_sync() + assert daily_budget() == 2000 + + class TestPullSharedLog: """The fail-closed wrapper the gate and the lock-screen button share.""" diff --git a/diet_guard/tests/test_sync_merge.py b/diet_guard/tests/test_sync_merge.py index d268c33..a1e34e5 100644 --- a/diet_guard/tests/test_sync_merge.py +++ b/diet_guard/tests/test_sync_merge.py @@ -11,15 +11,19 @@ from __future__ import annotations import json -from crdt_sync import merge_logs +from crdt_sync import Record, merge_logs import pytest from diet_guard._sync_merge import ( + _budget_hlc, _entry_hlc, _legacy_entry_id, + budget_to_log, daylog_to_log, entry_to_record, + log_to_budget, log_to_daylog, + parse_remote_budget, parse_remote_log, record_to_entry, ) @@ -210,5 +214,81 @@ class TestParseRemoteLog: parse_remote_log("not json{{{") +def _budget_record(**overrides: object) -> dict[str, object]: + """Build a minimal valid raw budget record, overriding what a test needs.""" + record: dict[str, object] = { + "v": 2, + "b": 2000, + "t": "2026-06-22T08:00:00+02:00", + } + record.update(overrides) + return record + + +class TestBudgetHlc: + def test_same_record_always_yields_the_same_hlc(self) -> None: + record = _budget_record() + assert _budget_hlc(record) == _budget_hlc(dict(record)) + + def test_malformed_t_still_yields_a_valid_hlc(self) -> None: + record = _budget_record(t="not-a-timestamp") + assert _budget_hlc(record).wall_time_ms == 0 + + def test_a_later_t_yields_a_greater_hlc(self) -> None: + earlier = _budget_record(t="2020-01-01T00:00:00+00:00") + later = _budget_record(t="2030-01-01T00:00:00+00:00") + assert _budget_hlc(later) > _budget_hlc(earlier) + + +class TestBudgetLogRoundTrip: + def test_none_record_yields_an_empty_log(self) -> None: + assert budget_to_log(None) == {} + + def test_round_trip_preserves_the_budget_and_weight(self) -> None: + record = _budget_record(w=80.0) + round_tripped = log_to_budget(budget_to_log(record)) + assert round_tripped is not None + assert round_tripped["b"] == 2000 + assert round_tripped["w"] == 80.0 + + def test_round_tripped_t_reflects_the_winning_hlc(self) -> None: + record = _budget_record(t="2026-06-22T08:00:00+02:00") + round_tripped = log_to_budget(budget_to_log(record)) + assert round_tripped is not None + assert round_tripped["t"] != "" + + def test_empty_log_has_no_budget(self) -> None: + assert log_to_budget({}) is None + + def test_record_with_no_value_field_has_no_hlc_in_result(self) -> None: + """A record present but missing the "value" field is a defensive + edge case (should not occur from budget_to_log itself) -- the + result still comes back without crashing, and with no ``t``. + """ + log = {"budget": Record(id="budget", fields={})} + round_tripped = log_to_budget(log) + assert round_tripped == {} + + +class TestParseRemoteBudget: + def test_parses_pushed_budget_wire_content(self) -> None: + record = _budget_record() + pushed = budget_to_log(record) + wire = json.dumps({rid: rec.to_dict() for rid, rec in pushed.items()}) + log = parse_remote_budget(wire) + assert log["budget"].id == "budget" + + def test_empty_object_parses_as_empty_log(self) -> None: + assert parse_remote_budget("{}") == {} + + def test_non_object_top_level_raises_type_error(self) -> None: + with pytest.raises(TypeError): + parse_remote_budget("[1, 2, 3]") + + def test_invalid_json_raises(self) -> None: + with pytest.raises(json.JSONDecodeError): + parse_remote_budget("not json{{{") + + def _dumps(data: object) -> str: return json.dumps(data) diff --git a/install.sh b/install.sh index a5ad2b0..80f6c9f 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================================ -# Diet Guard installer: hidden budget + log-to-unlock gate. +# Diet Guard installer: log-to-unlock gate with a freely-editable budget. # # Usage: bash install.sh # @@ -14,8 +14,8 @@ # 4. Installs + enables the systemd user timer that syncs the log every ~15m # (the sync itself stays unconfigured -- and a no-op -- until you create # a sync token; see the reminder this step prints) -# 5. Seals your daily budget from biometrics (only if not already sealed) -# 6. Locks the budget file immutable with `chattr +i` (the real tamper gate) +# 5. Sets your initial daily budget from biometrics (only if not already set; +# it stays freely editable afterward from the gate or the phone app) # ============================================================================ set -euo pipefail @@ -36,7 +36,7 @@ readonly SYNC_TOKEN_FILE="$HOME/.config/diet_guard/sync_token" echo "=== Diet Guard Installer ===" # 1. System dependencies ------------------------------------------------------ -echo "[1/6] Checking system dependencies..." +echo "[1/5] Checking system dependencies..." if ! command -v setxkbmap &>/dev/null; then echo " Installing xorg-setxkbmap (gate disables VT switching while locked)..." sudo pacman -S --noconfirm xorg-setxkbmap @@ -45,14 +45,14 @@ else fi # 2. Install this package + its dependencies into system Python ------------- -echo "[2/6] Installing diet_guard + dependencies for /usr/bin/python..." +echo "[2/5] Installing diet_guard + dependencies for /usr/bin/python..." /usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR" echo " Installed. Verifying import..." /usr/bin/python3 -c "import diet_guard; import gatelock" \ && echo " diet_guard and gatelock import cleanly from the system interpreter." # 3. systemd user timer + service (gate) ------------------------------------- -echo "[3/6] Installing the gate's systemd user timer + service..." +echo "[3/5] Installing the gate's systemd user timer + service..." mkdir -p "$SYSTEMD_USER_DIR" cp "$SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.service" cp "$TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-gate.timer" @@ -61,7 +61,7 @@ systemctl --user enable --now diet-guard-gate.timer echo " Timer enabled and started (fires the gate every ~30 min)." # 4. systemd user timer + service (sync) ------------------------------------- -echo "[4/6] Installing the sync's systemd user timer + service..." +echo "[4/5] Installing the sync's systemd user timer + service..." cp "$SYNC_SERVICE_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.service" cp "$SYNC_TIMER_SRC" "$SYSTEMD_USER_DIR/diet-guard-sync.timer" systemctl --user daemon-reload @@ -76,25 +76,18 @@ else echo " mode 600: chmod 600 \"$SYNC_TOKEN_FILE\"" fi -# 5. Seal the daily budget (hidden) ------------------------------------------ -echo "[5/6] Sealing your daily budget..." +# 5. Set the initial daily budget --------------------------------------------- +echo "[5/5] Setting your initial daily budget..." if [[ -e "$BUDGET_FILE" ]]; then - echo " Budget already sealed at $BUDGET_FILE - skipping init." + echo " Budget already set at $BUDGET_FILE - skipping init." + echo " Edit it any time from the gate's calendar tab or the phone app." else - echo " Enter your biometrics (used once then discarded; the value is hidden):" + echo " Enter your biometrics (used once then discarded to compute a" + echo " starting value; you can change the number itself any time from" + echo " the gate's calendar tab or the phone app):" python -m diet_guard init fi -# 6. Lock the budget immutable (the real tamper friction) -------------------- -echo "[6/6] Locking the budget file (chattr +i)..." -read -r attrs _ <<<"$(lsattr -d "$BUDGET_FILE" 2>/dev/null || true)" -if [[ "$attrs" == *i* ]]; then - echo " Already immutable." -else - sudo chattr +i "$BUDGET_FILE" - echo " Locked. To change it later: sudo chattr -i '$BUDGET_FILE'; re-run init; re-lock." -fi - echo "=== Installation complete ===" echo "The gate checks every ~30 min (08:00-22:00) and locks until you log a meal" echo "once you have gone 5h without logging."