diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index ca11f09..8892ba6 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -8,7 +8,7 @@
import { api } from './lib/api.js'
import {
currentDate, appView, goPrevDay, goNextDay, setDate,
- navigateTo, addLogEntryToStore, refreshDayData
+ navigateTo, addLogEntryToStore, refreshDayData, mealEdit
} from './lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity, previewCalories } from './lib/format.js'
import Dashboard from './components/Dashboard.svelte'
@@ -337,6 +337,9 @@
{:else if appView.current === 'createFood'}
+ {:else if appView.current === 'editMeal'}
+
+
{:else if appView.current === 'targetForm'}
diff --git a/frontend/src/components/Dashboard.svelte b/frontend/src/components/Dashboard.svelte
index e516a33..85f6d67 100644
--- a/frontend/src/components/Dashboard.svelte
+++ b/frontend/src/components/Dashboard.svelte
@@ -2,13 +2,15 @@
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6).
// Every async view handles loading / error / empty states (spec §8.2 rule 4).
// Recent foods quick-log section per TICKET-006.
+ // Multi-select "Save as Meal" flow (spec §4.3, TICKET-007).
import { onMount } from 'svelte'
import { api } from '../lib/api.js'
- import { currentDate, dayData, refreshDayData, appView, addLogEntryToStore } from '../lib/stores.svelte.js'
+ import { currentDate, dayData, refreshDayData, appView, replaceEntriesForMeal } from '../lib/stores.svelte.js'
import { formatDate, formatKcal, defaultQuantity } from '../lib/format.js'
import ProgressBar from './ProgressBar.svelte'
import LogEntry from './LogEntry.svelte'
+ import MealBuilder from './MealBuilder.svelte'
let { date } = $props()
@@ -48,7 +50,7 @@
meal_slot: getDefaultSlot(),
date: currentDate.value,
})
- await addLogEntryToStore(entry)
+ dayData.log = [...dayData.log, entry]
} catch {
// Silently fail — user can manually log
}
@@ -86,6 +88,32 @@
if (unslotted.length) result.push({ slot: null, entries: unslotted })
return result
})
+
+ // ── Multi-select for "Save as Meal" (§4.3) ──────────────────────────────
+ let selectedIds = $state(new Set())
+ let selecting = $state(false) // true → checkboxes visible
+ let creatingMeal = $state(false) // true → show MealBuilder overlay
+
+ function toggleSelect(entryId) {
+ const next = new Set(selectedIds)
+ if (next.has(entryId)) {
+ next.delete(entryId)
+ } else {
+ next.add(entryId)
+ }
+ selectedIds = next
+ }
+
+ function cancelSelection() {
+ selecting = false
+ selectedIds = new Set()
+ }
+
+ function closeMealBuilder() {
+ creatingMeal = false
+ selecting = false
+ selectedIds = new Set()
+ }
@@ -126,17 +154,61 @@
{#if dayData.log.length === 0}
Nothing logged yet. Tap "Add Food" to get started.
{:else}
+ {#if !selecting}
+
+
+
+ {/if}
{#each groups as group (group.slot ?? 'other')}
{/each}
{/if}
+
+
+ {#if selecting && !creatingMeal}
+
+
+
+
+ {/if}
+
+
+ {#if creatingMeal}
+
+ {/if}
{/if}
@@ -219,8 +291,64 @@
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border, #e5e7eb);
}
- .entry-list {
+
+ /* ── Entry row with multi-select ────────────────────────────────────── */
+ .entry-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.4rem;
margin: 0;
padding: 0;
}
-
+ .entry-row.selected {
+ background: var(--bg-muted, #f0f4ff);
+ border-radius: 0.35rem;
+ }
+ .select-label {
+ padding-top: 0.7rem;
+ flex-shrink: 0;
+ }
+ .select-checkbox {
+ width: 1.1rem;
+ height: 1.1rem;
+ cursor: pointer;
+ }
+ .entry-content {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .entry-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ /* ── Meal actions ───────────────────────────────────────────────────── */
+ .meal-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ justify-content: center;
+ }
+ .save-meal-btn {
+ padding: 0.6rem 1.2rem;
+ background: #7c3aed;
+ color: #fff;
+ border: 1px solid #7c3aed;
+ border-radius: 0.35rem;
+ font: inherit;
+ font-size: 0.95rem;
+ cursor: pointer;
+ font-weight: 600;
+ }
+ button.secondary {
+ 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;
+ }
+
\ No newline at end of file
diff --git a/frontend/src/components/FoodEditor.svelte b/frontend/src/components/FoodEditor.svelte
index b32e25e..6c5d885 100644
--- a/frontend/src/components/FoodEditor.svelte
+++ b/frontend/src/components/FoodEditor.svelte
@@ -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
- {#if justSaved}
+ {#if mealId}
+
+
Edit meal components{meal ? `: ${meal.name}` : ''}
+
+ {#if mealLoading}
+
Loading meal…
+ {:else if mealLoadError}
+
{mealLoadError}
+ {:else if meal}
+
+ {#if meal.computed_nutrition_per_meal}
+
+ 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)}
+
+ {/if}
+
+
+
+
+ {#if compSearchError}
{compSearchError}
{/if}
+ {#if compResults.length > 0}
+
+ {#each compResults as r (r.id)}
+ -
+ {r.name}{r.brand ? ` (${r.brand})` : ''}{r.is_meal ? ' [meal]' : ''}
+
+
+ {/each}
+
+ {/if}
+
+ {#if compSaveError}
{compSaveError}
{/if}
+
+
+
+
+
+ {/if}
+
+ {:else if justSaved}
"{justSaved.food.name}" saved
@@ -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;
diff --git a/frontend/src/components/LogEntry.svelte b/frontend/src/components/LogEntry.svelte
index 9cefad5..9fdf24b 100644
--- a/frontend/src/components/LogEntry.svelte
+++ b/frontend/src/components/LogEntry.svelte
@@ -1,10 +1,13 @@
@@ -110,18 +142,71 @@
{:else}
-
{entry.food?.name ?? `Food #${entry.food_id}`}
-
{entry.food?.brand}
-
{formatQuantity(entry)}
-
{formatKcal(kcal)}
- {#if entry.meal_slot}
-
{entry.meal_slot}
+ {#if isMeal}
+
+
+
{entry.food.name}
+
meal
+
{formatKcal(kcal)}
+ {#if entry.meal_slot}
+
{entry.meal_slot}
+ {/if}
+
+
+
+
+
+
+ {:else}
+
+
{entry.food?.name ?? `Food #${entry.food_id}`}
+ {#if entry.food?.brand}
+
{entry.food.brand}
+ {/if}
+
{entry.quantity}{entry.food?.unit_type === 'count' ? '×' : 'g'}
+
{formatKcal(kcal)}
+ {#if entry.meal_slot}
+
{entry.meal_slot}
+ {/if}
+
+
+
+
{/if}
-
-
-
-
+
+ {#if unpackError}
+ {unpackError}
+ {/if}
+
+
+ {#if isMeal && expanded}
+
+ {#if entry.food?.components && entry.food.components.length > 0}
+ {#each entry.food.components as comp (comp.food_id)}
+ -
+ {comp.food?.name ?? `Food #${comp.food_id}`}
+ {#if comp.food?.brand}
+ {comp.food.brand}
+ {/if}
+ {comp.quantity}{comp.food?.unit_type === 'count' ? '×' : 'g'}
+ {formatKcal(Math.round(componentCalories(comp)))}
+
+ {/each}
+ {:else}
+ - No components
+ {/if}
+ -
+
+
+
+ {/if}
{/if}
@@ -139,6 +224,17 @@
}
.food-name { font-weight: 600; }
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
+ .meal-name { cursor: pointer; }
+ .meal-badge {
+ font-size: 0.7rem;
+ background: #e0e7ff;
+ color: #4338ca;
+ padding: 0.1em 0.4em;
+ border-radius: 0.3rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+ }
.qty { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
.kcal { font-weight: 600; margin-left: auto; }
.slot-badge {
@@ -148,6 +244,18 @@
border-radius: 0.3rem;
text-transform: capitalize;
}
+ .meal-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 0.2rem;
+ color: var(--text-muted, #6b7280);
+ }
+ .collapse-arrow {
+ display: inline-block;
+ width: 0.8rem;
+ }
.entry-actions {
display: flex;
gap: 0.25rem;
@@ -160,6 +268,10 @@
padding: 0.2rem;
line-height: 1;
}
+ .icon-btn:disabled { opacity: 0.4; cursor: default; }
+ .unpack-btn {
+ font-size: 1rem;
+ }
.edit-form {
display: flex;
flex-wrap: wrap;
@@ -186,6 +298,32 @@
gap: 0.5rem;
font-size: 0.9rem;
}
+
+ /* Component list (expanded meal) */
+ .component-list {
+ margin: 0.5rem 0 0 1.5rem;
+ padding: 0;
+ border-left: 2px solid var(--border, #e5e7eb);
+ padding-left: 0.75rem;
+ }
+ .component-item {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.3rem 0.6rem;
+ padding: 0.3rem 0;
+ font-size: 0.85rem;
+ }
+ .comp-name { font-weight: 500; }
+ .comp-brand { color: var(--text-muted, #6b7280); font-size: 0.8rem; }
+ .comp-qty { color: var(--text-muted, #6b7280); }
+ .comp-kcal { margin-left: auto; font-weight: 500; color: var(--text, #111827); }
+ .component-item.empty {
+ color: var(--text-muted, #6b7280);
+ font-style: italic;
+ }
+
button {
padding: 0.3rem 0.7rem;
border: 1px solid var(--border, #d1d5db);
@@ -199,4 +337,4 @@
button.secondary { background: var(--bg-muted, #f3f4f6); }
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
.err { color: #dc2626; font-size: 0.85rem; width: 100%; }
-
+
\ No newline at end of file
diff --git a/frontend/src/components/MealBuilder.svelte b/frontend/src/components/MealBuilder.svelte
index 5a18f12..e426938 100644
--- a/frontend/src/components/MealBuilder.svelte
+++ b/frontend/src/components/MealBuilder.svelte
@@ -1,5 +1,155 @@
-MealBuilder (placeholder)
+
+
+
+
Save as Meal
+
Name your meal from {entryIds.length} selected entries.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index f3c212e..13be5a3 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -47,4 +47,17 @@ export const api = {
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
offRefresh: (foodId) => request(`/api/off/refresh/${foodId}`, { method: 'POST' }),
-}
+ // Meals (spec §3.2, TICKET-007)
+ createMealFromLog: (name, date, entryIds) => request('/api/meals/from-log', {
+ method: 'POST',
+ body: JSON.stringify({ name, date, entry_ids: entryIds }),
+ }),
+ unpackMeal: (mealId, date, entryId) => request(`/api/meals/${mealId}/unpack`, {
+ method: 'POST',
+ body: JSON.stringify({ date, entry_id: entryId }),
+ }),
+ updateMealComponents: (mealId, components) => request(`/api/meals/${mealId}/components`, {
+ method: 'PUT',
+ body: JSON.stringify({ components }),
+ }),
+}
\ No newline at end of file
diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js
index 84991ef..613b146 100644
--- a/frontend/src/lib/format.js
+++ b/frontend/src/lib/format.js
@@ -25,11 +25,18 @@ export function formatDate(yyyyMmDd) {
/**
* Compute the calories contributed by a single log entry.
- * This is the permitted simple linear scaling for display only (spec §8.2 rule 2).
+ * The server is the source of truth for nutrition (spec §8.2 rule 2).
+ * For entries with computed_nutrition from the backend (meals, or any entry
+ * where the backend has computed the values), use that directly.
+ * For simple foods, fall back to linear scaling (quantity × per_unit).
* - weight-type: (quantity / 100) × calories_per_unit
* - count-type: quantity × calories_per_unit
*/
export function caloriesForEntry(entry) {
+ // If the backend sent computed_nutrition, use it (spec §8.2 rule 2)
+ if (entry.computed_nutrition?.calories != null) {
+ return entry.computed_nutrition.calories
+ }
const food = entry.food
if (!food || food.calories_per_unit == null) return 0
if (food.unit_type === 'count') {
@@ -89,4 +96,4 @@ export function shiftDate(yyyyMmDd, days) {
const d = new Date(yyyyMmDd + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/format.test.js b/frontend/src/lib/format.test.js
index 47f842a..1014b60 100644
--- a/frontend/src/lib/format.test.js
+++ b/frontend/src/lib/format.test.js
@@ -1,9 +1,8 @@
/**
- * Example test suite — scaffold for future logic tests (spec §8.4:
- * Vitest only for stores/format logic, no component tests in v1).
+ * Test suite for format.js logic (spec §8.4: Vitest only for stores/format logic).
*/
import { describe, it, expect } from 'vitest'
-import { formatKcal, formatGrams, formatDate, shiftDate } from './format.js'
+import { formatKcal, formatGrams, formatDate, shiftDate, caloriesForEntry } from './format.js'
describe('formatKcal', () => {
it('rounds to whole numbers', () => {
@@ -64,3 +63,45 @@ describe('shiftDate', () => {
expect(shiftDate('2026-08-01', -7)).toBe('2026-07-25')
})
})
+
+describe('caloriesForEntry', () => {
+ it('uses computed_nutrition when available (meal entries)', () => {
+ const entry = {
+ quantity: 1,
+ computed_nutrition: { calories: 420, protein_g: 25 },
+ food: { calories_per_unit: null, unit_type: 'weight', is_meal: true },
+ }
+ expect(caloriesForEntry(entry)).toBe(420)
+ })
+
+ it('falls back to weight-type scaling for non-meal foods', () => {
+ const entry = {
+ quantity: 200,
+ computed_nutrition: null,
+ food: { calories_per_unit: 350, unit_type: 'weight' },
+ }
+ expect(caloriesForEntry(entry)).toBe(700) // (200/100) * 350
+ })
+
+ it('falls back to count-type scaling for count foods', () => {
+ const entry = {
+ quantity: 3,
+ computed_nutrition: null,
+ food: { calories_per_unit: 80, unit_type: 'count' },
+ }
+ expect(caloriesForEntry(entry)).toBe(240) // 3 * 80
+ })
+
+ it('returns 0 when food has null calories_per_unit and no computed_nutrition', () => {
+ const entry = {
+ quantity: 1,
+ computed_nutrition: null,
+ food: { calories_per_unit: null, unit_type: 'weight', is_meal: true },
+ }
+ expect(caloriesForEntry(entry)).toBe(0)
+ })
+
+ it('returns 0 when food is null', () => {
+ expect(caloriesForEntry({ quantity: 1, food: null })).toBe(0)
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/lib/stores.svelte.js b/frontend/src/lib/stores.svelte.js
index 284921d..964dccc 100644
--- a/frontend/src/lib/stores.svelte.js
+++ b/frontend/src/lib/stores.svelte.js
@@ -32,6 +32,16 @@ export function goNextDay() {
export const appView = $state({ current: 'dashboard' })
+// ── Meal editor ───────────────────────────────────────────────────────────
+
+/** Which meal food is open in the component editor (spec §4.7, TICKET-007). */
+export const mealEdit = $state({ foodId: null })
+
+export function openMealEditor(foodId) {
+ mealEdit.foodId = foodId
+ appView.current = 'editMeal'
+}
+
/** Simple navigation requests from child components (e.g., "Set a target"). */
export const navigateTo = (view) => { appView.current = view }
@@ -88,3 +98,19 @@ export async function removeLogEntryFromStore(id) {
dayData.log = dayData.log.filter(e => e.id !== id)
await refreshSummary()
}
+
+/**
+ * Replace entries from a "save as meal" operation:
+ * removes the source entry IDs and inserts the replacement meal entry.
+ */
+export async function replaceEntriesForMeal(entryIds, replacement) {
+ const ids = new Set(entryIds)
+ dayData.log = [...dayData.log.filter(e => !ids.has(e.id)), replacement]
+ await refreshSummary()
+}
+
+/** Replace entries from an "unpack" operation. */
+export async function replaceEntryForUnpack(oldEntryId, newEntries) {
+ dayData.log = [...dayData.log.filter(e => e.id !== oldEntryId), ...newEntries]
+ await refreshSummary()
+}
\ No newline at end of file