feat: Focus Areas Slider UI (Goal System v2.0 complete)
Replaces single goal mode cards with weighted multi-focus system UI Features: - 6 sliders for focus dimensions (5% increments) - Live sum calculation with visual feedback - Validation: Sum must equal 100% - Color-coded sliders per dimension - Edit/Display mode toggle - Shows derived values if not customized UX Flow: 1. Default: Shows focus distribution (bars) 2. Click 'Anpassen': Shows sliders 3. Adjust percentages (sum = 100%) 4. Save → Updates backend + reloads Visual: - Active dimensions shown as colored cards (display mode) - Gradient sliders with percentage labels (edit mode) - Green box when sum = 100%, red when != 100% - Info message if derived from old goal_mode Complete v2.0: ✅ Backend (Migration 027, API, get_focus_weights V2) ✅ Frontend (Slider UI, state management, validation) ✅ Auto-migration (goal_mode → focus_areas) Ready for: KI-Integration with weighted scoring
This commit is contained in:
parent
4a11d20c4d
commit
d97925d5a1
|
|
@ -46,6 +46,16 @@ const GOAL_MODES = [
|
||||||
|
|
||||||
export default function GoalsPage() {
|
export default function GoalsPage() {
|
||||||
const [goalMode, setGoalMode] = useState(null)
|
const [goalMode, setGoalMode] = useState(null)
|
||||||
|
const [focusAreas, setFocusAreas] = useState(null)
|
||||||
|
const [focusEditing, setFocusEditing] = useState(false)
|
||||||
|
const [focusTemp, setFocusTemp] = useState({
|
||||||
|
weight_loss_pct: 0,
|
||||||
|
muscle_gain_pct: 0,
|
||||||
|
strength_pct: 0,
|
||||||
|
endurance_pct: 0,
|
||||||
|
flexibility_pct: 0,
|
||||||
|
health_pct: 0
|
||||||
|
})
|
||||||
const [goals, setGoals] = useState([])
|
const [goals, setGoals] = useState([])
|
||||||
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
||||||
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
|
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
|
||||||
|
|
@ -74,13 +84,16 @@ export default function GoalsPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [modeData, goalsData, typesData] = await Promise.all([
|
const [modeData, goalsData, typesData, focusData] = await Promise.all([
|
||||||
api.getGoalMode(),
|
api.getGoalMode(),
|
||||||
api.listGoals(),
|
api.listGoals(),
|
||||||
api.listGoalTypeDefinitions() // Phase 1.5: Load from DB
|
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
|
||||||
|
api.getFocusAreas() // v2.0: Load focus areas
|
||||||
])
|
])
|
||||||
setGoalMode(modeData.goal_mode)
|
setGoalMode(modeData.goal_mode)
|
||||||
setGoals(goalsData)
|
setGoals(goalsData)
|
||||||
|
setFocusAreas(focusData)
|
||||||
|
setFocusTemp(focusData) // Initialize temp state
|
||||||
|
|
||||||
// Convert types array to map for quick lookup
|
// Convert types array to map for quick lookup
|
||||||
const typesMap = {}
|
const typesMap = {}
|
||||||
|
|
@ -258,43 +271,189 @@ export default function GoalsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Strategic Goal Mode Selection */}
|
{/* Focus Areas (v2.0) */}
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>🎯 Trainingsmodus</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2>
|
||||||
|
{!focusEditing && focusAreas && (
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setFocusEditing(true)}
|
||||||
|
style={{ padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
<Pencil size={14} /> Anpassen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
|
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
|
||||||
Wähle deine grundlegende Trainingsausrichtung. Dies beeinflusst die Gewichtung
|
Gewichte deine Trainingsziele individuell. Die Summe muss 100% ergeben.
|
||||||
und Interpretation aller Analysen.
|
{focusAreas && !focusAreas.custom && (
|
||||||
|
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
|
||||||
|
ℹ️ Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{
|
{focusEditing ? (
|
||||||
display: 'grid',
|
<>
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
{/* Sliders */}
|
||||||
gap: 12
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 20 }}>
|
||||||
}}>
|
{[
|
||||||
{GOAL_MODES.map(mode => (
|
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' },
|
||||||
<button
|
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' },
|
||||||
key={mode.id}
|
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' },
|
||||||
onClick={() => handleGoalModeChange(mode.id)}
|
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' },
|
||||||
className={goalMode === mode.id ? 'btn-primary' : 'btn-secondary'}
|
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' },
|
||||||
style={{
|
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' }
|
||||||
padding: 16,
|
].map(area => (
|
||||||
textAlign: 'left',
|
<div key={area.key}>
|
||||||
background: goalMode === mode.id ? mode.color : 'var(--surface)',
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
borderColor: goalMode === mode.id ? mode.color : 'var(--border)',
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
color: goalMode === mode.id ? 'white' : 'var(--text1)'
|
<span style={{ fontSize: 20 }}>{area.icon}</span>
|
||||||
}}
|
<span style={{ fontWeight: 500 }}>{area.label}</span>
|
||||||
>
|
</div>
|
||||||
<div style={{ fontSize: 24, marginBottom: 8 }}>{mode.icon}</div>
|
<span style={{
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mode.label}</div>
|
fontSize: 18,
|
||||||
<div style={{
|
fontWeight: 600,
|
||||||
fontSize: 12,
|
color: area.color,
|
||||||
opacity: goalMode === mode.id ? 0.9 : 0.7
|
minWidth: 50,
|
||||||
}}>
|
textAlign: 'right'
|
||||||
{mode.description}
|
}}>
|
||||||
|
{focusTemp[area.key]}%
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Sum Display */}
|
||||||
|
<div style={{
|
||||||
|
padding: 12,
|
||||||
|
background: (() => {
|
||||||
|
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
|
||||||
|
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,
|
||||||
|
marginBottom: 16
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: (() => {
|
||||||
|
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
|
||||||
|
if (sum === 100) return 'white'
|
||||||
|
return '#DC2626'
|
||||||
|
})()
|
||||||
|
}}>
|
||||||
|
Summe:
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 20,
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{Object.values(focusTemp).reduce((a, b) => a + b, 0) !== 100 && (
|
||||||
))}
|
<div style={{ fontSize: 12, marginTop: 4, color: '#DC2626' }}>
|
||||||
</div>
|
⚠️ Summe muss 100% ergeben
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={async () => {
|
||||||
|
const sum = Object.values(focusTemp).reduce((a, b) => a + b, 0)
|
||||||
|
if (sum !== 100) {
|
||||||
|
setError('Summe muss 100% ergeben')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.updateFocusAreas(focusTemp)
|
||||||
|
showToast('✓ Fokus-Bereiche aktualisiert')
|
||||||
|
await loadData()
|
||||||
|
setFocusEditing(false)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={Object.values(focusTemp).reduce((a, b) => a + b, 0) !== 100}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setFocusTemp(focusAreas)
|
||||||
|
setFocusEditing(false)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : focusAreas && (
|
||||||
|
/* Display Mode */
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' },
|
||||||
|
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' },
|
||||||
|
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' },
|
||||||
|
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' },
|
||||||
|
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' },
|
||||||
|
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' }
|
||||||
|
].filter(area => focusAreas[area.key] > 0).map(area => (
|
||||||
|
<div
|
||||||
|
key={area.key}
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 24, marginBottom: 4 }}>{area.icon}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 4 }}>{area.label}</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: area.color }}>{focusAreas[area.key]}%</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tactical Goals List */}
|
{/* Tactical Goals List */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user