feat: relative weight sliders for focus areas
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Improved UX for focus area configuration:
- Sliders now use relative weights (0-10) instead of percentages
- System automatically normalizes to percentages (sum=100%)
- Live preview shows "weight → percent%" (e.g., "5 → 50%")
- No more manual balancing required from user

User sets: Kraft=5, Ausdauer=3, Flexibilität=2
System calculates: 50%, 30%, 20%

Addresses user feedback: "Summe muss 100% sein" not user-friendly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-27 12:10:56 +01:00
parent 1fdf91cb50
commit 92cc309489

View File

@ -286,7 +286,7 @@ export default function GoalsPage() {
)} )}
</div> </div>
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}> <p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
Gewichte deine Trainingsziele individuell. Die Summe muss 100% ergeben. Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile.
{focusAreas && !focusAreas.custom && ( {focusAreas && !focusAreas.custom && (
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}> <span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
@ -305,87 +305,68 @@ export default function GoalsPage() {
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' }, { key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' },
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' }, { key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' },
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' } { key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' }
].map(area => ( ].map(area => {
<div key={area.key}> const weight = Math.round(focusTemp[area.key] / 10)
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> const actualPercent = sum > 0 ? Math.round(focusTemp[area.key] / sum * 100) : 0
<span style={{ fontSize: 20 }}>{area.icon}</span>
<span style={{ fontWeight: 500 }}>{area.label}</span> return (
<div key={area.key}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 20 }}>{area.icon}</span>
<span style={{ fontWeight: 500 }}>{area.label}</span>
</div>
<span style={{
fontSize: 16,
fontWeight: 600,
color: area.color,
minWidth: 80,
textAlign: 'right'
}}>
{weight} {actualPercent}%
</span>
</div> </div>
<span style={{ <input
fontSize: 18, type="range"
fontWeight: 600, min="0"
color: area.color, max="10"
minWidth: 50, step="1"
textAlign: 'right' value={weight}
}}> onChange={e => setFocusTemp(f => ({ ...f, [area.key]: parseInt(e.target.value) * 10 }))}
{focusTemp[area.key]}% style={{
</span> width: '100%',
height: 8,
borderRadius: 4,
background: `linear-gradient(to right, ${area.color} 0%, ${area.color} ${weight * 10}%, var(--border) ${weight * 10}%, var(--border) 100%)`,
outline: 'none',
cursor: 'pointer'
}}
/>
</div> </div>
<input )
type="range" })}
min="0"
max="100"
step="5"
value={focusTemp[area.key]}
onChange={e => setFocusTemp(f => ({ ...f, [area.key]: parseInt(e.target.value) }))}
style={{
width: '100%',
height: 8,
borderRadius: 4,
background: `linear-gradient(to right, ${area.color} 0%, ${area.color} ${focusTemp[area.key]}%, var(--border) ${focusTemp[area.key]}%, var(--border) 100%)`,
outline: 'none',
cursor: 'pointer'
}}
/>
</div>
))}
</div> </div>
{/* Sum Display */} {/* Weight Total Display */}
<div style={{ <div style={{
padding: 12, padding: 12,
background: (() => { background: 'var(--surface2)',
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0) border: '1px solid var(--border)',
if (sum === 100) return 'var(--accent)'
return '#FEF2F2'
})(),
border: (() => {
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
if (sum === 100) return '1px solid var(--accent)'
return '1px solid #FCA5A5'
})(),
borderRadius: 8, borderRadius: 8,
marginBottom: 16 marginBottom: 16
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ <span style={{ fontWeight: 600, color: 'var(--text2)' }}>
fontWeight: 600, Gewichtung gesamt:
color: (() => {
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
if (sum === 100) return 'white'
return '#DC2626'
})()
}}>
Summe:
</span> </span>
<span style={{ <span style={{ fontSize: 18, fontWeight: 600, color: 'var(--text1)' }}>
fontSize: 20, {Object.values(focusTemp).reduce((a, b) => a + b, 0) / 10}
fontWeight: 700,
color: (() => {
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
if (sum === 100) return 'white'
return '#DC2626'
})()
}}>
{Object.values(focusTemp).reduce((a, b) => a + b, 0)}%
</span> </span>
</div> </div>
{Object.values(focusTemp).reduce((a, b) => a + b, 0) !== 100 && ( <div style={{ fontSize: 12, marginTop: 4, color: 'var(--text3)' }}>
<div style={{ fontSize: 12, marginTop: 4, color: '#DC2626' }}> 💡 Die Prozentanteile werden automatisch berechnet
Summe muss 100% ergeben </div>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
@ -394,12 +375,27 @@ export default function GoalsPage() {
className="btn-primary" className="btn-primary"
onClick={async () => { onClick={async () => {
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0) const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
if (sum !== 100) {
setError('Summe muss 100% ergeben') if (sum === 0) {
setError('Mindestens ein Bereich muss gewichtet sein')
return return
} }
// Normalize to percentages
const normalized = {}
Object.keys(focusTemp).forEach(key => {
normalized[key] = Math.round(focusTemp[key] / sum * 100)
})
// Ensure sum is exactly 100 (adjust largest value if needed due to rounding)
const normalizedSum = Object.values(normalized).reduce((a, b) => a + b, 0)
if (normalizedSum !== 100) {
const largest = Object.entries(normalized).reduce((max, [k, v]) => v > max[1] ? [k, v] : max, ['', 0])
normalized[largest[0]] += (100 - normalizedSum)
}
try { try {
await api.updateFocusAreas(focusTemp) await api.updateFocusAreas(normalized)
showToast('✓ Fokus-Bereiche aktualisiert') showToast('✓ Fokus-Bereiche aktualisiert')
await loadData() await loadData()
setFocusEditing(false) setFocusEditing(false)
@ -408,7 +404,6 @@ export default function GoalsPage() {
setError(err.message || 'Fehler beim Speichern') setError(err.message || 'Fehler beim Speichern')
} }
}} }}
disabled={Object.values(focusTemp).reduce((a, b) => a + b, 0) !== 100}
style={{ flex: 1 }} style={{ flex: 1 }}
> >
Speichern Speichern