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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user