Show weekly and monthly average intake on both devices

The calendar already said which individual days went over budget, but not
whether the week as a whole did. A run of small overages reads as four
harmless yellow squares; averaged, it is the thing that actually moves the
needle.

Three rules are load-bearing, and are documented in CLAUDE.md because getting
any of them wrong silently changes what "under budget" means:

- The denominator is logged days, not elapsed days. An unlogged day is not a
  zero-kcal day, and averaging gaps in as zeros would make a badly-tracked
  week look like the best week of the year.
- The yardstick is the mean of the per-day budgets over those same days,
  resolved through BudgetSchedule -- never today's budget, which would
  retroactively reclassify past weeks on every budget edit.
- Today is excluded. A half-logged today drags the mean far enough to flip
  the band, so every period ends at yesterday.

Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by
import rather than by copy, so a period and its days can never disagree
about what "over" means; that constant loses its underscore to say so.

The MCP get_averages tool returns the average and the band but drops the
average budget, keeping the module's existing invariant that the budget
number never leaves this machine. The CLI, the gate and the phone all show
both, since it is hidden from the network, not from the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
This commit is contained in:
Krzysztof Rudnicki 2026-08-09 14:59:40 +02:00
parent 4d803203ab
commit e07284d3e1
20 changed files with 1913 additions and 43 deletions

View File

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

View File

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

View File

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

View File

@ -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<CalendarScreen> {
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<CalendarScreen> {
/// 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<void> _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<CalendarScreen> {
adherenceStreak: _adherenceStreak,
tally: _tally,
),
const SizedBox(height: 4),
AverageSummaryRow(week: _weekAverage, month: _monthAverage),
],
),
),

View File

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

View File

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

View File

@ -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<String, double> 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 = <String, List<FoodEntry>>{
'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);
});
});
}

View File

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

298
diet_guard/_averages.py Normal file
View File

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

View File

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

View File

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

110
diet_guard/_cli_averages.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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