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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user