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).
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user