TICKET-005: Frontend daily view — milestone M1 complete

- Dashboard: progress bar vs target, entries grouped by meal slot,
  edit/delete inline, date navigation (UTC-safe shiftDate helper)
- Add Food manual creation form, search-and-log flow with live preview
- Minimal target form; loading/error/empty states throughout
- Stores (current date, log, summary) with summary refresh on mutation
- All HTTP via lib/api.js; formatting via lib/format.js; runes only
- Full suites green (backend 104 passed, frontend vitest + build)
This commit is contained in:
Craig
2026-07-26 13:55:39 +01:00
parent 5372e8cbca
commit b69661c997
11 changed files with 1375 additions and 47 deletions
+230 -2
View File
@@ -1,5 +1,233 @@
<script>
// FoodEditor — Food create/edit form, shared by scan/search/library flows (spec §4.5, §4.7)
// FoodEditor — Manual food creation form (spec §4.5).
// Shared by scan/search/library flows; here used for manual creation.
import { api } from '../lib/api.js'
import { appView } from '../lib/stores.svelte.js'
let name = $state('')
let brand = $state('')
let unitType = $state('weight') // 'weight' | 'count'
let caloriesPerUnit = $state('')
let proteinPerUnit = $state('')
let carbsPerUnit = $state('')
let fatPerUnit = $state('')
let servingSizeG = $state('')
let servingName = $state('')
let saving = $state(false)
let error = $state(null)
function reset() {
name = ''
brand = ''
unitType = 'weight'
caloriesPerUnit = ''
proteinPerUnit = ''
carbsPerUnit = ''
fatPerUnit = ''
servingSizeG = ''
servingName = ''
error = null
}
async function handleSubmit(e) {
e.preventDefault()
const cal = parseFloat(caloriesPerUnit)
if (!name.trim()) { error = 'Name is required'; return }
if (isNaN(cal) || cal <= 0) { error = 'Calories per unit is required'; return }
saving = true
error = null
try {
const food = await api.createFood({
name: name.trim(),
brand: brand.trim() || null,
unit_type: unitType,
calories_per_unit: cal,
protein_per_unit: proteinPerUnit ? parseFloat(proteinPerUnit) : null,
carbs_per_unit: carbsPerUnit ? parseFloat(carbsPerUnit) : null,
fat_per_unit: fatPerUnit ? parseFloat(fatPerUnit) : null,
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
serving_name: servingName.trim() || null,
source: 'manual',
is_meal: false,
})
reset()
// Go to search so user can log the new food immediately
appView.current = 'addFood'
} catch (e) {
error = e.message
} finally {
saving = false
}
}
</script>
<p>FoodEditor (placeholder)</p>
<div class="food-editor">
<button type="button" class="back-btn" onclick={() => { reset(); appView.current = 'addFood' }}> Back</button>
<h3>Create food</h3>
<form onsubmit={handleSubmit}>
<label>
Name <span class="required">*</span>
<input type="text" bind:value={name} placeholder="e.g. Oatmeal" required />
</label>
<label>
Brand
<input type="text" bind:value={brand} placeholder="Optional" />
</label>
<fieldset>
<legend>Unit type</legend>
<label class="radio-label">
<input type="radio" name="unitType" value="weight" bind:group={unitType} />
Weight (nutrition per 100g)
</label>
<label class="radio-label">
<input type="radio" name="unitType" value="count" bind:group={unitType} />
Count (nutrition per item)
</label>
</fieldset>
<label>
Calories per {unitType === 'weight' ? '100g' : 'item'} <span class="required">*</span>
<input type="number" step="any" min="0.01" bind:value={caloriesPerUnit} placeholder="e.g. 350" required />
</label>
<fieldset>
<legend>Macros (optional, per {unitType === 'weight' ? '100g' : 'item'})</legend>
<div class="macro-grid">
<label>
Protein
<input type="number" step="any" min="0" bind:value={proteinPerUnit} placeholder="g" />
</label>
<label>
Carbs
<input type="number" step="any" min="0" bind:value={carbsPerUnit} placeholder="g" />
</label>
<label>
Fat
<input type="number" step="any" min="0" bind:value={fatPerUnit} placeholder="g" />
</label>
</div>
</fieldset>
{#if unitType === 'weight'}
<fieldset>
<legend>Serving info (optional)</legend>
<label>
Serving size (g)
<input type="number" step="any" min="0.1" bind:value={servingSizeG} placeholder="e.g. 40" />
</label>
<label>
Serving name
<input type="text" bind:value={servingName} placeholder='e.g. "1 scoop (40g)"' />
</label>
</fieldset>
{/if}
{#if error}<p class="err" role="alert">{error}</p>{/if}
<div class="form-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save food'}
</button>
<button type="button" class="secondary" onclick={() => { reset(); appView.current = 'addFood' }} disabled={saving}>
Cancel
</button>
</div>
</form>
</div>
<style>
.food-editor { }
.back-btn {
background: none;
border: none;
color: var(--link, #2563eb);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
padding: 0;
margin-bottom: 0.75rem;
}
h3 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.9rem;
color: var(--text, #111827);
}
.required { color: #dc2626; }
input[type="text"],
input[type="number"] {
padding: 0.4rem 0.6rem;
font: inherit;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
}
fieldset {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.35rem;
padding: 0.6rem 0.8rem;
}
legend {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted, #6b7280);
padding: 0 0.3rem;
}
.radio-label {
flex-direction: row;
align-items: center;
gap: 0.4rem;
margin-top: 0.3rem;
}
.macro-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.form-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.35rem;
background: var(--bg, #fff);
cursor: pointer;
font: inherit;
font-size: 0.9rem;
}
button:disabled { opacity: 0.5; cursor: default; }
button.secondary { background: var(--bg-muted, #f3f4f6); }
.err { color: #dc2626; font-size: 0.85rem; }
</style>