diff --git a/backend/schemas.py b/backend/schemas.py
index f532834..ff0da0a 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -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
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index fd25491..2fabbbd 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,10 +1,17 @@
CalCount
{#if backend.loading}
- Connecting to backend…
+ Connecting to backend…
{:else if backend.error}
- Backend unreachable: {backend.error}
+ Backend unreachable: {backend.error}
{:else}
-
+ {#if appView.current === 'dashboard' || appView.current === 'addFood' || appView.current === 'createFood' || appView.current === 'targetForm'}
+
+
+ {/if}
+
+ {#if appView.current === 'dashboard'}
+
+
+
+
+
+
+
+ {:else if appView.current === 'addFood'}
+
+
+ {:else if appView.current === 'createFood'}
+
+
+ {:else if appView.current === 'targetForm'}
+
+ {/if}
{/if}
@@ -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; }
diff --git a/frontend/src/components/Dashboard.svelte b/frontend/src/components/Dashboard.svelte
index 4755473..690f4a3 100644
--- a/frontend/src/components/Dashboard.svelte
+++ b/frontend/src/components/Dashboard.svelte
@@ -1,34 +1,111 @@
-
{formatDate(date)}
+
+ {formatDate(date)}
-{#if error}
- Failed to load log: {error}
-{:else if entries === null}
- Loading…
-{:else if entries.length === 0}
- Nothing logged yet today.
-{:else}
-
- {#each entries as entry (entry.id)}
- - Food #{entry.food_id} × {entry.quantity}
- {/each}
-
-{/if}
+ {#if dayData.loading}
+ Loading…
+ {:else if dayData.error}
+ Failed to load: {dayData.error}
+ {:else}
+
+
+ {#if dayData.log.length === 0}
+ Nothing logged yet. Tap "Add Food" to get started.
+ {:else}
+ {#each groups as group (group.slot ?? 'other')}
+
+
+
+ {#each group.entries as entry (entry.id)}
+
+ {/each}
+
+
+ {/each}
+ {/if}
+ {/if}
+
+
+
diff --git a/frontend/src/components/FoodEditor.svelte b/frontend/src/components/FoodEditor.svelte
index 1e0011a..fcc1e45 100644
--- a/frontend/src/components/FoodEditor.svelte
+++ b/frontend/src/components/FoodEditor.svelte
@@ -1,5 +1,233 @@
-FoodEditor (placeholder)
+
+
+
+
Create food
+
+
+
+
+
diff --git a/frontend/src/components/FoodSearch.svelte b/frontend/src/components/FoodSearch.svelte
index 9f74b90..3801e18 100644
--- a/frontend/src/components/FoodSearch.svelte
+++ b/frontend/src/components/FoodSearch.svelte
@@ -1,5 +1,282 @@
-FoodSearch (placeholder)
+
+
+
+ {#if !selected}
+
+
Find a food
+
+
+ {#if searching}
+
Searching…
+ {:else if searchError}
+
{searchError}
+ {:else if results !== null}
+ {#if results.length === 0}
+
No foods found.
+ {:else}
+
+ {#each results as food (food.id)}
+ -
+
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+ Or
+
+
+
+ {:else}
+
+
Log "{selected.name}"
+ {#if selected.brand}
{selected.brand}
{/if}
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/LogEntry.svelte b/frontend/src/components/LogEntry.svelte
index dcadb6b..9cefad5 100644
--- a/frontend/src/components/LogEntry.svelte
+++ b/frontend/src/components/LogEntry.svelte
@@ -1,5 +1,202 @@
-LogEntry (placeholder)
+
+ {#if editing}
+
+ {:else if confirmingDelete}
+
+ Delete "{entry.food?.name}"?
+
+
+
+ {:else}
+
+
{entry.food?.name ?? `Food #${entry.food_id}`}
+
{entry.food?.brand}
+
{formatQuantity(entry)}
+
{formatKcal(kcal)}
+ {#if entry.meal_slot}
+
{entry.meal_slot}
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/ProgressBar.svelte b/frontend/src/components/ProgressBar.svelte
index baad3eb..5a5fd91 100644
--- a/frontend/src/components/ProgressBar.svelte
+++ b/frontend/src/components/ProgressBar.svelte
@@ -1,5 +1,90 @@
-ProgressBar (placeholder)
+
+ {#if goal}
+
+
+ {formatKcal(consumed)} consumed
+ {formatKcal(goal)} target
+ {#if remaining !== null}
+
+ {remaining <= 0 ? formatKcal(Math.abs(remaining)) + ' over' : formatKcal(remaining) + ' remaining'}
+
+ {/if}
+
+ {:else}
+
+ {formatKcal(consumed)} consumed today.
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 661be76..c67e76c 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -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)}`),
diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js
index 02d2aac..84991ef 100644
--- a/frontend/src/lib/format.js
+++ b/frontend/src/lib/format.js
@@ -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)
+}
diff --git a/frontend/src/lib/format.test.js b/frontend/src/lib/format.test.js
index 9e6089a..47f842a 100644
--- a/frontend/src/lib/format.test.js
+++ b/frontend/src/lib/format.test.js
@@ -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')
+ })
+})
diff --git a/frontend/src/lib/stores.svelte.js b/frontend/src/lib/stores.svelte.js
index 56da21c..284921d 100644
--- a/frontend/src/lib/stores.svelte.js
+++ b/frontend/src/lib/stores.svelte.js
@@ -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()
+}