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
+117 -2
View File
@@ -1,9 +1,12 @@
<script>
// 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).
// Recent foods quick-log section per TICKET-006.
import { currentDate, dayData, refreshDayData, appView } from '../lib/stores.svelte.js'
import { formatDate } from '../lib/format.js'
import { onMount } from 'svelte'
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 LogEntry from './LogEntry.svelte'
@@ -16,6 +19,49 @@
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")
let groups = $derived.by(() => {
const log = dayData.log
@@ -55,6 +101,28 @@
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}
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
{:else}
@@ -93,6 +161,53 @@
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 {
margin-top: 1rem;
}