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):
|
||||
"""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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<h2>{formatDate(date)}</h2>
|
||||
<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>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each entries as entry (entry.id)}
|
||||
<li>Food #{entry.food_id} × {entry.quantity}</li>
|
||||
{#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}
|
||||
<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>
|
||||
{/if}
|
||||
</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>
|
||||
// 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)}`),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user