TICKET-006 (frontend): barcode scanner, OFF scan/search flows, recent foods

- lib/scanner.js: native BarcodeDetector + lazy zxing-wasm fallback, ~4fps
  decode loop, camera teardown in stop() (§8.2 rule 6)
- BarcodeScanner.svelte: getUserMedia camera, permission-denied → message +
  manual barcode text input fallback, stop on first detect + on destroy
- App.svelte: §4.1 scan-flow state machine (localFound/offFound/notFound/
  error) — local-by-barcode first, OFF lookup → pre-fill FoodEditor → save
  → log, Refresh-from-OFF button, not-found → manual/search options
- FoodEditor.svelte: optional food prop pre-fills from OFF (read-only
  barcode, source badge), post-save log flow
- FoodSearch.svelte: OFF fallback (§4.2) when local results empty
- Dashboard.svelte: recent foods quick-log chips
- api.js: searchFoodsByBarcode, offRefresh, updateFood, recentFoods
- scanner.test.js: 2 unit tests for pure helpers
- §8.4 phone checklist deferred to user testing over Caddy HTTPS;
  manual-barcode + OFF search flows playwright-verified
- HANDOFF.md updated: M1+M2 complete, 007 next
This commit is contained in:
Craig
2026-07-26 16:19:26 +01:00
parent 32461b7405
commit 4dd44b08d0
9 changed files with 1300 additions and 159 deletions
+79 -37
View File
@@ -5,9 +5,11 @@ first; this file only records progress and session-specific notes.
## Where we are in the plan ## Where we are in the plan
**Milestone M1 (Manual calorie tracker) is COMPLETE.** Tickets 001005 all **Milestones M1 (Manual calorie tracker) and M2 (Barcode scanning & OFF
implemented, QA-verified, and committed. The app is usable end-to-end for integration) are COMPLETE.** Tickets 001006 all implemented, QA-verified,
manual food entry and daily logging. and committed. The app is usable end-to-end for manual food entry, daily
logging, barcode scanning (manual-barcode path verified; phone camera path
pending user testing), and OpenFoodFacts lookup/search.
| Ticket | Status | Commit | | Ticket | Status | Commit |
|--------|--------|--------| |--------|--------|--------|
@@ -16,12 +18,12 @@ manual food entry and daily logging.
| 003 Daily log write path | ✅ done, QA passed | `87d7eca` | | 003 Daily log write path | ✅ done, QA passed | `87d7eca` |
| 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` | | 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` |
| 005 Frontend daily view | ✅ done, QA passed | `b69661c` | | 005 Frontend daily view | ✅ done, QA passed | `b69661c` |
| 006 OFF + barcode scan/search flows | **next** | — | | 006 OFF + barcode scan/search flows | ✅ done, QA passed (backend `32461b7`, frontend below) | `32461b7` + frontend |
| 007 Meals (from-log, unpack, recursion) | ⬜ pending | — | | 007 Meals (from-log, unpack, recursion) | ⬜ **next** | — |
| 008 Food library view + restore | ⬜ pending | — | | 008 Food library view + restore | ⬜ pending | — |
Test counts at HEAD: backend **104 passed** (`cd backend && uv run pytest`), Test counts at HEAD: backend **152 passed** (`cd backend && uv run pytest`),
frontend vitest + `npm run build` green. frontend **18 vitest passed** + `npm run build` green.
## What was done this session ## What was done this session
@@ -42,40 +44,80 @@ frontend vitest + `npm run build` green.
layout. All HTTP via `lib/api.js`; shared state in `stores.svelte.js`; layout. All HTTP via `lib/api.js`; shared state in `stores.svelte.js`;
Svelte 5 runes only. Svelte 5 runes only.
## Process lessons (important for the next orchestrator) ## Process notes (session 2)
1. **Implementer agents can falsely report success without writing code.** - Baked the retrospective's structural fixes into the agent definitions
This happened twice (TICKET-004 backend, first TICKET-005 frontend attempt). (chore `f8048da`): be/fe-implementer now MUST paste `git status`, test
Mitigations that worked: output, and a live smoke test; qa is adversarial (distrust self-reports,
- Every implementer task must require `git status --short` evidence and a confirm features via `/openapi.json`), restarts both servers + resets the
live smoke test (curl the endpoint / build + serve) in its report. dev DB before testing, and writes expected numbers into scenarios. This
- QA must be explicitly told not to trust the implementer's report and to eliminated the false-success-report failure mode on 006.
verify the feature exists (e.g. check `/openapi.json` routes) before - `.playwright-cli/` + `*.png` are now gitignored; QA writes artifacts to
testing. `/tmp`.
2. QA (playwright) genuinely catches real bugs — it found broken date-nav - The scout agent earned its keep on 006: primed exact seams for both
buttons and a stale-summary bug in TICKET-005; both were fixed and stacks, saving re-derivation. Its one slip (recent-foods ordering by
re-verified before commit. `created_at` vs `daily_log` appearance) was caught and corrected in the
3. Backend servers go stale between tickets (old uvicorn missing new routes). implementer prompt.
QA handles restarts, but expect it. - Split 006 into backend → QA(curl) → commit, then frontend → QA(playwright)
→ commit, with an orchestrator diff review before each QA run. Two commits
for one ticket (across stacks) gave clean checkpoints.
- Bug-fix loop on 006 backend (OFF search 500 on upstream 503) was cheap and
effective — one focused implementer call + verify.
## Where to pick up: TICKET-006 (Milestone M2) ## Where to pick up: TICKET-007 (Milestone M3 — Meals)
OFF normalization + barcode scan & search flows. Depends on 005 (done). Meals: `meal_components` table, `POST /api/meals/from-log`, `POST
Read the TICKET-006 section of `IMPLEMENTATION_PLAN.md` — key points: /api/meals/{meal_id}/unpack`, `PUT /api/meals/{meal_id}/components`, and
real derived meal nutrition (recursive component summation + cycle
detection in `services/nutrition.py`). Read the TICKET-007 section of
`IMPLEMENTATION_PLAN.md` — key points:
- Backend: OFF → foods normalization in exactly one module (kcal/kJ mapping, - **This is the highest-complexity ticket.** Recursion, cycle detection,
User-Agent, timeouts); `GET /api/off/product/{barcode}`, `GET /api/off/search`, and transactional rollback are the spec's named testing priorities
`POST /api/off/refresh/{food_id}`; restore-on-rescan rule (§3.1); implement (§8.4). Build the service layer first with direct unit tests, then wire
`GET /api/foods/recent`; httpx mocked at the boundary in tests. routers. Run implementer and QA as **separate** calls (not a chain) with
- Frontend: `BarcodeScanner.svelte` + `lib/scanner.js` (native an orchestrator diff review in between (retrospective lesson #11).
`BarcodeDetector` with lazy zxing-wasm fallback, ~35 fps decode loop, - Backend: `POST /api/meals/from-log` and `POST /api/meals/{meal_id}/unpack`
camera teardown on destroy); scan flow per §4.1 with manual barcode are one-transaction-each (commit once or roll back entirely — §8.1 rule 6).
fallback; OFF fallback in search per §4.2; recent foods surfaced in UI. `PUT /api/meals/{meal_id}/components` replaces the component list wholesale,
- **Camera testing caveat (from the user):** a webcam exists but real scanner cycle-checked. Meal nutrition = recursive component summation with a
verification (esp. the §8.4 phone checklist — Android Chrome, iOS Safari, visited-set, both for nutrition reads and cycle checks on write.
EAN-13/UPC-A) needs the user manually over Caddy HTTPS. Don't block the - Remove the ticket-004 TODO (meals contributing 0 to summary). Summary
ticket on camera QA: verify the manual-barcode fallback and OFF search (ticket 004), `GET /api/foods/{id}`, and `GET /api/log` responses must now
flows via playwright, and mark the phone checklist as pending user testing. show real derived meal nutrition; log responses include meal components
nested for the collapsible UI (§3.3).
- Unpack quantity scaling (§3.2): each component's quantity × the original
meal entry's scaling factor (1.5× meal → 1.5× each component). Pin this
with a dedicated test.
- Cycle attempts (meal containing itself, directly or transitively) → 422.
- Frontend: "Save as Meal" (multi-select today's entries → name → replace
with meal entry), collapsible meal rows (collapsed = name + total kcal,
expanded = components), "Unpack" action, meal component editing from the
food editor for `is_meal` foods (§4.7).
## TICKET-006 notes (for reference / loose ends)
- Backend OFF normalizer is `backend/services/off.py` (the ONE module,
§8.1 rule 10): kcal/kJ fallback, not-found rule, graceful degradation on
upstream 503/timeout (returns `None`/`[]`, never 500). `GET /api/off/product`,
`/api/off/search`, `POST /api/off/refresh/{food_id}` (404 unknown id, 400
no-barcode). Restore-on-rescan in `services/foods.py` `create_food()`.
`GET /api/foods/recent` orders by `daily_log.created_at` (most recent log
ACTION, deliberately NOT by the log `date` field — see docstring; QA once
read this as a bug, it's a tested, deliberate choice).
- Frontend scan flow lives in `App.svelte` as a phase state machine
(`null`/`localFound`/`offFound`/`notFound`/`error`). `lib/scanner.js` is
the camera+decode loop (native BarcodeDetector + lazy zxing-wasm,
~4fps, teardown in `stop()`). `BarcodeScanner.svelte` stops on first
detect + on destroy (§8.2 rule 6).
- **§8.4 phone checklist PENDING USER TESTING** (native BarcodeDetector on
Android Chrome, zxing-wasm on iOS Safari, EAN-13/UPC-A decode). Requires
Caddy HTTPS. The manual-barcode fallback + OFF search flows are
playwright-verified; do not regress them.
- **Dev-only Vite proxy caching** observed by QA: empty OFF search
responses were cached within a Vite dev session; a fresh Vite restart
cleared it. Not a code defect (production build unaffected). If it
recurs, check the vite proxy config (`changeOrigin`, cache headers).
## Loose ends / chores ## Loose ends / chores
+300 -15
View File
@@ -1,15 +1,20 @@
<script> <script>
// App.svelte — Root component with conditional view switching (no router library). // App.svelte — Root component with conditional view switching (no router library).
// Date navigation: previous/next day buttons + current date display. // Date navigation: previous/next day button + current date display.
// Scan flow per §4.1: local DB check → confirm+log OR OFF lookup → FoodEditor → log.
// Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4). // Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4).
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from './lib/api.js' import { api } from './lib/api.js'
import { currentDate, appView, goPrevDay, goNextDay, setDate } from './lib/stores.svelte.js' import {
import { formatDate } from './lib/format.js' currentDate, appView, goPrevDay, goNextDay, setDate,
navigateTo, addLogEntryToStore, refreshDayData
} from './lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity, previewCalories } from './lib/format.js'
import Dashboard from './components/Dashboard.svelte' import Dashboard from './components/Dashboard.svelte'
import FoodSearch from './components/FoodSearch.svelte' import FoodSearch from './components/FoodSearch.svelte'
import FoodEditor from './components/FoodEditor.svelte' import FoodEditor from './components/FoodEditor.svelte'
import BarcodeScanner from './components/BarcodeScanner.svelte'
// Health check — every async view handles loading/error states (spec §8.2 rule 4) // Health check — every async view handles loading/error states (spec §8.2 rule 4)
let backend = $state({ loading: true, ok: false, error: null }) let backend = $state({ loading: true, ok: false, error: null })
@@ -67,6 +72,128 @@
} }
} }
// ── Scan flow state (§4.1) ─────────────────────────────────────────────────
// Phases: null (not scanning), 'localFound', 'localConfirm', 'offFound', 'notFound', 'error'
let scanPhase = $state(null)
let scanBarcode = $state('')
let scanLoading = $state(false)
let scanError = $state(null)
// Data from API
let localFood = $state(null) // FoodRead if found locally
let offFood = $state(null) // Normalized OFF food dict
// Logging state
let scanLogQuantity = $state(1)
let scanLogMealSlot = $state('')
let scanLogging = $state(false)
let scanLogError = $state(null)
let scanLiveKcal = $derived(localFood ? Math.round(previewCalories(localFood, scanLogQuantity)) : 0)
let refreshing = $state(false)
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
function resetScanFlow() {
scanPhase = null
scanBarcode = ''
scanLoading = false
scanError = null
localFood = null
offFood = null
scanLogQuantity = 1
scanLogMealSlot = ''
scanLogging = false
scanLogError = null
refreshing = false
}
async function handleBarcode(barcode) {
resetScanFlow()
scanBarcode = barcode
scanLoading = true
// Step 1: Check local DB by barcode
try {
const results = await api.searchFoodsByBarcode(barcode)
if (results && results.length > 0) {
localFood = results[0]
scanLogQuantity = defaultQuantity(localFood)
scanLoading = false
scanPhase = 'localFound'
return
}
} catch (e) {
// Local lookup failed — try OFF anyway
}
// Step 2: Not found locally → try OFF
try {
offFood = await api.offProduct(barcode)
scanLoading = false
scanPhase = 'offFound'
} catch (e) {
// OFF miss (404) or network error
scanLoading = false
if (e.message?.includes('404') || e.message?.includes('not found')) {
scanPhase = 'notFound'
} else {
scanError = e.message
scanPhase = 'error'
}
}
}
async function confirmLocalLog() {
if (!localFood || scanLogQuantity <= 0) return
scanLogging = true
scanLogError = null
try {
const entry = await api.addLogEntry({
food_id: localFood.id,
quantity: parseFloat(scanLogQuantity),
meal_slot: scanLogMealSlot || null,
date: currentDate.value,
})
await addLogEntryToStore(entry)
resetScanFlow()
appView.current = 'dashboard'
} catch (e) {
scanLogError = e.message
} finally {
scanLogging = false
}
}
async function refreshFromOff() {
if (!localFood) return
refreshing = true
try {
const updated = await api.offRefresh(localFood.id)
localFood = updated
scanLogQuantity = defaultQuantity(updated)
} catch (e) {
scanError = e.message
} finally {
refreshing = false
}
}
function handleOffSaved(savedFood) {
// FoodEditor called onSaved after creating food from OFF data
// Now offer to log it
localFood = savedFood
offFood = null
scanLogQuantity = defaultQuantity(savedFood)
scanPhase = 'localFound'
}
function backToScan() {
resetScanFlow()
}
function backToDashboard() {
resetScanFlow()
appView.current = 'dashboard'
}
// ── Date navigation ─────────────────────────────────────────────────────── // ── Date navigation ───────────────────────────────────────────────────────
function handleDateInput(e) { function handleDateInput(e) {
setDate(e.target.value) setDate(e.target.value)
@@ -103,11 +230,107 @@
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}> <button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
+ Add Food + Add Food
</button> </button>
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
📷 Scan
</button>
<button type="button" class="target-btn" onclick={openTargetForm}> <button type="button" class="target-btn" onclick={openTargetForm}>
🎯 Target 🎯 Target
</button> </button>
</div> </div>
{:else if appView.current === 'scan'}
<!-- Scan view — BarcodeScanner → scan flow -->
{#if !scanPhase}
<button type="button" class="back-btn" onclick={backToDashboard}> Dashboard</button>
<BarcodeScanner onBarcode={handleBarcode} />
{:else if scanLoading}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<p class="status">Looking up barcode {scanBarcode}</p>
</div>
{:else if scanPhase === 'localFound'}
<!-- Local food found → confirm and log -->
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Found: {localFood.name}</h3>
{#if localFood.brand}<p class="scan-brand">{localFood.brand}</p>{/if}
<p class="scan-kcal">{formatKcal(localFood.calories_per_unit)}/{localFood.unit_type === 'count' ? 'item' : '100g'}</p>
<div class="log-form">
<label>
Quantity
<input
type="number"
step="any"
min="0.1"
bind:value={scanLogQuantity}
class="qty-input"
/>
{localFood.unit_type === 'count' ? 'items' : 'g'}
</label>
<p class="preview-kcal">= {formatKcal(scanLiveKcal)}</p>
<label>
Meal slot
<select bind:value={scanLogMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
{#if scanLogError}<p class="err" role="alert">{scanLogError}</p>{/if}
{#if scanError}<p class="err" role="alert">{scanError}</p>{/if}
<div class="log-actions">
<button type="button" onclick={confirmLocalLog} disabled={scanLogging || scanLogQuantity <= 0}>
{scanLogging ? 'Logging…' : 'Log it'}
</button>
</div>
{#if localFood.source === 'openfoodfacts' || localFood.barcode}
<div class="refresh-section">
<p class="refresh-hint">Data from {localFood.source === 'openfoodfacts' ? 'OpenFoodFacts' : 'local'}. Refresh for latest?</p>
<button type="button" class="secondary" onclick={refreshFromOff} disabled={refreshing}>
{refreshing ? 'Refreshing…' : 'Refresh from OpenFoodFacts'}
</button>
</div>
{/if}
</div>
</div>
{:else if scanPhase === 'offFound'}
<!-- OFF product found → pre-fill FoodEditor -->
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Found on OpenFoodFacts</h3>
<FoodEditor food={offFood} onSaved={handleOffSaved} onCancel={backToScan} />
</div>
{:else if scanPhase === 'notFound'}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<h3>Not found</h3>
<p class="status">Barcode "{scanBarcode}" was not found locally or on OpenFoodFacts.</p>
<div class="not-found-actions">
<button type="button" onclick={() => { resetScanFlow(); appView.current = 'createFood' }}>
Create food manually
</button>
<button type="button" onclick={() => { resetScanFlow(); appView.current = 'addFood' }}>
Search foods
</button>
</div>
</div>
{:else if scanPhase === 'error'}
<div class="scan-status-card">
<button type="button" class="back-btn" onclick={backToScan}> Scan again</button>
<p class="err" role="alert">Lookup error: {scanError}</p>
</div>
{/if}
{:else if appView.current === 'addFood'} {:else if appView.current === 'addFood'}
<FoodSearch /> <FoodSearch />
@@ -185,6 +408,17 @@
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); } .status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
.status.err { color: #dc2626; } .status.err { color: #dc2626; }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
}
/* ── Date navigation ─────────────────────────────────────────────── */ /* ── Date navigation ─────────────────────────────────────────────── */
.date-nav { .date-nav {
display: flex; display: flex;
@@ -235,23 +469,68 @@
border-color: var(--link, #2563eb); border-color: var(--link, #2563eb);
font-weight: 600; font-weight: 600;
} }
.scan-fab {
background: #7c3aed;
border-color: #7c3aed;
}
/* ── Scan flow cards ─────────────────────────────────────────────── */
.scan-status-card {
/* common wrapper for scan phases */
}
.scan-brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
.scan-kcal { font-weight: 600; font-size: 1rem; margin: 0.5rem 0; }
.log-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 0.75rem;
}
.log-form label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.preview-kcal {
font-weight: 600;
font-size: 1rem;
margin: 0;
}
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.refresh-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border, #e5e7eb);
}
.refresh-hint {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.4rem;
}
.not-found-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
h3 { margin: 0 0 0.5rem; font-size: 1.1rem; }
/* ── Target form ─────────────────────────────────────────────────── */ /* ── Target form ─────────────────────────────────────────────────── */
.target-form-view { .target-form-view {
/* mobile-first single column */ /* mobile-first single column */
} }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
}
h3 { margin: 0 0 1rem; font-size: 1.1rem; }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -305,4 +584,10 @@
button.secondary { background: var(--bg-muted, #f3f4f6); } button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; } .err { color: #dc2626; font-size: 0.85rem; }
.success { color: #16a34a; font-size: 1rem; font-weight: 600; } .success { color: #16a34a; font-size: 1rem; font-weight: 600; }
select {
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
</style> </style>
+194 -2
View File
@@ -1,5 +1,197 @@
<script> <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). // BarcodeScanner — Camera scanner with permission-denied fallback to
// manual input (spec §4.1). Uses lib/scanner.js; stops the stream on
// destroy (spec §8.2 rule 6).
import { onMount } from 'svelte'
import { startScanner, isPermissionDeniedError, hasNativeBarcodeDetector } from '../lib/scanner.js'
/** @type {(barcode: string) => void} */
let { onBarcode } = $props()
let videoEl = $state(null)
let error = $state(null)
let loading = $state(true)
let manualBarcode = $state('')
let cameraUnavailable = $state(false)
/** @type {{ stop: () => void } | null} */
let stopHandle = null
function handleDetected(barcode) {
// Stop scanning once we have a barcode
if (stopHandle) {
stopHandle.stop()
stopHandle = null
}
onBarcode(barcode)
}
function handleError(err) {
loading = false
if (isPermissionDeniedError(err)) {
cameraUnavailable = true
error = 'Camera access needed for barcode scanning. Check your browser settings.'
} else if (err.name === 'NotFoundError' || err.message?.includes('No video')) {
cameraUnavailable = true
error = 'No camera found on this device.'
} else {
error = `Camera error: ${err.message}`
cameraUnavailable = true
}
}
function handleManualSubmit(e) {
e.preventDefault()
const code = manualBarcode.trim()
if (!code) return
handleDetected(code)
}
onMount(() => {
if (videoEl) {
stopHandle = startScanner(videoEl, {
onDetect: handleDetected,
onError: handleError,
})
// Camera started successfully (or at least getUserMedia was called)
// If the video starts playing, clear loading
videoEl.addEventListener('play', () => {
loading = false
}, { once: true })
}
return () => {
if (stopHandle) {
stopHandle.stop()
stopHandle = null
}
}
})
</script> </script>
<p>BarcodeScanner (placeholder)</p> <div class="barcode-scanner">
{#if error}
<p class="err" role="alert">{error}</p>
{/if}
{#if loading && !cameraUnavailable}
<p class="status">Starting camera…</p>
{/if}
<!-- Camera viewport — hidden when unavailable or permission denied -->
<div class="camera-container" class:hidden={cameraUnavailable}>
<video
bind:this={videoEl}
autoplay
playsinline
muted
class="scanner-video"
></video>
{#if loading}
<p class="status">Starting camera…</p>
{/if}
</div>
<!-- Manual barcode fallback (always available, primary when camera is gone) -->
<div class="manual-fallback">
<p class="hint">
{#if cameraUnavailable}
Enter a barcode number manually:
{:else}
Or type a barcode:
{/if}
</p>
<form class="manual-form" onsubmit={handleManualSubmit}>
<input
type="text"
inputmode="numeric"
placeholder="e.g. 3017620422003"
bind:value={manualBarcode}
class="barcode-input"
/>
<button type="submit" disabled={!manualBarcode.trim()}>Go</button>
</form>
</div>
</div>
<style>
.barcode-scanner {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.camera-container {
position: relative;
width: 100%;
max-width: 100%;
border-radius: 0.5rem;
overflow: hidden;
background: #000;
}
.camera-container.hidden {
display: none;
}
.scanner-video {
display: block;
width: 100%;
height: auto;
/* mirror for front-facing cameras; environment is usually not mirrored */
}
.status {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
text-align: center;
padding: 1rem;
}
.err {
color: #dc2626;
font-size: 0.9rem;
background: #fef2f2;
padding: 0.75rem;
border-radius: 0.35rem;
margin: 0;
}
.manual-fallback {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.75rem;
}
.hint {
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.manual-form {
display: flex;
gap: 0.4rem;
}
.barcode-input {
flex: 1;
padding: 0.5rem 0.7rem;
font: inherit;
font-size: 1.1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
letter-spacing: 0.05em;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.95rem;
}
button:disabled { opacity: 0.5; cursor: default; }
</style>
+117 -2
View File
@@ -1,9 +1,12 @@
<script> <script>
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6). // Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6).
// Every async view handles loading / error / empty states (spec §8.2 rule 4). // Every async view handles loading / error / empty states (spec §8.2 rule 4).
// Recent foods quick-log section per TICKET-006.
import { currentDate, dayData, refreshDayData, appView } from '../lib/stores.svelte.js' import { onMount } from 'svelte'
import { formatDate } from '../lib/format.js' import { api } from '../lib/api.js'
import { currentDate, dayData, refreshDayData, appView, addLogEntryToStore } from '../lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity } from '../lib/format.js'
import ProgressBar from './ProgressBar.svelte' import ProgressBar from './ProgressBar.svelte'
import LogEntry from './LogEntry.svelte' import LogEntry from './LogEntry.svelte'
@@ -16,6 +19,49 @@
refreshDayData() refreshDayData()
}) })
// ── Recent foods ──────────────────────────────────────────────────────────
let recentFoods = $state([])
let recentLoading = $state(false)
let recentError = $state(null)
onMount(() => {
loadRecentFoods()
})
async function loadRecentFoods() {
recentLoading = true
recentError = null
try {
recentFoods = await api.recentFoods(10)
} catch (e) {
recentError = e.message
} finally {
recentLoading = false
}
}
async function quickLog(food) {
try {
const entry = await api.addLogEntry({
food_id: food.id,
quantity: defaultQuantity(food),
meal_slot: getDefaultSlot(),
date: currentDate.value,
})
await addLogEntryToStore(entry)
} catch {
// Silently fail — user can manually log
}
}
function getDefaultSlot() {
const hour = new Date().getHours()
if (hour < 10) return 'breakfast'
if (hour < 14) return 'lunch'
if (hour < 19) return 'dinner'
return 'snack'
}
// Group entries by meal_slot (null → "Other") // Group entries by meal_slot (null → "Other")
let groups = $derived.by(() => { let groups = $derived.by(() => {
const log = dayData.log const log = dayData.log
@@ -55,6 +101,28 @@
target={dayData.summary?.target} target={dayData.summary?.target}
/> />
<!-- Recent foods quick-log -->
{#if recentFoods.length > 0}
<div class="recent-foods">
<h3 class="recent-header">Recent</h3>
<div class="recent-list">
{#each recentFoods as food (food.id)}
<button
type="button"
class="recent-chip"
onclick={() => quickLog(food)}
title="{food.name}{food.brand ? ' • ' + food.brand : ''} {formatKcal(food.calories_per_unit)}/{food.unit_type === 'count' ? 'item' : '100g'}"
>
<span class="chip-name">{food.name}</span>
<span class="chip-kcal">{formatKcal(food.calories_per_unit)}</span>
</button>
{/each}
</div>
</div>
{:else if recentLoading}
<p class="status">Loading recent foods…</p>
{/if}
{#if dayData.log.length === 0} {#if dayData.log.length === 0}
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p> <p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
{:else} {:else}
@@ -93,6 +161,53 @@
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/* ── Recent foods ───────────────────────────────────────────────────── */
.recent-foods {
margin: 0.75rem 0 1rem;
}
.recent-header {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
margin: 0 0 0.4rem;
}
.recent-list {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.3rem;
}
.recent-chip {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.4rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
text-align: left;
min-width: 5rem;
}
.recent-chip:hover {
background: var(--bg-muted, #f9fafb);
}
.chip-name {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
}
.chip-kcal {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
}
.meal-group { .meal-group {
margin-top: 1rem; margin-top: 1rem;
} }
+273 -70
View File
@@ -1,26 +1,66 @@
<script> <script>
// FoodEditor — Manual food creation form (spec §4.5). // FoodEditor — Food creation/editing form (spec §4.5).
// Shared by scan/search/library flows; here used for manual creation. // Shared by scan/search/library flows. Accepts an optional `food` prop
// for pre-filling from OFF or edit flows. When pre-filled, barcode is
// read-only and source is set from the prefill.
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { appView } from '../lib/stores.svelte.js' import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
import { defaultQuantity, previewCalories, formatKcal } from '../lib/format.js'
/** @type {import('../lib/api.js').FoodCreate | null} */
let { food = null, onSaved = null, onCancel = null } = $props()
// ── Form state ──────────────────────────────────────────────────────────
let name = $state('') let name = $state('')
let brand = $state('') let brand = $state('')
let unitType = $state('weight') // 'weight' | 'count' let barcode = $state('')
let source = $state('manual')
let unitType = $state('weight')
let caloriesPerUnit = $state('') let caloriesPerUnit = $state('')
let proteinPerUnit = $state('') let proteinPerUnit = $state('')
let carbsPerUnit = $state('') let carbsPerUnit = $state('')
let fatPerUnit = $state('') let fatPerUnit = $state('')
let servingSizeG = $state('') let servingSizeG = $state('')
let servingName = $state('') let servingName = $state('')
let isPrefilled = $state(false)
// ── Log-after-save state ────────────────────────────────────────────────
let justSaved = $state(null) // { food: FoodRead } after save
let logQuantity = $state(1)
let logMealSlot = $state('')
let logging = $state(false)
let logError = $state(null)
let liveKcal = $derived(justSaved ? Math.round(previewCalories(justSaved.food, logQuantity)) : 0)
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
let saving = $state(false) let saving = $state(false)
let error = $state(null) let error = $state(null)
// ── Pre-fill from food prop ─────────────────────────────────────────────
$effect(() => {
if (food) {
name = food.name || ''
brand = food.brand || ''
barcode = food.barcode || ''
source = food.source || 'manual'
unitType = food.unit_type || 'weight'
caloriesPerUnit = food.calories_per_unit != null ? String(food.calories_per_unit) : ''
proteinPerUnit = food.protein_per_unit != null ? String(food.protein_per_unit) : ''
carbsPerUnit = food.carbs_per_unit != null ? String(food.carbs_per_unit) : ''
fatPerUnit = food.fat_per_unit != null ? String(food.fat_per_unit) : ''
servingSizeG = food.serving_size_g != null ? String(food.serving_size_g) : ''
servingName = food.serving_name || ''
isPrefilled = true
}
})
function reset() { function reset() {
name = '' name = ''
brand = '' brand = ''
barcode = ''
source = 'manual'
unitType = 'weight' unitType = 'weight'
caloriesPerUnit = '' caloriesPerUnit = ''
proteinPerUnit = '' proteinPerUnit = ''
@@ -28,6 +68,8 @@
fatPerUnit = '' fatPerUnit = ''
servingSizeG = '' servingSizeG = ''
servingName = '' servingName = ''
isPrefilled = false
justSaved = null
error = null error = null
} }
@@ -40,7 +82,7 @@
saving = true saving = true
error = null error = null
try { try {
const food = await api.createFood({ const payload = {
name: name.trim(), name: name.trim(),
brand: brand.trim() || null, brand: brand.trim() || null,
unit_type: unitType, unit_type: unitType,
@@ -50,96 +92,203 @@
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null, fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null, serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null, serving_name: servingName.trim() || null,
source: 'manual', source,
is_meal: false, is_meal: false,
}) }
reset() // Include barcode when present
// Go to search so user can log the new food immediately if (barcode.trim()) payload.barcode = barcode.trim()
appView.current = 'addFood' else payload.barcode = null
const saved = await api.createFood(payload)
// After save, offer to log the new food
justSaved = { food: saved }
logQuantity = defaultQuantity(saved)
logMealSlot = ''
if (onSaved) onSaved(saved)
} catch (e) { } catch (e) {
error = e.message error = e.message
} finally { } finally {
saving = false saving = false
} }
} }
async function confirmLog() {
if (!justSaved || logQuantity <= 0) return
logging = true
logError = null
try {
const entry = await api.addLogEntry({
food_id: justSaved.food.id,
quantity: parseFloat(logQuantity),
meal_slot: logMealSlot || null,
date: currentDate.value,
})
await addLogEntryToStore(entry)
reset()
appView.current = 'dashboard'
} catch (e) {
logError = e.message
} finally {
logging = false
}
}
function skipLog() {
reset()
appView.current = 'dashboard'
}
function goBack() {
reset()
if (onCancel) {
onCancel()
} else if (onSaved) {
// Came from scan flow with no cancel — go to dashboard
appView.current = 'dashboard'
} else {
appView.current = 'addFood'
}
}
</script> </script>
<div class="food-editor"> <div class="food-editor">
<button type="button" class="back-btn" onclick={() => { reset(); appView.current = 'addFood' }}> Back</button> <button type="button" class="back-btn" onclick={goBack}>
← Back
</button>
<h3>Create food</h3> {#if justSaved}
<!-- Post-save: offer to log the new food -->
<h3>"{justSaved.food.name}" saved</h3>
<form onsubmit={handleSubmit}> <div class="log-form">
<label> <p class="brand">{justSaved.food.brand}</p>
Name <span class="required">*</span>
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
</label>
<label> <label>
Brand Quantity
<input type="text" bind:value={brand} placeholder="Optional" /> <input
</label> type="number"
step="any"
<fieldset> min="0.1"
<legend>Unit type</legend> bind:value={logQuantity}
<label class="radio-label"> class="qty-input"
<input type="radio" name="unitType" value="weight" bind:group={unitType} /> />
Weight (nutrition per 100g) {justSaved.food.unit_type === 'count' ? 'items' : 'g'}
</label> </label>
<label class="radio-label"> <p class="preview-kcal">= {formatKcal(liveKcal)}</p>
<input type="radio" name="unitType" value="count" bind:group={unitType} />
Count (nutrition per item) <label>
Meal slot
<select bind:value={logMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label> </label>
</fieldset>
<label> {#if logError}<p class="err" role="alert">{logError}</p>{/if}
Calories per {unitType === 'weight' ? '100g' : 'item'} <span class="required">*</span>
<input type="number" step="any" min="0.01" bind:value={caloriesPerUnit} placeholder="e.g. 350" required />
</label>
<fieldset> <div class="log-actions">
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend> <button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
<div class="macro-grid"> {logging ? 'Logging…' : 'Log it'}
<label> </button>
Protein <button type="button" class="secondary" onclick={skipLog} disabled={logging}>
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" /> Skip
</label> </button>
<label>
Carbs
<input type="number" step="any" min="0" bind:value={carbsPerUnit} placeholder="g" />
</label>
<label>
Fat
<input type="number" step="any" min="0" bind:value={fatPerUnit} placeholder="g" />
</label>
</div> </div>
</fieldset> </div>
{:else}
<!-- Creation/edit form -->
<h3>{isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
<form onsubmit={handleSubmit}>
<label>
Name <span class="required">*</span>
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
</label>
<label>
Brand
<input type="text" bind:value={brand} placeholder="Optional" />
</label>
<label>
Barcode
<input
type="text"
bind:value={barcode}
placeholder={isPrefilled ? '' : 'Optional'}
disabled={isPrefilled}
class:barcode-ro={isPrefilled}
/>
</label>
{#if isPrefilled}
<p class="source-badge">Source: {source}</p>
{/if}
{#if unitType === 'weight'}
<fieldset> <fieldset>
<legend>Serving info (optional)</legend> <legend>Unit type</legend>
<label> <label class="radio-label">
Serving size (g) <input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" /> Weight (nutrition per 100g)
</label> </label>
<label> <label class="radio-label">
Serving name <input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' /> Count (nutrition per item)
</label> </label>
</fieldset> </fieldset>
{/if}
{#if error}<p class="err" role="alert">{error}</p>{/if} <label>
Calories per {unitType === 'weight' ? '100g' : 'item'} <span class="required">*</span>
<input type="number" step="any" min="0.01" bind:value={caloriesPerUnit} placeholder="e.g. 350" required />
</label>
<div class="form-actions"> <fieldset>
<button type="submit" disabled={saving}> <legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
{saving ? 'Saving…' : 'Save food'} <div class="macro-grid">
</button> <label>
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}> Protein
Cancel <input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
</button> </label>
</div> <label>
</form> Carbs
<input type="number" step="any" min="0" bind:value={carbsPerUnit} placeholder="g" />
</label>
<label>
Fat
<input type="number" step="any" min="0" bind:value={fatPerUnit} placeholder="g" />
</label>
</div>
</fieldset>
{#if unitType === 'weight'}
<fieldset>
<legend>Serving info (optional)</legend>
<label>
Serving size (g)
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" />
</label>
<label>
Serving name
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' />
</label>
</fieldset>
{/if}
{#if error}<p class="err" role="alert">{error}</p>{/if}
<div class="form-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'}
</button>
<button type="button" class="secondary" onclick={goBack} disabled={saving}>
Cancel
</button>
</div>
</form>
{/if}
</div> </div>
<style> <style>
@@ -185,6 +334,22 @@
border-radius: 0.35rem; border-radius: 0.35rem;
} }
.barcode-ro {
background: var(--bg-muted, #f3f4f6);
color: var(--text-muted, #6b7280);
}
.source-badge {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
background: var(--bg-muted, #f3f4f6);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
display: inline-block;
text-transform: capitalize;
margin: 0;
}
fieldset { fieldset {
border: 1px solid var(--border, #e5e7eb); border: 1px solid var(--border, #e5e7eb);
border-radius: 0.35rem; border-radius: 0.35rem;
@@ -217,6 +382,38 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
.log-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.log-form label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.preview-kcal {
font-weight: 600;
font-size: 1rem;
color: var(--text, #111827);
margin: 0;
}
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
button { button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db); border: 1px solid var(--border, #d1d5db);
@@ -230,4 +427,10 @@
button.secondary { background: var(--bg-muted, #f3f4f6); } button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; } .err { color: #dc2626; font-size: 0.85rem; }
select {
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
</style> </style>
+156 -24
View File
@@ -1,17 +1,24 @@
<script> <script>
// FoodSearch — Free-text search: local DB search, select, log (spec §4.2, TICKET-005). // FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2).
// Full search UX (recent foods, OFF fallback) is TICKET-006. // Selecting a local food → log it; selecting an OFF result → FoodEditor pre-filled.
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js' import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js' import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
import FoodEditor from './FoodEditor.svelte'
let query = $state('') let query = $state('')
let results = $state(null) // null = not searched yet, [] = no results let results = $state(null) // null = not searched yet, [] = no results
let searching = $state(false) let searching = $state(false)
let searchError = $state(null) let searchError = $state(null)
// Selected food for logging // OFF fallback state
let offResults = $state(null) // null = not searched OFF, [] = no off results
let offSearching = $state(false)
let offError = $state(null)
let offQueried = $state(false) // track if we've already tried OFF for this query
// Selected local food for logging
let selected = $state(null) let selected = $state(null)
let logQuantity = $state(1) let logQuantity = $state(1)
let logMealSlot = $state('') let logMealSlot = $state('')
@@ -20,14 +27,19 @@
let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity))) let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity)))
// Selected OFF food for pre-fill
let offSelected = $state(null) // the normalized OFF food dict
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack'] const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
async function doSearch() { async function doSearch() {
const q = query.trim() const q = query.trim()
if (!q) { results = null; return } if (!q) { results = null; offResults = null; offQueried = false; return }
searching = true searching = true
searchError = null searchError = null
offResults = null
offQueried = false
try { try {
results = await api.searchFoods(q) results = await api.searchFoods(q)
} catch (e) { } catch (e) {
@@ -38,6 +50,23 @@
} }
} }
async function searchOff() {
const q = query.trim()
if (!q) return
offSearching = true
offError = null
offQueried = true
try {
offResults = await api.offSearch(q)
} catch (e) {
offError = e.message
offResults = []
} finally {
offSearching = false
}
}
function pickFood(food) { function pickFood(food) {
selected = food selected = food
logQuantity = defaultQuantity(food) logQuantity = defaultQuantity(food)
@@ -62,10 +91,11 @@
date: currentDate.value, date: currentDate.value,
}) })
await addLogEntryToStore(entry) await addLogEntryToStore(entry)
// Reset and go back to dashboard
selected = null selected = null
query = '' query = ''
results = null results = null
offResults = null
offQueried = false
appView.current = 'dashboard' appView.current = 'dashboard'
} catch (e) { } catch (e) {
logError = e.message logError = e.message
@@ -73,15 +103,27 @@
logging = false logging = false
} }
} }
function pickOffFood(offFood) {
offSelected = offFood
}
function clearOffSelection() {
offSelected = null
}
</script> </script>
<div class="food-search"> <div class="food-search">
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button> <button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button>
{#if !selected} {#if offSelected}
<!-- OFF food selected → pre-fill FoodEditor -->
<FoodEditor food={offSelected} onCancel={() => { offSelected = null }} />
{:else if !selected}
<!-- Search --> <!-- Search -->
<h3>Find a food</h3> <h3>Find a food</h3>
<form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch() }}> <form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch(); offResults = null; offQueried = false }}>
<input <input
type="search" type="search"
placeholder="Search foods…" placeholder="Search foods…"
@@ -95,35 +137,80 @@
<p class="status">Searching…</p> <p class="status">Searching…</p>
{:else if searchError} {:else if searchError}
<p class="status err" role="alert">{searchError}</p> <p class="status err" role="alert">{searchError}</p>
{:else if results !== null} {:else if results !== null && results.length > 0}
{#if results.length === 0} <!-- Local results found -->
<p class="status">No foods found.</p> <p class="status">{results.length} local result{results.length !== 1 ? 's' : ''}</p>
{:else} <ul class="results-list">
<ul class="results-list"> {#each results as food (food.id)}
{#each results as food (food.id)} <li>
<button type="button" class="result-item" onclick={() => pickFood(food)}>
<span class="r-name">{food.name}</span>
{#if food.brand}<span class="r-brand">{food.brand}</span>{/if}
<span class="r-kcal">{formatKcal(food.calories_per_unit)}
{food.unit_type === 'count' ? '/item' : '/100g'}
</span>
</button>
</li>
{/each}
</ul>
{:else if results !== null && results.length === 0}
<!-- No local results — offer OFF fallback -->
<p class="status">No foods found locally.</p>
{#if !offQueried}
<div class="off-fallback">
<p class="off-hint">Not found locally — search OpenFoodFacts?</p>
<button type="button" class="off-btn" onclick={searchOff} disabled={offSearching}>
{offSearching ? 'Searching OFF…' : 'Search OpenFoodFacts'}
</button>
</div>
{/if}
{/if}
<!-- OFF results (also shown if there ARE local results but user wants more) -->
{#if offQueried}
{#if offSearching}
<p class="status">Searching OpenFoodFacts…</p>
{:else if offError}
<p class="status err" role="alert">OFF search failed: {offError}</p>
{:else if offResults && offResults.length > 0}
<p class="status off-status">OpenFoodFacts results:</p>
<ul class="results-list off-list">
{#each offResults as offFood, i (`off-${i}`)}
<li> <li>
<button type="button" class="result-item" onclick={() => pickFood(food)}> <button type="button" class="result-item" onclick={() => pickOffFood(offFood)}>
<span class="r-name">{food.name}</span> <span class="r-name">{offFood.name}</span>
{#if food.brand}<span class="r-brand">{food.brand}</span>{/if} {#if offFood.brand}<span class="r-brand">{offFood.brand}</span>{/if}
<span class="r-kcal">{formatKcal(food.calories_per_unit)} <span class="r-kcal">{formatKcal(offFood.calories_per_unit)}/100g</span>
{food.unit_type === 'count' ? '/item' : '/100g'} <span class="r-source">OFF</span>
</span>
</button> </button>
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if offResults !== null && offResults.length === 0}
<p class="status">No results from OpenFoodFacts either.</p>
{/if} {/if}
{/if} {/if}
{#if !offQueried && results !== null && results.length > 0}
<!-- Offer OFF search even when local results exist -->
<div class="off-fallback">
<button type="button" class="link off-link" onclick={searchOff} disabled={offSearching}>
Also search OpenFoodFacts
</button>
</div>
{/if}
<p class="or-create"> <p class="or-create">
Or Or
<button type="button" class="link" onclick={() => appView.current = 'createFood'}> <button type="button" class="link" onclick={() => appView.current = 'createFood'}>
create a new food create a new food manually
</button> </button>
</p> </p>
{:else} {:else}
<!-- Logging form for selected food --> <!-- Logging form for selected local food -->
<h3>Log "{selected.name}"</h3> <h3>Log "{selected.name}"</h3>
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if} {#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
@@ -221,11 +308,51 @@
.r-name { font-weight: 600; } .r-name { font-weight: 600; }
.r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; } .r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); } .r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); }
.or-create { .r-source {
margin-top: 0.75rem; font-size: 0.7rem;
font-size: 0.9rem; background: var(--bg-muted, #e5e7eb);
padding: 0.1em 0.4em;
border-radius: 0.25rem;
color: var(--text-muted, #6b7280); color: var(--text-muted, #6b7280);
} }
.off-fallback {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px dashed var(--border, #d1d5db);
border-radius: 0.5rem;
text-align: center;
}
.off-hint {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.off-btn {
padding: 0.45rem 0.9rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
.off-btn:disabled { opacity: 0.5; cursor: default; }
.off-list {
border-color: var(--border, #d1d5db);
}
.off-status {
margin-top: 1rem;
font-weight: 600;
}
.off-link {
color: var(--link, #2563eb);
text-decoration: underline;
cursor: pointer;
}
button.link { button.link {
background: none; background: none;
border: none; border: none;
@@ -235,6 +362,11 @@
font: inherit; font: inherit;
padding: 0; padding: 0;
} }
.or-create {
margin-top: 0.75rem;
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
}
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; } .brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
.log-form { .log-form {
display: flex; display: flex;
+3
View File
@@ -28,9 +28,11 @@ export const api = {
health: () => request('/api/health'), health: () => request('/api/health'),
// Foods (spec §3.1) // Foods (spec §3.1)
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`), searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`), recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
getFood: (id) => request(`/api/foods/${id}`), getFood: (id) => request(`/api/foods/${id}`),
createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }), createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }),
updateFood: (id, food) => request(`/api/foods/${id}`, { method: 'PUT', body: JSON.stringify(food) }),
// Daily log (spec §3.3) — dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4) // 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}`), getLog: (date) => request(`/api/log?date=${date}`),
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }), addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
@@ -44,4 +46,5 @@ export const api = {
// OFF proxy (spec §3.5) — the frontend never calls OFF directly // OFF proxy (spec §3.5) — the frontend never calls OFF directly
offProduct: (barcode) => request(`/api/off/product/${barcode}`), offProduct: (barcode) => request(`/api/off/product/${barcode}`),
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`), offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
offRefresh: (foodId) => request(`/api/off/refresh/${foodId}`, { method: 'POST' }),
} }
+150 -9
View File
@@ -12,20 +12,161 @@
// zxing-wasm is the fallback decoder; imported lazily so Chromium users // zxing-wasm is the fallback decoder; imported lazily so Chromium users
// on the native path never pay the WASM download cost. // on the native path never pay the WASM download cost.
// import { readBarcodes } from 'zxing-wasm/reader' // The module exposes readBarcodesFromImageData for ImageData input.
/** Formats we care about for grocery barcodes. */
const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8', 'upc_e']
/** ~4 fps: 250ms between decode attempts. */
const DECODE_INTERVAL_MS = 250
export function hasNativeBarcodeDetector() { export function hasNativeBarcodeDetector() {
return typeof globalThis.BarcodeDetector !== 'undefined' return typeof globalThis.BarcodeDetector !== 'undefined'
} }
/** /**
* TODO: implement startScanner(videoEl, { onDetect }) → stop() handle. * Classify an error from getUserMedia — returns true for permission-denied
* - getUserMedia({ video: { facingMode: 'environment' } }) * (NotAllowedError / SecurityError), which the component uses to show the
* - native BarcodeDetector if hasNativeBarcodeDetector(), else zxing-wasm * manual-barcode fallback message.
* - 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() { export function isPermissionDeniedError(err) {
throw new Error('scanner not implemented yet — see spec §4.1') return err instanceof DOMException && (
err.name === 'NotAllowedError' || err.name === 'SecurityError'
)
}
/**
* Start the barcode scanner. Opens the camera stream, attaches it to
* `videoEl`, and runs a throttled decode loop. When a barcode is detected,
* calls `onDetect(barcode)`.
*
* @param {HTMLVideoElement} videoEl — an <video> element already in the DOM.
* @param {object} callbacks
* @param {(barcode: string) => void} callbacks.onDetect
* @param {(error: Error) => void} callbacks.onError
* @returns {{ stop: () => void }} A handle with a `stop()` method.
*/
export function startScanner(videoEl, { onDetect, onError }) {
// ── State that stop() tears down ──────────────────────────────────────
let stream = null
let timer = null
let stopped = false
let canvas = null
let ctx = null
// Lazy-loaded zxing-wasm function (null until first use)
let zxingReader = null
// ── Canvas for frame extraction ──────────────────────────────────────
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d', { willReadFrequently: true })
// ── BarcodeDetector instance (native or null) ─────────────────────────
/** @type {BarcodeDetector | null} */
let detector = null
if (hasNativeBarcodeDetector()) {
try {
detector = new BarcodeDetector({ formats: BARCODE_FORMATS })
} catch {
// Some browsers may support the API but reject these formats;
// fall through to zxing-wasm.
detector = null
}
}
// ── Decode loop ───────────────────────────────────────────────────────
async function decodeFrame() {
if (stopped) return
try {
// Extract the current video frame into our canvas
const vw = videoEl.videoWidth
const vh = videoEl.videoHeight
if (vw === 0 || vh === 0) return // not playing yet
canvas.width = vw
canvas.height = vh
ctx.drawImage(videoEl, 0, 0, vw, vh)
let barcode = null
if (detector) {
// Native BarcodeDetector path
try {
const detections = await detector.detect(canvas)
if (detections.length > 0 && !stopped) {
barcode = detections[0].rawValue
}
} catch {
// Native detector can throw on some frames; ignore and try next
}
} else {
// zxing-wasm fallback — load once
if (!zxingReader) {
const mod = await import('zxing-wasm')
// zxing-wasm v3 exports readBarcodesFromImageData
zxingReader = mod.readBarcodesFromImageData
}
try {
const imageData = ctx.getImageData(0, 0, vw, vh)
const results = await zxingReader(imageData, {
formats: BARCODE_FORMATS,
})
if (results.length > 0 && !stopped) {
barcode = results[0].text
}
} catch {
// zxing decode errors on non-barcode frames — ignore
}
}
if (barcode && !stopped) {
onDetect(barcode)
}
} catch {
// Frame extraction can fail if video isn't ready yet — ignore
}
}
// ── Start camera ──────────────────────────────────────────────────────
;(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
})
if (stopped) {
// Component unmounted while we were waiting for permission
stream.getTracks().forEach(t => t.stop())
return
}
videoEl.srcObject = stream
await videoEl.play()
// Start decode loop at ~4 fps
timer = setInterval(decodeFrame, DECODE_INTERVAL_MS)
} catch (err) {
if (!stopped) {
onError(err instanceof Error ? err : new Error(String(err)))
}
}
})()
// ── Stop handle ───────────────────────────────────────────────────────
function stop() {
stopped = true
if (timer !== null) {
clearInterval(timer)
timer = null
}
if (stream) {
stream.getTracks().forEach(t => t.stop())
stream = null
}
// Clear canvas reference to help GC
canvas = null
ctx = null
}
return { stop }
} }
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { hasNativeBarcodeDetector, isPermissionDeniedError } from './scanner.js'
describe('hasNativeBarcodeDetector', () => {
it('returns boolean', () => {
expect(typeof hasNativeBarcodeDetector()).toBe('boolean')
})
})
describe('isPermissionDeniedError', () => {
it('returns true for NotAllowedError', () => {
const err = new DOMException('Permission denied', 'NotAllowedError')
expect(isPermissionDeniedError(err)).toBe(true)
})
it('returns true for SecurityError', () => {
const err = new DOMException('The operation is insecure', 'SecurityError')
expect(isPermissionDeniedError(err)).toBe(true)
})
it('returns false for regular Error', () => {
expect(isPermissionDeniedError(new Error('something else'))).toBe(false)
})
it('returns false for non-DOMException', () => {
expect(isPermissionDeniedError({ name: 'NotAllowedError' })).toBe(false)
})
})