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

22 KiB
Raw Permalink Blame History

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

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

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

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

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

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

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

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

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.