mitai-jinkendo/frontend/src/pages/CaliperScreen.jsx

222 lines
9.9 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
import { Pencil, Trash2, Check, X, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
function emptyForm() {
return {
date: dayjs().format('YYYY-MM-DD'), sf_method:'jackson3', notes:'',
sf_chest:'', sf_axilla:'', sf_triceps:'', sf_subscap:'',
sf_suprailiac:'', sf_abdomen:'', sf_thigh:'',
sf_calf_med:'', sf_lowerback:'', sf_biceps:''
}
}
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const sex = profile?.sex||'m'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const weight = form.weight || 80
const sfPoints = METHOD_POINTS[form.sf_method]?.[sex] || []
const sfVals = {}
sfPoints.forEach(k=>{ const v=form[`sf_${k}`]; if(v!==''&&v!=null) sfVals[k]=parseFloat(v) })
const bfPct = Object.keys(sfVals).length===sfPoints.length&&sfPoints.length>0
? Math.round(calcBodyFat(form.sf_method, sfVals, sex, age)*10)/10 : null
const bfCat = bfPct ? getBfCategory(bfPct, sex) : null
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Methode</label>
<select className="form-select" value={form.sf_method} onChange={e=>set('sf_method',e.target.value)}>
{Object.entries(CALIPER_METHODS).map(([k,m])=><option key={k} value={k}>{m.label}</option>)}
</select>
</div>
<div style={{padding:'7px 10px',background:'var(--warn-bg)',borderRadius:8,marginBottom:10,fontSize:12,color:'var(--warn-text)'}}>
Rechte Körperseite · Falte 1 cm abheben · Caliper 2 Sek. warten · 3× messen, Mittelwert
</div>
{sfPoints.map(k=>{
const p = CALIPER_POINTS[k]
return p ? (
<div key={k} className="form-row">
<label className="form-label" title={p.where}>{p.label}</label>
<input type="number" className="form-input" min={2} max={80} step={0.5}
placeholder="–" value={form[`sf_${k}`]||''} onChange={e=>set(`sf_${k}`,e.target.value)}/>
<span className="form-unit">mm</span>
</div>
) : null
})}
{bfPct!==null && (
<div style={{margin:'10px 0',padding:'12px',background:'var(--accent-light)',borderRadius:8,textAlign:'center'}}>
<div style={{fontSize:28,fontWeight:700,color:bfCat?.color||'var(--accent)'}}>{bfPct}%</div>
<div style={{fontSize:12,color:'var(--accent-dark)'}}>{bfCat?.label} · {CALIPER_METHODS[form.sf_method]?.label}</div>
</div>
)}
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}>
<div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>onSave(bfPct, sex)}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
)
}
export default function CaliperScreen() {
const [entries, setEntries] = useState([])
const [profile, setProfile] = useState(null)
const [form, setForm] = useState(emptyForm())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [caliperUsage, setCaliperUsage] = useState(null) // Phase 4: Usage badge
const nav = useNavigate()
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
.then(([e,p])=>{ setEntries(e); setProfile(p) })
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const caliperFeature = features.find(f => f.feature_id === 'caliper_entries')
setCaliperUsage(caliperFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const buildPayload = (f, bfPct, sex) => {
const weight = profile?.weight || null
const payload = { date: f.date, sf_method: f.sf_method, notes: f.notes }
Object.entries(f).forEach(([k,v])=>{ if(k.startsWith('sf_')&&v!==''&&v!=null) payload[k]=parseFloat(v) })
if(bfPct!=null) {
payload.body_fat_pct = bfPct
// get latest weight from profile or skip lean/fat
}
return payload
}
const handleSave = async (bfPct, sex) => {
setSaving(true)
setError(null)
try {
const payload = buildPayload(form, bfPct, sex)
await api.upsertCaliper(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false),2000)
setForm(emptyForm())
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async (bfPct, sex) => {
const payload = buildPayload(editing, bfPct, sex)
await api.updateCaliper(editing.id, payload)
setEditing(null); await load()
}
const handleDelete = async (id) => {
if(!confirm('Eintrag löschen?')) return
await api.deleteCaliper(id); await load()
}
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<h1 className="page-title" style={{margin:0}}>Caliper</h1>
<button className="btn btn-secondary" style={{fontSize:12,padding:'6px 10px'}} onClick={()=>nav('/guide')}>
<BookOpen size={13}/> Anleitung
</button>
</div>
<div className="card section-gap">
<div className="card-title badge-container-right">
<span>Neue Messung</span>
{caliperUsage && <UsageBadge {...caliperUsage} />}
</div>
<CaliperForm form={form} setForm={setForm} profile={profile}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={caliperUsage}/>
</div>
<div className="section-gap">
<div className="card-title" style={{marginBottom:8}}>Verlauf ({entries.length})</div>
{entries.length===0 && <p className="muted">Noch keine Caliper-Messungen.</p>}
{entries.map((e,i)=>{
const prev = entries[i+1]
const bfCat = e.body_fat_pct ? getBfCategory(e.body_fat_pct, profile?.sex||'m') : null
const isEd = editing?.id===e.id
return (
<div key={e.id} className="card" style={{marginBottom:8}}>
{isEd ? (
<CaliperForm form={editing} setForm={setEditing} profile={profile}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Änderungen speichern"/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
<div style={{fontWeight:600,fontSize:14}}>{dayjs(e.date).format('DD. MMMM YYYY')}</div>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>setEditing({...e})}><Pencil size={13}/></button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}><Trash2 size={13}/></button>
</div>
</div>
{e.body_fat_pct && (
<div style={{display:'flex',gap:10,marginBottom:6}}>
<span style={{fontSize:22,fontWeight:700,color:bfCat?.color}}>{e.body_fat_pct}%</span>
{bfCat && <span style={{fontSize:12,alignSelf:'center',background:bfCat.color+'22',color:bfCat.color,padding:'2px 8px',borderRadius:6}}>{bfCat.label}</span>}
{prev?.body_fat_pct && <span style={{fontSize:12,alignSelf:'center',color:e.body_fat_pct<prev.body_fat_pct?'var(--accent)':'var(--warn)'}}>
{e.body_fat_pct<prev.body_fat_pct?'▼':'▲'} {Math.abs(Math.round((e.body_fat_pct-prev.body_fat_pct)*10)/10)}%
</span>}
</div>
)}
<div style={{fontSize:11,color:'var(--text3)'}}>{e.sf_method} · {CALIPER_METHODS[e.sf_method]?.label}</div>
{e.notes && <p style={{fontSize:12,color:'var(--text2)',fontStyle:'italic',marginTop:4}}>"{e.notes}"</p>}
</div>
)}
</div>
)
})}
</div>
</div>
)
}