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:
+4
-2
@@ -113,14 +113,16 @@ class LogEntryUpdate(BaseModel):
|
|||||||
|
|
||||||
class LogFoodRead(BaseModel):
|
class LogFoodRead(BaseModel):
|
||||||
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
|
"""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
|
Includes name, brand, unit_type, calories_per_unit, and serving info so the
|
||||||
log entries without N+1 lookups. Soft-deleted foods render here (§2.1)."""
|
frontend can render log entries without N+1 lookups. Soft-deleted foods
|
||||||
|
render here (§2.1)."""
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
brand: str | None
|
brand: str | None
|
||||||
unit_type: UnitType
|
unit_type: UnitType
|
||||||
|
calories_per_unit: float | None
|
||||||
serving_size_g: float | None
|
serving_size_g: float | None
|
||||||
serving_name: str | None
|
serving_name: str | None
|
||||||
is_meal: bool
|
is_meal: bool
|
||||||
|
|||||||
+274
-5
@@ -1,10 +1,17 @@
|
|||||||
<script>
|
<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 { onMount } from 'svelte'
|
||||||
import { api } from './lib/api.js'
|
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 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 })
|
let backend = $state({ loading: true, ok: false, error: null })
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -15,17 +22,149 @@
|
|||||||
backend = { loading: false, ok: false, error: e.message }
|
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>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>CalCount</h1>
|
<h1>CalCount</h1>
|
||||||
|
|
||||||
{#if backend.loading}
|
{#if backend.loading}
|
||||||
<p>Connecting to backend…</p>
|
<p class="status">Connecting to backend…</p>
|
||||||
{:else if backend.error}
|
{:else if backend.error}
|
||||||
<p role="alert">Backend unreachable: {backend.error}</p>
|
<p class="status err" role="alert">Backend unreachable: {backend.error}</p>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -35,5 +174,135 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-family: system-ui, sans-serif;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,34 +1,111 @@
|
|||||||
<script>
|
<script>
|
||||||
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6)
|
// Dashboard — Daily view: progress bar + log entries grouped by meal_slot (spec §4.6).
|
||||||
import { api } from '../lib/api.js'
|
// 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 { formatDate } from '../lib/format.js'
|
||||||
|
import ProgressBar from './ProgressBar.svelte'
|
||||||
|
import LogEntry from './LogEntry.svelte'
|
||||||
|
|
||||||
let { date } = $props()
|
let { date } = $props()
|
||||||
|
|
||||||
let entries = $state(null) // null = loading
|
// Re-fetch whenever date changes
|
||||||
let error = $state(null)
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
entries = null
|
// reading date triggers re-run
|
||||||
error = null
|
void date
|
||||||
api.getLog(date)
|
refreshDayData()
|
||||||
.then((data) => (entries = data))
|
})
|
||||||
.catch((e) => (error = e.message))
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
<h2>{formatDate(date)}</h2>
|
<section class="dashboard">
|
||||||
|
<h2>{formatDate(date)}</h2>
|
||||||
|
|
||||||
{#if error}
|
{#if dayData.loading}
|
||||||
<p role="alert">Failed to load log: {error}</p>
|
<p class="status">Loading…</p>
|
||||||
{:else if entries === null}
|
{:else if dayData.error}
|
||||||
<p>Loading…</p>
|
<p class="status err" role="alert">Failed to load: {dayData.error}</p>
|
||||||
{:else if entries.length === 0}
|
{:else}
|
||||||
<p>Nothing logged yet today.</p>
|
<ProgressBar
|
||||||
{:else}
|
totals={dayData.summary?.totals}
|
||||||
<ul>
|
target={dayData.summary?.target}
|
||||||
{#each entries as entry (entry.id)}
|
/>
|
||||||
<li>Food #{entry.food_id} × {entry.quantity}</li>
|
|
||||||
{/each}
|
{#if dayData.log.length === 0}
|
||||||
</ul>
|
<p class="status empty">Nothing logged yet. Tap "Add Food" to get started.</p>
|
||||||
{/if}
|
{: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>
|
||||||
|
|||||||
@@ -1,5 +1,233 @@
|
|||||||
<script>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,282 @@
|
|||||||
<script>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,202 @@
|
|||||||
<script>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,90 @@
|
|||||||
<script>
|
<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>
|
</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
@@ -11,8 +11,16 @@ async function request(path, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
if (!resp.ok) {
|
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()
|
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)
|
// 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}`),
|
getLog: (date) => request(`/api/log?date=${date}`),
|
||||||
addLogEntry: (entry) => request('/api/log', { method: 'POST', body: JSON.stringify(entry) }),
|
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' }),
|
deleteLogEntry: (id) => request(`/api/log/${id}`, { method: 'DELETE' }),
|
||||||
getSummary: (date) => request(`/api/log/summary?date=${date}`),
|
getSummary: (date) => request(`/api/log/summary?date=${date}`),
|
||||||
// Targets (spec §3.4)
|
// Targets (spec §3.4)
|
||||||
getCurrentTarget: () => request('/api/targets/current'),
|
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
|
// OFF proxy (spec §3.5) — the frontend never calls OFF directly
|
||||||
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
offProduct: (barcode) => request(`/api/off/product/${barcode}`),
|
||||||
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
offSearch: (q) => request(`/api/off/search?q=${encodeURIComponent(q)}`),
|
||||||
|
|||||||
@@ -22,3 +22,71 @@ export function formatDate(yyyyMmDd) {
|
|||||||
day: 'numeric',
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Vitest only for stores/format logic, no component tests in v1).
|
* Vitest only for stores/format logic, no component tests in v1).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { formatKcal, formatGrams, formatDate } from './format.js'
|
import { formatKcal, formatGrams, formatDate, shiftDate } from './format.js'
|
||||||
|
|
||||||
describe('formatKcal', () => {
|
describe('formatKcal', () => {
|
||||||
it('rounds to whole numbers', () => {
|
it('rounds to whole numbers', () => {
|
||||||
@@ -26,3 +26,41 @@ describe('formatDate', () => {
|
|||||||
expect(formatDate('2026-07-25')).toMatch(/Jul 25/)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,11 +4,87 @@
|
|||||||
* Svelte 5 runes style only (spec §8.2 rule 8).
|
* 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)
|
import { api } from './api.js'
|
||||||
export const currentDate = $state({
|
import { shiftDate } from './format.js'
|
||||||
value: new Date().toISOString().slice(0, 10),
|
|
||||||
|
/** 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 async function refreshDayData() {
|
||||||
export const todayLog = $state({ entries: [], loading: false, error: null })
|
const date = currentDate.value
|
||||||
export const currentTarget = $state({ target: null, loading: false, error: null })
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user