174 lines
8.1 KiB
JavaScript
174 lines
8.1 KiB
JavaScript
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 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' }) {
|
||
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>
|
||
<div style={{display:'flex',gap:6,marginTop:8}}>
|
||
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button>
|
||
{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 [saved, setSaved] = useState(false)
|
||
const nav = useNavigate()
|
||
|
||
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
|
||
.then(([e,p])=>{ setEntries(e); setProfile(p) })
|
||
useEffect(()=>{ load() },[])
|
||
|
||
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) => {
|
||
const payload = buildPayload(form, bfPct, sex)
|
||
await api.upsertCaliper(payload)
|
||
setSaved(true); await load()
|
||
setTimeout(()=>setSaved(false),2000)
|
||
setForm(emptyForm())
|
||
}
|
||
|
||
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">Neue Messung</div>
|
||
<CaliperForm form={form} setForm={setForm} profile={profile}
|
||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
||
</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>
|
||
)
|
||
}
|