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}}
|
||||
|
||||
|
||||
@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."""
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────────────────────
|
||||
function DataTab({ entries, onUpdate }) {
|
||||
const [editId, setEditId] = useState(null)
|
||||
|
|
@ -619,7 +792,10 @@ export default function NutritionPage() {
|
|||
</div>
|
||||
|
||||
{tab==='data' && (
|
||||
<DataTab entries={entries} onUpdate={load}/>
|
||||
<>
|
||||
<EntryForm onSaved={load}/>
|
||||
<DataTab entries={entries} onUpdate={load}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab==='overview' && (
|
||||
|
|
|
|||
|
|
@ -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'}),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user