From 7db64840f745fb29b949463eb0f0d5ec53f21f8c Mon Sep 17 00:00:00 2001 From: Craig Date: Sun, 26 Jul 2026 17:27:15 +0100 Subject: [PATCH] TICKET-007 (frontend): save-as-meal flow, collapsible meal rows, unpack, meal component editor --- frontend/src/App.svelte | 5 +- frontend/src/components/Dashboard.svelte | 138 +++++++++++++- frontend/src/components/FoodEditor.svelte | 212 ++++++++++++++++++++- frontend/src/components/LogEntry.svelte | 166 ++++++++++++++-- frontend/src/components/MealBuilder.svelte | 154 ++++++++++++++- frontend/src/lib/api.js | 15 +- frontend/src/lib/format.js | 11 +- frontend/src/lib/format.test.js | 47 ++++- frontend/src/lib/stores.svelte.js | 26 +++ 9 files changed, 741 insertions(+), 33 deletions(-) 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')}

{group.slot || 'Other'}

    {#each group.entries as entry (entry.id)} - +
  • + {#if selecting || creatingMeal} + + + {/if} +
    + +
    +
  • {/each}
{/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} + + {: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} + +
    + {#each components as comp (comp.food_id)} +
  • + {comp.food?.name ?? `Food #${comp.food_id}`} + + {comp.food?.unit_type === 'count' ? '×' : 'g'} + +
  • + {:else} +
  • No components yet
  • + {/each} +
+ + + {#if 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}{/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} + + {/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.

    + +
    { e.preventDefault(); handleCreate() }}> + + + {#if error} + + {/if} + +
    + + +
    +
    +
    +
    + + \ 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