Files
calcount/frontend/src/components/Dashboard.svelte
T

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>