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
+273 -70
View File
@@ -1,26 +1,66 @@
<script>
// FoodEditor — Manual food creation form (spec §4.5).
// Shared by scan/search/library flows; here used for manual creation.
// FoodEditor — Food creation/editing form (spec §4.5).
// 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 { 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 brand = $state('')
let unitType = $state('weight') // 'weight' | 'count'
let barcode = $state('')
let source = $state('manual')
let unitType = $state('weight')
let caloriesPerUnit = $state('')
let proteinPerUnit = $state('')
let carbsPerUnit = $state('')
let fatPerUnit = $state('')
let servingSizeG = $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 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() {
name = ''
brand = ''
barcode = ''
source = 'manual'
unitType = 'weight'
caloriesPerUnit = ''
proteinPerUnit = ''
@@ -28,6 +68,8 @@
fatPerUnit = ''
servingSizeG = ''
servingName = ''
isPrefilled = false
justSaved = null
error = null
}
@@ -40,7 +82,7 @@
saving = true
error = null
try {
const food = await api.createFood({
const payload = {
name: name.trim(),
brand: brand.trim() || null,
unit_type: unitType,
@@ -50,96 +92,203 @@
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null,
source: 'manual',
source,
is_meal: false,
})
reset()
// Go to search so user can log the new food immediately
appView.current = 'addFood'
}
// Include barcode when present
if (barcode.trim()) payload.barcode = barcode.trim()
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) {
error = e.message
} finally {
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>
<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}>
<label>
Name <span class="required">*</span>
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
</label>
<div class="log-form">
<p class="brand">{justSaved.food.brand}</p>
<label>
Brand
<input type="text" bind:value={brand} placeholder="Optional" />
</label>
<fieldset>
<legend>Unit type</legend>
<label class="radio-label">
<input type="radio" name="unitType" value="weight" bind:group={unitType} />
Weight (nutrition per 100g)
<label>
Quantity
<input
type="number"
step="any"
min="0.1"
bind:value={logQuantity}
class="qty-input"
/>
{justSaved.food.unit_type === 'count' ? 'items' : 'g'}
</label>
<label class="radio-label">
<input type="radio" name="unitType" value="count" bind:group={unitType} />
Count (nutrition per item)
<p class="preview-kcal">= {formatKcal(liveKcal)}</p>
<label>
Meal slot
<select bind:value={logMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
</fieldset>
<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>
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
<fieldset>
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
<div class="macro-grid">
<label>
Protein
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
</label>
<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 class="log-actions">
<button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
{logging ? 'Logging…' : 'Log it'}
</button>
<button type="button" class="secondary" onclick={skipLog} disabled={logging}>
Skip
</button>
</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>
<legend>Serving info (optional)</legend>
<label>
Serving size (g)
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" />
<legend>Unit type</legend>
<label class="radio-label">
<input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
Weight (nutrition per 100g)
</label>
<label>
Serving name
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' />
<label class="radio-label">
<input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
Count (nutrition per item)
</label>
</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">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'}
</button>
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}>
Cancel
</button>
</div>
</form>
<fieldset>
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
<div class="macro-grid">
<label>
Protein
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
</label>
<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>
</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>
<style>
@@ -185,6 +334,22 @@
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 {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.35rem;
@@ -217,6 +382,38 @@
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 {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
@@ -230,4 +427,10 @@
button.secondary { background: var(--bg-muted, #f3f4f6); }
.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>