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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+43
View File
@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CalCount</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}
+2083
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.56.4",
"vite": "^7.3.6",
"vitest": "^4.1.10"
},
"dependencies": {
"zxing-wasm": "^3.1.2"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+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
+2
View File
@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
// Dev proxy so the frontend can call /api without CORS in production-style setups
proxy: {
'/api': 'http://localhost:8000',
},
},
test: {
// Vitest (spec §8.4): only for stores/format logic, not components
include: ['src/**/*.test.js'],
},
})