diff --git a/app/lib/screens/log_meal_actions.dart b/app/lib/screens/log_meal_actions.dart index a7fadc7..ace0693 100644 --- a/app/lib/screens/log_meal_actions.dart +++ b/app/lib/screens/log_meal_actions.dart @@ -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 diff --git a/app/lib/screens/log_meal_future_mixin.dart b/app/lib/screens/log_meal_future_mixin.dart new file mode 100644 index 0000000..7a4527b --- /dev/null +++ b/app/lib/screens/log_meal_future_mixin.dart @@ -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 on State { + DateTime? _futureDate; + int? _futureSlot; + Set _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 _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 loggedSlots, + required int? selectedSlot, + required ValueChanged 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), + ); + } +} diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 9952a5d..58d24bb 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -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 WidgetsBindingObserver, LogMealSyncMixin, LogMealNavMixin, - LogMealKuchniaMixin { + LogMealKuchniaMixin, + LogMealFutureMixin { @override http.Client? get syncHttpClient => widget.httpClient; @@ -63,16 +64,7 @@ class _LogMealScreenState extends State 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 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 }); 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 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( diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index b4056b2..51b4cdf 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -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 logMeal( String desc, Nutrition nutrition, { int? slot, List? 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), diff --git a/app/lib/widgets/macro_input_row.dart b/app/lib/widgets/macro_input_row.dart index 54455e4..58fe3cb 100644 --- a/app/lib/widgets/macro_input_row.dart +++ b/app/lib/widgets/macro_input_row.dart @@ -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(); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e8f50c3..036cc7a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/screens/log_meal_screen_future_test.dart b/app/test/screens/log_meal_screen_future_test.dart new file mode 100644 index 0000000..d7605f7 --- /dev/null +++ b/app/test/screens/log_meal_screen_future_test.dart @@ -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 settle(WidgetTester tester) async { + await Future.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); + }); + }); +} diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index c72e29c..745bb62 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -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 { diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 26fc122..112b3db 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -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, diff --git a/diet_guard/_cli_args.py b/diet_guard/_cli_args.py index bcbad82..d1b5ecb 100644 --- a/diet_guard/_cli_args.py +++ b/diet_guard/_cli_args.py @@ -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.") diff --git a/diet_guard/_cli_log.py b/diet_guard/_cli_log.py index b47fd98..8339987 100644 --- a/diet_guard/_cli_log.py +++ b/diet_guard/_cli_log.py @@ -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 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. diff --git a/diet_guard/_slots.py b/diet_guard/_slots.py index 7b4fcd5..4a5a4b2 100644 --- a/diet_guard/_slots.py +++ b/diet_guard/_slots.py @@ -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" diff --git a/diet_guard/_state.py b/diet_guard/_state.py index 0b5e91d..b99331d 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -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 diff --git a/diet_guard/tests/test_cli_future_ate.py b/diet_guard/tests/test_cli_future_ate.py new file mode 100644 index 0000000..fc22331 --- /dev/null +++ b/diet_guard/tests/test_cli_future_ate.py @@ -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 diff --git a/diet_guard/tests/test_slots_future.py b/diet_guard/tests/test_slots_future.py new file mode 100644 index 0000000..e1a088b --- /dev/null +++ b/diet_guard/tests/test_slots_future.py @@ -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 diff --git a/diet_guard/tests/test_state.py b/diet_guard/tests/test_state.py index fc8c397..f702acc 100644 --- a/diet_guard/tests/test_state.py +++ b/diet_guard/tests/test_state.py @@ -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() == {}