Files
Craig e047d884b6 Initial project scaffold: FastAPI backend + Svelte 5 frontend
Backend (uv): FastAPI app with routers (foods, log, targets, OFF proxy),
SQLAlchemy models, Pydantic schemas, migration runner + 0001 initial schema,
example pytest suite (7 tests).
Frontend (npm): Vite 7 + Svelte 5 runes, lib/ (api, stores, scanner, format)
per spec §7, placeholder components, example vitest suite (4 tests).
SPEC.md §1: registered uvicorn and Vitest (rule §8.3.3).
2026-07-26 10:26:08 +01:00

401 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CalCount — Specification & Decisions
A personal, self-hosted calorie counter web app with barcode scanning via phone camera and OpenFoodFacts lookup.
---
## 1. Tech Stack
| Layer | Decision | Version | Notes |
|-------|----------|---------|-------|
| Language | **Python** | ≥3.12 | |
| Backend framework | **FastAPI** | ≥0.115, <1.0 | Lightweight, async-native, excellent Pydantic integration |
| ASGI server | **uvicorn** | ≥0.30 | Dev via `uv run uvicorn main:app --reload` |
| Data validation | **Pydantic** | ≥2.7, <3 | Request/response schemas; defined separately from ORM models |
| Database | **SQLite** | Bundled with Python | Single-file, zero-config, perfect for single-user personal use |
| ORM | **SQLAlchemy** | ≥2.0, <2.1 | Plays well with Pydantic; define DB models + Pydantic schemas separately, FastAPI bridges them |
| HTTP client (OFF calls) | **httpx** | ≥0.27 | Used by the OFF proxy; mocked at this boundary in tests |
| Testing | **pytest** | ≥8 | See Testing Strategy (§8.4) |
| Package management | **uv** | latest | Fast Python package manager; `uv.lock` holds the exact pins |
| Frontend | **Svelte 5 (runes) + Vite** | Svelte ^5, Vite ^7, Node ≥22.12 | SPA talking to the FastAPI backend via `fetch()`. Component-based reactivity, but still a fully decoupled static frontend — the backend never renders HTML. `package-lock.json` holds the exact pins. Build plugin: `@sveltejs/vite-plugin-svelte` ^6. |
| Frontend test runner | **Vitest** | ^4 | Only for stores/format logic — no component tests in v1 (§8.4) |
| Barcode scanning | **Custom scanner component** | zxing-wasm ^3 | Native `BarcodeDetector` API where available (Chromium/Android), falling back to **zxing-wasm** (actively maintained ZXing-C++ WASM build) for Safari/Firefox. Camera via `getUserMedia`; decode loop throttled to ~3-5 fps. Avoids html5-qrcode, which is unmaintained. |
| Food data | **OpenFoodFacts API** proxied through backend | API v2 | `GET /api/off/product/{barcode}` and `GET /api/off/search?q=...`. All OFF calls go through the backend so there's one source of truth and caching is possible. |
| Auth | **None** | — | Personal single-user; no login, no sessions, no multi-tenancy |
| Deployment | **Self-hosted** | — | Exact method TBD (Docker, systemd, etc.) |
*Version numbers are floors/ceilings; exact pins live in `uv.lock` and `package-lock.json`. Update this table when bumping a major version or adding a dependency (rule §8.3.3).*
---
## 2. Data Model
### 2.1 `foods` table
The canonical food database. Grows over time as products are scanned/searched and meals are saved.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int (PK) | |
| `name` | text | Display name |
| `brand` | text | nullable |
| `barcode` | text | nullable, unique when set; the EAN-13/UPC from OFF |
| `source` | text | `"openfoodfacts"`, `"manual"`, or `"meal"` |
| `is_meal` | bool | `true` if this is a composed meal template |
| `unit_type` | text | `"weight"` (nutrition per 100g) or `"count"` (nutrition per 1 item). Default `"weight"`. |
| `calories_per_unit` | real | nullable. Mandatory for non-meal foods — per 100g for weight-type, per 1 item for count-type. Null for meals (nutrition derived from components). Invariant enforced with a CHECK constraint: `is_meal = 1 OR calories_per_unit IS NOT NULL`. |
| `protein_per_unit` | real | nullable |
| `carbs_per_unit` | real | nullable |
| `fat_per_unit` | real | nullable |
| `fiber_per_unit` | real | nullable |
| `saturated_fat_per_unit` | real | nullable |
| `sugars_per_unit` | real | nullable |
| `sodium_per_unit` | real | nullable |
| `serving_size_g` | real | Default serving size in grams. Nullable — null for count-type foods (e.g. "1 apple") where the concept of grams doesn't apply. |
| `serving_name` | text | Human label for the default serving, e.g. `"1 slice (28g)"`, `"1 apple"`, `"100g"`. Can be edited by user. |
| `off_data` | text | Full OpenFoodFacts product JSON blob, salted away for future use. Null for manual/meal foods. (Stored as TEXT — SQLite has no native JSON type.) |
| `deleted_at` | datetime | nullable; soft-delete timestamp. Foods with `deleted_at IS NOT NULL` are hidden from search/recent but still renderable in historical log entries. |
| `created_at` | datetime | |
| `updated_at` | datetime | |
**Key design choices:**
- **Nutrition stored as individual nullable columns** (not JSON). Only `calories_per_unit` is mandatory, and only for non-meal foods — meal nutrition is always derived from components (2.2), never stored. This gives type safety, SQL queryability, and clean SQLAlchemy/Pydantic mapping. Extra nutrients can be added later via migration without touching the others.
- **`quantity` — one field, interpreted by context.** There is no `quantity_grams` column anywhere. Instead, a single `quantity` field changes meaning based on what it's attached to:
- `unit_type = "weight"`: `quantity` is grams. Nutrition = `(quantity / 100) × nutrition_per_unit`.
- `unit_type = "count"`: `quantity` is an item count. Nutrition = `quantity × nutrition_per_unit`.
- Meal (logged via daily_log): `quantity` is a scaling factor (1.0 = one full meal, 1.5 = 1.5×).
- This avoids misleading column names that lie for count-type foods or meal scaling. The nutrition service is the single place that resolves context.
- **`unit_type` determines the nutrition base and quantity interpretation** (see above). Two values are sufficient for v1:
- `"weight"`: Default. Covers most OFF products, manual entries, and **liquids** (ml ≈ g; error is negligible for beverages and small oil quantities — measure by weight if precision matters).
- `"count"`: For foods where per-item nutrition is the natural unit (eggs, apples, protein bars).
- The "both per-100g and per-item" case (e.g. a chocolate bar) is handled by the **serving mechanism**, not a third `unit_type`. The food is weight-type with `serving_size_g = 55`, `serving_name = "1 bar (55g)"`. The UI presents "1 bar" as the default serving but the user can always enter raw grams. No third unit type needed.
- **`is_meal` flag** distinguishes atomic foods from composed meals. Both live in the same table so they're equally searchable.
- **Meal nutrition is derived** from its components (not stored redundantly — see 2.2). Editing a component food retroactively changes all historical meals referencing it. Acceptable tradeoff for a personal app (fix data once).
- **`serving_size_g` and `serving_name`** are editable per food. The UI presents the serving as the default logging amount, but the user can always override the quantity.
- **Soft-delete**: Foods are never hard-deleted. `DELETE /api/foods/{id}` sets `deleted_at`. Deleted foods are excluded from search/recent but remain available for rendering historical log entries that reference them.
### 2.2 `meal_components` table
Defines what goes into a meal. Only rows where `foods.is_meal = true` have entries here.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int (PK) | |
| `meal_id` | int (FK → foods.id) | The meal this component belongs to |
| `food_id` | int (FK → foods.id) | The component food (can itself be a meal — nesting allowed) |
| `quantity` | real | Amount of this component. Interpreted per the component food's `unit_type` (grams for weight-type, count for count-type). |
**Deriving meal nutrition:** When a meal is queried, compute nutrition by summing each component's nutrition scaled to `quantity`, then normalize back to per-unit and total serving. This is computed, not stored — so if a component food's nutrition is updated, the meal automatically reflects the change.
**Nesting resolution:** Meals can contain other meals. Resolve recursively at query time to avoid cycles. Insertion should check for cycles.
### 2.3 `daily_log` table
Each row is a food/meal entry logged on a specific date with a specific quantity.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int (PK) | |
| `date` | date | The day this entry belongs to |
| `food_id` | int (FK → foods.id) | The food or meal logged |
| `quantity` | real | How much was consumed. Interpreted per the food's `unit_type`: grams for weight-type, item count for count-type, scaling factor for meals (1.0 = one full meal). |
| `meal_slot` | text | nullable; `"breakfast"`, `"lunch"`, `"dinner"`, `"snack"` — loosely coupled, used for grouping in UI |
| `sort_order` | int | Position within the day/meal-slot for display ordering |
| `created_at` | datetime | |
**Notes:**
- No separate "meal log" table. When a meal is logged, the entry references the meal food. The UI can optionally "explode" it to show components, or keep it collapsed. For target math: always sum the exploded nutrition.
- `meal_slot` is advisory — changing or removing it later should be trivial.
### 2.4 `targets` table
User's nutritional targets, which can change over time.
| Column | Type | Notes |
|--------|------|-------|
| `id` | int (PK) | |
| `start_date` | date | When this target became active |
| `end_date` | date | nullable; null = currently active |
| `calories` | int | Daily calorie target |
| `protein_g` | real | nullable |
| `carbs_g` | real | nullable |
| `fat_g` | real | nullable |
**Notes:**
- Only one row has `end_date IS NULL` at a time (the current target). Enforced in application logic.
- Historical views use the target whose date range contains the log date.
- Additional macro fields (fiber, saturated fat, etc.) can be added later as optional columns.
---
## 3. API Design Sketch
All endpoints return JSON. No HTML rendering from the backend.
### 3.1 Foods
```
GET /api/foods/recent — most recently logged foods (query param `limit`, default 10). Returns foods ordered by most recent appearance in daily_log, deduplicated.
GET /api/foods — search foods (query params: `q` for name+brand; `barcode=` for exact lookup; `limit`/`offset` for pagination, default 50/0)
GET /api/foods/{id} — get single food with computed nutrition (for meals: includes nested components)
POST /api/foods — create manual food
PUT /api/foods/{id} — update food (name, serving, nutrition, etc.)
DELETE /api/foods/{id} — soft-delete (sets `deleted_at`; food hidden from search/recent but preserved for historical logs)
POST /api/foods/{id}/restore — clear `deleted_at` (used by the food management view; `GET /api/foods` takes `include_deleted=true` to list deleted foods)
```
**Note:** scanning a barcode that belongs to a soft-deleted food **restores** that food (clears `deleted_at`) rather than creating a duplicate — barcode uniqueness makes a fresh insert fail otherwise, and history stays attached to one row.
### 3.2 Meals
```
POST /api/meals/from-log — create a meal from selected daily_log entries
Body: { name, entry_ids: [...], date }
Runs in a transaction: creates the meal food + components, deletes the source log entries,
inserts a single replacement log entry. Rolls back entirely on any failure.
POST /api/meals/{meal_id}/unpack — on a given date, replace a logged meal entry with its component entries
Body: { date }
Runs in a transaction: deletes the meal log entry, inserts individual log entries for each
component (resolved recursively if nested). Each new entry's quantity = component quantity
× the original entry's quantity (so unpacking a 1.5× meal yields 1.5× each component).
Rolls back entirely on any failure.
PUT /api/meals/{meal_id}/components — replace a meal's component list (for editing recipes)
Body: { components: [{ food_id, quantity }, ...] } — full replacement, cycle-checked.
```
### 3.3 Daily Log
```
GET /api/log?date=YYYY-MM-DD — get all entries for a date.
Response includes meals with their components nested, so the
frontend can render a collapsible row (collapsed by default,
showing the meal name + total calories; expand to see
individual components with their quantities).
POST /api/log — add an entry: { food_id, quantity, meal_slot?, date }
PUT /api/log/{id} — update quantity, meal_slot, etc.
DELETE /api/log/{id} — remove an entry
GET /api/log/summary?date=YYYY-MM-DD — computed totals for the day vs. target
```
### 3.4 Targets
```
GET /api/targets — list all targets (history)
GET /api/targets/current — current active target
POST /api/targets — create a new target (auto-closes previous)
PUT /api/targets/{id} — update a target
```
### 3.5 OpenFoodFacts proxy
All OpenFoodFacts requests go through the backend. The frontend never calls OFF directly.
```
GET /api/off/search?q=... — proxy search to OFF
GET /api/off/product/{barcode} — proxy product lookup by barcode
POST /api/off/refresh/{food_id} — re-fetch a food's data from OFF (by its stored barcode) and update the local food row
```
OFF data is returned to the frontend as a normalized JSON object matching our `foods` schema (nutrition fields mapped to our column names). No local DB write happens at this stage — the frontend receives the data, the user can edit it, and only on confirm does `POST /api/foods` save it.
---
## 4. User Flows
### 4.1 Scan barcode
1. User taps "Scan" button
2. Camera opens (html5-qrcode)
3. **If camera permission denied:** show clear message — "Camera access needed for barcode scanning. Check your browser settings." — alongside a manual barcode text input as fallback.
4. User points at barcode → barcode captured
5. Backend called: `GET /api/foods?barcode=...` first checks local DB
6. If found locally → return food with serving info. User can optionally tap "Refresh from OpenFoodFacts" to pull latest data from OFF (useful if product formulation changed). User edits quantity → confirm → logged.
7. If not found locally → `GET /api/off/product/{barcode}` → return OFF data pre-filled into editable form
8. If OFF not found → tell user "not found", offer manual entry or free-text search
9. User confirms → food saved to local DB (on confirm, not on scan) + logged to today
**Manual barcode fallback:** If the camera is unavailable (denied, unsupported, or user just prefers typing), a small text input accepts a barcode number. On submit, the flow jumps to step 5 above. This is a secondary path — the primary flow is camera scanning.
### 4.2 Free-text search
1. User types in search bar
2. GET `/api/foods?q=...` — searches local DB name+brand
3. If no results, optionally fall back to `/api/off/search?q=...`
4. Results shown as list; user selects one
5. User edits serving size/quantity → confirm → logged to today
### 4.3 Create a meal from today's entries
1. User views today's log
2. Selects multiple entries (checkboxes)
3. Taps "Save as Meal"
4. Prompted for a name (no auto-suggest)
5. System: creates new `foods` row with `is_meal=true` and `meal_components` rows from selected entries
6. System: replaces selected log entries with a single entry referencing the new meal
7. Future: the meal appears in food search results
### 4.4 Unpack a meal
1. User views today's log, sees a meal entry
2. Taps "Unpack" on that entry
3. System: deletes the meal log entry, inserts individual entries for each component (resolved recursively if nested), with each component's quantity multiplied by the original entry's scaling factor
4. User can now tweak individual amounts
### 4.5 Manual food creation
1. User taps "Add Food" (without scanning)
2. Fills in: name, brand (optional), calories per unit (per 100g or per item depending on unit_type), serving info, optional macros
3. Saved to local DB
4. Immediately available for logging
### 4.7 Manage food library
1. User taps "Foods" nav item
2. Browsable, searchable, paginated list of all local foods (uses `GET /api/foods`)
3. Each row: name, brand, calories, edit and delete buttons
4. Edit opens the same food editor used in scan/search flows; meals additionally expose component editing (add/remove/change quantities)
5. Toggle shows soft-deleted foods; deleted rows can be restored
### 4.6 Dashboard / daily view
1. User lands on today's log
2. Progress bar at top: current calories / target calories, with remaining
3. Below: entries grouped loosely by meal_slot
4. Each entry shows: food name, quantity, calories, mini edit/delete buttons
5. "Scan" and "Search" buttons prominent
---
## 5. Open Decisions / Deferred
| Item | Status | Notes |
|------|--------|-------|
| Frontend framework | **Decided** | Svelte (Vite + Svelte) from the start. Vanilla JS would become unwieldy with collapsible meal rows, scanner integration, and multi-view state. |
| Exact deployment method | **Deferred** | Docker vs bare systemd vs something else. Decide when the app runs locally. |
| OFF search fallback | **Confirmed** | All OFF requests proxied through backend. Frontend never calls OFF directly. |
| OFF data refresh | **Confirmed** | When viewing a food that came from OFF, a "Refresh from OpenFoodFacts" button re-fetches the product data and updates the local food. |
| Camera permission handling | **Confirmed** | Permission denied → clear message + manual barcode text input as fallback (section 4.1). |
| HTTPS for camera | **Confirmed** | `getUserMedia` requires a secure context — plain LAN HTTP won't open the camera. Handled via existing Caddy reverse proxy on the homeserver (TLS). Must be in place before scanner testing on a phone. |
| Fiber/saturated fat/sugar/sodium in UI | **Deferred** | Columns exist in the `foods` table. UI shows only PCF+calories initially. Add to UI later. |
| Soft-delete vs hard-delete for foods | **Decided** | Soft-delete. `deleted_at` column added; deleted foods hidden from search/recent but preserved for historical log rendering. |
| Import/export | **Deferred** | SQLite file is fine for backup. A CSV export button could be added later. |
| Body weight tracking | **Not planned** | Out of scope for v1. |
| Nutrition derivation: live vs snapshotted | **Decided** | Live derivation. Editing a food retroactively changes historical meal entries. Acceptable tradeoff for a personal app. |
---
## 6. Non-Goals (v1)
- User authentication / multi-user
- Body weight / measurement tracking
- Barcode scanning from desktop (phone only)
- Native app / PWA (though PWA manifest could be added cheaply later for "add to home screen")
- Social features, sharing
- AI-powered food recognition from photos
- Nutrition goals per meal-slot (only daily totals)
- Complex unit conversion (stick to grams + per-item counting)
---
## 7. Project Structure (Proposed)
```
calcount/
├── backend/
│ ├── main.py # FastAPI app entry
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic request/response schemas
│ ├── database.py # DB connection, session management
│ ├── routers/
│ │ ├── foods.py
│ │ ├── log.py
│ │ ├── targets.py
│ │ └── off.py # OpenFoodFacts proxy
│ ├── services/ # Business logic (meal resolution, nutrition math)
│ │ ├── nutrition.py
│ │ └── meals.py
│ └── pyproject.toml # uv project config
├── frontend/
│ ├── index.html # Vite entry point
│ ├── package.json
│ ├── vite.config.js
│ ├── src/
│ │ ├── main.js # Svelte mount point
│ │ ├── App.svelte # Root component, routing
│ │ ├── lib/
│ │ │ ├── api.js # fetch() wrappers
│ │ │ ├── scanner.js # camera + BarcodeDetector / zxing-wasm fallback
│ │ │ └── stores.js # Svelte stores (shared state)
│ │ ├── components/
│ │ │ ├── Dashboard.svelte
│ │ │ ├── FoodSearch.svelte
│ │ │ ├── FoodEditor.svelte
│ │ │ ├── FoodLibrary.svelte
│ │ │ ├── BarcodeScanner.svelte
│ │ │ ├── MealBuilder.svelte
│ │ │ ├── LogEntry.svelte
│ │ │ └── ProgressBar.svelte
│ │ └── assets/
│ └── public/
└── SPEC.md # This file
```
---
## 8. Development Best Practices
Rules of the road for anyone (human or AI) working on this codebase. Each rule exists because violating it is easy and painful. Follow them by default; when a rule is deliberately changed, update this section and the changelog.
### 8.1 Backend
1. **Nutrition math lives in exactly one place: `services/nutrition.py`.** Routers never compute nutrition; the frontend never re-derives it. This is the rule most likely to erode — guard it.
2. **Routers are thin.** Parse/validate via Pydantic → call a service → return a schema. No business logic, no SQL in routers. A handler growing past ~15 lines is the smell.
3. **ORM objects never leave the service layer.** Services convert SQLAlchemy models → Pydantic schemas (`from_attributes=True`) before returning. Prevents lazy-loading surprises and leaky abstractions.
4. **Sync `def` endpoints, not `async def`.** FastAPI runs sync handlers in a threadpool — the sane way to use SQLite + SQLAlchemy. No async DB layer at this scale.
5. **One DB session per request, via `Depends(get_db)`.** No global sessions, no ad-hoc sessions inside services.
6. **Multi-write operations are transactions owned by the service.** `from-log` and `unpack` commit once at the end or roll back entirely — never partial writes.
7. **Soft-delete discipline.** Every search/recent query filters `deleted_at IS NULL`; historical log rendering never does. Implement as shared query helpers, not per-endpoint filter clauses — this gets copy-pasted wrong otherwise. Related rule: **scanning a soft-deleted food's barcode restores it** (clears `deleted_at`) instead of inserting a duplicate (§3.1).
8. **All datetimes UTC; all dates are client-supplied `YYYY-MM-DD`.** The server never decides what "today" is.
9. **Migrations from day one.** Numbered SQL files in `backend/migrations/`, applied in order at startup, tracked in a `schema_migrations` table. Never hand-edit the database; every schema change is a new numbered file — including the initial schema.
10. **OFF normalization happens in exactly one module.** A descriptive `User-Agent` header on every OFF call (their API etiquette requires it), timeouts set, and the kcal-vs-kJ field mapping handled there and nowhere else.
11. **Pydantic validates at the boundary.** Quantities positive; `unit_type`/`source`/`meal_slot` as `Literal` enums; dates parsed. Bad data never reaches a service.
### 8.2 Frontend
1. **All HTTP goes through `lib/api.js`.** No raw `fetch()` in components. One place for base URL, error handling, and any future cross-cutting concern.
2. **The server is the source of truth for nutrition.** The frontend renders computed values from API responses; it never reimplements meal explosion or unit-type resolution. Sole exception: simple linear scaling (`quantity × per_unit`) for a live preview while editing a quantity — display only, never persisted.
3. **Shared state lives in stores, not prop-drilling.** Current date, today's log, and current target are stores. Mutation flow: component → `api.js` → update store from the response.
4. **Every async view handles loading / error / empty states.** Non-negotiable for the scanner (permission denied, no camera, decode failure) — it's the primary flow.
5. **Mobile-first, tested on a real phone.** Touch targets, one-hand use, exercised over the LAN via Caddy HTTPS — not just desktop devtools' device emulator.
6. **Scanner lifecycle discipline.** Stop the camera stream and decode loop on component destroy. A leaked stream drains battery and breaks re-entry to the scanner view.
7. **Formatting in one place** (`lib/format.js`): kcal rounding (whole numbers displayed, full precision stored), grams, dates. No scattered `Math.round` call sites.
8. **Svelte 5 runes style only.** No legacy `$:` reactive statements in new code — one reactivity paradigm.
### 8.3 Cross-cutting
1. **`schemas.py` is the contract.** When an endpoint shape changes, the Pydantic schema changes first, then both sides. The shapes in `api.js` mirror it.
2. **SPEC.md stays current.** Any decision that changes gets edited here with the changelog updated. The spec is a living document, not a historical artifact.
3. **New dependencies require a spec entry.** §1 is the dependency registry — nothing enters `pyproject.toml`/`package.json` without a row there. This is how library decisions stay deliberate.
4. **Dates flow as `YYYY-MM-DD` strings end-to-end.** No `Date` objects crossing the API boundary, no timezone math anywhere.
### 8.4 Testing Strategy
**Backend (pytest, `backend/tests/`):**
- Focus on the code where correctness actually matters:
- `services/nutrition.py` — unit_type resolution, meal recursion, cycle detection, unpack quantity scaling
- The two transactional endpoints (`from-log`, `unpack`) — including rollback on failure
- Soft-delete query helpers — deleted foods hidden from search/recent, visible in history
- FastAPI `TestClient` with a fresh temp-file SQLite DB per test session; seed data via fixtures.
- OFF proxy tests mock at the httpx boundary — never hit the real OFF API in tests.
- **No coverage targets.** Test the math and the money paths; skip CRUD trivia.
- `pytest` must be green before committing backend changes.
**Frontend:**
- v1 has **no component test suite** — it rots fast in a solo project, and the scanner can't be meaningfully unit-tested anyway.
- Vitest only if a store or `format.js` grows real logic worth pinning down.
- Scanner and camera flows are verified **manually on a real phone** (requires Caddy HTTPS in place). Checklist:
- [ ] Permission granted → scan works
- [ ] Permission denied → message + manual barcode fallback (flow 4.1)
- [ ] Android Chrome (native BarcodeDetector path)
- [ ] iOS Safari (zxing-wasm fallback path)
- [ ] EAN-13 and UPC-A barcodes both decode
**Definition of done:** backend changes = `pytest` green; scanner/log changes = `pytest` green + real-phone checklist pass.
---
*Last updated: 2026-07-26 — Revision #2: nullable `calories_per_unit` for meals (derived nutrition), unpack quantity scaling, food management view + restore endpoint, meal component editing. Revision #3: dev best practices + testing strategy (§8), version constraints (§1), restore-on-rescan rule (§3.1). Revision dates corrected to 2026. Revision #4: initial project scaffold; uvicorn + Vitest registered in §1.*