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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user