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
+300 -15
View File
@@ -1,15 +1,20 @@
<script>
// 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).
import { onMount } from 'svelte'
import { api } from './lib/api.js'
import { currentDate, appView, goPrevDay, goNextDay, setDate } from './lib/stores.svelte.js'
import { formatDate } from './lib/format.js'
import {
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 FoodSearch from './components/FoodSearch.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)
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 ───────────────────────────────────────────────────────
function handleDateInput(e) {
setDate(e.target.value)
@@ -103,11 +230,107 @@
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
+ Add Food
</button>
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
📷 Scan
</button>
<button type="button" class="target-btn" onclick={openTargetForm}>
🎯 Target
</button>
</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'}
<FoodSearch />
@@ -185,6 +408,17 @@
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
.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-nav {
display: flex;
@@ -235,23 +469,68 @@
border-color: var(--link, #2563eb);
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-view {
/* 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 {
display: flex;
flex-direction: column;
@@ -305,4 +584,10 @@
button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; }
.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>