- Introduced `c_arm_relaxed` to the CircumferenceEntry model for tracking relaxed arm measurements. - Updated database schema to include `c_arm_relaxed` in the circumference_log table. - Implemented calculation for 28-day relaxed arm circumference change with `calculate_arm_relaxed_28d_delta`. - Enhanced placeholder resolver and registration to support new relaxed arm measurement. - Updated frontend components to accommodate the new measurement, including forms and CSV exports. - Improved documentation and guide data to reflect the addition of relaxed arm measurements.
419 lines
19 KiB
JavaScript
419 lines
19 KiB
JavaScript
import { useState } from 'react'
|
||
import { ChevronRight, ChevronLeft, 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 { CIRCUMFERENCE_POINTS, CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
|
||
import dayjs from 'dayjs'
|
||
|
||
// ── Circumference Wizard ──────────────────────────────────────────────────────
|
||
function CircumWizard({ onDone, onCancel }) {
|
||
const [step, setStep] = useState(0)
|
||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
|
||
const [values, setValues] = useState({})
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const points = CIRCUMFERENCE_POINTS
|
||
const current = points[step]
|
||
const totalSteps = points.length
|
||
|
||
const handleNext = () => {
|
||
if (step < totalSteps - 1) setStep(s => s + 1)
|
||
else handleSave()
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const payload = { date }
|
||
Object.entries(values).forEach(([k,v]) => { if(v) payload[k] = parseFloat(v) })
|
||
await api.upsertCirc(payload)
|
||
onDone()
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
const progress = ((step + 1) / totalSteps) * 100
|
||
|
||
return (
|
||
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
|
||
{/* Header */}
|
||
<div style={{marginBottom:20}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
||
<span style={{fontSize:12,color:'var(--text3)'}}>Schritt {step+1} von {totalSteps}</span>
|
||
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
|
||
<X size={18}/>
|
||
</button>
|
||
</div>
|
||
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
|
||
<div style={{height:'100%',background:'var(--accent)',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Datum (nur erster Schritt) */}
|
||
{step === 0 && (
|
||
<div className="card" style={{marginBottom:16}}>
|
||
<div className="form-row">
|
||
<label className="form-label">Datum</label>
|
||
<input type="date" className="form-input" style={{width:140}}
|
||
value={date} onChange={e=>setDate(e.target.value)}/>
|
||
<span className="form-unit"/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Messpunkt */}
|
||
<div className="card" style={{flex:1,marginBottom:16}}>
|
||
{/* Punkt-Indikator */}
|
||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
|
||
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
|
||
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
|
||
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
|
||
</div>
|
||
<div>
|
||
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
|
||
<div style={{fontSize:11,color:'var(--text3)'}}>Umfang messen</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Anleitung */}
|
||
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
|
||
{[
|
||
['📍 Wo', current.where],
|
||
['🧍 Haltung', current.posture],
|
||
['📏 Maßband', current.how],
|
||
['💡 Tipp', current.tip],
|
||
].map(([label, text]) => (
|
||
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
|
||
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Eingabe */}
|
||
<div style={{display:'flex',gap:10,alignItems:'center'}}>
|
||
<input
|
||
type="number" min={10} max={200} step={0.1}
|
||
className="form-input"
|
||
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
|
||
placeholder="–"
|
||
value={values[current.id] || ''}
|
||
onChange={e => setValues(v => ({...v, [current.id]: e.target.value}))}
|
||
onKeyDown={e => e.key==='Enter' && handleNext()}
|
||
autoFocus
|
||
/>
|
||
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>cm</span>
|
||
</div>
|
||
{values[current.id] && (
|
||
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
|
||
✓ {values[current.id]} cm erfasst
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation */}
|
||
<div style={{display:'flex',gap:8}}>
|
||
<button className="btn btn-secondary" style={{flex:1}} onClick={()=>setStep(s=>s-1)} disabled={step===0}>
|
||
<ChevronLeft size={16}/> Zurück
|
||
</button>
|
||
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
|
||
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
|
||
step === totalSteps-1
|
||
? <><Check size={16}/> Speichern</>
|
||
: <>Weiter <ChevronRight size={16}/></>}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Übersicht bereits erfasster Werte */}
|
||
{Object.keys(values).length > 0 && (
|
||
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
|
||
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
|
||
{points.slice(0, step+1).map(p => values[p.id] ? (
|
||
<span key={p.id} onClick={()=>setStep(points.indexOf(p))}
|
||
style={{fontSize:12,background:'var(--accent-light)',color:'var(--accent-dark)',
|
||
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
|
||
{p.label}: {values[p.id]}
|
||
</span>
|
||
) : null)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Caliper Wizard ────────────────────────────────────────────────────────────
|
||
function CaliperWizard({ onDone, onCancel, profile }) {
|
||
const [method, setMethod] = useState('jackson3')
|
||
const [step, setStep] = useState(-1) // -1 = method select
|
||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
|
||
const [values, setValues] = useState({})
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const sex = profile?.sex || 'm'
|
||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
||
const sfPoints = METHOD_POINTS[method]?.[sex] || []
|
||
const current = step >= 0 ? CALIPER_POINTS[sfPoints[step]] : null
|
||
const totalSteps = sfPoints.length
|
||
|
||
// Live BF calculation
|
||
const sfVals = {}
|
||
sfPoints.forEach(k => { const v=values[`sf_${k}`]; if(v) sfVals[k]=parseFloat(v) })
|
||
const bfPct = Object.keys(sfVals).length === sfPoints.length && sfPoints.length > 0
|
||
? Math.round(calcBodyFat(method, sfVals, sex, age)*10)/10 : null
|
||
const bfCat = bfPct ? getBfCategory(bfPct, sex) : null
|
||
|
||
const handleNext = () => {
|
||
if (step < totalSteps - 1) setStep(s => s+1)
|
||
else handleSave()
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const payload = { date, sf_method: method }
|
||
Object.entries(values).forEach(([k,v]) => { if(v) payload[k]=parseFloat(v) })
|
||
if (bfPct) {
|
||
payload.body_fat_pct = bfPct
|
||
if (profile?.weight || values.weight) {
|
||
const w = parseFloat(profile?.weight || values.weight)
|
||
payload.lean_mass = Math.round(w*(1-bfPct/100)*10)/10
|
||
payload.fat_mass = Math.round(w*(bfPct/100)*10)/10
|
||
}
|
||
}
|
||
await api.upsertCaliper(payload)
|
||
onDone()
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
const progress = step >= 0 ? ((step+1)/totalSteps)*100 : 0
|
||
|
||
// Method selection screen
|
||
if (step === -1) {
|
||
return (
|
||
<div>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:20}}>
|
||
<h2 style={{fontSize:18,fontWeight:700,margin:0}}>Caliper-Methode</h2>
|
||
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
|
||
<X size={18}/>
|
||
</button>
|
||
</div>
|
||
<div className="form-row" style={{marginBottom:16}}>
|
||
<label className="form-label">Datum</label>
|
||
<input type="date" className="form-input" style={{width:140}}
|
||
value={date} onChange={e=>setDate(e.target.value)}/>
|
||
<span className="form-unit"/>
|
||
</div>
|
||
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
|
||
{Object.entries(CALIPER_METHODS).map(([k,m]) => (
|
||
<button key={k} onClick={()=>setMethod(k)}
|
||
style={{padding:'14px 16px',borderRadius:12,border:`2px solid ${method===k?'var(--accent)':'var(--border2)'}`,
|
||
background:method===k?'var(--accent-light)':'var(--surface)',cursor:'pointer',
|
||
fontFamily:'var(--font)',textAlign:'left',transition:'all 0.15s'}}>
|
||
<div style={{fontSize:15,fontWeight:600,color:method===k?'var(--accent-dark)':'var(--text1)'}}>{m.label}</div>
|
||
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{m.points_m.length} Messpunkte</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{padding:'10px 12px',background:'var(--warn-bg)',borderRadius:8,marginBottom:16,fontSize:12,color:'var(--warn-text)'}}>
|
||
Immer rechte Körperseite · Falte 1 cm abheben · Caliper 2 Sek. · 3× messen, Mittelwert
|
||
</div>
|
||
<button className="btn btn-primary btn-full" onClick={()=>setStep(0)}>
|
||
Weiter mit {CALIPER_METHODS[method]?.label} <ChevronRight size={16}/>
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{display:'flex',flexDirection:'column',minHeight:'calc(100vh - 120px)'}}>
|
||
{/* Header */}
|
||
<div style={{marginBottom:20}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
||
<span style={{fontSize:12,color:'var(--text3)'}}>Punkt {step+1} von {totalSteps} · {CALIPER_METHODS[method]?.label}</span>
|
||
<button onClick={onCancel} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}>
|
||
<X size={18}/>
|
||
</button>
|
||
</div>
|
||
<div style={{height:4,background:'var(--border)',borderRadius:2}}>
|
||
<div style={{height:'100%',background:'#D85A30',borderRadius:2,width:`${progress}%`,transition:'width 0.3s'}}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Live BF Preview */}
|
||
{bfPct && (
|
||
<div style={{padding:'10px 14px',background:'var(--accent-light)',borderRadius:10,marginBottom:14,
|
||
display:'flex',alignItems:'center',gap:10}}>
|
||
<div style={{fontSize:22,fontWeight:700,color:bfCat?.color||'var(--accent)'}}>{bfPct}%</div>
|
||
{bfCat && <div style={{fontSize:12,color:'var(--accent-dark)'}}>{bfCat.label}</div>}
|
||
<div style={{flex:1,fontSize:11,color:'var(--accent-dark)',textAlign:'right'}}>Körperfett (live)</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Messpunkt */}
|
||
{current && (
|
||
<div className="card" style={{flex:1,marginBottom:16}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16}}>
|
||
<div style={{width:40,height:40,borderRadius:'50%',background:current.color,
|
||
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
|
||
<span style={{fontSize:16,fontWeight:700,color:'white'}}>{step+1}</span>
|
||
</div>
|
||
<div>
|
||
<div style={{fontSize:18,fontWeight:700}}>{current.label}</div>
|
||
<div style={{fontSize:11,color:'var(--text3)'}}>Hautfalte messen</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{display:'flex',flexDirection:'column',gap:10,marginBottom:20}}>
|
||
{[
|
||
['📍 Wo', current.where],
|
||
['🧍 Haltung', current.posture],
|
||
['🔧 Technik', current.how],
|
||
['💡 Tipp', current.tip],
|
||
].map(([label, text]) => (
|
||
<div key={label} style={{background:'var(--surface2)',borderRadius:8,padding:'10px 12px'}}>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:3}}>{label}</div>
|
||
<div style={{fontSize:13,color:'var(--text1)',lineHeight:1.55}}>{text}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{display:'flex',gap:10,alignItems:'center'}}>
|
||
<input
|
||
type="number" min={2} max={80} step={0.5}
|
||
className="form-input"
|
||
style={{flex:1,fontSize:24,fontWeight:700,textAlign:'center',height:56}}
|
||
placeholder="–"
|
||
value={values[`sf_${sfPoints[step]}`] || ''}
|
||
onChange={e => setValues(v => ({...v, [`sf_${sfPoints[step]}`]: e.target.value}))}
|
||
onKeyDown={e => e.key==='Enter' && handleNext()}
|
||
autoFocus
|
||
/>
|
||
<span style={{fontSize:18,color:'var(--text3)',fontWeight:500}}>mm</span>
|
||
</div>
|
||
{values[`sf_${sfPoints[step]}`] && (
|
||
<div style={{textAlign:'center',marginTop:8,fontSize:12,color:'var(--accent)'}}>
|
||
✓ {values[`sf_${sfPoints[step]}`]} mm erfasst
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Navigation */}
|
||
<div style={{display:'flex',gap:8}}>
|
||
<button className="btn btn-secondary" style={{flex:1}}
|
||
onClick={()=>setStep(s=>s<=0?-1:s-1)}>
|
||
<ChevronLeft size={16}/> Zurück
|
||
</button>
|
||
<button className="btn btn-primary" style={{flex:2}} onClick={handleNext} disabled={saving}>
|
||
{saving ? <div className="spinner" style={{width:14,height:14}}/> :
|
||
step === totalSteps-1
|
||
? <><Check size={16}/> Speichern</>
|
||
: <>Weiter <ChevronRight size={16}/></>}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Übersicht */}
|
||
{Object.keys(values).length > 0 && (
|
||
<div style={{marginTop:14,padding:'10px 12px',background:'var(--surface2)',borderRadius:8}}>
|
||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',marginBottom:6}}>Bisher erfasst:</div>
|
||
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
|
||
{sfPoints.slice(0,step+1).map((k,idx) => values[`sf_${k}`] ? (
|
||
<span key={k} onClick={()=>setStep(idx)}
|
||
style={{fontSize:12,background:'#D85A3022',color:'#D85A30',
|
||
padding:'2px 8px',borderRadius:6,cursor:'pointer'}}>
|
||
{CALIPER_POINTS[k]?.label}: {values[`sf_${k}`]}mm
|
||
</span>
|
||
) : null)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main Wizard Page ──────────────────────────────────────────────────────────
|
||
export default function MeasureWizard() {
|
||
const [mode, setMode] = useState(null) // null | 'circum' | 'caliper'
|
||
const [done, setDone] = useState(false)
|
||
const [profile, setProfile] = useState(null)
|
||
const nav = useNavigate()
|
||
|
||
useState(() => { api.getProfile().then(setProfile) })
|
||
|
||
if (done) {
|
||
return (
|
||
<div className="capture-page" style={{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',
|
||
minHeight:'60vh',gap:16,textAlign:'center'}}>
|
||
<div style={{fontSize:48}}>✅</div>
|
||
<h2 style={{fontSize:20,fontWeight:700}}>Gespeichert!</h2>
|
||
<p style={{color:'var(--text2)',fontSize:14}}>Deine Messung wurde erfolgreich gespeichert.</p>
|
||
<div style={{display:'flex',gap:8}}>
|
||
<button className="btn btn-primary" onClick={()=>{ setDone(false); setMode(null) }}>
|
||
Weitere Messung
|
||
</button>
|
||
<button className="btn btn-secondary" onClick={()=>nav('/')}>
|
||
Zum Dashboard
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (mode === 'circum') return (
|
||
<div className="capture-page">
|
||
<CircumWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)}/>
|
||
</div>
|
||
)
|
||
if (mode === 'caliper') return (
|
||
<div className="capture-page">
|
||
<CaliperWizard onDone={()=>setDone(true)} onCancel={()=>setMode(null)} profile={profile}/>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="capture-page">
|
||
<h1 className="page-title">Assistent</h1>
|
||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}>
|
||
Der Assistent führt dich Schritt für Schritt durch die Messung – mit Anleitung für jeden Messpunkt.
|
||
</p>
|
||
<div style={{display:'flex',flexDirection:'column',gap:12}}>
|
||
<button onClick={()=>setMode('circum')}
|
||
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
|
||
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
|
||
display:'flex',alignItems:'center',gap:14}}>
|
||
<div style={{fontSize:32}}>📏</div>
|
||
<div>
|
||
<div style={{fontSize:16,fontWeight:600}}>Umfänge messen</div>
|
||
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
||
9 Messpunkte · inkl. Oberarm kontrahiert + Oberarm entspannt
|
||
</div>
|
||
</div>
|
||
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
|
||
</button>
|
||
|
||
<button onClick={()=>setMode('caliper')}
|
||
style={{padding:'20px 16px',borderRadius:14,border:'1.5px solid var(--border2)',
|
||
background:'var(--surface)',cursor:'pointer',fontFamily:'var(--font)',textAlign:'left',
|
||
display:'flex',alignItems:'center',gap:14}}>
|
||
<div style={{fontSize:32}}>📐</div>
|
||
<div>
|
||
<div style={{fontSize:16,fontWeight:600}}>Caliper Körperfett</div>
|
||
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
||
3–9 Messpunkte · Jackson/Pollock, Durnin oder Parrillo
|
||
</div>
|
||
</div>
|
||
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>
|
||
</button>
|
||
|
||
<div style={{marginTop:8,padding:'12px 14px',background:'var(--surface2)',borderRadius:10,
|
||
fontSize:12,color:'var(--text3)',lineHeight:1.6}}>
|
||
💡 Für schnelle Einzeleingaben oder Bearbeitung bestehender Werte nutze die direkten Screens
|
||
unter <strong>Gewicht</strong>, <strong>Umfänge</strong> und <strong>Caliper</strong>.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|