TICKET-008 (frontend): Foods library view — search, pagination, edit, delete, restore

This commit is contained in:
Craig
2026-07-26 17:45:11 +01:00
parent 0c239cdbd4
commit 731e541013
4 changed files with 367 additions and 8 deletions
+7
View File
@@ -14,6 +14,7 @@
import Dashboard from './components/Dashboard.svelte' import Dashboard from './components/Dashboard.svelte'
import FoodSearch from './components/FoodSearch.svelte' import FoodSearch from './components/FoodSearch.svelte'
import FoodEditor from './components/FoodEditor.svelte' import FoodEditor from './components/FoodEditor.svelte'
import FoodLibrary from './components/FoodLibrary.svelte'
import BarcodeScanner from './components/BarcodeScanner.svelte' import BarcodeScanner from './components/BarcodeScanner.svelte'
// Health check — every async view handles loading/error states (spec §8.2 rule 4) // Health check — every async view handles loading/error states (spec §8.2 rule 4)
@@ -233,6 +234,9 @@
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}> <button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
📷 Scan 📷 Scan
</button> </button>
<button type="button" class="target-btn" onclick={() => appView.current = 'foods'}>
🍔 Foods
</button>
<button type="button" class="target-btn" onclick={openTargetForm}> <button type="button" class="target-btn" onclick={openTargetForm}>
🎯 Target 🎯 Target
</button> </button>
@@ -337,6 +341,9 @@
{:else if appView.current === 'createFood'} {:else if appView.current === 'createFood'}
<FoodEditor /> <FoodEditor />
{:else if appView.current === 'foods'}
<FoodLibrary />
{:else if appView.current === 'editMeal'} {:else if appView.current === 'editMeal'}
<FoodEditor mealId={mealEdit.foodId} /> <FoodEditor mealId={mealEdit.foodId} />
+18 -6
View File
@@ -121,6 +121,9 @@
let saving = $state(false) let saving = $state(false)
let error = $state(null) let error = $state(null)
// Edit mode: an existing food (with id) was passed in — update, don't create.
let isEdit = $derived(!!food?.id)
// ── Pre-fill from food prop ───────────────────────────────────────────── // ── Pre-fill from food prop ─────────────────────────────────────────────
$effect(() => { $effect(() => {
if (food) { if (food) {
@@ -176,12 +179,21 @@
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null, serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null, serving_name: servingName.trim() || null,
source, source,
is_meal: false, is_meal: food?.is_meal ?? false,
} }
// Include barcode when present // Include barcode when present
if (barcode.trim()) payload.barcode = barcode.trim() if (barcode.trim()) payload.barcode = barcode.trim()
else payload.barcode = null else payload.barcode = null
if (isEdit) {
const saved = await api.updateFood(food.id, payload)
// Nutrition edits affect historical log rendering — refresh the day
await refreshDayData()
if (onSaved) onSaved(saved)
else appView.current = 'dashboard'
return
}
const saved = await api.createFood(payload) const saved = await api.createFood(payload)
// After save, offer to log the new food // After save, offer to log the new food
justSaved = { food: saved } justSaved = { food: saved }
@@ -224,10 +236,10 @@
function goBack() { function goBack() {
reset() reset()
if (mealId) { if (onCancel) {
appView.current = 'dashboard'
} else if (onCancel) {
onCancel() onCancel()
} else if (mealId) {
appView.current = 'dashboard'
} 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
appView.current = 'dashboard' appView.current = 'dashboard'
@@ -355,7 +367,7 @@
{:else} {:else}
<!-- Creation/edit form --> <!-- Creation/edit form -->
<h3>{isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3> <h3>{isEdit ? 'Edit food' : isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
<form onsubmit={handleSubmit}> <form onsubmit={handleSubmit}>
<label> <label>
@@ -436,7 +448,7 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" disabled={saving}> <button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'} {saving ? 'Saving…' : isEdit ? 'Save changes' : 'Save food'}
</button> </button>
<button type="button" class="secondary" onclick={goBack} disabled={saving}> <button type="button" class="secondary" onclick={goBack} disabled={saving}>
Cancel Cancel
+338 -2
View File
@@ -1,5 +1,341 @@
<script> <script>
// FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7) // FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7).
// Uses GET /api/foods with limit/offset; include_deleted toggle shows soft-deleted
// foods (visually distinct, restorable). Edit reuses FoodEditor; meals open the
// meal component editor instead (TICKET-007).
import { onMount } from 'svelte'
import { api } from '../lib/api.js'
import { appView, refreshDayData } from '../lib/stores.svelte.js'
import { formatKcal } from '../lib/format.js'
import FoodEditor from './FoodEditor.svelte'
const PAGE_SIZE = 20
let foods = $state([])
let loading = $state(true)
let error = $state(null)
let query = $state('')
let offset = $state(0)
let showDeleted = $state(false)
let hasMore = $state(false)
// Edit state: which food is open in FoodEditor (null = list view)
let editingFood = $state(null) // regular food edit
let editingMealId = $state(null) // meal component editing
// Per-row action state
let confirmingDeleteId = $state(null)
let actionBusyId = $state(null)
let actionError = $state(null)
async function load() {
loading = true
error = null
try {
// Fetch one extra row to know whether a next page exists
const rows = await api.listFoods({
q: query,
limit: PAGE_SIZE + 1,
offset,
includeDeleted: showDeleted,
})
hasMore = rows.length > PAGE_SIZE
foods = rows.slice(0, PAGE_SIZE)
} catch (e) {
error = e.message
foods = []
hasMore = false
} finally {
loading = false
}
}
// Initial load only — subsequent loads are triggered explicitly
// (search submit, pagination, toggle) to avoid re-loading on every keystroke.
onMount(load)
function search(e) {
e.preventDefault()
offset = 0
load()
}
function toggleDeleted() {
showDeleted = !showDeleted
offset = 0
load()
}
function prevPage() {
offset = Math.max(0, offset - PAGE_SIZE)
load()
}
function nextPage() {
if (!hasMore) return
offset += PAGE_SIZE
load()
}
function openEdit(food) {
actionError = null
if (food.is_meal) {
editingMealId = food.id
} else {
editingFood = food
}
}
async function closeEditor() {
editingFood = null
editingMealId = null
await refreshDayData() // food/meal edits affect log rendering + summary
load()
}
async function doDelete(food) {
actionBusyId = food.id
actionError = null
try {
await api.deleteFood(food.id)
confirmingDeleteId = null
await refreshDayData()
await load()
} catch (e) {
actionError = e.message
} finally {
actionBusyId = null
}
}
async function doRestore(food) {
actionBusyId = food.id
actionError = null
try {
await api.restoreFood(food.id)
await refreshDayData()
await load()
} catch (e) {
actionError = e.message
} finally {
actionBusyId = null
}
}
/** Display kcal for a row: per 100g or per item depending on unit type. */
function rowKcal(food) {
if (food.is_meal) return 'meal'
if (food.calories_per_unit == null) return '—'
const unit = food.unit_type === 'count' ? '/item' : '/100g'
return `${formatKcal(Math.round(food.calories_per_unit))}${unit}`
}
</script> </script>
<p>FoodLibrary (placeholder)</p> <div class="food-library">
{#if editingMealId}
<FoodEditor mealId={editingMealId} onCancel={closeEditor} />
{:else if editingFood}
<FoodEditor food={editingFood} onSaved={closeEditor} onCancel={closeEditor} />
{:else}
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>
← Dashboard
</button>
<h2>Foods</h2>
<form class="search-bar" onsubmit={search}>
<input type="text" bind:value={query} placeholder="Search name or brand…" />
<button type="submit" disabled={loading}>Search</button>
</form>
<label class="deleted-toggle">
<input type="checkbox" checked={showDeleted} onchange={toggleDeleted} />
Show deleted
</label>
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
{#if loading}
<p class="status">Loading foods…</p>
{:else if error}
<p class="err" role="alert">{error}</p>
{:else if foods.length === 0}
<p class="status empty">No foods found.</p>
{:else}
<ul class="food-list">
{#each foods as food (food.id)}
<li class="food-row" class:deleted={food.deleted_at}>
<div class="food-info">
<span class="food-name">
{food.name}
{#if food.is_meal}<span class="meal-badge">meal</span>{/if}
{#if food.deleted_at}<span class="deleted-badge">deleted</span>{/if}
</span>
{#if food.brand}<span class="food-brand">{food.brand}</span>{/if}
<span class="food-kcal">{rowKcal(food)}</span>
</div>
<div class="row-actions">
{#if food.deleted_at}
<button
type="button"
class="secondary"
onclick={() => doRestore(food)}
disabled={actionBusyId === food.id}
>
{actionBusyId === food.id ? '…' : 'Restore'}
</button>
{:else if confirmingDeleteId === food.id}
<span class="confirm-text">Delete?</span>
<button
type="button"
class="danger"
onclick={() => doDelete(food)}
disabled={actionBusyId === food.id}
>
{actionBusyId === food.id ? '…' : 'Yes'}
</button>
<button type="button" class="secondary" onclick={() => confirmingDeleteId = null}>No</button>
{:else}
<button type="button" class="secondary" onclick={() => openEdit(food)}>Edit</button>
<button type="button" class="danger" onclick={() => confirmingDeleteId = food.id}>Delete</button>
{/if}
</div>
</li>
{/each}
</ul>
<div class="pagination">
<button type="button" class="secondary" onclick={prevPage} disabled={offset === 0}>
← Prev
</button>
<span class="page-info">Showing {offset + 1}{offset + foods.length}</span>
<button type="button" class="secondary" onclick={nextPage} disabled={!hasMore}>
Next →
</button>
</div>
{/if}
{/if}
</div>
<style>
.food-library { }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.5rem;
}
h2 { margin: 0 0 0.75rem; font-size: 1.25rem; }
.search-bar {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.search-bar input {
flex: 1;
padding: 0.4rem 0.6rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.deleted-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
margin-bottom: 0.75rem;
}
.status { color: var(--text-muted, #6b7280); }
.status.empty { font-style: italic; }
.err { color: #dc2626; font-size: 0.85rem; }
.food-list {
list-style: none;
margin: 0;
padding: 0;
}
.food-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
padding: 0.55rem 0;
border-bottom: 1px solid var(--border, #e5e7eb);
}
.food-row.deleted {
opacity: 0.6;
background: var(--bg-muted, #f9fafb);
}
.food-info {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.5rem;
min-width: 0;
}
.food-name { font-weight: 600; }
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.food-kcal { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.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;
}
.deleted-badge {
font-size: 0.7rem;
background: #fee2e2;
color: #991b1b;
padding: 0.1em 0.4em;
border-radius: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.confirm-text { font-size: 0.85rem; color: var(--text, #111827); }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.75rem;
}
.page-info {
font-size: 0.85rem;
color: var(--text-muted, #6b7280);
}
button {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
button:disabled { opacity: 0.5; cursor: default; }
button.secondary { background: var(--bg-muted, #f3f4f6); }
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
</style>
+4
View File
@@ -28,6 +28,10 @@ export const api = {
health: () => request('/api/health'), health: () => request('/api/health'),
// Foods (spec §3.1) // Foods (spec §3.1)
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`), searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
listFoods: ({ q = '', limit = 20, offset = 0, includeDeleted = false } = {}) =>
request(`/api/foods?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}&include_deleted=${includeDeleted}`),
deleteFood: (id) => request(`/api/foods/${id}`, { method: 'DELETE' }),
restoreFood: (id) => request(`/api/foods/${id}/restore`, { method: 'POST' }),
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`), searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`), recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
getFood: (id) => request(`/api/foods/${id}`), getFood: (id) => request(`/api/foods/${id}`),