mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
4d803203ab
commit
e07284d3e1
36
CLAUDE.md
36
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
|
||||
|
||||
27
README.md
27
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`
|
||||
|
||||
104
app/lib/models/period_average.dart
Normal file
104
app/lib/models/period_average.dart
Normal 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)})';
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
175
app/lib/services/average_service.dart
Normal file
175
app/lib/services/average_service.dart
Normal 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')}';
|
||||
43
app/lib/widgets/average_summary_row.dart
Normal file
43
app/lib/widgets/average_summary_row.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
234
app/test/services/average_service_test.dart
Normal file
234
app/test/services/average_service_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
67
app/test/widgets/average_summary_row_test.dart
Normal file
67
app/test/widgets/average_summary_row_test.dart
Normal 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
298
diet_guard/_averages.py
Normal 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)
|
||||
)
|
||||
@ -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)}"
|
||||
)
|
||||
|
||||
@ -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
110
diet_guard/_cli_averages.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
351
diet_guard/tests/test_averages.py
Normal file
351
diet_guard/tests/test_averages.py
Normal 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
|
||||
59
diet_guard/tests/test_calendar_averages.py
Normal file
59
diet_guard/tests/test_calendar_averages.py
Normal 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))
|
||||
135
diet_guard/tests/test_cli_averages.py
Normal file
135
diet_guard/tests/test_cli_averages.py
Normal 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
|
||||
91
diet_guard/tests/test_mcp_averages.py
Normal file
91
diet_guard/tests/test_mcp_averages.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user