376 lines
22 KiB
Markdown
376 lines
22 KiB
Markdown
# 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.
|