TICKET-007 (frontend): save-as-meal flow, collapsible meal rows, unpack, meal component editor
This commit is contained in:
@@ -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'}
|
||||
<FoodEditor />
|
||||
|
||||
{:else if appView.current === 'editMeal'}
|
||||
<FoodEditor mealId={mealEdit.foodId} />
|
||||
|
||||
{:else if appView.current === 'targetForm'}
|
||||
<div class="target-form-view">
|
||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="dashboard">
|
||||
@@ -126,17 +154,61 @@
|
||||
{#if dayData.log.length === 0}
|
||||
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
||||
{:else}
|
||||
{#if !selecting}
|
||||
<div class="meal-actions">
|
||||
<button type="button" class="secondary select-btn" onclick={() => selecting = true}>
|
||||
☑ Select entries
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#each groups as group (group.slot ?? 'other')}
|
||||
<div class="meal-group">
|
||||
<h3 class="slot-header">{group.slot || 'Other'}</h3>
|
||||
<ul class="entry-list">
|
||||
{#each group.entries as entry (entry.id)}
|
||||
<LogEntry {entry} />
|
||||
<li class="entry-row" class:selected={selectedIds.has(entry.id)}>
|
||||
{#if selecting || creatingMeal}
|
||||
<!-- Multi-select checkbox -->
|
||||
<label class="select-label" title="Select for meal">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onchange={() => toggleSelect(entry.id)}
|
||||
class="select-checkbox"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="entry-content">
|
||||
<LogEntry {entry} />
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Save as Meal button (spec §4.3) — shown when entries are selected -->
|
||||
{#if selecting && !creatingMeal}
|
||||
<div class="meal-actions">
|
||||
<button type="button" class="save-meal-btn" onclick={() => creatingMeal = true} disabled={selectedIds.size < 2}>
|
||||
🍽️ Save as Meal ({selectedIds.size} selected)
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={cancelSelection}>
|
||||
Cancel selection
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MealBuilder overlay -->
|
||||
{#if creatingMeal}
|
||||
<MealBuilder
|
||||
entryIds={[...selectedIds]}
|
||||
date={currentDate.value}
|
||||
onComplete={closeMealBuilder}
|
||||
onCancel={closeMealBuilder}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script>
|
||||
// LogEntry — One log row: name, quantity, kcal, edit/delete (spec §3.3, §4.6).
|
||||
// Meals render collapsible (TICKET-007); for now a flat row.
|
||||
// Meal entries render collapsible: collapsed = meal name + total kcal;
|
||||
// expanded = components with their quantities (spec §3.3, TICKET-007).
|
||||
// "Unpack" action on logged meal entries (spec §4.4).
|
||||
|
||||
import { api } from '../lib/api.js'
|
||||
import { formatKcal, formatQuantity, caloriesForEntry, previewCalories } from '../lib/format.js'
|
||||
import { updateLogEntryInStore, removeLogEntryFromStore } from '../lib/stores.svelte.js'
|
||||
import { formatKcal, caloriesForEntry, previewCalories } from '../lib/format.js'
|
||||
import { updateLogEntryInStore, removeLogEntryFromStore, replaceEntryForUnpack } from '../lib/stores.svelte.js'
|
||||
import { currentDate, openMealEditor } from '../lib/stores.svelte.js'
|
||||
|
||||
let { entry } = $props()
|
||||
|
||||
@@ -16,6 +19,11 @@
|
||||
let confirmingDelete = $state(false)
|
||||
let deleting = $state(false)
|
||||
|
||||
// Meal collapsible state
|
||||
let expanded = $state(false)
|
||||
let unpacking = $state(false)
|
||||
let unpackError = $state(null)
|
||||
|
||||
// Sync edit state when entry changes or editing starts
|
||||
$effect(() => {
|
||||
if (entry) {
|
||||
@@ -24,6 +32,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
let isMeal = $derived(entry.food?.is_meal ?? false)
|
||||
let kcal = $derived(Math.round(caloriesForEntry(entry)))
|
||||
let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity)))
|
||||
|
||||
@@ -67,6 +76,29 @@
|
||||
deleting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnpack() {
|
||||
unpacking = true
|
||||
unpackError = null
|
||||
try {
|
||||
const result = await api.unpackMeal(entry.food_id, currentDate.value, entry.id)
|
||||
await replaceEntryForUnpack(entry.id, result.entries)
|
||||
} catch (e) {
|
||||
unpackError = e.message
|
||||
} finally {
|
||||
unpacking = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute calories for a meal component entry (child of a meal). */
|
||||
function componentCalories(component) {
|
||||
const food = component.food
|
||||
if (!food || food.calories_per_unit == null) return 0
|
||||
if (food.unit_type === 'count') {
|
||||
return component.quantity * food.calories_per_unit
|
||||
}
|
||||
return (component.quantity / 100) * food.calories_per_unit
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="log-entry">
|
||||
@@ -110,18 +142,71 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="entry-main">
|
||||
<span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span>
|
||||
<span class="food-brand">{entry.food?.brand}</span>
|
||||
<span class="qty">{formatQuantity(entry)}</span>
|
||||
<span class="kcal">{formatKcal(kcal)}</span>
|
||||
{#if entry.meal_slot}
|
||||
<span class="slot-badge">{entry.meal_slot}</span>
|
||||
{#if isMeal}
|
||||
<!-- Meal entry: collapsible header -->
|
||||
<button type="button" class="meal-toggle" onclick={() => expanded = !expanded} title={expanded ? 'Collapse' : 'Expand'}>
|
||||
<span class="collapse-arrow">{expanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
<span class="food-name meal-name">{entry.food.name}</span>
|
||||
<span class="meal-badge">meal</span>
|
||||
<span class="kcal">{formatKcal(kcal)}</span>
|
||||
{#if entry.meal_slot}
|
||||
<span class="slot-badge">{entry.meal_slot}</span>
|
||||
{/if}
|
||||
<div class="entry-actions">
|
||||
<!-- Unpack button (spec §4.4) -->
|
||||
<button type="button" class="icon-btn unpack-btn" title="Unpack" onclick={doUnpack} disabled={unpacking}>
|
||||
{unpacking ? '…' : '🔓'}
|
||||
</button>
|
||||
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
|
||||
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Regular entry -->
|
||||
<span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span>
|
||||
{#if entry.food?.brand}
|
||||
<span class="food-brand">{entry.food.brand}</span>
|
||||
{/if}
|
||||
<span class="qty">{entry.quantity}{entry.food?.unit_type === 'count' ? '×' : 'g'}</span>
|
||||
<span class="kcal">{formatKcal(kcal)}</span>
|
||||
{#if entry.meal_slot}
|
||||
<span class="slot-badge">{entry.meal_slot}</span>
|
||||
{/if}
|
||||
<div class="entry-actions">
|
||||
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
|
||||
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="entry-actions">
|
||||
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
|
||||
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if unpackError}
|
||||
<p class="err" role="alert">{unpackError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Expanded meal components (spec §3.3) -->
|
||||
{#if isMeal && expanded}
|
||||
<ul class="component-list">
|
||||
{#if entry.food?.components && entry.food.components.length > 0}
|
||||
{#each entry.food.components as comp (comp.food_id)}
|
||||
<li class="component-item">
|
||||
<span class="comp-name">{comp.food?.name ?? `Food #${comp.food_id}`}</span>
|
||||
{#if comp.food?.brand}
|
||||
<span class="comp-brand">{comp.food.brand}</span>
|
||||
{/if}
|
||||
<span class="comp-qty">{comp.quantity}{comp.food?.unit_type === 'count' ? '×' : 'g'}</span>
|
||||
<span class="comp-kcal">{formatKcal(Math.round(componentCalories(comp)))}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li class="component-item empty">No components</li>
|
||||
{/if}
|
||||
<li class="component-item">
|
||||
<button type="button" class="secondary edit-components-btn" onclick={() => openMealEditor(entry.food_id)}>
|
||||
Edit components
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
@@ -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%; }
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,5 +1,155 @@
|
||||
<script>
|
||||
// MealBuilder — Create a meal from selected log entries (spec §4.3)
|
||||
// MealBuilder — Create a meal from selected log entries (spec §4.3, TICKET-007).
|
||||
// Shows a name prompt, triggers POST /api/meals/from-log, replaces entries.
|
||||
|
||||
import { api } from '../lib/api.js'
|
||||
import { replaceEntriesForMeal } from '../lib/stores.svelte.js'
|
||||
|
||||
let { entryIds, date, onComplete, onCancel } = $props()
|
||||
|
||||
let mealName = $state('')
|
||||
let saving = $state(false)
|
||||
let error = $state(null)
|
||||
|
||||
async function handleCreate() {
|
||||
const name = mealName.trim()
|
||||
if (!name) {
|
||||
error = 'Please enter a meal name'
|
||||
return
|
||||
}
|
||||
|
||||
saving = true
|
||||
error = null
|
||||
try {
|
||||
const result = await api.createMealFromLog(name, date, entryIds)
|
||||
// result: { meal: FoodRead, entry: LogEntryRead }
|
||||
await replaceEntriesForMeal(entryIds, result.entry)
|
||||
if (onComplete) onComplete()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<p>MealBuilder (placeholder)</p>
|
||||
<div class="meal-builder-overlay">
|
||||
<div class="meal-builder-card">
|
||||
<button type="button" class="close-btn" onclick={onCancel}>✕</button>
|
||||
<h3>Save as Meal</h3>
|
||||
<p class="hint">Name your meal from {entryIds.length} selected entries.</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleCreate() }}>
|
||||
<label>
|
||||
Meal name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mealName}
|
||||
placeholder="e.g. Morning Oatmeal"
|
||||
autofocus
|
||||
required
|
||||
class="name-input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="err" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving || !mealName.trim()}>
|
||||
{saving ? 'Creating…' : 'Create meal'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.meal-builder-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
.meal-builder-card {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #6b7280);
|
||||
padding: 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
.name-input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
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[type="submit"] {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||
.err { color: #dc2626; font-size: 0.85rem; }
|
||||
</style>
|
||||
+14
-1
@@ -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 }),
|
||||
}),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user