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:
+79
-37
@@ -5,9 +5,11 @@ first; this file only records progress and session-specific notes.
|
||||
|
||||
## Where we are in the plan
|
||||
|
||||
**Milestone M1 (Manual calorie tracker) is COMPLETE.** Tickets 001–005 all
|
||||
implemented, QA-verified, and committed. The app is usable end-to-end for
|
||||
manual food entry and daily logging.
|
||||
**Milestones M1 (Manual calorie tracker) and M2 (Barcode scanning & OFF
|
||||
integration) are COMPLETE.** Tickets 001–006 all implemented, QA-verified,
|
||||
and committed. The app is usable end-to-end for manual food entry, daily
|
||||
logging, barcode scanning (manual-barcode path verified; phone camera path
|
||||
pending user testing), and OpenFoodFacts lookup/search.
|
||||
|
||||
| Ticket | Status | Commit |
|
||||
|--------|--------|--------|
|
||||
@@ -16,12 +18,12 @@ manual food entry and daily logging.
|
||||
| 003 Daily log write path | ✅ done, QA passed | `87d7eca` |
|
||||
| 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` |
|
||||
| 005 Frontend daily view | ✅ done, QA passed | `b69661c` |
|
||||
| 006 OFF + barcode scan/search flows | ⬜ **next** | — |
|
||||
| 007 Meals (from-log, unpack, recursion) | ⬜ pending | — |
|
||||
| 006 OFF + barcode scan/search flows | ✅ done, QA passed (backend `32461b7`, frontend below) | `32461b7` + frontend |
|
||||
| 007 Meals (from-log, unpack, recursion) | ⬜ **next** | — |
|
||||
| 008 Food library view + restore | ⬜ pending | — |
|
||||
|
||||
Test counts at HEAD: backend **104 passed** (`cd backend && uv run pytest`),
|
||||
frontend vitest + `npm run build` green.
|
||||
Test counts at HEAD: backend **152 passed** (`cd backend && uv run pytest`),
|
||||
frontend **18 vitest passed** + `npm run build` green.
|
||||
|
||||
## What was done this session
|
||||
|
||||
@@ -42,40 +44,80 @@ frontend vitest + `npm run build` green.
|
||||
layout. All HTTP via `lib/api.js`; shared state in `stores.svelte.js`;
|
||||
Svelte 5 runes only.
|
||||
|
||||
## Process lessons (important for the next orchestrator)
|
||||
## Process notes (session 2)
|
||||
|
||||
1. **Implementer agents can falsely report success without writing code.**
|
||||
This happened twice (TICKET-004 backend, first TICKET-005 frontend attempt).
|
||||
Mitigations that worked:
|
||||
- Every implementer task must require `git status --short` evidence and a
|
||||
live smoke test (curl the endpoint / build + serve) in its report.
|
||||
- QA must be explicitly told not to trust the implementer's report and to
|
||||
verify the feature exists (e.g. check `/openapi.json` routes) before
|
||||
testing.
|
||||
2. QA (playwright) genuinely catches real bugs — it found broken date-nav
|
||||
buttons and a stale-summary bug in TICKET-005; both were fixed and
|
||||
re-verified before commit.
|
||||
3. Backend servers go stale between tickets (old uvicorn missing new routes).
|
||||
QA handles restarts, but expect it.
|
||||
- Baked the retrospective's structural fixes into the agent definitions
|
||||
(chore `f8048da`): be/fe-implementer now MUST paste `git status`, test
|
||||
output, and a live smoke test; qa is adversarial (distrust self-reports,
|
||||
confirm features via `/openapi.json`), restarts both servers + resets the
|
||||
dev DB before testing, and writes expected numbers into scenarios. This
|
||||
eliminated the false-success-report failure mode on 006.
|
||||
- `.playwright-cli/` + `*.png` are now gitignored; QA writes artifacts to
|
||||
`/tmp`.
|
||||
- The scout agent earned its keep on 006: primed exact seams for both
|
||||
stacks, saving re-derivation. Its one slip (recent-foods ordering by
|
||||
`created_at` vs `daily_log` appearance) was caught and corrected in the
|
||||
implementer prompt.
|
||||
- Split 006 into backend → QA(curl) → commit, then frontend → QA(playwright)
|
||||
→ commit, with an orchestrator diff review before each QA run. Two commits
|
||||
for one ticket (across stacks) gave clean checkpoints.
|
||||
- Bug-fix loop on 006 backend (OFF search 500 on upstream 503) was cheap and
|
||||
effective — one focused implementer call + verify.
|
||||
|
||||
## Where to pick up: TICKET-006 (Milestone M2)
|
||||
## Where to pick up: TICKET-007 (Milestone M3 — Meals)
|
||||
|
||||
OFF normalization + barcode scan & search flows. Depends on 005 (done).
|
||||
Read the TICKET-006 section of `IMPLEMENTATION_PLAN.md` — key points:
|
||||
Meals: `meal_components` table, `POST /api/meals/from-log`, `POST
|
||||
/api/meals/{meal_id}/unpack`, `PUT /api/meals/{meal_id}/components`, and
|
||||
real derived meal nutrition (recursive component summation + cycle
|
||||
detection in `services/nutrition.py`). Read the TICKET-007 section of
|
||||
`IMPLEMENTATION_PLAN.md` — key points:
|
||||
|
||||
- Backend: OFF → foods normalization in exactly one module (kcal/kJ mapping,
|
||||
User-Agent, timeouts); `GET /api/off/product/{barcode}`, `GET /api/off/search`,
|
||||
`POST /api/off/refresh/{food_id}`; restore-on-rescan rule (§3.1); implement
|
||||
`GET /api/foods/recent`; httpx mocked at the boundary in tests.
|
||||
- Frontend: `BarcodeScanner.svelte` + `lib/scanner.js` (native
|
||||
`BarcodeDetector` with lazy zxing-wasm fallback, ~3–5 fps decode loop,
|
||||
camera teardown on destroy); scan flow per §4.1 with manual barcode
|
||||
fallback; OFF fallback in search per §4.2; recent foods surfaced in UI.
|
||||
- **Camera testing caveat (from the user):** a webcam exists but real scanner
|
||||
verification (esp. the §8.4 phone checklist — Android Chrome, iOS Safari,
|
||||
EAN-13/UPC-A) needs the user manually over Caddy HTTPS. Don't block the
|
||||
ticket on camera QA: verify the manual-barcode fallback and OFF search
|
||||
flows via playwright, and mark the phone checklist as pending user testing.
|
||||
- **This is the highest-complexity ticket.** Recursion, cycle detection,
|
||||
and transactional rollback are the spec's named testing priorities
|
||||
(§8.4). Build the service layer first with direct unit tests, then wire
|
||||
routers. Run implementer and QA as **separate** calls (not a chain) with
|
||||
an orchestrator diff review in between (retrospective lesson #11).
|
||||
- Backend: `POST /api/meals/from-log` and `POST /api/meals/{meal_id}/unpack`
|
||||
are one-transaction-each (commit once or roll back entirely — §8.1 rule 6).
|
||||
`PUT /api/meals/{meal_id}/components` replaces the component list wholesale,
|
||||
cycle-checked. Meal nutrition = recursive component summation with a
|
||||
visited-set, both for nutrition reads and cycle checks on write.
|
||||
- Remove the ticket-004 TODO (meals contributing 0 to summary). Summary
|
||||
(ticket 004), `GET /api/foods/{id}`, and `GET /api/log` responses must now
|
||||
show real derived meal nutrition; log responses include meal components
|
||||
nested for the collapsible UI (§3.3).
|
||||
- Unpack quantity scaling (§3.2): each component's quantity × the original
|
||||
meal entry's scaling factor (1.5× meal → 1.5× each component). Pin this
|
||||
with a dedicated test.
|
||||
- Cycle attempts (meal containing itself, directly or transitively) → 422.
|
||||
- Frontend: "Save as Meal" (multi-select today's entries → name → replace
|
||||
with meal entry), collapsible meal rows (collapsed = name + total kcal,
|
||||
expanded = components), "Unpack" action, meal component editing from the
|
||||
food editor for `is_meal` foods (§4.7).
|
||||
|
||||
## TICKET-006 notes (for reference / loose ends)
|
||||
|
||||
- Backend OFF normalizer is `backend/services/off.py` (the ONE module,
|
||||
§8.1 rule 10): kcal/kJ fallback, not-found rule, graceful degradation on
|
||||
upstream 503/timeout (returns `None`/`[]`, never 500). `GET /api/off/product`,
|
||||
`/api/off/search`, `POST /api/off/refresh/{food_id}` (404 unknown id, 400
|
||||
no-barcode). Restore-on-rescan in `services/foods.py` `create_food()`.
|
||||
`GET /api/foods/recent` orders by `daily_log.created_at` (most recent log
|
||||
ACTION, deliberately NOT by the log `date` field — see docstring; QA once
|
||||
read this as a bug, it's a tested, deliberate choice).
|
||||
- Frontend scan flow lives in `App.svelte` as a phase state machine
|
||||
(`null`/`localFound`/`offFound`/`notFound`/`error`). `lib/scanner.js` is
|
||||
the camera+decode loop (native BarcodeDetector + lazy zxing-wasm,
|
||||
~4fps, teardown in `stop()`). `BarcodeScanner.svelte` stops on first
|
||||
detect + on destroy (§8.2 rule 6).
|
||||
- **§8.4 phone checklist PENDING USER TESTING** (native BarcodeDetector on
|
||||
Android Chrome, zxing-wasm on iOS Safari, EAN-13/UPC-A decode). Requires
|
||||
Caddy HTTPS. The manual-barcode fallback + OFF search flows are
|
||||
playwright-verified; do not regress them.
|
||||
- **Dev-only Vite proxy caching** observed by QA: empty OFF search
|
||||
responses were cached within a Vite dev session; a fresh Vite restart
|
||||
cleared it. Not a code defect (production build unaffected). If it
|
||||
recurs, check the vite proxy config (`changeOrigin`, cache headers).
|
||||
|
||||
## Loose ends / chores
|
||||
|
||||
|
||||
+300
-15
@@ -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>
|
||||
|
||||
@@ -1,5 +1,197 @@
|
||||
<script>
|
||||
// BarcodeScanner — Camera scanner with permission-denied fallback to manual input (spec §4.1). Uses lib/scanner.js; stop the stream on destroy (spec §8.2 rule 6).
|
||||
// BarcodeScanner — Camera scanner with permission-denied fallback to
|
||||
// manual input (spec §4.1). Uses lib/scanner.js; stops the stream on
|
||||
// destroy (spec §8.2 rule 6).
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { startScanner, isPermissionDeniedError, hasNativeBarcodeDetector } from '../lib/scanner.js'
|
||||
|
||||
/** @type {(barcode: string) => void} */
|
||||
let { onBarcode } = $props()
|
||||
|
||||
let videoEl = $state(null)
|
||||
let error = $state(null)
|
||||
let loading = $state(true)
|
||||
let manualBarcode = $state('')
|
||||
let cameraUnavailable = $state(false)
|
||||
|
||||
/** @type {{ stop: () => void } | null} */
|
||||
let stopHandle = null
|
||||
|
||||
function handleDetected(barcode) {
|
||||
// Stop scanning once we have a barcode
|
||||
if (stopHandle) {
|
||||
stopHandle.stop()
|
||||
stopHandle = null
|
||||
}
|
||||
onBarcode(barcode)
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
loading = false
|
||||
if (isPermissionDeniedError(err)) {
|
||||
cameraUnavailable = true
|
||||
error = 'Camera access needed for barcode scanning. Check your browser settings.'
|
||||
} else if (err.name === 'NotFoundError' || err.message?.includes('No video')) {
|
||||
cameraUnavailable = true
|
||||
error = 'No camera found on this device.'
|
||||
} else {
|
||||
error = `Camera error: ${err.message}`
|
||||
cameraUnavailable = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleManualSubmit(e) {
|
||||
e.preventDefault()
|
||||
const code = manualBarcode.trim()
|
||||
if (!code) return
|
||||
handleDetected(code)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (videoEl) {
|
||||
stopHandle = startScanner(videoEl, {
|
||||
onDetect: handleDetected,
|
||||
onError: handleError,
|
||||
})
|
||||
// Camera started successfully (or at least getUserMedia was called)
|
||||
// If the video starts playing, clear loading
|
||||
videoEl.addEventListener('play', () => {
|
||||
loading = false
|
||||
}, { once: true })
|
||||
}
|
||||
return () => {
|
||||
if (stopHandle) {
|
||||
stopHandle.stop()
|
||||
stopHandle = null
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<p>BarcodeScanner (placeholder)</p>
|
||||
<div class="barcode-scanner">
|
||||
{#if error}
|
||||
<p class="err" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && !cameraUnavailable}
|
||||
<p class="status">Starting camera…</p>
|
||||
{/if}
|
||||
|
||||
<!-- Camera viewport — hidden when unavailable or permission denied -->
|
||||
<div class="camera-container" class:hidden={cameraUnavailable}>
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="scanner-video"
|
||||
></video>
|
||||
{#if loading}
|
||||
<p class="status">Starting camera…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Manual barcode fallback (always available, primary when camera is gone) -->
|
||||
<div class="manual-fallback">
|
||||
<p class="hint">
|
||||
{#if cameraUnavailable}
|
||||
Enter a barcode number manually:
|
||||
{:else}
|
||||
Or type a barcode:
|
||||
{/if}
|
||||
</p>
|
||||
<form class="manual-form" onsubmit={handleManualSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="e.g. 3017620422003"
|
||||
bind:value={manualBarcode}
|
||||
class="barcode-input"
|
||||
/>
|
||||
<button type="submit" disabled={!manualBarcode.trim()}>Go</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barcode-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.camera-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scanner-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* mirror for front-facing cameras; environment is usually not mirrored */
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
background: #fef2f2;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manual-fallback {
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.manual-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.barcode-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg, #fff);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,24 +92,115 @@
|
||||
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>
|
||||
|
||||
<div class="log-form">
|
||||
<p class="brand">{justSaved.food.brand}</p>
|
||||
|
||||
<label>
|
||||
Quantity
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0.1"
|
||||
bind:value={logQuantity}
|
||||
class="qty-input"
|
||||
/>
|
||||
{justSaved.food.unit_type === 'count' ? 'items' : 'g'}
|
||||
</label>
|
||||
<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>
|
||||
|
||||
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Creation/edit form -->
|
||||
<h3>{isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
@@ -80,14 +213,29 @@
|
||||
<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}
|
||||
|
||||
<fieldset>
|
||||
<legend>Unit type</legend>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="unitType" value="weight" bind:group={unitType} />
|
||||
<input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
|
||||
Weight (nutrition per 100g)
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="unitType" value="count" bind:group={unitType} />
|
||||
<input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
|
||||
Count (nutrition per item)
|
||||
</label>
|
||||
</fieldset>
|
||||
@@ -135,11 +283,12 @@
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save food'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}>
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<script>
|
||||
// FoodSearch — Free-text search: local DB search, select, log (spec §4.2, TICKET-005).
|
||||
// Full search UX (recent foods, OFF fallback) is TICKET-006.
|
||||
// FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2).
|
||||
// Selecting a local food → log it; selecting an OFF result → FoodEditor pre-filled.
|
||||
|
||||
import { api } from '../lib/api.js'
|
||||
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
|
||||
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
|
||||
import FoodEditor from './FoodEditor.svelte'
|
||||
|
||||
let query = $state('')
|
||||
let results = $state(null) // null = not searched yet, [] = no results
|
||||
let searching = $state(false)
|
||||
let searchError = $state(null)
|
||||
|
||||
// Selected food for logging
|
||||
// OFF fallback state
|
||||
let offResults = $state(null) // null = not searched OFF, [] = no off results
|
||||
let offSearching = $state(false)
|
||||
let offError = $state(null)
|
||||
let offQueried = $state(false) // track if we've already tried OFF for this query
|
||||
|
||||
// Selected local food for logging
|
||||
let selected = $state(null)
|
||||
let logQuantity = $state(1)
|
||||
let logMealSlot = $state('')
|
||||
@@ -20,14 +27,19 @@
|
||||
|
||||
let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity)))
|
||||
|
||||
// Selected OFF food for pre-fill
|
||||
let offSelected = $state(null) // the normalized OFF food dict
|
||||
|
||||
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
|
||||
|
||||
async function doSearch() {
|
||||
const q = query.trim()
|
||||
if (!q) { results = null; return }
|
||||
if (!q) { results = null; offResults = null; offQueried = false; return }
|
||||
|
||||
searching = true
|
||||
searchError = null
|
||||
offResults = null
|
||||
offQueried = false
|
||||
try {
|
||||
results = await api.searchFoods(q)
|
||||
} catch (e) {
|
||||
@@ -38,6 +50,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchOff() {
|
||||
const q = query.trim()
|
||||
if (!q) return
|
||||
|
||||
offSearching = true
|
||||
offError = null
|
||||
offQueried = true
|
||||
try {
|
||||
offResults = await api.offSearch(q)
|
||||
} catch (e) {
|
||||
offError = e.message
|
||||
offResults = []
|
||||
} finally {
|
||||
offSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickFood(food) {
|
||||
selected = food
|
||||
logQuantity = defaultQuantity(food)
|
||||
@@ -62,10 +91,11 @@
|
||||
date: currentDate.value,
|
||||
})
|
||||
await addLogEntryToStore(entry)
|
||||
// Reset and go back to dashboard
|
||||
selected = null
|
||||
query = ''
|
||||
results = null
|
||||
offResults = null
|
||||
offQueried = false
|
||||
appView.current = 'dashboard'
|
||||
} catch (e) {
|
||||
logError = e.message
|
||||
@@ -73,15 +103,27 @@
|
||||
logging = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickOffFood(offFood) {
|
||||
offSelected = offFood
|
||||
}
|
||||
|
||||
function clearOffSelection() {
|
||||
offSelected = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="food-search">
|
||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
||||
|
||||
{#if !selected}
|
||||
{#if offSelected}
|
||||
<!-- OFF food selected → pre-fill FoodEditor -->
|
||||
<FoodEditor food={offSelected} onCancel={() => { offSelected = null }} />
|
||||
|
||||
{:else if !selected}
|
||||
<!-- Search -->
|
||||
<h3>Find a food</h3>
|
||||
<form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch() }}>
|
||||
<form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch(); offResults = null; offQueried = false }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search foods…"
|
||||
@@ -95,10 +137,9 @@
|
||||
<p class="status">Searching…</p>
|
||||
{:else if searchError}
|
||||
<p class="status err" role="alert">{searchError}</p>
|
||||
{:else if results !== null}
|
||||
{#if results.length === 0}
|
||||
<p class="status">No foods found.</p>
|
||||
{:else}
|
||||
{:else if results !== null && results.length > 0}
|
||||
<!-- Local results found -->
|
||||
<p class="status">{results.length} local result{results.length !== 1 ? 's' : ''}</p>
|
||||
<ul class="results-list">
|
||||
{#each results as food (food.id)}
|
||||
<li>
|
||||
@@ -112,18 +153,64 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{:else if results !== null && results.length === 0}
|
||||
<!-- No local results — offer OFF fallback -->
|
||||
<p class="status">No foods found locally.</p>
|
||||
|
||||
{#if !offQueried}
|
||||
<div class="off-fallback">
|
||||
<p class="off-hint">Not found locally — search OpenFoodFacts?</p>
|
||||
<button type="button" class="off-btn" onclick={searchOff} disabled={offSearching}>
|
||||
{offSearching ? 'Searching OFF…' : 'Search OpenFoodFacts'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- OFF results (also shown if there ARE local results but user wants more) -->
|
||||
{#if offQueried}
|
||||
{#if offSearching}
|
||||
<p class="status">Searching OpenFoodFacts…</p>
|
||||
{:else if offError}
|
||||
<p class="status err" role="alert">OFF search failed: {offError}</p>
|
||||
{:else if offResults && offResults.length > 0}
|
||||
<p class="status off-status">OpenFoodFacts results:</p>
|
||||
<ul class="results-list off-list">
|
||||
{#each offResults as offFood, i (`off-${i}`)}
|
||||
<li>
|
||||
<button type="button" class="result-item" onclick={() => pickOffFood(offFood)}>
|
||||
<span class="r-name">{offFood.name}</span>
|
||||
{#if offFood.brand}<span class="r-brand">{offFood.brand}</span>{/if}
|
||||
<span class="r-kcal">{formatKcal(offFood.calories_per_unit)}/100g</span>
|
||||
<span class="r-source">OFF</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if offResults !== null && offResults.length === 0}
|
||||
<p class="status">No results from OpenFoodFacts either.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !offQueried && results !== null && results.length > 0}
|
||||
<!-- Offer OFF search even when local results exist -->
|
||||
<div class="off-fallback">
|
||||
<button type="button" class="link off-link" onclick={searchOff} disabled={offSearching}>
|
||||
Also search OpenFoodFacts
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="or-create">
|
||||
Or
|
||||
<button type="button" class="link" onclick={() => appView.current = 'createFood'}>
|
||||
create a new food
|
||||
create a new food manually
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{:else}
|
||||
<!-- Logging form for selected food -->
|
||||
<!-- Logging form for selected local food -->
|
||||
<h3>Log "{selected.name}"</h3>
|
||||
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
|
||||
|
||||
@@ -221,11 +308,51 @@
|
||||
.r-name { font-weight: 600; }
|
||||
.r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
|
||||
.r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); }
|
||||
.or-create {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
.r-source {
|
||||
font-size: 0.7rem;
|
||||
background: var(--bg-muted, #e5e7eb);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.off-fallback {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--border, #d1d5db);
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.off-hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.off-btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg, #fff);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.off-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.off-list {
|
||||
border-color: var(--border, #d1d5db);
|
||||
}
|
||||
.off-status {
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.off-link {
|
||||
color: var(--link, #2563eb);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -235,6 +362,11 @@
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.or-create {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
|
||||
.log-form {
|
||||
display: flex;
|
||||
|
||||
@@ -28,9 +28,11 @@ export const api = {
|
||||
health: () => request('/api/health'),
|
||||
// Foods (spec §3.1)
|
||||
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
|
||||
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
|
||||
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
||||
getFood: (id) => request(`/api/foods/${id}`),
|
||||
createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }),
|
||||
updateFood: (id, food) => request(`/api/foods/${id}`, { method: 'PUT', body: JSON.stringify(food) }),
|
||||
// Daily log (spec §3.3) — dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4)
|
||||
getLog: (date) => request(`/api/log?date=${date}`),
|
||||
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
|
||||
@@ -44,4 +46,5 @@ export const api = {
|
||||
// OFF proxy (spec §3.5) — the frontend never calls OFF directly
|
||||
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
||||
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
||||
offRefresh: (foodId) => request(`/api/off/refresh/${foodId}`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
+150
-9
@@ -12,20 +12,161 @@
|
||||
|
||||
// zxing-wasm is the fallback decoder; imported lazily so Chromium users
|
||||
// on the native path never pay the WASM download cost.
|
||||
// import { readBarcodes } from 'zxing-wasm/reader'
|
||||
// The module exposes readBarcodesFromImageData for ImageData input.
|
||||
|
||||
/** Formats we care about for grocery barcodes. */
|
||||
const BARCODE_FORMATS = ['ean_13', 'upc_a', 'ean_8', 'upc_e']
|
||||
|
||||
/** ~4 fps: 250ms between decode attempts. */
|
||||
const DECODE_INTERVAL_MS = 250
|
||||
|
||||
export function hasNativeBarcodeDetector() {
|
||||
return typeof globalThis.BarcodeDetector !== 'undefined'
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement startScanner(videoEl, { onDetect }) → stop() handle.
|
||||
* - getUserMedia({ video: { facingMode: 'environment' } })
|
||||
* - native BarcodeDetector if hasNativeBarcodeDetector(), else zxing-wasm
|
||||
* - decode loop throttled to ~3-5 fps
|
||||
* - stop(): release tracks, cancel loop
|
||||
* - permission denied → caller shows message + manual barcode input (spec §4.1)
|
||||
* Classify an error from getUserMedia — returns true for permission-denied
|
||||
* (NotAllowedError / SecurityError), which the component uses to show the
|
||||
* manual-barcode fallback message.
|
||||
*/
|
||||
export function startScanner() {
|
||||
throw new Error('scanner not implemented yet — see spec §4.1')
|
||||
export function isPermissionDeniedError(err) {
|
||||
return err instanceof DOMException && (
|
||||
err.name === 'NotAllowedError' || err.name === 'SecurityError'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the barcode scanner. Opens the camera stream, attaches it to
|
||||
* `videoEl`, and runs a throttled decode loop. When a barcode is detected,
|
||||
* calls `onDetect(barcode)`.
|
||||
*
|
||||
* @param {HTMLVideoElement} videoEl — an <video> element already in the DOM.
|
||||
* @param {object} callbacks
|
||||
* @param {(barcode: string) => void} callbacks.onDetect
|
||||
* @param {(error: Error) => void} callbacks.onError
|
||||
* @returns {{ stop: () => void }} A handle with a `stop()` method.
|
||||
*/
|
||||
export function startScanner(videoEl, { onDetect, onError }) {
|
||||
// ── State that stop() tears down ──────────────────────────────────────
|
||||
let stream = null
|
||||
let timer = null
|
||||
let stopped = false
|
||||
let canvas = null
|
||||
let ctx = null
|
||||
|
||||
// Lazy-loaded zxing-wasm function (null until first use)
|
||||
let zxingReader = null
|
||||
|
||||
// ── Canvas for frame extraction ──────────────────────────────────────
|
||||
canvas = document.createElement('canvas')
|
||||
ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
// ── BarcodeDetector instance (native or null) ─────────────────────────
|
||||
/** @type {BarcodeDetector | null} */
|
||||
let detector = null
|
||||
if (hasNativeBarcodeDetector()) {
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: BARCODE_FORMATS })
|
||||
} catch {
|
||||
// Some browsers may support the API but reject these formats;
|
||||
// fall through to zxing-wasm.
|
||||
detector = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decode loop ───────────────────────────────────────────────────────
|
||||
async function decodeFrame() {
|
||||
if (stopped) return
|
||||
|
||||
try {
|
||||
// Extract the current video frame into our canvas
|
||||
const vw = videoEl.videoWidth
|
||||
const vh = videoEl.videoHeight
|
||||
if (vw === 0 || vh === 0) return // not playing yet
|
||||
|
||||
canvas.width = vw
|
||||
canvas.height = vh
|
||||
ctx.drawImage(videoEl, 0, 0, vw, vh)
|
||||
|
||||
let barcode = null
|
||||
|
||||
if (detector) {
|
||||
// Native BarcodeDetector path
|
||||
try {
|
||||
const detections = await detector.detect(canvas)
|
||||
if (detections.length > 0 && !stopped) {
|
||||
barcode = detections[0].rawValue
|
||||
}
|
||||
} catch {
|
||||
// Native detector can throw on some frames; ignore and try next
|
||||
}
|
||||
} else {
|
||||
// zxing-wasm fallback — load once
|
||||
if (!zxingReader) {
|
||||
const mod = await import('zxing-wasm')
|
||||
// zxing-wasm v3 exports readBarcodesFromImageData
|
||||
zxingReader = mod.readBarcodesFromImageData
|
||||
}
|
||||
try {
|
||||
const imageData = ctx.getImageData(0, 0, vw, vh)
|
||||
const results = await zxingReader(imageData, {
|
||||
formats: BARCODE_FORMATS,
|
||||
})
|
||||
if (results.length > 0 && !stopped) {
|
||||
barcode = results[0].text
|
||||
}
|
||||
} catch {
|
||||
// zxing decode errors on non-barcode frames — ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (barcode && !stopped) {
|
||||
onDetect(barcode)
|
||||
}
|
||||
} catch {
|
||||
// Frame extraction can fail if video isn't ready yet — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start camera ──────────────────────────────────────────────────────
|
||||
;(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
audio: false,
|
||||
})
|
||||
if (stopped) {
|
||||
// Component unmounted while we were waiting for permission
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
return
|
||||
}
|
||||
videoEl.srcObject = stream
|
||||
await videoEl.play()
|
||||
|
||||
// Start decode loop at ~4 fps
|
||||
timer = setInterval(decodeFrame, DECODE_INTERVAL_MS)
|
||||
} catch (err) {
|
||||
if (!stopped) {
|
||||
onError(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
// ── Stop handle ───────────────────────────────────────────────────────
|
||||
function stop() {
|
||||
stopped = true
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
stream = null
|
||||
}
|
||||
// Clear canvas reference to help GC
|
||||
canvas = null
|
||||
ctx = null
|
||||
}
|
||||
|
||||
return { stop }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hasNativeBarcodeDetector, isPermissionDeniedError } from './scanner.js'
|
||||
|
||||
describe('hasNativeBarcodeDetector', () => {
|
||||
it('returns boolean', () => {
|
||||
expect(typeof hasNativeBarcodeDetector()).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPermissionDeniedError', () => {
|
||||
it('returns true for NotAllowedError', () => {
|
||||
const err = new DOMException('Permission denied', 'NotAllowedError')
|
||||
expect(isPermissionDeniedError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for SecurityError', () => {
|
||||
const err = new DOMException('The operation is insecure', 'SecurityError')
|
||||
expect(isPermissionDeniedError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for regular Error', () => {
|
||||
expect(isPermissionDeniedError(new Error('something else'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-DOMException', () => {
|
||||
expect(isPermissionDeniedError({ name: 'NotAllowedError' })).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user