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
|
## Where we are in the plan
|
||||||
|
|
||||||
**Milestone M1 (Manual calorie tracker) is COMPLETE.** Tickets 001–005 all
|
**Milestones M1 (Manual calorie tracker) and M2 (Barcode scanning & OFF
|
||||||
implemented, QA-verified, and committed. The app is usable end-to-end for
|
integration) are COMPLETE.** Tickets 001–006 all implemented, QA-verified,
|
||||||
manual food entry and daily logging.
|
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 |
|
| Ticket | Status | Commit |
|
||||||
|--------|--------|--------|
|
|--------|--------|--------|
|
||||||
@@ -16,12 +18,12 @@ manual food entry and daily logging.
|
|||||||
| 003 Daily log write path | ✅ done, QA passed | `87d7eca` |
|
| 003 Daily log write path | ✅ done, QA passed | `87d7eca` |
|
||||||
| 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` |
|
| 004 Day summary endpoint | ✅ done, QA passed | `5372e8c` |
|
||||||
| 005 Frontend daily view | ✅ done, QA passed | `b69661c` |
|
| 005 Frontend daily view | ✅ done, QA passed | `b69661c` |
|
||||||
| 006 OFF + barcode scan/search flows | ⬜ **next** | — |
|
| 006 OFF + barcode scan/search flows | ✅ done, QA passed (backend `32461b7`, frontend below) | `32461b7` + frontend |
|
||||||
| 007 Meals (from-log, unpack, recursion) | ⬜ pending | — |
|
| 007 Meals (from-log, unpack, recursion) | ⬜ **next** | — |
|
||||||
| 008 Food library view + restore | ⬜ pending | — |
|
| 008 Food library view + restore | ⬜ pending | — |
|
||||||
|
|
||||||
Test counts at HEAD: backend **104 passed** (`cd backend && uv run pytest`),
|
Test counts at HEAD: backend **152 passed** (`cd backend && uv run pytest`),
|
||||||
frontend vitest + `npm run build` green.
|
frontend **18 vitest passed** + `npm run build` green.
|
||||||
|
|
||||||
## What was done this session
|
## 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`;
|
layout. All HTTP via `lib/api.js`; shared state in `stores.svelte.js`;
|
||||||
Svelte 5 runes only.
|
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.**
|
- Baked the retrospective's structural fixes into the agent definitions
|
||||||
This happened twice (TICKET-004 backend, first TICKET-005 frontend attempt).
|
(chore `f8048da`): be/fe-implementer now MUST paste `git status`, test
|
||||||
Mitigations that worked:
|
output, and a live smoke test; qa is adversarial (distrust self-reports,
|
||||||
- Every implementer task must require `git status --short` evidence and a
|
confirm features via `/openapi.json`), restarts both servers + resets the
|
||||||
live smoke test (curl the endpoint / build + serve) in its report.
|
dev DB before testing, and writes expected numbers into scenarios. This
|
||||||
- QA must be explicitly told not to trust the implementer's report and to
|
eliminated the false-success-report failure mode on 006.
|
||||||
verify the feature exists (e.g. check `/openapi.json` routes) before
|
- `.playwright-cli/` + `*.png` are now gitignored; QA writes artifacts to
|
||||||
testing.
|
`/tmp`.
|
||||||
2. QA (playwright) genuinely catches real bugs — it found broken date-nav
|
- The scout agent earned its keep on 006: primed exact seams for both
|
||||||
buttons and a stale-summary bug in TICKET-005; both were fixed and
|
stacks, saving re-derivation. Its one slip (recent-foods ordering by
|
||||||
re-verified before commit.
|
`created_at` vs `daily_log` appearance) was caught and corrected in the
|
||||||
3. Backend servers go stale between tickets (old uvicorn missing new routes).
|
implementer prompt.
|
||||||
QA handles restarts, but expect it.
|
- 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).
|
Meals: `meal_components` table, `POST /api/meals/from-log`, `POST
|
||||||
Read the TICKET-006 section of `IMPLEMENTATION_PLAN.md` — key points:
|
/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,
|
- **This is the highest-complexity ticket.** Recursion, cycle detection,
|
||||||
User-Agent, timeouts); `GET /api/off/product/{barcode}`, `GET /api/off/search`,
|
and transactional rollback are the spec's named testing priorities
|
||||||
`POST /api/off/refresh/{food_id}`; restore-on-rescan rule (§3.1); implement
|
(§8.4). Build the service layer first with direct unit tests, then wire
|
||||||
`GET /api/foods/recent`; httpx mocked at the boundary in tests.
|
routers. Run implementer and QA as **separate** calls (not a chain) with
|
||||||
- Frontend: `BarcodeScanner.svelte` + `lib/scanner.js` (native
|
an orchestrator diff review in between (retrospective lesson #11).
|
||||||
`BarcodeDetector` with lazy zxing-wasm fallback, ~3–5 fps decode loop,
|
- Backend: `POST /api/meals/from-log` and `POST /api/meals/{meal_id}/unpack`
|
||||||
camera teardown on destroy); scan flow per §4.1 with manual barcode
|
are one-transaction-each (commit once or roll back entirely — §8.1 rule 6).
|
||||||
fallback; OFF fallback in search per §4.2; recent foods surfaced in UI.
|
`PUT /api/meals/{meal_id}/components` replaces the component list wholesale,
|
||||||
- **Camera testing caveat (from the user):** a webcam exists but real scanner
|
cycle-checked. Meal nutrition = recursive component summation with a
|
||||||
verification (esp. the §8.4 phone checklist — Android Chrome, iOS Safari,
|
visited-set, both for nutrition reads and cycle checks on write.
|
||||||
EAN-13/UPC-A) needs the user manually over Caddy HTTPS. Don't block the
|
- Remove the ticket-004 TODO (meals contributing 0 to summary). Summary
|
||||||
ticket on camera QA: verify the manual-barcode fallback and OFF search
|
(ticket 004), `GET /api/foods/{id}`, and `GET /api/log` responses must now
|
||||||
flows via playwright, and mark the phone checklist as pending user testing.
|
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
|
## Loose ends / chores
|
||||||
|
|
||||||
|
|||||||
+300
-15
@@ -1,15 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
// App.svelte — Root component with conditional view switching (no router library).
|
// 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).
|
// Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4).
|
||||||
|
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
import { currentDate, appView, goPrevDay, goNextDay, setDate } from './lib/stores.svelte.js'
|
import {
|
||||||
import { formatDate } from './lib/format.js'
|
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 Dashboard from './components/Dashboard.svelte'
|
||||||
import FoodSearch from './components/FoodSearch.svelte'
|
import FoodSearch from './components/FoodSearch.svelte'
|
||||||
import FoodEditor from './components/FoodEditor.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)
|
// Health check — every async view handles loading/error states (spec §8.2 rule 4)
|
||||||
let backend = $state({ loading: true, ok: false, error: null })
|
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 ───────────────────────────────────────────────────────
|
// ── Date navigation ───────────────────────────────────────────────────────
|
||||||
function handleDateInput(e) {
|
function handleDateInput(e) {
|
||||||
setDate(e.target.value)
|
setDate(e.target.value)
|
||||||
@@ -103,11 +230,107 @@
|
|||||||
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
|
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
|
||||||
+ Add Food
|
+ Add Food
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
|
||||||
|
📷 Scan
|
||||||
|
</button>
|
||||||
<button type="button" class="target-btn" onclick={openTargetForm}>
|
<button type="button" class="target-btn" onclick={openTargetForm}>
|
||||||
🎯 Target
|
🎯 Target
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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'}
|
{:else if appView.current === 'addFood'}
|
||||||
<FoodSearch />
|
<FoodSearch />
|
||||||
|
|
||||||
@@ -185,6 +408,17 @@
|
|||||||
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
|
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
|
||||||
.status.err { color: #dc2626; }
|
.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 navigation ─────────────────────────────────────────────── */
|
||||||
.date-nav {
|
.date-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -235,23 +469,68 @@
|
|||||||
border-color: var(--link, #2563eb);
|
border-color: var(--link, #2563eb);
|
||||||
font-weight: 600;
|
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 ─────────────────────────────────────────────────── */
|
||||||
.target-form-view {
|
.target-form-view {
|
||||||
/* mobile-first single column */
|
/* 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 {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -305,4 +584,10 @@
|
|||||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||||
.err { color: #dc2626; font-size: 0.85rem; }
|
.err { color: #dc2626; font-size: 0.85rem; }
|
||||||
.success { color: #16a34a; font-size: 1rem; font-weight: 600; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,197 @@
|
|||||||
<script>
|
<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>
|
</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>
|
<script>
|
||||||
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6).
|
// 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).
|
// 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 { onMount } from 'svelte'
|
||||||
import { formatDate } from '../lib/format.js'
|
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 ProgressBar from './ProgressBar.svelte'
|
||||||
import LogEntry from './LogEntry.svelte'
|
import LogEntry from './LogEntry.svelte'
|
||||||
|
|
||||||
@@ -16,6 +19,49 @@
|
|||||||
refreshDayData()
|
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")
|
// Group entries by meal_slot (null → "Other")
|
||||||
let groups = $derived.by(() => {
|
let groups = $derived.by(() => {
|
||||||
const log = dayData.log
|
const log = dayData.log
|
||||||
@@ -55,6 +101,28 @@
|
|||||||
target={dayData.summary?.target}
|
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}
|
{#if dayData.log.length === 0}
|
||||||
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -93,6 +161,53 @@
|
|||||||
border-radius: 0.5rem;
|
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 {
|
.meal-group {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
<script>
|
<script>
|
||||||
// FoodEditor — Manual food creation form (spec §4.5).
|
// FoodEditor — Food creation/editing form (spec §4.5).
|
||||||
// Shared by scan/search/library flows; here used for manual creation.
|
// 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 { 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 name = $state('')
|
||||||
let brand = $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 caloriesPerUnit = $state('')
|
||||||
let proteinPerUnit = $state('')
|
let proteinPerUnit = $state('')
|
||||||
let carbsPerUnit = $state('')
|
let carbsPerUnit = $state('')
|
||||||
let fatPerUnit = $state('')
|
let fatPerUnit = $state('')
|
||||||
let servingSizeG = $state('')
|
let servingSizeG = $state('')
|
||||||
let servingName = $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 saving = $state(false)
|
||||||
let error = $state(null)
|
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() {
|
function reset() {
|
||||||
name = ''
|
name = ''
|
||||||
brand = ''
|
brand = ''
|
||||||
|
barcode = ''
|
||||||
|
source = 'manual'
|
||||||
unitType = 'weight'
|
unitType = 'weight'
|
||||||
caloriesPerUnit = ''
|
caloriesPerUnit = ''
|
||||||
proteinPerUnit = ''
|
proteinPerUnit = ''
|
||||||
@@ -28,6 +68,8 @@
|
|||||||
fatPerUnit = ''
|
fatPerUnit = ''
|
||||||
servingSizeG = ''
|
servingSizeG = ''
|
||||||
servingName = ''
|
servingName = ''
|
||||||
|
isPrefilled = false
|
||||||
|
justSaved = null
|
||||||
error = null
|
error = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +82,7 @@
|
|||||||
saving = true
|
saving = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
const food = await api.createFood({
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
brand: brand.trim() || null,
|
brand: brand.trim() || null,
|
||||||
unit_type: unitType,
|
unit_type: unitType,
|
||||||
@@ -50,96 +92,203 @@
|
|||||||
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
|
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
|
||||||
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
||||||
serving_name: servingName.trim() || null,
|
serving_name: servingName.trim() || null,
|
||||||
source: 'manual',
|
source,
|
||||||
is_meal: false,
|
is_meal: false,
|
||||||
})
|
}
|
||||||
reset()
|
// Include barcode when present
|
||||||
// Go to search so user can log the new food immediately
|
if (barcode.trim()) payload.barcode = barcode.trim()
|
||||||
appView.current = 'addFood'
|
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) {
|
} catch (e) {
|
||||||
error = e.message
|
error = e.message
|
||||||
} finally {
|
} finally {
|
||||||
saving = false
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="food-editor">
|
<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}>
|
<div class="log-form">
|
||||||
<label>
|
<p class="brand">{justSaved.food.brand}</p>
|
||||||
Name <span class="required">*</span>
|
|
||||||
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Brand
|
Quantity
|
||||||
<input type="text" bind:value={brand} placeholder="Optional" />
|
<input
|
||||||
</label>
|
type="number"
|
||||||
|
step="any"
|
||||||
<fieldset>
|
min="0.1"
|
||||||
<legend>Unit type</legend>
|
bind:value={logQuantity}
|
||||||
<label class="radio-label">
|
class="qty-input"
|
||||||
<input type="radio" name="unitType" value="weight" bind:group={unitType} />
|
/>
|
||||||
Weight (nutrition per 100g)
|
{justSaved.food.unit_type === 'count' ? 'items' : 'g'}
|
||||||
</label>
|
</label>
|
||||||
<label class="radio-label">
|
<p class="preview-kcal">= {formatKcal(liveKcal)}</p>
|
||||||
<input type="radio" name="unitType" value="count" bind:group={unitType} />
|
|
||||||
Count (nutrition per item)
|
<label>
|
||||||
|
Meal slot
|
||||||
|
<select bind:value={logMealSlot}>
|
||||||
|
{#each SLOTS as s}
|
||||||
|
<option value={s}>{s || '(none)'}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<label>
|
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
|
||||||
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>
|
|
||||||
|
|
||||||
<fieldset>
|
<div class="log-actions">
|
||||||
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
|
<button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
|
||||||
<div class="macro-grid">
|
{logging ? 'Logging…' : 'Log it'}
|
||||||
<label>
|
</button>
|
||||||
Protein
|
<button type="button" class="secondary" onclick={skipLog} disabled={logging}>
|
||||||
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
|
Skip
|
||||||
</label>
|
</button>
|
||||||
<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>
|
</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>
|
<fieldset>
|
||||||
<legend>Serving info (optional)</legend>
|
<legend>Unit type</legend>
|
||||||
<label>
|
<label class="radio-label">
|
||||||
Serving size (g)
|
<input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
|
||||||
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" />
|
Weight (nutrition per 100g)
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="radio-label">
|
||||||
Serving name
|
<input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
|
||||||
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' />
|
Count (nutrition per item)
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</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">
|
<fieldset>
|
||||||
<button type="submit" disabled={saving}>
|
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
|
||||||
{saving ? 'Saving…' : 'Save food'}
|
<div class="macro-grid">
|
||||||
</button>
|
<label>
|
||||||
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}>
|
Protein
|
||||||
Cancel
|
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<label>
|
||||||
</form>
|
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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -185,6 +334,22 @@
|
|||||||
border-radius: 0.35rem;
|
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 {
|
fieldset {
|
||||||
border: 1px solid var(--border, #e5e7eb);
|
border: 1px solid var(--border, #e5e7eb);
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.35rem;
|
||||||
@@ -217,6 +382,38 @@
|
|||||||
margin-top: 0.5rem;
|
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 {
|
button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid var(--border, #d1d5db);
|
border: 1px solid var(--border, #d1d5db);
|
||||||
@@ -230,4 +427,10 @@
|
|||||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||||
|
|
||||||
.err { color: #dc2626; font-size: 0.85rem; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
// FoodSearch — Free-text search: local DB search, select, log (spec §4.2, TICKET-005).
|
// FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2).
|
||||||
// Full search UX (recent foods, OFF fallback) is TICKET-006.
|
// Selecting a local food → log it; selecting an OFF result → FoodEditor pre-filled.
|
||||||
|
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
|
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
|
||||||
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
|
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
|
||||||
|
import FoodEditor from './FoodEditor.svelte'
|
||||||
|
|
||||||
let query = $state('')
|
let query = $state('')
|
||||||
let results = $state(null) // null = not searched yet, [] = no results
|
let results = $state(null) // null = not searched yet, [] = no results
|
||||||
let searching = $state(false)
|
let searching = $state(false)
|
||||||
let searchError = $state(null)
|
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 selected = $state(null)
|
||||||
let logQuantity = $state(1)
|
let logQuantity = $state(1)
|
||||||
let logMealSlot = $state('')
|
let logMealSlot = $state('')
|
||||||
@@ -20,14 +27,19 @@
|
|||||||
|
|
||||||
let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity)))
|
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']
|
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
const q = query.trim()
|
const q = query.trim()
|
||||||
if (!q) { results = null; return }
|
if (!q) { results = null; offResults = null; offQueried = false; return }
|
||||||
|
|
||||||
searching = true
|
searching = true
|
||||||
searchError = null
|
searchError = null
|
||||||
|
offResults = null
|
||||||
|
offQueried = false
|
||||||
try {
|
try {
|
||||||
results = await api.searchFoods(q)
|
results = await api.searchFoods(q)
|
||||||
} catch (e) {
|
} 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) {
|
function pickFood(food) {
|
||||||
selected = food
|
selected = food
|
||||||
logQuantity = defaultQuantity(food)
|
logQuantity = defaultQuantity(food)
|
||||||
@@ -62,10 +91,11 @@
|
|||||||
date: currentDate.value,
|
date: currentDate.value,
|
||||||
})
|
})
|
||||||
await addLogEntryToStore(entry)
|
await addLogEntryToStore(entry)
|
||||||
// Reset and go back to dashboard
|
|
||||||
selected = null
|
selected = null
|
||||||
query = ''
|
query = ''
|
||||||
results = null
|
results = null
|
||||||
|
offResults = null
|
||||||
|
offQueried = false
|
||||||
appView.current = 'dashboard'
|
appView.current = 'dashboard'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError = e.message
|
logError = e.message
|
||||||
@@ -73,15 +103,27 @@
|
|||||||
logging = false
|
logging = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickOffFood(offFood) {
|
||||||
|
offSelected = offFood
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOffSelection() {
|
||||||
|
offSelected = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="food-search">
|
<div class="food-search">
|
||||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
<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 -->
|
<!-- Search -->
|
||||||
<h3>Find a food</h3>
|
<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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search foods…"
|
placeholder="Search foods…"
|
||||||
@@ -95,35 +137,80 @@
|
|||||||
<p class="status">Searching…</p>
|
<p class="status">Searching…</p>
|
||||||
{:else if searchError}
|
{:else if searchError}
|
||||||
<p class="status err" role="alert">{searchError}</p>
|
<p class="status err" role="alert">{searchError}</p>
|
||||||
{:else if results !== null}
|
{:else if results !== null && results.length > 0}
|
||||||
{#if results.length === 0}
|
<!-- Local results found -->
|
||||||
<p class="status">No foods found.</p>
|
<p class="status">{results.length} local result{results.length !== 1 ? 's' : ''}</p>
|
||||||
{:else}
|
<ul class="results-list">
|
||||||
<ul class="results-list">
|
{#each results as food (food.id)}
|
||||||
{#each results as food (food.id)}
|
<li>
|
||||||
|
<button type="button" class="result-item" onclick={() => pickFood(food)}>
|
||||||
|
<span class="r-name">{food.name}</span>
|
||||||
|
{#if food.brand}<span class="r-brand">{food.brand}</span>{/if}
|
||||||
|
<span class="r-kcal">{formatKcal(food.calories_per_unit)}
|
||||||
|
{food.unit_type === 'count' ? '/item' : '/100g'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</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>
|
<li>
|
||||||
<button type="button" class="result-item" onclick={() => pickFood(food)}>
|
<button type="button" class="result-item" onclick={() => pickOffFood(offFood)}>
|
||||||
<span class="r-name">{food.name}</span>
|
<span class="r-name">{offFood.name}</span>
|
||||||
{#if food.brand}<span class="r-brand">{food.brand}</span>{/if}
|
{#if offFood.brand}<span class="r-brand">{offFood.brand}</span>{/if}
|
||||||
<span class="r-kcal">{formatKcal(food.calories_per_unit)}
|
<span class="r-kcal">{formatKcal(offFood.calories_per_unit)}/100g</span>
|
||||||
{food.unit_type === 'count' ? '/item' : '/100g'}
|
<span class="r-source">OFF</span>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{:else if offResults !== null && offResults.length === 0}
|
||||||
|
<p class="status">No results from OpenFoodFacts either.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/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">
|
<p class="or-create">
|
||||||
Or
|
Or
|
||||||
<button type="button" class="link" onclick={() => appView.current = 'createFood'}>
|
<button type="button" class="link" onclick={() => appView.current = 'createFood'}>
|
||||||
create a new food
|
create a new food manually
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Logging form for selected food -->
|
<!-- Logging form for selected local food -->
|
||||||
<h3>Log "{selected.name}"</h3>
|
<h3>Log "{selected.name}"</h3>
|
||||||
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
|
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
|
||||||
|
|
||||||
@@ -221,11 +308,51 @@
|
|||||||
.r-name { font-weight: 600; }
|
.r-name { font-weight: 600; }
|
||||||
.r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
|
.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); }
|
.r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); }
|
||||||
.or-create {
|
.r-source {
|
||||||
margin-top: 0.75rem;
|
font-size: 0.7rem;
|
||||||
font-size: 0.9rem;
|
background: var(--bg-muted, #e5e7eb);
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
color: var(--text-muted, #6b7280);
|
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 {
|
button.link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -235,6 +362,11 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 0;
|
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; }
|
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
|
||||||
.log-form {
|
.log-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export const api = {
|
|||||||
health: () => request('/api/health'),
|
health: () => request('/api/health'),
|
||||||
// Foods (spec §3.1)
|
// Foods (spec §3.1)
|
||||||
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
|
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}`),
|
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
||||||
getFood: (id) => request(`/api/foods/${id}`),
|
getFood: (id) => request(`/api/foods/${id}`),
|
||||||
createFood: (food) => request('/api/foods', { method: 'POST', body: JSON.stringify(food) }),
|
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)
|
// 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}`),
|
getLog: (date) => request(`/api/log?date=${date}`),
|
||||||
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
|
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
|
// OFF proxy (spec §3.5) — the frontend never calls OFF directly
|
||||||
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
||||||
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
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
|
// zxing-wasm is the fallback decoder; imported lazily so Chromium users
|
||||||
// on the native path never pay the WASM download cost.
|
// 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() {
|
export function hasNativeBarcodeDetector() {
|
||||||
return typeof globalThis.BarcodeDetector !== 'undefined'
|
return typeof globalThis.BarcodeDetector !== 'undefined'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: implement startScanner(videoEl, { onDetect }) → stop() handle.
|
* Classify an error from getUserMedia — returns true for permission-denied
|
||||||
* - getUserMedia({ video: { facingMode: 'environment' } })
|
* (NotAllowedError / SecurityError), which the component uses to show the
|
||||||
* - native BarcodeDetector if hasNativeBarcodeDetector(), else zxing-wasm
|
* manual-barcode fallback message.
|
||||||
* - decode loop throttled to ~3-5 fps
|
|
||||||
* - stop(): release tracks, cancel loop
|
|
||||||
* - permission denied → caller shows message + manual barcode input (spec §4.1)
|
|
||||||
*/
|
*/
|
||||||
export function startScanner() {
|
export function isPermissionDeniedError(err) {
|
||||||
throw new Error('scanner not implemented yet — see spec §4.1')
|
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