diff --git a/CLAUDE.md b/CLAUDE.md index 96d4079..a18d913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,10 @@ 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`). +budget-adherence calendar, logging/adherence streaks, a year-to-date +tally, and weekly/monthly average intake, all derived from the same +day-status classification (`_daystatus.py` / `day_status_service.dart`) and +the averages built on it (`_averages.py` / `average_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). Note that the @@ -193,6 +194,7 @@ Consequences worth knowing before touching `app/`: - Lint: `pre-commit run --all-files` - Test the lock manually (safe, closeable): `python -m diet_guard gate --demo` - Run one sync tick manually: `python -m diet_guard sync` +- Weekly/monthly averages: `python -m diet_guard averages` - Install for production: `bash install.sh` - App tests: `cd app && flutter test` - Run the desktop app from the repo: `cd app && bash run.sh` @@ -200,6 +202,34 @@ Consequences worth knowing before touching `app/`: `cd app && bash install_arch.sh` - Build the phone app: `cd app && flutter build apk --release` +## Averages (`_averages.py` / `average_service.dart`) + +Three rules are load-bearing; changing any of them silently changes what +"under budget" means, so re-read this before touching them: + +- **Denominator is logged days, not elapsed days.** An unlogged day is not a + zero-kcal day. Averaging gaps in as zeros makes a badly-tracked week look + like the best week of the year, which is the exact failure the gate exists + to prevent. +- **The yardstick is the mean of the per-day budgets over those same logged + days**, resolved through the `BudgetSchedule` -- never `daily_budget()`. + Using today's number would retroactively reclassify past weeks on every + budget edit, the same bug `_budget_history.py` was written to fix. +- **Today is excluded.** Every period ends at `last_complete_day()`, i.e. + yesterday. A period ending "now" mixes finished days with one that is three + hours old, and a half-logged today drags the mean far enough to flip the + band. The visible cost is that "this week" is empty on a Monday; that is + correct, and it reports `no logged days yet` rather than a fake average. + +Band boundaries reuse `_daystatus.OVER_BUDGET_YELLOW_CEILING` / +`kOverBudgetYellowCeiling` **by import, not by copy** -- two literals would let +the averages line and the calendar disagree about what "over" means. + +The MCP `get_averages` tool returns `avg_kcal` and the band but deliberately +drops `avg_budget`, keeping the module's budget-secrecy invariant. The CLI, +the gate, and the phone all show both numbers, since the budget is hidden only +from the network, not from the user. + ## Do NOT - Don't relax the meal-slot logic without re-reading `docs/design.md` — the diff --git a/README.md b/README.md index c79f3e7..0e743b0 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,32 @@ budget, and locks the budget file immutable. ```bash python -m diet_guard init # one-time: compute and seal today's budget python -m diet_guard gate --demo # test the lock window (safe, closeable) +python -m diet_guard averages # weekly/monthly average kcal vs budget ``` +### Averages + +`averages` prints the mean kcal/day for this week, last week, this month and +last month, each labelled **under** / **slightly over** / **very over**. + +Three rules make the number honest, and all three are mirrored in the phone +app and the gate's History tab: + +* the mean is over **logged days only** -- a day you forgot to log is not a + zero-calorie day, and averaging it in as one would make every gap in the log + read as "well under budget"; +* the yardstick is the **mean of the budgets that applied on those same days**, + not today's budget, so editing the budget never retroactively reclassifies a + past week (same reason the calendar uses `.budget_history`); +* **today is excluded** -- every period ends at yesterday, because a + half-logged today drags the mean far enough to flip the band. On a Monday + "this week" therefore reports no logged days yet rather than a flattering + number. + +The band boundaries are the calendar's own (at or under budget / up to 20% +over / more than 20% over), so a period and its day cells can never disagree +about what "over" means. + The timer runs the gate automatically every ~30 minutes; no manual invocation is needed once installed. @@ -40,6 +64,9 @@ Tools: sealed), plus due / logged / current meal slots. - `list_today` — today's logged meals (description, kcal, macros, source, slot). - `get_slots` — the day's fixed meal slots and the current one. +- `get_averages` — average kcal/day for this/last week and this/last month, + each with its band (`under` / `slightly_over` / `very_over`). Returns the + intake and the band only — never the period's average *budget*. - `log_meal(description, grams=, kcal=, protein=, carbs=, fat=, slot=, confirm=False)` — **gated write**: with `confirm=False` it previews the resolved nutrition and target slot and mutates nothing; only `confirm=True` diff --git a/app/lib/models/period_average.dart b/app/lib/models/period_average.dart new file mode 100644 index 0000000..22fa2d2 --- /dev/null +++ b/app/lib/models/period_average.dart @@ -0,0 +1,104 @@ +/// Value types for a period's average intake and its budget band. +library; + +import 'package:flutter/foundation.dart'; + +/// How a period's average intake compared with its average budget. +/// +/// Kept in sync with `diet_guard/_averages.py`'s `AverageBand`. +enum AverageBand { + /// Average intake at or below the average budget. + under, + + /// Above budget, but no more than 20% above. + slightlyOver, + + /// More than 20% above budget. + veryOver, +} + +/// The human phrase for [band], or `'no data'` when it is null. +/// +/// Byte-identical to `_averages.band_label` so the phone and the PC gate never +/// describe the same week with different words. +String bandLabel(AverageBand? band) => switch (band) { + null => 'no data', + AverageBand.under => 'under', + AverageBand.slightlyOver => 'slightly over', + AverageBand.veryOver => 'very over', +}; + +/// One period's mean intake and how it compared with the mean budget. +@immutable +class PeriodAverage { + /// Creates a [PeriodAverage]. + const PeriodAverage({ + required this.start, + required this.end, + required this.loggedDays, + required this.elapsedDays, + required this.avgKcal, + required this.avgBudget, + required this.band, + }); + + /// An entirely empty period, for a screen that has not loaded yet. + static const empty = PeriodAverage( + start: '', + end: '', + loggedDays: 0, + elapsedDays: 0, + avgKcal: null, + avgBudget: null, + band: null, + ); + + /// `YYYY-MM-DD`, the period's first day (inclusive). + final String start; + + /// `YYYY-MM-DD`, the period's last *complete* day (inclusive); earlier than + /// [start] when the period has none yet. + final String end; + + /// Days in the range carrying at least one valid, non-tombstoned entry. + final int loggedDays; + + /// Complete days in the range, logged or not. + final int elapsedDays; + + /// Mean kcal across [loggedDays], or null when that is zero. + final double? avgKcal; + + /// Mean daily budget across those same days, or null. + final double? avgBudget; + + /// The band, or null when there is nothing to judge. + final AverageBand? band; + + @override + bool operator ==(Object other) => + other is PeriodAverage && + other.start == start && + other.end == end && + other.loggedDays == loggedDays && + other.elapsedDays == elapsedDays && + other.avgKcal == avgKcal && + other.avgBudget == avgBudget && + other.band == band; + + @override + int get hashCode => Object.hash( + start, + end, + loggedDays, + elapsedDays, + avgKcal, + avgBudget, + band, + ); + + @override + String toString() => + 'PeriodAverage($start..$end, $avgKcal kcal over $loggedDays/' + '$elapsedDays days, ${bandLabel(band)})'; +} diff --git a/app/lib/screens/calendar_screen.dart b/app/lib/screens/calendar_screen.dart index 2eb3c33..e3e4b88 100644 --- a/app/lib/screens/calendar_screen.dart +++ b/app/lib/screens/calendar_screen.dart @@ -10,11 +10,14 @@ library; import 'dart:async'; import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/models/period_average.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/average_service.dart'; import 'package:diet_guard_app/services/budget_history_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/average_summary_row.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'; @@ -40,6 +43,8 @@ class _CalendarScreenState extends State { elapsedDays: 0, adherentDays: 0, ); + PeriodAverage _weekAverage = PeriodAverage.empty; + PeriodAverage _monthAverage = PeriodAverage.empty; bool _loading = true; bool _editingBudget = false; String? _budgetStatus; @@ -62,21 +67,23 @@ class _CalendarScreenState extends State { /// their unsaved text is left untouched. /// /// Each past day is judged against the budget that applied *then*, so - /// changing the budget today leaves the grid, the streaks, and the tally - /// for earlier days alone. + /// changing the budget today leaves the grid, the streaks, the tally, and + /// the averages for earlier days alone. Future _load() async { final log = await LogStorageService.instance.readLog(); if (!mounted) return; final budget = AppSettingsService.dailyKcalGoal; - final statusByDate = statusMap( - log, - schedule: BudgetHistoryService.schedule, - ); + final schedule = BudgetHistoryService.schedule; + final statusByDate = statusMap(log, schedule: schedule); + final week = weeklyAverage(log, schedule: schedule); + final month = monthlyAverage(log, schedule: schedule); setState(() { _statusByDate = statusByDate; _loggingStreak = loggingStreak(statusByDate); _adherenceStreak = adherenceStreak(statusByDate); _tally = yearToDateTally(statusByDate); + _weekAverage = week; + _monthAverage = month; _loading = false; if (!_editingBudget) { _budgetController.text = budget.toString(); @@ -185,6 +192,8 @@ class _CalendarScreenState extends State { adherenceStreak: _adherenceStreak, tally: _tally, ), + const SizedBox(height: 4), + AverageSummaryRow(week: _weekAverage, month: _monthAverage), ], ), ), diff --git a/app/lib/services/average_service.dart b/app/lib/services/average_service.dart new file mode 100644 index 0000000..dbc224e --- /dev/null +++ b/app/lib/services/average_service.dart @@ -0,0 +1,175 @@ +/// Weekly and monthly average intake, and how that average met the budget. +/// +/// PERIOD AVERAGE SPEC (keep in sync with `diet_guard/_averages.py`): +/// +/// * the average is over **logged days only** -- `sum(kcal) / loggedDays`. A +/// day you forgot to log is not a zero-calorie day, and counting it as one +/// would make every gap in the log read as "well under budget", which is the +/// exact opposite of the truth. `loggedDays`/`elapsedDays` travel with the +/// average so a caller can show how much of the period it covers. +/// * the yardstick is the **mean of the per-day budgets over those same logged +/// days**, resolved through a [BudgetSchedule] -- never today's budget. +/// Comparing a past week against a budget lowered yesterday would +/// retroactively reclassify it, which is what `budget_schedule.dart` exists +/// to prevent. +/// * bands reuse the calendar's own over-budget boundary +/// ([kOverBudgetYellowCeiling]), so a period and its days can never disagree +/// about what "over" means: +/// +/// - `under`: `avgKcal <= avgBudget` +/// - `slightlyOver`: `avgBudget < avgKcal <= avgBudget * ceiling` +/// - `veryOver`: `avgKcal > avgBudget * ceiling` +/// +/// * **today is excluded.** A period ending at "now" mixes complete days with +/// one that is three hours old, and a half-logged today drags the mean far +/// enough to flip the band. Every period ends at [lastCompleteDay] -- +/// yesterday -- so "this week" means "this week so far, in finished days". A +/// period with no finished days yet (Monday, or the 1st) reports +/// `elapsedDays == 0` and a null average rather than a flattering fake one. +/// * weeks are **ISO weeks, Monday through Sunday**; months are calendar +/// months. +/// +/// Every function here is a pure function of its explicit `log`/`schedule`/ +/// `today` arguments and never reaches into on-disk state. +library; + +import 'package:diet_guard_app/models/period_average.dart'; +import 'package:diet_guard_app/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/day_status_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; + +/// Classifies [avgKcal] against [avgBudget]. +AverageBand bandFor(double avgKcal, double avgBudget) { + if (avgKcal <= avgBudget) return AverageBand.under; + if (avgKcal <= avgBudget * kOverBudgetYellowCeiling) { + return AverageBand.slightlyOver; + } + return AverageBand.veryOver; +} + +/// Returns the last day whose log is finished: the day before [today]. +DateTime lastCompleteDay(DateTime today) => + _dateOnly(today).subtract(const Duration(days: 1)); + +/// Returns the Monday and Sunday of the ISO week containing [day]. +(DateTime, DateTime) weekBounds(DateTime day) { + final d = _dateOnly(day); + final monday = d.subtract(Duration(days: d.weekday - DateTime.monday)); + return (monday, monday.add(const Duration(days: 6))); +} + +/// Returns the first and last dates of [day]'s calendar month. +(DateTime, DateTime) monthBounds(DateTime day) => ( + DateTime(day.year, day.month), + DateTime(day.year, day.month + 1).subtract(const Duration(days: 1)), +); + +/// Returns the first of the month [months] before [day]'s month. +DateTime shiftMonths(DateTime day, int months) => + DateTime(day.year, day.month - months); + +/// Averages [log]'s intake over `[start, end]` and classifies it. +/// +/// An [end] before [start] is an empty period, not an error. +PeriodAverage periodAverage( + DayLog log, { + required BudgetSchedule schedule, + required DateTime start, + required DateTime end, +}) { + final from = _dateOnly(start); + final to = _dateOnly(end); + var totalKcal = 0.0; + var totalBudget = 0; + var loggedDays = 0; + var elapsedDays = 0; + for ( + var day = from; + !day.isAfter(to); + day = day.add(const Duration(days: 1)) + ) { + elapsedDays++; + final key = dateKey(day); + final entries = (log[key] ?? const []).where((e) => !e.deleted); + if (entries.isEmpty) continue; + loggedDays++; + totalKcal += sumKcal(entries); + totalBudget += schedule.forDay(key); + } + if (loggedDays == 0) { + return PeriodAverage( + start: dateKey(from), + end: dateKey(to), + loggedDays: 0, + elapsedDays: elapsedDays, + avgKcal: null, + avgBudget: null, + band: null, + ); + } + final avgKcal = totalKcal / loggedDays; + final avgBudget = totalBudget / loggedDays; + return PeriodAverage( + start: dateKey(from), + end: dateKey(to), + loggedDays: loggedDays, + elapsedDays: elapsedDays, + avgKcal: avgKcal, + avgBudget: avgBudget, + band: bandFor(avgKcal, avgBudget), + ); +} + +/// Returns the average for an ISO week, [weeksAgo] weeks back. +/// +/// [weeksAgo] 0 is the current week through yesterday, 1 the previous complete +/// week, and so on. +PeriodAverage weeklyAverage( + DayLog log, { + required BudgetSchedule schedule, + int weeksAgo = 0, + DateTime? today, +}) { + final ref = _dateOnly(today ?? DateTime.now()); + final anchor = ref.subtract(Duration(days: DateTime.daysPerWeek * weeksAgo)); + return _capped(log, schedule, weekBounds(anchor), ref); +} + +/// Returns the average for a calendar month, [monthsAgo] months back. +PeriodAverage monthlyAverage( + DayLog log, { + required BudgetSchedule schedule, + int monthsAgo = 0, + DateTime? today, +}) { + final ref = _dateOnly(today ?? DateTime.now()); + return _capped(log, schedule, monthBounds(shiftMonths(ref, monthsAgo)), ref); +} + +/// Averages [bounds], truncated so it never includes [ref] itself. +/// +/// The single place the "today is excluded" rule is applied, so a caller +/// cannot construct a period that half-counts an unfinished day. +PeriodAverage _capped( + DayLog log, + BudgetSchedule schedule, + (DateTime, DateTime) bounds, + DateTime ref, +) { + final (start, end) = bounds; + final cap = lastCompleteDay(ref); + return periodAverage( + log, + schedule: schedule, + start: start, + end: end.isBefore(cap) ? end : cap, + ); +} + +DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + +/// Formats [d] as a `YYYY-MM-DD` log key. +String dateKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; diff --git a/app/lib/widgets/average_summary_row.dart b/app/lib/widgets/average_summary_row.dart new file mode 100644 index 0000000..a4f4b65 --- /dev/null +++ b/app/lib/widgets/average_summary_row.dart @@ -0,0 +1,43 @@ +/// Weekly/monthly average-intake summary row for the History screen. +library; + +import 'package:diet_guard_app/models/period_average.dart'; +import 'package:flutter/material.dart'; + +/// Displays this week's and this month's average kcal/day and their bands. +/// +/// Mirrors `_calendar_view.py`'s `averages_text`: "Avg/day (to yesterday): +/// week NNNN kcal (band) · month NNNN kcal (band)". +/// +/// Both periods stop at yesterday, so this line does not swing every time a +/// meal is logged today -- it answers "how has this week gone", not "how am I +/// doing right now", which is what the Today progress card is for. +class AverageSummaryRow extends StatelessWidget { + /// Creates an [AverageSummaryRow]. + const AverageSummaryRow({ + required this.week, + required this.month, + super.key, + }); + + /// The current ISO week's average, through yesterday. + final PeriodAverage week; + + /// The current calendar month's average, through yesterday. + final PeriodAverage month; + + static String _part(PeriodAverage period) { + final avg = period.avgKcal; + if (avg == null) return bandLabel(period.band); + return '${avg.round()} kcal (${bandLabel(period.band)})'; + } + + @override + Widget build(BuildContext context) { + return Text( + 'Avg/day (to yesterday): week ${_part(week)} · month ${_part(month)}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ); + } +} diff --git a/app/test/services/average_service_test.dart b/app/test/services/average_service_test.dart new file mode 100644 index 0000000..cd9c326 --- /dev/null +++ b/app/test/services/average_service_test.dart @@ -0,0 +1,234 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/period_average.dart'; +import 'package:diet_guard_app/services/average_service.dart'; +import 'package:diet_guard_app/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/log_storage_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, +); + +BudgetSchedule flat(int budget) => BudgetSchedule( + [BudgetEntry(effectiveFrom: kEpochDay, kcal: budget, editedAt: '')], + fallback: budget, +); + +DayLog logOf(Map kcalByDay) => { + for (final e in kcalByDay.entries) e.key: [buildEntry(kcal: e.value)], +}; + +void main() { + group('bandFor', () { + test('exactly at budget is under', () { + expect(bandFor(2000, 2000), AverageBand.under); + }); + test('one over is slightly over', () { + expect(bandFor(2001, 2000), AverageBand.slightlyOver); + }); + test('exactly at the 120% ceiling is slightly over', () { + expect(bandFor(2400, 2000), AverageBand.slightlyOver); + }); + test('just past the ceiling is very over', () { + expect(bandFor(2400.01, 2000), AverageBand.veryOver); + }); + }); + + group('bandLabel', () { + test('mirrors the Python labels exactly', () { + expect(bandLabel(null), 'no data'); + expect(bandLabel(AverageBand.under), 'under'); + expect(bandLabel(AverageBand.slightlyOver), 'slightly over'); + expect(bandLabel(AverageBand.veryOver), 'very over'); + }); + }); + + group('bounds', () { + test('week starts on Monday', () { + // 2026-01-07 is a Wednesday. + final (start, end) = weekBounds(DateTime(2026, 1, 7)); + expect(start, DateTime(2026, 1, 5)); + expect(end, DateTime(2026, 1, 11)); + }); + test('a Sunday looks back to its own Monday', () { + final (start, _) = weekBounds(DateTime(2026, 1, 11)); + expect(start, DateTime(2026, 1, 5)); + }); + test('month bounds cover a short February', () { + final (start, end) = monthBounds(DateTime(2026, 2, 14)); + expect(start, DateTime(2026, 2)); + expect(end, DateTime(2026, 2, 28)); + }); + test('month bounds cover a leap February', () { + final (_, end) = monthBounds(DateTime(2024, 2, 14)); + expect(end, DateTime(2024, 2, 29)); + }); + test('lastCompleteDay is yesterday', () { + expect(lastCompleteDay(DateTime(2026, 3)), DateTime(2026, 2, 28)); + }); + test('shiftMonths wraps the year', () { + expect(shiftMonths(DateTime(2026, 1, 20), 1), DateTime(2025, 12)); + }); + }); + + group('periodAverage', () { + test('averages over logged days only, not elapsed days', () { + final log = logOf({ + '2026-01-05': 2000, + '2026-01-06': 3000, + '2026-01-09': 4000, + }); + final result = periodAverage( + log, + schedule: flat(2500), + start: DateTime(2026, 1, 5), + end: DateTime(2026, 1, 9), + ); + expect(result.avgKcal, 3000); + expect(result.loggedDays, 3); + expect(result.elapsedDays, 5); + }); + + test('tombstoned entries do not make a day count as logged', () { + final log = >{ + '2026-01-05': [buildEntry(kcal: 9000, deleted: true)], + }; + final result = periodAverage( + log, + schedule: flat(2000), + start: DateTime(2026, 1, 5), + end: DateTime(2026, 1, 5), + ); + expect(result.loggedDays, 0); + expect(result.avgKcal, isNull); + }); + + test('an empty period has a null average, not zero', () { + final result = periodAverage( + const {}, + schedule: flat(2000), + start: DateTime(2026, 1, 5), + end: DateTime(2026, 1, 11), + ); + expect(result.avgKcal, isNull); + expect(result.band, isNull); + expect(result.elapsedDays, 7); + }); + + test('end before start is zero elapsed, not negative', () { + final result = periodAverage( + const {}, + schedule: flat(2000), + start: DateTime(2026, 1, 5), + end: DateTime(2026, 1, 4), + ); + expect(result.elapsedDays, 0); + }); + + test('the budget is averaged per logged day, not taken from today', () { + final schedule = BudgetSchedule([ + BudgetEntry(effectiveFrom: kEpochDay, kcal: 1000, editedAt: ''), + BudgetEntry(effectiveFrom: '2026-01-06', kcal: 3000, editedAt: ''), + ], fallback: 1000); + final log = logOf({'2026-01-05': 2000, '2026-01-06': 2000}); + final result = periodAverage( + log, + schedule: schedule, + start: DateTime(2026, 1, 5), + end: DateTime(2026, 1, 6), + ); + expect(result.avgBudget, 2000); + expect(result.band, AverageBand.under); + }); + }); + + group('weeklyAverage', () { + test('the current week stops at yesterday', () { + final log = logOf({ + '2026-01-05': 3000, + '2026-01-06': 3000, + '2026-01-07': 3000, + '2026-01-08': 100, + }); + final result = weeklyAverage( + log, + schedule: flat(2000), + today: DateTime(2026, 1, 8), + ); + expect(result.avgKcal, 3000); + expect(result.end, '2026-01-07'); + expect(result.band, AverageBand.veryOver); + }); + + test('Monday has no complete days this week', () { + final result = weeklyAverage( + logOf({'2026-01-05': 3000}), + schedule: flat(2000), + today: DateTime(2026, 1, 5), + ); + expect(result.elapsedDays, 0); + expect(result.avgKcal, isNull); + }); + + test('the previous week is whole', () { + final result = weeklyAverage( + logOf({'2026-01-05': 2100, '2026-01-11': 2100}), + schedule: flat(2000), + weeksAgo: 1, + today: DateTime(2026, 1, 14), + ); + expect(result.start, '2026-01-05'); + expect(result.end, '2026-01-11'); + expect(result.elapsedDays, 7); + expect(result.band, AverageBand.slightlyOver); + }); + }); + + group('monthlyAverage', () { + test('the current month stops at yesterday', () { + final log = logOf({ + '2026-03-01': 1000, + '2026-03-02': 1000, + '2026-03-04': 9000, + }); + final result = monthlyAverage( + log, + schedule: flat(2000), + today: DateTime(2026, 3, 4), + ); + expect(result.avgKcal, 1000); + expect(result.end, '2026-03-03'); + expect(result.band, AverageBand.under); + }); + + test('the first of the month has no complete days', () { + final result = monthlyAverage( + const {}, + schedule: flat(2000), + today: DateTime(2026, 3), + ); + expect(result.elapsedDays, 0); + }); + + test('the previous month is whole', () { + final result = monthlyAverage( + logOf({'2026-02-10': 2500}), + schedule: flat(2000), + monthsAgo: 1, + today: DateTime(2026, 3, 10), + ); + expect(result.start, '2026-02-01'); + expect(result.end, '2026-02-28'); + expect(result.elapsedDays, 28); + expect(result.band, AverageBand.veryOver); + }); + }); +} diff --git a/app/test/widgets/average_summary_row_test.dart b/app/test/widgets/average_summary_row_test.dart new file mode 100644 index 0000000..7705bb4 --- /dev/null +++ b/app/test/widgets/average_summary_row_test.dart @@ -0,0 +1,67 @@ +import 'package:diet_guard_app/models/period_average.dart'; +import 'package:diet_guard_app/widgets/average_summary_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +PeriodAverage period({double? avgKcal, AverageBand? band}) => PeriodAverage( + start: '2026-01-05', + end: '2026-01-11', + loggedDays: 7, + elapsedDays: 7, + avgKcal: avgKcal, + avgBudget: 2000, + band: band, +); + +Future pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +void main() { + testWidgets('renders both averages and their bands', (tester) async { + await pump( + tester, + AverageSummaryRow( + week: period(avgKcal: 2100, band: AverageBand.slightlyOver), + month: period(avgKcal: 1900, band: AverageBand.under), + ), + ); + expect( + find.text( + 'Avg/day (to yesterday): week 2100 kcal (slightly over) · ' + 'month 1900 kcal (under)', + ), + findsOneWidget, + ); + }); + + testWidgets('rounds the average to whole kcal', (tester) async { + await pump( + tester, + AverageSummaryRow( + week: period(avgKcal: 2100.6, band: AverageBand.slightlyOver), + month: period(avgKcal: 1900, band: AverageBand.under), + ), + ); + expect(find.textContaining('week 2101 kcal'), findsOneWidget); + }); + + testWidgets('an empty period reads "no data", never 0 kcal', (tester) async { + await pump( + tester, + AverageSummaryRow(week: period(), month: period()), + ); + expect(find.textContaining('0 kcal'), findsNothing); + expect(find.textContaining('week no data'), findsOneWidget); + }); + + testWidgets('the placeholder empty period renders safely', (tester) async { + await pump( + tester, + const AverageSummaryRow( + week: PeriodAverage.empty, + month: PeriodAverage.empty, + ), + ); + expect(find.textContaining('no data'), findsOneWidget); + }); +} diff --git a/diet_guard/_averages.py b/diet_guard/_averages.py new file mode 100644 index 0000000..ecd96e3 --- /dev/null +++ b/diet_guard/_averages.py @@ -0,0 +1,298 @@ +"""Weekly and monthly average intake, and how that average met the budget. + +PERIOD AVERAGE SPEC (keep in sync with +``app/lib/services/average_service.dart``): + +* the average is over **logged days only** -- ``sum(kcal) / logged_days``. A + day you forgot to log is not a zero-calorie day, and counting it as one + would make every gap in the log read as "well under budget", which is the + exact opposite of the truth. ``logged_days`` / ``elapsed_days`` travel with + the average so a caller can show how much of the period it actually covers. +* the yardstick is the **mean of the per-day budgets over those same logged + days**, resolved through a + :class:`~diet_guard._budget_history.BudgetSchedule` -- never today's budget. + Comparing a past week's intake against a budget you lowered yesterday would + retroactively reclassify it, which is precisely what + :mod:`diet_guard._budget_history` exists to prevent. +* bands, reusing the calendar's own over-budget boundary + (:data:`~diet_guard._daystatus.OVER_BUDGET_YELLOW_CEILING`) so a period and + its days can never disagree about what "over" means: + + - ``under``: ``avg_kcal <= avg_budget`` + - ``slightly_over``: ``avg_budget < avg_kcal <= avg_budget * ceiling`` + - ``very_over``: ``avg_kcal > avg_budget * ceiling`` + +* **today is excluded.** A period ending at "now" mixes complete days with + one that is three hours old, and a half-logged today drags the mean down far + enough to flip the band. Every period therefore ends at + :func:`last_complete_day` -- yesterday -- so "this week" means "this week so + far, in finished days". A period with no finished days yet (Monday, or the + 1st) reports ``elapsed_days == 0`` and a ``None`` average rather than a + flattering fake one. +* weeks are **ISO weeks, Monday through Sunday**; months are calendar months. + +Every function here is a pure function of its explicit ``log`` / ``schedule`` +/ ``today`` arguments and never reaches into on-disk state, so the band +boundaries and the period edges are testable with synthetic data. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from datetime import date, timedelta +import enum +from typing import TYPE_CHECKING + +from diet_guard._daystatus import OVER_BUDGET_YELLOW_CEILING, day_total_kcal +from diet_guard._state import now_local + +if TYPE_CHECKING: + from diet_guard._budget_history import BudgetSchedule + from diet_guard._state import DayLog + +_DAYS_PER_WEEK = 7 +_MONTHS_PER_YEAR = 12 + + +class AverageBand(enum.Enum): + """How a period's average intake compared with its average budget.""" + + UNDER = "under" + SLIGHTLY_OVER = "slightly_over" + VERY_OVER = "very_over" + + +_BAND_LABELS = { + AverageBand.UNDER: "under", + AverageBand.SLIGHTLY_OVER: "slightly over", + AverageBand.VERY_OVER: "very over", +} + +_NO_DATA_LABEL = "no data" + + +def band_label(band: AverageBand | None) -> str: + """Return the human phrase for ``band``, or ``"no data"`` for None.""" + return _NO_DATA_LABEL if band is None else _BAND_LABELS[band] + + +@dataclass(frozen=True) +class PeriodAverage: + """One period's mean intake and how it compared with the mean budget. + + Attributes: + start: ``YYYY-MM-DD``, the period's first day (inclusive). + end: ``YYYY-MM-DD``, the period's last *complete* day (inclusive); + earlier than ``start`` when the period has none yet. + logged_days: Days in the range that carry at least one valid entry. + elapsed_days: Complete days in the range, logged or not. + avg_kcal: Mean kcal across ``logged_days``, or None when that is zero. + avg_budget: Mean daily budget across those same days, or None. + band: The :class:`AverageBand`, or None when there is nothing to judge. + """ + + start: str + end: str + logged_days: int + elapsed_days: int + avg_kcal: float | None + avg_budget: float | None + band: AverageBand | None + + +def band_for(avg_kcal: float, avg_budget: float) -> AverageBand: + """Classify ``avg_kcal`` against ``avg_budget``. + + Args: + avg_kcal: The period's mean daily intake. + avg_budget: The period's mean daily budget. + + Returns: + The matching :class:`AverageBand`. + """ + if avg_kcal <= avg_budget: + return AverageBand.UNDER + if avg_kcal <= avg_budget * OVER_BUDGET_YELLOW_CEILING: + return AverageBand.SLIGHTLY_OVER + return AverageBand.VERY_OVER + + +def last_complete_day(today: date) -> date: + """Return the last day whose log is finished: the day before ``today``.""" + return today - timedelta(days=1) + + +def week_bounds(day: date) -> tuple[date, date]: + """Return the Monday and Sunday of the ISO week containing ``day``.""" + monday = day - timedelta(days=day.weekday()) + return monday, monday + timedelta(days=_DAYS_PER_WEEK - 1) + + +def month_bounds(day: date) -> tuple[date, date]: + """Return the first and last dates of ``day``'s calendar month.""" + last = calendar.monthrange(day.year, day.month)[1] + return day.replace(day=1), day.replace(day=last) + + +def _shift_months(day: date, months: int) -> date: + """Return the first of the month ``months`` before ``day``'s month.""" + index = day.year * _MONTHS_PER_YEAR + (day.month - 1) - months + year, month = divmod(index, _MONTHS_PER_YEAR) + return date(year, month + 1, 1) + + +def period_average( + log: DayLog, + *, + schedule: BudgetSchedule, + start: date, + end: date, +) -> PeriodAverage: + """Average ``log``'s intake over ``[start, end]`` and classify it. + + 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). + schedule: Resolves the budget that applied on each individual day. + start: First day of the period (inclusive). + end: Last day of the period (inclusive); an ``end`` before ``start`` + is an empty period, not an error. + + Returns: + The period's :class:`PeriodAverage`. + """ + totals: list[float] = [] + budgets: list[int] = [] + day = start + while day <= end: + key = day.isoformat() + if key in log: + totals.append(day_total_kcal(log, key)) + budgets.append(schedule.for_day(key)) + day += timedelta(days=1) + elapsed = max((end - start).days + 1, 0) + if not totals: + return PeriodAverage( + start=start.isoformat(), + end=end.isoformat(), + logged_days=0, + elapsed_days=elapsed, + avg_kcal=None, + avg_budget=None, + band=None, + ) + avg_kcal = sum(totals) / len(totals) + avg_budget = sum(budgets) / len(budgets) + return PeriodAverage( + start=start.isoformat(), + end=end.isoformat(), + logged_days=len(totals), + elapsed_days=elapsed, + avg_kcal=avg_kcal, + avg_budget=avg_budget, + band=band_for(avg_kcal, avg_budget), + ) + + +def _resolve_today(today: date | None) -> date: + """Return ``today`` or the real current local date when it is None.""" + return today if today is not None else now_local().date() + + +def _capped( + log: DayLog, + schedule: BudgetSchedule, + bounds: tuple[date, date], + ref: date, +) -> PeriodAverage: + """Average ``bounds``, truncated so it never includes ``ref`` itself. + + The single place the "today is excluded" rule is applied, so a caller + cannot construct a period that half-counts an unfinished day. + """ + start, end = bounds + return period_average( + log, + schedule=schedule, + start=start, + end=min(end, last_complete_day(ref)), + ) + + +def weekly_average( + log: DayLog, + *, + schedule: BudgetSchedule, + weeks_ago: int = 0, + today: date | None = None, +) -> PeriodAverage: + """Return the average for an ISO week, ``weeks_ago`` weeks back. + + Args: + log: A filtered log (see :func:`period_average`). + schedule: Resolves each day's budget. + weeks_ago: 0 for the current week (through yesterday), 1 for the + previous complete week, and so on. + today: The reference "today"; defaults to the real current date. + + Returns: + That week's :class:`PeriodAverage`. + """ + ref = _resolve_today(today) + anchor = ref - timedelta(weeks=weeks_ago) + return _capped(log, schedule, week_bounds(anchor), ref) + + +def monthly_average( + log: DayLog, + *, + schedule: BudgetSchedule, + months_ago: int = 0, + today: date | None = None, +) -> PeriodAverage: + """Return the average for a calendar month, ``months_ago`` months back. + + Args: + log: A filtered log (see :func:`period_average`). + schedule: Resolves each day's budget. + months_ago: 0 for the current month (through yesterday), 1 for the + previous complete month, and so on. + today: The reference "today"; defaults to the real current date. + + Returns: + That month's :class:`PeriodAverage`. + """ + ref = _resolve_today(today) + return _capped(log, schedule, month_bounds(_shift_months(ref, months_ago)), ref) + + +def recent_weeks( + log: DayLog, + *, + schedule: BudgetSchedule, + count: int, + today: date | None = None, +) -> tuple[PeriodAverage, ...]: + """Return the last ``count`` weekly averages, most recent first.""" + ref = _resolve_today(today) + return tuple( + weekly_average(log, schedule=schedule, weeks_ago=back, today=ref) + for back in range(count) + ) + + +def recent_months( + log: DayLog, + *, + schedule: BudgetSchedule, + count: int, + today: date | None = None, +) -> tuple[PeriodAverage, ...]: + """Return the last ``count`` monthly averages, most recent first.""" + ref = _resolve_today(today) + return tuple( + monthly_average(log, schedule=schedule, months_ago=back, today=ref) + for back in range(count) + ) diff --git a/diet_guard/_calendar_view.py b/diet_guard/_calendar_view.py index 7eb12e9..3dfd673 100644 --- a/diet_guard/_calendar_view.py +++ b/diet_guard/_calendar_view.py @@ -12,9 +12,16 @@ from __future__ import annotations import calendar from dataclasses import dataclass from datetime import date +from typing import TYPE_CHECKING from gatelock import LockConfig +from diet_guard._averages import ( + PeriodAverage, + band_label, + monthly_average, + weekly_average, +) from diet_guard._daystatus import ( DayStatus, adherence_streak, @@ -24,6 +31,10 @@ from diet_guard._daystatus import ( from diet_guard._gatelock_ui import BG, FG from diet_guard._state import now_local +if TYPE_CHECKING: + from diet_guard._budget_history import BudgetSchedule + from diet_guard._state import DayLog + _COLORS = LockConfig() # A "not logged" cell renders hollow -- background-colored fill, near-white # outline -- rather than a pure-black square (safe-design-rules rule 1), @@ -155,3 +166,34 @@ def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · " f"{tally.adherent_days} within budget" ) + + +def _average_part(period: PeriodAverage) -> str: + """Render one period as ``"NNNN kcal (band)"``, or ``"no data"``.""" + if period.avg_kcal is None: + return band_label(period.band) + return f"{period.avg_kcal:.0f} kcal ({band_label(period.band)})" + + +def averages_text( + log: DayLog, + *, + schedule: BudgetSchedule, + today: date | None = None, +) -> str: + """Render the weekly/monthly average-intake line. + + Both periods stop at yesterday (see :mod:`diet_guard._averages`), so the + line never swings every time a meal is logged today -- it answers "how has + this week gone", not "how am I doing right now", which is what the Log Meal + tab's remaining-budget readout is for. + + ``today`` defaults to the real current date for live UI use; tests pass it + explicitly for deterministic period edges. + """ + week = weekly_average(log, schedule=schedule, today=today) + month = monthly_average(log, schedule=schedule, today=today) + return ( + f"Avg/day (to yesterday): week {_average_part(week)} \u00b7 " + f"month {_average_part(month)}" + ) diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 16e1233..bc48fb2 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -6,6 +6,7 @@ Examples: python -m diet_guard ate "two slices of pizza" --grams 240 python -m diet_guard ate "protein shake" --kcal 180 python -m diet_guard status + python -m diet_guard averages python -m diet_guard undo The daily budget lives outside the repo (so it is never exposed online) but is @@ -18,6 +19,7 @@ from __future__ import annotations import argparse from dataclasses import dataclass import sys +from typing import TYPE_CHECKING from diet_guard._budget import ( Biometrics, @@ -28,6 +30,7 @@ from diet_guard._budget import ( protein_target_g, write_budget, ) +from diet_guard._cli_averages import cmd_averages, register_averages_subparser from diet_guard._cli_gate import cmd_gate from diet_guard._cli_sync import cmd_sync, register_sync_subparser from diet_guard._foodbank import remember_food @@ -49,6 +52,9 @@ from diet_guard._state import ( undo_last_today, ) +if TYPE_CHECKING: + from collections.abc import Callable + # Column width for a meal description in the status listing. _DESC_WIDTH = 24 # An ISO timestamp formats as "YYYY-MM-DDTHH:MM:SS"; HH:MM is chars 11..16. @@ -173,6 +179,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: sub.add_parser("status", help="Show today's calories and budget band.") sub.add_parser("undo", help="Remove today's most recent entry.") + register_averages_subparser(sub) register_sync_subparser(sub) gate = sub.add_parser( @@ -413,6 +420,39 @@ def _cmd_undo() -> int: return 0 +def _dispatch_ate(args: argparse.Namespace) -> int: + """Marshal ``ate``'s flags into value objects, then log the meal.""" + return _cmd_ate( + args.description, + _Portion(grams=args.grams, count=args.count, per_grams=args.per), + _ManualMacros( + kcal=args.kcal, + protein=args.protein, + carbs=args.carbs, + fat=args.fat, + ), + ) + + +def _dispatch_gate(args: argparse.Namespace) -> int: + """Run the gate subcommand with its two flags.""" + return cmd_gate(_emit, check=args.check, demo=args.demo) + + +# A table rather than an if-chain: the chain grew a return per subcommand and +# tripped ruff's return-count limit at the seventh, and a table also has no +# unreachable trailing branch for the 100%-coverage gate to chase. +_COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = { + "init": lambda _args: _cmd_init(), + "ate": _dispatch_ate, + "status": lambda _args: _cmd_status(), + "averages": lambda _args: cmd_averages(_emit), + "sync": lambda _args: cmd_sync(_emit), + "gate": _dispatch_gate, + "undo": lambda _args: _cmd_undo(), +} + + def main(argv: list[str] | None = None) -> int: """Dispatch a diet_guard subcommand. @@ -423,25 +463,4 @@ def main(argv: list[str] | None = None) -> int: A process exit code (0 on success). """ args = _parse_args(sys.argv[1:] if argv is None else argv) - if args.command == "init": - return _cmd_init() - if args.command == "ate": - macros = _ManualMacros( - kcal=args.kcal, - protein=args.protein, - carbs=args.carbs, - fat=args.fat, - ) - portion = _Portion( - grams=args.grams, - count=args.count, - per_grams=args.per, - ) - return _cmd_ate(args.description, portion, macros) - if args.command == "status": - return _cmd_status() - if args.command == "sync": - return cmd_sync(_emit) - if args.command == "gate": - return cmd_gate(_emit, check=args.check, demo=args.demo) - return _cmd_undo() + return _COMMANDS[args.command](args) diff --git a/diet_guard/_cli_averages.py b/diet_guard/_cli_averages.py new file mode 100644 index 0000000..25f1ae4 --- /dev/null +++ b/diet_guard/_cli_averages.py @@ -0,0 +1,110 @@ +"""CLI handler for the ``averages`` subcommand. + +Split out from :mod:`diet_guard._cli` for the same reason +:mod:`diet_guard._cli_sync` is: that module is already close to the repo's +500-line cap. + +The daily budget is printed here on purpose. It is "hidden" only in the sense +of never leaving this machine (see :mod:`diet_guard._constants`), and an +average intake with no yardstick beside it is unreadable -- "2310 kcal/day, +slightly over" invites the obvious "over *what*?". The MCP server's +equivalent tool deliberately makes the opposite trade and returns the band +alone; see :mod:`diet_guard._mcp`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from diet_guard._averages import ( + band_label, + monthly_average, + weekly_average, +) +from diet_guard._budget import ( + BudgetFileCorruptError, + BudgetNotInitializedError, + current_schedule, + daily_budget, +) +from diet_guard._state import load_log + +if TYPE_CHECKING: + import argparse + from collections.abc import Callable + + from diet_guard._averages import PeriodAverage + from diet_guard._budget_history import BudgetSchedule + +# Width of the leading period label, so the numbers line up in a column. +_LABEL_WIDTH = 11 + + +def register_averages_subparser(sub: argparse._SubParsersAction) -> None: + """Register the ``averages`` subcommand on ``sub``.""" + sub.add_parser( + "averages", + help="Average kcal/day this and last week, and this and last month.", + ) + + +def _format_period(label: str, period: PeriodAverage) -> str: + """Render one period as a single aligned line. + + Args: + label: The period's name, e.g. ``"this week"``. + period: The computed average. + + Returns: + A line ending in the coverage count, or a short "nothing yet" line + when the period has no logged day to average. + """ + head = f"{label:<{_LABEL_WIDTH}} {period.start}..{period.end}" + if period.avg_kcal is None or period.avg_budget is None: + return f"{head} no logged days yet" + return ( + f"{head} {period.avg_kcal:.0f} kcal/day - " + f"{band_label(period.band)} (budget {period.avg_budget:.0f}) " + f"[{period.logged_days}/{period.elapsed_days} days logged]" + ) + + +def _emit_periods(emit: Callable[[str], None], schedule: BudgetSchedule) -> None: + """Print the four period lines against ``schedule``.""" + log = load_log() + rows = ( + ("this week", weekly_average(log, schedule=schedule, weeks_ago=0)), + ("last week", weekly_average(log, schedule=schedule, weeks_ago=1)), + ("this month", monthly_average(log, schedule=schedule, months_ago=0)), + ("last month", monthly_average(log, schedule=schedule, months_ago=1)), + ) + for label, period in rows: + emit(_format_period(label, period)) + + +def cmd_averages(emit: Callable[[str], None]) -> int: + """Print weekly and monthly average intake and its budget band. + + Every period ends at *yesterday*: today is still being logged, and half a + day of entries would drag the mean far enough to flip the band (see + :mod:`diet_guard._averages`). + + Args: + emit: A one-line output sink (``_cli._emit``, passed in rather than + imported so this module has no reach-in dependency on ``_cli``). + + Returns: + 0 once the periods are printed, 1 when no usable budget exists to + judge them against. + """ + try: + budget = daily_budget() + except BudgetNotInitializedError: + emit("budget not set - run: python -m diet_guard init") + return 1 + except BudgetFileCorruptError: + emit("budget file corrupt - re-run init") + return 1 + emit("averages exclude today, which is still being logged.") + _emit_periods(emit, current_schedule(default=budget)) + return 0 diff --git a/diet_guard/_daystatus.py b/diet_guard/_daystatus.py index 2e31101..fe5d4cc 100644 --- a/diet_guard/_daystatus.py +++ b/diet_guard/_daystatus.py @@ -49,8 +49,11 @@ class DayStatus(enum.Enum): # 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) +# band), so the two bands are symmetric around the budget line. Public +# within the package because :mod:`diet_guard._averages` classifies a +# *period average* with the same boundary -- two copies of this number +# would let the calendar and the averages line disagree about "over". +OVER_BUDGET_YELLOW_CEILING = 1.0 + (1.0 - BUDGET_WARN_FRACTION) _LOGGED_STATUSES = frozenset( {DayStatus.GREEN, DayStatus.YELLOW, DayStatus.RED}, @@ -96,7 +99,7 @@ def day_status(log: DayLog, day: str, budget: int) -> DayStatus: total = day_total_kcal(log, day) if total <= budget: return DayStatus.GREEN - if total <= budget * _OVER_BUDGET_YELLOW_CEILING: + if total <= budget * OVER_BUDGET_YELLOW_CEILING: return DayStatus.YELLOW return DayStatus.RED diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py index 0f58db1..e0aaf7d 100644 --- a/diet_guard/_gatelock_calendar.py +++ b/diet_guard/_gatelock_calendar.py @@ -32,6 +32,7 @@ from diet_guard._budget import ( ) from diet_guard._calendar_view import ( CalendarCell, + averages_text, build_month_cells, cell_style, streaks_text, @@ -125,7 +126,7 @@ class _GateCalendar(_GateMealFlow): # -- refresh -------------------------------------------------------------- def _refresh_calendar(self) -> None: - """Recompute the calendar grid, streaks, YTD tally, and budget field. + """Recompute the grid, streaks, YTD tally, averages, and budget field. Each past day is judged against the budget that applied *then* (:mod:`diet_guard._budget_history`), so changing the budget today @@ -149,18 +150,18 @@ class _GateCalendar(_GateMealFlow): budget = _DEFAULT_BUDGET_KCAL except BudgetFileCorruptError: budget = None - status_map_ = ( - status_map(log, schedule=current_schedule(default=budget)) - if budget is not None - else None - ) - self._render_month(status_map_) - if status_map_ is None: + schedule = current_schedule(default=budget) if budget is not None else None + if schedule is None: + self._render_month(None) self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.") self._cal_vars.ytd.set("") + self._cal_vars.averages.set("") else: + status_map_ = status_map(log, schedule=schedule) + self._render_month(status_map_) self._cal_vars.streaks.set(streaks_text(status_map_)) self._cal_vars.ytd.set(ytd_text(status_map_)) + self._cal_vars.averages.set(averages_text(log, schedule=schedule)) if not self._cal_editing_budget: self._refresh_budget_field(budget) diff --git a/diet_guard/_gatelock_calendar_ui.py b/diet_guard/_gatelock_calendar_ui.py index 7b926b2..d8aade7 100644 --- a/diet_guard/_gatelock_calendar_ui.py +++ b/diet_guard/_gatelock_calendar_ui.py @@ -55,6 +55,7 @@ class CalendarVars: month_label: tk.StringVar streaks: tk.StringVar ytd: tk.StringVar + averages: tk.StringVar budget_status: tk.StringVar budget: tk.StringVar """Backs the budget entry so its per-monitor copies cannot disagree.""" @@ -86,6 +87,7 @@ def make_calendar_vars(root: tk.Misc) -> CalendarVars: month_label=tk.StringVar(master=root, value=""), streaks=tk.StringVar(master=root, value=""), ytd=tk.StringVar(master=root, value=""), + averages=tk.StringVar(master=root, value=""), budget_status=tk.StringVar(master=root, value=""), budget=tk.StringVar(master=root, value=""), ) @@ -246,6 +248,13 @@ def build_calendar_frame( font=(_COLORS.font_family, CAPTION), bg=BG, fg=_MUTED, + ).pack(pady=(XS, 0)) + tk.Label( + frame, + textvariable=vars_.averages, + font=(_COLORS.font_family, CAPTION), + bg=BG, + fg=_MUTED, ).pack(pady=(XS, SM)) return CalendarWidgets( diff --git a/diet_guard/_mcp.py b/diet_guard/_mcp.py index 44e7840..9cfc9e5 100644 --- a/diet_guard/_mcp.py +++ b/diet_guard/_mcp.py @@ -41,12 +41,19 @@ from mcp.server import MCPServer from mcp.types import ToolAnnotations from pydantic import BaseModel -from diet_guard._budget import BudgetError +from diet_guard._averages import ( + PeriodAverage, + band_label, + monthly_average, + weekly_average, +) +from diet_guard._budget import BudgetError, current_schedule, daily_budget from diet_guard._gate import due_slots from diet_guard._resolve import ManualMacros, resolve_nutrition from diet_guard._slots import current_slot, day_slots, slot_for_log, slot_label from diet_guard._state import ( consumption_band, + load_log, logged_slots_today, now_local, today_entries, @@ -161,6 +168,62 @@ def list_today() -> dict[str, Any]: } +def _period_view(period: PeriodAverage) -> dict[str, Any]: + """Render one period for the wire, WITHOUT its average budget. + + ``avg_budget`` is dropped on purpose: it is the daily budget by another + name, and the module invariant is that the number never leaves via this + interface. What remains -- the mean intake plus the qualitative band -- is + the same trade ``get_status`` already makes (a band plus a consumed number + bounds the budget loosely; the exact figure stays on the machine). + """ + return { + "start": period.start, + "end": period.end, + "logged_days": period.logged_days, + "elapsed_days": period.elapsed_days, + "avg_kcal": period.avg_kcal, + "band": None if period.band is None else period.band.value, + "band_label": band_label(period.band), + } + + +@mcp.tool(title="Weekly and monthly average intake", annotations=_READS_ONLY) +def get_averages() -> dict[str, Any]: + """Return average kcal/day for this and last week, and this and last month. + + Each period's average is over its *logged* days only and is classified + ``"under"`` / ``"slightly_over"`` / ``"very_over"`` against the mean of the + budgets that applied on those same days -- never against today's budget, so + a budget edit does not retroactively reclassify past weeks. + + Every period ends at **yesterday**: today is still being logged, and a + half-logged day would drag the mean far enough to flip the band. A period + with no finished logged day yet reports ``avg_kcal: null`` rather than a + flattering zero. + + The per-period average budget is deliberately not returned (see the module + docstring's budget invariant); only the intake and the band are. + """ + try: + schedule = current_schedule(default=daily_budget()) + except BudgetError: + # No budget set, or a corrupt file: no yardstick, so no bands. + return {"budget_initialized": False, "periods": {}} + log = load_log() + periods = { + "this_week": weekly_average(log, schedule=schedule, weeks_ago=0), + "last_week": weekly_average(log, schedule=schedule, weeks_ago=1), + "this_month": monthly_average(log, schedule=schedule, months_ago=0), + "last_month": monthly_average(log, schedule=schedule, months_ago=1), + } + return { + "budget_initialized": True, + "excludes_today": True, + "periods": {name: _period_view(period) for name, period in periods.items()}, + } + + @mcp.tool(title="Meal slots for the day", annotations=_READS_ONLY) def get_slots() -> dict[str, Any]: """Return the day's fixed meal slots and which one is current. diff --git a/diet_guard/tests/test_averages.py b/diet_guard/tests/test_averages.py new file mode 100644 index 0000000..0348ce3 --- /dev/null +++ b/diet_guard/tests/test_averages.py @@ -0,0 +1,351 @@ +"""Tests for diet_guard._averages: period edges, bands, and coverage counts.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from diet_guard import _averages +from diet_guard._averages import AverageBand +from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule + + +def _entry(kcal: float) -> dict[str, object]: + return {"kcal": kcal} + + +def _flat(budget: int) -> BudgetSchedule: + """A schedule where one budget has applied since the beginning of time.""" + return BudgetSchedule( + (BudgetEntry(EPOCH_DAY, budget, "1970-01-01T00:00:00+00:00"),), + default=budget, + ) + + +def _days(start: str, kcals: list[float]) -> dict[str, list[dict[str, object]]]: + """A log of consecutive days from ``start``, one entry each.""" + first = date.fromisoformat(start) + return { + (first + timedelta(days=offset)).isoformat(): [_entry(kcal)] + for offset, kcal in enumerate(kcals) + } + + +class TestBandFor: + def test_exactly_at_budget_is_under(self) -> None: + assert _averages.band_for(2000, 2000) is AverageBand.UNDER + + def test_below_budget_is_under(self) -> None: + assert _averages.band_for(1500, 2000) is AverageBand.UNDER + + def test_one_over_is_slightly_over(self) -> None: + assert _averages.band_for(2001, 2000) is AverageBand.SLIGHTLY_OVER + + def test_exactly_at_ceiling_is_slightly_over(self) -> None: + # 2400 sits right at the 120% ceiling for a 2000 budget. + assert _averages.band_for(2400, 2000) is AverageBand.SLIGHTLY_OVER + + def test_just_past_ceiling_is_very_over(self) -> None: + assert _averages.band_for(2400.01, 2000) is AverageBand.VERY_OVER + + def test_way_past_ceiling_is_very_over(self) -> None: + assert _averages.band_for(4000, 2000) is AverageBand.VERY_OVER + + def test_boundary_matches_the_calendar_day_boundary(self) -> None: + # The averages line and the calendar must never disagree about "over": + # a period averaging exactly one day's YELLOW total is SLIGHTLY_OVER. + ceiling = _averages.OVER_BUDGET_YELLOW_CEILING + assert _averages.band_for(2000 * ceiling, 2000) is AverageBand.SLIGHTLY_OVER + + +class TestBandLabel: + def test_none_is_no_data(self) -> None: + assert _averages.band_label(None) == "no data" + + def test_under(self) -> None: + assert _averages.band_label(AverageBand.UNDER) == "under" + + def test_slightly_over(self) -> None: + assert _averages.band_label(AverageBand.SLIGHTLY_OVER) == "slightly over" + + def test_very_over(self) -> None: + assert _averages.band_label(AverageBand.VERY_OVER) == "very over" + + +class TestPeriodBounds: + def test_week_starts_monday(self) -> None: + # 2026-01-07 is a Wednesday. + start, end = _averages.week_bounds(date(2026, 1, 7)) + assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) + + def test_week_of_a_monday_is_that_monday(self) -> None: + start, end = _averages.week_bounds(date(2026, 1, 5)) + assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) + + def test_week_of_a_sunday_looks_back(self) -> None: + start, end = _averages.week_bounds(date(2026, 1, 11)) + assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) + + def test_month_bounds(self) -> None: + start, end = _averages.month_bounds(date(2026, 2, 14)) + assert (start, end) == (date(2026, 2, 1), date(2026, 2, 28)) + + def test_month_bounds_leap_february(self) -> None: + _, end = _averages.month_bounds(date(2024, 2, 14)) + assert end == date(2024, 2, 29) + + def test_last_complete_day_is_yesterday(self) -> None: + assert _averages.last_complete_day(date(2026, 3, 1)) == date(2026, 2, 28) + + +class TestShiftMonths: + def test_zero_is_the_same_month(self) -> None: + assert _averages._shift_months(date(2026, 5, 20), 0) == date(2026, 5, 1) + + def test_one_back(self) -> None: + assert _averages._shift_months(date(2026, 5, 20), 1) == date(2026, 4, 1) + + def test_wraps_the_year(self) -> None: + assert _averages._shift_months(date(2026, 1, 20), 1) == date(2025, 12, 1) + + def test_wraps_several_years(self) -> None: + assert _averages._shift_months(date(2026, 3, 20), 27) == date(2023, 12, 1) + + +class TestPeriodAverage: + def test_averages_over_logged_days_only(self) -> None: + # Three logged days (2000, 3000, 4000) plus two gaps: the mean is + # 3000, NOT 1800 -- a day you forgot to log is not a zero-kcal day. + log = { + "2026-01-05": [_entry(2000)], + "2026-01-06": [_entry(3000)], + "2026-01-09": [_entry(4000)], + } + result = _averages.period_average( + log, + schedule=_flat(2500), + start=date(2026, 1, 5), + end=date(2026, 1, 9), + ) + assert result.avg_kcal == 3000 + assert result.logged_days == 3 + assert result.elapsed_days == 5 + + def test_sums_multiple_entries_in_a_day(self) -> None: + log = {"2026-01-05": [_entry(1200), _entry(800)]} + result = _averages.period_average( + log, + schedule=_flat(2000), + start=date(2026, 1, 5), + end=date(2026, 1, 5), + ) + assert result.avg_kcal == 2000 + assert result.band is AverageBand.UNDER + + def test_empty_period_has_no_average(self) -> None: + result = _averages.period_average( + {}, + schedule=_flat(2000), + start=date(2026, 1, 5), + end=date(2026, 1, 11), + ) + assert result.avg_kcal is None + assert result.avg_budget is None + assert result.band is None + assert result.logged_days == 0 + assert result.elapsed_days == 7 + + def test_end_before_start_is_zero_elapsed_not_negative(self) -> None: + result = _averages.period_average( + {}, + schedule=_flat(2000), + start=date(2026, 1, 5), + end=date(2026, 1, 4), + ) + assert result.elapsed_days == 0 + assert result.avg_kcal is None + + def test_days_outside_the_range_are_ignored(self) -> None: + log = {"2026-01-04": [_entry(9000)], "2026-01-05": [_entry(1000)]} + result = _averages.period_average( + log, + schedule=_flat(2000), + start=date(2026, 1, 5), + end=date(2026, 1, 5), + ) + assert result.avg_kcal == 1000 + + def test_budget_is_averaged_per_logged_day_not_taken_from_today(self) -> None: + # The budget rose to 3000 partway through: a period spanning the change + # is judged against the mean of the two, not against either endpoint. + schedule = BudgetSchedule( + ( + BudgetEntry(EPOCH_DAY, 1000, "1970-01-01T00:00:00+00:00"), + BudgetEntry("2026-01-06", 3000, "2026-01-06T00:00:00+00:00"), + ), + default=1000, + ) + log = {"2026-01-05": [_entry(2000)], "2026-01-06": [_entry(2000)]} + result = _averages.period_average( + log, + schedule=schedule, + start=date(2026, 1, 5), + end=date(2026, 1, 6), + ) + assert result.avg_budget == 2000 + assert result.band is AverageBand.UNDER + + def test_unlogged_days_do_not_pull_the_budget_average(self) -> None: + # Only the logged day counts on both sides of the comparison. + schedule = BudgetSchedule( + ( + BudgetEntry(EPOCH_DAY, 1000, "1970-01-01T00:00:00+00:00"), + BudgetEntry("2026-01-06", 3000, "2026-01-06T00:00:00+00:00"), + ), + default=1000, + ) + log = {"2026-01-06": [_entry(2000)]} + result = _averages.period_average( + log, + schedule=schedule, + start=date(2026, 1, 5), + end=date(2026, 1, 6), + ) + assert result.avg_budget == 3000 + + def test_reports_its_own_range(self) -> None: + result = _averages.period_average( + {}, + schedule=_flat(2000), + start=date(2026, 1, 5), + end=date(2026, 1, 11), + ) + assert (result.start, result.end) == ("2026-01-05", "2026-01-11") + + +class TestWeeklyAverage: + def test_current_week_stops_at_yesterday(self) -> None: + # Today is Thursday 2026-01-08 and today's 100 kcal must NOT count: + # the mean is over Mon-Wed only. + log = _days("2026-01-05", [3000, 3000, 3000]) | {"2026-01-08": [_entry(100)]} + result = _averages.weekly_average( + log, + schedule=_flat(2000), + today=date(2026, 1, 8), + ) + assert result.avg_kcal == 3000 + assert result.end == "2026-01-07" + assert result.band is AverageBand.VERY_OVER + + def test_monday_has_no_complete_days_this_week(self) -> None: + log = _days("2026-01-05", [3000]) + result = _averages.weekly_average( + log, + schedule=_flat(2000), + today=date(2026, 1, 5), + ) + assert result.elapsed_days == 0 + assert result.avg_kcal is None + assert result.band is None + + def test_previous_week_is_whole(self) -> None: + log = _days("2026-01-05", [2100, 2100, 2100, 2100, 2100, 2100, 2100]) + result = _averages.weekly_average( + log, + schedule=_flat(2000), + weeks_ago=1, + today=date(2026, 1, 14), + ) + assert (result.start, result.end) == ("2026-01-05", "2026-01-11") + assert result.elapsed_days == 7 + assert result.logged_days == 7 + assert result.band is AverageBand.SLIGHTLY_OVER + + def test_defaults_to_the_real_today(self) -> None: + # No explicit `today`: the call must still resolve a real date range + # rather than raising or returning an unbounded period. + result = _averages.weekly_average({}, schedule=_flat(2000)) + assert result.elapsed_days >= 0 + + +class TestMonthlyAverage: + def test_current_month_stops_at_yesterday(self) -> None: + log = _days("2026-03-01", [1000, 1000, 1000]) | {"2026-03-04": [_entry(9000)]} + result = _averages.monthly_average( + log, + schedule=_flat(2000), + today=date(2026, 3, 4), + ) + assert result.avg_kcal == 1000 + assert (result.start, result.end) == ("2026-03-01", "2026-03-03") + assert result.band is AverageBand.UNDER + + def test_first_of_the_month_has_no_complete_days(self) -> None: + result = _averages.monthly_average( + {}, + schedule=_flat(2000), + today=date(2026, 3, 1), + ) + assert result.elapsed_days == 0 + assert result.avg_kcal is None + + def test_previous_month_is_whole(self) -> None: + log = _days("2026-02-01", [2500, 2500]) + result = _averages.monthly_average( + log, + schedule=_flat(2000), + months_ago=1, + today=date(2026, 3, 10), + ) + assert (result.start, result.end) == ("2026-02-01", "2026-02-28") + assert result.elapsed_days == 28 + assert result.logged_days == 2 + assert result.band is AverageBand.VERY_OVER + + def test_defaults_to_the_real_today(self) -> None: + result = _averages.monthly_average({}, schedule=_flat(2000)) + assert result.elapsed_days >= 0 + + +class TestRecentPeriods: + def test_recent_weeks_are_newest_first(self) -> None: + weeks = _averages.recent_weeks( + {}, + schedule=_flat(2000), + count=3, + today=date(2026, 1, 14), + ) + assert [week.start for week in weeks] == [ + "2026-01-12", + "2026-01-05", + "2025-12-29", + ] + + def test_recent_months_are_newest_first(self) -> None: + months = _averages.recent_months( + {}, + schedule=_flat(2000), + count=3, + today=date(2026, 1, 14), + ) + assert [month.start for month in months] == [ + "2026-01-01", + "2025-12-01", + "2025-11-01", + ] + + def test_zero_count_is_empty(self) -> None: + assert ( + _averages.recent_weeks( + {}, + schedule=_flat(2000), + count=0, + today=date(2026, 1, 14), + ) + == () + ) + + def test_recent_weeks_defaults_to_the_real_today(self) -> None: + assert len(_averages.recent_weeks({}, schedule=_flat(2000), count=2)) == 2 + + def test_recent_months_defaults_to_the_real_today(self) -> None: + assert len(_averages.recent_months({}, schedule=_flat(2000), count=2)) == 2 diff --git a/diet_guard/tests/test_calendar_averages.py b/diet_guard/tests/test_calendar_averages.py new file mode 100644 index 0000000..0af6a7a --- /dev/null +++ b/diet_guard/tests/test_calendar_averages.py @@ -0,0 +1,59 @@ +"""Tests for _calendar_view.averages_text: the History tab's averages line.""" + +from __future__ import annotations + +from datetime import date + +from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule +from diet_guard._calendar_view import averages_text + + +def _flat(budget: int) -> BudgetSchedule: + return BudgetSchedule( + (BudgetEntry(EPOCH_DAY, budget, "1970-01-01T00:00:00+00:00"),), + default=budget, + ) + + +class TestAveragesText: + def test_names_both_periods(self) -> None: + text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "week" in text + assert "month" in text + + def test_says_it_stops_at_yesterday(self) -> None: + text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "to yesterday" in text + + def test_empty_log_reads_no_data_not_zero(self) -> None: + text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "no data" in text + assert "0 kcal" not in text + + def test_renders_the_average_and_its_band(self) -> None: + # Mon-Tue of the week containing Wed 2026-01-14, at 3000 kcal each + # against a 2000 budget: 150% of budget, comfortably "very over". + log = { + "2026-01-12": [{"kcal": 3000.0}], + "2026-01-13": [{"kcal": 3000.0}], + } + text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "week 3000 kcal (very over)" in text + + def test_under_budget_reads_under(self) -> None: + log = {"2026-01-12": [{"kcal": 1500.0}]} + text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "week 1500 kcal (under)" in text + + def test_slightly_over_reads_slightly_over(self) -> None: + log = {"2026-01-12": [{"kcal": 2200.0}]} + text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "week 2200 kcal (slightly over)" in text + + def test_today_is_not_averaged_in(self) -> None: + log = {"2026-01-14": [{"kcal": 9000.0}]} + text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) + assert "9000" not in text + + def test_defaults_to_the_real_today(self) -> None: + assert "week" in averages_text({}, schedule=_flat(2000)) diff --git a/diet_guard/tests/test_cli_averages.py b/diet_guard/tests/test_cli_averages.py new file mode 100644 index 0000000..af50351 --- /dev/null +++ b/diet_guard/tests/test_cli_averages.py @@ -0,0 +1,135 @@ +"""Tests for the averages subcommand's handler, split out of test_cli.py +alongside its source module (see _cli_averages.py's module docstring). +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from diet_guard import _cli, _cli_averages +from diet_guard._averages import AverageBand, PeriodAverage +from diet_guard._budget import ( + BudgetFileCorruptError, + BudgetNotInitializedError, + write_budget, +) +from diet_guard._state import now_local + + +def _period( + *, + avg_kcal: float | None = 2100.0, + avg_budget: float | None = 2000.0, + band: AverageBand | None = AverageBand.SLIGHTLY_OVER, + logged_days: int = 7, +) -> PeriodAverage: + """A fully-populated period, overridable field by field.""" + return PeriodAverage( + start="2026-01-05", + end="2026-01-11", + logged_days=logged_days, + elapsed_days=7, + avg_kcal=avg_kcal, + avg_budget=avg_budget, + band=band, + ) + + +class TestFormatPeriod: + def test_renders_average_band_budget_and_coverage(self) -> None: + line = _cli_averages._format_period("this week", _period()) + assert "2026-01-05..2026-01-11" in line + assert "2100 kcal/day" in line + assert "slightly over" in line + assert "budget 2000" in line + assert "[7/7 days logged]" in line + + def test_rounds_the_average_to_whole_kcal(self) -> None: + line = _cli_averages._format_period("this week", _period(avg_kcal=2100.6)) + assert "2101 kcal/day" in line + + def test_empty_period_says_so_instead_of_printing_zero(self) -> None: + line = _cli_averages._format_period( + "this week", + _period(avg_kcal=None, avg_budget=None, band=None, logged_days=0), + ) + assert line.endswith("no logged days yet") + assert "kcal/day" not in line + + def test_missing_budget_alone_also_degrades(self) -> None: + # avg_budget is None only when avg_kcal is too, but the guard covers + # both so a future partial period can never format "budget None". + line = _cli_averages._format_period("this week", _period(avg_budget=None)) + assert line.endswith("no logged days yet") + + def test_label_column_is_padded_so_the_dates_line_up(self) -> None: + short = _cli_averages._format_period("this week", _period()) + long = _cli_averages._format_period("this month", _period()) + assert short.index("2026-01-05") == long.index("2026-01-05") + + +class TestCmdAverages: + def test_prints_a_header_and_one_line_per_period(self) -> None: + write_budget(2000) + lines: list[str] = [] + assert _cli_averages.cmd_averages(lines.append) == 0 + assert lines[0] == "averages exclude today, which is still being logged." + assert len(lines) == 5 + assert [" ".join(line.split()[:2]) for line in lines[1:]] == [ + "this week", + "last week", + "this month", + "last month", + ] + + def test_a_logged_past_day_reaches_the_average(self) -> None: + write_budget(2000) + yesterday = (now_local().date() - timedelta(days=1)).isoformat() + log = {yesterday: [{"kcal": 2500.0}]} + lines: list[str] = [] + with patch.object(_cli_averages, "load_log", return_value=log): + assert _cli_averages.cmd_averages(lines.append) == 0 + assert any("2500 kcal/day" in line for line in lines) + assert any("very over" in line for line in lines) + + def test_today_is_excluded_from_the_average(self) -> None: + # Today's 9000 kcal must not appear anywhere: every period stops at + # yesterday, so a half-logged day cannot flip a band. + write_budget(2000) + today = now_local().date().isoformat() + lines: list[str] = [] + with patch.object( + _cli_averages, + "load_log", + return_value={today: [{"kcal": 9000.0}]}, + ): + assert _cli_averages.cmd_averages(lines.append) == 0 + assert not any("9000 kcal/day" in line for line in lines) + + def test_uninitialized_budget_is_reported_not_crashed(self) -> None: + lines: list[str] = [] + with patch.object( + _cli_averages, + "daily_budget", + side_effect=BudgetNotInitializedError, + ): + assert _cli_averages.cmd_averages(lines.append) == 1 + assert lines == ["budget not set - run: python -m diet_guard init"] + + def test_corrupt_budget_is_reported_not_crashed(self) -> None: + lines: list[str] = [] + with patch.object( + _cli_averages, + "daily_budget", + side_effect=BudgetFileCorruptError, + ): + assert _cli_averages.cmd_averages(lines.append) == 1 + assert lines == ["budget file corrupt - re-run init"] + + +class TestSubparser: + def test_averages_is_reachable_from_main(self) -> None: + with patch.object(_cli, "cmd_averages", return_value=0) as handler: + assert _cli.main(["averages"]) == 0 + assert handler.call_count == 1 diff --git a/diet_guard/tests/test_mcp_averages.py b/diet_guard/tests/test_mcp_averages.py new file mode 100644 index 0000000..69c1b8e --- /dev/null +++ b/diet_guard/tests/test_mcp_averages.py @@ -0,0 +1,91 @@ +"""Tests for the get_averages MCP tool, including its budget-secrecy invariant.""" + +from __future__ import annotations + +from unittest.mock import patch + +from diet_guard import _mcp +from diet_guard._averages import AverageBand, PeriodAverage +from diet_guard._budget import BudgetNotInitializedError + + +def _period( + *, + avg_kcal: float | None = 2100.0, + band: AverageBand | None = AverageBand.SLIGHTLY_OVER, +) -> PeriodAverage: + return PeriodAverage( + start="2026-01-05", + end="2026-01-11", + logged_days=7, + elapsed_days=7, + avg_kcal=avg_kcal, + avg_budget=2000.0, + band=band, + ) + + +class TestPeriodView: + def test_never_carries_the_average_budget(self) -> None: + # The module invariant: no tool hands an automated caller the budget. + view = _mcp._period_view(_period()) + assert "avg_budget" not in view + assert 2000.0 not in view.values() + + def test_carries_intake_band_and_coverage(self) -> None: + view = _mcp._period_view(_period()) + assert view["avg_kcal"] == 2100.0 + assert view["band"] == "slightly_over" + assert view["band_label"] == "slightly over" + assert view["logged_days"] == 7 + assert view["elapsed_days"] == 7 + assert view["start"] == "2026-01-05" + assert view["end"] == "2026-01-11" + + def test_empty_period_serializes_as_null_not_zero(self) -> None: + view = _mcp._period_view(_period(avg_kcal=None, band=None)) + assert view["avg_kcal"] is None + assert view["band"] is None + assert view["band_label"] == "no data" + + +class TestGetAverages: + def test_returns_all_four_periods(self) -> None: + with ( + patch.object(_mcp, "daily_budget", return_value=2000), + patch.object(_mcp, "current_schedule"), + patch.object(_mcp, "load_log", return_value={}), + patch.object(_mcp, "weekly_average", return_value=_period()), + patch.object(_mcp, "monthly_average", return_value=_period()), + ): + out = _mcp.get_averages() + assert out["budget_initialized"] is True + assert out["excludes_today"] is True + assert set(out["periods"]) == { + "this_week", + "last_week", + "this_month", + "last_month", + } + + def test_no_budget_degrades_to_empty_periods(self) -> None: + with patch.object( + _mcp, + "daily_budget", + side_effect=BudgetNotInitializedError, + ): + out = _mcp.get_averages() + assert out == {"budget_initialized": False, "periods": {}} + + def test_no_response_field_leaks_the_budget_number(self) -> None: + with ( + patch.object(_mcp, "daily_budget", return_value=2000), + patch.object(_mcp, "current_schedule"), + patch.object(_mcp, "load_log", return_value={}), + patch.object(_mcp, "weekly_average", return_value=_period()), + patch.object(_mcp, "monthly_average", return_value=_period()), + ): + out = _mcp.get_averages() + flat = repr(out) + assert "2000" not in flat + assert "avg_budget" not in flat