651 lines
19 KiB
Svelte
651 lines
19 KiB
Svelte
<script>
|
||
// FoodEditor — Food creation/editing form (spec §4.5).
|
||
// Shared by scan/search/library flows. Accepts an optional `food` prop
|
||
// for pre-filling from OFF or edit flows. When pre-filled, barcode is
|
||
// read-only and source is set from the prefill.
|
||
|
||
import { api } from '../lib/api.js'
|
||
import { currentDate, addLogEntryToStore, appView, refreshDayData } from '../lib/stores.svelte.js'
|
||
import { defaultQuantity, previewCalories, formatKcal, formatGrams } from '../lib/format.js'
|
||
|
||
/** @type {import('../lib/api.js').FoodCreate | null} */
|
||
let { food = null, mealId = null, onSaved = null, onCancel = null } = $props()
|
||
|
||
// ── Meal component editing state (spec §4.7, TICKET-007) ───────────────
|
||
// Active when `mealId` is set: edit the component list of an is_meal food.
|
||
let meal = $state(null) // MealRead: components + computed_nutrition_per_meal
|
||
let mealLoading = $state(false)
|
||
let mealLoadError = $state(null)
|
||
let components = $state([]) // [{ food_id, quantity, food }]
|
||
let compSaving = $state(false)
|
||
let compSaveError = $state(null)
|
||
let compQuery = $state('')
|
||
let compResults = $state([])
|
||
let compSearching = $state(false)
|
||
let compSearchError = $state(null)
|
||
|
||
$effect(() => {
|
||
if (mealId) loadMeal(mealId)
|
||
})
|
||
|
||
async function loadMeal(id) {
|
||
mealLoading = true
|
||
mealLoadError = null
|
||
try {
|
||
const m = await api.getFood(id)
|
||
meal = m
|
||
components = (m.components || []).map(c => ({
|
||
food_id: c.food_id,
|
||
quantity: c.quantity,
|
||
food: c.food,
|
||
}))
|
||
} catch (e) {
|
||
mealLoadError = e.message
|
||
} finally {
|
||
mealLoading = false
|
||
}
|
||
}
|
||
|
||
async function searchComponents(e) {
|
||
e.preventDefault()
|
||
if (!compQuery.trim()) return
|
||
compSearching = true
|
||
compSearchError = null
|
||
try {
|
||
compResults = await api.searchFoods(compQuery.trim())
|
||
} catch (err) {
|
||
compSearchError = err.message
|
||
compResults = []
|
||
} finally {
|
||
compSearching = false
|
||
}
|
||
}
|
||
|
||
function addComponent(result) {
|
||
if (components.some(c => c.food_id === result.id)) return
|
||
components = [...components, {
|
||
food_id: result.id,
|
||
quantity: defaultQuantity(result),
|
||
food: result,
|
||
}]
|
||
compResults = compResults.filter(r => r.id !== result.id)
|
||
}
|
||
|
||
function removeComponent(foodId) {
|
||
components = components.filter(c => c.food_id !== foodId)
|
||
}
|
||
|
||
async function saveComponents() {
|
||
compSaving = true
|
||
compSaveError = null
|
||
try {
|
||
const payload = components.map(c => ({
|
||
food_id: c.food_id,
|
||
quantity: parseFloat(c.quantity),
|
||
}))
|
||
await api.updateMealComponents(mealId, payload)
|
||
await refreshDayData() // meal nutrition changed → log/summary refresh
|
||
appView.current = 'dashboard'
|
||
} catch (e) {
|
||
// Backend rejects cycles with 422 — surface the detail to the user
|
||
compSaveError = e.message
|
||
} finally {
|
||
compSaving = false
|
||
}
|
||
}
|
||
|
||
// ── Form state ──────────────────────────────────────────────────────────
|
||
let name = $state('')
|
||
let brand = $state('')
|
||
let barcode = $state('')
|
||
let source = $state('manual')
|
||
let unitType = $state('weight')
|
||
let caloriesPerUnit = $state('')
|
||
let proteinPerUnit = $state('')
|
||
let carbsPerUnit = $state('')
|
||
let fatPerUnit = $state('')
|
||
let servingSizeG = $state('')
|
||
let servingName = $state('')
|
||
let isPrefilled = $state(false)
|
||
|
||
// ── Log-after-save state ────────────────────────────────────────────────
|
||
let justSaved = $state(null) // { food: FoodRead } after save
|
||
let logQuantity = $state(1)
|
||
let logMealSlot = $state('')
|
||
let logging = $state(false)
|
||
let logError = $state(null)
|
||
let liveKcal = $derived(justSaved ? Math.round(previewCalories(justSaved.food, logQuantity)) : 0)
|
||
|
||
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
|
||
|
||
let saving = $state(false)
|
||
let error = $state(null)
|
||
|
||
// Edit mode: an existing food (with id) was passed in — update, don't create.
|
||
let isEdit = $derived(!!food?.id)
|
||
|
||
// ── Pre-fill from food prop ─────────────────────────────────────────────
|
||
$effect(() => {
|
||
if (food) {
|
||
name = food.name || ''
|
||
brand = food.brand || ''
|
||
barcode = food.barcode || ''
|
||
source = food.source || 'manual'
|
||
unitType = food.unit_type || 'weight'
|
||
caloriesPerUnit = food.calories_per_unit != null ? String(food.calories_per_unit) : ''
|
||
proteinPerUnit = food.protein_per_unit != null ? String(food.protein_per_unit) : ''
|
||
carbsPerUnit = food.carbs_per_unit != null ? String(food.carbs_per_unit) : ''
|
||
fatPerUnit = food.fat_per_unit != null ? String(food.fat_per_unit) : ''
|
||
servingSizeG = food.serving_size_g != null ? String(food.serving_size_g) : ''
|
||
servingName = food.serving_name || ''
|
||
isPrefilled = true
|
||
}
|
||
})
|
||
|
||
function reset() {
|
||
name = ''
|
||
brand = ''
|
||
barcode = ''
|
||
source = 'manual'
|
||
unitType = 'weight'
|
||
caloriesPerUnit = ''
|
||
proteinPerUnit = ''
|
||
carbsPerUnit = ''
|
||
fatPerUnit = ''
|
||
servingSizeG = ''
|
||
servingName = ''
|
||
isPrefilled = false
|
||
justSaved = null
|
||
error = null
|
||
}
|
||
|
||
async function handleSubmit(e) {
|
||
e.preventDefault()
|
||
const cal = parseFloat(caloriesPerUnit)
|
||
if (!name.trim()) { error = 'Name is required'; return }
|
||
if (isNaN(cal) || cal <= 0) { error = 'Calories per unit is required'; return }
|
||
|
||
saving = true
|
||
error = null
|
||
try {
|
||
const payload = {
|
||
name: name.trim(),
|
||
brand: brand.trim() || null,
|
||
unit_type: unitType,
|
||
calories_per_unit: cal,
|
||
protein_per_unit: proteinPerUnit ? parseFloat(proteinPerUnit) : null,
|
||
carbs_per_unit: carbsPerUnit ? parseFloat(carbsPerUnit) : null,
|
||
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
|
||
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
||
serving_name: servingName.trim() || null,
|
||
source,
|
||
is_meal: food?.is_meal ?? false,
|
||
}
|
||
// Include barcode when present
|
||
if (barcode.trim()) payload.barcode = barcode.trim()
|
||
else payload.barcode = null
|
||
|
||
if (isEdit) {
|
||
const saved = await api.updateFood(food.id, payload)
|
||
// Nutrition edits affect historical log rendering — refresh the day
|
||
await refreshDayData()
|
||
if (onSaved) onSaved(saved)
|
||
else appView.current = 'dashboard'
|
||
return
|
||
}
|
||
|
||
const saved = await api.createFood(payload)
|
||
// After save, offer to log the new food
|
||
justSaved = { food: saved }
|
||
logQuantity = defaultQuantity(saved)
|
||
logMealSlot = ''
|
||
|
||
if (onSaved) onSaved(saved)
|
||
} catch (e) {
|
||
error = e.message
|
||
} finally {
|
||
saving = false
|
||
}
|
||
}
|
||
|
||
async function confirmLog() {
|
||
if (!justSaved || logQuantity <= 0) return
|
||
logging = true
|
||
logError = null
|
||
try {
|
||
const entry = await api.addLogEntry({
|
||
food_id: justSaved.food.id,
|
||
quantity: parseFloat(logQuantity),
|
||
meal_slot: logMealSlot || null,
|
||
date: currentDate.value,
|
||
})
|
||
await addLogEntryToStore(entry)
|
||
reset()
|
||
appView.current = 'dashboard'
|
||
} catch (e) {
|
||
logError = e.message
|
||
} finally {
|
||
logging = false
|
||
}
|
||
}
|
||
|
||
function skipLog() {
|
||
reset()
|
||
appView.current = 'dashboard'
|
||
}
|
||
|
||
function goBack() {
|
||
reset()
|
||
if (onCancel) {
|
||
onCancel()
|
||
} else if (mealId) {
|
||
appView.current = 'dashboard'
|
||
} else if (onSaved) {
|
||
// Came from scan flow with no cancel — go to dashboard
|
||
appView.current = 'dashboard'
|
||
} else {
|
||
appView.current = 'addFood'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="food-editor">
|
||
<button type="button" class="back-btn" onclick={goBack}>
|
||
← Back
|
||
</button>
|
||
|
||
{#if mealId}
|
||
<!-- Meal component editor (spec §4.7) -->
|
||
<h3>Edit meal components{meal ? `: ${meal.name}` : ''}</h3>
|
||
|
||
{#if mealLoading}
|
||
<p>Loading meal…</p>
|
||
{:else if mealLoadError}
|
||
<p class="err" role="alert">{mealLoadError}</p>
|
||
{:else if meal}
|
||
<!-- Derived nutrition is read-only — the backend is the source of truth (§8.2 rule 2) -->
|
||
{#if meal.computed_nutrition_per_meal}
|
||
<p class="derived-nutrition">
|
||
Per meal: {formatKcal(Math.round(meal.computed_nutrition_per_meal.calories ?? 0))}
|
||
· P {formatGrams(meal.computed_nutrition_per_meal.protein_g ?? 0)}
|
||
· C {formatGrams(meal.computed_nutrition_per_meal.carbs_g ?? 0)}
|
||
· F {formatGrams(meal.computed_nutrition_per_meal.fat_g ?? 0)}
|
||
</p>
|
||
{/if}
|
||
|
||
<ul class="component-edit-list">
|
||
{#each components as comp (comp.food_id)}
|
||
<li class="component-edit-item">
|
||
<span class="comp-name">{comp.food?.name ?? `Food #${comp.food_id}`}</span>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
min="0.1"
|
||
bind:value={comp.quantity}
|
||
class="qty-input"
|
||
/>
|
||
<span class="comp-unit">{comp.food?.unit_type === 'count' ? '×' : 'g'}</span>
|
||
<button type="button" class="icon-btn" title="Remove" onclick={() => removeComponent(comp.food_id)}>✕</button>
|
||
</li>
|
||
{:else}
|
||
<li class="component-edit-item empty">No components yet</li>
|
||
{/each}
|
||
</ul>
|
||
|
||
<form class="comp-search" onsubmit={searchComponents}>
|
||
<input type="text" bind:value={compQuery} placeholder="Search foods to add…" />
|
||
<button type="submit" disabled={compSearching || !compQuery.trim()}>
|
||
{compSearching ? '…' : 'Search'}
|
||
</button>
|
||
</form>
|
||
{#if compSearchError}<p class="err" role="alert">{compSearchError}</p>{/if}
|
||
{#if compResults.length > 0}
|
||
<ul class="comp-results">
|
||
{#each compResults as r (r.id)}
|
||
<li class="comp-result">
|
||
<span>{r.name}{r.brand ? ` (${r.brand})` : ''}{r.is_meal ? ' [meal]' : ''}</span>
|
||
<button type="button" class="secondary" onclick={() => addComponent(r)}>Add</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
{#if compSaveError}<p class="err" role="alert">{compSaveError}</p>{/if}
|
||
|
||
<div class="form-actions">
|
||
<button
|
||
type="button"
|
||
onclick={saveComponents}
|
||
disabled={compSaving || components.length === 0 || components.some(c => !(parseFloat(c.quantity) > 0))}
|
||
>
|
||
{compSaving ? 'Saving…' : 'Save components'}
|
||
</button>
|
||
<button type="button" class="secondary" onclick={goBack} disabled={compSaving}>Cancel</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{:else if justSaved}
|
||
<!-- Post-save: offer to log the new food -->
|
||
<h3>"{justSaved.food.name}" saved</h3>
|
||
|
||
<div class="log-form">
|
||
<p class="brand">{justSaved.food.brand}</p>
|
||
|
||
<label>
|
||
Quantity
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
min="0.1"
|
||
bind:value={logQuantity}
|
||
class="qty-input"
|
||
/>
|
||
{justSaved.food.unit_type === 'count' ? 'items' : 'g'}
|
||
</label>
|
||
<p class="preview-kcal">= {formatKcal(liveKcal)}</p>
|
||
|
||
<label>
|
||
Meal slot
|
||
<select bind:value={logMealSlot}>
|
||
{#each SLOTS as s}
|
||
<option value={s}>{s || '(none)'}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
|
||
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
|
||
|
||
<div class="log-actions">
|
||
<button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
|
||
{logging ? 'Logging…' : 'Log it'}
|
||
</button>
|
||
<button type="button" class="secondary" onclick={skipLog} disabled={logging}>
|
||
Skip
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{:else}
|
||
<!-- Creation/edit form -->
|
||
<h3>{isEdit ? 'Edit food' : 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}
|
||
|
||
<fieldset>
|
||
<legend>Unit type</legend>
|
||
<label class="radio-label">
|
||
<input type="radio" name="unitType" value="weight" bind:group={unitType} disabled={isPrefilled} />
|
||
Weight (nutrition per 100g)
|
||
</label>
|
||
<label class="radio-label">
|
||
<input type="radio" name="unitType" value="count" bind:group={unitType} disabled={isPrefilled} />
|
||
Count (nutrition per item)
|
||
</label>
|
||
</fieldset>
|
||
|
||
<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>
|
||
|
||
<fieldset>
|
||
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
|
||
<div class="macro-grid">
|
||
<label>
|
||
Protein
|
||
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
|
||
</label>
|
||
<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>
|
||
</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…' : isEdit ? 'Save changes' : 'Save food'}
|
||
</button>
|
||
<button type="button" class="secondary" onclick={goBack} disabled={saving}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.food-editor { }
|
||
|
||
.back-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--link, #2563eb);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-size: 0.9rem;
|
||
padding: 0;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
h3 {
|
||
margin: 0 0 1rem;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.8rem;
|
||
}
|
||
|
||
label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
font-size: 0.9rem;
|
||
color: var(--text, #111827);
|
||
}
|
||
|
||
.required { color: #dc2626; }
|
||
|
||
input[type="text"],
|
||
input[type="number"] {
|
||
padding: 0.4rem 0.6rem;
|
||
font: inherit;
|
||
border: 1px solid var(--border, #d1d5db);
|
||
border-radius: 0.35rem;
|
||
}
|
||
|
||
.barcode-ro {
|
||
background: var(--bg-muted, #f3f4f6);
|
||
color: var(--text-muted, #6b7280);
|
||
}
|
||
|
||
.source-badge {
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted, #6b7280);
|
||
background: var(--bg-muted, #f3f4f6);
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 0.25rem;
|
||
display: inline-block;
|
||
text-transform: capitalize;
|
||
margin: 0;
|
||
}
|
||
|
||
fieldset {
|
||
border: 1px solid var(--border, #e5e7eb);
|
||
border-radius: 0.35rem;
|
||
padding: 0.6rem 0.8rem;
|
||
}
|
||
|
||
legend {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted, #6b7280);
|
||
padding: 0 0.3rem;
|
||
}
|
||
|
||
.radio-label {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
margin-top: 0.3rem;
|
||
}
|
||
|
||
.macro-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
|
||
|
||
.log-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
.log-form label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.qty-input {
|
||
width: 5rem;
|
||
padding: 0.3rem 0.4rem;
|
||
font: inherit;
|
||
border: 1px solid var(--border, #d1d5db);
|
||
border-radius: 0.35rem;
|
||
}
|
||
|
||
.preview-kcal {
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
color: var(--text, #111827);
|
||
margin: 0;
|
||
}
|
||
|
||
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
|
||
|
||
button {
|
||
padding: 0.5rem 1rem;
|
||
border: 1px solid var(--border, #d1d5db);
|
||
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; }
|
||
|
||
/* Meal component editor (§4.7) */
|
||
.derived-nutrition {
|
||
font-size: 0.9rem;
|
||
color: var(--text-muted, #6b7280);
|
||
background: var(--bg-muted, #f3f4f6);
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: 0.35rem;
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
.component-edit-list {
|
||
list-style: none;
|
||
margin: 0 0 0.75rem;
|
||
padding: 0;
|
||
}
|
||
.component-edit-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
padding: 0.35rem 0;
|
||
border-bottom: 1px solid var(--border, #e5e7eb);
|
||
font-size: 0.9rem;
|
||
}
|
||
.component-edit-item .comp-name { flex: 1; font-weight: 500; }
|
||
.component-edit-item.empty { color: var(--text-muted, #6b7280); font-style: italic; }
|
||
.component-edit-item .qty-input { width: 5rem; }
|
||
.comp-unit { color: var(--text-muted, #6b7280); }
|
||
.comp-search {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.comp-search input { flex: 1; }
|
||
.comp-results {
|
||
list-style: none;
|
||
margin: 0 0 0.75rem;
|
||
padding: 0;
|
||
}
|
||
.comp-result {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.4rem;
|
||
padding: 0.3rem 0;
|
||
border-bottom: 1px solid var(--border, #e5e7eb);
|
||
font-size: 0.9rem;
|
||
}
|
||
select {
|
||
padding: 0.3rem 0.4rem;
|
||
font: inherit;
|
||
border: 1px solid var(--border, #d1d5db);
|
||
border-radius: 0.35rem;
|
||
}
|
||
</style>
|