Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.

Expand Down
80 changes: 80 additions & 0 deletions docs/plans.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
19 changes: 13 additions & 6 deletions docs/status.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -52,9 +57,6 @@ pytest -q
ruff check .
```

## Current Blockers
- None.

## Audit Log
| Date | Milestone | Files | Commands | Result | Next |
| --- | --- | --- | --- | --- | --- |
Expand All @@ -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.
Expand All @@ -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.
34 changes: 34 additions & 0 deletions docs/test-plan.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
89 changes: 85 additions & 4 deletions nomnomcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 | "
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -344,16 +416,25 @@ 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
_print_log(result, args.json)
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":
Expand Down
Loading
Loading