Files
calcount/SPEC.md
T
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

26 KiB
Raw Blame History

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.

  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.