feat: add manual nutrition entry form with auto-detect
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:
parent
873f08042e
commit
02ca9772d6
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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' && (
|
||||||
|
|
|
||||||
|
|
@ -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'}),
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user