Pre-log meals for a future date and slot (CLI --date/--hour, app 'Log for later')
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-27 19:59:38 +02:00
parent 49b3ea4d57
commit 4b1de83e27
16 changed files with 629 additions and 36 deletions

View File

@ -1,8 +1,22 @@
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/screens/log_meal_progress.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart';
/// Writes [suggestion]'s name and macros into the log form's controllers.
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap, mirroring
/// `log_meal_kuchnia_mixin.dart`'s `fillControllersFromDish`.
void fillControllersFromSuggestion(
FoodSuggestion suggestion,
TextEditingController desc,
MacroControllers macros,
) {
desc.text = suggestion.name;
macros.fillFrom(suggestion.nutrition);
}
/// Builds the [Nutrition] for a submit from the form's macro controllers.
///
/// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Pure over

View File

@ -0,0 +1,138 @@
/// Lets the user pre-log a meal against a future date + slot instead of now.
///
/// Its own file because `log_meal_screen.dart` is at the repo's 250-line cap.
///
/// Scoped to dates strictly after today: the date picker's `firstDate` is
/// tomorrow. Today's own not-yet-due slots keep using the screen's existing
/// [SlotSelectorRow] (driven by the real `DateTime.now()`), so this mixin
/// never has to reproduce its "due" (red) coloring, which is meaningless for
/// a day that hasn't started.
library;
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
import 'package:flutter/material.dart';
/// Adds a future-date meal-log picker to [LogMealScreen]'s state.
mixin LogMealFutureMixin<T extends StatefulWidget> on State<T> {
DateTime? _futureDate;
int? _futureSlot;
Set<int> _futureLoggedSlots = {};
/// The date currently chosen for a future log, or null when logging for
/// today through the screen's normal flow.
DateTime? get futureDate => _futureDate;
/// The slot chosen for [futureDate], or null until one is picked.
int? get futureSlot => _futureSlot;
/// Clears back to "log for today", dropping the picked date and slot.
void resetFutureLog() {
setState(() {
_futureDate = null;
_futureSlot = null;
_futureLoggedSlots = {};
});
}
Future<void> _pickFutureDate() async {
final today = DateTime.now();
final tomorrow = DateTime(today.year, today.month, today.day + 1);
final picked = await showDatePicker(
context: context,
firstDate: tomorrow,
lastDate: tomorrow.add(const Duration(days: 365)),
initialDate: _futureDate ?? tomorrow,
);
if (picked == null || !mounted) return;
final log = await LogStorageService.instance.readLog();
if (!mounted) return;
final logged = (log[localDateKey(picked)] ?? const [])
.where((entry) => !entry.deleted && entry.slot != null)
.map((entry) => entry.slot!)
.toSet();
setState(() {
_futureDate = picked;
_futureLoggedSlots = logged;
_futureSlot = null;
});
}
/// The "When" row: a "Log for later" button, or the picked date with a
/// button to change it and one to clear back to today.
Widget buildWhenRow(BuildContext context) {
final date = _futureDate;
final scheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(Icons.event, size: 18, color: scheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.xs),
Expanded(child: Text(date == null ? 'Today' : localDateKey(date))),
TextButton.icon(
// Not Icons.calendar_month: the app bar's own "Calendar" nav button
// already uses that, and a second one on screen makes
// `find.byIcon` ambiguous for widget tests (and taps) alike.
icon: const Icon(Icons.date_range, size: 18),
label: Text(date == null ? 'Log for later' : 'Change date'),
onPressed: _pickFutureDate,
),
if (date != null)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Back to today',
onPressed: resetFutureLog,
),
],
);
}
/// The slot row to show: [futureDate]'s own (driven by that date's logged
/// slots) when a future date is picked, otherwise today's, built from the
/// screen's own state and [onTodaySlotSelected].
Widget buildSlotRow({
required Set<int> loggedSlots,
required int? selectedSlot,
required ValueChanged<int?> onTodaySlotSelected,
}) {
final date = _futureDate;
if (date == null) {
return SlotSelectorRow(
now: DateTime.now(),
loggedSlots: loggedSlots,
selectedSlot: selectedSlot,
onSlotSelected: onTodaySlotSelected,
);
}
return SlotSelectorRow(
now: DateTime(date.year, date.month, date.day),
loggedSlots: _futureLoggedSlots,
selectedSlot: _futureSlot,
onSlotSelected: (slot) => setState(() => _futureSlot = slot),
);
}
/// Resolves the slot/time to log against: the future pick when one is
/// active, otherwise [todaySlot] for now. `error` is set instead when a
/// future date is chosen but no slot on it has been picked yet.
({String? error, int? slot, DateTime? when}) resolveLogTarget(
int? todaySlot,
) {
final date = _futureDate;
if (date == null) return (error: null, slot: todaySlot, when: null);
final slot = _futureSlot;
if (slot == null) {
return (
error: 'Pick a slot for that date first.',
slot: null,
when: null,
);
}
return (
error: null,
slot: slot,
when: DateTime(date.year, date.month, date.day, slot),
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/log_meal_actions.dart';
import 'package:diet_guard_app/screens/log_meal_future_mixin.dart';
import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart';
import 'package:diet_guard_app/screens/log_meal_nav_mixin.dart';
import 'package:diet_guard_app/screens/log_meal_progress.dart';
@ -20,7 +21,6 @@ import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/log_meal_actions_row.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
import 'package:diet_guard_app/widgets/sync_health_banner.dart';
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
@ -45,7 +45,8 @@ class _LogMealScreenState extends State<LogMealScreen>
WidgetsBindingObserver,
LogMealSyncMixin<LogMealScreen>,
LogMealNavMixin<LogMealScreen>,
LogMealKuchniaMixin<LogMealScreen> {
LogMealKuchniaMixin<LogMealScreen>,
LogMealFutureMixin<LogMealScreen> {
@override
http.Client? get syncHttpClient => widget.httpClient;
@ -63,16 +64,7 @@ class _LogMealScreenState extends State<LogMealScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_descController.addListener(_onDescChanged);
for (final controller in [
_macros.kcal,
_macros.protein,
_macros.carbs,
_macros.fat,
_macros.perGrams,
_macros.grams,
]) {
controller.addListener(_onMacroEdited);
}
_macros.addListenerToAll(_onMacroEdited);
selectedSlot = slotForLog(DateTime.now(), MealScheduleService.current);
unawaited(refreshSlots());
unawaited(_onDescChanged());
@ -123,8 +115,7 @@ class _LogMealScreenState extends State<LogMealScreen>
void onDishPrefilled() => setState(() => _source = 'catering');
void _onSuggestionSelected(FoodSuggestion suggestion) {
_descController.text = suggestion.name;
_macros.fillFrom(suggestion.nutrition);
fillControllersFromSuggestion(suggestion, _descController, _macros);
setState(() {
_source = 'food bank';
_suggestions = const [];
@ -140,12 +131,21 @@ class _LogMealScreenState extends State<LogMealScreen>
});
return;
}
final nutrition = nutritionFromControllers(_macros, _source);
final target = resolveLogTarget(selectedSlot);
if (target.error != null) {
setState(() {
_status = target.error;
_progress = null;
});
return;
}
await LogStorageService.instance.logMeal(
desc,
nutrition,
slot: selectedSlot,
nutritionFromControllers(_macros, _source),
slot: target.slot,
when: target.when,
);
resetFutureLog();
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
// Push the new meal now instead of waiting for the next lifecycle event,
@ -199,11 +199,13 @@ class _LogMealScreenState extends State<LogMealScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SyncHealthBanner(status: syncHealth),
SlotSelectorRow(
now: DateTime.now(),
buildWhenRow(context),
const SizedBox(height: 8),
buildSlotRow(
loggedSlots: loggedSlots,
selectedSlot: selectedSlot,
onSlotSelected: (slot) => setState(() => selectedSlot = slot),
onTodaySlotSelected: (slot) =>
setState(() => selectedSlot = slot),
),
const SizedBox(height: 8),
TextField(

View File

@ -96,18 +96,24 @@ class LogStorageService {
await _store.write(documentName, jsonEncode(encoded));
}
/// Appends a signed-on-PC-eventually entry for [desc] to today's log.
/// Appends a signed-on-PC-eventually entry for [desc] to [when]'s day (or
/// today's, when [when] is null).
///
/// Mirrors `_state.log_meal`: always assigns a fresh `id`, never computes
/// an `hmac` (the phone never holds the shared key -- the PC re-signs on
/// merge, see Milestone 3).
/// merge, see Milestone 3). [when] lets a caller pre-log a meal against a
/// future date/slot; must not be in the past.
Future<FoodEntry> logMeal(
String desc,
Nutrition nutrition, {
int? slot,
List<MealComponent>? components,
DateTime? when,
}) async {
final now = DateTime.now();
if (when != null && when.isBefore(DateTime.now())) {
throw ArgumentError.value(when, 'when', 'cannot log a meal in the past');
}
final now = when ?? DateTime.now();
final entry = FoodEntry(
id: const Uuid().v4(),
time: isoLocalSeconds(now),

View File

@ -67,6 +67,19 @@ class MacroControllers {
grams.clear();
}
/// Adds [listener] to every field's controller.
///
/// Split out for `log_meal_screen.dart`'s 250-line cap, mirroring [clear]
/// and [dispose]'s own per-field enumeration.
void addListenerToAll(VoidCallback listener) {
kcal.addListener(listener);
protein.addListener(listener);
carbs.addListener(listener);
fat.addListener(listener);
perGrams.addListener(listener);
grams.addListener(listener);
}
/// Disposes every controller.
void dispose() {
kcal.dispose();

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.94+1
version: 1.0.95+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,143 @@
import 'dart:io';
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Covers logging a meal against a future date/slot through [LogMealScreen]'s
/// "When" row -- its own file, split out the same way
/// `log_meal_screen_nav_test.dart` and `log_meal_screen_sync_test.dart` are.
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_future_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await MealScheduleService.initForTesting(FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
MealScheduleService.resetForTesting();
await tempDir.delete(recursive: true);
});
final logMealButton = find.byTooltip('Log meal');
// See log_meal_screen_test.dart's `settle` for why: fire-and-forget I/O
// that pumpAndSettle() alone does not wait for.
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
DateTime tomorrow() {
final today = DateTime.now();
return DateTime(today.year, today.month, today.day + 1);
}
testWidgets(
'logging for a future date/slot persists under that date, not today, '
'and resets back to Today',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await settle(tester);
// The date picker's initialDate is already tomorrow, so confirming
// immediately picks it without touching a specific calendar cell.
await tester.tap(find.text('OK'));
await settle(tester);
final tomorrowKey = localDateKey(tomorrow());
expect(find.text(tomorrowKey), findsOneWidget);
final firstSlot = daySlots(MealScheduleService.current).first;
await tester.tap(find.text(slotLabel(firstSlot)));
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'future meal');
await settle(tester);
await tester.enterText(find.byType(TextField).at(2), '150');
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
expect(await LogStorageService.instance.todayEntries(), isEmpty);
final log = await LogStorageService.instance.readLog();
final future = log[tomorrowKey]!.single;
expect(future.desc, 'future meal');
expect(future.slot, firstSlot);
expect(future.time.startsWith(tomorrowKey), isTrue);
// Back to logging for today.
expect(find.text('Today'), findsOneWidget);
expect(find.text('Log for later'), findsOneWidget);
});
},
);
testWidgets('refuses to log a future date with no slot picked', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await settle(tester);
await tester.tap(find.text('OK'));
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'no slot meal');
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
expect(find.text('Pick a slot for that date first.'), findsOneWidget);
final log = await LogStorageService.instance.readLog();
expect(log.values.expand((e) => e), isEmpty);
});
});
testWidgets('the "x" button clears the future date back to today', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Log for later'));
await settle(tester);
await tester.tap(find.text('OK'));
await settle(tester);
expect(find.text('Today'), findsNothing);
await tester.tap(find.byTooltip('Back to today'));
await settle(tester);
expect(find.text('Today'), findsOneWidget);
expect(find.text('Log for later'), findsOneWidget);
});
});
}

