From 02ca9772d610d1094ee32471fce89719a91f2361 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 08:37:01 +0100 Subject: [PATCH] feat: add manual nutrition entry form with auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/nutrition.py | 66 ++++++++++ frontend/src/pages/NutritionPage.jsx | 178 ++++++++++++++++++++++++++- frontend/src/utils/api.js | 2 + 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 6738331..65d6777 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -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}} +@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("") 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.""" @@ -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()] +@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") 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.""" diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 4a75ee9..fa0ef30 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -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 ( +
+
Eintrag hinzufügen / bearbeiten
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ ✓ {success} +
+ )} + +
+
+ + setDate(e.target.value)} + max={dayjs().format('YYYY-MM-DD')} + style={{width:'100%'}} + /> + {existingId && !loading && ( +
+ ℹ️ Eintrag existiert bereits – wird beim Speichern aktualisiert +
+ )} +
+ +
+ + setValues({...values, kcal: e.target.value})} + placeholder="z.B. 2000" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, protein_g: e.target.value})} + placeholder="z.B. 150" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, fat_g: e.target.value})} + placeholder="z.B. 80" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, carbs_g: e.target.value})} + placeholder="z.B. 200" + disabled={loading} + style={{width:'100%'}} + /> +
+
+ + +
+ ) +} + // ── Data Tab (Editable Entry List) ─────────────────────────────────────────── function DataTab({ entries, onUpdate }) { const [editId, setEditId] = useState(null) @@ -619,7 +792,10 @@ export default function NutritionPage() { {tab==='data' && ( - + <> + + + )} {tab==='overview' && ( diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e8cbfde..bc4701e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -83,6 +83,8 @@ export const api = { nutritionCorrelations: () => req('/nutrition/correlations'), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), 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'}), deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),