Files
calcount/frontend/src/components/FoodEditor.svelte
T

651 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>