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
+39
View File
@@ -0,0 +1,39 @@
<script>
import { onMount } from 'svelte'
import { api } from './lib/api.js'
import { currentDate } from './lib/stores.js'
import Dashboard from './components/Dashboard.svelte'
// Every async view handles loading / error / empty states (spec §8.2 rule 4)
let backend = $state({ loading: true, ok: false, error: null })
onMount(async () => {
try {
await api.health()
backend = { loading: false, ok: true, error: null }
} catch (e) {
backend = { loading: false, ok: false, error: e.message }
}
})
</script>
<main>
<h1>CalCount</h1>
{#if backend.loading}
<p>Connecting to backend…</p>
{:else if backend.error}
<p role="alert">Backend unreachable: {backend.error}</p>
{:else}
<Dashboard date={currentDate.value} />
{/if}
</main>
<style>
main {
max-width: 32rem;
margin: 0 auto;
padding: 1rem;
font-family: system-ui, sans-serif;
}
</style>
@@ -0,0 +1,5 @@
<script>
// BarcodeScanner — Camera scanner with permission-denied fallback to manual input (spec §4.1). Uses lib/scanner.js; stop the stream on destroy (spec §8.2 rule 6).
</script>
<p>BarcodeScanner (placeholder)</p>
+34
View File
@@ -0,0 +1,34 @@
<script>
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6)
import { api } from '../lib/api.js'
import { formatDate } from '../lib/format.js'
let { date } = $props()
let entries = $state(null) // null = loading
let error = $state(null)
$effect(() => {
entries = null
error = null
api.getLog(date)
.then((data) => (entries = data))
.catch((e) => (error = e.message))
})
</script>
<h2>{formatDate(date)}</h2>
{#if error}
<p role="alert">Failed to load log: {error}</p>
{:else if entries === null}
<p>Loading…</p>
{:else if entries.length === 0}
<p>Nothing logged yet today.</p>
{:else}
<ul>
{#each entries as entry (entry.id)}
<li>Food #{entry.food_id} × {entry.quantity}</li>
{/each}
</ul>
{/if}
@@ -0,0 +1,5 @@
<script>
// FoodEditor — Food create/edit form, shared by scan/search/library flows (spec §4.5, §4.7)
</script>
<p>FoodEditor (placeholder)</p>
@@ -0,0 +1,5 @@
<script>
// FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7)
</script>
<p>FoodLibrary (placeholder)</p>
@@ -0,0 +1,5 @@
<script>
// FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2)
</script>
<p>FoodSearch (placeholder)</p>
+5
View File
@@ -0,0 +1,5 @@
<script>
// LogEntry — One log row: name, quantity, kcal, edit/delete; meals render collapsible (spec §3.3)
</script>
<p>LogEntry (placeholder)</p>
@@ -0,0 +1,5 @@
<script>
// MealBuilder — Create a meal from selected log entries (spec §4.3)
</script>
<p>MealBuilder (placeholder)</p>
@@ -0,0 +1,5 @@
<script>
// ProgressBar — Calories vs target with remaining (spec §4.6). Renders server-computed values only (spec §8.2 rule 2).
</script>
<p>ProgressBar (placeholder)</p>
+36
View File
@@ -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)}`),
}
+24
View File
@@ -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',
})
}
+28
View File
@@ -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/)
})
})
+31
View File
@@ -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')
}
+14
View File
@@ -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 })
+8
View File
@@ -0,0 +1,8 @@
import { mount } from 'svelte'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app'),
})
export default app