diff --git a/README.md b/README.md index 0078334..a79e500 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ nomnom log --parse "rice 150 g, eggs 2 pieces" --json nomnom log --food "chickpeas, cooked" --grams 120 --json ``` +For a remembered meal from a prior local calendar day, pass an explicit ISO date to either form: + +```sh +nomnom log --parse "rice 150 g, eggs 2 pieces" --date 2026-07-20 --json +nomnom log --food "chickpeas, cooked" --grams 120 --date 2026-07-20 --json +``` + +`--date` accepts only `YYYY-MM-DD`; it never parses a time or a date from the food text. The date +is interpreted in the user's local timezone and stored deterministically at local noon on that +day. Successful log JSON includes the effective offset-aware `logged_at` and `local_date`, whether +or not `--date` was supplied. Without `--date`, logging keeps its existing current-local-time +behavior. Malformed, impossible, and future dates are rejected without a database write; today is +allowed. Use this CLI path for remembered meals—never edit the SQLite database directly. + Resolution is deterministic and ordered: 1. exact phrase in the user's alias table, pointing to an exact local cache name; @@ -301,10 +315,15 @@ alternatives, and assumptions before treating the resolution as confirmed. ```sh nomnom stats today --json nomnom stats week --json +nomnom stats date 2026-07-20 --json nomnom recipe add "https://example.com/recipe" --servings 4 --json nomnom recipe log "Recipe name" --portions 1.5 --json ``` +`stats date` uses the same user-local calendar-day boundary as `log --date`: local midnight is +inclusive and the next local midnight is exclusive. Existing `today` and `week` behavior is +unchanged. + Recipe ingredients use the same runtime resolver. An unresolved ingredient fails the whole import instead of storing partial nutrition. diff --git a/docs/plans.md b/docs/plans.md index e9e347c..2d02ff9 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,85 @@ # Plans +## Issue #27 Source +- Task: Add safe backdated meal logging and local-date-scoped stats. +- Canonical input: GitHub issue #27 and the user's strict TDD, smoke, commit, and no-push requirements. +- Repo context: CLI argument contracts, local-time timestamp handling, SQLite log queries, tests, README, and agent skill. +- Last updated: 2026-07-21 + +## Issue #27 Assumptions +- The process local timezone is the user local timezone; tests set `TZ` and call `time.tzset()` where available. +- A supplied date maps to local 12:00:00 with the offset active on that calendar day; it is persisted as the existing ISO-8601 `logged_at` field, so no schema migration is required. +- Date-scoped stats use a half-open interval from local midnight on the requested date to local midnight on the next date. +- `today` and `week`, existing rows, food cache, aliases, and recipes retain their current contracts. + +## Issue #27 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M21 | Freeze date parsing, timestamp, and no-write contracts | M20 | [x] | +| M22 | Implement backdated parsed/direct logging | M21 | [x] | +| M23 | Implement date-scoped stats and preserve old periods | M22 | [x] | +| M24 | Update guidance, validate, smoke, audit, and commit | M23 | [x] | + +## M21. Freeze date parsing, timestamp, and no-write contracts `[x]` +### Goal +- Controlled-timezone tests specify literal `2026-07-20`, deterministic local noon, malformed/impossible/future errors, and unchanged default behavior before production changes. + +### Tasks +- [x] Add CLI and database behavior tests. +- [x] Witness the focused tests fail for the expected missing behavior. + +### Definition of Done +- Tests cover both parsed and direct log forms, JSON date fields, adjacent-day exclusion, no-write failures, and no-date compatibility. + +### Validation +```sh +TZ=Asia/Almaty PYTHONPATH=. pytest -q tests/test_cli.py tests/test_db.py +``` + +### Known Risks +- Offset-aware ISO strings sort correctly only when query bounds use the same local timezone contract; DST boundaries require constructing each local midnight independently. + +### Stop-and-Fix Rule +- Do not modify production date behavior until the new focused tests are observed RED. + +## M22. Implement backdated parsed/direct logging `[x]` +### Goal +- `--date YYYY-MM-DD` safely stores local noon and returns effective `logged_at` plus `local_date` for either log form. + +### Validation +```sh +TZ=Asia/Almaty PYTHONPATH=. pytest -q tests/test_cli.py -k 'date or backdated' +``` + +### Stop-and-Fix Rule +- Any invalid/future input write or default-log regression blocks M23. + +## M23. Implement date-scoped stats and preserve old periods `[x]` +### Goal +- `stats date YYYY-MM-DD` returns only entries in the requested local calendar day while `today` and `week` remain compatible. + +### Validation +```sh +TZ=Asia/Almaty PYTHONPATH=. pytest -q tests/test_db.py tests/test_cli.py -k 'stats or date' +``` + +### Stop-and-Fix Rule +- Adjacent local-day leakage or a changed existing period contract blocks M24. + +## M24. Update guidance, validate, smoke, audit, and commit `[x]` +### Goal +- Humans and agents use the CLI rather than SQLite; all requested gates and literal disposable CLI commands pass before one local commit. + +### Validation +```sh +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + +### Stop-and-Fix Rule +- Do not commit until full tests, Ruff, checkout-import proof, clean disposable smoke, and diff/status audit pass. + ## Issue #23 Source - Task: Make no-key base mode a successful safe product and USDA an optional coverage enhancement. - Canonical input: GitHub issue #23 and the user's strict TDD, smoke, commit, and no-push requirements. diff --git a/docs/status.md b/docs/status.md index ae50296..8738257 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,12 +1,15 @@ # Status ## Snapshot -- Current phase: issue #23 complete +- Current phase: issue #27 implemented and independently verified - Plan file: `docs/plans.md` - Status: green - Last updated: 2026-07-21 ## Done +- Completed issue #27 with strict local-date parsing, deterministic local-noon backdating for both log forms, additive timestamp/date JSON, exact date stats, no-write failures, and preserved no-date/today/week behavior. +- Documented `--date` for remembered meals and that humans/agents must never manipulate SQLite directly. +- Passed 189 tests, Ruff, diff checks, checkout import proof, and the clean disposable installed-CLI literal smoke. - Fixed issue #17 with OFF v1-only full-text search, independent product/full-text probes, typed bounded 503 handling, deterministic contract coverage, and updated provider docs. - Built the complete package, 431-food database, 258-entry Russian synonym layer, CLI, recipes, installer, skill, docs, CI, and tests. - Passed 30 pytest tests and Ruff with no issues. @@ -21,12 +24,14 @@ - Passed 177 tests, Ruff, checkout-import guard, shell syntax, and the disposable checkout-built installer/provider-stub smoke. ## In Progress -- None. +- No implementation work pending; the feature is ready for the issue #27 pull request. ## Next -- None; issue #23 is complete and ready for the requested local commit. +- Push the scoped commit and open the requested pull request. ## Decisions Made +- Issue #27: reuse the existing offset-aware `logged_at` field; no migration is needed. +- Issue #27: explicit dates become local noon and stats use `[local midnight, next local midnight)` boundaries. - Issue #23: a healthy no-key install is complete base coverage; USDA is only an optional enhancement for broader no-photo generic/raw-food resolution. - Issue #23: PATH repair outranks base/enhanced installer completion status, but output must still describe the available coverage. - Default generic policy is `allow_for_unbranded`; this explicit user decision supersedes issue #19's older `ask` default. @@ -52,9 +57,6 @@ pytest -q ruff check . ``` -## Current Blockers -- None. - ## Audit Log | Date | Milestone | Files | Commands | Result | Next | | --- | --- | --- | --- | --- | --- | @@ -75,6 +77,10 @@ ruff check . | 2026-07-21 | M18 | installer statuses, capability JSON/human output, issue #22 isolation | focused RED/GREEN pytest | 10 installer tests pass before docs additions | M19 | | 2026-07-21 | M19 | OFF identity/provenance and no-key source error | focused RED/GREEN pytest | 79 resolver/OFF/CLI/capture tests pass | M20 | | 2026-07-21 | M20 | docs, import isolation, full suite, lint, disposable install | `pytest -q`; `ruff check .`; local offline installer smoke | 177 pass; clean; smoke pass | local commit | +| 2026-07-21 | issue #27 preflight | CLI, database, tests, docs, skill, planning records | baseline `pytest -q`; checkout import/executable inspection | 179 pass; clean baseline | M21 | +| 2026-07-21 | M21–M22 | CLI date tests and `nomnomcli/cli.py` | focused RED then GREEN pytest | 7 expected failures; then 7 pass | M23 | +| 2026-07-21 | M23 | database/CLI date stats tests and query implementation | focused RED then GREEN pytest | 2 expected failures; then 8 pass | M24 | +| 2026-07-21 | M24 | README, skill, changed code/tests, disposable installed checkout | focused/full pytest; Ruff; diff check; literal temp-DB smoke | 40/189 pass; clean; smoke 150 kcal | local commit | ## Smoke / Demo Checklist - [x] Fresh temp DB: help/version, capture label, alias, log, and invalid structured capture error. @@ -93,3 +99,4 @@ ruff check . - [x] Fresh no-token checkout install reports `installed_base_ready` with base coverage. - [x] Setup reports `base_ready/base` without USDA and `connected/enhanced` with the local USDA stub. - [x] Safe no-key miss returns `food_needs_source` with photo/barcode/label/cache options and optional USDA. +- [x] Literal parsed/direct `2026-07-20` logs persist local noon and date stats return only that local day. diff --git a/docs/test-plan.md b/docs/test-plan.md index 827d28b..3f1d876 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,39 @@ # Test Plan +## Issue #27 Source +- Task: Validate safe backdated logging and local-date-scoped stats. +- Plan file: `docs/plans.md` +- Status file: `docs/status.md` +- Last updated: 2026-07-21 + +## Issue #27 Validation Scope +- In scope: parsed/direct `--date`, strict ISO calendar-date validation, future rejection, deterministic local noon, additive log JSON fields, exact local-day stats, old today/week and no-date compatibility, docs, skill, and checkout CLI smoke. +- Out of scope: implicit date/time text parsing, schema changes, editing user data/aliases, recipe backdating, live providers, or personal data. + +## Issue #27 Environment / Fixtures +- Use temporary databases with synthetic cached food only. +- Set `TZ=Asia/Almaty` and use `time.tzset()` where practical so expected `2026-07-20T12:00:00+05:00` and day boundaries are deterministic. +- Use injected `now` values for database tests and a non-future literal date for CLI tests. + +## Issue #27 Test Levels + +### Unit / Integration +- Parse exact `YYYY-MM-DD`; reject malformed, impossible, and future dates with structured actionable errors and zero log writes. +- Persist parsed and direct logs at local noon and return effective `logged_at` and `local_date`. +- Query a half-open local-day interval that excludes both adjacent local days. +- Preserve default no-date timestamp behavior and today/week stats. + +### End-to-End / Smoke +- Exercise the checkout-installed `nomnom` command against a disposable database using the exact literal parsed log, direct log, and date stats forms. + +## Issue #27 Acceptance Gates +- [x] Focused behavior tests witnessed RED before implementation. +- [x] Focused tests pass under controlled TZ — 40 passed. +- [x] `PYTHONPATH=. pytest -q` passes — 189 passed. +- [x] `ruff check .` and `git diff --check` pass. +- [x] Checkout import/executable proof and disposable literal CLI smoke pass. +- [x] Scoped diff/status audit passed; ready for the scoped commit and pull request. + ## Issue #23 Source - Task: Validate safe no-key base mode and optional USDA enhancement UX. - Plan file: `docs/plans.md` diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index fd50b99..fa0ca0d 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -3,8 +3,10 @@ import argparse import json import math +import re import sys from collections.abc import Sequence +from datetime import date, datetime from nomnomcli import __version__ from nomnomcli.db import connect, get_stats, store_log @@ -20,6 +22,53 @@ def _json_output(payload: dict | list) -> str: return json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) +def _local_now() -> datetime: + return datetime.now().astimezone() + + +def _parse_local_date(value: str) -> date: + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise NomnomError( + "invalid_date", + "Date must be a valid local calendar date in YYYY-MM-DD format", + details={ + "value": value, + "expected_format": "YYYY-MM-DD", + "action": "Use a date such as 2026-07-20; times are not accepted.", + }, + ) + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise NomnomError( + "invalid_date", + "Date must be a valid local calendar date in YYYY-MM-DD format", + details={ + "value": value, + "expected_format": "YYYY-MM-DD", + "action": "Correct the impossible calendar date and try again.", + }, + ) from exc + if parsed > _local_now().date(): + raise NomnomError( + "future_date", + "Future dates are not allowed", + details={ + "value": value, + "expected_format": "YYYY-MM-DD", + "action": "Use today's local date or an earlier local calendar date.", + }, + ) + return parsed + + +def _effective_log_time(value: str | None) -> datetime: + if value is None: + return _local_now() + local_date = _parse_local_date(value) + return datetime(local_date.year, local_date.month, local_date.day, 12).astimezone() + + def _nutrition_line(totals: dict) -> str: return ( f"{totals['kcal']:.2f} kcal | " @@ -95,10 +144,12 @@ def _build_parser() -> argparse.ArgumentParser: form.add_argument("--parse", metavar="TEXT", help="comma-separated food phrases") form.add_argument("--food", metavar="NAME", help="food name for direct logging") log.add_argument("--grams", type=float, help="grams for --food") + log.add_argument("--date", help="local calendar date in YYYY-MM-DD; stored at local noon") log.add_argument("--json", action="store_true", help="machine-readable JSON output") stats = commands.add_parser("stats", help="show nutrition totals") - stats.add_argument("period", choices=("today", "week")) + stats.add_argument("period", choices=("today", "week", "date")) + stats.add_argument("date", nargs="?", help="local YYYY-MM-DD when period is date") stats.add_argument("--json", action="store_true", help="machine-readable JSON output") search = commands.add_parser("search", help="search the user food cache") @@ -168,6 +219,27 @@ def _build_parser() -> argparse.ArgumentParser: def _run(args: argparse.Namespace) -> int: + log_time = _effective_log_time(args.date) if args.command == "log" else None + stats_date = None + if args.command == "stats": + if args.period == "date": + if args.date is None: + raise NomnomError( + "date_required", + "A local calendar date is required for date-scoped stats", + details={ + "expected_format": "YYYY-MM-DD", + "action": "Run nomnom stats date YYYY-MM-DD.", + }, + ) + stats_date = _parse_local_date(args.date) + elif args.date is not None: + raise NomnomError( + "invalid_arguments", + "A calendar date can only be used with the date stats period", + details={"action": "Use nomnom stats date YYYY-MM-DD."}, + ) + if args.command == "setup": if args.status: result = setup_status_report() @@ -344,8 +416,15 @@ def _run(args: argparse.Namespace) -> int: resolved = parse_free_text(args.parse, repository) items = [item.to_dict() for item in resolved] totals = total_items(resolved) - log_id = store_log(connection, items, totals) - result = {"items": items, "totals": totals, "log_id": log_id} + log_id = store_log(connection, items, totals, logged_at=log_time) + logged_at = log_time.isoformat(timespec="seconds") + result = { + "items": items, + "totals": totals, + "log_id": log_id, + "logged_at": logged_at, + "local_date": log_time.date().isoformat(), + } assumptions = [item["assumption"] for item in items if item.get("assumption")] if assumptions: result["assumptions"] = assumptions @@ -353,7 +432,9 @@ def _run(args: argparse.Namespace) -> int: return 0 if args.command == "stats": - _print_stats(get_stats(connection, args.period), args.json) + _print_stats( + get_stats(connection, args.period, local_date=stats_date), args.json + ) return 0 if args.command == "search": diff --git a/nomnomcli/db.py b/nomnomcli/db.py index ac54802..156a20c 100644 --- a/nomnomcli/db.py +++ b/nomnomcli/db.py @@ -5,7 +5,7 @@ import sqlite3 from collections.abc import Iterator from contextlib import contextmanager -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from pathlib import Path LATEST_SCHEMA_VERSION = 4 @@ -241,12 +241,38 @@ def period_start(period: str, now: datetime | None = None) -> datetime: raise ValueError(f"unsupported period: {period}") -def get_stats(connection: sqlite3.Connection, period: str, now: datetime | None = None) -> dict: - start = period_start(period, now) - rows = connection.execute( - "SELECT * FROM log_entries WHERE logged_at >= ? ORDER BY logged_at, id", - (start.isoformat(timespec="seconds"),), - ).fetchall() +def local_day_bounds(local_date: date) -> tuple[datetime, datetime]: + next_date = local_date + timedelta(days=1) + start = datetime(local_date.year, local_date.month, local_date.day).astimezone() + end = datetime(next_date.year, next_date.month, next_date.day).astimezone() + return start, end + + +def get_stats( + connection: sqlite3.Connection, + period: str, + now: datetime | None = None, + *, + local_date: date | None = None, +) -> dict: + end = None + if period == "date": + if local_date is None: + raise ValueError("local_date is required for the date period") + start, end = local_day_bounds(local_date) + rows = connection.execute( + """SELECT * FROM log_entries + WHERE julianday(logged_at) >= julianday(?) + AND julianday(logged_at) < julianday(?) + ORDER BY logged_at, id""", + (start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")), + ).fetchall() + else: + start = period_start(period, now) + rows = connection.execute( + "SELECT * FROM log_entries WHERE logged_at >= ? ORDER BY logged_at, id", + (start.isoformat(timespec="seconds"),), + ).fetchall() meals = [] totals = {key: 0.0 for key in ("kcal", "protein", "fat", "carbs")} for row in rows: @@ -263,9 +289,13 @@ def get_stats(connection: sqlite3.Connection, period: str, now: datetime | None "totals": meal_totals, } ) - return { + result = { "period": period, "from": start.isoformat(timespec="seconds"), "totals": {key: round(value, 2) for key, value in totals.items()}, "meals": meals, } + if end is not None: + result["to"] = end.isoformat(timespec="seconds") + result["local_date"] = local_date.isoformat() + return result diff --git a/skill/SKILL.md b/skill/SKILL.md index 4ba1b35..68a468a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -58,12 +58,14 @@ Follow this exact sequence: 2. Translate each item to the language-agnostic contract: food name + quantity + unit + optional modifiers. Translate language, not nutrition. 3. Run `nomnom log --parse "FOOD QUANTITY UNIT, FOOD QUANTITY UNIT" --json`. + For a remembered prior local day, append `--date YYYY-MM-DD`; never infer a date or time. 4. Show returned canonical names, grams, confidence, totals, `alternatives`, and - every `assumptions` entry. + every `assumptions` entry, plus `logged_at` and `local_date`. 5. Ask the user to confirm the resolution before relying on it. Successful logs are stored immediately. Do not silently correct or rerun one; -tell the user before creating a replacement entry. +tell the user before creating a replacement entry. Never read, edit, or manipulate SQLite directly; +use `nomnom log --date` for remembered meals and the CLI for all user data operations. Supported dish prefixes split only named ingredients. Never add oil or another missing ingredient. Size words are parser syntax; piece grams must come from the @@ -176,6 +178,7 @@ a credential value. ```sh nomnom stats today --json nomnom stats week --json +nomnom stats date YYYY-MM-DD --json ``` Summarize only returned values. diff --git a/tests/conftest.py b/tests/conftest.py index 78a5c67..d54b9ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import time from pathlib import Path import pytest @@ -16,6 +18,23 @@ def isolate_user_provider_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) +@pytest.fixture +def almaty_timezone(monkeypatch: pytest.MonkeyPatch): + if not hasattr(time, "tzset"): + pytest.skip("controlled process timezones require time.tzset") + original = os.environ.get("TZ") + monkeypatch.setenv("TZ", "Asia/Almaty") + time.tzset() + try: + yield + finally: + if original is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original + time.tzset() + + @pytest.fixture def user_db(tmp_path: Path) -> Path: return tmp_path / "user.sqlite3" diff --git a/tests/test_cli.py b/tests/test_cli.py index d022e8b..0cd82ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,13 @@ from __future__ import annotations import json +from datetime import datetime import pytest import requests from nomnomcli.cli import main -from nomnomcli.db import connect +from nomnomcli.db import connect, store_log from nomnomcli.errors import NomnomError from nomnomcli.models import Food from nomnomcli.off import OpenFoodFactsClient @@ -103,6 +104,161 @@ def test_cli_log_and_stats_json(seeded_user_db, monkeypatch, capsys): assert stats["totals"] == logged["totals"] +@pytest.mark.parametrize( + "arguments", + [ + ["--parse", "борщ 100 г"], + ["--food", "borscht", "--grams", "100"], + ], + ids=("parsed", "direct"), +) +def test_cli_backdated_log_uses_local_noon_and_returns_effective_date( + seeded_user_db, monkeypatch, capsys, almaty_timezone, arguments +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(seeded_user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:30:00+05:00"), + raising=False, + ) + + code = main(["log", *arguments, "--date", "2026-07-20", "--json"]) + result = json.loads(capsys.readouterr().out) + + assert code == 0 + assert result["logged_at"] == "2026-07-20T12:00:00+05:00" + assert result["local_date"] == "2026-07-20" + with connect(seeded_user_db) as connection: + row = connection.execute("SELECT logged_at FROM log_entries").fetchone() + assert row["logged_at"] == result["logged_at"] + + +def test_cli_log_without_date_keeps_current_time_behavior_and_returns_effective_date( + seeded_user_db, monkeypatch, capsys, almaty_timezone +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(seeded_user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:34:56+05:00"), + raising=False, + ) + + assert main(["log", "--food", "borscht", "--grams", "100", "--json"]) == 0 + result = json.loads(capsys.readouterr().out) + + assert result["logged_at"] == "2026-07-21T08:34:56+05:00" + assert result["local_date"] == "2026-07-21" + + +def test_cli_explicit_today_is_allowed( + seeded_user_db, monkeypatch, capsys, almaty_timezone +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(seeded_user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:34:56+05:00"), + raising=False, + ) + + assert ( + main( + [ + "log", + "--food", + "borscht", + "--grams", + "100", + "--date", + "2026-07-21", + "--json", + ] + ) + == 0 + ) + result = json.loads(capsys.readouterr().out) + + assert result["logged_at"] == "2026-07-21T12:00:00+05:00" + assert result["local_date"] == "2026-07-21" + + +@pytest.mark.parametrize( + ("value", "error_code"), + [ + ("20-07-2026", "invalid_date"), + ("2026-07-20T10:30:00", "invalid_date"), + ("2026-02-30", "invalid_date"), + ("2026-07-22", "future_date"), + ], +) +def test_cli_invalid_or_future_log_date_is_actionable_and_makes_no_write( + user_db, monkeypatch, capsys, almaty_timezone, value, error_code +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:30:00+05:00"), + raising=False, + ) + + code = main( + ["log", "--food", "anything", "--grams", "100", "--date", value, "--json"] + ) + captured = capsys.readouterr() + error = json.loads(captured.err)["error"] + + assert code == 2 + assert captured.out == "" + assert error["code"] == error_code + assert error["details"]["value"] == value + assert error["details"]["expected_format"] == "YYYY-MM-DD" + assert error["details"]["action"] + assert not user_db.exists() + + +def test_cli_stats_date_returns_only_requested_local_day( + user_db, monkeypatch, capsys, almaty_timezone +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:30:00+05:00"), + raising=False, + ) + item = { + "name": "test food", + "grams": 100.0, + "kcal": 55.0, + "protein": 2.5, + "fat": 2.1, + "carbs": 6.4, + "match_confidence": 1.0, + } + totals = {"kcal": 55.0, "protein": 2.5, "fat": 2.1, "carbs": 6.4} + with connect(user_db) as connection: + store_log( + connection, + [item], + totals, + logged_at=datetime.fromisoformat("2026-07-20T12:00:00+05:00"), + ) + store_log( + connection, + [item], + totals, + logged_at=datetime.fromisoformat("2026-07-21T00:00:00+05:00"), + ) + + code = main(["stats", "date", "2026-07-20", "--json"]) + result = json.loads(capsys.readouterr().out) + + assert code == 0 + assert result["local_date"] == "2026-07-20" + assert result["totals"]["kcal"] == 55 + assert [meal["logged_at"] for meal in result["meals"]] == [ + "2026-07-20T12:00:00+05:00" + ] + + def test_cli_unknown_food_json_error(user_db, monkeypatch, capsys): monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) monkeypatch.setenv("NOMNOM_OFFLINE", "1") diff --git a/tests/test_db.py b/tests/test_db.py index c124eac..a4bdb79 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,7 +1,7 @@ from __future__ import annotations import sqlite3 -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta import pytest @@ -401,6 +401,45 @@ def test_empty_stats(user_db): assert result["totals"]["kcal"] == 0 +def test_date_stats_use_exact_local_day_boundaries(user_db, almaty_timezone): + with connect(user_db) as connection: + store_log( + connection, + [ITEM], + TOTALS, + logged_at=datetime.fromisoformat("2026-07-19T23:59:59+05:00"), + ) + store_log( + connection, + [ITEM], + TOTALS, + logged_at=datetime.fromisoformat("2026-07-20T00:00:00+05:00"), + ) + store_log( + connection, + [ITEM], + TOTALS, + logged_at=datetime.fromisoformat("2026-07-20T23:59:59+05:00"), + ) + store_log( + connection, + [ITEM], + TOTALS, + logged_at=datetime.fromisoformat("2026-07-21T00:00:00+05:00"), + ) + + result = get_stats(connection, "date", local_date=date(2026, 7, 20)) + + assert result["local_date"] == "2026-07-20" + assert result["from"] == "2026-07-20T00:00:00+05:00" + assert result["to"] == "2026-07-21T00:00:00+05:00" + assert result["totals"]["kcal"] == 110 + assert [meal["logged_at"] for meal in result["meals"]] == [ + "2026-07-20T00:00:00+05:00", + "2026-07-20T23:59:59+05:00", + ] + + def test_connect_migrates_v01_food_cache(user_db): with sqlite3.connect(user_db) as connection: connection.execute(