# CalCount — Implementation Plan Ordered tickets for delegation. Each ticket is one coherent API surface or UI flow, sized for a single work session. Acceptance criteria are phrased to be verifiable with curl/pytest (backend) or a browser (frontend). Dependencies are sequential unless noted — do them in order. Milestones: - **M1 — Manual calorie tracker** (tickets 001–005): app is usable for manual food entry and daily logging. - **M2 — Barcode scanning & OFF integration** (ticket 006). - **M3 — Meals** (ticket 007). - **M4 — Food library management** (ticket 008). Definition of done for every ticket (spec §8.4): `pytest` green for backend changes; scanner/log changes additionally need the real-phone checklist. --- # TICKET-001: Foods CRUD (create, read, update, soft-delete) **Milestone:** M1 **Depends on:** none (scaffold only) **Spec references:** §2.1 (foods table), §3.1 (API), §8.1 rules 1–3, 5, 7, 11 ## Goal Complete the foods write path so a food can be created, read, updated, and soft-deleted via the API. After this ticket, all endpoints in spec §3.1 work except `restore` (which ships with the food library ticket). ## Acceptance criteria - `POST /api/foods` creates a food and returns it (201). Validates per §8.1 rule 11: `calories_per_unit` required when `is_meal=false`, `unit_type`/`source` constrained, quantities positive. Invalid bodies get a 422 with a useful error. - `GET /api/foods/{id}` returns a single food, including soft-deleted ones (historical log rendering depends on this — §2.1). 404 for unknown id. - `GET /api/foods` supports `q` (matches name **and** brand), `barcode` (exact match), `limit`/`offset` (defaults 50/0), and `include_deleted=true`. Soft-deleted foods are excluded by default (§8.1 rule 7). - `PUT /api/foods/{id}` updates editable fields (name, brand, serving info, nutrition, etc.) and bumps `updated_at`. 404 for unknown id. - `DELETE /api/foods/{id}` sets `deleted_at` (soft-delete — never hard-delete, §2.1). The food then disappears from search but is still returned by `GET /api/foods/{id}`. - Barcode uniqueness is respected: creating a food with a barcode that already exists (on a live food) fails with a 409, not a 500. - ORM objects never leave the service layer; handlers stay thin (§8.1 rules 2–3). - `pytest` green, including new tests covering: create + read round-trip, validation failures, soft-delete hidden from search but visible by id, barcode conflict. ## Implementation notes - `FoodCreate` schema exists in `schemas.py` but is incomplete — extend it and add update/read variants as needed. The schema is the contract (§8.3 rule 1): change it first, then the code. - Soft-delete filtering should be a shared query helper, not per-endpoint filter clauses (§8.1 rule 7) — this is the first real consumer of that pattern, so set it up properly. - `GET /api/foods/{id}` for meals should eventually include computed nutrition/components — **don't build that here**; return the flat food row and leave meal resolution for the meals ticket. - `barcode` unique-when-set: SQLite treats NULLs as distinct, so the existing UNIQUE column already allows multiple barcode-less foods. The restore-on-rescan rule (§3.1) is out of scope here — it belongs to the scanning ticket. - No OFF interaction in this ticket. `source="manual"` is the expected path; don't special-case `"meal"` foods beyond letting the CHECK constraint do its job. - Router prefix/structure is already in place in `routers/foods.py`; the existing `list_foods` endpoint is a scaffold and can be replaced. ## Out of scope - Meals (`is_meal=true`) component handling, cycle checks - `POST /api/foods/{id}/restore` and the restore-on-rescan flow - `GET /api/foods/recent` - Any frontend work ## Verify ```bash cd backend && uv run pytest # plus a manual smoke test with curl against POST/PUT/DELETE on a dev server ``` --- # TICKET-002: Targets CRUD **Milestone:** M1 **Depends on:** none (independent of 001; do after it for consistency) **Spec references:** §2.4 (targets table), §3.4 (API), §8.1 rules 2, 3, 11 ## Goal Users can set and update their daily nutritional targets, with history preserved. After this ticket, `GET /api/targets/current` stops 404ing and the dashboard has something to compare against. ## Acceptance criteria - `POST /api/targets` creates a new target and auto-closes the previous one (sets its `end_date`). Exactly one row has `end_date IS NULL` at any time — enforced in application logic (§2.4). - `GET /api/targets` lists all targets (history), ordered by `start_date`. - `GET /api/targets/current` returns the active target; 404 if none exists. - `PUT /api/targets/{id}` updates a target (values and/or date range). The single-active-target invariant still holds after any update. - Validation per §8.1 rule 11: `calories` positive integer, macros non-negative, dates parse, `end_date` after `start_date` when set. - The auto-close on create happens in one transaction — no window with zero or two active targets. - `pytest` green, including tests for: create → current round-trip, auto-close of previous target, invariant maintained after updates, validation failures. ## Implementation notes - Routers stay thin; the single-active-target invariant lives in the service layer (§8.1 rules 2, 6). - What "auto-close" means for `end_date` (same day vs. day before the new `start_date`) is a judgement call — pick one, document it in the service docstring, and make sure historical lookup by date range stays unambiguous. - `start_date` is client-supplied `YYYY-MM-DD` like all dates (§8.1 rule 8). - Historical lookup ("target whose range contains a given log date", §2.4) is needed by the summary ticket — expose it as a service function even if no endpoint uses it yet. ## Out of scope - Additional macro fields beyond the existing columns (fiber etc. — §2.4 notes) - Any frontend work ## Verify ```bash cd backend && uv run pytest # curl: create two targets, confirm the first is closed and /current returns the second ``` --- # TICKET-003: Daily log write path **Milestone:** M1 **Depends on:** TICKET-001 (log entries reference foods) **Spec references:** §2.3 (daily_log table), §3.3 (API), §8.1 rules 2, 3, 6, 8, 11 ## Goal Foods can be logged to a day, edited, and removed. After this ticket the core daily-logging loop works end to end at the API level. ## Acceptance criteria - `POST /api/log` adds an entry `{ food_id, quantity, meal_slot?, date }` and returns it (201). `quantity` must be positive; `meal_slot` constrained to the four slots; `date` is client-supplied `YYYY-MM-DD` (§8.1 rule 8). 404/422 for unknown `food_id` (including soft-deleted foods — you can't log what you can't search). - `PUT /api/log/{id}` updates `quantity`, `meal_slot`, and `sort_order`. 404 for unknown id. - `DELETE /api/log/{id}` removes an entry (hard delete is fine here — §2.3 has no soft-delete). 404 for unknown id. - `GET /api/log?date=` returns entries for the day ordered by `sort_order`, then id, each with the referenced food embedded (name, brand, unit_type, serving info) so the frontend doesn't need N+1 lookups. Soft-deleted foods still render here (§2.1). - Meal entries (a `food_id` pointing at an `is_meal` food) are accepted by all of the above — full meal explosion is the meals ticket, but logging a meal must not break. - `pytest` green, including tests for: add/update/delete round-trip, validation failures, ordering, embedded food data present, soft-deleted food still renders in history. ## Implementation notes - `LogEntryCreate`/`LogEntryRead` schemas exist but `LogEntryRead` needs the embedded food — design that shape now since the frontend will mirror it (§8.3 rule 1). - `sort_order` defaulting for new entries (e.g. append to end of day/slot) is a service-layer decision — keep it simple and document it. - Logging a food with `unit_type="count"` vs `"weight"` needs no special handling at this layer — `quantity` interpretation is the nutrition service's job (§2.1). ## Out of scope - Computed nutrition in log responses (ticket 004) - Meal explosion/nesting in responses (ticket 007) - `GET /api/foods/recent` (uses daily_log, but belongs with the search/scan UX in ticket 006) - Any frontend work ## Verify ```bash cd backend && uv run pytest # curl: create a food, log it to a date, update quantity, delete it ``` --- # TICKET-004: Day summary endpoint (totals vs. target) **Milestone:** M1 **Depends on:** TICKET-002 (targets), TICKET-003 (log entries) **Spec references:** §2.1 (quantity interpretation), §3.3 (`/api/log/summary`), §8.1 rule 1 ## Goal `GET /api/log/summary?date=` returns the day's computed nutrition totals alongside the target in effect for that date. This is the first real consumer of the nutrition service. ## Acceptance criteria - Response includes: summed calories (and protein/carbs/fat at minimum — the other columns are cheap, include them if convenient) across all entries for the date, plus the applicable target (the one whose date range contains the log date — §2.4), or null if none. - `quantity` is resolved per food context (§2.1): grams/100 for weight-type, per-item for count-type. This math lives only in `services/nutrition.py` (§8.1 rule 1). - Foods with null nutrition fields contribute 0 for those fields, not an error. - Meal entries in the log: **temporarily** contribute their flat `calories_per_unit`-style values only if present (they'll be null per the CHECK constraint — so meals contribute 0 for now). Add a clearly-marked TODO; real meal nutrition lands in ticket 007. Do not half-implement meal recursion here. - Works for arbitrary past/future dates, using the historical target lookup from ticket 002. - `pytest` green, with tests that are the start of the "test the math" suite (§8.4): weight-type scaling, count-type scaling, mixed entries, null nutrition fields, correct target selected for historical dates, no-target case. ## Implementation notes - The response shape is a new schema — design it as the contract the frontend ProgressBar will consume (§8.3 rule 1). Remaining-vs-target arithmetic: compute it server-side or leave it to the frontend, but pick one and document it. - This ticket should grow `services/nutrition.py`, not put math in the router or a summary-specific helper (§8.1 rule 1). ## Out of scope - Meal nutrition derivation (ticket 007) - Any frontend work ## Verify ```bash cd backend && uv run pytest # curl: seed a target + a few log entries, check totals and target selection for a past date ``` --- # TICKET-005: Frontend daily view (dashboard, progress bar, log entries, manual add) **Milestone:** M1 — after this ticket the app is usable for manual tracking **Depends on:** TICKET-003, TICKET-004 (TICKET-001 transitively) **Spec references:** §4.5 (manual food creation), §4.6 (dashboard), §8.2 (all frontend rules) ## Goal The main screen shows today's progress against the target and the day's entries, and the user can create a food manually and log it — the full manual-tracking loop in the UI. ## Acceptance criteria - Dashboard shows: progress bar (consumed vs. target calories, remaining), entries grouped loosely by `meal_slot`, each entry showing food name, quantity (serving-aware where set), and calories. - Entries can be edited (quantity, meal slot) and deleted from the UI. - "Add Food" flow: manual creation form (name, brand, calories per unit, unit type, serving info, optional macros — §4.5) that saves and is immediately loggable. - Logging flow: from search results of local foods (a simple search box hitting `GET /api/foods?q=` is enough — full search UX is ticket 006), pick a food, edit quantity (defaulting to the food's serving where set), confirm → logged to the current date. - Date navigation: view previous/next days, not just today. Dates are `YYYY-MM-DD` strings throughout (§8.3 rule 4). - All async views handle loading / error / empty states (§8.2 rule 4). Backend unreachable → clear error, not a blank page. - All HTTP through `lib/api.js`; shared state (current date, today's log, current target) in stores; mutation flow component → api → store (§8.2 rules 1, 3). - Formatting via `lib/format.js` only (§8.2 rule 7); Svelte 5 runes style (§8.2 rule 8). - Mobile-first layout (§8.2 rule 5) — check it at phone width in devtools at minimum. - Backend never re-derives nutrition in the frontend beyond the permitted live-preview exception (§8.2 rule 2). ## Implementation notes - Placeholder components exist for Dashboard, ProgressBar, LogEntry, FoodEditor, FoodSearch — fill these in rather than inventing new structure. App.svelte currently has a trivial health check; replace with real layout/routing (simple conditional view switching is fine; no router library needed yet). - Wire up the `api.js` methods that already exist; add the missing ones (update entry, create target if you surface targets UI — optional here). - Setting the initial target needs *some* UI or the progress bar has nothing to compare against — a minimal target form is acceptable; polish is not required. - Live quantity preview while editing is the one place frontend math is allowed (§8.2 rule 2) — display only. ## Out of scope - Barcode scanning, OFF integration, recent foods (ticket 006) - Meals: creation, unpacking, collapsible meal rows (ticket 007) - Food library management view (ticket 008) - Component tests (none in v1 — §8.4); extend vitest only if stores/format grow real logic ## Verify ```bash cd backend && uv run pytest # still green cd frontend && npm test && npm run build # manual: run both servers, do the full loop — set target, add food, log it, # edit quantity, delete it, navigate dates — at desktop and phone widths ``` --- # TICKET-006: OFF normalization + barcode scan & search flows **Milestone:** M2 **Depends on:** TICKET-005 (uses FoodEditor/FoodSearch UI and the foods write path) **Spec references:** §3.5 (OFF proxy), §4.1 (scan flow), §4.2 (search flow), §8.1 rule 10, §8.2 rules 4–6, §8.4 (phone checklist) ## Goal The primary input flow works: scan a barcode (or search OFF by text), get a pre-filled editable form, confirm → food saved locally and logged. Camera permission failure degrades gracefully to manual barcode entry. ## Acceptance criteria **Backend:** - OFF responses are normalized to our `foods` schema shape in exactly one module (§8.1 rule 10), including kcal-vs-kJ mapping. No local DB write on lookup — the frontend confirms first (§3.5). - `GET /api/off/product/{barcode}` returns normalized data or a clean not-found; `GET /api/off/search?q=` returns normalized results. User-Agent and timeouts in place (already scaffolded). - Restore-on-rescan rule (§3.1): saving a food whose barcode belongs to a soft-deleted food clears `deleted_at` and updates that row instead of inserting a duplicate. - `POST /api/off/refresh/{food_id}` re-fetches by stored barcode and updates the local row (§3.5). Sensible behaviour when the food has no barcode or OFF no longer has it. - OFF proxy tests mock at the httpx boundary — never hit the real API (§8.4). **Frontend:** - BarcodeScanner component: camera via `getUserMedia`, native `BarcodeDetector` where available, zxing-wasm fallback (§1), decode loop throttled ~3–5 fps. - Scan flow per §4.1: local DB check first → found: confirm + log (with optional "Refresh from OpenFoodFacts"); not found: OFF lookup → pre-filled FoodEditor → confirm saves + logs; OFF miss: clear "not found" + manual entry / text search offered. - Permission denied / no camera / decode failure → clear message + manual barcode text input fallback (§4.1, §8.2 rule 4). - Camera stream and decode loop stop on component destroy; re-entering the scanner works (§8.2 rule 6). - Free-text search per §4.2: local results first, OFF fallback offered when local is empty. - `GET /api/foods/recent` implemented (backend) and surfaced in the UI — it's part of making logging fast (§3.1). ## Implementation notes - Decide the OFF→foods field mapping once, in the normalization module, with tests pinning the kcal/kJ behaviour on real-shaped (recorded, not live) OFF responses. - zxing-wasm is already installed; keep the WASM loading lazy so the native path never pays for it. - `lib/scanner.js` is a documented stub — implement behind a clean `startScanner(videoEl, callbacks) → stop()` style interface so the component stays thin. - HTTPS is required for camera on a phone — coordinate with the user for Caddy access before the manual checklist; the checklist (§8.4) is part of done. ## Out of scope - Meals (ticket 007) - Food library management (ticket 008) ## Verify ```bash cd backend && uv run pytest # incl. mocked-httpx OFF tests cd frontend && npm test && npm run build # real-phone checklist from §8.4 (permission granted/denied, Android Chrome, # iOS Safari, EAN-13 + UPC-A) — requires Caddy HTTPS in place ``` --- # TICKET-007: Meals (components, from-log, unpack, editing) **Milestone:** M3 **Depends on:** TICKET-005 (log UI exists), TICKET-004 (summary must learn real meal nutrition) **Spec references:** §2.2 (meal_components), §3.2 (API), §4.3 (create), §4.4 (unpack), §8.1 rule 6, §8.4 (test the money paths) ## Goal Users can save a set of log entries as a reusable meal, log meals, unpack them back into individual entries, and edit a meal's components. Meal nutrition is derived live from components everywhere it appears. ## Acceptance criteria **Backend:** - `POST /api/meals/from-log` runs as one transaction (§8.1 rule 6): creates the meal food + components, deletes the source entries, inserts one replacement entry. Rolls back entirely on failure. - `POST /api/meals/{meal_id}/unpack` (transactional): replaces a logged meal entry with component entries, each component quantity × the original entry's scaling factor (§3.2), resolving nested meals recursively. - `PUT /api/meals/{meal_id}/components` replaces the component list wholesale, cycle-checked (§2.2). - Meal nutrition is computed by recursive component summation in `services/nutrition.py` (§8.1 rule 1), with cycle detection. Summary (ticket 004), `GET /api/foods/{id}`, and `GET /api/log` responses all show real derived meal nutrition; log responses include meal components nested for the collapsible UI (§3.3). - The ticket-004 TODO (meals contributing 0) is removed. - Cycle attempts (meal containing itself, directly or transitively) are rejected with a 422. - `pytest` green with the §8.4 "money path" suite: recursion depth, nested meals, cycle rejection, from-log/unpack happy paths, and **rollback on failure** for both transactional endpoints. ## Acceptance criteria (frontend) - "Save as Meal" flow per §4.3: multi-select today's entries, name prompt, confirm → entries replaced by the meal. - Meal log rows render collapsible: collapsed = meal name + total calories; expanded = components with quantities (§3.3). - "Unpack" action per §4.4 on a logged meal entry. - Meal component editing (add/remove/change quantities) exposed from the food editor for `is_meal` foods (§4.7). ## Implementation notes - This is the highest-complexity ticket — the recursion, cycle detection, and transaction boundaries are the spec's named testing priorities. Build the service layer first with direct unit tests, then wire routers. - Nested meals can contain meals: resolution must be recursive with a visited-set, both for nutrition and for cycle checks on write. - Unpack quantity scaling (§3.2) is easy to get wrong — pin it with a dedicated test (1.5× meal → 1.5× each component). - Editing a component food retroactively changes historical meals (§2.1) — that's intended; don't snapshot. ## Out of scope - Food library browse/restore UI (ticket 008) — though meal editing shares its editor ## Verify ```bash cd backend && uv run pytest # full suite incl. rollback tests # manual: build a meal from today's entries, log it, unpack it at 1.5×, # try to create a cycle, confirm summary totals update throughout ``` --- # TICKET-008: Food library management view + restore **Milestone:** M4 **Depends on:** TICKET-001 (soft-delete), TICKET-007 (meal editing uses the same editor) **Spec references:** §4.7 (manage food library), §3.1 (restore endpoint, include_deleted) ## Goal A "Foods" nav view where the user browses, searches, edits, deletes, and restores their local food database. ## Acceptance criteria - `POST /api/foods/{id}/restore` clears `deleted_at` (backend; §3.1). 404 for unknown id, sensible response if the food isn't deleted. - Frontend "Foods" view: paginated, searchable list of all local foods using `GET /api/foods` with `limit`/`offset`; each row shows name, brand, calories, edit + delete buttons (§4.7). - Edit opens the same FoodEditor used in scan/search flows; meals expose component editing (already built in ticket 007). - Delete confirms first, then soft-deletes — the row disappears from the default list. - A toggle shows soft-deleted foods (`include_deleted=true`); deleted rows are visually distinct and offer restore. - Loading/error/empty states throughout (§8.2 rule 4); mobile-first (§8.2 rule 5). - `pytest` green with restore tests (restore clears flag, food reappears in search, history intact). ## Implementation notes - This is the last v1 surface — reuse, don't rebuild: FoodEditor, api.js patterns, stores. - Pagination UI can be minimal (prev/next + count is fine). - If ticket 006's restore-on-rescan left any edge cases (e.g. restoring a food whose barcode now conflicts), surface and resolve them here. ## Out of scope - CSV export, bulk operations (§5 — deferred) - OFF refresh from the library view (already available via ticket 006's endpoint; wiring a button here is optional polish) ## Verify ```bash cd backend && uv run pytest # manual: browse/search/paginate, edit a food, delete it, toggle deleted view, # restore it, confirm it renders in old log entries throughout ``` --- ## After v1 (not ticketed) Deferred items from spec §5: deployment method, CSV export, fiber/sat-fat/sugar/sodium in UI, PWA manifest. Revisit SPEC.md once M1–M4 ship.