TICKET-007 (frontend): save-as-meal flow, collapsible meal rows, unpack, meal component editor
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user