TICKET-005: Frontend daily view — milestone M1 complete

- Dashboard: progress bar vs target, entries grouped by meal slot,
  edit/delete inline, date navigation (UTC-safe shiftDate helper)
- Add Food manual creation form, search-and-log flow with live preview
- Minimal target form; loading/error/empty states throughout
- Stores (current date, log, summary) with summary refresh on mutation
- All HTTP via lib/api.js; formatting via lib/format.js; runes only
- Full suites green (backend 104 passed, frontend vitest + build)
This commit is contained in:
Craig
2026-07-26 13:55:39 +01:00
parent 5372e8cbca
commit b69661c997
11 changed files with 1375 additions and 47 deletions
+101 -24
View File
@@ -1,34 +1,111 @@
<script>
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6)
import { api } from '../lib/api.js'
// 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).
import { currentDate, dayData, refreshDayData, appView } from '../lib/stores.svelte.js'
import { formatDate } from '../lib/format.js'
import ProgressBar from './ProgressBar.svelte'
import LogEntry from './LogEntry.svelte'
let { date } = $props()
let entries = $state(null) // null = loading
let error = $state(null)
// Re-fetch whenever date changes
$effect(() => {
entries = null
error = null
api.getLog(date)
.then((data) => (entries = data))
.catch((e) => (error = e.message))
// reading date triggers re-run
void date
refreshDayData()
})
// Group entries by meal_slot (null → "Other")
let groups = $derived.by(() => {
const log = dayData.log
const map = new Map()
const order = ['breakfast', 'lunch', 'dinner', 'snack']
for (const slot of order) map.set(slot, [])
const unslotted = []
for (const entry of log) {
if (entry.meal_slot && map.has(entry.meal_slot)) {
map.get(entry.meal_slot).push(entry)
} else {
unslotted.push(entry)
}
}
const result = []
for (const slot of order) {
const entries = map.get(slot)
if (entries?.length) result.push({ slot, entries })
}
if (unslotted.length) result.push({ slot: null, entries: unslotted })
return result
})
</script>
<h2>{formatDate(date)}</h2>
<section class="dashboard">
<h2>{formatDate(date)}</h2>
{#if error}
<p role="alert">Failed to load log: {error}</p>
{:else if entries === null}
<p>Loading…</p>
{:else if entries.length === 0}
<p>Nothing logged yet today.</p>
{:else}
<ul>
{#each entries as entry (entry.id)}
<li>Food #{entry.food_id} × {entry.quantity}</li>
{/each}
</ul>
{/if}
{#if dayData.loading}
<p class="status">Loading…</p>
{:else if dayData.error}
<p class="status err" role="alert">Failed to load: {dayData.error}</p>
{:else}
<ProgressBar
totals={dayData.summary?.totals}
target={dayData.summary?.target}
/>
{#if dayData.log.length === 0}
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
{:else}
{#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} />
{/each}
</ul>
</div>
{/each}
{/if}
{/if}
</section>
<style>
.dashboard { }
h2 {
margin: 0 0 0.75rem;
font-size: 1.15rem;
}
.status {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
}
.status.err { color: #dc2626; }
.status.empty {
margin-top: 1.5rem;
text-align: center;
padding: 2rem 1rem;
border: 2px dashed var(--border, #e5e7eb);
border-radius: 0.5rem;
}
.meal-group {
margin-top: 1rem;
}
.slot-header {
font-size: 0.85rem;
text-transform: capitalize;
color: var(--text-muted, #6b7280);
margin: 0 0 0.25rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border, #e5e7eb);
}
.entry-list {
margin: 0;
padding: 0;
}
</style>