Files
calcount/IMPLEMENTATION_PLAN.md
T
2026-07-26 11:16:10 +01:00

376 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 001005): 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 13, 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 23).
- `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 46, §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 ~35 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 M1M4 ship.