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 { api } from './lib/api.js'
|
||||||
import {
|
import {
|
||||||
currentDate, appView, goPrevDay, goNextDay, setDate,
|
currentDate, appView, goPrevDay, goNextDay, setDate,
|
||||||
navigateTo, addLogEntryToStore, refreshDayData
|
navigateTo, addLogEntryToStore, refreshDayData, mealEdit
|
||||||
} from './lib/stores.svelte.js'
|
} from './lib/stores.svelte.js'
|
||||||
import { formatDate, formatKcal, defaultQuantity, previewCalories } from './lib/format.js'
|
import { formatDate, formatKcal, defaultQuantity, previewCalories } from './lib/format.js'
|
||||||
import Dashboard from './components/Dashboard.svelte'
|
import Dashboard from './components/Dashboard.svelte'
|
||||||
@@ -337,6 +337,9 @@
|
|||||||
{:else if appView.current === 'createFood'}
|
{:else if appView.current === 'createFood'}
|
||||||
<FoodEditor />
|
<FoodEditor />
|
||||||
|
|
||||||
|
{:else if appView.current === 'editMeal'}
|
||||||
|
<FoodEditor mealId={mealEdit.foodId} />
|
||||||
|
|
||||||
{:else if appView.current === 'targetForm'}
|
{:else if appView.current === 'targetForm'}
|
||||||
<div class="target-form-view">
|
<div class="target-form-view">
|
||||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
<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).
|
// 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).
|
// Every async view handles loading / error / empty states (spec §8.2 rule 4).
|
||||||
// Recent foods quick-log section per TICKET-006.
|
// Recent foods quick-log section per TICKET-006.
|
||||||
|
// Multi-select "Save as Meal" flow (spec §4.3, TICKET-007).
|
||||||
|
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../lib/api.js'
|
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 { formatDate, formatKcal, defaultQuantity } from '../lib/format.js'
|
||||||
import ProgressBar from './ProgressBar.svelte'
|
import ProgressBar from './ProgressBar.svelte'
|
||||||
import LogEntry from './LogEntry.svelte'
|
import LogEntry from './LogEntry.svelte'
|
||||||
|
import MealBuilder from './MealBuilder.svelte'
|
||||||
|
|
||||||
let { date } = $props()
|
let { date } = $props()
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
meal_slot: getDefaultSlot(),
|
meal_slot: getDefaultSlot(),
|
||||||
date: currentDate.value,
|
date: currentDate.value,
|
||||||
})
|
})
|
||||||
await addLogEntryToStore(entry)
|
dayData.log = [...dayData.log, entry]
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — user can manually log
|
// Silently fail — user can manually log
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,32 @@
|
|||||||
if (unslotted.length) result.push({ slot: null, entries: unslotted })
|
if (unslotted.length) result.push({ slot: null, entries: unslotted })
|
||||||
return result
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="dashboard">
|
<section class="dashboard">
|
||||||
@@ -126,17 +154,61 @@
|
|||||||
{#if dayData.log.length === 0}
|
{#if dayData.log.length === 0}
|
||||||
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
||||||
{:else}
|
{: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')}
|
{#each groups as group (group.slot ?? 'other')}
|
||||||
<div class="meal-group">
|
<div class="meal-group">
|
||||||
<h3 class="slot-header">{group.slot || 'Other'}</h3>
|
<h3 class="slot-header">{group.slot || 'Other'}</h3>
|
||||||
<ul class="entry-list">
|
<ul class="entry-list">
|
||||||
{#each group.entries as entry (entry.id)}
|
{#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}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -219,8 +291,64 @@
|
|||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
border-bottom: 1px solid var(--border, #e5e7eb);
|
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;
|
margin: 0;
|
||||||
padding: 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.
|
// read-only and source is set from the prefill.
|
||||||
|
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
|
import { currentDate, addLogEntryToStore, appView, refreshDayData } from '../lib/stores.svelte.js'
|
||||||
import { defaultQuantity, previewCalories, formatKcal } from '../lib/format.js'
|
import { defaultQuantity, previewCalories, formatKcal, formatGrams } from '../lib/format.js'
|
||||||
|
|
||||||
/** @type {import('../lib/api.js').FoodCreate | null} */
|
/** @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 ──────────────────────────────────────────────────────────
|
// ── Form state ──────────────────────────────────────────────────────────
|
||||||
let name = $state('')
|
let name = $state('')
|
||||||
@@ -141,7 +224,9 @@
|
|||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
reset()
|
reset()
|
||||||
if (onCancel) {
|
if (mealId) {
|
||||||
|
appView.current = 'dashboard'
|
||||||
|
} else if (onCancel) {
|
||||||
onCancel()
|
onCancel()
|
||||||
} else if (onSaved) {
|
} else if (onSaved) {
|
||||||
// Came from scan flow with no cancel — go to dashboard
|
// Came from scan flow with no cancel — go to dashboard
|
||||||
@@ -157,7 +242,77 @@
|
|||||||
← Back
|
← Back
|
||||||
</button>
|
</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 -->
|
<!-- Post-save: offer to log the new food -->
|
||||||
<h3>"{justSaved.food.name}" saved</h3>
|
<h3>"{justSaved.food.name}" saved</h3>
|
||||||
|
|
||||||
@@ -427,6 +582,53 @@
|
|||||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||||
|
|
||||||
.err { color: #dc2626; font-size: 0.85rem; }
|
.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 {
|
select {
|
||||||
padding: 0.3rem 0.4rem;
|
padding: 0.3rem 0.4rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
// LogEntry — One log row: name, quantity, kcal, edit/delete (spec §3.3, §4.6).
|
// 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 { api } from '../lib/api.js'
|
||||||
import { formatKcal, formatQuantity, caloriesForEntry, previewCalories } from '../lib/format.js'
|
import { formatKcal, caloriesForEntry, previewCalories } from '../lib/format.js'
|
||||||
import { updateLogEntryInStore, removeLogEntryFromStore } from '../lib/stores.svelte.js'
|
import { updateLogEntryInStore, removeLogEntryFromStore, replaceEntryForUnpack } from '../lib/stores.svelte.js'
|
||||||
|
import { currentDate, openMealEditor } from '../lib/stores.svelte.js'
|
||||||
|
|
||||||
let { entry } = $props()
|
let { entry } = $props()
|
||||||
|
|
||||||
@@ -16,6 +19,11 @@
|
|||||||
let confirmingDelete = $state(false)
|
let confirmingDelete = $state(false)
|
||||||
let deleting = $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
|
// Sync edit state when entry changes or editing starts
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
@@ -24,6 +32,7 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isMeal = $derived(entry.food?.is_meal ?? false)
|
||||||
let kcal = $derived(Math.round(caloriesForEntry(entry)))
|
let kcal = $derived(Math.round(caloriesForEntry(entry)))
|
||||||
let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity)))
|
let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity)))
|
||||||
|
|
||||||
@@ -67,6 +76,29 @@
|
|||||||
deleting = false
|
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>
|
</script>
|
||||||
|
|
||||||
<li class="log-entry">
|
<li class="log-entry">
|
||||||
@@ -110,18 +142,71 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="entry-main">
|
<div class="entry-main">
|
||||||
<span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span>
|
{#if isMeal}
|
||||||
<span class="food-brand">{entry.food?.brand}</span>
|
<!-- Meal entry: collapsible header -->
|
||||||
<span class="qty">{formatQuantity(entry)}</span>
|
<button type="button" class="meal-toggle" onclick={() => expanded = !expanded} title={expanded ? 'Collapse' : 'Expand'}>
|
||||||
<span class="kcal">{formatKcal(kcal)}</span>
|
<span class="collapse-arrow">{expanded ? '▼' : '▶'}</span>
|
||||||
{#if entry.meal_slot}
|
</button>
|
||||||
<span class="slot-badge">{entry.meal_slot}</span>
|
<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}
|
{/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>
|
</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}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -139,6 +224,17 @@
|
|||||||
}
|
}
|
||||||
.food-name { font-weight: 600; }
|
.food-name { font-weight: 600; }
|
||||||
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
|
.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; }
|
.qty { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
|
||||||
.kcal { font-weight: 600; margin-left: auto; }
|
.kcal { font-weight: 600; margin-left: auto; }
|
||||||
.slot-badge {
|
.slot-badge {
|
||||||
@@ -148,6 +244,18 @@
|
|||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
text-transform: capitalize;
|
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 {
|
.entry-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -160,6 +268,10 @@
|
|||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
.icon-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.unpack-btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -186,6 +298,32 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
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 {
|
button {
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
border: 1px solid var(--border, #d1d5db);
|
border: 1px solid var(--border, #d1d5db);
|
||||||
@@ -199,4 +337,4 @@
|
|||||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||||
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
|
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
|
||||||
.err { color: #dc2626; font-size: 0.85rem; width: 100%; }
|
.err { color: #dc2626; font-size: 0.85rem; width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,155 @@
|
|||||||
<script>
|
<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>
|
</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}`),
|
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
||||||
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
||||||
offRefresh: (foodId) => request(`/api/off/refresh/${foodId}`, { method: 'POST' }),
|
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.
|
* 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
|
* - weight-type: (quantity / 100) × calories_per_unit
|
||||||
* - count-type: quantity × calories_per_unit
|
* - count-type: quantity × calories_per_unit
|
||||||
*/
|
*/
|
||||||
export function caloriesForEntry(entry) {
|
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
|
const food = entry.food
|
||||||
if (!food || food.calories_per_unit == null) return 0
|
if (!food || food.calories_per_unit == null) return 0
|
||||||
if (food.unit_type === 'count') {
|
if (food.unit_type === 'count') {
|
||||||
@@ -89,4 +96,4 @@ export function shiftDate(yyyyMmDd, days) {
|
|||||||
const d = new Date(yyyyMmDd + 'T00:00:00Z')
|
const d = new Date(yyyyMmDd + 'T00:00:00Z')
|
||||||
d.setUTCDate(d.getUTCDate() + days)
|
d.setUTCDate(d.getUTCDate() + days)
|
||||||
return d.toISOString().slice(0, 10)
|
return d.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Example test suite — scaffold for future logic tests (spec §8.4:
|
* Test suite for format.js logic (spec §8.4: Vitest only for stores/format logic).
|
||||||
* Vitest only for stores/format logic, no component tests in v1).
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('formatKcal', () => {
|
||||||
it('rounds to whole numbers', () => {
|
it('rounds to whole numbers', () => {
|
||||||
@@ -64,3 +63,45 @@ describe('shiftDate', () => {
|
|||||||
expect(shiftDate('2026-08-01', -7)).toBe('2026-07-25')
|
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' })
|
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"). */
|
/** Simple navigation requests from child components (e.g., "Set a target"). */
|
||||||
export const navigateTo = (view) => { appView.current = view }
|
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)
|
dayData.log = dayData.log.filter(e => e.id !== id)
|
||||||
await refreshSummary()
|
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