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
+4 -1
View File
@@ -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>
+133 -5
View File
@@ -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>
+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;
+152 -14
View File
@@ -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>
+152 -2
View File
@@ -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
View File
@@ -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 }),
}),
}
+9 -2
View File
@@ -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)
}
}
+44 -3
View File
@@ -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)
})
})
+26
View File
@@ -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()
}