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:
Craig
2026-07-26 10:25:59 +01:00
commit e047d884b6
47 changed files with 3994 additions and 0 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
"""Foods router (spec §3.1). Thin: validate → service → schema (spec §8.1 rule 2)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import Food
from schemas import FoodRead
router = APIRouter(prefix="/api/foods", tags=["foods"])
@router.get("", response_model=list[FoodRead])
def list_foods(
q: str | None = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
db: Session = Depends(get_db),
):
"""Search local foods. Soft-deleted foods are hidden (spec §8.1 rule 7)."""
stmt = select(Food).where(Food.deleted_at.is_(None)).limit(limit).offset(offset)
if q:
stmt = stmt.where(Food.name.contains(q) | Food.brand.contains(q))
return db.scalars(stmt).all()
+24
View File
@@ -0,0 +1,24 @@
"""Daily log router (spec §3.3)."""
from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import DailyLogEntry
from schemas import LogEntryRead
router = APIRouter(prefix="/api/log", tags=["log"])
@router.get("", response_model=list[LogEntryRead])
def get_log(date: date, db: Session = Depends(get_db)):
"""All entries for a client-supplied date (spec §8.1 rule 8)."""
stmt = (
select(DailyLogEntry)
.where(DailyLogEntry.date == date)
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
)
return db.scalars(stmt).all()
+30
View File
@@ -0,0 +1,30 @@
"""OpenFoodFacts proxy (spec §3.5). All OFF calls go through this router.
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
boundary — never hit the real OFF API (spec §8.4).
"""
import httpx
from fastapi import APIRouter
OFF_BASE_URL = "https://world.openfoodfacts.org"
OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)"
OFF_TIMEOUT = 10.0
router = APIRouter(prefix="/api/off", tags=["off"])
@router.get("/product/{barcode}")
def get_product(barcode: str):
"""Proxy a product lookup by barcode. Returns raw OFF JSON for now.
TODO: normalize to our foods schema (spec §3.5) in one shared module.
"""
resp = httpx.get(
f"{OFF_BASE_URL}/api/v2/product/{barcode}",
headers={"User-Agent": OFF_USER_AGENT},
timeout=OFF_TIMEOUT,
)
resp.raise_for_status()
return resp.json()
+24
View File
@@ -0,0 +1,24 @@
"""Targets router (spec §3.4)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import Target
from schemas import TargetRead
router = APIRouter(prefix="/api/targets", tags=["targets"])
@router.get("", response_model=list[TargetRead])
def list_targets(db: Session = Depends(get_db)):
return db.scalars(select(Target).order_by(Target.start_date)).all()
@router.get("/current", response_model=TargetRead)
def current_target(db: Session = Depends(get_db)):
target = db.scalar(select(Target).where(Target.end_date.is_(None)))
if target is None:
raise HTTPException(status_code=404, detail="No active target")
return target