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,36 @@
|
||||
/**
|
||||
* All HTTP goes through this module (spec §8.2 rule 1).
|
||||
* No raw fetch() in components. Shapes mirror backend schemas.py (spec §8.3 rule 1).
|
||||
*/
|
||||
|
||||
const BASE_URL = '' // same origin; Vite dev server proxies /api to the backend
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const resp = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`API ${options.method ?? 'GET'} ${path} failed: ${resp.status}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request('/api/health'),
|
||||
// Foods (spec §3.1)
|
||||
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
|
||||
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
||||
getFood: (id) => request(`/api/foods/${id}`),
|
||||
createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }),
|
||||
// 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}`),
|
||||
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
|
||||
deleteLogEntry: (id) => request(`/api/log/${id}`, { method: 'DELETE' }),
|
||||
getSummary: (date) => request(`/api/log/summary?date=${date}`),
|
||||
// Targets (spec §3.4)
|
||||
getCurrentTarget: () => request('/api/targets/current'),
|
||||
// OFF proxy (spec §3.5) — the frontend never calls OFF directly
|
||||
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
||||
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Formatting in one place (spec §8.2 rule 7). No scattered Math.round call sites.
|
||||
* Kcal: whole numbers displayed, full precision stored.
|
||||
*/
|
||||
|
||||
export function formatKcal(value) {
|
||||
if (value == null) return '—'
|
||||
return `${Math.round(value)} kcal`
|
||||
}
|
||||
|
||||
export function formatGrams(value) {
|
||||
if (value == null) return '—'
|
||||
return `${Math.round(value)}g`
|
||||
}
|
||||
|
||||
/** Dates flow as YYYY-MM-DD strings end-to-end (spec §8.3 rule 4). */
|
||||
export function formatDate(yyyyMmDd) {
|
||||
const [y, m, d] = yyyyMmDd.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Example test suite — scaffold for future logic tests (spec §8.4:
|
||||
* Vitest only for stores/format logic, no component tests in v1).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatKcal, formatGrams, formatDate } from './format.js'
|
||||
|
||||
describe('formatKcal', () => {
|
||||
it('rounds to whole numbers', () => {
|
||||
expect(formatKcal(249.6)).toBe('250 kcal')
|
||||
})
|
||||
|
||||
it('handles null', () => {
|
||||
expect(formatKcal(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGrams', () => {
|
||||
it('formats grams', () => {
|
||||
expect(formatGrams(55.4)).toBe('55g')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a YYYY-MM-DD string without timezone math', () => {
|
||||
expect(formatDate('2026-07-25')).toMatch(/Jul 25/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Barcode scanning (spec §1, §4.1): native BarcodeDetector where available
|
||||
* (Chromium/Android), falling back to zxing-wasm for Safari/Firefox.
|
||||
* Camera via getUserMedia; decode loop throttled to ~3-5 fps.
|
||||
*
|
||||
* Scanner lifecycle discipline (spec §8.2 rule 6): stop the camera stream
|
||||
* and decode loop on component destroy.
|
||||
*
|
||||
* NOTE: getUserMedia requires a secure context — HTTPS via the Caddy
|
||||
* reverse proxy must be in place before phone testing (spec §5).
|
||||
*/
|
||||
|
||||
// zxing-wasm is the fallback decoder; imported lazily so Chromium users
|
||||
// on the native path never pay the WASM download cost.
|
||||
// import { readBarcodes } from 'zxing-wasm/reader'
|
||||
|
||||
export function hasNativeBarcodeDetector() {
|
||||
return typeof globalThis.BarcodeDetector !== 'undefined'
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement startScanner(videoEl, { onDetect }) → stop() handle.
|
||||
* - getUserMedia({ video: { facingMode: 'environment' } })
|
||||
* - native BarcodeDetector if hasNativeBarcodeDetector(), else zxing-wasm
|
||||
* - 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() {
|
||||
throw new Error('scanner not implemented yet — see spec §4.1')
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared state lives in stores, not prop-drilling (spec §8.2 rule 3).
|
||||
* Mutation flow: component → api.js → update store from the response.
|
||||
* Svelte 5 runes style only (spec §8.2 rule 8).
|
||||
*/
|
||||
|
||||
// Current date as a YYYY-MM-DD string — the server never decides "today" (spec §8.1 rule 8)
|
||||
export const currentDate = $state({
|
||||
value: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
// Today's log entries and current target, refreshed from the API after any mutation
|
||||
export const todayLog = $state({ entries: [], loading: false, error: null })
|
||||
export const currentTarget = $state({ target: null, loading: false, error: null })
|
||||
Reference in New Issue
Block a user