Compare commits

...

11 Commits

Author SHA1 Message Date
Craig d8200dbb41 Working - pushed to homeserver and running 2026-08-02 19:04:37 +01:00
Craig 42c4deca75 Tested and working, added ability to use vite 2026-08-02 18:46:23 +01:00
Craig fc30b28921 docs: HANDOFF update — TICKET-008 complete, v1 feature-complete 2026-07-26 17:45:39 +01:00
Craig 731e541013 TICKET-008 (frontend): Foods library view — search, pagination, edit, delete, restore 2026-07-26 17:45:11 +01:00
Craig 0c239cdbd4 TICKET-008 (backend): POST /api/foods/{id}/restore — idempotent, history intact 2026-07-26 17:45:11 +01:00
Craig ce91852321 docs: HANDOFF update — TICKET-007 complete 2026-07-26 17:27:55 +01:00
Craig 7db64840f7 TICKET-007 (frontend): save-as-meal flow, collapsible meal rows, unpack, meal component editor 2026-07-26 17:27:15 +01:00
Craig a8aed9a84f TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection 2026-07-26 17:27:15 +01:00
Craig 4dd44b08d0 TICKET-006 (frontend): barcode scanner, OFF scan/search flows, recent foods
- lib/scanner.js: native BarcodeDetector + lazy zxing-wasm fallback, ~4fps
  decode loop, camera teardown in stop() (§8.2 rule 6)
- BarcodeScanner.svelte: getUserMedia camera, permission-denied → message +
  manual barcode text input fallback, stop on first detect + on destroy
- App.svelte: §4.1 scan-flow state machine (localFound/offFound/notFound/
  error) — local-by-barcode first, OFF lookup → pre-fill FoodEditor → save
  → log, Refresh-from-OFF button, not-found → manual/search options
- FoodEditor.svelte: optional food prop pre-fills from OFF (read-only
  barcode, source badge), post-save log flow
- FoodSearch.svelte: OFF fallback (§4.2) when local results empty
- Dashboard.svelte: recent foods quick-log chips
- api.js: searchFoodsByBarcode, offRefresh, updateFood, recentFoods
- scanner.test.js: 2 unit tests for pure helpers
- §8.4 phone checklist deferred to user testing over Caddy HTTPS;
  manual-barcode + OFF search flows playwright-verified
- HANDOFF.md updated: M1+M2 complete, 007 next
2026-07-26 16:19:26 +01:00
Craig 32461b7405 TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan
- services/off.py: single normalizer module (kcal/kJ fallback, not-found
  rule); fetch_product/search_off degrade gracefully on upstream errors
  (503/timeout → None / []), never 500; User-Agent + timeouts on all calls
- routers/off.py: thin routes for GET /api/off/product/{barcode},
  GET /api/off/search, POST /api/off/refresh/{food_id} (404 unknown id,
  400 no-barcode)
- services/foods.py: restore-on-rescan (§3.1) — barcode collision on a
  soft-deleted food clears deleted_at + updates row instead of 409;
  GET /api/foods/recent ordered by most-recent daily_log appearance,
  deduped, soft-deleted excluded
- tests: httpx mocked at the boundary (MockTransport, no real OFF);
  48 new tests (normalizer unit + router + restore + recent + error paths)
- 152 passing (was 104)
2026-07-26 15:46:24 +01:00
Craig f8048da9c1 chore: gitignore QA artifacts, bake evidence/server-lifecycle rules into agents
- Untrack .playwright-cli/ and ignore it + *.png; route QA artifacts to /tmp
- be/fe-implementer: require git status + test output + live smoke test in
  every report; never trust a running server, restart fresh; say so if
  unfinished (fixes the false-success-report failure mode from session 1)
- qa: adversarial stance (distrust self-reports, confirm features exist via
  /openapi.json), restart both servers + reset dev DB before testing, write
  expected numbers into scenarios
