615 lines
19 KiB
Svelte
615 lines
19 KiB
Svelte
<script>
|
|
// App.svelte — Root component with conditional view switching (no router library).
|
|
// 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,
|
|
navigateTo, addLogEntryToStore, refreshDayData, mealEdit
|
|
} 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 FoodLibrary from './components/FoodLibrary.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 })
|
|
|
|
onMount(async () => {
|
|
try {
|
|
await api.health()
|
|
backend = { loading: false, ok: true, error: null }
|
|
} catch (e) {
|
|
backend = { loading: false, ok: false, error: e.message }
|
|
}
|
|
})
|
|
|
|
// ── Target form state ──────────────────────────────────────────────────────
|
|
let targetCalories = $state('2000')
|
|
let targetProtein = $state('')
|
|
let targetCarbs = $state('')
|
|
let targetFat = $state('')
|
|
let targetStartDate = $state(currentDate.value)
|
|
let targetSaving = $state(false)
|
|
let targetError = $state(null)
|
|
let targetSuccess = $state(false)
|
|
|
|
function openTargetForm() {
|
|
targetStartDate = currentDate.value
|
|
targetSuccess = false
|
|
targetError = null
|
|
appView.current = 'targetForm'
|
|
}
|
|
|
|
async function saveTarget(e) {
|
|
e.preventDefault()
|
|
const cal = parseInt(targetCalories)
|
|
if (isNaN(cal) || cal <= 0) { targetError = 'Calories must be a positive number'; return }
|
|
|
|
targetSaving = true
|
|
targetError = null
|
|
try {
|
|
await api.createTarget({
|
|
start_date: targetStartDate,
|
|
calories: cal,
|
|
protein_g: targetProtein ? parseFloat(targetProtein) : null,
|
|
carbs_g: targetCarbs ? parseFloat(targetCarbs) : null,
|
|
fat_g: targetFat ? parseFloat(targetFat) : null,
|
|
})
|
|
targetSuccess = true
|
|
setTimeout(() => {
|
|
appView.current = 'dashboard'
|
|
targetSuccess = false
|
|
}, 800)
|
|
} catch (e) {
|
|
targetError = e.message
|
|
} finally {
|
|
targetSaving = false
|
|
}
|
|
}
|
|
|
|
// ── 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) {
|
|
console.log('[App] handleBarcode:', barcode)
|
|
resetScanFlow()
|
|
scanBarcode = barcode
|
|
scanLoading = true
|
|
|
|
// Step 1: Check local DB by barcode
|
|
try {
|
|
console.log('[App] searching local DB for barcode:', barcode)
|
|
const results = await api.searchFoodsByBarcode(barcode)
|
|
console.log('[App] local search results:', results?.length ?? 0)
|
|
if (results && results.length > 0) {
|
|
localFood = results[0]
|
|
scanLogQuantity = defaultQuantity(localFood)
|
|
scanLoading = false
|
|
scanPhase = 'localFound'
|
|
console.log('[App] local food found:', localFood.name)
|
|
return
|
|
}
|
|
console.log('[App] not found locally, trying OpenFoodFacts…')
|
|
} catch (e) {
|
|
console.warn('[App] local search failed:', e)
|
|
// Local lookup failed — try OFF anyway
|
|
}
|
|
|
|
// Step 2: Not found locally → try OFF
|
|
try {
|
|
console.log('[App] fetching OFF product:', barcode)
|
|
offFood = await api.offProduct(barcode)
|
|
scanLoading = false
|
|
scanPhase = 'offFound'
|
|
console.log('[App] OFF product found:', offFood?.product_name ?? offFood?.name)
|
|
} catch (e) {
|
|
console.warn('[App] OFF lookup failed:', e.message)
|
|
// OFF miss (404) or network error
|
|
scanLoading = false
|
|
if (e.message?.includes('404') || e.message?.includes('not found')) {
|
|
scanPhase = 'notFound'
|
|
console.log('[App] OFF product not found (404)')
|
|
} else {
|
|
scanError = e.message
|
|
scanPhase = 'error'
|
|
console.error('[App] OFF lookup error:', e.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
</script>
|
|
|
|
<main>
|
|
<h1>CalCount</h1>
|
|
|
|
{#if backend.loading}
|
|
<p class="status">Connecting to backend…</p>
|
|
{:else if backend.error}
|
|
<p class="status err" role="alert">Backend unreachable: {backend.error}</p>
|
|
{:else}
|
|
{#if appView.current === 'dashboard' || appView.current === 'addFood' || appView.current === 'createFood' || appView.current === 'targetForm'}
|
|
<!-- Date navigation (always visible except on target form or when not relevant) -->
|
|
<nav class="date-nav">
|
|
<button type="button" class="nav-btn" onclick={goPrevDay} title="Previous day">←</button>
|
|
<input
|
|
type="date"
|
|
value={currentDate.value}
|
|
onchange={handleDateInput}
|
|
class="date-input"
|
|
/>
|
|
<button type="button" class="nav-btn" onclick={goNextDay} title="Next day">→</button>
|
|
<span class="date-display">{formatDate(currentDate.value)}</span>
|
|
</nav>
|
|
{/if}
|
|
|
|
{#if appView.current === 'dashboard'}
|
|
<Dashboard date={currentDate.value} />
|
|
|
|
<div class="fab-container">
|
|
<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={() => appView.current = 'foods'}>
|
|
🍔 Foods
|
|
</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 />
|
|
|
|
{:else if appView.current === 'createFood'}
|
|
<FoodEditor />
|
|
|
|
{:else if appView.current === 'foods'}
|
|
<FoodLibrary />
|
|
|
|
{:else if appView.current === 'editMeal'}
|
|
<FoodEditor mealId={mealEdit.foodId} />
|
|
|
|
{:else if appView.current === 'targetForm'}
|
|
<div class="target-form-view">
|
|
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
|
<h3>Set daily target</h3>
|
|
|
|
{#if targetSuccess}
|
|
<p class="success">Target saved!</p>
|
|
{:else}
|
|
<form onsubmit={saveTarget}>
|
|
<label>
|
|
Calorie goal <span class="required">*</span>
|
|
<input type="number" min="1" bind:value={targetCalories} required />
|
|
</label>
|
|
|
|
<label>
|
|
Start date
|
|
<input type="date" bind:value={targetStartDate} />
|
|
</label>
|
|
|
|
<fieldset>
|
|
<legend>Macros (optional, grams)</legend>
|
|
<div class="macro-grid">
|
|
<label>
|
|
Protein
|
|
<input type="number" step="any" min="0" bind:value={targetProtein} placeholder="g" />
|
|
</label>
|
|
<label>
|
|
Carbs
|
|
<input type="number" step="any" min="0" bind:value={targetCarbs} placeholder="g" />
|
|
</label>
|
|
<label>
|
|
Fat
|
|
<input type="number" step="any" min="0" bind:value={targetFat} placeholder="g" />
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{#if targetError}<p class="err" role="alert">{targetError}</p>{/if}
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" disabled={targetSaving}>
|
|
{targetSaving ? 'Saving…' : 'Save target'}
|
|
</button>
|
|
<button type="button" class="secondary" onclick={() => appView.current = 'dashboard'}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
max-width: 32rem;
|
|
margin: 0 auto;
|
|
padding: 1rem;
|
|
font-family: system-ui, sans-serif;
|
|
/* mobile-first single column */
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.25rem;
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
|
|
.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;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.nav-btn {
|
|
background: var(--bg-muted, #f3f4f6);
|
|
border: 1px solid var(--border, #d1d5db);
|
|
border-radius: 0.35rem;
|
|
padding: 0.35rem 0.6rem;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
.date-input {
|
|
padding: 0.35rem 0.5rem;
|
|
font: inherit;
|
|
border: 1px solid var(--border, #d1d5db);
|
|
border-radius: 0.35rem;
|
|
}
|
|
.date-display {
|
|
font-size: 0.9rem;
|
|
color: var(--text-muted, #6b7280);
|
|
margin-left: 0.3rem;
|
|
}
|
|
|
|
/* ── FAB / action buttons ────────────────────────────────────────── */
|
|
.fab-container {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 1.5rem;
|
|
justify-content: center;
|
|
}
|
|
.fab, .target-btn {
|
|
padding: 0.6rem 1.2rem;
|
|
border: 1px solid var(--border, #d1d5db);
|
|
border-radius: 0.35rem;
|
|
font: inherit;
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
background: var(--bg, #fff);
|
|
}
|
|
.fab {
|
|
background: var(--link, #2563eb);
|
|
color: #fff;
|
|
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 */
|
|
}
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.8rem;
|
|
}
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.required { color: #dc2626; }
|
|
input[type="number"],
|
|
input[type="date"] {
|
|
padding: 0.4rem 0.6rem;
|
|
font: inherit;
|
|
border: 1px solid var(--border, #d1d5db);
|
|
border-radius: 0.35rem;
|
|
}
|
|
fieldset {
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 0.35rem;
|
|
padding: 0.6rem 0.8rem;
|
|
}
|
|
legend {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted, #6b7280);
|
|
padding: 0 0.3rem;
|
|
}
|
|
.macro-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
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.9rem;
|
|
}
|
|
button:disabled { opacity: 0.5; cursor: default; }
|
|
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>
|