View File

@ -94,6 +94,27 @@ void main() {
final entries = await LogStorageService.instance.todayEntries();
expect(entries, hasLength(2));
});
test('a future `when` buckets the entry under that future date', () async {
final future = DateTime.now().add(const Duration(days: 1, hours: 1));
final entry = await LogStorageService.instance.logMeal(
'future meal',
_manual,
slot: 12,
when: future,
);
final log = await LogStorageService.instance.readLog();
expect(log[localDateKey(future)]!.single.id, entry.id);
expect(await LogStorageService.instance.todayEntries(), isEmpty);
});
test('rejects a `when` before now', () {
final past = DateTime.now().subtract(const Duration(days: 1));
expect(
() => LogStorageService.instance.logMeal('late', _manual, when: past),
throwsArgumentError,
);
});
});
group('undoLastToday', () {
test('returns null when today has no entries', () async {

View File

@ -170,7 +170,13 @@ def _dispatch_ate(args: argparse.Namespace) -> int:
_emit,
_print_summary,
args.description,
Portion(grams=args.grams, count=args.count, per_grams=args.per),
Portion(
grams=args.grams,
count=args.count,
per_grams=args.per,
date=args.date,
hour=args.hour,
),
ManualMacroArgs(
kcal=args.kcal,
protein=args.protein,

View File

@ -79,6 +79,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
help="Number of items eaten (e.g. 5 apples) instead of --grams;"
" multiplied by the staple's unit weight.",
)
ate.add_argument(
"--date",
default=None,
help="Pre-log for a future date (YYYY-MM-DD) instead of now; requires --hour.",
)
ate.add_argument(
"--hour",
type=int,
default=None,
help="Slot hour on --date this meal satisfies (e.g. 20); requires --date.",
)
sub.add_parser("status", help="Show today's calories and budget band.")
sub.add_parser("undo", help="Remove today's most recent entry.")

View File

@ -22,12 +22,15 @@ from diet_guard._kuchnia_import import refresh_delivery_once
from diet_guard._meal_schedule_store import current_schedule
from diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import slot_for_log
from diet_guard._slots import resolve_future_when, slot_for_log
from diet_guard._state import log_meal, now_local
from diet_guard._sync_events import publish_after_log_detached
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from diet_guard._meal_schedule import MealSchedule
@dataclass(frozen=True)
@ -52,7 +55,7 @@ class ManualMacroArgs:
@dataclass(frozen=True)
class Portion:
"""How much was eaten and the basis for any typed macros.
"""How much was eaten, when, and the basis for any typed macros.
Grouped so :func:`cmd_ate` stays within the argument-count limit.
@ -61,11 +64,17 @@ class Portion:
count: Number of items eaten (an alternative to ``grams``), or None.
per_grams: Reference weight the typed macros are stated for (e.g. 100
for a per-100 g label), or None to treat the macros as totals.
date: Log against this future date (``YYYY-MM-DD``) instead of now.
Must be given together with ``hour``. None means "now".
hour: The slot hour on ``date`` this meal satisfies. Required
together with ``date``.
"""
grams: float | None
count: float | None
per_grams: float | None
date: str | None = None
hour: int | None = None
def eaten_grams(
@ -98,6 +107,24 @@ def eaten_grams(
return portion.grams, None
def _resolve_target(
portion: Portion, schedule: MealSchedule
) -> tuple[datetime | None, int]:
"""Resolve ``(when, slot)`` for ``portion``: now, or a future pre-log.
Split out of :func:`cmd_ate` to keep it under pylint's local-variable
limit.
Raises:
ValueError: ``portion.date``/``portion.hour`` are an invalid future
combination -- see :func:`diet_guard._slots.resolve_future_when`.
"""
if portion.date is None or portion.hour is None:
return None, slot_for_log(now_local(), schedule)
when = resolve_future_when(portion.date, portion.hour, schedule, now_local())
return when, slot_for_log(when, schedule)
def cmd_ate(
emit: Callable[[str], None],
print_summary: Callable[[], None],
@ -112,17 +139,30 @@ def cmd_ate(
per-reference macro basis is converted to the amount actually eaten first,
and the food is remembered so next time it is served from local history.
Passing ``--date``/``--hour`` (both required together) pre-logs the meal
against a future date+slot instead of now; see
:func:`diet_guard._slots.resolve_future_when`.
Args:
emit: A one-line output sink (``_cli._emit``, passed in rather than
imported -- see ``_cli_sync.cmd_sync`` for why).
print_summary: Prints today's total and remaining budget.
description: The food as the user typed it.
portion: How much was eaten.
portion: How much was eaten, and optionally when.
macros: Any manually supplied calories/macros.
Returns:
0 once logged, or 1 when the food could not be resolved at all.
0 once logged, or 1 when the food could not be resolved, or the
date/hour were missing/invalid.
"""
if (portion.date is None) != (portion.hour is None):
emit("--date and --hour must be given together.")
return 1
try:
when, slot = _resolve_target(portion, current_schedule())
except ValueError as exc:
emit(str(exc))
return 1
eaten, note = eaten_grams(description, portion)
if note is not None:
emit(note)
@ -148,13 +188,14 @@ def cmd_ate(
"re-run with --kcal <number> to log it manually.",
)
return 1
log_meal(description, nutrition, slot_for_log(now_local(), current_schedule()))
log_meal(description, nutrition, slot, when=when)
remember_food(description, nutrition)
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"
emit(
f"logged: {description} {nutrition.kcal:g} kcal "
f"({macro_str}) [{nutrition.source}, {portion_str}]",
f"({macro_str}) [{nutrition.source}, {portion_str}]"
f"{f' for {portion.date} {portion.hour:02d}:00' if when else ''}",
)
# Publish straight away rather than waiting for a periodic tick: until this
# lands, the phone still believes this slot is unlogged and will nag for it.

View File

@ -24,11 +24,10 @@ Callers resolve the value at the impure edge, mirroring how
from __future__ import annotations
from datetime import date, datetime, time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from diet_guard._meal_schedule import MealSchedule
_HOURS_PER_DAY = 24
@ -144,6 +143,46 @@ def slot_for_log(now: datetime, schedule: MealSchedule) -> int:
return current if current is not None else slots[-1]
def resolve_future_when(
date_str: str, hour: int, schedule: MealSchedule, now: datetime
) -> datetime:
"""Build a tz-aware datetime for a meal pre-logged against a future slot.
Lets a caller (the CLI's ``--date``/``--hour`` flags, or the app's future-
date picker) turn a user-picked date+hour into the ``when`` that
:func:`diet_guard._state.log_meal` expects, with the same validation on
both platforms. Still clock-free per the module's own rule: ``now`` is
supplied by the caller rather than read here.
Args:
date_str: The chosen date as ``YYYY-MM-DD``.
hour: The chosen slot hour; must be one of ``schedule``'s slot hours.
schedule: The schedule in force, used to validate ``hour``.
now: Reference local time, used to reject a non-future date.
Returns:
A tz-aware datetime combining ``date_str`` and ``hour``, in ``now``'s
timezone.
Raises:
ValueError: ``date_str`` doesn't parse, ``hour`` isn't a slot hour, or
the resulting date isn't strictly after ``now``'s date.
"""
try:
parsed_date = date.fromisoformat(date_str)
except ValueError as exc:
msg = f"invalid date {date_str!r}, expected YYYY-MM-DD"
raise ValueError(msg) from exc
slots = day_slots(schedule)
if hour not in slots:
msg = f"hour {hour} is not a meal slot; choose one of {slots}"
raise ValueError(msg)
if parsed_date <= now.date():
msg = f"{date_str} is not a future date"
raise ValueError(msg)
return datetime.combine(parsed_date, time(hour=hour), tzinfo=now.tzinfo)
def slot_label(slot: int) -> str:
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
return f"{slot % _HOURS_PER_DAY:02d}:00"

View File

@ -130,8 +130,9 @@ def log_meal(
slot: int | None = None,
*,
components: list[dict[str, object]] | None = None,
when: datetime | None = None,
) -> dict[str, object]:
"""Append a signed entry for ``description`` to today's log.
"""Append a signed entry for ``description`` to a day's log.
Args:
description: The user's free-text meal description.
@ -144,13 +145,25 @@ def log_meal(
food bank -- so a bank rebuilt purely by replaying the log (the
companion phone app's sync model) can recover every component's
standalone nutrition, not just the composite's summed total.
when: Pre-log this meal against a future date/time instead of now
(e.g. via ``diet_guard ate --date ... --hour ...``). Must not be
in the past. The entry's ``time`` and day bucket are both derived
from this value, so it lands under the future date and the gate
sees that slot as already satisfied once that day arrives.
Returns:
The stored entry dict (carrying an ``hmac`` field when a key exists).
Raises:
ValueError: ``when`` is before the current time.
"""
if when is not None and when < now_local():
msg = "cannot log a meal in the past"
raise ValueError(msg)
effective = when or now_local()
entry: dict[str, object] = {
"id": str(uuid.uuid4()),
"time": now_local().isoformat(timespec="seconds"),
"time": effective.isoformat(timespec="seconds"),
"desc": description,
"grams": nutrition.grams,
"kcal": nutrition.kcal,
@ -170,7 +183,7 @@ def log_meal(
_logger.warning("HMAC key unavailable - logging unsigned entry")
log = _read_raw_log()
log.setdefault(_today(), []).append(entry)
log.setdefault(effective.date().isoformat(), []).append(entry)
_write_log(log)
return entry

View File

@ -0,0 +1,59 @@
"""Tests for ``diet_guard ate --date/--hour`` — pre-logging a future meal.
Split out of test_cli.py's ``TestAte`` for the repo's 250-line cap.
"""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from unittest.mock import patch
from diet_guard import _cli_log
from diet_guard._budget import write_budget
from diet_guard._cli import main
from diet_guard._estimator import Nutrition
from diet_guard._state import load_log, now_local
if TYPE_CHECKING:
import pytest
_NUT = Nutrition(250, 12, 30, 10, 200, "manual")
class TestAteFutureDate:
"""Logging a meal against a future ``--date``/``--hour`` from the CLI."""
def test_future_date_and_hour_logs_under_that_date(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""--date/--hour together pre-log against a future slot."""
write_budget(2000)
tomorrow = (now_local() + timedelta(days=1)).date().isoformat()
with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT):
assert (
main(["ate", "future lunch", "--date", tomorrow, "--hour", "12"]) == 0
)
out = capsys.readouterr().out
assert "logged:" in out
assert tomorrow in out
assert load_log()[tomorrow][0]["slot"] == 12
def test_date_without_hour_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A lone --date without --hour is rejected before resolving food."""
assert main(["ate", "x", "--date", "2099-01-01"]) == 1
assert "must be given together" in capsys.readouterr().out
def test_invalid_future_hour_errors(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""An hour that isn't a configured slot is rejected."""
tomorrow = (now_local() + timedelta(days=1)).date().isoformat()
assert main(["ate", "x", "--date", tomorrow, "--hour", "13"]) == 1
assert "not a meal slot" in capsys.readouterr().out
def test_past_date_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A non-future date is rejected."""
yesterday = (now_local() - timedelta(days=1)).date().isoformat()
assert main(["ate", "x", "--date", yesterday, "--hour", "12"]) == 1
assert "not a future date" in capsys.readouterr().out

View File

@ -0,0 +1,64 @@
"""Tests for _slots.resolve_future_when — a pre-logged future meal's slot.
Split out of test_slots.py for the repo's 250-line cap.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._slots import day_slots, resolve_future_when
def _at(hour: int) -> datetime:
"""Return a fixed local datetime at ``hour`` (date is irrelevant here)."""
return datetime(2026, 1, 1, hour, 0, tzinfo=UTC)
class TestResolveFutureWhen:
"""Validation and construction for a pre-logged future meal's datetime."""
def test_valid_future_date_and_slot_hour(self) -> None:
"""A future date on a real slot hour resolves to that datetime."""
now = _at(10)
when = resolve_future_when("2026-01-02", 12, DEFAULT_SCHEDULE, now)
assert when == datetime(2026, 1, 2, 12, 0, tzinfo=UTC)
def test_preserves_nows_timezone(self) -> None:
"""The returned datetime carries ``now``'s tzinfo, not a bare one."""
now = _at(10)
when = resolve_future_when("2026-01-02", 8, DEFAULT_SCHEDULE, now)
assert when.tzinfo == now.tzinfo
def test_rejects_malformed_date(self) -> None:
"""A date string that doesn't parse raises ValueError."""
with pytest.raises(ValueError, match="invalid date"):
resolve_future_when("not-a-date", 8, DEFAULT_SCHEDULE, _at(10))
def test_rejects_hour_not_a_slot(self) -> None:
"""An hour that isn't one of the schedule's slots raises ValueError."""
with pytest.raises(ValueError, match="not a meal slot"):
resolve_future_when("2026-01-02", 13, DEFAULT_SCHEDULE, _at(10))
def test_rejects_todays_date(self) -> None:
"""Today's own date is not "future", even on a real slot hour."""
now = datetime(2026, 1, 1, 10, 0, tzinfo=UTC)
with pytest.raises(ValueError, match="not a future date"):
resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now)
def test_rejects_past_date(self) -> None:
"""A date before today raises ValueError."""
now = datetime(2026, 1, 5, 10, 0, tzinfo=UTC)
with pytest.raises(ValueError, match="not a future date"):
resolve_future_when("2026-01-01", 12, DEFAULT_SCHEDULE, now)
def test_accepts_every_configured_slot_hour(self) -> None:
"""Every hour the schedule actually opens is accepted, not just one."""
schedule = MealSchedule(8, 20, 5)
now = _at(10)
for hour in day_slots(schedule):
when = resolve_future_when("2026-01-02", hour, schedule, now)
assert when.hour == hour

View File

@ -7,9 +7,12 @@ defensive read paths are all exercised in isolation.
from __future__ import annotations
from datetime import timedelta
import json
from unittest.mock import patch
import pytest
from diet_guard import _state
from diet_guard._estimator import Nutrition
from diet_guard._state import (
@ -210,3 +213,23 @@ class TestIdAndComponents:
]
entry = log_meal("dinner", _nut(165), slot=20, components=parts)
assert entry["components"] == parts
class TestFutureLogging:
"""Pre-logging a meal against a future date/time via ``when``."""
def test_buckets_under_the_future_date(self) -> None:
"""A future ``when`` places the entry under that date, not today."""
future = now_local() + timedelta(days=1)
entry = log_meal("future meal", _nut(200), slot=12, when=future)
future_day = future.date().isoformat()
assert today_total_kcal() == 0.0
assert load_log()[future_day][0]["desc"] == "future meal"
assert entry["time"] == future.isoformat(timespec="seconds")
def test_rejects_a_past_when(self) -> None:
"""A ``when`` before now raises ValueError and writes nothing."""
past = now_local() - timedelta(days=1)
with pytest.raises(ValueError, match="in the past"):
log_meal("late", _nut(50), slot=8, when=past)
assert load_log() == {}