354 lines
9.7 KiB
Svelte
354 lines
9.7 KiB
Svelte
<script>
|
|
// 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, 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()
|
|
|
|
// Re-fetch whenever date changes
|
|
$effect(() => {
|
|
// reading date triggers re-run
|
|
void date
|
|
refreshDayData()
|
|
})
|
|
|
|
// ── Recent foods ──────────────────────────────────────────────────────────
|
|
let recentFoods = $state([])
|
|
let recentLoading = $state(false)
|
|
let recentError = $state(null)
|
|
|
|
onMount(() => {
|
|
loadRecentFoods()
|
|
})
|
|
|
|
async function loadRecentFoods() {
|
|
recentLoading = true
|
|
recentError = null
|
|
try {
|
|
recentFoods = await api.recentFoods(10)
|
|
} catch (e) {
|
|
recentError = e.message
|
|
} finally {
|
|
recentLoading = false
|
|
}
|
|
}
|
|
|
|
async function quickLog(food) {
|
|
try {
|
|
const entry = await api.addLogEntry({
|
|
food_id: food.id,
|
|
quantity: defaultQuantity(food),
|
|
meal_slot: getDefaultSlot(),
|
|
date: currentDate.value,
|
|
})
|
|
dayData.log = [...dayData.log, entry]
|
|
} catch {
|
|
// Silently fail — user can manually log
|
|
}
|
|
}
|
|
|
|
function getDefaultSlot() {
|
|
const hour = new Date().getHours()
|
|
if (hour < 10) return 'breakfast'
|
|
if (hour < 14) return 'lunch'
|
|
if (hour < 19) return 'dinner'
|
|
return 'snack'
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// ── 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">
|
|
<h2>{formatDate(date)}</h2>
|
|
|
|
{#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}
|
|
/>
|
|
|
|
<!-- Recent foods quick-log -->
|
|
{#if recentFoods.length > 0}
|
|
<div class="recent-foods">
|
|
<h3 class="recent-header">Recent</h3>
|
|
<div class="recent-list">
|
|
{#each recentFoods as food (food.id)}
|
|
<button
|
|
type="button"
|
|
class="recent-chip"
|
|
onclick={() => quickLog(food)}
|
|
title="{food.name}{food.brand ? ' • ' + food.brand : ''} — {formatKcal(food.calories_per_unit)}/{food.unit_type === 'count' ? 'item' : '100g'}"
|
|
>
|
|
<span class="chip-name">{food.name}</span>
|
|
<span class="chip-kcal">{formatKcal(food.calories_per_unit)}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else if recentLoading}
|
|
<p class="status">Loading recent foods…</p>
|
|
{/if}
|
|
|
|
{#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)}
|
|
<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>
|
|
|
|
<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;
|
|
}
|
|
|
|
/* ── Recent foods ───────────────────────────────────────────────────── */
|
|
.recent-foods {
|
|
margin: 0.75rem 0 1rem;
|
|
}
|
|
.recent-header {
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-muted, #6b7280);
|
|
margin: 0 0 0.4rem;
|
|
}
|
|
.recent-list {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
overflow-x: auto;
|
|
padding-bottom: 0.3rem;
|
|
}
|
|
.recent-chip {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--border, #d1d5db);
|
|
border-radius: 0.4rem;
|
|
background: var(--bg, #fff);
|
|
cursor: pointer;
|
|
font: inherit;
|
|
text-align: left;
|
|
min-width: 5rem;
|
|
}
|
|
.recent-chip:hover {
|
|
background: var(--bg-muted, #f9fafb);
|
|
}
|
|
.chip-name {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 8rem;
|
|
}
|
|
.chip-kcal {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted, #6b7280);
|
|
}
|
|
|
|
.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 row with multi-select ────────────────────────────────────── */
|
|
.entry-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.4rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.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> |