mitai-jinkendo/frontend/src/pages/CaliperScreen.jsx
Lars Stommer 89b6c0b072
Some checks are pending
Deploy to Raspberry Pi / deploy (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Build Test / lint-backend (push) Waiting to run
feat: initial commit – Mitai Jinkendo v9a
2026-03-16 13:35:11 +01:00

174 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}