TICKET-007 (frontend): save-as-meal flow, collapsible meal rows, unpack, meal component editor

This commit is contained in:
Craig
2026-07-26 17:27:15 +01:00
parent a8aed9a84f
commit 7db64840f7
9 changed files with 741 additions and 33 deletions
+207 -5
View File
@@ -5,11 +5,94 @@
// read-only and source is set from the prefill.
import { api } from '../lib/api.js'
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
import { defaultQuantity, previewCalories, formatKcal } from '../lib/format.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, onSaved = null, onCancel = null } = $props()
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('')
@@ -141,7 +224,9 @@
function goBack() {
reset()
if (onCancel) {
if (mealId) {
appView.current = 'dashboard'
} else if (onCancel) {
onCancel()
} else if (onSaved) {
// Came from scan flow with no cancel — go to dashboard
@@ -157,7 +242,77 @@
← Back
</button>
{#if justSaved}
{#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>
@@ -427,6 +582,53 @@
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;