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:
+274
-5
@@ -1,10 +1,17 @@
|
||||
<script>
|
||||
// App.svelte — Root component with conditional view switching (no router library).
|
||||
// Date navigation: previous/next day buttons + current date display.
|
||||
// Dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4).
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from './lib/api.js'
|
||||
import { currentDate } from './lib/stores.svelte.js'
|
||||
import { currentDate, appView, goPrevDay, goNextDay, setDate } from './lib/stores.svelte.js'
|
||||
import { formatDate } from './lib/format.js'
|
||||
import Dashboard from './components/Dashboard.svelte'
|
||||
import FoodSearch from './components/FoodSearch.svelte'
|
||||
import FoodEditor from './components/FoodEditor.svelte'
|
||||
|
||||
// Every async view handles loading / error / empty states (spec §8.2 rule 4)
|
||||
// Health check — every async view handles loading/error states (spec §8.2 rule 4)
|
||||
let backend = $state({ loading: true, ok: false, error: null })
|
||||
|
||||
onMount(async () => {
|
||||
@@ -15,17 +22,149 @@
|
||||
backend = { loading: false, ok: false, error: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Target form state ──────────────────────────────────────────────────────
|
||||
let targetCalories = $state('2000')
|
||||
let targetProtein = $state('')
|
||||
let targetCarbs = $state('')
|
||||
let targetFat = $state('')
|
||||
let targetStartDate = $state(currentDate.value)
|
||||
let targetSaving = $state(false)
|
||||
let targetError = $state(null)
|
||||
let targetSuccess = $state(false)
|
||||
|
||||
function openTargetForm() {
|
||||
targetStartDate = currentDate.value
|
||||
targetSuccess = false
|
||||
targetError = null
|
||||
appView.current = 'targetForm'
|
||||
}
|
||||
|
||||
async function saveTarget(e) {
|
||||
e.preventDefault()
|
||||
const cal = parseInt(targetCalories)
|
||||
if (isNaN(cal) || cal <= 0) { targetError = 'Calories must be a positive number'; return }
|
||||
|
||||
targetSaving = true
|
||||
targetError = null
|
||||
try {
|
||||
await api.createTarget({
|
||||
start_date: targetStartDate,
|
||||
calories: cal,
|
||||
protein_g: targetProtein ? parseFloat(targetProtein) : null,
|
||||
carbs_g: targetCarbs ? parseFloat(targetCarbs) : null,
|
||||
fat_g: targetFat ? parseFloat(targetFat) : null,
|
||||
})
|
||||
targetSuccess = true
|
||||
setTimeout(() => {
|
||||
appView.current = 'dashboard'
|
||||
targetSuccess = false
|
||||
}, 800)
|
||||
} catch (e) {
|
||||
targetError = e.message
|
||||
} finally {
|
||||
targetSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date navigation ───────────────────────────────────────────────────────
|
||||
function handleDateInput(e) {
|
||||
setDate(e.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>CalCount</h1>
|
||||
|
||||
{#if backend.loading}
|
||||
<p>Connecting to backend…</p>
|
||||
<p class="status">Connecting to backend…</p>
|
||||
{:else if backend.error}
|
||||
<p role="alert">Backend unreachable: {backend.error}</p>
|
||||
<p class="status err" role="alert">Backend unreachable: {backend.error}</p>
|
||||
{:else}
|
||||
<Dashboard date={currentDate.value} />
|
||||
{#if appView.current === 'dashboard' || appView.current === 'addFood' || appView.current === 'createFood' || appView.current === 'targetForm'}
|
||||
<!-- Date navigation (always visible except on target form or when not relevant) -->
|
||||
<nav class="date-nav">
|
||||
<button type="button" class="nav-btn" onclick={goPrevDay} title="Previous day">←</button>
|
||||
<input
|
||||
type="date"
|
||||
value={currentDate.value}
|
||||
onchange={handleDateInput}
|
||||
class="date-input"
|
||||
/>
|
||||
<button type="button" class="nav-btn" onclick={goNextDay} title="Next day">→</button>
|
||||
<span class="date-display">{formatDate(currentDate.value)}</span>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
{#if appView.current === 'dashboard'}
|
||||
<Dashboard date={currentDate.value} />
|
||||
|
||||
<div class="fab-container">
|
||||
<button type="button" class="fab" onclick={() => appView.current = 'addFood'}>
|
||||
+ Add Food
|
||||
</button>
|
||||
<button type="button" class="target-btn" onclick={openTargetForm}>
|
||||
🎯 Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if appView.current === 'addFood'}
|
||||
<FoodSearch />
|
||||
|
||||
{:else if appView.current === 'createFood'}
|
||||
<FoodEditor />
|
||||
|
||||
{:else if appView.current === 'targetForm'}
|
||||
<div class="target-form-view">
|
||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>← Back</button>
|
||||
<h3>Set daily target</h3>
|
||||
|
||||
{#if targetSuccess}
|
||||
<p class="success">Target saved!</p>
|
||||
{:else}
|
||||
<form onsubmit={saveTarget}>
|
||||
<label>
|
||||
Calorie goal <span class="required">*</span>
|
||||
<input type="number" min="1" bind:value={targetCalories} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Start date
|
||||
<input type="date" bind:value={targetStartDate} />
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Macros (optional, grams)</legend>
|
||||
<div class="macro-grid">
|
||||
<label>
|
||||
Protein
|
||||
<input type="number" step="any" min="0" bind:value={targetProtein} placeholder="g" />
|
||||
</label>
|
||||
<label>
|
||||
Carbs
|
||||
<input type="number" step="any" min="0" bind:value={targetCarbs} placeholder="g" />
|
||||
</label>
|
||||
<label>
|
||||
Fat
|
||||
<input type="number" step="any" min="0" bind:value={targetFat} placeholder="g" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if targetError}<p class="err" role="alert">{targetError}</p>{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" disabled={targetSaving}>
|
||||
{targetSaving ? 'Saving…' : 'Save target'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => appView.current = 'dashboard'}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -35,5 +174,135 @@
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
/* mobile-first single column */
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); }
|
||||
.status.err { color: #dc2626; }
|
||||
|
||||
/* ── Date navigation ─────────────────────────────────────────────── */
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.nav-btn {
|
||||
background: var(--bg-muted, #f3f4f6);
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.date-input {
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.date-display {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
/* ── FAB / action buttons ────────────────────────────────────────── */
|
||||
.fab-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.fab, .target-btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg, #fff);
|
||||
}
|
||||
.fab {
|
||||
background: var(--link, #2563eb);
|
||||
color: #fff;
|
||||
border-color: var(--link, #2563eb);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Target form ─────────────────────────────────────────────────── */
|
||||
.target-form-view {
|
||||
/* mobile-first single column */
|
||||
}
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link, #2563eb);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
h3 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.required { color: #dc2626; }
|
||||
input[type="number"],
|
||||
input[type="date"] {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font: inherit;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
legend {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #6b7280);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.macro-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
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:disabled { opacity: 0.5; cursor: default; }
|
||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||
.err { color: #dc2626; font-size: 0.85rem; }
|
||||
.success { color: #16a34a; font-size: 1rem; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user