mitai-jinkendo/frontend/src/pages/MeasureWizard.jsx
Lars df0165bee3
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
feat: add relaxed arm circumference measurement and update related features
- 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.
2026-04-19 10:34:51 +02:00

419 lines
19 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 } 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}}>
39 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>
)
}