From b69661c99790761c1c568cbfffed0796941c033f Mon Sep 17 00:00:00 2001 From: Craig Date: Sun, 26 Jul 2026 13:55:39 +0100 Subject: [PATCH] =?UTF-8?q?TICKET-005:=20Frontend=20daily=20view=20?= =?UTF-8?q?=E2=80=94=20milestone=20M1=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/schemas.py | 6 +- frontend/src/App.svelte | 279 +++++++++++++++++++- frontend/src/components/Dashboard.svelte | 125 +++++++-- frontend/src/components/FoodEditor.svelte | 232 ++++++++++++++++- frontend/src/components/FoodSearch.svelte | 281 ++++++++++++++++++++- frontend/src/components/LogEntry.svelte | 201 ++++++++++++++- frontend/src/components/ProgressBar.svelte | 89 ++++++- frontend/src/lib/api.js | 13 +- frontend/src/lib/format.js | 68 +++++ frontend/src/lib/format.test.js | 40 ++- frontend/src/lib/stores.svelte.js | 88 ++++++- 11 files changed, 1375 insertions(+), 47 deletions(-) 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}

+ {: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'} +
+ +

Set daily target

+ + {#if targetSuccess} +

Target saved!

+ {:else} +
+ + + + +
+ Macros (optional, grams) +
+ + + +
+
+ + {#if targetError}{/if} + +
+ + +
+
+ {/if} +
+ {/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} - -{/if} + {#if dayData.loading} +

Loading…

+ {:else if 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')} +
+

{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

+ +
+ + + + +
+ Unit type + + +
+ + + +
+ Macros (optional, per {unitType === 'weight' ? '100g' : 'item'}) +
+ + + +
+
+ + {#if unitType === 'weight'} +
+ Serving info (optional) + + +
+ {/if} + + {#if error}{/if} + +
+ + +
+
+
+ + 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)

+ + + 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} +
    + + {formatKcal(liveKcal)} + +
    + + +
    + {#if error}{/if} +
    + {: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() +}