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
+4 -2
View File
@@ -113,14 +113,16 @@ class LogEntryUpdate(BaseModel):
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, and serving info so the frontend can render
log entries without N+1 lookups. Soft-deleted foods render here (§2.1)."""
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1)."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
+273 -4
View File
@@ -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}
{#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>
+96 -19
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>
<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>
{#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}
<ul>
{#each entries as entry (entry.id)}
<li>Food #{entry.food_id} × {entry.quantity}</li>
<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>
+230 -2
View File
@@ -1,5 +1,233 @@
<script>
// FoodEditor — Food create/edit form, shared by scan/search/library flows (spec §4.5, §4.7)
// FoodEditor — Manual food creation form (spec §4.5).
// Shared by scan/search/library flows; here used for manual creation.
import { api } from '../lib/api.js'
import { appView } from '../lib/stores.svelte.js'
let name = $state('')
let brand = $state('')
let unitType = $state('weight') // 'weight' | 'count'
let caloriesPerUnit = $state('')
let proteinPerUnit = $state('')
let carbsPerUnit = $state('')
let fatPerUnit = $state('')
let servingSizeG = $state('')
let servingName = $state('')
let saving = $state(false)
let error = $state(null)
function reset() {
name = ''
brand = ''
unitType = 'weight'
caloriesPerUnit = ''
proteinPerUnit = ''
carbsPerUnit = ''
fatPerUnit = ''
servingSizeG = ''
servingName = ''
error = null
}
async function handleSubmit(e) {
e.preventDefault()
const cal = parseFloat(caloriesPerUnit)
if (!name.trim()) { error = 'Name is required'; return }
if (isNaN(cal) || cal <= 0) { error = 'Calories per unit is required'; return }
saving = true
error = null
try {
const food = await api.createFood({
name: name.trim(),
brand: brand.trim() || null,
unit_type: unitType,
calories_per_unit: cal,
protein_per_unit: proteinPerUnit ? parseFloat(proteinPerUnit) : null,
carbs_per_unit: carbsPerUnit ? parseFloat(carbsPerUnit) : null,
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null,
source: 'manual',
is_meal: false,
})
reset()
// Go to search so user can log the new food immediately
appView.current = 'addFood'
} catch (e) {
error = e.message
} finally {
saving = false
}
}
</script>
<p>FoodEditor (placeholder)</p>
<div class="food-editor">
<button type="button" class="back-btn" onclick={() => { reset(); appView.current = 'addFood' }}> Back</button>
<h3>Create food</h3>
<form onsubmit={handleSubmit}>
<label>
Name <span class="required">*</span>
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
</label>
<label>
Brand
<input type="text" bind:value={brand} placeholder="Optional" />
</label>
<fieldset>
<legend>Unit type</legend>
<label class="radio-label">
<input type="radio" name="unitType" value="weight" bind:group={unitType} />
Weight (nutrition per 100g)
</label>
<label class="radio-label">
<input type="radio" name="unitType" value="count" bind:group={unitType} />
Count (nutrition per item)
</label>
</fieldset>
<label>
Calories per {unitType === 'weight' ? '100g' : 'item'} <span class="required">*</span>
<input type="number" step="any" min="0.01" bind:value={caloriesPerUnit} placeholder="e.g. 350" required />
</label>
<fieldset>
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
<div class="macro-grid">
<label>
Protein
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
</label>
<label>
Carbs
<input type="number" step="any" min="0" bind:value={carbsPerUnit} placeholder="g" />
</label>
<label>
Fat
<input type="number" step="any" min="0" bind:value={fatPerUnit} placeholder="g" />
</label>
</div>
</fieldset>
{#if unitType === 'weight'}
<fieldset>
<legend>Serving info (optional)</legend>
<label>
Serving size (g)
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" />
</label>
<label>
Serving name
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' />
</label>
</fieldset>
{/if}
{#if error}<p class="err" role="alert">{error}</p>{/if}
<div class="form-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'}
</button>
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}>
Cancel
</button>
</div>
</form>
</div>
<style>
.food-editor { }
.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;
color: var(--text, #111827);
}
.required { color: #dc2626; }
input[type="text"],
input[type="number"] {
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;
}
.radio-label {
flex-direction: row;
align-items: center;
gap: 0.4rem;
margin-top: 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; }
</style>
+279 -2
View File
@@ -1,5 +1,282 @@
<script>
// FoodSearch — Free-text search: local DB first, OFF fallback (spec §4.2)
// FoodSearch — Free-text search: local DB search, select, log (spec §4.2, TICKET-005).
// Full search UX (recent foods, OFF fallback) is TICKET-006.
import { api } from '../lib/api.js'
import { currentDate, addLogEntryToStore, appView } from '../lib/stores.svelte.js'
import { formatKcal, defaultQuantity, previewCalories } from '../lib/format.js'
let query = $state('')
let results = $state(null) // null = not searched yet, [] = no results
let searching = $state(false)
let searchError = $state(null)
// Selected food for logging
let selected = $state(null)
let logQuantity = $state(1)
let logMealSlot = $state('')
let logging = $state(false)
let logError = $state(null)
let liveKcal = $derived(Math.round(previewCalories(selected, logQuantity)))
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
async function doSearch() {
const q = query.trim()
if (!q) { results = null; return }
searching = true
searchError = null
try {
results = await api.searchFoods(q)
} catch (e) {
searchError = e.message
results = []
} finally {
searching = false
}
}
function pickFood(food) {
selected = food
logQuantity = defaultQuantity(food)
logMealSlot = ''
logError = null
}
function clearSelection() {
selected = null
logError = null
}
async function confirmLog() {
if (!selected || logQuantity <= 0) return
logging = true
logError = null
try {
const entry = await api.addLogEntry({
food_id: selected.id,
quantity: parseFloat(logQuantity),
meal_slot: logMealSlot || null,
date: currentDate.value,
})
await addLogEntryToStore(entry)
// Reset and go back to dashboard
selected = null
query = ''
results = null
appView.current = 'dashboard'
} catch (e) {
logError = e.message
} finally {
logging = false
}
}
</script>
<p>FoodSearch (placeholder)</p>
<div class="food-search">
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}> Back</button>
{#if !selected}
<!-- Search -->
<h3>Find a food</h3>
<form class="search-form" onsubmit={(e) => { e.preventDefault(); doSearch() }}>
<input
type="search"
placeholder="Search foods…"
bind:value={query}
class="search-input"
/>
<button type="submit" disabled={searching || !query.trim()}>Search</button>
</form>
{#if searching}
<p class="status">Searching…</p>
{:else if searchError}
<p class="status err" role="alert">{searchError}</p>
{:else if results !== null}
{#if results.length === 0}
<p class="status">No foods found.</p>
{:else}
<ul class="results-list">
{#each results as food (food.id)}
<li>
<button type="button" class="result-item" onclick={() => pickFood(food)}>
<span class="r-name">{food.name}</span>
{#if food.brand}<span class="r-brand">{food.brand}</span>{/if}
<span class="r-kcal">{formatKcal(food.calories_per_unit)}
{food.unit_type === 'count' ? '/item' : '/100g'}
</span>
</button>
</li>
{/each}
</ul>
{/if}
{/if}
<p class="or-create">
Or
<button type="button" class="link" onclick={() => appView.current = 'createFood'}>
create a new food
</button>
</p>
{:else}
<!-- Logging form for selected food -->
<h3>Log "{selected.name}"</h3>
{#if selected.brand}<p class="brand">{selected.brand}</p>{/if}
<div class="log-form">
<label>
Quantity
<input
type="number"
step="any"
min="0.1"
bind:value={logQuantity}
class="qty-input"
/>
{selected.unit_type === 'count' ? 'items' : 'g'}
</label>
<p class="preview-kcal">= {formatKcal(liveKcal)}</p>
<label>
Meal slot
<select bind:value={logMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
{#if logError}<p class="err" role="alert">{logError}</p>{/if}
<div class="log-actions">
<button type="button" onclick={confirmLog} disabled={logging || logQuantity <= 0}>
{logging ? 'Logging…' : 'Log it'}
</button>
<button type="button" class="secondary" onclick={clearSelection} disabled={logging}>
Back
</button>
</div>
</div>
{/if}
</div>
<style>
.food-search {
/* 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 0.75rem;
font-size: 1.1rem;
}
.search-form {
display: flex;
gap: 0.4rem;
}
.search-input {
flex: 1;
padding: 0.4rem 0.6rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.status { font-size: 0.9rem; color: var(--text-muted, #6b7280); margin-top: 0.5rem; }
.status.err { color: #dc2626; }
.results-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.35rem;
}
.result-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
width: 100%;
padding: 0.5rem 0.6rem;
border: none;
background: none;
cursor: pointer;
font: inherit;
text-align: left;
border-bottom: 1px solid var(--border, #e5e7eb);
}
.result-item:last-child { border-bottom: none; }
.result-item:hover { background: var(--bg-muted, #f9fafb); }
.r-name { font-weight: 600; }
.r-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.r-kcal { margin-left: auto; font-size: 0.85rem; color: var(--text-muted, #6b7280); }
.or-create {
margin-top: 0.75rem;
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
}
button.link {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
text-decoration: underline;
font: inherit;
padding: 0;
}
.brand { color: var(--text-muted, #6b7280); font-size: 0.9rem; margin-top: -0.5rem; }
.log-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.log-form label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
.preview-kcal {
font-weight: 600;
font-size: 1rem;
color: var(--text, #111827);
margin: 0;
}
.log-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
button {
padding: 0.45rem 0.9rem;
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; }
select {
padding: 0.3rem 0.4rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
</style>
+199 -2
View File
@@ -1,5 +1,202 @@
<script>
// LogEntry — One log row: name, quantity, kcal, edit/delete; meals render collapsible (spec §3.3)
// LogEntry — One log row: name, quantity, kcal, edit/delete (spec §3.3, §4.6).
// Meals render collapsible (TICKET-007); for now a flat row.
import { api } from '../lib/api.js'
import { formatKcal, formatQuantity, caloriesForEntry, previewCalories } from '../lib/format.js'
import { updateLogEntryInStore, removeLogEntryFromStore } from '../lib/stores.svelte.js'
let { entry } = $props()
let editing = $state(false)
let editQuantity = $state(0)
let editMealSlot = $state('')
let saving = $state(false)
let error = $state(null)
let confirmingDelete = $state(false)
let deleting = $state(false)
// Sync edit state when entry changes or editing starts
$effect(() => {
if (entry) {
editQuantity = entry.quantity
editMealSlot = entry.meal_slot || ''
}
})
let kcal = $derived(Math.round(caloriesForEntry(entry)))
let liveKcal = $derived(Math.round(previewCalories(entry.food, editQuantity)))
const SLOTS = ['', 'breakfast', 'lunch', 'dinner', 'snack']
async function saveEdit() {
saving = true
error = null
try {
const updates = { quantity: parseFloat(editQuantity) }
if (editMealSlot !== (entry.meal_slot || '')) {
updates.meal_slot = editMealSlot || null
}
const updated = await api.updateLogEntry(entry.id, updates)
await updateLogEntryInStore(updated)
editing = false
} catch (e) {
error = e.message
} finally {
saving = false
}
}
function cancelEdit() {
editQuantity = entry.quantity
editMealSlot = entry.meal_slot || ''
editing = false
error = null
}
async function doDelete() {
deleting = true
error = null
try {
await api.deleteLogEntry(entry.id)
await removeLogEntryFromStore(entry.id)
} catch (e) {
error = e.message
confirmingDelete = false
} finally {
deleting = false
}
}
</script>
<p>LogEntry (placeholder)</p>
<li class="log-entry">
{#if editing}
<div class="edit-form">
<label>
Qty
<input
type="number"
step="any"
min="0.1"
bind:value={editQuantity}
class="qty-input"
/>
{entry.food?.unit_type === 'count' ? 'items' : 'g'}
</label>
<span class="live-kcal">{formatKcal(liveKcal)}</span>
<label>
Slot
<select bind:value={editMealSlot}>
{#each SLOTS as s}
<option value={s}>{s || '(none)'}</option>
{/each}
</select>
</label>
<div class="edit-actions">
<button type="button" onclick={saveEdit} disabled={saving || editQuantity <= 0}>
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" class="secondary" onclick={cancelEdit} disabled={saving}>Cancel</button>
</div>
{#if error}<p class="err" role="alert">{error}</p>{/if}
</div>
{:else if confirmingDelete}
<div class="confirm-delete">
<span>Delete "{entry.food?.name}"?</span>
<button type="button" class="danger" onclick={doDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Yes'}
</button>
<button type="button" class="secondary" onclick={() => confirmingDelete = false} disabled={deleting}>No</button>
</div>
{:else}
<div class="entry-main">
<span class="food-name">{entry.food?.name ?? `Food #${entry.food_id}`}</span>
<span class="food-brand">{entry.food?.brand}</span>
<span class="qty">{formatQuantity(entry)}</span>
<span class="kcal">{formatKcal(kcal)}</span>
{#if entry.meal_slot}
<span class="slot-badge">{entry.meal_slot}</span>
{/if}
<div class="entry-actions">
<button type="button" class="icon-btn" title="Edit" onclick={() => editing = true}>✏️</button>
<button type="button" class="icon-btn" title="Delete" onclick={() => confirmingDelete = true}>🗑️</button>
</div>
</div>
{/if}
</li>
<style>
.log-entry {
list-style: none;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border, #e5e7eb);
}
.entry-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.7rem;
}
.food-name { font-weight: 600; }
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
.qty { color: var(--text-muted, #6b7280); font-size: 0.9rem; }
.kcal { font-weight: 600; margin-left: auto; }
.slot-badge {
font-size: 0.75rem;
background: var(--bg-muted, #e5e7eb);
padding: 0.15em 0.5em;
border-radius: 0.3rem;
text-transform: capitalize;
}
.entry-actions {
display: flex;
gap: 0.25rem;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0.2rem;
line-height: 1;
}
.edit-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.qty-input {
width: 5rem;
padding: 0.2rem 0.4rem;
font: inherit;
}
.live-kcal {
font-weight: 600;
color: var(--text-muted, #6b7280);
}
.edit-actions {
display: flex;
gap: 0.3rem;
}
.confirm-delete {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
button {
padding: 0.3rem 0.7rem;
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; }
.err { color: #dc2626; font-size: 0.85rem; width: 100%; }
</style>
+87 -2
View File
@@ -1,5 +1,90 @@
<script>
// ProgressBar — Calories vs target with remaining (spec §4.6). Renders server-computed values only (spec §8.2 rule 2).
// ProgressBar — Calories vs target with remaining (spec §4.6).
// Renders server-computed values only (spec §8.2 rule 2).
// The server returns raw totals + target; remaining computed here for display.
import { formatKcal } from '../lib/format.js'
import { navigateTo } from '../lib/stores.svelte.js'
let { totals, target } = $props()
let consumed = $derived(totals?.calories ?? 0)
let goal = $derived(target?.calories ?? null)
let pct = $derived(goal ? Math.min(100, Math.round((consumed / goal) * 100)) : 0)
let remaining = $derived(goal ? goal - consumed : null)
let barColor = $derived(pct > 100 ? 'over' : pct >= 90 ? 'warn' : 'ok')
</script>
<p>ProgressBar (placeholder)</p>
<div class="progress-bar">
{#if goal}
<div class="bar-track">
<div
class="bar-fill {barColor}"
style="width: {Math.min(100, pct)}%"
role="progressbar"
aria-valuenow={Math.round(consumed)}
aria-valuemin="0"
aria-valuemax={goal}
></div>
</div>
<div class="bar-labels">
<span>{formatKcal(consumed)} consumed</span>
<span>{formatKcal(goal)} target</span>
{#if remaining !== null}
<span class="remaining">
{remaining <= 0 ? formatKcal(Math.abs(remaining)) + ' over' : formatKcal(remaining) + ' remaining'}
</span>
{/if}
</div>
{:else}
<p class="no-target">
{formatKcal(consumed)} consumed today.
<button class="link" type="button" onclick={() => navigateTo('targetForm')}>
Set a target
</button>
</p>
{/if}
</div>
<style>
.progress-bar {
margin-bottom: 1.25rem;
}
.bar-track {
height: 1.25rem;
background: var(--bg-muted, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 0.5rem;
transition: width 0.3s ease;
min-width: 0;
}
.bar-fill.ok { background: var(--color-ok, #22c55e); }
.bar-fill.warn { background: var(--color-warn, #f59e0b); }
.bar-fill.over { background: var(--color-over, #ef4444); }
.bar-labels {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
margin-top: 0.4rem;
color: var(--text-muted, #6b7280);
}
.remaining { font-weight: 600; }
.no-target {
font-size: 0.9rem;
color: var(--text-muted, #6b7280);
}
button.link {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
text-decoration: underline;
font: inherit;
padding: 0;
}
</style>
+12 -1
View File
@@ -11,8 +11,16 @@ async function request(path, options = {}) {
...options,
})
if (!resp.ok) {
throw new Error(`API ${options.method ?? 'GET'} ${path} failed: ${resp.status}`)
// Try to extract backend error detail for a better message
let detail = `${resp.status}`
try {
const body = await resp.json()
if (body.detail) detail = body.detail
} catch { /* can't parse — use status code */ }
throw new Error(`API ${options.method ?? 'GET'} ${path} failed: ${detail}`)
}
// 204 No Content (DELETE) — return success indicator
if (resp.status === 204) return { ok: true }
return resp.json()
}
@@ -26,10 +34,13 @@ export const api = {
// Daily log (spec §3.3) — dates are YYYY-MM-DD strings end-to-end (spec §8.3 rule 4)
getLog: (date) => request(`/api/log?date=${date}`),
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
updateLogEntry: (id, data) => request(`/api/log/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteLogEntry: (id) => request(`/api/log/${id}`, { method: 'DELETE' }),
getSummary: (date) => request(`/api/log/summary?date=${date}`),
// Targets (spec §3.4)
getCurrentTarget: () => request('/api/targets/current'),
getTargets: () => request('/api/targets'),
createTarget: (target) => request('/api/targets', { method: 'POST', body: JSON.stringify(target) }),
// OFF proxy (spec §3.5) — the frontend never calls OFF directly
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
+68
View File
@@ -22,3 +22,71 @@ export function formatDate(yyyyMmDd) {
day: 'numeric',
})
}
/**
* Compute the calories contributed by a single log entry.
* This is the permitted simple linear scaling for display only (spec §8.2 rule 2).
* - weight-type: (quantity / 100) × calories_per_unit
* - count-type: quantity × calories_per_unit
*/
export function caloriesForEntry(entry) {
const food = entry.food
if (!food || food.calories_per_unit == null) return 0
if (food.unit_type === 'count') {
return entry.quantity * food.calories_per_unit
}
// weight-type (default)
return (entry.quantity / 100) * food.calories_per_unit
}
/**
* Render a quantity string with serving awareness.
* - weight-type with matching serving: "1 serving (40g)" or just "40g"
* - count-type: "2×" or "1×"
*/
export function formatQuantity(entry) {
const food = entry.food
if (!food) return `${entry.quantity}`
if (food.unit_type === 'count') {
return `${entry.quantity}×`
}
// weight-type
if (food.serving_size_g && Math.abs(entry.quantity - food.serving_size_g) < 0.01) {
const label = food.serving_name || `${food.serving_size_g}g`
return `1 serving (${label})`
}
return `${Math.round(entry.quantity)}g`
}
/**
* Return a default quantity for a food when logging.
* - weight-type: serving_size_g if set, otherwise 100
* - count-type: 1
*/
export function defaultQuantity(food) {
if (food.unit_type === 'count') return 1
return food.serving_size_g || 100
}
/**
* Live preview of calories for a given quantity of a food (display only — spec §8.2 rule 2).
*/
export function previewCalories(food, quantity) {
if (!food || food.calories_per_unit == null || quantity == null) return 0
if (food.unit_type === 'count') {
return quantity * food.calories_per_unit
}
return (quantity / 100) * food.calories_per_unit
}
/**
* Shift a YYYY-MM-DD date string by n days (can be negative).
* Uses UTC date math to avoid timezone shifts (spec §8.3 rule 4).
*/
export function shiftDate(yyyyMmDd, days) {
// Parse as UTC midnight so setUTCDate / getUTCDate stay on the civil day
const d = new Date(yyyyMmDd + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
+39 -1
View File
@@ -3,7 +3,7 @@
* Vitest only for stores/format logic, no component tests in v1).
*/
import { describe, it, expect } from 'vitest'
import { formatKcal, formatGrams, formatDate } from './format.js'
import { formatKcal, formatGrams, formatDate, shiftDate } from './format.js'
describe('formatKcal', () => {
it('rounds to whole numbers', () => {
@@ -26,3 +26,41 @@ describe('formatDate', () => {
expect(formatDate('2026-07-25')).toMatch(/Jul 25/)
})
})
describe('shiftDate', () => {
it('shifts forward by one day', () => {
expect(shiftDate('2026-07-25', 1)).toBe('2026-07-26')
})
it('shifts backward by one day', () => {
expect(shiftDate('2026-07-25', -1)).toBe('2026-07-24')
})
it('shifts across month boundaries forward', () => {
expect(shiftDate('2026-07-31', 1)).toBe('2026-08-01')
})
it('shifts across month boundaries backward', () => {
expect(shiftDate('2026-08-01', -1)).toBe('2026-07-31')
})
it('shifts across year boundaries forward', () => {
expect(shiftDate('2026-12-31', 1)).toBe('2027-01-01')
})
it('shifts across year boundaries backward', () => {
expect(shiftDate('2027-01-01', -1)).toBe('2026-12-31')
})
it('returns same date when shifting by zero', () => {
expect(shiftDate('2026-07-25', 0)).toBe('2026-07-25')
})
it('shifts by multiple days forward', () => {
expect(shiftDate('2026-07-25', 7)).toBe('2026-08-01')
})
it('shifts by multiple days backward', () => {
expect(shiftDate('2026-08-01', -7)).toBe('2026-07-25')
})
})
+82 -6
View File
@@ -4,11 +4,87 @@
* Svelte 5 runes style only (spec §8.2 rule 8).
*/
// Current date as a YYYY-MM-DD string — the server never decides "today" (spec §8.1 rule 8)
export const currentDate = $state({
value: new Date().toISOString().slice(0, 10),
import { api } from './api.js'
import { shiftDate } from './format.js'
/** Return today as a YYYY-MM-DD string — the server never decides "today" (spec §8.1 rule 8). */
function todayString() {
return new Date().toISOString().slice(0, 10)
}
// ── Current date ────────────────────────────────────────────────────────────
export const currentDate = $state({ value: todayString() })
export function setDate(dateStr) {
currentDate.value = dateStr
}
export function goPrevDay() {
currentDate.value = shiftDate(currentDate.value, -1)
}
export function goNextDay() {
currentDate.value = shiftDate(currentDate.value, 1)
}
// ── App view ─────────────────────────────────────────────────────────────────
export const appView = $state({ current: 'dashboard' })
/** Simple navigation requests from child components (e.g., "Set a target"). */
export const navigateTo = (view) => { appView.current = view }
// ── Day data (log + summary) ─────────────────────────────────────────────────
export const dayData = $state({
log: [],
summary: null, // { date, totals: { calories, protein_g, ... }, target: {...} | null }
loading: false,
error: null,
})
// Today's log entries and current target, refreshed from the API after any mutation
export const todayLog = $state({ entries: [], loading: false, error: null })
export const currentTarget = $state({ target: null, loading: false, error: null })
export async function refreshDayData() {
const date = currentDate.value
dayData.loading = true
dayData.error = null
try {
const [log, summary] = await Promise.all([
api.getLog(date),
api.getSummary(date),
])
dayData.log = log
dayData.summary = summary
dayData.loading = false
} catch (e) {
dayData.error = e.message
dayData.loading = false
}
}
/** Re-fetch the summary for the current date without touching the log array. */
async function refreshSummary() {
try {
dayData.summary = await api.getSummary(currentDate.value)
} catch {
// Keep the previous summary on failure; the user can retry.
}
}
/** Add a newly-created log entry to the store then refresh the summary. */
export async function addLogEntryToStore(entry) {
dayData.log = [...dayData.log, entry]
await refreshSummary()
}
/** Update a log entry in the store after a successful API PUT, then refresh the summary. */
export async function updateLogEntryInStore(updated) {
dayData.log = dayData.log.map(e => (e.id === updated.id ? updated : e))
await refreshSummary()
}
/** Remove a log entry from the store after a successful API DELETE, then refresh the summary. */
export async function removeLogEntryFromStore(id) {
dayData.log = dayData.log.filter(e => e.id !== id)
await refreshSummary()
}