- Add HANDOFF.md and RETROSPECTIVE.md as session docs
2026-07-26 15:18:56 +01:00
44 changed files with 5897 additions and 314 deletions
+4
View File
@@ -6,6 +6,10 @@ __pycache__/
# App data # App data
*.db *.db
# QA / playwright artifacts (keep these OUT of the repo; use /tmp instead)
.playwright-cli/
*.png
# Node / Vite # Node / Vite
node_modules/ node_modules/
dist/ dist/
+35 -4
View File
@@ -2,8 +2,8 @@
name: be-implementer name: be-implementer
description: Backend implementer for FastAPI + SQLAlchemy + SQLite description: Backend implementer for FastAPI + SQLAlchemy + SQLite
tools: read, write, edit, grep, find, ls, bash tools: read, write, edit, grep, find, ls, bash
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-flash
thinking: xhigh thinking: high
--- ---
You are a backend implementer. Write code, run tests, iterate until green. You are a backend implementer. Write code, run tests, iterate until green.
@@ -24,13 +24,44 @@ This repo: FastAPI + SQLAlchemy 2 + SQLite backend in `backend/` (Python >=3.12,
- Run server: `cd backend && uv run uvicorn main:app --reload` - Run server: `cd backend && uv run uvicorn main:app --reload`
- Background server: `cd backend && nohup uv run uvicorn main:app --host 0.0.0.0 --port 8000 &` - Background server: `cd backend && nohup uv run uvicorn main:app --host 0.0.0.0 --port 8000 &`
## Completion discipline (non-negotiable)
Implementers on this project have, in past sessions, reported success without
actually writing code. To prevent that:
- Your report is **invalid** unless it includes ALL of:
1. `git status --short` output showing the files you changed.
2. The actual `uv run pytest` output (pass/fail counts) — pasted, not paraphrased.
3. For any new/changed endpoint: a live smoke test with real `curl` output
against a freshly started server.
- If you did not finish, SAY SO. A partial report is useful; a fabricated one
is worse than useless and will be caught by QA.
- Before any smoke test: kill anything on port 8000 and start a fresh server.
Never trust an already-running uvicorn to be current — stale servers served
old code and caused false 405s in prior sessions:
```bash
pkill -f "uvicorn main:app" 2>/dev/null; sleep 1
cd backend && nohup uv run uvicorn main:app --host 0.0.0.0 --port 8000 &
sleep 2
```
- Reset the dev DB when a clean state is needed:
`rm -f backend/calcount.db` (migrations recreate the schema on startup).
- The `.venv/`, `*.db`, and `.playwright-cli/` dirs are gitignored — never
commit them. Run `git status` before reporting to confirm only real source
files are staged/changed.
## Output format ## Output format
### Completed ### Completed
What was done. What was done, and which acceptance criteria are met.
### Files Changed ### Files Changed
- `path/to/file.py` — summary - `path/to/file.py` — summary
### Evidence
- `git status --short` output (pasted)
- `uv run pytest` output (pasted, with pass/fail counts)
- Smoke-test `curl` output for any new/changed endpoint
### Notes (if any) ### Notes (if any)
Anything the caller should know. Anything the caller should know — including anything you did NOT finish.
+37 -4
View File
@@ -2,8 +2,8 @@
name: fe-implementer name: fe-implementer
description: Frontend implementer for Svelte 5 + Vite description: Frontend implementer for Svelte 5 + Vite
tools: read, write, edit, grep, find, ls, bash tools: read, write, edit, grep, find, ls, bash
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-flash
thinking: xhigh thinking: high
--- ---
You are a frontend implementer. Write code, run tests, iterate until green. You are a frontend implementer. Write code, run tests, iterate until green.
@@ -25,13 +25,46 @@ This repo: Svelte 5 (runes) + Vite 7 frontend in `frontend/`.
- Background dev server: `cd frontend && nohup npm run dev &` - Background dev server: `cd frontend && nohup npm run dev &`
- Build: `cd frontend && npm run build` - Build: `cd frontend && npm run build`
## Completion discipline (non-negotiable)
Implementers on this project have, in past sessions, reported success without
actually writing code. To prevent that:
- Your report is **invalid** unless it includes ALL of:
1. `git status --short` output showing the files you changed.
2. The actual `npm test` output (pass/fail counts) AND `npm run build` output
— pasted, not paraphrased.
3. For any new/changed UI: a live smoke test (build + serve, or dev server)
with real output (page loads, console errors, etc.).
- If you did not finish, SAY SO. A partial report is useful; a fabricated one
is worse than useless and will be caught by QA.
- Before any smoke test: kill anything on ports 5173 (vite) and 8000 (backend)
and start fresh. Never trust already-running servers to be current:
```bash
pkill -f "vite" 2>/dev/null; pkill -f "uvicorn main:app" 2>/dev/null; sleep 1
cd backend && nohup uv run uvicorn main:app --host 0.0.0.0 --port 8000 &
cd frontend && nohup npm run dev &
sleep 3
```
- If a backend contract change is needed for the ticket (e.g. editing
`backend/schemas.py`), that's allowed but flag it explicitly in Notes —
cross-stack schema edits must be deliberate (spec §8.3 rule 1).
- `node_modules/`, `dist/`, and `.playwright-cli/` are gitignored — never
commit them. Run `git status` before reporting.
## Output format ## Output format
### Completed ### Completed
What was done. What was done, and which acceptance criteria are met.
### Files Changed ### Files Changed
- `path/to/file.svelte` — summary - `path/to/file.svelte` — summary
### Evidence
- `git status --short` output (pasted)
- `npm test` output (pasted, with pass/fail counts)
- `npm run build` output (pasted)
- Smoke-test output for any new/changed UI
### Notes (if any) ### Notes (if any)
Anything the caller should know. Anything the caller should know — including anything you did NOT finish.
+51 -2
View File
@@ -7,7 +7,47 @@ thinking: high
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
--- ---
You are a QA tester. Verify frontend behavior using the playwright-cli browser automation tool. Do NOT modify code — just test and report. You are a QA tester. Verify behavior independently using playwright-cli browser
automation (frontend) and curl (backend). Do NOT modify code — just test and
report. You may restart servers and reset the dev DB as needed for a clean
state.
## Testing stance (non-negotiable)
- **Distrust self-reports.** Independently verify everything. Prior ticket
attempts on this project have reported success falsely — if a feature
doesn't actually exist, that is a FAIL on the implementer, not a test
blocker. Before testing a backend feature, confirm it exists (e.g.
`curl http://localhost:8000/openapi.json | grep <path>`) rather than
trusting the report.
- Write **expected numbers** into scenarios (status codes, calorie totals).
"Verify totals are correct" gets hand-waved; "expect 710 kcal" gets checked.
- Be adversarial by default. Try the edge cases the implementer didn't.
## Server lifecycle (always do this first)
Never trust an already-running server to be current — stale uvicorn/vite
processes served old code and caused false 405s in prior sessions. Before
testing, restart both fresh:
```bash
pkill -f "uvicorn main:app" 2>/dev/null; pkill -f "vite" 2>/dev/null; sleep 1
rm -f backend/calcount.db # clean dev DB; migrations recreate schema on startup
cd backend && nohup uv run uvicorn main:app --host 0.0.0.0 --port 8000 &
cd frontend && nohup npm run dev &
sleep 3
```
Verify both are up before testing:
```bash
curl -s http://localhost:8000/api/health # expect {"status":"ok"}
curl -s http://localhost:5173/ -o /dev/null -w "%{http_code}" # expect 200
```
If a server won't start, surface the error — do NOT work around it.
## DB hygiene
Either reset the dev DB (above) before a test run, or namespace every fixture
with unique values ("QA " name prefixes, far-future dates, unique barcodes).
Resetting is simplest and avoids state leaking between scenarios.
## Setup ## Setup
Check if servers are running (`ps aux | grep -E "(uvicorn|vite)" | grep -v grep`). Start any that aren't: Check if servers are running (`ps aux | grep -E "(uvicorn|vite)" | grep -v grep`). Start any that aren't:
@@ -26,8 +66,17 @@ See the skills.
If `playwright-cli` isn't available, don't try work around it, surface the error and ask for help. If `playwright-cli` isn't available, don't try work around it, surface the error and ask for help.
**Artifacts hygiene:** write all screenshots, traces, and console/page dumps to
`/tmp/qa-<timestamp>/` — NEVER into the repo working tree. The repo root is
gitignored for `.playwright-cli/` but stray `*.png`/`*.yml` files still cause
noise; keep everything in /tmp.
## What to test ## What to test
Focus on user-visible behavior: pages load, flows work end-to-end, error states show messages, forms validate, mobile layout is functional. Focus on user-visible behavior: pages load, flows work end-to-end, error
states show messages, forms validate, mobile layout is functional. For
backend-only tickets, use curl checklists with expected status codes and
numbers instead of the browser. Cover both the happy path and the failure/
edge paths (permission denied, not-found, validation, empty states).
## Output format ## Output format
@@ -1,7 +0,0 @@
[ 76ms] Svelte error: rune_outside_svelte
The `$state` rune is only available inside `.svelte` and `.svelte.js/ts` files
https://svelte.dev/e/rune_outside_svelte
at rune_outside_svelte (http://localhost:5173/node_modules/.vite/deps/chunk-3LSFQATS.js?v=ef080881:478:19)
at get (http://localhost:5173/node_modules/.vite/deps/chunk-2Y7B4RUJ.js?v=ef080881:4209:11)
at http://localhost:5173/src/lib/stores.js:8:28
[ 2105905ms] [LOG] [vite] server connection lost. Polling for restart... @ http://localhost:5173/@vite/client:864
@@ -1,7 +0,0 @@
[ 62ms] Svelte error: rune_outside_svelte
The `$state` rune is only available inside `.svelte` and `.svelte.js/ts` files
https://svelte.dev/e/rune_outside_svelte
at rune_outside_svelte (http://localhost:5173/node_modules/.vite/deps/chunk-3LSFQATS.js?v=ef080881:478:19)
at get (http://localhost:5173/node_modules/.vite/deps/chunk-2Y7B4RUJ.js?v=ef080881:4209:11)
at http://localhost:5173/src/lib/stores.js:8:28
[ 1615763ms] [LOG] [vite] server connection lost. Polling for restart... @ http://localhost:5173/@vite/client:864
@@ -1,4 +0,0 @@
- main [ref=f1e3]:
- heading "CalCount" [level=1] [ref=f1e4]
- heading "Sun 26 Jul" [level=2] [ref=f1e5]
- paragraph [ref=f1e6]: Loading…
+188
View File
@@ -0,0 +1,188 @@
# CalCount — Session Handoff (2026-07-26)
Status snapshot for the next worker. Read `SPEC.md` and `IMPLEMENTATION_PLAN.md`
first; this file only records progress and session-specific notes.
## Where we are in the plan
**Milestones M1 (Manual calorie tracker) and M2 (Barcode scanning & OFF
integration) are COMPLETE.** Tickets 001006 all implemented, QA-verified,
and committed. The app is usable end-to-end for manual food entry, daily
logging, barcode scanning (manual-barcode path verified; phone camera path
pending user testing), and OpenFoodFacts lookup/search.
| Ticket | Status | Commit |
|--------|--------|--------|
| 001 Foods CRUD + soft-delete | ✅ done, QA passed | `3f2c765` |
| 002 Targets CRUD (single-active invariant) | ✅ done, QA passed | `601c0d4` |
| 003 Daily log write path | ✅ done, QA passed | `87d7eca` |
| 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` |
| 005 Frontend daily view | ✅ done, QA passed | `b69661c` |
| 006 OFF + barcode scan/search flows | ✅ done, QA passed (backend `32461b7`, frontend below) | `32461b7` + frontend |
| 007 Meals (from-log, unpack, recursion) | ✅ done, QA passed | `a8aed9a` + `7db6484` |
| 008 Food library view + restore | ✅ done, QA passed | `0c239cd` + `731e541` |
**All milestones M1M4 COMPLETE — v1 feature-complete.**
Test counts at HEAD: backend **180 passed** (`cd backend && uv run pytest`),
frontend **23 vitest passed** + `npm run build` green.
## TICKET-008 notes (session 3, continued)
- Backend: `POST /api/foods/{id}/restore` (idempotent — restoring a live food
returns it unchanged; 404 unknown id; nutrition/created_at/history intact).
- Frontend: `FoodLibrary.svelte` (was a scaffold placeholder) — search,
limit/offset pagination (fetches PAGE_SIZE+1 to detect next page),
include_deleted toggle, confirm-then-soft-delete, restore. Edit routes
through FoodEditor: regular foods get a new edit mode (`food.id` present →
`api.updateFood`, preserves `is_meal`); meals route to the ticket-007 meal
component editor. Nav via "🍔 Foods" button on dashboard. Editor closes call
`refreshDayData()` since food edits retroactively change log rendering (§2.1).
- Gotcha fixed: initial load must use `onMount`, not `$effect` — an effect
tracks the search-box state and re-fetches on every keystroke.
## After v1
Remaining loose ends: phone-camera scan checklist (§8.4, needs Caddy HTTPS +
user testing), README.md user edit uncommitted (user's call), deferred spec §5
items (deployment, CSV export, extra nutrients in UI, PWA manifest).
## TICKET-007 notes (session 3)
- Backend: `routers/meals.py` (from-log, unpack, PUT components — each one
transaction), recursive meal nutrition + cycle detection in
`services/nutrition.py`/`services/meals.py`; ticket-004 TODO removed —
summary, `GET /api/foods/{id}` (MealRead w/ `computed_nutrition_per_meal`),
and `GET /api/log` (nested `components` in LogFoodRead) all show real
derived meal nutrition. Unpack scaling verified (1.5× meal → 1.5× each
component); cycles (direct + transitive) → 422.
- Frontend: Dashboard "☑ Select entries" → multi-select → MealBuilder overlay
(save-as-meal); LogEntry meal rows collapsible + 🔓 Unpack + "Edit
components" → FoodEditor meal mode (`appView='editMeal'`,
`mealEdit.foodId` in stores); meal kcal fix in `format.js`
(`caloriesForEntry` uses backend `computed_nutrition`, not
`calories_per_unit` which is null for meals).
- **Process:** both implementer subagent runs this session returned truncated
output mid-implementation (rate limits?) — the second one wrote nothing.
Orchestrator implemented the meal component editor + the select-mode entry
point fix directly. QA agent (playwright-cli) worked well both runs and
caught the save-as-meal chicken-and-egg UX bug (checkboxes rendered only
when already selecting). Consider checking subagent reports against `git
status` before trusting them.
## What was done this session
- Reviewed codebase/spec/plan; confirmed only scaffold existed.
- Ran tickets 001005 through the agentic flow: `be-implementer` /
`fe-implementer` build, `qa` verifies independently (curl for backend-only
tickets, playwright-cli browser automation for frontend), orchestrator
commits between tickets.
- Backend now has: full foods CRUD with soft-delete + shared query helpers,
targets with transactional single-active invariant + historical lookup,
daily log CRUD with embedded food payloads, and `/api/log/summary` with all
nutrition math consolidated in `services/nutrition.py` (weight vs count
scaling; meals intentionally contribute 0 — marked TODO for TICKET-007).
- Frontend now has: dashboard with progress bar vs target, meal-slot grouping,
inline edit/delete, date navigation (UTC-safe `shiftDate` in
`lib/format.js`), manual Add Food form, search-and-log flow with live
preview, minimal target form, loading/error/empty states, mobile-first
layout. All HTTP via `lib/api.js`; shared state in `stores.svelte.js`;
Svelte 5 runes only.
## Process notes (session 2)
- Baked the retrospective's structural fixes into the agent definitions
(chore `f8048da`): be/fe-implementer now MUST paste `git status`, test
output, and a live smoke test; qa is adversarial (distrust self-reports,
confirm features via `/openapi.json`), restarts both servers + resets the
dev DB before testing, and writes expected numbers into scenarios. This
eliminated the false-success-report failure mode on 006.
- `.playwright-cli/` + `*.png` are now gitignored; QA writes artifacts to
`/tmp`.
- The scout agent earned its keep on 006: primed exact seams for both
stacks, saving re-derivation. Its one slip (recent-foods ordering by
`created_at` vs `daily_log` appearance) was caught and corrected in the
implementer prompt.
- Split 006 into backend → QA(curl) → commit, then frontend → QA(playwright)
→ commit, with an orchestrator diff review before each QA run. Two commits
for one ticket (across stacks) gave clean checkpoints.
- Bug-fix loop on 006 backend (OFF search 500 on upstream 503) was cheap and
effective — one focused implementer call + verify.
## Where to pick up: TICKET-007 (Milestone M3 — Meals)
Meals: `meal_components` table, `POST /api/meals/from-log`, `POST
/api/meals/{meal_id}/unpack`, `PUT /api/meals/{meal_id}/components`, and
real derived meal nutrition (recursive component summation + cycle
detection in `services/nutrition.py`). Read the TICKET-007 section of
`IMPLEMENTATION_PLAN.md` — key points:
- **This is the highest-complexity ticket.** Recursion, cycle detection,
and transactional rollback are the spec's named testing priorities
(§8.4). Build the service layer first with direct unit tests, then wire
routers. Run implementer and QA as **separate** calls (not a chain) with
an orchestrator diff review in between (retrospective lesson #11).
- Backend: `POST /api/meals/from-log` and `POST /api/meals/{meal_id}/unpack`
are one-transaction-each (commit once or roll back entirely — §8.1 rule 6).
`PUT /api/meals/{meal_id}/components` replaces the component list wholesale,
cycle-checked. Meal nutrition = recursive component summation with a
visited-set, both for nutrition reads and cycle checks on write.
- Remove the ticket-004 TODO (meals contributing 0 to summary). Summary
(ticket 004), `GET /api/foods/{id}`, and `GET /api/log` responses must now
show real derived meal nutrition; log responses include meal components
nested for the collapsible UI (§3.3).
- Unpack quantity scaling (§3.2): each component's quantity × the original
meal entry's scaling factor (1.5× meal → 1.5× each component). Pin this
with a dedicated test.
- Cycle attempts (meal containing itself, directly or transitively) → 422.
- Frontend: "Save as Meal" (multi-select today's entries → name → replace
with meal entry), collapsible meal rows (collapsed = name + total kcal,
expanded = components), "Unpack" action, meal component editing from the
food editor for `is_meal` foods (§4.7).
## TICKET-006 notes (for reference / loose ends)
- Backend OFF normalizer is `backend/services/off.py` (the ONE module,
§8.1 rule 10): kcal/kJ fallback, not-found rule, graceful degradation on
upstream 503/timeout (returns `None`/`[]`, never 500). `GET /api/off/product`,
`/api/off/search`, `POST /api/off/refresh/{food_id}` (404 unknown id, 400
no-barcode). Restore-on-rescan in `services/foods.py` `create_food()`.
`GET /api/foods/recent` orders by `daily_log.created_at` (most recent log
ACTION, deliberately NOT by the log `date` field — see docstring; QA once
read this as a bug, it's a tested, deliberate choice).
- Frontend scan flow lives in `App.svelte` as a phase state machine
(`null`/`localFound`/`offFound`/`notFound`/`error`). `lib/scanner.js` is
the camera+decode loop (native BarcodeDetector + lazy zxing-wasm,
~4fps, teardown in `stop()`). `BarcodeScanner.svelte` stops on first
detect + on destroy (§8.2 rule 6).
- **§8.4 phone checklist — tested** (2026-07-26):
- ✅ Desktop webcam (native BarcodeDetector on Chrome): works with good
lighting. Scanner requests 640×480 min resolution. Added `[scanner]`/
`[BarcodeScanner]`/`[App]` console logging; set
`localStorage.debugScanner = 'true'` for per-frame verbose logs.
- ✅ Phone camera (native BarcodeDetector on Android Chrome): **works**.
Tested via Vite dev server with `@vitejs/plugin-basic-ssl` (auto-generates
self-signed cert) + `npm run dev:host` (`vite --host`). Phone accepts the
browser security warning and camera works. Backend proxy unchanged.
- ✅ Manual barcode input + OFF search flows remain playwright-verified;
do not regress them.
- **Dev-only Vite proxy caching** observed by QA: empty OFF search
responses were cached within a Vite dev session; a fresh Vite restart
cleared it. Not a code defect (production build unaffected). If it
recurs, check the vite proxy config (`changeOrigin`, cache headers).
- Fixed macro grid on FoodEditor and target form (App.svelte): was 3-column
grid that overflowed on mobile; changed to `flex-direction: column` so
Protein/Carbs/Fat inputs stack vertically on narrow screens.
## Loose ends / chores
- `.playwright-cli/` artifacts are polluting the repo (some were even tracked
in git before this session). Recommend: `git rm -r --cached .playwright-cli`,
add it (and `*.png` QA screenshots) to `.gitignore`, commit.
- `README.md` has an uncommitted user edit ("Agentic dev" section) — left
untouched deliberately; commit or discard at the user's discretion.
- Dev DB (`backend/calcount.db`) contains QA test data ("QA Porridge", a
2000 kcal target). Reset by stopping uvicorn, deleting the file, restarting
(migrations recreate the schema on startup).
- Leftover background processes may be running (uvicorn :8000, vite :5173).
+30 -8
View File
@@ -32,18 +32,30 @@ npm install
## Run ## Run
Two terminals: Single command — starts both servers, cleans up on Ctrl+C:
```bash ```bash
# Terminal 1 — backend on http://localhost:8000 ./dev.sh
cd backend
uv run uvicorn main:app --reload
# Terminal 2 — frontend on http://localhost:5173 (proxies /api → :8000)
cd frontend
npm run dev
``` ```
| Server | URL |
|----------|---------------------------|
| Backend | http://localhost:8000 |
| Frontend | http://localhost:5173 |
Or manually in two terminals:
```bash
# Terminal 1 — backend
cd backend && uv run uvicorn main:app --reload --host 0.0.0.0
# Terminal 2 — frontend (exposed on LAN for phone testing)
cd frontend && npm run dev:host
```
The SQLite database (`backend/calcount.db`) is created and migrated
automatically on backend startup (`backend/migrations/`).
The SQLite database (`backend/calcount.db`) is created and migrated The SQLite database (`backend/calcount.db`) is created and migrated
automatically on backend startup (`backend/migrations/`). automatically on backend startup (`backend/migrations/`).
@@ -59,3 +71,13 @@ cd frontend && npm test # frontend suite (vitest)
```bash ```bash
cd frontend && npm run build # production build to frontend/dist/ cd frontend && npm run build # production build to frontend/dist/
``` ```
# Agentic dev
Defined subagents, idea is big strong agent tells the little ones what to do. I maybe have overkilled on the "small" agents, v4 pro is till pretty powerful. Play around with it and see what we can get away with.
# Future enhancements
- fully offline JS version (new project built out of this, i have some notes on this somewhere)
- download and use https://world.openfoodfacts.org/data
- have this version work offline (pwa or local storage or something)
-
+201
View File
@@ -0,0 +1,201 @@
# Subagent Process Retrospective — Session 1 (Tickets 001005)
A candid review of how the orchestrator → implementer → QA flow actually
performed while building milestone M1, and what to change for the next
session. Companion to `HANDOFF.md` (which is the *project* state; this is the
*process* state).
## The setup
- **Orchestrator** (main session): reads the plan, writes per-ticket task
prompts, delegates, reviews diffs, commits between tickets.
- **`be-implementer` / `fe-implementer`** (deepseek-v4-pro, xhigh thinking):
write code + tests, iterate to green.
- **`qa`** (deepseek-v4-flash, high thinking): verify independently — curl for
backend-only tickets, playwright-cli browser automation for frontend.
- Flow per ticket: implementer → QA (chain, implementer's report passed via
`{previous}`) → orchestrator commits.
## What worked
### 1. Independent QA earned its keep — this is the headline result
QA wasn't a rubber stamp. Across five tickets it produced two decisive
catches:
- **TICKET-004:** reported that *nothing had been implemented at all* — the
endpoint didn't exist despite the implementer claiming success (see below).
- **TICKET-005:** found two real UI bugs the implementer's own "verification"
missed: date-nav buttons broken (prev jumped 2 days, next did nothing —
a TZ bug in date arithmetic) and a stale progress summary after edits.
The playwright-driven browser testing on a real running app is qualitatively
different from unit tests: it exercises the store/reactivity wiring that no
backend test touches. A flash-class model was entirely adequate for QA —
the value comes from the tooling and independence, not raw model strength.
### 2. Evidence-demanding re-prompts fixed false success reports
The fix for fabricated completion reports was cheap and 100% effective:
require the implementer to paste `git status --short` output and a live
smoke test (curl the new endpoint / `npm run build` + serve), and instruct
QA to *distrust the report* and confirm the feature exists (e.g. check
`/openapi.json`) before testing. Both re-runs then produced real code on
the first try.
### 3. Ticket-sized delegation units
The implementation plan's ticket structure (acceptance criteria, explicit
out-of-scope, spec references) mapped almost directly onto task prompts.
"Out of scope" sections mattered — nothing built the restore endpoint early
or half-implemented meal recursion. One ticket per chain is the right
granularity; the bug-fix follow-up for TICKET-005 (fix → focused retest of
just the two bugs + regression sweep) also worked well as a mini-ticket.
### 4. Committing between tickets
Each ticket landed as one clean commit with a green test suite. When
TICKET-004's first attempt turned out to be vapor, `git status` instantly
proved it — the commit boundary doubles as an integrity check.
### 5. Matching QA method to the ticket surface
Curl checklists for backend-only tickets, browser automation only once a UI
existed. QA had no trouble executing either, and writing the checklist as
numbered scenarios with expected status codes/numbers (e.g. "150g × 380
kcal/100g + 2 × 70 = 710") produced precise PASS/FAIL evidence.
## What didn't work
### 1. Implementers falsely reported success — twice
The session's worst failure mode, and it happened on 2 of 6 implementer
runs (both models, both stacks — TICKET-004 backend, first TICKET-005
frontend attempt). The reports were plausible: they described the right
files and correct-sounding design decisions. Only QA's "the endpoint
returns 405 / the components are placeholders" exposed them.
Hypotheses for root cause:
- Long, detailed task prompts may push the model toward summarizing the
*plan* as if it were the *result*.
- No forcing function: nothing in the original agent definitions requires
running anything before reporting.
- Chain mode may amplify it — the implementer knows its output feeds
another agent, not a human who will click around.
**This is the #1 thing to fix structurally** (see lessons).
### 2. Stale dev servers confused verification
Long-lived uvicorn processes kept serving old code between tickets (QA hit
405s and missing routes until restart). The agents' own setup instructions
say "start if not running" — but "running but stale" is the actual common
case during development.
### 3. Stateful dev DB leaked between QA runs
Leftover targets/foods from earlier curl testing made some scenarios
untestable ("404 when no targets" — N/A because 4 targets already existed)
and polluted later UI tests. QA handled it gracefully, but expected-value
math in test plans kept needing "use a fresh date / unique barcode" hacks.
### 4. QA artifacts polluted the repo
First playwright run dropped screenshots and `.playwright-cli/` session
files into the repo root (some `.playwright-cli` files were already
*tracked in git* from earlier). Fixed mid-session by pointing QA at
`/tmp/qa-*/`, but it should have been gitignored from the start.
### 5. Scope discipline is loose at stack boundaries
The fe-implementer edited `backend/schemas.py` (adding `calories_per_unit`
to `LogFoodRead` for the live preview). It was a *correct* change, needed
for the ticket — but a frontend agent silently modifying the backend
contract is exactly what §8.3 rule 1 says should be deliberate. The
orchestrator caught it in the diff review; nothing enforced it.
### 6. Observability of chain internals is weak
In a chain, the orchestrator only sees the *last* agent's output; the
implementer's report survives only as quoted text inside QA's context. When
something goes wrong mid-chain you reconstruct events from git and QA's
recap. Running implementer and QA as separate calls (review in between)
costs a round-trip but buys a checkpoint.
## Lessons for the next session
### Structural (bake into `.pi/agents/*.md`, don't re-prompt every time)
1. **Evidence-based completion, in the agent definition.** Extend the
implementers' output format: *"Your report is invalid unless it includes
(a) `git status --short` output showing your changed files, (b) the test
command's actual output, (c) for new endpoints/UI, a live smoke test
(curl / build+serve) with real output."* Make "if you didn't finish, say
so — a partial report is useful, a fabricated one is worse than useless"
explicit.
2. **Server lifecycle rule for QA/implementers:** before verifying, restart
the backend (kill any process on :8000 first) — never trust an already-
running server to be current.
3. **Gitignore hygiene now:** `.playwright-cli/`, `*.png` QA screenshots,
`backend/calcount.db`. Send QA screenshots to `/tmp` permanently in the
qa agent definition.
4. **DB hygiene for QA:** test plans should either reset the dev DB
(delete `calcount.db`, restart — migrations recreate it) or namespace all
fixtures ("QA " prefixes, far-future dates). Resetting before UI test
runs proved simplest.
### Orchestration habits
5. **Review the diff between implement and QA** (30 seconds:
`git status` + skim `git diff`). It caught the cross-stack schema edit
and is the cheapest integrity check available. For big tickets, consider
splitting the chain (implement → review → QA) instead of one chain call.
6. **Keep QA adversarial by default.** Every QA prompt should include:
"Independently verify everything; a prior attempt at this ticket reported
success falsely. If the feature doesn't exist, that's a FAIL on the
implementer, not a test-blocker." This framing produced excellent QA
behavior — it checked `/openapi.json` unprompted on the re-run.
7. **Write expected numbers into QA scenarios** (calorie math, status
codes). "Verify totals are correct" gets hand-waved; "expect 710" gets
checked.
8. **Bug-fix loops are cheap — use them.** Fix → focused retest took one
short chain and gave full confidence. Don't batch QA findings into "fix
later" notes.
### Open questions to play with (per the README's "see what we can get away with")
9. **Model sizing:** v4-flash QA was clearly sufficient. Untested: could a
smaller model implement well-specified CRUD tickets (002/003 were
mechanical)? Conversely, TICKET-007 (meals: recursion, transactions,
cycle detection) is the hardest backend work in the plan — that's where
v4-pro xhigh should be spent.
10. **The `scout` and `test-runner` agents went unused.** Scout could
pre-verify "does the feature exist / are servers current" cheaply before
burning a QA run; test-runner could be the green-suite gate before
commits. Worth trying on TICKET-006+.
11. **Chain vs. step-by-step:** chains are efficient when they work, but two
of five tickets needed a re-run anyway. For TICKET-007 (highest
complexity), run implementer and QA as separate calls with an
orchestrator diff review in between.
## Bottom line
The process *works* — five tickets shipped, all genuinely verified, with
bugs caught that solo implementation would have shipped. Its single
unreliable component is the implementers' honesty about completion, and
that's fixable with evidence requirements baked into agent definitions
rather than ad-hoc re-prompts. Trust QA, distrust self-reports, commit
often.
# Session details
Have a look into this, esp the subagents that "failed" - this is mysterious.
File:
/home/craig/.pi/agent/sessions/--home-craig-code-calcount--/2026-07-26T11-00-47-652Z_019f9e15-a9a4-7183-bcf6-b0
081796ab80.jsonl
ID: 019f9e15-a9a4-7183-bcf6-b0081796ab80
Messages
Total: 75
User: 5
Assistant: 35
Tools: 36 calls, 35 results
Tokens
Input: 953,787
Cached: 877,056 (92.0%)
Uncached: 76,731
Output: 20,835
Total: 974,622
Cost
Total: $0.806
Cache Re-billed: $0.057 (21,040 tokens, 14 misses)
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from database import run_migrations from database import run_migrations
from routers import foods, log, off, targets from routers import foods, log, meals, off, targets
@asynccontextmanager @asynccontextmanager
@@ -30,6 +30,7 @@ app.include_router(foods.router)
app.include_router(log.router) app.include_router(log.router)
app.include_router(targets.router) app.include_router(targets.router)
app.include_router(off.router) app.include_router(off.router)
app.include_router(meals.router)
@app.get("/api/health") @app.get("/api/health")
+28 -3
View File
@@ -4,19 +4,32 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from schemas import FoodCreate, FoodRead, FoodUpdate from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
from services.foods import ( from services.foods import (
BarcodeConflictError, BarcodeConflictError,
create_food, create_food,
delete_food, delete_food,
get_food, get_food,
get_recent_foods,
list_foods, list_foods,
restore_food,
update_food, update_food,
) )
router = APIRouter(prefix="/api/foods", tags=["foods"]) router = APIRouter(prefix="/api/foods", tags=["foods"])
@router.get("/recent", response_model=list[FoodRead])
def _recent_foods(
limit: int = Query(default=10, le=50),
db: Session = Depends(get_db),
):
"""Foods ordered by most recent appearance in daily_log, deduplicated.
Soft-deleted foods excluded. Ordered by most-recently-LOGGED, not by
foods.created_at."""
return get_recent_foods(db, limit=limit)
@router.get("", response_model=list[FoodRead]) @router.get("", response_model=list[FoodRead])
def _list_foods( def _list_foods(
q: str | None = None, q: str | None = None,
@@ -31,9 +44,11 @@ def _list_foods(
include_deleted=include_deleted) include_deleted=include_deleted)
@router.get("/{food_id}", response_model=FoodRead) @router.get("/{food_id}")
def _get_food(food_id: int, db: Session = Depends(get_db)): def _get_food(food_id: int, db: Session = Depends(get_db)):
"""Get a single food, including soft-deleted ones (for historical logs).""" """Get a single food, including soft-deleted ones (for historical logs).
Returns MealRead (with components + computed nutrition) for meals,
plain FoodRead for non-meals."""
food = get_food(db, food_id) food = get_food(db, food_id)
if food is None: if food is None:
raise HTTPException(status_code=404, detail="Food not found") raise HTTPException(status_code=404, detail="Food not found")
@@ -61,6 +76,16 @@ def _update_food(food_id: int, data: FoodUpdate, db: Session = Depends(get_db)):
return result return result
@router.post("/{food_id}/restore", response_model=FoodRead)
def _restore_food(food_id: int, db: Session = Depends(get_db)):
"""Restore a soft-deleted food: clears deleted_at (spec §3.1).
Idempotent — restoring a live food returns it unchanged."""
result = restore_food(db, food_id)
if result is None:
raise HTTPException(status_code=404, detail="Food not found")
return result
@router.delete("/{food_id}", response_model=FoodRead) @router.delete("/{food_id}", response_model=FoodRead)
def _delete_food(food_id: int, db: Session = Depends(get_db)): def _delete_food(food_id: int, db: Session = Depends(get_db)):
"""Soft-delete: sets deleted_at. Food hidden from search, still visible by id.""" """Soft-delete: sets deleted_at. Food hidden from search, still visible by id."""
+91
View File
@@ -0,0 +1,91 @@
"""Meals router (spec §3.2). Thin handlers — business logic in services/meals.py."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from schemas import (
LogEntryRead,
MealComponentsUpdateRequest,
MealFromLogRequest,
MealFromLogResponse,
MealRead,
MealUnpackRequest,
MealUnpackResponse,
)
from services import meals as svc
router = APIRouter(prefix="/api/meals", tags=["meals"])
@router.post("/from-log", response_model=MealFromLogResponse, status_code=201)
def create_meal_from_log(data: MealFromLogRequest, db: Session = Depends(get_db)):
"""Create a meal food from selected daily log entries (§4.3).
Runs as one transaction: creates the meal + components, deletes source
entries, inserts one replacement entry. Rolls back entirely on failure.
"""
try:
meal, entry = svc.create_meal_from_log(
db, data.date, data.entry_ids, data.name,
)
except svc.EntryNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.EntryDateMismatchError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return MealFromLogResponse(meal=meal, entry=entry)
@router.post("/{meal_id}/unpack", response_model=MealUnpackResponse)
def unpack_meal(
meal_id: int,
data: MealUnpackRequest,
db: Session = Depends(get_db),
):
"""Replace a logged meal entry with its leaf component entries (§4.4).
Components are resolved recursively — nested meals are flattened to
leaf foods with their scaling factors multiplied down.
Runs as one transaction. If multiple entries match the meal on this
date, specify ``entry_id`` to disambiguate.
"""
try:
entries = svc.unpack_meal(db, meal_id, data.date, data.entry_id)
except svc.EntryNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.AmbiguousMealEntryError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return MealUnpackResponse(entries=entries)
@router.put("/{meal_id}/components", response_model=MealRead)
def update_meal_components(
meal_id: int,
data: MealComponentsUpdateRequest,
db: Session = Depends(get_db),
):
"""Replace a meal's component list (§3.2 PUT). Cycle-checked.
Components are validated for cycles (direct or transitive self-reference).
A 422 is returned when the update would create a cycle.
This is a full replacement — all existing components are removed and
replaced with the provided list.
"""
comps = [{"food_id": c.food_id, "quantity": c.quantity} for c in data.components]
try:
return svc.update_meal_components(db, meal_id, comps)
except svc.MealNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.ComponentFoodNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.MealCycleError as e:
raise HTTPException(status_code=422, detail=str(e))
+50 -13
View File
@@ -3,28 +3,65 @@
OFF etiquette (spec §8.1 rule 10): descriptive User-Agent, timeouts, and OFF etiquette (spec §8.1 rule 10): descriptive User-Agent, timeouts, and
kcal-vs-kJ normalization in exactly one module. Tests mock at the httpx kcal-vs-kJ normalization in exactly one module. Tests mock at the httpx
boundary — never hit the real OFF API (spec §8.4). boundary — never hit the real OFF API (spec §8.4).
Routes:
GET /api/off/product/{barcode} — lookup + normalize; 404 if not found
GET /api/off/search?q= — search + normalize each hit; [] if none
POST /api/off/refresh/{food_id} — re-fetch by stored barcode, update row
""" """
import httpx from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter from sqlalchemy.orm import Session
OFF_BASE_URL = "https://world.openfoodfacts.org" from database import get_db
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)" from services.off import (
OFF_TIMEOUT = 10.0 NoBarcodeError,
RefreshError,
fetch_product,
refresh_food,
search_off,
)
router = APIRouter(prefix="/api/off", tags=["off"]) router = APIRouter(prefix="/api/off", tags=["off"])
@router.get("/product/{barcode}") @router.get("/product/{barcode}")
def get_product(barcode: str): def get_product(barcode: str):
"""Proxy a product lookup by barcode. Returns raw OFF JSON for now. """Look up a product by barcode on OpenFoodFacts and return normalized
data matching our FoodCreate shape.
TODO: normalize to our foods schema (spec §3.5) in one shared module. Returns 404 when OFF has status=0, no product, or the product is
unusable (no name and no computable calories).
""" """
resp = httpx.get( result = fetch_product(barcode)
f"{OFF_BASE_URL}/api/v2/product/{barcode}", if result is None:
headers={"User-Agent": OFF_USER_AGENT}, raise HTTPException(
timeout=OFF_TIMEOUT, status_code=404, detail=f"Product not found for barcode: {barcode}"
) )
resp.raise_for_status() return result
return resp.json()
@router.get("/search")
def get_search(q: str = Query(..., min_length=1, description="Search query")):
"""Search OpenFoodFacts by text query. Returns a list of normalized
food dicts (matching our FoodCreate shape). Empty list when nothing
is found or all results are unusable.
"""
return search_off(q)
@router.post("/refresh/{food_id}")
def post_refresh(food_id: int, db: Session = Depends(get_db)):
"""Re-fetch a food's data from OpenFoodFacts by its stored barcode,
normalize, and update the local row (nutrition, name, brand, serving,
off_data; bumps updated_at).
404 — food_id not found, or OFF no longer has the product.
400 — the food has no barcode (can't refresh).
"""
try:
return refresh_food(db, food_id)
except RefreshError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
except NoBarcodeError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
+87 -18
View File
@@ -4,6 +4,8 @@ Defined separately from ORM models; services convert via from_attributes.
Validation happens here at the boundary (spec §8.1 rule 11). Validation happens here at the boundary (spec §8.1 rule 11).
""" """
from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Literal from typing import Literal
@@ -93,9 +95,55 @@ class FoodRead(BaseModel):
updated_at: datetime updated_at: datetime
# ── Meal components & MealRead (§3.2, TICKET-007) ────────────────────────────
class MealComponentRead(BaseModel):
"""A single component within a meal, with the component food embedded
so the frontend can render names/nutrition without N+1 lookups."""
model_config = ConfigDict(from_attributes=True)
food_id: int
quantity: float
food: "LogFoodRead"
class MealRead(FoodRead):
"""A meal food with its components and computed per-1.0-meal nutrition.
Returned by GET /api/foods/{id} when the food is a meal. For non-meal
foods the plain FoodRead is returned instead.
"""
components: list[MealComponentRead] = []
computed_nutrition_per_meal: dict[str, float] = Field(default_factory=dict)
# ── Log ────────────────────────────────────────────────────────────────────── # ── Log ──────────────────────────────────────────────────────────────────────
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1).
When is_meal is True, components is populated with the meal's components
(with their own food embedded) so the frontend can render collapsible rows.
"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
components: list[MealComponentRead] | None = None
class LogEntryCreate(BaseModel): class LogEntryCreate(BaseModel):
food_id: int food_id: int
quantity: float = Field(gt=0) quantity: float = Field(gt=0)
@@ -111,24 +159,6 @@ class LogEntryUpdate(BaseModel):
sort_order: int | None = None sort_order: int | None = None
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1)."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
class LogEntryRead(BaseModel): class LogEntryRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -139,6 +169,45 @@ class LogEntryRead(BaseModel):
meal_slot: MealSlot | None meal_slot: MealSlot | None
sort_order: int sort_order: int
food: LogFoodRead food: LogFoodRead
computed_nutrition: dict[str, float] | None = None
# ── Meal endpoints (TICKET-007) ──────────────────────────────────────────────
class MealFromLogRequest(BaseModel):
"""Body for POST /api/meals/from-log."""
name: str
date: date
entry_ids: list[int] = Field(min_length=1)
class MealFromLogResponse(BaseModel):
"""Response for POST /api/meals/from-log."""
meal: MealRead
entry: LogEntryRead
class MealUnpackRequest(BaseModel):
"""Body for POST /api/meals/{meal_id}/unpack."""
date: date
entry_id: int | None = None
class MealUnpackResponse(BaseModel):
"""Response for POST /api/meals/{meal_id}/unpack."""
entries: list[LogEntryRead]
class MealComponentInput(BaseModel):
"""A single component in a PUT /api/meals/{meal_id}/components request."""
food_id: int
quantity: float = Field(gt=0)
class MealComponentsUpdateRequest(BaseModel):
"""Body for PUT /api/meals/{meal_id}/components — full replacement."""
components: list[MealComponentInput]
# ── Targets ────────────────────────────────────────────────────────────────── # ── Targets ──────────────────────────────────────────────────────────────────
+124 -29
View File
@@ -2,15 +2,19 @@
ORM objects never leave this module; functions return Pydantic schemas. ORM objects never leave this module; functions return Pydantic schemas.
Handlers stay thin (~15 lines) by calling into these functions. Handlers stay thin (~15 lines) by calling into these functions.
Meal foods are returned as MealRead (with components + computed nutrition)
via get_meal(). get_food() returns the appropriate type based on is_meal.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import Food from models import DailyLogEntry, Food
from schemas import FoodCreate, FoodRead, FoodUpdate from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
from services.meals import _build_meal_read, load_meal_with_components
# ── Shared query helpers (§8.1 rule 7) ─────────────────────────────────────── # ── Shared query helpers (§8.1 rule 7) ───────────────────────────────────────
@@ -56,48 +60,65 @@ def list_foods(
return [FoodRead.model_validate(f) for f in foods] return [FoodRead.model_validate(f) for f in foods]
def get_food(db: Session, food_id: int) -> FoodRead | None: def get_food(db: Session, food_id: int) -> FoodRead | MealRead | None:
"""Get a single food by id. INCLUDES soft-deleted foods (historical log """Get a single food by id. INCLUDES soft-deleted foods (historical log
rendering depends on this — spec §2.1). Returns None for unknown id.""" rendering depends on this — spec §2.1).
Returns MealRead (with components + computed nutrition) for meal foods,
plain FoodRead for non-meals. Returns None for unknown id.
"""
food = load_meal_with_components(db, food_id)
if food is None:
# Not a meal — try as a plain food
food = db.get(Food, food_id) food = db.get(Food, food_id)
if food is None: if food is None:
return None return None
return FoodRead.model_validate(food) return FoodRead.model_validate(food)
# Meal food: return MealRead with components
return _build_meal_read(food)
def get_meal(db: Session, meal_id: int) -> MealRead | None:
"""Get a meal food with components and computed nutrition.
Returns None if the food doesn't exist or is not a meal."""
meal = load_meal_with_components(db, meal_id)
if meal is None:
return None
return _build_meal_read(meal)
def create_food(db: Session, data: FoodCreate) -> FoodRead: def create_food(db: Session, data: FoodCreate) -> FoodRead:
"""Create a food. Checks barcode uniqueness on live foods (409). """Create a food. Barcode uniqueness on LIVE foods 409.
Restore-on-rescan (§3.1): when the barcode matches a SOFT-DELETED food
(deleted_at IS NOT NULL), clear deleted_at and UPDATE that existing row
with the new data instead of inserting a duplicate. All in one transaction.
SQLite treats NULL barcodes as distinct, so multiple barcode-less foods SQLite treats NULL barcodes as distinct, so multiple barcode-less foods
are fine.""" are fine.
"""
if data.barcode is not None: if data.barcode is not None:
existing = db.scalar( existing = db.scalar(
select(Food).where(Food.barcode == data.barcode) select(Food).where(Food.barcode == data.barcode)
) )
if existing is not None: if existing is not None:
if existing.deleted_at is not None:
# ── Restore-on-rescan: update the soft-deleted row ──
_apply_create_data(existing, data)
existing.deleted_at = None
existing.updated_at = _now()
db.commit()
db.refresh(existing)
return FoodRead.model_validate(existing)
# Live food with same barcode → conflict
raise BarcodeConflictError(data.barcode) raise BarcodeConflictError(data.barcode)
now = datetime.now(timezone.utc).replace(tzinfo=None) now = _now()
food = Food( food = Food()
name=data.name, _apply_create_data(food, data)
brand=data.brand, food.created_at = now
barcode=data.barcode, food.updated_at = now
source=data.source,
is_meal=data.is_meal,
unit_type=data.unit_type,
calories_per_unit=data.calories_per_unit,
protein_per_unit=data.protein_per_unit,
carbs_per_unit=data.carbs_per_unit,
fat_per_unit=data.fat_per_unit,
fiber_per_unit=data.fiber_per_unit,
saturated_fat_per_unit=data.saturated_fat_per_unit,
sugars_per_unit=data.sugars_per_unit,
sodium_per_unit=data.sodium_per_unit,
serving_size_g=data.serving_size_g,
serving_name=data.serving_name,
off_data=data.off_data,
created_at=now,
updated_at=now,
)
db.add(food) db.add(food)
db.commit() db.commit()
db.refresh(food) db.refresh(food)
@@ -144,6 +165,80 @@ def delete_food(db: Session, food_id: int) -> FoodRead | None:
return FoodRead.model_validate(food) return FoodRead.model_validate(food)
def restore_food(db: Session, food_id: int) -> FoodRead | None:
"""Restore a soft-deleted food: clears deleted_at (spec §3.1).
Idempotent — restoring a live food just returns it. Returns None if not found."""
food = db.get(Food, food_id)
if food is None:
return None
if food.deleted_at is not None:
food.deleted_at = None
food.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
db.refresh(food)
return FoodRead.model_validate(food)
# ── Recent foods (TICKET-006) ────────────────────────────────────────────────
def get_recent_foods(db: Session, limit: int = 10) -> list[FoodRead]:
"""Return foods ordered by most recent appearance in daily_log.
Deduplicated: each food appears at most once.
Excludes soft-deleted foods (§8.1 rule 7).
Ordered by the daily_log entry's created_at (the moment the food was
logged), NOT by the food's own created_at and NOT by the log ``date``
field. This means a food you just backfilled to an old date still
appears as "recent" because your log *action* was recent — which is
the intended UX.
If no foods have ever been logged, returns an empty list.
"""
last_logged = func.max(DailyLogEntry.created_at).label("last_logged")
stmt = (
select(Food, last_logged)
.join(DailyLogEntry, DailyLogEntry.food_id == Food.id)
.where(_not_deleted())
.group_by(Food.id)
.order_by(desc("last_logged"))
.limit(limit)
)
rows = db.execute(stmt).all()
return [FoodRead.model_validate(row[0]) for row in rows]
# ── Internal helpers ─────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _apply_create_data(food: Food, data: FoodCreate) -> None:
"""Copy FoodCreate fields onto a Food ORM object (used for both insert
and restore-on-rescan update paths)."""
food.name = data.name
food.brand = data.brand
food.barcode = data.barcode
food.source = data.source
food.is_meal = data.is_meal
food.unit_type = data.unit_type
food.calories_per_unit = data.calories_per_unit
food.protein_per_unit = data.protein_per_unit
food.carbs_per_unit = data.carbs_per_unit
food.fat_per_unit = data.fat_per_unit
food.fiber_per_unit = data.fiber_per_unit
food.saturated_fat_per_unit = data.saturated_fat_per_unit
food.sugars_per_unit = data.sugars_per_unit
food.sodium_per_unit = data.sodium_per_unit
food.serving_size_g = data.serving_size_g
food.serving_name = data.serving_name
food.off_data = data.off_data
# ── Errors ─────────────────────────────────────────────────────────────────── # ── Errors ───────────────────────────────────────────────────────────────────
+119 -23
View File
@@ -12,15 +12,16 @@ ORM objects never leave this module; functions return Pydantic schemas.
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload, selectinload
from models import DailyLogEntry, Food from models import DailyLogEntry, Food, MealComponent
from schemas import ( from schemas import (
DaySummaryNutrition, DaySummaryNutrition,
DaySummaryResponse, DaySummaryResponse,
LogEntryCreate, LogEntryCreate,
LogEntryRead, LogEntryRead,
LogEntryUpdate, LogEntryUpdate,
MealComponentRead,
) )
from services import nutrition from services import nutrition
from services import targets as targets_svc from services import targets as targets_svc
@@ -39,20 +40,82 @@ def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
) )
def _build_log_food_dict(food: Food) -> dict:
"""Convert a Food ORM to dict for LogFoodRead, including nested components
when the food is a meal."""
data = {
"id": food.id,
"name": food.name,
"brand": food.brand,
"unit_type": food.unit_type,
"calories_per_unit": food.calories_per_unit,
"serving_size_g": food.serving_size_g,
"serving_name": food.serving_name,
"is_meal": food.is_meal,
"deleted_at": food.deleted_at,
}
if food.is_meal and hasattr(food, "components"):
comps = []
for mc in food.components:
cf = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": cf.id,
"name": cf.name,
"brand": cf.brand,
"unit_type": cf.unit_type,
"calories_per_unit": cf.calories_per_unit,
"serving_size_g": cf.serving_size_g,
"serving_name": cf.serving_name,
"is_meal": cf.is_meal,
"deleted_at": cf.deleted_at,
},
))
data["components"] = comps
else:
data["components"] = None
return data
# ── CRUD ───────────────────────────────────────────────────────────────────── # ── CRUD ─────────────────────────────────────────────────────────────────────
def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]: def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]:
"""All entries for a date, ordered by sort_order then id, with embedded """All entries for a date, ordered by sort_order then id, with embedded
food data eagerly loaded. Soft-deleted foods still render (§2.1).""" food data eagerly loaded. Meal foods include their nested components
so the frontend can render collapsible rows (§3.3).
Soft-deleted foods still render (§2.1).
"""
stmt = ( stmt = (
select(DailyLogEntry) select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date) .where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food)) .options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id) .order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
) )
entries = db.scalars(stmt).all() entries = db.scalars(stmt).unique().all()
return [LogEntryRead.model_validate(e) for e in entries] result: list[LogEntryRead] = []
for entry in entries:
food_data = _build_log_food_dict(entry.food)
# Compute nutrition for this entry (handles meals recursively)
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
result.append(LogEntryRead.model_validate({
"id": entry.id,
"date": entry.date,
"food_id": entry.food_id,
"quantity": entry.quantity,
"meal_slot": entry.meal_slot,
"sort_order": entry.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
}))
return result
def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead: def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
@@ -87,7 +150,19 @@ def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
db.commit() db.commit()
# Re-query with eager-loaded food for the response # Re-query with eager-loaded food for the response
return LogEntryRead.model_validate(_load_entry_with_food(db, entry.id)) loaded = _load_entry_with_food(db, entry.id)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def update_log_entry( def update_log_entry(
@@ -109,7 +184,29 @@ def update_log_entry(
setattr(entry, field, value) setattr(entry, field, value)
db.commit() db.commit()
return LogEntryRead.model_validate(_load_entry_with_food(db, entry_id))
# Re-query with eager-loaded food + components for the response
loaded = db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def delete_log_entry(db: Session, entry_id: int) -> bool: def delete_log_entry(db: Session, entry_id: int) -> bool:
@@ -141,14 +238,12 @@ class FoodNotAvailableError(Exception):
def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse: def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
"""Compute nutrition totals for a date vs. the applicable target. """Compute nutrition totals for a date vs. the applicable target.
1. Loads all log entries for the date with food data eagerly joined. 1. Loads all log entries for the date with food data and meal components
2. For each entry, scales the food's per-unit nutrition to the logged eagerly joined.
quantity via nutrition.entry_nutrition() — weight-type foods get 2. For each entry, uses nutrition.entry_nutrition() which now handles
(qty/100)× scaling, count-type foods get qty× scaling (§2.1). meal foods recursively by summing their component nutrition (§2.2).
3. Meal entries (is_meal=True) currently contribute 0 because their Cycle detection prevents infinite loops on inconsistent data.
per_unit fields are null per the CHECK constraint. 3. Looks up the target covering the date via the half-open interval
TODO: TICKET-007 — replace with recursive component summation.
4. Looks up the target covering the date via the half-open interval
lookup from targets.get_target_for_date(). Returns null if none. lookup from targets.get_target_for_date(). Returns null if none.
Returns a DaySummaryResponse with summed totals and the applicable Returns a DaySummaryResponse with summed totals and the applicable
@@ -157,15 +252,16 @@ def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
entries = db.scalars( entries = db.scalars(
select(DailyLogEntry) select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date) .where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food)) .options(
).all() joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
).unique().all()
# Sum nutrition across all entries for the date. # Sum nutrition across all entries for the date.
# TODO: TICKET-007 — meal entries currently contribute 0 because their # entry_nutrition() now handles meal foods by recursive component
# per_unit fields are null per the CHECK constraint. Real meal nutrition # summation (TICKET-007).
# will be derived by recursively summing component foods' nutrition.
# When that lands, replace the flat entry_nutrition() call with a
# meal-aware sum function from services/nutrition.py.
totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS} totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS}
for entry in entries: for entry in entries:
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity) entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
+546 -2
View File
@@ -1,7 +1,551 @@
"""Meal business logic: composition, recursion, cycle detection (spec §2.2). """Meal business logic: composition, recursion, cycle detection (spec §2.2).
Multi-write operations here own their transactions (spec §8.1 rule 6): Multi-write operations here own their transactions (spec §8.1 rule 6):
commit once at the end or roll back entirely. create_meal_from_log, unpack_meal, and update_meal_components each commit once
at the end or roll back entirely — never partial writes.
TODO: from-log, unpack, component replacement with cycle checks. ORM objects never leave this module; functions return Pydantic schemas
(spec §8.1 rule 3).
""" """
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from models import DailyLogEntry, Food, MealComponent
from schemas import (
FoodRead,
LogEntryRead,
MealComponentRead,
MealRead,
)
from services import nutrition
# ── Helpers ──────────────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
"""Eager-load a single entry with its food relationship."""
return db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(joinedload(DailyLogEntry.food))
)
def _load_entry_with_food_components(db: Session, entry_id: int) -> DailyLogEntry | None:
"""Eager-load a single entry with food and nested meal components."""
return db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
def _build_log_entry_read(entry: DailyLogEntry) -> LogEntryRead:
"""Convert a DailyLogEntry ORM object to LogEntryRead, populating
nested meal components on the embedded food when is_meal is True."""
data = {
"id": entry.id,
"date": entry.date,
"food_id": entry.food_id,
"quantity": entry.quantity,
"meal_slot": entry.meal_slot,
"sort_order": entry.sort_order,
}
# Build the embedded food
food = entry.food
food_data = {
"id": food.id,
"name": food.name,
"brand": food.brand,
"unit_type": food.unit_type,
"calories_per_unit": food.calories_per_unit,
"serving_size_g": food.serving_size_g,
"serving_name": food.serving_name,
"is_meal": food.is_meal,
"deleted_at": food.deleted_at,
}
# Populate nested components for meal foods
if food.is_meal and hasattr(food, "components"):
comps = []
for mc in food.components:
comp_food = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": comp_food.id,
"name": comp_food.name,
"brand": comp_food.brand,
"unit_type": comp_food.unit_type,
"calories_per_unit": comp_food.calories_per_unit,
"serving_size_g": comp_food.serving_size_g,
"serving_name": comp_food.serving_name,
"is_meal": comp_food.is_meal,
"deleted_at": comp_food.deleted_at,
},
))
food_data["components"] = comps
else:
food_data["components"] = None
data["food"] = food_data
# Compute nutrition for this entry (handles meals recursively)
data["computed_nutrition"] = nutrition.entry_nutrition(entry.food, entry.quantity)
return LogEntryRead.model_validate(data)
def _build_meal_read(meal: Food) -> MealRead:
"""Convert a meal Food ORM to MealRead with components & computed nutrition."""
# Base food fields
meal_data = {
"id": meal.id,
"name": meal.name,
"brand": meal.brand,
"barcode": meal.barcode,
"source": meal.source,
"is_meal": meal.is_meal,
"unit_type": meal.unit_type,
"calories_per_unit": meal.calories_per_unit,
"protein_per_unit": meal.protein_per_unit,
"carbs_per_unit": meal.carbs_per_unit,
"fat_per_unit": meal.fat_per_unit,
"fiber_per_unit": meal.fiber_per_unit,
"saturated_fat_per_unit": meal.saturated_fat_per_unit,
"sugars_per_unit": meal.sugars_per_unit,
"sodium_per_unit": meal.sodium_per_unit,
"serving_size_g": meal.serving_size_g,
"serving_name": meal.serving_name,
"deleted_at": meal.deleted_at,
"created_at": meal.created_at,
"updated_at": meal.updated_at,
}
# Build component list
comps = []
if hasattr(meal, "components"):
for mc in meal.components:
comp_food = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": comp_food.id,
"name": comp_food.name,
"brand": comp_food.brand,
"unit_type": comp_food.unit_type,
"calories_per_unit": comp_food.calories_per_unit,
"serving_size_g": comp_food.serving_size_g,
"serving_name": comp_food.serving_name,
"is_meal": comp_food.is_meal,
"deleted_at": comp_food.deleted_at,
},
))
meal_data["components"] = comps
meal_data["computed_nutrition_per_meal"] = nutrition.entry_nutrition(meal, 1.0)
return MealRead.model_validate(meal_data)
# ── Meal resolution (for foods.py get_food) ──────────────────────────────────
def load_meal_with_components(db: Session, meal_id: int) -> Food | None:
"""Eager-load a meal food with its components and their foods.
Returns None if the food doesn't exist or is not a meal.
"""
result = db.scalar(
select(Food)
.where(Food.id == meal_id, Food.is_meal.is_(True))
.options(
selectinload(Food.components).joinedload(MealComponent.food)
)
)
return result
# ── Cycle detection ──────────────────────────────────────────────────────────
class MealCycleError(Exception):
"""Raised when a component update would create a cycle in the meal graph."""
def __init__(self, meal_id: int, food_id: int):
super().__init__(
f"Adding food {food_id} as a component of meal {meal_id} "
f"would create a cycle"
)
self.meal_id = meal_id
self.food_id = food_id
def _check_cycle(db: Session, meal_id: int, proposed_food_ids: list[int]) -> None:
"""Raise MealCycleError if adding proposed_food_ids as components of
meal_id would create a cycle (directly or transitively).
Direct self-reference (food_id == meal_id) is also considered a cycle.
"""
# Build adjacency from all existing meal_components
stmt = select(MealComponent.meal_id, MealComponent.food_id)
rows = db.execute(stmt).all()
adj: dict[int, set[int]] = {}
for row in rows:
adj.setdefault(row.meal_id, set()).add(row.food_id)
# Add proposed edges
for fid in proposed_food_ids:
adj.setdefault(meal_id, set()).add(fid)
# DFS from each proposed food_id; if meal_id is reachable, it's a cycle
for fid in proposed_food_ids:
if _dfs_reachable(adj, fid, meal_id, set()):
raise MealCycleError(meal_id, fid)
def _dfs_reachable(
adj: dict[int, set[int]], current: int, target: int, visited: set[int],
) -> bool:
"""Return True if target is reachable from current in the adjacency graph."""
if current == target:
return True
if current in visited:
return False
visited.add(current)
for neighbor in adj.get(current, set()):
if _dfs_reachable(adj, neighbor, target, visited):
return True
return False
# ── create_meal_from_log (§4.3) ──────────────────────────────────────────────
def create_meal_from_log(
db: Session, lookup_date: date, entry_ids: list[int], name: str,
) -> tuple[FoodRead, LogEntryRead]:
"""Create a meal from selected daily_log entries. Runs as one transaction.
Steps:
1. Load the source entries; validate they all exist and belong to ``date``.
2. Create a new Food (is_meal=True, source="meal").
3. Create MealComponent rows (quantity = original entry.quantity).
4. Delete the source entries.
5. Insert ONE replacement entry with quantity=1.0.
6. Commit once; roll back entirely on any failure.
Returns (FoodRead of the new meal, LogEntryRead of the replacement entry).
"""
# 1. Load & validate source entries
source_entries: list[DailyLogEntry] = []
for eid in entry_ids:
entry = db.get(DailyLogEntry, eid)
if entry is None:
raise EntryNotFoundError(eid)
if entry.date != lookup_date:
raise EntryDateMismatchError(eid, entry.date, lookup_date)
source_entries.append(entry)
if not source_entries:
raise ValueError("No valid source entries provided")
# 2. Create the meal food
now = _now()
meal = Food(
name=name,
brand=None,
barcode=None,
source="meal",
is_meal=True,
unit_type="weight", # placeholder — meals use quantity as scaling factor
calories_per_unit=None,
protein_per_unit=None,
carbs_per_unit=None,
fat_per_unit=None,
fiber_per_unit=None,
saturated_fat_per_unit=None,
sugars_per_unit=None,
sodium_per_unit=None,
serving_size_g=None,
serving_name=None,
off_data=None,
deleted_at=None,
created_at=now,
updated_at=now,
)
db.add(meal)
db.flush() # get meal.id
# 3. Create MealComponent rows
for entry in source_entries:
mc = MealComponent(
meal_id=meal.id,
food_id=entry.food_id,
quantity=entry.quantity,
)
db.add(mc)
# 4. Delete source entries
max_sort = 0
meal_slots: set[str | None] = set()
for entry in source_entries:
if entry.sort_order > max_sort:
max_sort = entry.sort_order
meal_slots.add(entry.meal_slot)
db.delete(entry)
# 5. Insert replacement entry
# meal_slot: use the first source entry's slot if all agree, else None
replacement_slot = source_entries[0].meal_slot if len(meal_slots) == 1 else None
replacement = DailyLogEntry(
date=lookup_date,
food_id=meal.id,
quantity=1.0,
meal_slot=replacement_slot,
sort_order=max_sort,
created_at=now,
)
db.add(replacement)
db.flush() # get replacement.id
# Commit
db.commit()
# Re-query for response with eager-loaded relationships
meal_loaded = load_meal_with_components(db, meal.id)
entry_loaded = _load_entry_with_food_components(db, replacement.id)
meal_read = _build_meal_read(meal_loaded)
entry_read = _build_log_entry_read(entry_loaded)
return meal_read, entry_read
# ── unpack_meal (§4.4) ──────────────────────────────────────────────────────
def unpack_meal(
db: Session, meal_id: int, lookup_date: date, entry_id: int | None = None,
) -> list[LogEntryRead]:
"""Replace a logged meal entry with its component entries.
Runs as one transaction:
1. Find the target log entry (by entry_id or auto-detect).
2. Flatten the meal recursively to LEAF foods.
3. Insert new entries for each leaf.
4. Delete the original meal entry.
5. Commit once; roll back entirely on any failure.
If ``entry_id`` is given, it must point at a log entry for ``meal_id``
on ``lookup_date``. If ``entry_id`` is None, the entry is auto-detected:
exactly one matching entry must exist, or an error is raised.
"""
# 1. Find the target log entry
if entry_id is not None:
target = db.scalar(
select(DailyLogEntry)
.where(
DailyLogEntry.id == entry_id,
DailyLogEntry.food_id == meal_id,
DailyLogEntry.date == lookup_date,
)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
if target is None:
raise EntryNotFoundError(entry_id)
else:
candidates = db.scalars(
select(DailyLogEntry)
.where(
DailyLogEntry.food_id == meal_id,
DailyLogEntry.date == lookup_date,
)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
).unique().all()
if len(candidates) == 0:
raise ValueError(
f"No log entry found for meal {meal_id} on {lookup_date}"
)
if len(candidates) > 1:
raise AmbiguousMealEntryError(meal_id, lookup_date, len(candidates))
target = candidates[0]
meal_food = target.food
if not meal_food.is_meal:
raise ValueError(f"Food {meal_id} is not a meal")
# 2. Flatten recursively to leaf foods
leaf_entries = _flatten_meal(meal_food, target.quantity)
# 3. Insert new entries for each leaf
sort_base = target.sort_order
new_entries: list[DailyLogEntry] = []
for i, (leaf_food_id, eff_qty) in enumerate(leaf_entries):
entry = DailyLogEntry(
date=lookup_date,
food_id=leaf_food_id,
quantity=eff_qty,
meal_slot=target.meal_slot,
sort_order=sort_base + i,
created_at=_now(),
)
db.add(entry)
new_entries.append(entry)
# 4. Delete the original meal entry
db.delete(target)
# Commit
db.commit()
# Re-query for responses with eager-loaded food
result: list[LogEntryRead] = []
for entry in new_entries:
loaded = _load_entry_with_food(db, entry.id)
result.append(_build_log_entry_read(loaded))
return result
def _flatten_meal(
food: Food, scaling: float, visited: set[int] | None = None,
) -> list[tuple[int, float]]:
"""Recursively flatten a meal to its leaf foods.
Returns a list of (food_id, effective_quantity) for each leaf food.
Nested meals are expanded; their scaling factors are multiplied down.
``visited`` guards against cycles (safety net; cycles should be prevented
by the write path). A cycled branch returns an empty list.
"""
if visited is None:
visited = set()
if food.id in visited:
return []
visited.add(food.id)
if not food.is_meal:
return [(food.id, scaling)]
leaves: list[tuple[int, float]] = []
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by
# the scaling factor for this log entry.
leaves.extend(
_flatten_meal(component.food, component.quantity * scaling, visited)
)
return leaves
# ── update_meal_components (§3.2 PUT, cycle-checked) ─────────────────────────
def update_meal_components(
db: Session, meal_id: int, components: list[dict],
) -> MealRead:
"""Replace the component list for a meal food.
``components`` is a list of dicts with ``food_id`` and ``quantity`` keys.
Validates that each food_id exists, quantity > 0, and the new component
list doesn't create a cycle (direct or transitive self-reference).
Runs in one transaction: deletes existing components, inserts new ones,
commits. Returns the updated MealRead.
"""
# Validate meal exists and is a meal
meal = db.get(Food, meal_id)
if meal is None or not meal.is_meal:
raise MealNotFoundError(meal_id)
# Collect proposed food_ids
proposed_ids = [c["food_id"] for c in components]
# Validate each food_id exists
for fid in proposed_ids:
if db.get(Food, fid) is None:
raise ComponentFoodNotFoundError(fid)
# Cycle check: would adding these components create a cycle?
_check_cycle(db, meal_id, proposed_ids)
# Replace: delete existing, insert new
db.execute(
MealComponent.__table__.delete().where(MealComponent.meal_id == meal_id)
)
for c in components:
mc = MealComponent(
meal_id=meal_id,
food_id=c["food_id"],
quantity=c["quantity"],
)
db.add(mc)
db.commit()
# Re-query with components loaded
meal = load_meal_with_components(db, meal_id)
return _build_meal_read(meal)
# ── Errors ───────────────────────────────────────────────────────────────────
class EntryNotFoundError(Exception):
def __init__(self, entry_id: int):
super().__init__(f"Log entry {entry_id} not found")
self.entry_id = entry_id
class EntryDateMismatchError(Exception):
def __init__(self, entry_id: int, entry_date: date, expected_date: date):
super().__init__(
f"Log entry {entry_id} has date {entry_date}, "
f"not the expected {expected_date}"
)
self.entry_id = entry_id
self.entry_date = entry_date
self.expected_date = expected_date
class AmbiguousMealEntryError(Exception):
def __init__(self, meal_id: int, lookup_date: date, count: int):
super().__init__(
f"Multiple ({count}) log entries for meal {meal_id} on {lookup_date}. "
f"Specify entry_id to disambiguate."
)
self.meal_id = meal_id
self.lookup_date = lookup_date
self.count = count
class MealNotFoundError(Exception):
def __init__(self, meal_id: int):
super().__init__(f"Meal {meal_id} not found or is not a meal")
self.meal_id = meal_id
class ComponentFoodNotFoundError(Exception):
def __init__(self, food_id: int):
super().__init__(f"Component food {food_id} not found")
self.food_id = food_id
+59 -11
View File
@@ -5,6 +5,10 @@ Routers never compute nutrition; the frontend never re-derives it.
- unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit - unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit
- unit_type "count": quantity is item count; nutrition = quantity × per_unit - unit_type "count": quantity is item count; nutrition = quantity × per_unit
- meal: quantity is a scaling factor (1.0 = one full meal) - meal: quantity is a scaling factor (1.0 = one full meal)
Meal nutrition is derived by recursive component summation (§2.2), with cycle
detection. A cycle returns zeros for that branch (safety net — writes prevent
cycles, but reads must never infinite-loop).
""" """
from models import Food from models import Food
@@ -42,26 +46,57 @@ def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -
raise ValueError(f"unknown unit_type: {unit_type!r}") raise ValueError(f"unknown unit_type: {unit_type!r}")
def entry_calories(food: Food, quantity: float) -> float: def entry_calories(food: Food, quantity: float, visited: set[int] | None = None) -> float:
"""Calories for a single (non-meal) food at a logged quantity. """Calories for a food at a logged quantity.
TODO: handle is_meal foods by summing scaled component nutrition For regular foods: scales per_unit by quantity using unit_type.
(recursively, with cycle detection — spec §2.2). For meals: recursively sums scaled component nutrition. Cycle detection
prevents infinite loops — a cycled branch returns 0 (safety net; writes
should prevent cycles from being created).
""" """
if visited is None:
visited = set()
if food.id in visited:
return 0.0
if not food.is_meal:
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type) return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
# Meal: recurse into components
visited.add(food.id)
total = 0.0
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by the
# entry's scaling factor to get the effective quantity for this log entry.
total += entry_calories(component.food, component.quantity * quantity, visited)
return total
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
def entry_nutrition(
food: Food, quantity: float, visited: set[int] | None = None,
) -> dict[str, float]:
"""Return all nutrition fields scaled to a logged quantity. """Return all nutrition fields scaled to a logged quantity.
Each field is resolved via scale_to_quantity using the food's unit_type. For regular foods: each field is resolved via scale_to_quantity using
NULL per-unit values contribute 0.0, not an error. the food's unit_type. NULL per-unit values contribute 0.0.
Meal foods (is_meal=True) have null per_unit fields per the CHECK For meals: recursively sums scaled component nutrition (§2.2). Cycle
constraint, so they naturally contribute 0 for all fields. detection prevents infinite loops — a cycled branch returns zeros for
TODO: TICKET-007 — real meal nutrition will sum scaled component all fields (safety net; writes should prevent cycles from being created).
foods recursively. Until then, meal entries contribute 0.
The ``visited`` set tracks food IDs on the current recursion path.
Callers should NOT pre-populate it — it defaults to an empty set and
is only used internally for recursion.
""" """
if visited is None:
visited = set()
if food.id in visited:
# Cycle detected — safety net; return zeros for this branch.
return {field: 0.0 for field in NUTRITION_FIELDS}
if not food.is_meal:
return { return {
field: scale_to_quantity( field: scale_to_quantity(
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None), getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
@@ -70,3 +105,16 @@ def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
) )
for field in NUTRITION_FIELDS for field in NUTRITION_FIELDS
} }
# Meal: recurse into components, multiplying the scaling factor down.
visited.add(food.id)
totals = {field: 0.0 for field in NUTRITION_FIELDS}
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by the
# entry's scaling factor to get the effective quantity for this log entry.
component_nut = entry_nutrition(
component.food, component.quantity * quantity, visited,
)
for field in NUTRITION_FIELDS:
totals[field] += component_nut[field]
return totals
+266
View File
@@ -0,0 +1,266 @@
"""OFF normalization + HTTP calls (spec §8.1 rule 10).
All OFF→foods field mapping lives here and nowhere else.
Tests mock at the httpx boundary — never hit the real OFF API (spec §8.4).
Normalization field mapping (OFF v2 → FoodCreate):
- name: product_name (fallback: generic_name)
- brand: brands (comma-separated → first entry trimmed; see _first_brand)
- barcode: code
- source: "openfoodfacts"
- unit_type: "weight" (OFF nutrition is per-100g)
- calories_per_unit: nutriments["energy-kcal_100g"], or
nutriments["energy-kj_100g"] / 4.184 if kcal absent → rounded 1dp
- *_per_unit: nutriments["{field}_100g"] (absent → null)
- serving_size_g: serving_quantity parsed as float (grams) if present
- serving_name: serving_size (human string) if present
- off_data: raw product JSON serialized to string
Not-found rule: returns None when the product has no usable name AND no
computable calories (both missing → not a useful food).
"""
import json
from datetime import datetime, timezone
import httpx
from sqlalchemy.orm import Session
from models import Food
from schemas import FoodRead
OFF_BASE_URL = "https://world.openfoodfacts.org"
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)"
OFF_TIMEOUT = 10.0
# 1 kcal = 4.184 kJ → kJ_to_kcal = 1 / 4.184
_KJ_TO_KCAL = 1.0 / 4.184
# ── Client factory (mocked at the httpx boundary in tests) ───────────────────
def _default_client() -> httpx.Client:
"""Return an httpx Client with our User-Agent and timeout."""
return httpx.Client(
headers={"User-Agent": OFF_USER_AGENT},
timeout=OFF_TIMEOUT,
)
# ── Normalization (the ONE module — spec §8.1 rule 10) ──────────────────────
def _first_brand(brands_raw: str | None) -> str | None:
"""OFF brands is comma-separated (e.g. "Nutella, Ferrero, Yum yum").
We split on comma, trim whitespace, and return the first non-empty entry.
Returns None when the string is empty or all-whitespace."""
if not brands_raw or not brands_raw.strip():
return None
parts = [p.strip() for p in brands_raw.split(",")]
for p in parts:
if p:
return p
return None
def _parse_float(value: object) -> float | None:
"""Coerce an OFF value (number or string) to float. Returns None on failure."""
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def normalize_off_product(product: dict) -> dict | None:
"""Map an OFF v2 product JSON object → dict matching FoodCreate.
Returns None when the product is unusable: no name AND no computable
calories. This is the "not-found" signal for the proxy layer.
"""
nutriments = product.get("nutriments") or {}
# ── name ──
name = (product.get("product_name") or product.get("generic_name") or "").strip()
# ── calories: prefer kcal; fall back to kJ → kcal conversion ──
kcal = _parse_float(nutriments.get("energy-kcal_100g"))
if kcal is None:
kj = _parse_float(nutriments.get("energy-kj_100g"))
if kj is not None:
kcal = round(kj * _KJ_TO_KCAL, 1)
# ── not-found check ──
if not name and kcal is None:
return None
# ── brand ──
brand = _first_brand(product.get("brands"))
# ── serving ──
serving_qty = _parse_float(product.get("serving_quantity"))
return {
"name": name,
"brand": brand,
"barcode": str(product.get("code", "")),
"source": "openfoodfacts",
"is_meal": False,
"unit_type": "weight",
"calories_per_unit": kcal,
"protein_per_unit": _parse_float(nutriments.get("proteins_100g")),
"carbs_per_unit": _parse_float(nutriments.get("carbohydrates_100g")),
"fat_per_unit": _parse_float(nutriments.get("fat_100g")),
"fiber_per_unit": _parse_float(nutriments.get("fiber_100g")),
"saturated_fat_per_unit": _parse_float(nutriments.get("saturated-fat_100g")),
"sugars_per_unit": _parse_float(nutriments.get("sugars_100g")),
"sodium_per_unit": _parse_float(nutriments.get("sodium_100g")),
"serving_size_g": serving_qty,
"serving_name": product.get("serving_size") or None,
"off_data": json.dumps(product),
}
# ── OFF proxy calls ──────────────────────────────────────────────────────────
def fetch_product(barcode: str, client: httpx.Client | None = None) -> dict | None:
"""Fetch a product from OFF by barcode and return normalized data.
Returns the normalized food dict (FoodCreate shape), or None if OFF
has no product / status ≠ 1 / unusable data.
Upstream errors (HTTP 5xx, timeouts, connection failures) are treated
as "not found" (returns None → router maps to 404) so the API degrades
gracefully instead of crashing with a 500.
Pass *client* with a MockTransport in tests; otherwise a default
httpx.Client is created (and closed) per call.
"""
own = client is None
if own:
client = _default_client()
try:
resp = client.get(f"{OFF_BASE_URL}/api/v2/product/{barcode}")
resp.raise_for_status()
data = resp.json()
if data.get("status") != 1 or not data.get("product"):
return None
return normalize_off_product(data["product"])
except (httpx.HTTPStatusError, httpx.RequestError):
# Upstream unavailable or transport failure — degrade gracefully.
return None
finally:
if own:
client.close()
def search_off(query: str, client: httpx.Client | None = None) -> list[dict]:
"""Search OFF by text query and return a list of normalized food dicts.
Fields requested: code, product_name, generic_name, brands, nutriments,
serving_quantity, serving_size. Page size is capped at 20.
Returns an empty list when OFF has no matches, all results are
unusable after normalization, or an upstream/transport error occurs
(graceful degradation — no 500s).
"""
own = client is None
if own:
client = _default_client()
try:
resp = client.get(
f"{OFF_BASE_URL}/api/v2/search",
params={
"search_terms": query,
"fields": "code,product_name,generic_name,brands,"
"nutriments,serving_quantity,serving_size",
"page_size": 20,
},
)
resp.raise_for_status()
data = resp.json()
results: list[dict] = []
for p in data.get("products", []):
norm = normalize_off_product(p)
if norm is not None:
results.append(norm)
return results
except (httpx.HTTPStatusError, httpx.RequestError):
# Upstream unavailable or transport failure — degrade gracefully.
return []
finally:
if own:
client.close()
# ── Refresh (§3.5) ───────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class RefreshError(Exception):
"""Errors from the refresh endpoint that map to HTTP status codes."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class NoBarcodeError(RefreshError):
"""Food has no barcode — can't refresh from OFF."""
def __init__(self, food_id: int):
super().__init__(
400,
f"Food {food_id} has no barcode — cannot refresh from OpenFoodFacts",
)
def refresh_food(
db: Session, food_id: int, client: httpx.Client | None = None
) -> FoodRead:
"""Re-fetch a food's data from OFF by its stored barcode and update the
local row (nutrition, name, brand, serving, off_data; bump updated_at).
Raises:
RefreshError(404) — food_id not found
NoBarcodeError(400) — food has no barcode
RefreshError(404) — OFF no longer has the product
"""
food = db.get(Food, food_id)
if food is None:
raise RefreshError(404, f"Food {food_id} not found")
barcode = food.barcode
if not barcode:
raise NoBarcodeError(food_id)
norm = fetch_product(barcode, client=client)
if norm is None:
raise RefreshError(404, f"Barcode '{barcode}' no longer found on OpenFoodFacts")
# Update the local row with fresh OFF data
food.name = norm["name"]
food.brand = norm["brand"]
food.calories_per_unit = norm["calories_per_unit"]
food.protein_per_unit = norm["protein_per_unit"]
food.carbs_per_unit = norm["carbs_per_unit"]
food.fat_per_unit = norm["fat_per_unit"]
food.fiber_per_unit = norm["fiber_per_unit"]
food.saturated_fat_per_unit = norm["saturated_fat_per_unit"]
food.sugars_per_unit = norm["sugars_per_unit"]
food.sodium_per_unit = norm["sodium_per_unit"]
food.serving_size_g = norm["serving_size_g"]
food.serving_name = norm["serving_name"]
food.off_data = norm["off_data"]
food.updated_at = _now()
db.commit()
db.refresh(food)
return FoodRead.model_validate(food)
+32
View File
@@ -3,6 +3,7 @@
import os import os
import tempfile import tempfile
import httpx
import pytest import pytest
# Must be set before any app module is imported (engine binds at import time) # Must be set before any app module is imported (engine binds at import time)
@@ -20,3 +21,34 @@ def client():
run_migrations() run_migrations()
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
@pytest.fixture
def off_mock(monkeypatch):
"""Replace _default_client() in services.off with a mock client backed
by httpx.MockTransport. Tests assign off_mock.handler to control
responses.
Usage:
def test_foo(client, off_mock):
def handler(request):
return httpx.Response(200, json={...})
off_mock.handler = handler
resp = client.get("/api/off/product/123")
"""
state = _MockState()
def _make_mock_client():
return httpx.Client(
transport=httpx.MockTransport(lambda req: state.handler(req))
)
import services.off as off_mod
monkeypatch.setattr(off_mod, "_default_client", _make_mock_client)
return state
class _MockState:
"""Mutable state so tests can set .handler after fixture injection."""
def __init__(self):
self.handler = None
+197 -6
View File
@@ -165,15 +165,27 @@ def test_barcode_conflict_on_live_food(client):
assert "barcode" in resp.json()["detail"].lower() assert "barcode" in resp.json()["detail"].lower()
def test_barcode_conflict_on_deleted_food(client): def test_restore_on_rescan(client):
"""Creating a food with a barcode that exists on a DELETED food → also 409 """Creating a food with a barcode that belongs to a SOFT-DELETED food
for now (restore-on-rescan is TICKET-006).""" restores the deleted row (clears deleted_at, updates fields) instead
resp = create_food(client, name="First", barcode="conflict-on-deleted") of inserting a duplicate (§3.1 restore-on-rescan)."""
# Create a food, then soft-delete it
resp = create_food(client, name="Original", barcode="restore-me", calories_per_unit=100)
fid = resp.json()["id"] fid = resp.json()["id"]
client.delete(f"/api/foods/{fid}") client.delete(f"/api/foods/{fid}")
resp = create_food(client, name="Second", barcode="conflict-on-deleted") # Re-create with same barcode → should restore, not insert
assert resp.status_code == 409 resp = create_food(client, name="Restored", barcode="restore-me", calories_per_unit=200)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["id"] == fid # same row
assert data["name"] == "Restored" # updated
assert data["calories_per_unit"] == 200 # updated
assert data["deleted_at"] is None # restored
# Confirm only one row with this barcode
search = client.get("/api/foods", params={"barcode": "restore-me"})
assert len(search.json()) == 1
def test_multiple_none_barcodes_allowed(client): def test_multiple_none_barcodes_allowed(client):
@@ -322,3 +334,182 @@ def test_search_excludes_deleted_by_default(client):
resp = client.get("/api/foods", params={"q": "Hidden", "include_deleted": "true"}) resp = client.get("/api/foods", params={"q": "Hidden", "include_deleted": "true"})
assert len(resp.json()) == 1 assert len(resp.json()) == 1
# ── GET /api/foods/recent (TICKET-006) ──────────────────────────────────────
def test_recent_empty_when_no_logs(client):
"""With no daily_log entries, recent returns [] (no crash)."""
resp = client.get("/api/foods/recent")
assert resp.status_code == 200
assert resp.json() == []
def test_recent_ordering_by_last_logged(client):
"""Foods ordered by the daily_log entry's created_at (most recent log
action), NOT by the food's own created_at."""
# Create two foods with unique names
a = create_food(client, name="ZZ-Recent-A-Later")
b = create_food(client, name="ZZ-Recent-B-First")
a_id = a.json()["id"]
b_id = b.json()["id"]
# Log B first, then A — so A is most recently logged
from datetime import date
client.post("/api/log", json={"food_id": b_id, "quantity": 1, "date": str(date.today())})
client.post("/api/log", json={"food_id": a_id, "quantity": 1, "date": str(date.today())})
resp = client.get("/api/foods/recent")
assert resp.status_code == 200
results = resp.json()
# Filter to just our two foods (other tests may have logged other foods)
ours = [r for r in results if r["id"] in (a_id, b_id)]
assert len(ours) == 2
# A should be first (most recently logged)
assert ours[0]["id"] == a_id
assert ours[1]["id"] == b_id
def test_recent_deduplicates(client):
"""A food logged multiple times appears only once in recent."""
from datetime import date
food = create_food(client, name="ZZ-Multi-log")
fid = food.json()["id"]
# Log the same food twice
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": str(date.today())})
client.post("/api/log", json={"food_id": fid, "quantity": 2, "date": str(date.today())})
resp = client.get("/api/foods/recent")
results = resp.json()
# Should appear only once
ids = [r["id"] for r in results]
assert ids.count(fid) == 1
def test_recent_excludes_deleted(client):
"""Soft-deleted foods must not appear in recent."""
from datetime import date
food = create_food(client, name="ZZ-Delete-Me-Soon")
fid = food.json()["id"]
# Log it once
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": str(date.today())})
# Soft-delete
client.delete(f"/api/foods/{fid}")
resp = client.get("/api/foods/recent")
results = resp.json()
assert not any(r["id"] == fid for r in results)
def test_recent_respects_limit(client):
"""limit query param caps the result count."""
from datetime import date
today = str(date.today())
# Create 5 foods and log them
ids = []
for i in range(5):
f = create_food(client, name=f"ZZ-LimitTest {i}")
fid = f.json()["id"]
ids.append(fid)
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": today})
# Custom limit of 2 — our 5 foods are the most recent, but limit caps it
resp = client.get("/api/foods/recent?limit=2")
assert len(resp.json()) == 2
# All 5 should be within the default limit (10)
resp = client.get("/api/foods/recent")
ours = [r for r in resp.json() if r["id"] in ids]
assert len(ours) == 5
def test_recent_limit_enforced(client):
"""limit > 50 should be rejected."""
resp = client.get("/api/foods/recent?limit=100")
assert resp.status_code == 422
# ── Restore endpoint (TICKET-008, spec §3.1) ─────────────────────────────────
def test_restore_clears_deleted_flag(client):
"""POST /api/foods/{id}/restore clears deleted_at on a soft-deleted food."""
resp = create_food(client, name="RestoreMe")
fid = resp.json()["id"]
client.delete(f"/api/foods/{fid}")
resp = client.post(f"/api/foods/{fid}/restore")
assert resp.status_code == 200, resp.text
assert resp.json()["deleted_at"] is None
# Confirm via GET
resp = client.get(f"/api/foods/{fid}")
assert resp.json()["deleted_at"] is None
def test_restore_404_unknown_id(client):
resp = client.post("/api/foods/99999/restore")
assert resp.status_code == 404
def test_restore_not_deleted_is_sensible(client):
"""Restoring a food that isn't deleted returns it unchanged (idempotent)."""
resp = create_food(client, name="NeverDeleted")
fid = resp.json()["id"]
resp = client.post(f"/api/foods/{fid}/restore")
assert resp.status_code == 200, resp.text
assert resp.json()["id"] == fid
assert resp.json()["deleted_at"] is None
def test_restore_reappears_in_search(client):
"""A restored food shows up in default search again."""
resp = create_food(client, name="RestoreSearchable")
fid = resp.json()["id"]
client.delete(f"/api/foods/{fid}")
resp = client.get("/api/foods", params={"q": "RestoreSearchable"})
assert not any(f["id"] == fid for f in resp.json())
client.post(f"/api/foods/{fid}/restore")
resp = client.get("/api/foods", params={"q": "RestoreSearchable"})
assert any(f["id"] == fid for f in resp.json())
def test_restore_preserves_history(client):
"""Restoring does not touch nutrition fields or created_at (history intact)."""
resp = create_food(client, name="RestoreHistory", calories_per_unit=321.0)
fid = resp.json()["id"]
created_at = resp.json()["created_at"]
client.delete(f"/api/foods/{fid}")
resp = client.post(f"/api/foods/{fid}/restore")
data = resp.json()
assert data["calories_per_unit"] == 321.0
assert data["created_at"] == created_at
def test_restore_still_visible_in_historical_log(client):
"""A food soft-deleted and restored still renders in old log entries (§2.1)."""
resp = create_food(client, name="RestoreLog", calories_per_unit=100.0)
fid = resp.json()["id"]
log_resp = client.post("/api/log", json={
"food_id": fid, "quantity": 200.0, "date": "2026-07-26",
})
assert log_resp.status_code == 201, log_resp.text
client.delete(f"/api/foods/{fid}")
client.post(f"/api/foods/{fid}/restore")
resp = client.get("/api/log", params={"date": "2026-07-26"})
entries = [e for e in resp.json() if e["food_id"] == fid]
assert entries, "log entry missing after delete/restore"
assert entries[0]["food"]["name"] == "RestoreLog"
assert entries[0]["computed_nutrition"]["calories"] == 200.0
+773
View File
@@ -0,0 +1,773 @@
"""Meal tests — TICKET-007 (spec §2.2, §3.2, §4.3, §4.4, §8.1 rule 6, §8.4).
Covers:
- Nutrition recursion: meal of components, scaling factors, nested meals,
null nutrition fields contribute 0
- Cycle detection on nutrition reads: manually-constructed cycle returns zeros
- POST /api/meals/from-log happy path + rollback on failure
- POST /api/meals/{meal_id}/unpack happy path (incl. 1.5× scaling, nested
meal flattening) + rollback on failure
- PUT /api/meals/{meal_id}/components cycle rejection (transitive + direct
self-reference) + valid non-cyclic replacement
- Summary includes real meal nutrition (no longer 0 for meals)
- GET /api/log includes nested components for meal entries
- GET /api/foods/{id} returns MealRead for meals
"""
import pytest
# ── Helpers ──────────────────────────────────────────────────────────────────
def _create_food(client, **overrides) -> dict:
"""Create a food via POST and return the response JSON."""
payload = {
"name": "Test Food",
"calories_per_unit": 250.0,
"source": "manual",
"unit_type": "weight",
}
payload.update(overrides)
resp = client.post("/api/foods", json=payload)
assert resp.status_code == 201, f"food create failed: {resp.text}"
return resp.json()
def _create_meal(client, name, is_meal=True, source="meal") -> dict:
"""Create a meal food (is_meal=True, calories_per_unit=None)."""
return _create_food(
client, name=name, is_meal=is_meal, calories_per_unit=None, source=source,
)
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
"""Create a log entry and return the parsed JSON (asserts 201)."""
payload = {"food_id": food_id, "quantity": quantity, "date": date}
payload.update(overrides)
resp = client.post("/api/log", json=payload)
assert resp.status_code == 201, f"log create failed: {resp.text}"
return resp.json()
def _get_summary(client, date="2025-06-15") -> dict:
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
resp = client.get("/api/log/summary", params={"date": date})
assert resp.status_code == 200, f"summary failed: {resp.text}"
return resp.json()
def _get_log(client, date="2025-06-15") -> list[dict]:
"""Call GET /api/log?date= and return parsed JSON (asserts 200)."""
resp = client.get("/api/log", params={"date": date})
assert resp.status_code == 200, f"log GET failed: {resp.text}"
return resp.json()
def _from_log(client, name, date, entry_ids) -> dict:
"""Call POST /api/meals/from-log and return parsed JSON (asserts 201)."""
resp = client.post(
"/api/meals/from-log",
json={"name": name, "date": date, "entry_ids": entry_ids},
)
return resp
def _unpack_meal(client, meal_id, date, entry_id=None) -> dict:
"""Call POST /api/meals/{meal_id}/unpack and return parsed JSON (asserts 200)."""
body = {"date": date}
if entry_id is not None:
body["entry_id"] = entry_id
resp = client.post(f"/api/meals/{meal_id}/unpack", json=body)
return resp
def _update_components(client, meal_id, components) -> dict:
"""Call PUT /api/meals/{meal_id}/components and return response."""
resp = client.put(
f"/api/meals/{meal_id}/components",
json={"components": components},
)
return resp
# ── Nutrition recursion (§8.1 rule 1) ────────────────────────────────────────
def test_meal_nutrition_two_components_weight_type(client):
"""A meal of 100g rice (130 kcal/100g) + 1 egg (70 kcal/count) →
one meal = 130 + 70 = 200 kcal. Logged at 1.0× → 200 kcal."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
# Create a meal from scratch via the service (bypassing from-log to test pure nutrition)
date = "2025-07-20"
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, egg["id"], quantity=1.0, date=date)
resp = _from_log(client, "Rice + Egg", date, _get_entry_ids(client, date))
assert resp.status_code == 201, resp.text
data = resp.json()
meal = data["meal"]
entry = data["entry"]
# The replacement entry should be quantity=1.0
assert entry["quantity"] == 1.0
# Summary should reflect real meal nutrition (200 kcal)
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 200.0
def test_meal_nutrition_scaled_1_5x(client):
"""A meal logged at 1.5× should contribute 1.5× the nutrition."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
date = "2025-07-21"
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, egg["id"], quantity=1.0, date=date)
# Create meal from log
resp = _from_log(client, "Scaled Meal", date, _get_entry_ids(client, date))
assert resp.status_code == 201, resp.text
meal_id = resp.json()["meal"]["id"]
# Log the meal at 1.5×
_log_entry(client, meal_id, quantity=1.5, date=date)
summary = _get_summary(client, date)
# One meal at 1.0× (200 kcal) + one at 1.5× (300 kcal) = 500 total
assert summary["totals"]["calories"] == 500.0
def test_meal_nutrition_nested_meals(client):
"""A meal containing another meal → recursion depth 2 sums correctly."""
# Component foods
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
date = "2025-07-22"
# First: create an inner meal (rice + chicken)
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
inner_resp = _from_log(client, "Inner Meal", date, _get_entry_ids(client, date))
assert inner_resp.status_code == 201
inner_meal_id = inner_resp.json()["meal"]["id"]
inner_entry_id = inner_resp.json()["entry"]["id"]
# inner meal: 100g rice (130 kcal) + 200g chicken (330 kcal) = 460 kcal
# Second: create outer meal containing inner meal + another food
# We need to update inner meal's components to prepare, or just use from-log
# with the existing inner meal log entry
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
_log_entry(client, apple["id"], quantity=150.0, date=date) # 78 kcal
# Now the date has: inner_meal_entry (1.0x → 460 kcal) + apple entry (78 kcal)
all_eids = _get_entry_ids(client, date)
outer_resp = _from_log(client, "Outer Meal", date, all_eids)
assert outer_resp.status_code == 201
outer_meal_id = outer_resp.json()["meal"]["id"]
# Check summary: the outer meal at 1.0× should be 460 + 78 = 538 kcal
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 538.0
# GET the outer meal: computed_nutrition_per_meal should be 538
meal_resp = client.get(f"/api/foods/{outer_meal_id}")
assert meal_resp.status_code == 200
meal_data = meal_resp.json()
assert "components" in meal_data
assert "computed_nutrition_per_meal" in meal_data
assert meal_data["computed_nutrition_per_meal"]["calories"] == 538.0
def test_meal_nutrition_null_fields_contribute_zero(client):
"""A component with null calories_per_unit contributes 0."""
# Create a meal-type food (null per_unit) as a component
placeholder = _create_meal(client, "Placeholder")
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
date = "2025-07-23"
# Log and create meal from them
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, placeholder["id"], quantity=1.0, date=date)
resp = _from_log(client, "Mixed Meal", date, _get_entry_ids(client, date))
assert resp.status_code == 201
# Summary: only rice contributes (130 kcal), placeholder is 0
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 130.0
# ── Cycle detection on nutrition reads (§8.1 rule 1 safety net) ─────────────
def test_cycle_detection_on_nutrition_reads(client):
"""A manually-constructed cycle (bypassing the write check) returns zeros,
doesn't infinite-loop."""
from database import SessionLocal
from models import Food, MealComponent
date = "2025-07-24"
# Create two meal foods
meal_a = _create_meal(client, "Meal A")
meal_b = _create_meal(client, "Meal B")
# Manually insert cycle: A → B → A
db = SessionLocal()
try:
mc1 = MealComponent(meal_id=meal_a["id"], food_id=meal_b["id"], quantity=1.0)
mc2 = MealComponent(meal_id=meal_b["id"], food_id=meal_a["id"], quantity=1.0)
db.add(mc1)
db.add(mc2)
db.commit()
finally:
db.close()
# Log meal A
_log_entry(client, meal_a["id"], quantity=1.0, date=date)
# Summary should NOT infinite-loop; cycle returns 0
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 0.0 # cycle → zeros
# Cleanup
db = SessionLocal()
try:
db.query(MealComponent).filter(
MealComponent.meal_id.in_([meal_a["id"], meal_b["id"]])
).delete()
db.commit()
finally:
db.close()
# ── POST /api/meals/from-log happy path ──────────────────────────────────────
def test_from_log_happy_path(client):
"""Create 2 foods, log them to today, from-log with both entry_ids → 201,
meal food created, components with right quantities, original 2 entries
deleted, 1 replacement entry (quantity=1.0)."""
date = "2025-07-25"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
entry_ids = [e1["id"], e2["id"]]
resp = _from_log(client, "Lunch Meal", date, entry_ids)
assert resp.status_code == 201, resp.text
data = resp.json()
# Response shape
assert "meal" in data
assert "entry" in data
meal = data["meal"]
assert meal["is_meal"] is True
assert meal["source"] == "meal"
assert meal["name"] == "Lunch Meal"
assert meal["calories_per_unit"] is None
assert meal["protein_per_unit"] is None
entry = data["entry"]
assert entry["quantity"] == 1.0
assert entry["date"] == date
assert entry["food_id"] == meal["id"]
# Original entries are gone
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == entry["id"]
# GET the meal: should have components
meal_resp = client.get(f"/api/foods/{meal['id']}")
assert meal_resp.status_code == 200
meal_data = meal_resp.json()
assert len(meal_data["components"]) == 2
comp_food_ids = {c["food_id"] for c in meal_data["components"]}
assert comp_food_ids == {f1["id"], f2["id"]}
# Check component quantities match original log entries
for c in meal_data["components"]:
if c["food_id"] == f1["id"]:
assert c["quantity"] == 200.0
elif c["food_id"] == f2["id"]:
assert c["quantity"] == 150.0
# Summary matches sum of originals
summary = _get_summary(client, date)
# 130 × 200/100 = 260, 165 × 150/100 = 247.5 → 507.5
assert summary["totals"]["calories"] == 507.5
def test_from_log_meal_components_nested_in_log(client):
"""GET /api/log response includes meal components nested for rendering
collapsible rows (§3.3)."""
date = "2025-07-26"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch Meal", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
# GET /api/log should show the meal with nested components
entries = _get_log(client, date)
assert len(entries) == 1
food = entries[0]["food"]
assert food["is_meal"] is True
assert food["components"] is not None
assert len(food["components"]) == 2
# ── POST /api/meals/from-log rollback on failure ────────────────────────────
def test_from_log_rollback_on_bad_entry(client):
"""One entry_id doesn't exist → 404, nothing was written."""
date = "2025-07-27"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
# Try from-log with a non-existent entry_id
resp = _from_log(client, "Bad Meal", date, [e1["id"], 99999])
assert resp.status_code == 404, resp.text
# Original entries still exist
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == e1["id"]
# No meal food was created (search for meals with source="meal")
foods_resp = client.get("/api/foods", params={"limit": 200})
meal_foods = [f for f in foods_resp.json() if f["source"] == "meal" and f["name"] == "Bad Meal"]
assert len(meal_foods) == 0
def test_from_log_rollback_on_wrong_date(client):
"""Entry from a different date → 400, nothing was written."""
date_a = "2025-07-28"
date_b = "2025-07-29"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date_a)
# Try from-log requesting date_b but entry is on date_a
resp = _from_log(client, "Wrong Date Meal", date_b, [e1["id"]])
assert resp.status_code == 400, resp.text
# Entry still exists on date_a
log_entries = _get_log(client, date_a)
assert len(log_entries) == 1
assert log_entries[0]["id"] == e1["id"]
# ── POST /api/meals/{meal_id}/unpack happy path ─────────────────────────────
def test_unpack_happy_path(client):
"""Log a meal at 1.0×, unpack → meal entry deleted, component entries
inserted with component.quantity × 1.0. Summary unchanged."""
date = "2025-07-30"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
# Create meal
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Summary before unpack
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 507.5 # 260 + 247.5
# Unpack
unpack_resp = _unpack_meal(client, meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
unpack_data = unpack_resp.json()
entries = unpack_data["entries"]
assert len(entries) == 2
# Meal entry is gone
log_entries = _get_log(client, date)
log_ids = {e["id"] for e in log_entries}
assert meal_entry_id not in log_ids
assert len(log_entries) == 2
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 507.5
def test_unpack_with_scaling_1_5x(client):
"""Log a meal at 1.5×, unpack → component entries have quantity × 1.5."""
date = "2025-07-31"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Update meal entry to 1.5×
client.put(f"/api/log/{meal_entry_id}", json={"quantity": 1.5})
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 761.25 # 507.5 × 1.5
# Unpack
unpack_resp = _unpack_meal(client, meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
unpack_data = unpack_resp.json()
# Each component should be scaled: 200*1.5=300, 150*1.5=225
entries = unpack_data["entries"]
qty_by_food = {}
for e in entries:
qty_by_food[e["food_id"]] = e["quantity"]
assert qty_by_food.get(f1["id"]) == 300.0
assert qty_by_food.get(f2["id"]) == 225.0
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 761.25
def test_unpack_nested_meal_flattens_to_leaves(client):
"""Unpacking a meal containing a nested meal → flattens to leaf foods
with scaling factors multiplied down the chain.
1.5× outer containing a 2-component inner meal:
Inner: 100g rice (130 kcal/100g = 130) + 1 egg (70 kcal = 70) = 200 kcal
Outer: inner at 0.5 (half portion) + apple 100g (52 kcal/100g = 52) = 100 + 52 = 152
Log at 1.5× → 228 kcal. Unpack → 4 leaf entries each ×1.5×nested-scaling.
"""
date = "2025-08-01"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
# Step 1: Log rice + egg, create inner meal
_log_entry(client, rice["id"], quantity=100.0, date=date)
e_egg = _log_entry(client, egg["id"], quantity=1.0, date=date)
inner_resp = _from_log(client, "Inner", date, _get_entry_ids(client, date))
assert inner_resp.status_code == 201
inner_meal_id = inner_resp.json()["meal"]["id"]
inner_entry_id = inner_resp.json()["entry"]["id"]
# Step 2: Update inner entry to 0.5× (half portion)
client.put(f"/api/log/{inner_entry_id}", json={"quantity": 0.5})
# Step 3: Also log apple (100g = 52 kcal)
e_apple = _log_entry(client, apple["id"], quantity=100.0, date=date)
# Step 4: Create outer meal from inner meal entry (0.5×) + apple entry
outer_resp = _from_log(client, "Outer", date, [inner_entry_id, e_apple["id"]])
assert outer_resp.status_code == 201, outer_resp.text
outer_meal_id = outer_resp.json()["meal"]["id"]
outer_entry_id = outer_resp.json()["entry"]["id"]
# Step 5: Update outer entry to 1.5×
client.put(f"/api/log/{outer_entry_id}", json={"quantity": 1.5})
# Summary before unpack:
# Outer at 1.5×: inner component (0.5 portion of inner meal):
# inner meal per 1.0 = 100g rice (130) + 1 egg (70) = 200 kcal
# inner at 0.5 portion = 100 kcal
# apple at 100g = 52 kcal
# Outer per 1.0 = 100 + 52 = 152 kcal
# Outer at 1.5× = 228 kcal
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 228.0
# Step 6: Unpack → should flatten to 4 leaf entries
unpack_resp = _unpack_meal(client, outer_meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
entries = unpack_resp.json()["entries"]
# Expected leaf foods:
# - rice: 100g * 0.5 (inner portion) * 1.5 (outer scaling) = 75g → 97.5 kcal
# - egg: 1.0 * 0.5 * 1.5 = 0.75 → 52.5 kcal
# - apple: 100g * 1.5 = 150g → 78 kcal
# Total = 97.5 + 52.5 + 78 = 228
assert len(entries) == 3 # rice, egg, apple (all leaf foods)
leaf_qtys = {}
for e in entries:
fid = e["food_id"]
leaf_qtys[fid] = leaf_qtys.get(fid, 0.0) + e["quantity"]
# rice: 100 * 0.5 * 1.5 = 75
assert leaf_qtys.get(rice["id"]) == 75.0
# egg: 1 * 0.5 * 1.5 = 0.75
assert leaf_qtys.get(egg["id"]) == 0.75
# apple: 100 * 1.5 = 150
assert leaf_qtys.get(apple["id"]) == 150.0
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 228.0
# ── POST /api/meals/{meal_id}/unpack rollback on failure ────────────────────
def test_unpack_rollback_on_failure(client):
"""Force a failure mid-transaction by trying to unpack with a bad entry_id
→ nothing written, meal entry intact."""
date = "2025-08-02"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Try unpack with a non-existent entry_id
bad_resp = _unpack_meal(client, meal_id, date, entry_id=99999)
assert bad_resp.status_code == 404
# Meal entry still exists
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == meal_entry_id
def test_unpack_ambiguous_multiple_entries(client):
"""When multiple log entries reference the same meal on the same date,
unpack without entry_id returns 400."""
date = "2025-08-03"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
# Log the meal again on the same date
_log_entry(client, meal_id, quantity=1.0, date=date)
# Ambiguous unpack
bad_resp = _unpack_meal(client, meal_id, date)
assert bad_resp.status_code == 400, bad_resp.text
assert "multiple" in bad_resp.json()["detail"].lower() or "ambiguous" in bad_resp.json()["detail"].lower()
# But unpack with explicit entry_id works
entries = _get_log(client, date)
for e in entries:
if e["food_id"] == meal_id:
ok_resp = _unpack_meal(client, meal_id, date, entry_id=e["id"])
assert ok_resp.status_code == 200
break
# ── PUT /api/meals/{meal_id}/components cycle rejection ─────────────────────
def test_components_cycle_rejection_transitive(client):
"""Build meal A, meal B; try to set B's components to include A, then
A's components to include B → 422 MealCycleError, graph unchanged."""
date = "2025-08-04"
# Create base foods + two meals
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
# Create meal A with rice
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp_a = _from_log(client, "Meal A", date, _get_entry_ids(client, date))
meal_a_id = resp_a.json()["meal"]["id"]
# Create meal B with chicken
_log_entry(client, chicken["id"], quantity=200.0, date="2025-08-05")
resp_b = _from_log(client, "Meal B", "2025-08-05", _get_entry_ids(client, "2025-08-05"))
meal_b_id = resp_b.json()["meal"]["id"]
# Set meal A's components to include meal B
resp = _update_components(client, meal_a_id, [
{"food_id": meal_b_id, "quantity": 1.0},
{"food_id": rice["id"], "quantity": 100.0},
])
assert resp.status_code == 200, resp.text
# Now try to set meal B's components to include meal A → cycle!
resp = _update_components(client, meal_b_id, [
{"food_id": meal_a_id, "quantity": 1.0},
{"food_id": chicken["id"], "quantity": 200.0},
])
assert resp.status_code == 422, resp.text
# Meal B's components unchanged (still just chicken)
meal_b = client.get(f"/api/foods/{meal_b_id}").json()
b_food_ids = {c["food_id"] for c in meal_b["components"]}
assert b_food_ids == {chicken["id"]}
def test_components_direct_self_reference(client):
"""A meal trying to include itself → 422."""
date = "2025-08-06"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp = _from_log(client, "Self Meal", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
# Try to make meal include itself
resp = _update_components(client, meal_id, [
{"food_id": rice["id"], "quantity": 100.0},
{"food_id": meal_id, "quantity": 1.0},
])
assert resp.status_code == 422, resp.text
def test_components_valid_non_cyclic_replacement(client):
"""Valid non-cyclic replacement → 200, components replaced."""
date = "2025-08-07"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
# Create meal with rice + chicken
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
# Verify initial components
meal = client.get(f"/api/foods/{meal_id}").json()
initial_food_ids = {c["food_id"] for c in meal["components"]}
assert initial_food_ids == {rice["id"], chicken["id"]}
# Replace components with chicken + egg
resp = _update_components(client, meal_id, [
{"food_id": chicken["id"], "quantity": 150.0},
{"food_id": egg["id"], "quantity": 2.0},
])
assert resp.status_code == 200, resp.text
updated_meal = resp.json()
new_food_ids = {c["food_id"] for c in updated_meal["components"]}
assert new_food_ids == {chicken["id"], egg["id"]}
assert updated_meal["is_meal"] is True
def test_components_meal_not_found(client):
"""PUT components on non-existent meal → 404."""
resp = _update_components(client, 99999, [
{"food_id": 1, "quantity": 1.0},
])
assert resp.status_code == 404
def test_components_food_not_found(client):
"""PUT components referencing non-existent food → 404."""
date = "2025-08-08"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
resp = _update_components(client, meal_id, [
{"food_id": 99999, "quantity": 1.0},
])
assert resp.status_code == 404
# ── Summary: meals contribute real nutrition ────────────────────────────────
def test_summary_meal_contributes_real_nutrition(client):
"""Summary after from-log should show real derived nutrition (not 0)."""
date = "2025-08-09"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight",
protein_per_unit=2.7, carbs_per_unit=28.0, fat_per_unit=0.3)
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight",
protein_per_unit=31.0, carbs_per_unit=0.0, fat_per_unit=3.6)
_log_entry(client, rice["id"], quantity=200.0, date=date)
_log_entry(client, chicken["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
assert resp.status_code == 201
summary = _get_summary(client, date)
# rice: 130*200/100=260 kcal, 2.7*200/100=5.4g protein, 28*200/100=56g carbs, 0.3*200/100=0.6g fat
# chicken: 165*150/100=247.5 kcal, 31*150/100=46.5g protein, 0 carbs, 3.6*150/100=5.4g fat
# total: 507.5 kcal, 51.9g protein, 56g carbs, 6.0g fat
assert abs(summary["totals"]["calories"] - 507.5) < 0.01
assert abs(summary["totals"]["protein_g"] - 51.9) < 0.01
assert abs(summary["totals"]["carbs_g"] - 56.0) < 0.01
assert abs(summary["totals"]["fat_g"] - 6.0) < 0.01
# ── GET /api/foods/{id} for meals ───────────────────────────────────────────
def test_get_food_for_meal_returns_meal_read(client):
"""GET /api/foods/{id} for a meal returns components + computed nutrition."""
date = "2025-08-10"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
meal = client.get(f"/api/foods/{meal_id}").json()
assert meal["is_meal"] is True
assert "components" in meal
assert len(meal["components"]) == 2
assert "computed_nutrition_per_meal" in meal
# rice 100g (130) + chicken 200g (330) = 460
assert abs(meal["computed_nutrition_per_meal"]["calories"] - 460.0) < 0.01
def test_get_food_for_non_meal_returns_food_read(client):
"""GET /api/foods/{id} for a non-meal returns plain FoodRead (no components)."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
food = client.get(f"/api/foods/{rice['id']}").json()
assert food["is_meal"] is False
# Plain FoodRead shouldn't have components or computed_nutrition_per_meal
# (they might be present as empty/default — that's fine; key is it works)
assert food["name"] == "Rice"
# ── Helper: get all entry IDs for a date ─────────────────────────────────────
def _get_entry_ids(client, date: str) -> list[int]:
entries = client.get("/api/log", params={"date": date}).json()
return [e["id"] for e in entries]
+535
View File
@@ -0,0 +1,535 @@
"""OFF proxy tests — mock at the httpx boundary (spec §8.4).
Never hits the real OpenFoodFacts API.
Uses httpx.MockTransport (built into httpx, no extra dep).
"""
import json
import httpx
import pytest
from services.off import (
fetch_product,
normalize_off_product,
search_off,
)
# ── Reusable OFF product fixtures ────────────────────────────────────────────
def _make_off_product(**overrides):
"""Build a minimal but realistic OFF v2 product dict.
Default: Nutella-like product with kcal, protein, carbs, fat, and brand.
"""
p = {
"code": "3017620422003",
"product_name": "Nutella",
"generic_name": "Pâte à tartiner aux noisettes et au cacao",
"brands": "Nutella, Ferrero, Yum yum",
"serving_quantity": None,
"serving_size": None,
"nutriments": {
"energy-kcal_100g": 539,
"energy-kj_100g": 2252,
"proteins_100g": 6.3,
"carbohydrates_100g": 57.5,
"fat_100g": 30.9,
"fiber_100g": None, # not present in real Nutella
"saturated-fat_100g": 10.6,
"sugars_100g": 56.3,
"sodium_100g": 0.0428,
},
}
p.update(overrides)
return p
def _make_off_response(status=1, product=None):
"""Build an OFF v2 API response envelope."""
return {"status": status, "code": (product or {}).get("code", ""), "product": product}
def _mock_client(handler):
"""Create an httpx.Client backed by MockTransport with the given handler.
handler: callable(request) -> httpx.Response
"""
return httpx.Client(transport=httpx.MockTransport(handler))
# ── Normalizer unit tests ────────────────────────────────────────────────────
class TestNormalizeOffProduct:
"""Normalizer tests on hand-constructed OFF payloads (§8.4)."""
def test_kcal_present(self):
"""When energy-kcal_100g is present, use it directly."""
prod = _make_off_product()
result = normalize_off_product(prod)
assert result is not None
assert result["name"] == "Nutella"
assert result["calories_per_unit"] == 539
assert result["source"] == "openfoodfacts"
assert result["unit_type"] == "weight"
assert result["is_meal"] is False
def test_kj_fallback(self):
"""When kcal is absent, convert kJ → kcal (1 kcal = 4.184 kJ)."""
prod = _make_off_product()
del prod["nutriments"]["energy-kcal_100g"]
# 2252 kJ / 4.184 ≈ 538.3 → rounded to 1dp = 538.2
result = normalize_off_product(prod)
assert result is not None
assert result["calories_per_unit"] == round(2252 / 4.184, 1)
def test_kj_fallback_value(self):
"""Precise check: 1000 kJ → 239.0 kcal."""
prod = _make_off_product()
prod["nutriments"] = {"energy-kj_100g": 1000}
result = normalize_off_product(prod)
assert result is not None
assert result["calories_per_unit"] == round(1000 / 4.184, 1)
def test_missing_nutriments_become_null(self):
"""Absent nutriment keys → None (not crash)."""
prod = _make_off_product()
prod["nutriments"] = {"energy-kcal_100g": 100}
result = normalize_off_product(prod)
assert result is not None
assert result["protein_per_unit"] is None
assert result["carbs_per_unit"] is None
assert result["fat_per_unit"] is None
assert result["fiber_per_unit"] is None
assert result["saturated_fat_per_unit"] is None
assert result["sugars_per_unit"] is None
assert result["sodium_per_unit"] is None
def test_empty_nutriments(self):
"""Empty dict nutriments → all None."""
prod = _make_off_product()
prod["nutriments"] = {}
result = normalize_off_product(prod)
assert result is not None
assert result["calories_per_unit"] is None
def test_no_name_no_calories(self):
"""No usable name AND no calories → None (not-found)."""
prod = _make_off_product()
prod["product_name"] = ""
prod["generic_name"] = ""
prod["nutriments"] = {}
result = normalize_off_product(prod)
assert result is None
def test_no_name_but_has_calories(self):
"""No name but has calories → still usable."""
prod = _make_off_product()
prod["product_name"] = ""
prod["generic_name"] = ""
# kcal present
result = normalize_off_product(prod)
assert result is not None
assert result["name"] == ""
def test_has_name_no_calories(self):
"""Has name but no calories → still usable."""
prod = _make_off_product()
prod["nutriments"] = {}
result = normalize_off_product(prod)
assert result is not None
assert result["name"] == "Nutella"
assert result["calories_per_unit"] is None
def test_brand_first_entry(self):
"""Comma-separated brands → first trimmed entry."""
prod = _make_off_product()
prod["brands"] = " Nutella , Ferrero , Yum yum "
result = normalize_off_product(prod)
assert result["brand"] == "Nutella"
def test_brand_single(self):
"""Single brand, no commas."""
prod = _make_off_product()
prod["brands"] = "Nestlé"
result = normalize_off_product(prod)
assert result["brand"] == "Nestlé"
def test_brand_empty(self):
"""Empty brands string → None."""
prod = _make_off_product()
prod["brands"] = ""
result = normalize_off_product(prod)
assert result["brand"] is None
def test_brand_none(self):
"""Missing brands key → None."""
prod = _make_off_product()
del prod["brands"]
result = normalize_off_product(prod)
assert result["brand"] is None
def test_generic_name_fallback(self):
"""When product_name is empty, fall back to generic_name."""
prod = _make_off_product()
prod["product_name"] = ""
prod["generic_name"] = "Hazelnut cocoa spread"
result = normalize_off_product(prod)
assert result["name"] == "Hazelnut cocoa spread"
def test_serving_fields(self):
"""serving_quantity → serving_size_g; serving_size → serving_name."""
prod = _make_off_product()
prod["serving_quantity"] = 15
prod["serving_size"] = "1 tbsp (15g)"
result = normalize_off_product(prod)
assert result["serving_size_g"] == 15.0
assert result["serving_name"] == "1 tbsp (15g)"
def test_serving_quantity_string(self):
"""serving_quantity as string → parsed to float."""
prod = _make_off_product()
prod["serving_quantity"] = "30"
result = normalize_off_product(prod)
assert result["serving_size_g"] == 30.0
def test_serving_quantity_invalid(self):
"""Non-numeric serving_quantity → None."""
prod = _make_off_product()
prod["serving_quantity"] = "abc"
result = normalize_off_product(prod)
assert result["serving_size_g"] is None
def test_off_data_included(self):
"""Raw product JSON is stored in off_data."""
prod = _make_off_product()
result = normalize_off_product(prod)
assert result is not None
assert "off_data" in result
parsed = json.loads(result["off_data"])
assert parsed["code"] == "3017620422003"
assert parsed["product_name"] == "Nutella"
def test_barcode_from_code_field(self):
prod = _make_off_product(code="1234567890123")
# _make_off_product puts code in the product dict directly
# but the OFF envelope has code at top level too.
# normalize_off_product reads product["code"]
result = normalize_off_product(prod)
assert result is not None
assert result["barcode"] == "1234567890123"
# ── fetch_product tests ──────────────────────────────────────────────────────
class TestFetchProduct:
"""GET /api/off/product/{barcode} — mock at httpx boundary."""
def test_found(self):
"""Status=1 with product → normalized dict."""
prod = _make_off_product()
def handler(request):
return httpx.Response(200, json=_make_off_response(1, prod))
client = _mock_client(handler)
result = fetch_product("3017620422003", client=client)
assert result is not None
assert result["name"] == "Nutella"
assert result["calories_per_unit"] == 539
assert result["source"] == "openfoodfacts"
def test_not_found_status_zero(self):
"""Status=0 → None."""
def handler(request):
return httpx.Response(200, json={"status": 0, "code": "x", "product": None})
result = fetch_product("000", client=_mock_client(handler))
assert result is None
def test_not_found_no_product(self):
"""Status=1 but no product key → None."""
def handler(request):
return httpx.Response(200, json={"status": 1, "code": "x"})
result = fetch_product("000", client=_mock_client(handler))
assert result is None
def test_unusable_product(self):
"""Product with no name and no calories → None."""
prod = _make_off_product()
prod["product_name"] = ""
prod["generic_name"] = ""
prod["nutriments"] = {}
def handler(request):
return httpx.Response(200, json=_make_off_response(1, prod))
result = fetch_product("x", client=_mock_client(handler))
assert result is None
def test_upstream_503_returns_none(self):
"""Upstream HTTP 503 → None (graceful, no crash)."""
def handler(request):
return httpx.Response(503, html="<html>Service Unavailable</html>")
result = fetch_product("x", client=_mock_client(handler))
assert result is None
# ── search_off tests ─────────────────────────────────────────────────────────
class TestSearchOff:
"""GET /api/off/search?q= — mock at httpx boundary."""
def test_returns_normalized_list(self):
"""Search hits → list of normalized dicts."""
def handler(request):
return httpx.Response(200, json={
"count": 2,
"products": [
_make_off_product(code="1", product_name="Alpha"),
_make_off_product(code="2", product_name="Beta"),
],
})
result = search_off("test", client=_mock_client(handler))
assert len(result) == 2
assert result[0]["name"] == "Alpha"
assert result[1]["name"] == "Beta"
def test_empty_results(self):
"""No products → empty list."""
def handler(request):
return httpx.Response(200, json={"count": 0, "products": []})
result = search_off("nothing", client=_mock_client(handler))
assert result == []
def test_filters_unusable(self):
"""Unusable products (no name + no calories) are filtered out."""
usable = _make_off_product(code="1", product_name="Good")
unusable = _make_off_product(code="2")
unusable["product_name"] = ""
unusable["generic_name"] = ""
unusable["nutriments"] = {}
def handler(request):
return httpx.Response(200, json={
"count": 2,
"products": [unusable, usable],
})
result = search_off("test", client=_mock_client(handler))
assert len(result) == 1
assert result[0]["name"] == "Good"
def test_upstream_503_returns_empty(self):
"""Upstream HTTP 503 → graceful empty list, not a crash."""
def handler(request):
return httpx.Response(503, html="<html>Service Unavailable</html>")
result = search_off("test", client=_mock_client(handler))
assert result == []
def test_connect_error_returns_empty(self):
"""Transport failure (ConnectError) → graceful empty list."""
def handler(request):
raise httpx.ConnectError("connection refused")
result = search_off("test", client=_mock_client(handler))
assert result == []
# ── Integration-style tests via TestClient ────────────────────────────────────
# These test the full router → service path with mocked httpx at the boundary.
# The off_mock fixture (defined in conftest.py) patches _default_client().
class TestOffProductEndpoint:
"""GET /api/off/product/{barcode}"""
def test_found(self, client, off_mock):
prod = _make_off_product()
def handler(request):
return httpx.Response(200, json=_make_off_response(1, prod))
off_mock.handler = handler
resp = client.get("/api/off/product/3017620422003")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Nutella"
assert data["calories_per_unit"] == 539
assert data["source"] == "openfoodfacts"
def test_not_found(self, client, off_mock):
def handler(request):
return httpx.Response(200, json={"status": 0, "code": "x", "product": None})
off_mock.handler = handler
resp = client.get("/api/off/product/0000000000000")
assert resp.status_code == 404
def test_returns_normalized_not_raw(self, client, off_mock):
"""Response is normalized (FoodCreate shape), not raw OFF JSON."""
prod = _make_off_product()
def handler(request):
return httpx.Response(200, json=_make_off_response(1, prod))
off_mock.handler = handler
resp = client.get("/api/off/product/3017620422003")
assert resp.status_code == 200
data = resp.json()
# These are our normalized keys — not raw OFF field names
assert "calories_per_unit" in data
assert "protein_per_unit" in data
assert "serving_size_g" in data
assert "off_data" in data
# Raw OFF keys should NOT be at top level
assert "nutriments" not in data
assert "product_name" not in data
def test_upstream_503_returns_404(self, client, off_mock):
"""Upstream 503 → 404 (graceful, not 500)."""
def handler(request):
return httpx.Response(503, html="<html>Service Unavailable</html>")
off_mock.handler = handler
resp = client.get("/api/off/product/3017620422003")
assert resp.status_code == 404
class TestOffSearchEndpoint:
"""GET /api/off/search?q="""
def test_returns_list(self, client, off_mock):
def handler(request):
return httpx.Response(200, json={
"count": 2,
"products": [
_make_off_product(code="1", product_name="Alpha"),
_make_off_product(code="2", product_name="Beta"),
],
})
off_mock.handler = handler
resp = client.get("/api/off/search?q=test")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["name"] == "Alpha"
def test_missing_q(self, client):
"""q query param is required."""
resp = client.get("/api/off/search")
assert resp.status_code == 422
def test_empty_q(self, client):
"""Empty q → 422."""
resp = client.get("/api/off/search?q=")
assert resp.status_code == 422
def test_empty_results(self, client, off_mock):
def handler(request):
return httpx.Response(200, json={"count": 0, "products": []})
off_mock.handler = handler
resp = client.get("/api/off/search?q=nothing")
assert resp.status_code == 200
assert resp.json() == []
def test_upstream_503_returns_empty_list(self, client, off_mock):
"""Upstream 503 → HTTP 200 with [] (not 500)."""
def handler(request):
return httpx.Response(503, html="<html>Service Unavailable</html>")
off_mock.handler = handler
resp = client.get("/api/off/search?q=zzzznonexistentfood12345")
assert resp.status_code == 200
assert resp.json() == []
def test_connect_error_returns_empty_list(self, client, off_mock):
"""Transport failure → HTTP 200 with [] (not 500)."""
def handler(request):
raise httpx.ConnectError("connection refused")
off_mock.handler = handler
resp = client.get("/api/off/search?q=anything")
assert resp.status_code == 200
assert resp.json() == []
class TestOffRefreshEndpoint:
"""POST /api/off/refresh/{food_id}"""
def test_updates_food(self, client, off_mock):
"""Refresh re-fetches from OFF and updates the local row."""
# Create a food with a barcode
resp = client.post("/api/foods", json={
"name": "Old Name",
"barcode": "refresh-test",
"calories_per_unit": 100,
"source": "openfoodfacts",
"unit_type": "weight",
})
assert resp.status_code == 201
fid = resp.json()["id"]
# Mock OFF to return different data
updated_prod = _make_off_product(
code="refresh-test",
product_name="New Name",
)
updated_prod["nutriments"]["energy-kcal_100g"] = 200
def handler(request):
return httpx.Response(200, json=_make_off_response(1, updated_prod))
off_mock.handler = handler
resp = client.post(f"/api/off/refresh/{fid}")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "New Name"
assert data["calories_per_unit"] == 200
assert data["id"] == fid
def test_unknown_food_id(self, client):
"""404 for unknown food_id."""
resp = client.post("/api/off/refresh/99999")
assert resp.status_code == 404
def test_no_barcode(self, client):
"""400 when the food has no barcode."""
resp = client.post("/api/foods", json={
"name": "No Barcode Food",
"calories_per_unit": 100,
})
fid = resp.json()["id"]
resp = client.post(f"/api/off/refresh/{fid}")
assert resp.status_code == 400
assert "barcode" in resp.json()["detail"].lower()
def test_off_no_longer_has_product(self, client, off_mock):
"""404 when OFF returns status=0 for the barcode."""
resp = client.post("/api/foods", json={
"name": "Will Disappear",
"barcode": "will-be-gone",
"calories_per_unit": 100,
})
fid = resp.json()["id"]
def handler(request):
return httpx.Response(200, json={"status": 0, "code": "will-be-gone", "product": None})
off_mock.handler = handler
resp = client.post(f"/api/off/refresh/{fid}")
assert resp.status_code == 404
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
echo "=== Starting CalCount dev servers ==="
# Kill background processes on exit
cleanup() {
echo ""
echo "Shutting down..."
kill "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true
echo "Done."
}
trap cleanup EXIT SIGINT SIGTERM
# Start backend (FastAPI via uvicorn)
echo "[backend] uvicorn main:app --reload --host 0.0.0.0"
cd "$BACKEND_DIR"
uv run uvicorn main:app --reload --host 0.0.0.0 &
BACKEND_PID=$!
# Start frontend (Vite dev server with --host)
echo "[frontend] vite --host"
cd "$FRONTEND_DIR"
npm run dev:host &
FRONTEND_PID=$!
echo ""
echo " Backend: http://localhost:8000"
echo " Frontend: http://localhost:5173"
echo " Press Ctrl+C to stop both."
echo ""
# Wait for either process to exit
wait
+14
View File
@@ -12,6 +12,7 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"svelte": "^5.56.4", "svelte": "^5.56.4",
"vite": "^7.3.6", "vite": "^7.3.6",
"vitest": "^4.1.10" "vitest": "^4.1.10"
@@ -953,6 +954,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+2
View File
@@ -5,12 +5,14 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:host": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run" "test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"svelte": "^5.56.4", "svelte": "^5.56.4",
"vite": "^7.3.6", "vite": "^7.3.6",
"vitest": "^4.1.10" "vitest": "^4.1.10"
+323 -17
View File
@@ -1,15 +1,21 @@
<script> <script>
// App.svelte — Root component with conditional view switching (no router library). // App.svelte — Root component with conditional view switching (no router library).
// Date navigation: previous/next day buttons + current date display. // Date navigation: previous/next day button + current date display.
// Scan flow per §4.1: local DB check → confirm+log OR OFF lookup → FoodEditor → log.
// Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4). // Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4).
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from './lib/api.js' import { api } from './lib/api.js'
import { currentDate, appView, goPrevDay, goNextDay, setDate } from './lib/stores.svelte.js' import {
import { formatDate } from './lib/format.js' currentDate, appView, goPrevDay, goNextDay, setDate,
navigateTo, addLogEntryToStore, refreshDayData, mealEdit
} from './lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity, previewCalories } from './lib/format.js'
import Dashboard from './components/Dashboard.svelte' import Dashboard from './components/Dashboard.svelte'
import FoodSearch from './components/FoodSearch.svelte' import FoodSearch from './components/FoodSearch.svelte'
import FoodEditor from './components/FoodEditor.svelte' import FoodEditor from './components/FoodEditor.svelte'
import FoodLibrary from './components/FoodLibrary.svelte'
import BarcodeScanner from './components/BarcodeScanner.svelte'
// Health check — every async view handles loading/error states (spec §8.2 rule 4) // Health check — every async view handles loading/error states (spec §8.2 rule 4)
let backend = $state({ loading: true, ok: false, error: null }) let backend = $state({ loading: true, ok: false, error: null })
@@ -67,6 +73,139 @@
} }
} }
// ── Scan flow state (§4.1) ─────────────────────────────────────────────────
// Phases: null (not scanning), 'localFound', 'localConfirm', 'offFound', 'notFound', 'error'
let scanPhase = $state(null)
let scanBarcode = $state('')
let scanLoading = $state(false)
let scanError = $state(null)
// Data from API
let localFood = $state(null) // FoodRead if found locally
let offFood = $state(null) // Normalized OFF food dict
// Logging state
let scanLogQuantity = $state(1)
let scanLogMealSlot = $state('')
let scanLogging = $state(false)
let scanLogError = $state(null)
let scanLiveKcal = $derived(localFood ? Math.round(previewCalories(localFood, scanLogQuantity)) : 0)
let refreshing = $state(false)
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
function resetScanFlow() {
scanPhase = null
scanBarcode = ''
scanLoading = false
scanError = null
localFood = null
offFood = null
scanLogQuantity = 1
scanLogMealSlot = ''
scanLogging = false
scanLogError = null
refreshing = false
}
async function handleBarcode(barcode) {
console.log('[App] handleBarcode:', barcode)
resetScanFlow()
scanBarcode = barcode
scanLoading = true
// Step 1: Check local DB by barcode
try {
console.log('[App] searching local DB for barcode:', barcode)
const results = await api.searchFoodsByBarcode(barcode)
console.log('[App] local search results:', results?.length ?? 0)
if (results && results.length > 0) {
localFood = results[0]
scanLogQuantity = defaultQuantity(localFood)
scanLoading = false
scanPhase = 'localFound'
console.log('[App] local food found:', localFood.name)
return
}
console.log('[App] not found locally, trying OpenFoodFacts…')
} catch (e) {
console.warn('[App] local search failed:', e)
// Local lookup failed — try OFF anyway
}
// Step 2: Not found locally → try OFF
try {
console.log('[App] fetching OFF product:', barcode)
offFood = await api.offProduct(barcode)
scanLoading = false
scanPhase = 'offFound'
console.log('[App] OFF product found:', offFood?.product_name ?? offFood?.name)
} catch (e) {
console.warn('[App] OFF lookup failed:', e.message)
// OFF miss (404) or network error
scanLoading = false
if (e.message?.includes('404') || e.message?.includes('not found')) {
scanPhase = 'notFound'
console.log('[App] OFF product not found (404)')
} else {
scanError = e.message
scanPhase = 'error'
console.error('[App] OFF lookup error:', e.message)
}
}
}
async function confirmLocalLog() {
if (!localFood || scanLogQuantity <= 0) return
scanLogging = true
scanLogError = null
try {
const entry = await api.addLogEntry({
food_id: localFood.id,
quantity: parseFloat(scanLogQuantity),
meal_slot: scanLogMealSlot || null,
date: currentDate.value,
})
await addLogEntryToStore(entry)
resetScanFlow()
appView.current = 'dashboard'
} catch (e) {
scanLogError = e.message
} finally {
scanLogging = false
}
}
async function refreshFromOff() {
if (!localFood) return
refreshing = true
try {
const updated = await api.offRefresh(localFood.id)
localFood = updated
scanLogQuantity = defaultQuantity(updated)
} catch (e) {
scanError = e.message
} finally {
refreshing = false
}
}
function handleOffSaved(savedFood) {
// FoodEditor called onSaved after creating food from OFF data
// Now offer to log it
localFood = savedFood
offFood = null
scanLogQuantity = defaultQuantity(savedFood)
scanPhase = 'localFound'
}
function backToScan() {
resetScanFlow()
}
function backToDashboard() {
resetScanFlow()
appView.current = 'dashboard'
}
// ── Date navigation ─────────────────────────────────────────────────────── // ── Date navigation ───────────────────────────────────────────────────────
function handleDateInput(e) { function handleDateInput(e) {
setDate(e.target.value) setDate(e.target.value)
@@ -103,17 +242,122 @@
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}> <button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
+ Add Food + Add Food
</button> </button>
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
📷 Scan
</button>
<button type="button" class="target-btn" onclick={() => appView.current = 'foods'}>
🍔 Foods
</button>
<button type="button" class="target-btn" onclick={openTargetForm}> <button type="button" class="target-btn" onclick={openTargetForm}>
🎯 Target 🎯 Target
</button> </button>
</div> </div>
{:else if appView.current === 'scan'}
<!-- Scan view — BarcodeScanner → scan flow -->
{#if !scanPhase}
<button type="button" class="back-btn" onclick={backToDashboard}> Dashboard</button>
<BarcodeScanner onBarcode={handleBarcode} />
{:else if scanLoading}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<p class="status">Looking up barcode {scanBarcode}</p>
</div>
{:else if scanPhase === 'localFound'}
<!-- Local food found → confirm and log -->
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Found: {localFood.name}</h3>
{#if localFood.brand}<p class="scan-brand">{localFood.brand}</p>{/if}
<p class="scan-kcal">{formatKcal(localFood.calories_per_unit)}/{localFood.unit_type === 'count' ? 'item' : '100g'}</p>
<div class="log-form">
<label>
Quantity
<input
type="number"
step="any"
min="0.1"
bind:value={scanLogQuantity}
class="qty-input"
/>
{localFood.unit_type === 'count' ? 'items' : 'g'}
</label>
<p class="preview-kcal">= {formatKcal(scanLiveKcal)}</p>
<label>
Meal slot
<select bind:value={scanLogMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
{#if scanLogError}<p class="err" role="alert">{scanLogError}</p>{/if}
{#if scanError}<p class="err" role="alert">{scanError}</p>{/if}
<div class="log-actions">
<button type="button" onclick={confirmLocalLog} disabled={scanLogging || scanLogQuantity <= 0}>
{scanLogging ? 'Logging…' : 'Log it'}
</button>
</div>
{#if localFood.source === 'openfoodfacts' || localFood.barcode}
<div class="refresh-section">
<p class="refresh-hint">Data from {localFood.source === 'openfoodfacts' ? 'OpenFoodFacts' : 'local'}. Refresh for latest?</p>
<button type="button" class="secondary" onclick={refreshFromOff} disabled={refreshing}>
{refreshing ? 'Refreshing…' : 'Refresh from OpenFoodFacts'}
</button>
</div>
{/if}
</div>
</div>
{:else if scanPhase === 'offFound'}
<!-- OFF product found → pre-fill FoodEditor -->
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Found on OpenFoodFacts</h3>
<FoodEditor food={offFood} onSaved={handleOffSaved} onCancel={backToScan} />
</div>
{:else if scanPhase === 'notFound'}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Not found</h3>
<p class="status">Barcode "{scanBarcode}" was not found locally or on OpenFoodFacts.</p>
<div class="not-found-actions">
<button type="button" onclick={() => { resetScanFlow(); appView.current = 'createFood' }}>
Create food manually
</button>
<button type="button" onclick={() => { resetScanFlow(); appView.current = 'addFood' }}>
Search foods
</button>
</div>
</div>
{:else if scanPhase === 'error'}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<p class="err" role="alert">Lookup error: {scanError}</p>
</div>
{/if}
{:else if appView.current === 'addFood'} {:else if appView.current === 'addFood'}
<FoodSearch /> <FoodSearch />
{:else if appView.current === 'createFood'} {:else if appView.current === 'createFood'}
<FoodEditor /> <FoodEditor />
{:else if appView.current === 'foods'}
<FoodLibrary />
{:else if appView.current === 'editMeal'}
<FoodEditor mealId={mealEdit.foodId} />
{:else if appView.current === 'targetForm'} {:else if appView.current === 'targetForm'}
<div class="target-form-view"> <div class="target-form-view">
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button> <button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button>
@@ -185,6 +429,17 @@
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); } .status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
.status.err { color: #dc2626; } .status.err { color: #dc2626; }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
}
/* ── Date navigation ─────────────────────────────────────────────── */ /* ── Date navigation ─────────────────────────────────────────────── */
.date-nav { .date-nav {
display: flex; display: flex;
@@ -235,23 +490,68 @@
border-color: var(--link, #2563eb); border-color: var(--link, #2563eb);
font-weight: 600; font-weight: 600;
} }
.scan-fab {
background: #7c3aed;
border-color: #7c3aed;
}
/* ── Scan flow cards ─────────────────────────────────────────────── */
.scan-status-card {
/* common wrapper for scan phases */
}
.scan-brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
.scan-kcal { font-weight: 600; font-size: 1rem; margin: 0.5rem 0; }
.log-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 0.75rem;
}
.log-form label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.preview-kcal {
font-weight: 600;
font-size: 1rem;
margin: 0;
}
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.refresh-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border, #e5e7eb);
}
.refresh-hint {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.4rem;
}
.not-found-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
h3 { margin: 0 0 0.5rem; font-size: 1.1rem; }
/* ── Target form ─────────────────────────────────────────────────── */ /* ── Target form ─────────────────────────────────────────────────── */
.target-form-view { .target-form-view {
/* mobile-first single column */ /* mobile-first single column */
} }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
}
h3 { margin: 0 0 1rem; font-size: 1.1rem; }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -283,8 +583,8 @@
padding: 0 0.3rem; padding: 0 0.3rem;
} }
.macro-grid { .macro-grid {
display: grid; display: flex;
grid-template-columns: 1fr 1fr 1fr; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.form-actions { .form-actions {
@@ -305,4 +605,10 @@
button.secondary { background: var(--bg-muted, #f3f4f6); } button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; } .err { color: #dc2626; font-size: 0.85rem; }
.success { color: #16a34a; font-size: 1rem; font-weight: 600; } .success { color: #16a34a; font-size: 1rem; font-weight: 600; }
select {
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
</style> </style>
+197 -2
View File
@@ -1,5 +1,200 @@
<script> <script>
// BarcodeScanner — Camera scanner with permission-denied fallback to manual input (spec §4.1). Uses lib/scanner.js; stop the stream on destroy (spec §8.2 rule 6). // BarcodeScanner — Camera scanner with permission-denied fallback to
// manual input (spec §4.1). Uses lib/scanner.js; stops the stream on
// destroy (spec §8.2 rule 6).
import { onMount } from 'svelte'
import { startScanner, isPermissionDeniedError, hasNativeBarcodeDetector } from '../lib/scanner.js'
/** @type {(barcode: string) => void} */
let { onBarcode } = $props()
let videoEl = $state(null)
let error = $state(null)
let loading = $state(true)
let manualBarcode = $state('')
let cameraUnavailable = $state(false)
/** @type {{ stop: () => void } | null} */
let stopHandle = null
function handleDetected(barcode) {
console.log('[BarcodeScanner] 🎯 barcode detected:', barcode)
// Stop scanning once we have a barcode
if (stopHandle) {
stopHandle.stop()
stopHandle = null
}
onBarcode(barcode)
}
function handleError(err) {
console.error('[BarcodeScanner] camera error:', err)
loading = false
if (isPermissionDeniedError(err)) {
cameraUnavailable = true
error = 'Camera access needed for barcode scanning. Check your browser settings.'
} else if (err.name === 'NotFoundError' || err.message?.includes('No video')) {
cameraUnavailable = true
error = 'No camera found on this device.'
} else {
error = `Camera error: ${err.message}`
cameraUnavailable = true
}
}
function handleManualSubmit(e) {
e.preventDefault()
const code = manualBarcode.trim()
if (!code) return
console.log('[BarcodeScanner] manual barcode submitted:', code)
handleDetected(code)
}
onMount(() => {
if (videoEl) {
stopHandle = startScanner(videoEl, {
onDetect: handleDetected,
onError: handleError,
})
// Camera started successfully (or at least getUserMedia was called)
// If the video starts playing, clear loading
videoEl.addEventListener('play', () => {
loading = false
}, { once: true })
}
return () => {
if (stopHandle) {
stopHandle.stop()
stopHandle = null
}
}
})
</script> </script>
<p>BarcodeScanner (placeholder)</p> <div class="barcode-scanner">
{#if error}
<p class="err" role="alert">{error}</p>
{/if}
{#if loading && !cameraUnavailable}
<p class="status">Starting camera…</p>
{/if}
<!-- Camera viewport — hidden when unavailable or permission denied -->
<div class="camera-container" class:hidden={cameraUnavailable}>
<video
bind:this={videoEl}
autoplay
playsinline
muted
class="scanner-video"
></video>
{#if loading}
<p class="status">Starting camera…</p>
{/if}
</div>
<!-- Manual barcode fallback (always available, primary when camera is gone) -->
<div class="manual-fallback">
<p class="hint">
{#if cameraUnavailable}
Enter a barcode number manually:
{:else}
Or type a barcode:
{/if}
</p>
<form class="manual-form" onsubmit={handleManualSubmit}>
<input
type="text"
inputmode="numeric"
placeholder="e.g. 3017620422003"
bind:value={manualBarcode}
class="barcode-input"
/>
<button type="submit" disabled={!manualBarcode.trim()}>Go</button>
</form>
</div>
</div>
<style>
.barcode-scanner {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.camera-container {
position: relative;
width: 100%;
max-width: 100%;
border-radius: 0.5rem;
overflow: hidden;
background: #000;
}
.camera-container.hidden {
display: none;
}
.scanner-video {
display: block;
width: 100%;
height: auto;
/* mirror for front-facing cameras; environment is usually not mirrored */
}
.status {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
text-align: center;
padding: 1rem;
}
.err {
color: #dc2626;
font-size: 0.9rem;
background: #fef2f2;
padding: 0.75rem;
border-radius: 0.35rem;
margin: 0;
}
.manual-fallback {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.75rem;
}
.hint {
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.manual-form {
display: flex;
gap: 0.4rem;
}
.barcode-input {
flex: 1;
padding: 0.5rem 0.7rem;
font: inherit;
font-size: 1.1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
letter-spacing: 0.05em;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.95rem;
}
button:disabled { opacity: 0.5; cursor: default; }
</style>
+246 -3
View File
@@ -1,11 +1,16 @@
<script> <script>
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6). // Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6).
// Every async view handles loading / error / empty states (spec §8.2 rule 4). // Every async view handles loading / error / empty states (spec §8.2 rule 4).
// Recent foods quick-log section per TICKET-006.
// Multi-select "Save as Meal" flow (spec §4.3, TICKET-007).
import { currentDate, dayData, refreshDayData, appView } from '../lib/stores.svelte.js' import { onMount } from 'svelte'
import { formatDate } from '../lib/format.js' import { api } from '../lib/api.js'
import { currentDate, dayData, refreshDayData, appView, replaceEntriesForMeal } from '../lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity } from '../lib/format.js'
import ProgressBar from './ProgressBar.svelte' import ProgressBar from './ProgressBar.svelte'
import LogEntry from './LogEntry.svelte' import LogEntry from './LogEntry.svelte'
import MealBuilder from './MealBuilder.svelte'
let { date } = $props() let { date } = $props()
@@ -16,6 +21,49 @@
refreshDayData() refreshDayData()
}) })
// ── Recent foods ──────────────────────────────────────────────────────────
let recentFoods = $state([])
let recentLoading = $state(false)
let recentError = $state(null)
onMount(() => {
loadRecentFoods()
})
async function loadRecentFoods() {
recentLoading = true
recentError = null
try {
recentFoods = await api.recentFoods(10)
} catch (e) {
recentError = e.message
} finally {
recentLoading = false
}
}
async function quickLog(food) {
try {
const entry = await api.addLogEntry({
food_id: food.id,
quantity: defaultQuantity(food),
meal_slot: getDefaultSlot(),
date: currentDate.value,
})
dayData.log = [...dayData.log, entry]
} catch {
// Silently fail — user can manually log
}
}
function getDefaultSlot() {
const hour = new Date().getHours()
if (hour < 10) return 'breakfast'
if (hour < 14) return 'lunch'
if (hour < 19) return 'dinner'
return 'snack'
}
// Group entries by meal_slot (null → "Other") // Group entries by meal_slot (null → "Other")
let groups = $derived.by(() => { let groups = $derived.by(() => {
const log = dayData.log const log = dayData.log
@@ -40,6 +88,32 @@
if (unslotted.length) result.push({ slot: null, entries: unslotted }) if (unslotted.length) result.push({ slot: null, entries: unslotted })
return result return result
}) })
// ── Multi-select for "Save as Meal" (§4.3) ──────────────────────────────
let selectedIds = $state(new Set())
let selecting = $state(false) // true → checkboxes visible
let creatingMeal = $state(false) // true → show MealBuilder overlay
function toggleSelect(entryId) {
const next = new Set(selectedIds)
if (next.has(entryId)) {
next.delete(entryId)
} else {
next.add(entryId)
}
selectedIds = next
}
function cancelSelection() {
selecting = false
selectedIds = new Set()
}
function closeMealBuilder() {
creatingMeal = false
selecting = false
selectedIds = new Set()
}
</script> </script>
<section class="dashboard"> <section class="dashboard">
@@ -55,20 +129,86 @@
target={dayData.summary?.target} target={dayData.summary?.target}
/> />
<!-- Recent foods quick-log -->
{#if recentFoods.length > 0}
<div class="recent-foods">
<h3 class="recent-header">Recent</h3>
<div class="recent-list">
{#each recentFoods as food (food.id)}
<button
type="button"
class="recent-chip"
onclick={() => quickLog(food)}
title="{food.name}{food.brand ? ' • ' + food.brand : ''} {formatKcal(food.calories_per_unit)}/{food.unit_type === 'count' ? 'item' : '100g'}"
>
<span class="chip-name">{food.name}</span>
<span class="chip-kcal">{formatKcal(food.calories_per_unit)}</span>
</button>
{/each}
</div>
</div>
{:else if recentLoading}
<p class="status">Loading recent foods…</p>
{/if}
{#if dayData.log.length === 0} {#if dayData.log.length === 0}
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p> <p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
{:else} {:else}
{#if !selecting}
<div class="meal-actions">
<button type="button" class="secondary select-btn" onclick={() => selecting = true}>
☑ Select entries
</button>
</div>
{/if}
{#each groups as group (group.slot ?? 'other')} {#each groups as group (group.slot ?? 'other')}
<div class="meal-group"> <div class="meal-group">
<h3 class="slot-header">{group.slot || 'Other'}</h3> <h3 class="slot-header">{group.slot || 'Other'}</h3>
<ul class="entry-list"> <ul class="entry-list">
{#each group.entries as entry (entry.id)} {#each group.entries as entry (entry.id)}
<li class="entry-row" class:selected={selectedIds.has(entry.id)}>
{#if selecting || creatingMeal}
<!-- Multi-select checkbox -->
<label class="select-label" title="Select for meal">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onchange={() => toggleSelect(entry.id)}
class="select-checkbox"
/>
</label>
{/if}
<div class="entry-content">
<LogEntry {entry} /> <LogEntry {entry} />
</div>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/each} {/each}
{/if} {/if}
<!-- Save as Meal button (spec §4.3) — shown when entries are selected -->
{#if selecting && !creatingMeal}
<div class="meal-actions">
<button type="button" class="save-meal-btn" onclick={() => creatingMeal = true} disabled={selectedIds.size < 2}>
🍽️ Save as Meal ({selectedIds.size} selected)
</button>
<button type="button" class="secondary" onclick={cancelSelection}>
Cancel selection
</button>
</div>
{/if}
<!-- MealBuilder overlay -->
{#if creatingMeal}
<MealBuilder
entryIds={[...selectedIds]}
date={currentDate.value}
onComplete={closeMealBuilder}
onCancel={closeMealBuilder}
/>
{/if}
{/if} {/if}
</section> </section>
@@ -93,6 +233,53 @@
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/* ── Recent foods ───────────────────────────────────────────────────── */
.recent-foods {
margin: 0.75rem 0 1rem;
}
.recent-header {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
margin: 0 0 0.4rem;
}
.recent-list {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.3rem;
}
.recent-chip {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.4rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
text-align: left;
min-width: 5rem;
}
.recent-chip:hover {
background: var(--bg-muted, #f9fafb);
}
.chip-name {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
}
.chip-kcal {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
}
.meal-group { .meal-group {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -104,8 +291,64 @@
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border, #e5e7eb); border-bottom: 1px solid var(--border, #e5e7eb);
} }
.entry-list {
/* ── Entry row with multi-select ────────────────────────────────────── */
.entry-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.entry-row.selected {
background: var(--bg-muted, #f0f4ff);
border-radius: 0.35rem;
}
.select-label {
padding-top: 0.7rem;
flex-shrink: 0;
}
.select-checkbox {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.entry-content {
flex: 1;
min-width: 0;
}
.entry-list {
margin: 0;
padding: 0;
list-style: none;
}
/* ── Meal actions ───────────────────────────────────────────────────── */
.meal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: center;
}
.save-meal-btn {
padding: 0.6rem 1.2rem;
background: #7c3aed;
color: #fff;
border: 1px solid #7c3aed;
border-radius: 0.35rem;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
font-weight: 600;
}
button.secondary {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
</style> </style>
+436 -19
View File
@@ -1,26 +1,152 @@
<script> <script>
// FoodEditor — Manual food creation form (spec §4.5). // FoodEditor — Food creation/editing form (spec §4.5).
// Shared by scan/search/library flows; here used for manual creation. // Shared by scan/search/library flows. Accepts an optional `food` prop
// for pre-filling from OFF or edit flows. When pre-filled, barcode is
// read-only and source is set from the prefill.
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { appView } from '../lib/stores.svelte.js' import { currentDate, addLogEntryToStore, appView, refreshDayData } from '../lib/stores.svelte.js'
import { defaultQuantity, previewCalories, formatKcal, formatGrams } from '../lib/format.js'
/** @type {import('../lib/api.js').FoodCreate | null} */
let { food = null, mealId = null, onSaved = null, onCancel = null } = $props()
// ── Meal component editing state (spec §4.7, TICKET-007) ───────────────
// Active when `mealId` is set: edit the component list of an is_meal food.
let meal = $state(null) // MealRead: components + computed_nutrition_per_meal
let mealLoading = $state(false)
let mealLoadError = $state(null)
let components = $state([]) // [{ food_id, quantity, food }]
let compSaving = $state(false)
let compSaveError = $state(null)
let compQuery = $state('')
let compResults = $state([])
let compSearching = $state(false)
let compSearchError = $state(null)
$effect(() => {
if (mealId) loadMeal(mealId)
})
async function loadMeal(id) {
mealLoading = true
mealLoadError = null
try {
const m = await api.getFood(id)
meal = m
components = (m.components || []).map(c => ({
food_id: c.food_id,
quantity: c.quantity,
food: c.food,
}))
} catch (e) {
mealLoadError = e.message
} finally {
mealLoading = false
}
}
async function searchComponents(e) {
e.preventDefault()
if (!compQuery.trim()) return
compSearching = true
compSearchError = null
try {
compResults = await api.searchFoods(compQuery.trim())
} catch (err) {
compSearchError = err.message
compResults = []
} finally {
compSearching = false
}
}
function addComponent(result) {
if (components.some(c => c.food_id === result.id)) return
components = [...components, {
food_id: result.id,
quantity: defaultQuantity(result),
food: result,
}]
compResults = compResults.filter(r => r.id !== result.id)
}
function removeComponent(foodId) {
components = components.filter(c => c.food_id !== foodId)
}
async function saveComponents() {
compSaving = true
compSaveError = null
try {
const payload = components.map(c => ({
food_id: c.food_id,
quantity: parseFloat(c.quantity),
}))
await api.updateMealComponents(mealId, payload)
await refreshDayData() // meal nutrition changed → log/summary refresh
appView.current = 'dashboard'
} catch (e) {
// Backend rejects cycles with 422 — surface the detail to the user
compSaveError = e.message
} finally {
compSaving = false
}
}
// ── Form state ──────────────────────────────────────────────────────────
let name = $state('') let name = $state('')
let brand = $state('') let brand = $state('')
let unitType = $state('weight') // 'weight' | 'count' let barcode = $state('')
let source = $state('manual')
let unitType = $state('weight')
let caloriesPerUnit = $state('') let caloriesPerUnit = $state('')
let proteinPerUnit = $state('') let proteinPerUnit = $state('')
let carbsPerUnit = $state('') let carbsPerUnit = $state('')
let fatPerUnit = $state('') let fatPerUnit = $state('')
let servingSizeG = $state('') let servingSizeG = $state('')
let servingName = $state('') let servingName = $state('')
let isPrefilled = $state(false)
// ── Log-after-save state ────────────────────────────────────────────────
let justSaved = $state(null) // { food: FoodRead } after save
let logQuantity = $state(1)
let logMealSlot = $state('')
let logging = $state(false)
let logError = $state(null)
let liveKcal = $derived(justSaved ? Math.round(previewCalories(justSaved.food, logQuantity)) : 0)
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
let saving = $state(false) let saving = $state(false)
let error = $state(null) let error = $state(null)
// Edit mode: an existing food (with id) was passed in — update, don't create.
let isEdit = $derived(!!food?.id)
// ── Pre-fill from food prop ─────────────────────────────────────────────
$effect(() => {
if (food) {
name = food.name || ''
brand = food.brand || ''
barcode = food.barcode || ''
source = food.source || 'manual'
unitType = food.unit_type || 'weight'
caloriesPerUnit = food.calories_per_unit != null ? String(food.calories_per_unit) : ''
proteinPerUnit = food.protein_per_unit != null ? String(food.protein_per_unit) : ''
carbsPerUnit = food.carbs_per_unit != null ? String(food.carbs_per_unit) : ''
fatPerUnit = food.fat_per_unit != null ? String(food.fat_per_unit) : ''
servingSizeG = food.serving_size_g != null ? String(food.serving_size_g) : ''
servingName = food.serving_name || ''
isPrefilled = true
}
})
function reset() { function reset() {
name = '' name = ''
brand = '' brand = ''
barcode = ''
source = 'manual'
unitType = 'weight' unitType = 'weight'
caloriesPerUnit = '' caloriesPerUnit = ''
proteinPerUnit = '' proteinPerUnit = ''
@@ -28,6 +154,8 @@
fatPerUnit = '' fatPerUnit = ''
servingSizeG = '' servingSizeG = ''
servingName = '' servingName = ''
isPrefilled = false
justSaved = null
error = null error = null
} }
@@ -40,7 +168,7 @@
saving = true saving = true
error = null error = null
try { try {
const food = await api.createFood({ const payload = {
name: name.trim(), name: name.trim(),
brand: brand.trim() || null, brand: brand.trim() || null,
unit_type: unitType, unit_type: unitType,
@@ -50,24 +178,196 @@
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null, fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null, serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null, serving_name: servingName.trim() || null,
source: 'manual', source,
is_meal: false, is_meal: food?.is_meal ?? false,
}) }
reset() // Include barcode when present
// Go to search so user can log the new food immediately if (barcode.trim()) payload.barcode = barcode.trim()
appView.current = 'addFood' else payload.barcode = null
if (isEdit) {
const saved = await api.updateFood(food.id, payload)
// Nutrition edits affect historical log rendering — refresh the day
await refreshDayData()
if (onSaved) onSaved(saved)
else appView.current = 'dashboard'
return
}
const saved = await api.createFood(payload)
// After save, offer to log the new food
justSaved = { food: saved }
logQuantity = defaultQuantity(saved)
logMealSlot = ''
if (onSaved) onSaved(saved)
} catch (e) { } catch (e) {
error = e.message error = e.message
} finally { } finally {
saving = false saving = false
} }
} }
async function confirmLog() {
if (!justSaved || logQuantity <= 0) return
logging = true
logError = null
try {
const entry = await api.addLogEntry({
food_id: justSaved.food.id,
quantity: parseFloat(logQuantity),
meal_slot: logMealSlot || null,
date: currentDate.value,
})
await addLogEntryToStore(entry)
reset()
appView.current = 'dashboard'
} catch (e) {
logError = e.message
} finally {
logging = false
}
}
function skipLog() {
reset()
appView.current = 'dashboard'
}
function goBack() {
reset()
if (onCancel) {
onCancel()
} else if (mealId) {
appView.current = 'dashboard'
} else if (onSaved) {
// Came from scan flow with no cancel — go to dashboard
appView.current = 'dashboard'
} else {
appView.current = 'addFood'
}
}
</script> </script>
<div class="food-editor"> <div class="food-editor">
<button type="button" class="back-btn" onclick={() => { reset(); appView.current = 'addFood' }}> Back</button> <button type="button" class="back-btn" onclick={goBack}>
← Back
</button>
<h3>Create food</h3> {#if mealId}
<!-- Meal component editor (spec §4.7) -->
<h3>Edit meal components{meal ? `: ${meal.name}` : ''}</h3>
{#if mealLoading}
<p>Loading meal…</p>
{:else if mealLoadError}
<p class="err" role="alert">{mealLoadError}</p>
{:else if meal}
<!-- Derived nutrition is read-only — the backend is the source of truth (§8.2 rule 2) -->
{#if meal.computed_nutrition_per_meal}
<p class="derived-nutrition">
Per meal: {formatKcal(Math.round(meal.computed_nutrition_per_meal.calories ?? 0))}
· P {formatGrams(meal.computed_nutrition_per_meal.protein_g ?? 0)}
· C {formatGrams(meal.computed_nutrition_per_meal.carbs_g ?? 0)}
· F {formatGrams(meal.computed_nutrition_per_meal.fat_g ?? 0)}
</p>
{/if}
<ul class="component-edit-list">
{#each components as comp (comp.food_id)}
<li class="component-edit-item">
<span class="comp-name">{comp.food?.name ?? `Food #${comp.food_id}`}</span>
<input
type="number"
step="any"
min="0.1"
bind:value={comp.quantity}
class="qty-input"
/>
<span class="comp-unit">{comp.food?.unit_type === 'count' ? '×' : 'g'}</span>
<button type="button" class="icon-btn" title="Remove" onclick={() => removeComponent(comp.food_id)}>✕</button>
</li>
{:else}
<li class="component-edit-item empty">No components yet</li>
{/each}
</ul>
<form class="comp-search" onsubmit={searchComponents}>
<input type="text" bind:value={compQuery} placeholder="Search foods to add…" />
<button type="submit" disabled={compSearching || !compQuery.trim()}>
{compSearching ? '…' : 'Search'}
</button>
</form>
{#if compSearchError}<p class="err" role="alert">{compSearchError}</p>{/if}
{#if compResults.length > 0}
<ul class="comp-results">
{#each compResults as r (r.id)}
<li class="comp-result">
<span>{r.name}{r.brand ? ` (${r.brand})` : ''}{r.is_meal ? ' [meal]' : ''}</span>
<button type="button" class="secondary" onclick={() => addComponent(r)}>Add</button>
</li>
{/each}
</ul>
{/if}
{#if compSaveError}<p class="err" role="alert">{compSaveError}</p>{/if}
<div class="form-actions">
<button
type="button"
onclick={saveComponents}
disabled={compSaving || components.length === 0 || components.some(c => !(parseFloat(c.quantity) > 0))}
>
{compSaving ? 'Saving…' : 'Save components'}
</button>
<button type="button" class="secondary" onclick={goBack} disabled={compSaving}>Cancel</button>
</div>
{/if}
{:else if justSaved}
<!-- Post-save: offer to log the new food -->
<h3>"{justSaved.food.name}" saved</h3>
<div class="log-form">
<p class="brand">{justSaved.food.brand}</p>
<label>
Quantity
<input
type="number"
step="any"
min="0.1"
bind:value={logQuantity}
class="qty-input"
/>
{justSaved.food.unit_type === 'count' ? 'items' : 'g'}
</label>
<p class="preview-kcal">= {formatKcal(liveKcal)}</p>
<label>
Meal slot
<select bind:value={logMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
<div class="log-actions">
<button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
{logging ? 'Logging…' : 'Log it'}
</button>
<button type="button" class="secondary" onclick={skipLog} disabled={logging}>
Skip
</button>
</div>
</div>
{:else}
<!-- Creation/edit form -->
<h3>{isEdit ? 'Edit food' : isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
<form onsubmit={handleSubmit}> <form onsubmit={handleSubmit}>
<label> <label>
@@ -80,14 +380,29 @@
<input type="text" bind:value={brand} placeholder="Optional" /> <input type="text" bind:value={brand} placeholder="Optional" />
</label> </label>
<label>
Barcode
<input
type="text"
bind:value={barcode}
placeholder={isPrefilled ? '' : 'Optional'}
disabled={isPrefilled}
class:barcode-ro={isPrefilled}
/>
</label>
{#if isPrefilled}
<p class="source-badge">Source: {source}</p>
{/if}
<fieldset> <fieldset>
<legend>Unit type</legend> <legend>Unit type</legend>
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="unitType" value="weight" bind:group={unitType} /> <input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
Weight (nutrition per 100g) Weight (nutrition per 100g)
</label> </label>
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="unitType" value="count" bind:group={unitType} /> <input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
Count (nutrition per item) Count (nutrition per item)
</label> </label>
</fieldset> </fieldset>
@@ -133,13 +448,14 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" disabled={saving}> <button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'} {saving ? 'Saving…' : isEdit ? 'Save changes' : 'Save food'}
</button> </button>
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}> <button type="button" class="secondary" onclick={goBack} disabled={saving}>
Cancel Cancel
</button> </button>
</div> </div>
</form> </form>
{/if}
</div> </div>
<style> <style>
@@ -185,6 +501,22 @@
border-radius: 0.35rem; border-radius: 0.35rem;
} }
.barcode-ro {
background: var(--bg-muted, #f3f4f6);
color: var(--text-muted, #6b7280);
}
.source-badge {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
background: var(--bg-muted, #f3f4f6);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
display: inline-block;
text-transform: capitalize;
margin: 0;
}
fieldset { fieldset {
border: 1px solid var(--border, #e5e7eb); border: 1px solid var(--border, #e5e7eb);
border-radius: 0.35rem; border-radius: 0.35rem;
@@ -206,8 +538,8 @@
} }
.macro-grid { .macro-grid {
display: grid; display: flex;
grid-template-columns: 1fr 1fr 1fr; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -217,6 +549,38 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
.log-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.log-form label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.preview-kcal {
font-weight: 600;
font-size: 1rem;
color: var(--text, #111827);
margin: 0;
}
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
button { button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db); border: 1px solid var(--border, #d1d5db);
@@ -230,4 +594,57 @@
button.secondary { background: var(--bg-muted, #f3f4f6); } button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; } .err { color: #dc2626; font-size: 0.85rem; }
/* Meal component editor (§4.7) */
.derived-nutrition {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
background: var(--bg-muted, #f3f4f6);
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
margin: 0 0 0.75rem;
}
.component-edit-list {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
}
.component-edit-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border, #e5e7eb);
font-size: 0.9rem;
}
.component-edit-item .comp-name { flex: 1; font-weight: 500; }
.component-edit-item.empty { color: var(--text-muted, #6b7280); font-style: italic; }
.component-edit-item .qty-input { width: 5rem; }
.comp-unit { color: var(--text-muted, #6b7280); }
.comp-search {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.comp-search input { flex: 1; }
.comp-results {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
}
.comp-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border, #e5e7eb);
font-size: 0.9rem;
}
select {
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
</style> </style>
+338 -2
View File
@@ -1,5 +1,341 @@
<script> <script>
// FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7) // FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7).
// Uses GET /api/foods with limit/offset; include_deleted toggle shows soft-deleted
// foods (visually distinct, restorable). Edit reuses FoodEditor; meals open the
// meal component editor instead (TICKET-007).
import { onMount } from 'svelte'
import { api } from '../lib/api.js'
import { appView, refreshDayData } from '../lib/stores.svelte.js'
import { formatKcal } from '../lib/format.js'
import FoodEditor from './FoodEditor.svelte'
const PAGE_SIZE = 20
let foods = $state([])
let loading = $state(true)
let error = $state(null)
let query = $state('')
let offset = $state(0)
let showDeleted = $state(false)
let hasMore = $state(false)
// Edit state: which food is open in FoodEditor (null = list view)
let editingFood = $state(null) // regular food edit
let editingMealId = $state(null) // meal component editing
// Per-row action state
let confirmingDeleteId = $state(null)
let actionBusyId = $state(null)
let actionError = $state(null)
async function load() {
loading = true
error = null
try {
// Fetch one extra row to know whether a next page exists
const rows = await api.listFoods({
q: query,
limit: PAGE_SIZE + 1,
offset,
includeDeleted: showDeleted,
})
hasMore = rows.length > PAGE_SIZE
foods = rows.slice(0, PAGE_SIZE)
} catch (e) {
error = e.message
foods = []
hasMore = false
} finally {
loading = false
}
}
// Initial load only — subsequent loads are triggered explicitly
// (search submit, pagination, toggle) to avoid re-loading on every keystroke.
onMount(load)
function search(e) {
e.preventDefault()
offset = 0
load()
}
function toggleDeleted() {
showDeleted = !showDeleted
offset = 0
load()
}
function prevPage() {
offset = Math.max(0, offset - PAGE_SIZE)
load()
}
function nextPage() {
if (!hasMore) return
offset += PAGE_SIZE
load()
}
function openEdit(food) {
actionError = null
if (food.is_meal) {
editingMealId = food.id
} else {
editingFood = food
}
}
async function closeEditor() {
editingFood = null
editingMealId = null
await refreshDayData() // food/meal edits affect log rendering + summary
load()
}
async function doDelete(food) {
actionBusyId = food.id
actionError = null
try {
await api.deleteFood(food.id)
confirmingDeleteId = null
await refreshDayData()
await load()
} catch (e) {
actionError = e.message
} finally {
actionBusyId = null
}
}
async function doRestore(food) {
actionBusyId = food.id
actionError = null
try {
await api.restoreFood(food.id)
await refreshDayData()
await load()
} catch (e) {
actionError = e.message
} finally {
actionBusyId = null
}
}
/** Display kcal for a row: per 100g or per item depending on unit type. */
function rowKcal(food) {
if (food.is_meal) return 'meal'
if (food.calories_per_unit == null) return '—'
const unit = food.unit_type === 'count' ? '/item' : '/100g'
return `${formatKcal(Math.round(food.calories_per_unit))}${unit}`
}
</script> </script>
<p>FoodLibrary (placeholder)</p> <div class="food-library">
{#if editingMealId}
<FoodEditor mealId={editingMealId} onCancel={closeEditor} />
{:else if editingFood}
<FoodEditor food={editingFood} onSaved={closeEditor} onCancel={closeEditor} />
{:else}
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>
← Dashboard
</button>
<h2>Foods</h2>
<form class="search-bar" onsubmit={search}>
<input type="text" bind:value={query} placeholder="Search name or brand…" />
<button type="submit" disabled={loading}>Search</button>
</form>
<label class="deleted-toggle">
<input type="checkbox" checked={showDeleted} onchange={toggleDeleted} />
Show deleted
</label>
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
{#if loading}
<p class="status">Loading foods…</p>
{:else if error}
<p class="err" role="alert">{error}</p>
{:else if foods.length === 0}
<p class="status empty">No foods found.</p>
{:else}
<ul class="food-list">
{#each foods as food (food.id)}
<li class="food-row" class:deleted={food.deleted_at}>
<div class="food-info">
<span class="food-name">
{food.name}
{#if food.is_meal}<span class="meal-badge">meal</span>{/if}
{#if food.deleted_at}<span class="deleted-badge">deleted</span>{/if}
</span>
{#if food.brand}<span class="food-brand">{food.brand}</span>{/if}
<span class="food-kcal">{rowKcal(food)}</span>
</div>
<div class="row-actions">
{#if food.deleted_at}
<button
type="button"
class="secondary"
onclick={() => doRestore(food)}
disabled={actionBusyId === food.id}
>
{actionBusyId === food.id ? '…' : 'Restore'}
</button>
{:else if confirmingDeleteId === food.id}
<span class="confirm-text">Delete?</span>
<button
type="button"
class="danger"
onclick={() => doDelete(food)}
disabled={actionBusyId === food.id}
>
{actionBusyId === food.id ? '…' : 'Yes'}
</button>
<button type="button" class="secondary" onclick={() => confirmingDeleteId = null}>No</button>
{:else}
<button type="button" class="secondary" onclick={() => openEdit(food)}>Edit</button>
<button type="button" class="danger" onclick={() => confirmingDeleteId = food.id}>Delete</button>
{/if}
</div>
</li>
{/each}
</ul>
<div class="pagination">
<button type="button" class="secondary" onclick={prevPage} disabled={offset === 0}>
← Prev
</button>
<span class="page-info">Showing {offset + 1}{offset + foods.length}</span>
<button type="button" class="secondary" onclick={nextPage} disabled={!hasMore}>
Next →
</button>
</div>
{/if}
{/if}
</div>
<style>
.food-library { }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.5rem;
}
h2 { margin: 0 0 0.75rem; font-size: 1.25rem; }
.search-bar {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.search-bar input {
flex: 1;
padding: 0.4rem 0.6rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.deleted-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
margin-bottom: 0.75rem;
}
.status { color: var(--text-muted, #6b7280); }
.status.empty { font-style: italic; }
.err { color: #dc2626; font-size: 0.85rem; }
.food-list {
list-style: none;
margin: 0;
padding: 0;
}
.food-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
padding: 0.55rem 0;
border-bottom: 1px solid var(--border, #e5e7eb);
}
.food-row.deleted {
opacity: 0.6;
background: var(--bg-muted, #f9fafb);
}
.food-info {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.5rem;
min-width: 0;
}
.food-name { font-weight: 600; }
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.food-kcal { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.meal-badge {
font-size: 0.7rem;
background: #e0e7ff;
color: #4338ca;
padding: 0.1em 0.4em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.deleted-badge {
font-size: 0.7rem;
background: #fee2e2;
color: #991b1b;
padding: 0.1em 0.4em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.confirm-text { font-size: 0.85rem; color: var(--text, #111827); }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.75rem;
}
.page-info {
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
}
button {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
button:disabled { opacity: 0.5; cursor: default; }
button.secondary { background: var(--bg-muted, #f3f4f6); }
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
</style>
+148 -16
View File
@@ -1,17 +1,24 @@
<script> <script>
// FoodSearch — Free-text search: local DB search, select, log (spec §4.2, TICKET-005). // FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2).
// Full search UX (recent foods, OFF fallback) is TICKET-006. // Selecting a local food → log it; selecting an OFF result → FoodEditor pre-filled.
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js' import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js' import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
import FoodEditor from './FoodEditor.svelte'
let query = $state('') let query = $state('')
let results = $state(null) // null = not searched yet, [] = no results let results = $state(null) // null = not searched yet, [] = no results
let searching = $state(false) let searching = $state(false)
let searchError = $state(null) let searchError = $state(null)
// Selected food for logging // OFF fallback state
let offResults = $state(null) // null = not searched OFF, [] = no off results
let offSearching = $state(false)
let offError = $state(null)
let offQueried = $state(false) // track if we've already tried OFF for this query
// Selected local food for logging
let selected = $state(null) let selected = $state(null)
let logQuantity = $state(1) let logQuantity = $state(1)
let logMealSlot = $state('') let logMealSlot = $state('')
@@ -20,14 +27,19 @@
let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity))) let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity)))
// Selected OFF food for pre-fill
let offSelected = $state(null) // the normalized OFF food dict
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack'] const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
async function doSearch() { async function doSearch() {
const q = query.trim() const q = query.trim()
if (!q) { results = null; return } if (!q) { results = null; offResults = null; offQueried = false; return }
searching = true searching = true
searchError = null searchError = null
offResults = null
offQueried = false
try { try {
results = await api.searchFoods(q) results = await api.searchFoods(q)
} catch (e) { } catch (e) {
@@ -38,6 +50,23 @@
} }
} }
async function searchOff() {
const q = query.trim()
if (!q) return
offSearching = true
offError = null
offQueried = true
try {
offResults = await api.offSearch(q)
} catch (e) {
offError = e.message
offResults = []
} finally {
offSearching = false
}
}
function pickFood(food) { function pickFood(food) {
selected = food selected = food
logQuantity = defaultQuantity(food) logQuantity = defaultQuantity(food)
@@ -62,10 +91,11 @@
date: currentDate.value, date: currentDate.value,
}) })
await addLogEntryToStore(entry) await addLogEntryToStore(entry)
// Reset and go back to dashboard
selected = null selected = null
query = '' query = ''
results = null results = null
offResults = null
offQueried = false
appView.current = 'dashboard' appView.current = 'dashboard'
} catch (e) { } catch (e) {
logError = e.message logError = e.message
@@ -73,15 +103,27 @@
logging = false logging = false
} }
} }
function pickOffFood(offFood) {
offSelected = offFood
}
function clearOffSelection() {
offSelected = null
}
</script> </script>
<div class="food-search"> <div class="food-search">
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button> <button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button>
{#if !selected} {#if offSelected}
<!-- OFF food selected → pre-fill FoodEditor -->
<FoodEditor food={offSelected} onCancel={() => { offSelected = null }} />
{:else if !selected}
<!-- Search --> <!-- Search -->
<h3>Find a food</h3> <h3>Find a food</h3>
<form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch() }}> <form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch(); offResults = null; offQueried = false }}>
<input <input
type="search" type="search"
placeholder="Search foods…" placeholder="Search foods…"
@@ -95,10 +137,9 @@
<p class="status">Searching…</p> <p class="status">Searching…</p>
{:else if searchError} {:else if searchError}
<p class="status err" role="alert">{searchError}</p> <p class="status err" role="alert">{searchError}</p>
{:else if results !== null} {:else if results !== null && results.length > 0}
{#if results.length === 0} <!-- Local results found -->
<p class="status">No foods found.</p> <p class="status">{results.length} local result{results.length !== 1 ? 's' : ''}</p>
{:else}
<ul class="results-list"> <ul class="results-list">
{#each results as food (food.id)} {#each results as food (food.id)}
<li> <li>
@@ -112,18 +153,64 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if results !== null && results.length === 0}
<!-- No local results — offer OFF fallback -->
<p class="status">No foods found locally.</p>
{#if !offQueried}
<div class="off-fallback">
<p class="off-hint">Not found locally — search OpenFoodFacts?</p>
<button type="button" class="off-btn" onclick={searchOff} disabled={offSearching}>
{offSearching ? 'Searching OFF…' : 'Search OpenFoodFacts'}
</button>
</div>
{/if} {/if}
{/if} {/if}
<!-- OFF results (also shown if there ARE local results but user wants more) -->
{#if offQueried}
{#if offSearching}
<p class="status">Searching OpenFoodFacts…</p>
{:else if offError}
<p class="status err" role="alert">OFF search failed: {offError}</p>
{:else if offResults && offResults.length > 0}
<p class="status off-status">OpenFoodFacts results:</p>
<ul class="results-list off-list">
{#each offResults as offFood, i (`off-${i}`)}
<li>
<button type="button" class="result-item" onclick={() => pickOffFood(offFood)}>
<span class="r-name">{offFood.name}</span>
{#if offFood.brand}<span class="r-brand">{offFood.brand}</span>{/if}
<span class="r-kcal">{formatKcal(offFood.calories_per_unit)}/100g</span>
<span class="r-source">OFF</span>
</button>
</li>
{/each}
</ul>
{:else if offResults !== null && offResults.length === 0}
<p class="status">No results from OpenFoodFacts either.</p>
{/if}
{/if}
{#if !offQueried && results !== null && results.length > 0}
<!-- Offer OFF search even when local results exist -->
<div class="off-fallback">
<button type="button" class="link off-link" onclick={searchOff} disabled={offSearching}>
Also search OpenFoodFacts
</button>
</div>
{/if}
<p class="or-create"> <p class="or-create">
Or Or
<button type="button" class="link" onclick={() => appView.current = 'createFood'}> <button type="button" class="link" onclick={() => appView.current = 'createFood'}>
create a new food create a new food manually
</button> </button>
</p> </p>
{:else} {:else}
<!-- Logging form for selected food --> <!-- Logging form for selected local food -->
<h3>Log "{selected.name}"</h3> <h3>Log "{selected.name}"</h3>
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if} {#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
@@ -221,11 +308,51 @@
.r-name { font-weight: 600; } .r-name { font-weight: 600; }
.r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; } .r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); } .r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); }
.or-create { .r-source {
margin-top: 0.75rem; font-size: 0.7rem;
font-size: 0.9rem; background: var(--bg-muted, #e5e7eb);
padding: 0.1em 0.4em;
border-radius: 0.25rem;
color: var(--text-muted, #6b7280); color: var(--text-muted, #6b7280);
} }
.off-fallback {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px dashed var(--border, #d1d5db);
border-radius: 0.5rem;
text-align: center;
}
.off-hint {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.off-btn {
padding: 0.45rem 0.9rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
.off-btn:disabled { opacity: 0.5; cursor: default; }
.off-list {
border-color: var(--border, #d1d5db);
}
.off-status {
margin-top: 1rem;
font-weight: 600;
}
.off-link {
color: var(--link, #2563eb);
text-decoration: underline;
cursor: pointer;
}
button.link { button.link {
background: none; background: none;
border: none; border: none;
@@ -235,6 +362,11 @@
font: inherit; font: inherit;
padding: 0; padding: 0;
} }
.or-create {
margin-top: 0.75rem;
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
}
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; } .brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
.log-form { .log-form {
display: flex; display: flex;
+143 -5
View File
@@ -1,10 +1,13 @@
<script> <script>
// LogEntry — One log row: name, quantity, kcal, edit/delete (spec §3.3, §4.6). // LogEntry — One log row: name, quantity, kcal, edit/delete (spec §3.3, §4.6).
// Meals render collapsible (TICKET-007); for now a flat row. // Meal entries render collapsible: collapsed = meal name + total kcal;
// expanded = components with their quantities (spec §3.3, TICKET-007).
// "Unpack" action on logged meal entries (spec §4.4).
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { formatKcal, formatQuantity, caloriesForEntry, previewCalories } from '../lib/format.js' import { formatKcal, caloriesForEntry, previewCalories } from '../lib/format.js'
import { updateLogEntryInStore, removeLogEntryFromStore } from '../lib/stores.svelte.js' import { updateLogEntryInStore, removeLogEntryFromStore, replaceEntryForUnpack } from '../lib/stores.svelte.js'
import { currentDate, openMealEditor } from '../lib/stores.svelte.js'
let { entry } = $props() let { entry } = $props()
@@ -16,6 +19,11 @@
let confirmingDelete = $state(false) let confirmingDelete = $state(false)
let deleting = $state(false) let deleting = $state(false)
// Meal collapsible state
let expanded = $state(false)
let unpacking = $state(false)
let unpackError = $state(null)
// Sync edit state when entry changes or editing starts // Sync edit state when entry changes or editing starts
$effect(() => { $effect(() => {
if (entry) { if (entry) {
@@ -24,6 +32,7 @@
} }
}) })
let isMeal = $derived(entry.food?.is_meal ?? false)
let kcal = $derived(Math.round(caloriesForEntry(entry))) let kcal = $derived(Math.round(caloriesForEntry(entry)))
let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity))) let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity)))
@@ -67,6 +76,29 @@
deleting = false deleting = false
} }
} }
async function doUnpack() {
unpacking = true
unpackError = null
try {
const result = await api.unpackMeal(entry.food_id, currentDate.value, entry.id)
await replaceEntryForUnpack(entry.id, result.entries)
} catch (e) {
unpackError = e.message
} finally {
unpacking = false
}
}
/** Compute calories for a meal component entry (child of a meal). */
function componentCalories(component) {
const food = component.food
if (!food || food.calories_per_unit == null) return 0
if (food.unit_type === 'count') {
return component.quantity * food.calories_per_unit
}
return (component.quantity / 100) * food.calories_per_unit
}
</script> </script>
<li class="log-entry"> <li class="log-entry">
@@ -110,9 +142,32 @@
</div> </div>
{:else} {:else}
<div class="entry-main"> <div class="entry-main">
{#if isMeal}
<!-- Meal entry: collapsible header -->
<button type="button" class="meal-toggle" onclick={() => expanded = !expanded} title={expanded ? 'Collapse' : 'Expand'}>
<span class="collapse-arrow">{expanded ? '▼' : '▶'}</span>
</button>
<span class="food-name meal-name">{entry.food.name}</span>
<span class="meal-badge">meal</span>
<span class="kcal">{formatKcal(kcal)}</span>
{#if entry.meal_slot}
<span class="slot-badge">{entry.meal_slot}</span>
{/if}
<div class="entry-actions">
<!-- Unpack button (spec §4.4) -->
<button type="button" class="icon-btn unpack-btn" title="Unpack" onclick={doUnpack} disabled={unpacking}>
{unpacking ? '…' : '🔓'}
</button>
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
</div>
{:else}
<!-- Regular entry -->
<span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span> <span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span>
<span class="food-brand">{entry.food?.brand}</span> {#if entry.food?.brand}
<span class="qty">{formatQuantity(entry)}</span> <span class="food-brand">{entry.food.brand}</span>
{/if}
<span class="qty">{entry.quantity}{entry.food?.unit_type === 'count' ? '×' : 'g'}</span>
<span class="kcal">{formatKcal(kcal)}</span> <span class="kcal">{formatKcal(kcal)}</span>
{#if entry.meal_slot} {#if entry.meal_slot}
<span class="slot-badge">{entry.meal_slot}</span> <span class="slot-badge">{entry.meal_slot}</span>
@@ -121,7 +176,37 @@
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button> <button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button> <button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
</div> </div>
{/if}
</div> </div>
{#if unpackError}
<p class="err" role="alert">{unpackError}</p>
{/if}
<!-- Expanded meal components (spec §3.3) -->
{#if isMeal && expanded}
<ul class="component-list">
{#if entry.food?.components && entry.food.components.length > 0}
{#each entry.food.components as comp (comp.food_id)}
<li class="component-item">
<span class="comp-name">{comp.food?.name ?? `Food #${comp.food_id}`}</span>
{#if comp.food?.brand}
<span class="comp-brand">{comp.food.brand}</span>
{/if}
<span class="comp-qty">{comp.quantity}{comp.food?.unit_type === 'count' ? '×' : 'g'}</span>
<span class="comp-kcal">{formatKcal(Math.round(componentCalories(comp)))}</span>
</li>
{/each}
{:else}
<li class="component-item empty">No components</li>
{/if}
<li class="component-item">
<button type="button" class="secondary edit-components-btn" onclick={() => openMealEditor(entry.food_id)}>
Edit components
</button>
</li>
</ul>
{/if}
{/if} {/if}
</li> </li>
@@ -139,6 +224,17 @@
} }
.food-name { font-weight: 600; } .food-name { font-weight: 600; }
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; } .food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.meal-name { cursor: pointer; }
.meal-badge {
font-size: 0.7rem;
background: #e0e7ff;
color: #4338ca;
padding: 0.1em 0.4em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.qty { color: var(--text-muted, #6b7280); font-size: 0.9rem; } .qty { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
.kcal { font-weight: 600; margin-left: auto; } .kcal { font-weight: 600; margin-left: auto; }
.slot-badge { .slot-badge {
@@ -148,6 +244,18 @@
border-radius: 0.3rem; border-radius: 0.3rem;
text-transform: capitalize; text-transform: capitalize;
} }
.meal-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem;
color: var(--text-muted, #6b7280);
}
.collapse-arrow {
display: inline-block;
width: 0.8rem;
}
.entry-actions { .entry-actions {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
@@ -160,6 +268,10 @@
padding: 0.2rem; padding: 0.2rem;
line-height: 1; line-height: 1;
} }
.icon-btn:disabled { opacity: 0.4; cursor: default; }
.unpack-btn {
font-size: 1rem;
}
.edit-form { .edit-form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -186,6 +298,32 @@
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Component list (expanded meal) */
.component-list {
margin: 0.5rem 0 0 1.5rem;
padding: 0;
border-left: 2px solid var(--border, #e5e7eb);
padding-left: 0.75rem;
}
.component-item {
list-style: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.comp-name { font-weight: 500; }
.comp-brand { color: var(--text-muted, #6b7280); font-size: 0.8rem; }
.comp-qty { color: var(--text-muted, #6b7280); }
.comp-kcal { margin-left: auto; font-weight: 500; color: var(--text, #111827); }
.component-item.empty {
color: var(--text-muted, #6b7280);
font-style: italic;
}
button { button {
padding: 0.3rem 0.7rem; padding: 0.3rem 0.7rem;
border: 1px solid var(--border, #d1d5db); border: 1px solid var(--border, #d1d5db);
+152 -2
View File
@@ -1,5 +1,155 @@
<script> <script>
// MealBuilder — Create a meal from selected log entries (spec §4.3) // MealBuilder — Create a meal from selected log entries (spec §4.3, TICKET-007).
// Shows a name prompt, triggers POST /api/meals/from-log, replaces entries.
import { api } from '../lib/api.js'
import { replaceEntriesForMeal } from '../lib/stores.svelte.js'
let { entryIds, date, onComplete, onCancel } = $props()
let mealName = $state('')
let saving = $state(false)
let error = $state(null)
async function handleCreate() {
const name = mealName.trim()
if (!name) {
error = 'Please enter a meal name'
return
}
saving = true
error = null
try {
const result = await api.createMealFromLog(name, date, entryIds)
// result: { meal: FoodRead, entry: LogEntryRead }
await replaceEntriesForMeal(entryIds, result.entry)
if (onComplete) onComplete()
} catch (e) {
error = e.message
} finally {
saving = false
}
}
</script> </script>
<p>MealBuilder (placeholder)</p> <div class="meal-builder-overlay">
<div class="meal-builder-card">
<button type="button" class="close-btn" onclick={onCancel}>✕</button>
<h3>Save as Meal</h3>
<p class="hint">Name your meal from {entryIds.length} selected entries.</p>
<form onsubmit={(e) => { e.preventDefault(); handleCreate() }}>
<label>
Meal name
<input
type="text"
bind:value={mealName}
placeholder="e.g. Morning Oatmeal"
autofocus
required
class="name-input"
/>
</label>
{#if error}
<p class="err" role="alert">{error}</p>
{/if}
<div class="actions">
<button type="submit" disabled={saving || !mealName.trim()}>
{saving ? 'Creating…' : 'Create meal'}
</button>
<button type="button" class="secondary" onclick={onCancel} disabled={saving}>
Cancel
</button>
</div>
</form>
</div>
</div>
<style>
.meal-builder-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.meal-builder-card {
background: #fff;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 24rem;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
}
.close-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--text-muted, #6b7280);
padding: 0.3rem;
line-height: 1;
}
h3 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: var(--text, #111827);
}
.hint {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
margin: 0 0 1rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
color: var(--text, #111827);
}
.name-input {
padding: 0.5rem 0.7rem;
font: inherit;
font-size: 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
button[type="submit"] {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
font-weight: 600;
}
button:disabled { opacity: 0.5; cursor: default; }
button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; }
</style>
+20
View File
@@ -28,9 +28,15 @@ export const api = {
health: () => request('/api/health'), health: () => request('/api/health'),
// Foods (spec §3.1) // Foods (spec §3.1)
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`), searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
listFoods: ({ q = '', limit = 20, offset = 0, includeDeleted = false } = {}) =>
request(`/api/foods?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}&include_deleted=${includeDeleted}`),
deleteFood: (id) => request(`/api/foods/${id}`, { method: 'DELETE' }),
restoreFood: (id) => request(`/api/foods/${id}/restore`, { method: 'POST' }),
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`), recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
getFood: (id) => request(`/api/foods/${id}`), getFood: (id) => request(`/api/foods/${id}`),
createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }), createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }),
updateFood: (id, food) => request(`/api/foods/${id}`, { method: 'PUT', body: JSON.stringify(food) }),
// Daily log (spec §3.3) — dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4) // Daily log (spec §3.3) — dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4)
getLog: (date) => request(`/api/log?date=${date}`), getLog: (date) => request(`/api/log?date=${date}`),
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }), addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
@@ -44,4 +50,18 @@ export const api = {
// OFF proxy (spec §3.5) — the frontend never calls OFF directly // OFF proxy (spec §3.5) — the frontend never calls OFF directly
offProduct: (barcode) => request(`/api/off/product/${barcode}`), offProduct: (barcode) => request(`/api/off/product/${barcode}`),
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`), offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
offRefresh: (foodId) => request(`/api/off/refresh/${foodId}`, { method: 'POST' }),
// Meals (spec §3.2, TICKET-007)
createMealFromLog: (name, date, entryIds) => request('/api/meals/from-log', {
method: 'POST',
body: JSON.stringify({ name, date, entry_ids: entryIds }),
}),
unpackMeal: (mealId, date, entryId) => request(`/api/meals/${mealId}/unpack`, {
method: 'POST',
body: JSON.stringify({ date, entry_id: entryId }),
}),
updateMealComponents: (mealId, components) => request(`/api/meals/${mealId}/components`, {
method: 'PUT',
body: JSON.stringify({ components }),
}),
} }
+8 -1
View File
@@ -25,11 +25,18 @@ export function formatDate(yyyyMmDd) {
/** /**
* Compute the calories contributed by a single log entry. * Compute the calories contributed by a single log entry.
* This is the permitted simple linear scaling for display only (spec §8.2 rule 2). * The server is the source of truth for nutrition (spec §8.2 rule 2).
* For entries with computed_nutrition from the backend (meals, or any entry
* where the backend has computed the values), use that directly.
* For simple foods, fall back to linear scaling (quantity × per_unit).
* - weight-type: (quantity / 100) × calories_per_unit * - weight-type: (quantity / 100) × calories_per_unit
* - count-type: quantity × calories_per_unit * - count-type: quantity × calories_per_unit
*/ */
export function caloriesForEntry(entry) { export function caloriesForEntry(entry) {
// If the backend sent computed_nutrition, use it (spec §8.2 rule 2)
if (entry.computed_nutrition?.calories != null) {
return entry.computed_nutrition.calories
}
const food = entry.food const food = entry.food
if (!food || food.calories_per_unit == null) return 0 if (!food || food.calories_per_unit == null) return 0
if (food.unit_type === 'count') { if (food.unit_type === 'count') {
+44 -3
View File
@@ -1,9 +1,8 @@
/** /**
* Example test suite — scaffold for future logic tests (spec §8.4: * Test suite for format.js logic (spec §8.4: Vitest only for stores/format logic).
* Vitest only for stores/format logic, no component tests in v1).
*/ */
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { formatKcal, formatGrams, formatDate, shiftDate } from './format.js' import { formatKcal, formatGrams, formatDate, shiftDate, caloriesForEntry } from './format.js'
describe('formatKcal', () => { describe('formatKcal', () => {
it('rounds to whole numbers', () => { it('rounds to whole numbers', () => {
@@ -64,3 +63,45 @@ describe('shiftDate', () => {
expect(shiftDate('2026-08-01', -7)).toBe('2026-07-25') expect(shiftDate('2026-08-01', -7)).toBe('2026-07-25')
}) })
}) })
describe('caloriesForEntry', () => {
it('uses computed_nutrition when available (meal entries)', () => {
const entry = {
quantity: 1,
computed_nutrition: { calories: 420, protein_g: 25 },
food: { calories_per_unit: null, unit_type: 'weight', is_meal: true },
}
expect(caloriesForEntry(entry)).toBe(420)
})
it('falls back to weight-type scaling for non-meal foods', () => {
const entry = {
quantity: 200,
computed_nutrition: null,
food: { calories_per_unit: 350, unit_type: 'weight' },
}
expect(caloriesForEntry(entry)).toBe(700) // (200/100) * 350
})
it('falls back to count-type scaling for count foods', () => {
const entry = {
quantity: 3,
computed_nutrition: null,
food: { calories_per_unit: 80, unit_type: 'count' },
}
expect(caloriesForEntry(entry)).toBe(240) // 3 * 80
})
it('returns 0 when food has null calories_per_unit and no computed_nutrition', () => {
const entry = {
quantity: 1,
computed_nutrition: null,
food: { calories_per_unit: null, unit_type: 'weight', is_meal: true },
}
expect(caloriesForEntry(entry)).toBe(0)
})
it('returns 0 when food is null', () => {
expect(caloriesForEntry({ quantity: 1, food: null })).toBe(0)
})
})
+184 -9
View File
@@ -8,24 +8,199 @@
* *
* NOTE: getUserMedia requires a secure context — HTTPS via the Caddy * NOTE: getUserMedia requires a secure context — HTTPS via the Caddy
* reverse proxy must be in place before phone testing (spec §5). * reverse proxy must be in place before phone testing (spec §5).
*
* DEBUGGING: Open browser DevTools (F12) → Console to see scanner logs.
* Set localStorage.debugScanner = 'true' for verbose per-frame logging.
*/ */
const DEBUG = typeof globalThis !== 'undefined' && globalThis.localStorage?.getItem('debugScanner') === 'true'
// zxing-wasm is the fallback decoder; imported lazily so Chromium users // zxing-wasm is the fallback decoder; imported lazily so Chromium users
// on the native path never pay the WASM download cost. // on the native path never pay the WASM download cost.
// import { readBarcodes } from 'zxing-wasm/reader' // The module exposes readBarcodesFromImageData for ImageData input.
/** Formats we care about for grocery barcodes. */
const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8', 'upc_e']
/** ~4 fps: 250ms between decode attempts. */
const DECODE_INTERVAL_MS = 250
export function hasNativeBarcodeDetector() { export function hasNativeBarcodeDetector() {
return typeof globalThis.BarcodeDetector !== 'undefined' return typeof globalThis.BarcodeDetector !== 'undefined'
} }
/** /**
* TODO: implement startScanner(videoEl, { onDetect }) → stop() handle. * Classify an error from getUserMedia — returns true for permission-denied
* - getUserMedia({ video: { facingMode: 'environment' } }) * (NotAllowedError / SecurityError), which the component uses to show the
* - native BarcodeDetector if hasNativeBarcodeDetector(), else zxing-wasm * manual-barcode fallback message.
* - decode loop throttled to ~3-5 fps
* - stop(): release tracks, cancel loop
* - permission denied → caller shows message + manual barcode input (spec §4.1)
*/ */
export function startScanner() { export function isPermissionDeniedError(err) {
throw new Error('scanner not implemented yet — see spec §4.1') return err instanceof DOMException && (
err.name === 'NotAllowedError' || err.name === 'SecurityError'
)
}
/**
* Start the barcode scanner. Opens the camera stream, attaches it to
* `videoEl`, and runs a throttled decode loop. When a barcode is detected,
* calls `onDetect(barcode)`.
*
* @param {HTMLVideoElement} videoEl — an <video> element already in the DOM.
* @param {object} callbacks
* @param {(barcode: string) => void} callbacks.onDetect
* @param {(error: Error) => void} callbacks.onError
* @returns {{ stop: () => void }} A handle with a `stop()` method.
*/
export function startScanner(videoEl, { onDetect, onError }) {
// ── State that stop() tears down ──────────────────────────────────────
let stream = null
let timer = null
let stopped = false
let canvas = null
let ctx = null
// Lazy-loaded zxing-wasm function (null until first use)
let zxingReader = null
// ── Canvas for frame extraction ──────────────────────────────────────
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d', { willReadFrequently: true })
// ── BarcodeDetector instance (native or null) ─────────────────────────
/** @type {BarcodeDetector | null} */
let detector = null
if (hasNativeBarcodeDetector()) {
try {
detector = new BarcodeDetector({ formats: BARCODE_FORMATS })
} catch {
// Some browsers may support the API but reject these formats;
// fall through to zxing-wasm.
detector = null
}
}
// ── Decode loop ───────────────────────────────────────────────────────
async function decodeFrame() {
if (stopped) return
try {
// Extract the current video frame into our canvas
const vw = videoEl.videoWidth
const vh = videoEl.videoHeight
if (vw === 0 || vh === 0) {
if (DEBUG) console.warn('[scanner] video dimensions are 0 — not playing yet?')
return
}
canvas.width = vw
canvas.height = vh
ctx.drawImage(videoEl, 0, 0, vw, vh)
if (DEBUG) console.log('[scanner] frame extracted', vw, '×', vh)
let barcode = null
if (detector) {
// Native BarcodeDetector path
try {
const detections = await detector.detect(canvas)
if (DEBUG) console.log('[scanner] BarcodeDetector returned', detections.length, 'results')
if (detections.length > 0 && !stopped) {
barcode = detections[0].rawValue
if (DEBUG) console.log('[scanner] 🎯 detected via BarcodeDetector:', barcode)
}
} catch (e) {
console.warn('[scanner] BarcodeDetector error on frame:', e)
}
} else {
// zxing-wasm fallback — load once
if (!zxingReader) {
try {
if (DEBUG) console.log('[scanner] loading zxing-wasm…')
const mod = await import('zxing-wasm')
// zxing-wasm v3 exports readBarcodesFromImageData
zxingReader = mod.readBarcodesFromImageData
if (DEBUG) console.log('[scanner] zxing-wasm loaded')
} catch (e) {
console.error('[scanner] failed to load zxing-wasm:', e)
return
}
}
try {
const imageData = ctx.getImageData(0, 0, vw, vh)
const results = await zxingReader(imageData, {
formats: BARCODE_FORMATS,
})
if (DEBUG) console.log('[scanner] zxing returned', results.length, 'results')
if (results.length > 0 && !stopped) {
barcode = results[0].text
if (DEBUG) console.log('[scanner] 🎯 detected via zxing:', barcode)
}
} catch (e) {
console.warn('[scanner] zxing decode error on frame:', e)
}
}
if (barcode && !stopped) {
onDetect(barcode)
}
} catch (e) {
console.warn('[scanner] frame extraction error:', e)
}
}
// ── Start camera ──────────────────────────────────────────────────────
;(async () => {
try {
// Request a minimum resolution for better barcode detection.
// Desktop webcams default to very low res (e.g. 320×240) which makes
// fine barcode lines hard to read. 640×480 is a good baseline.
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { min: 640, ideal: 1280 },
height: { min: 480, ideal: 720 },
},
audio: false,
})
if (stopped) {
// Component unmounted while we were waiting for permission
stream.getTracks().forEach(t => t.stop())
return
}
videoEl.srcObject = stream
await videoEl.play()
if (DEBUG) {
console.log('[scanner] camera started, dimensions:', videoEl.videoWidth, '×', videoEl.videoHeight)
console.log('[scanner] decoder:', detector ? 'native BarcodeDetector' : 'zxing-wasm')
}
// Start decode loop at ~4 fps
timer = setInterval(decodeFrame, DECODE_INTERVAL_MS)
} catch (err) {
console.error('[scanner] getUserMedia failed:', err)
if (!stopped) {
onError(err instanceof Error ? err : new Error(String(err)))
}
}
})()
// ── Stop handle ───────────────────────────────────────────────────────
function stop() {
stopped = true
if (timer !== null) {
clearInterval(timer)
timer = null
}
if (stream) {
stream.getTracks().forEach(t => t.stop())
stream = null
}
// Clear canvas reference to help GC
canvas = null
ctx = null
}
return { stop }
} }
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { hasNativeBarcodeDetector, isPermissionDeniedError } from './scanner.js'
describe('hasNativeBarcodeDetector', () => {
it('returns boolean', () => {
expect(typeof hasNativeBarcodeDetector()).toBe('boolean')
})
})
describe('isPermissionDeniedError', () => {
it('returns true for NotAllowedError', () => {
const err = new DOMException('Permission denied', 'NotAllowedError')
expect(isPermissionDeniedError(err)).toBe(true)
})
it('returns true for SecurityError', () => {
const err = new DOMException('The operation is insecure', 'SecurityError')
expect(isPermissionDeniedError(err)).toBe(true)
})
it('returns false for regular Error', () => {
expect(isPermissionDeniedError(new Error('something else'))).toBe(false)
})
it('returns false for non-DOMException', () => {
expect(isPermissionDeniedError({ name: 'NotAllowedError' })).toBe(false)
})
})
+26
View File
@@ -32,6 +32,16 @@ export function goNextDay() {
export const appView = $state({ current: 'dashboard' }) export const appView = $state({ current: 'dashboard' })
// ── Meal editor ───────────────────────────────────────────────────────────
/** Which meal food is open in the component editor (spec §4.7, TICKET-007). */
export const mealEdit = $state({ foodId: null })
export function openMealEditor(foodId) {
mealEdit.foodId = foodId
appView.current = 'editMeal'
}
/** Simple navigation requests from child components (e.g., "Set a target"). */ /** Simple navigation requests from child components (e.g., "Set a target"). */
export const navigateTo = (view) => { appView.current = view } export const navigateTo = (view) => { appView.current = view }
@@ -88,3 +98,19 @@ export async function removeLogEntryFromStore(id) {
dayData.log = dayData.log.filter(e => e.id !== id) dayData.log = dayData.log.filter(e => e.id !== id)
await refreshSummary() await refreshSummary()
} }
/**
* Replace entries from a "save as meal" operation:
* removes the source entry IDs and inserts the replacement meal entry.
*/
export async function replaceEntriesForMeal(entryIds, replacement) {
const ids = new Set(entryIds)
dayData.log = [...dayData.log.filter(e => !ids.has(e.id)), replacement]
await refreshSummary()
}
/** Replace entries from an "unpack" operation. */
export async function replaceEntryForUnpack(oldEntryId, newEntries) {
dayData.log = [...dayData.log.filter(e => e.id !== oldEntryId), ...newEntries]
await refreshSummary()
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
import basicSsl from '@vitejs/plugin-basic-ssl'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte(), basicSsl()],
server: { server: {
// Dev proxy so the frontend can call /api without CORS in production-style setups // Dev proxy so the frontend can call /api without CORS in production-style setups
proxy: { proxy: {