feat: add manual nutrition entry form with auto-detect
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Features:
- Manual entry form above data list
- Date picker with auto-load existing entries
- Upsert logic: creates new or updates existing entry
- Smart button text: "Hinzufügen" vs "Aktualisieren"
- Prevents duplicate entries per day
- Feature enforcement for nutrition_entries

Backend:
- POST /nutrition - Create or update entry (upsert)
- GET /nutrition/by-date/{date} - Load entry by date
- Auto-detects existing entry and switches to UPDATE mode
- Increments usage counter only on INSERT

Frontend:
- EntryForm component with date picker + macros inputs
- Auto-loads data when date changes
- Shows info message when entry exists
- Success/error feedback
- Disabled state while loading/saving

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 08:37:01 +01:00
parent 873f08042e
commit 02ca9772d6
3 changed files with 245 additions and 1 deletions

View File

@ -99,6 +99,61 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
@router.post("")
def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
# Validate date format
try:
datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD")
with get_db() as conn:
cur = get_cursor(conn)
# Check if entry exists
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
existing = cur.fetchone()
if existing:
# UPDATE existing entry
cur.execute("""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
return {"success": True, "mode": "updated", "id": existing['id']}
else:
# Phase 4: Check feature access before INSERT
access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# INSERT new entry
new_id = str(uuid.uuid4())
cur.execute("""
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'nutrition_entries')
return {"success": True, "mode": "created", "id": new_id}
@router.get("") @router.get("")
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entries for current profile.""" """Get nutrition entries for current profile."""
@ -110,6 +165,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@router.get("/by-date/{date}")
def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
row = cur.fetchone()
return r2d(row) if row else None
@router.get("/correlations") @router.get("/correlations")
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition data correlated with weight and body fat.""" """Get nutrition data correlated with weight and body fat."""

View File

@ -18,6 +18,179 @@ function rollingAvg(arr, key, window=7) {
}) })
} }
// Entry Form (Create/Update)
function EntryForm({ onSaved }) {
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
const [existingId, setExistingId] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
// Load data for selected date
useEffect(() => {
const load = async () => {
if (!date) return
setLoading(true)
setError(null)
try {
const data = await nutritionApi.getNutritionByDate(date)
if (data) {
setValues({
kcal: data.kcal || '',
protein_g: data.protein_g || '',
fat_g: data.fat_g || '',
carbs_g: data.carbs_g || ''
})
setExistingId(data.id)
} else {
setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
setExistingId(null)
}
} catch(e) {
console.error('Failed to load entry:', e)
} finally {
setLoading(false)
}
}
load()
}, [date])
const handleSave = async () => {
if (!date || !values.kcal) {
setError('Datum und Kalorien sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await nutritionApi.createNutrition(
date,
parseFloat(values.kcal) || 0,
parseFloat(values.protein_g) || 0,
parseFloat(values.fat_g) || 0,
parseFloat(values.carbs_g) || 0
)
setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert')
setTimeout(() => setSuccess(null), 3000)
onSaved()
} catch(e) {
if (e.message.includes('Limit erreicht')) {
setError(e.message)
} else {
setError('Speichern fehlgeschlagen: ' + e.message)
}
setTimeout(() => setError(null), 5000)
} finally {
setSaving(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen / bearbeiten</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{success && (
<div style={{padding:'8px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)',marginBottom:12}}>
{success}
</div>
)}
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12,marginBottom:12}}>
<div style={{gridColumn:'1 / -1'}}>
<label className="form-label">Datum</label>
<input
type="date"
className="form-input"
value={date}
onChange={e => setDate(e.target.value)}
max={dayjs().format('YYYY-MM-DD')}
style={{width:'100%'}}
/>
{existingId && !loading && (
<div style={{fontSize:11,color:'var(--accent)',marginTop:4}}>
Eintrag existiert bereits wird beim Speichern aktualisiert
</div>
)}
</div>
<div>
<label className="form-label">Kalorien *</label>
<input
type="number"
className="form-input"
value={values.kcal}
onChange={e => setValues({...values, kcal: e.target.value})}
placeholder="z.B. 2000"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Protein (g)</label>
<input
type="number"
className="form-input"
value={values.protein_g}
onChange={e => setValues({...values, protein_g: e.target.value})}
placeholder="z.B. 150"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Fett (g)</label>
<input
type="number"
className="form-input"
value={values.fat_g}
onChange={e => setValues({...values, fat_g: e.target.value})}
placeholder="z.B. 80"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Kohlenhydrate (g)</label>
<input
type="number"
className="form-input"
value={values.carbs_g}
onChange={e => setValues({...values, carbs_g: e.target.value})}
placeholder="z.B. 200"
disabled={loading}
style={{width:'100%'}}
/>
</div>
</div>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || loading || !date || !values.kcal}>
{saving ? (
<><div className="spinner" style={{width:14,height:14}}/> Speichere</>
) : existingId ? (
'📝 Eintrag aktualisieren'
) : (
' Eintrag hinzufügen'
)}
</button>
</div>
)
}
// Data Tab (Editable Entry List) // Data Tab (Editable Entry List)
function DataTab({ entries, onUpdate }) { function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null) const [editId, setEditId] = useState(null)
@ -619,7 +792,10 @@ export default function NutritionPage() {
</div> </div>
{tab==='data' && ( {tab==='data' && (
<DataTab entries={entries} onUpdate={load}/> <>
<EntryForm onSaved={load}/>
<DataTab entries={entries} onUpdate={load}/>
</>
)} )}
{tab==='overview' && ( {tab==='overview' && (

View File

@ -83,6 +83,8 @@ export const api = {
nutritionCorrelations: () => req('/nutrition/correlations'), nutritionCorrelations: () => req('/nutrition/correlations'),
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
nutritionImportHistory: () => req('/nutrition/import-history'), nutritionImportHistory: () => req('/nutrition/import-history'),
getNutritionByDate: (date) => req(`/nutrition/by-date/${date}`),
createNutrition: (date,kcal,protein,fat,carbs) => req(`/nutrition?date=${date}&kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'POST'}),
updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}), updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}),
deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}), deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),