Add budget-adherence calendar, streaks, and freely-editable synced budget
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 21:31:45 +02:00
parent 09cf5b726a
commit 957769ff0e
49 changed files with 4025 additions and 441 deletions

View File

@ -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

View File

@ -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)';
}

View File

@ -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<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
final _budgetController = TextEditingController();
DateTime _month = DateTime.now();
Map<String, DayStatus> _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<void> _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<void>(
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<void> _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'),
),
],
);
}
}

View File

@ -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<FoodEntry> entries) {
final items = <_HistoryItem>[];
for (final day in days) {
final dayEntries = byDay[day]!;
final totalKcal = dayEntries.fold<double>(0, (s, e) => s + e.kcal);
final totalKcal = sumKcal(dayEntries);
final totalProtein = dayEntries.fold<double>(0, (s, e) => s + e.proteinG);
final totalCarbs = dayEntries.fold<double>(0, (s, e) => s + e.carbsG);
final totalFat = dayEntries.fold<double>(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<HistoryScreen> createState() => _HistoryScreenState();
@ -295,13 +303,14 @@ class HistoryScreen extends StatefulWidget {
class _HistoryScreenState extends State<HistoryScreen> {
List<FoodEntry>? _allEntries;
List<FoodEntry> _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());
}

View File

@ -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<LogMealScreen>
);
}
void _onOpenCalendar() {
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const CalendarScreen()),
),
);
}
void _onOpenFoodBank() {
unawaited(
Navigator.of(context).push<void>(
@ -290,6 +299,11 @@ class _LogMealScreenState extends State<LogMealScreen>
tooltip: 'History',
onPressed: _onOpenHistory,
),
IconButton(
icon: const Icon(Icons.calendar_month),
tooltip: 'Calendar',
onPressed: _onOpenCalendar,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Sync settings',

View File

@ -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<AppSettingsService> 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<void> 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<void> 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<void> applySyncedBudget(int goal, {DateTime? updatedAt}) =>
_persist(goal, updatedAt);
Future<void> _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(),
}),
);
}
}

View File

@ -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<bool> backgroundSyncPush({http.Client? httpClient}) async {
await LogStorageService.init();
await FoodBankService.init();
await AppSettingsService.init();
final SyncSettings settings;
try {
settings = await SyncSettings.load();

View File

@ -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<DayStatus> _loggedStatuses = {
DayStatus.green,
DayStatus.yellow,
DayStatus.red,
};
const Set<DayStatus> _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<FoodEntry> 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<FoodEntry> _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<String, DayStatus> 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<String, DayStatus> statusMap,
Set<DayStatus> 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<String, DayStatus> 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<String, DayStatus> 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<String, DayStatus> 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,
);
}

View File

@ -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<String, dynamic> 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<String, dynamic>? record) {
if (record == null) return {};
final hlc = budgetHlc(record);
final value = Map<String, dynamic>.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<String, dynamic>? 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<String, dynamic>.from(value.cast<String, dynamic>())
: <String, dynamic>{};
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<String, dynamic>();
return rawMap.map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged budget [Log] for push.
String encodeBudgetForPush(Log log) {
final encoded = <String, dynamic>{
for (final entry in log.entries) entry.key: entry.value.toJson(),
};
return jsonEncode(encoded);
}

View File

@ -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<DayLog> 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<void> _syncBudget(GitHubClient client) async {
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
final localRecord = updatedAt == null
? null
: <String, dynamic>{
'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<String, String> _imagePathsById(DayLog log) {
final result = <String, String>{};
for (final entries in log.values) {

View File

@ -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<String, DayStatus> 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,
);
}),
);
}),
],
),
);
}
}

View File

@ -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,
),
],
);
}
}

View File

@ -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,
]);
});
}

View File

@ -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<void> 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<void> pumpUntil(
WidgetTester tester,
bool Function() done, {
int maxTries = 200,
}) async {
for (var i = 0; i < maxTries && !done(); i++) {
await Future<void>.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<TextField>(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<TextField>(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<TextField>(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<HistoryScreen>(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);
});
}

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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()));

View File

@ -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);
},
);
});
}

View File

@ -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 {

View File

@ -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(<String, List<FoodEntry>>{}, '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 = <String, List<FoodEntry>>{};
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 = <String, List<FoodEntry>>{};
expect(statusMap(log, budget: 2000), <String, DayStatus>{});
});
});
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);
});
});
}

View File

@ -311,4 +311,76 @@ void main() {
expect(() => parseRemoteLog('not json{{{'), throwsFormatException);
});
});
group('budget adapters', () {
Map<String, dynamic> 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);
});
});
});
}

View File

@ -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 = <String, dynamic>{'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<Map<String, dynamic>> 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<String, Map<String, dynamic>> 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<String, dynamic>);
final body = jsonDecode(req.body) as Map<String, dynamic>;
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<String, dynamic>;
final record = decoded.values.single as Map<String, dynamic>;
@ -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);
},
);
});
}

View File

@ -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<Container>(
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'));
});
});
}

View File

@ -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,
);
});
});
}

View File

@ -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)

View File

@ -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"
)

View File

@ -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

View File

@ -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) -------------------------------------------

201
diet_guard/_daystatus.py Normal file
View File

@ -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())

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -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(),

View File

@ -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)

View File

@ -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(

View File

@ -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()}

View File

@ -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)

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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"

View File

@ -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."""

View File

@ -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)

View File

@ -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."