feat: Dynamic Focus Areas system v2.0 - fully implemented
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 14s

**Migration 032:**
- user_focus_area_weights table (profile_id, focus_area_id, weight)
- Migrates legacy 6 preferences to dynamic weights

**Backend (focus_areas.py):**
- GET /user-preferences: Returns dynamic focus weights with percentages
- PUT /user-preferences: Saves user weights (dict: focus_area_id → weight)
- Auto-calculates percentages from relative weights
- Graceful fallback if Migration 032 not applied

**Frontend (GoalsPage.jsx):**
- REMOVED: Goal Mode cards (obsolete)
- REMOVED: 6 hardcoded legacy focus sliders
- NEW: Dynamic focus area cards (weight > 0 only)
- NEW: Edit mode with sliders for all 26 areas (grouped by category)
- Clean responsive design

**How it works:**
1. Admin defines focus areas in /admin/focus-areas (26 default)
2. User sets weights for areas they care about (0-100 relative)
3. System calculates percentages automatically
4. Cards show only weighted areas
5. Goals assign to 1-n focus areas (existing functionality)
This commit is contained in:
Lars 2026-03-27 20:51:19 +01:00
parent dfcdfbe335
commit 3116fbbc91
3 changed files with 281 additions and 364 deletions

View File

@ -0,0 +1,53 @@
-- Migration 032: User Focus Area Weights
-- Date: 2026-03-27
-- Purpose: Allow users to set custom weights for focus areas (dynamic preferences)
-- ============================================================================
-- User Focus Area Weights (many-to-many with weights)
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_focus_area_weights (
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
weight INTEGER NOT NULL DEFAULT 0 CHECK (weight >= 0 AND weight <= 100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (profile_id, focus_area_id)
);
CREATE INDEX idx_user_focus_weights_profile ON user_focus_area_weights(profile_id);
CREATE INDEX idx_user_focus_weights_area ON user_focus_area_weights(focus_area_id);
COMMENT ON TABLE user_focus_area_weights IS 'User-specific weights for focus areas (dynamic system)';
COMMENT ON COLUMN user_focus_area_weights.weight IS 'Relative weight (0-100) - will be normalized to percentages in UI';
-- ============================================================================
-- Migrate legacy preferences to dynamic weights
-- ============================================================================
-- For each user with legacy preferences, create weights for the 6 base areas
INSERT INTO user_focus_area_weights (profile_id, focus_area_id, weight)
SELECT
ufp.profile_id,
fad.id as focus_area_id,
CASE fad.key
WHEN 'weight_loss' THEN ufp.weight_loss_pct
WHEN 'muscle_gain' THEN ufp.muscle_gain_pct
WHEN 'strength' THEN ufp.strength_pct
WHEN 'aerobic_endurance' THEN ufp.endurance_pct
WHEN 'flexibility' THEN ufp.flexibility_pct
WHEN 'general_health' THEN ufp.health_pct
ELSE 0
END as weight
FROM user_focus_preferences ufp
CROSS JOIN focus_area_definitions fad
WHERE fad.key IN ('weight_loss', 'muscle_gain', 'strength', 'aerobic_endurance', 'flexibility', 'general_health')
AND (
(fad.key = 'weight_loss' AND ufp.weight_loss_pct > 0) OR
(fad.key = 'muscle_gain' AND ufp.muscle_gain_pct > 0) OR
(fad.key = 'strength' AND ufp.strength_pct > 0) OR
(fad.key = 'aerobic_endurance' AND ufp.endurance_pct > 0) OR
(fad.key = 'flexibility' AND ufp.flexibility_pct > 0) OR
(fad.key = 'general_health' AND ufp.health_pct > 0)
)
ON CONFLICT (profile_id, focus_area_id) DO NOTHING;

View File

@ -233,45 +233,61 @@ def delete_focus_area_definition(
@router.get("/user-preferences") @router.get("/user-preferences")
def get_user_focus_preferences(session: dict = Depends(require_auth)): def get_user_focus_preferences(session: dict = Depends(require_auth)):
""" """
Get user's focus area weightings. Get user's focus area weightings (dynamic system).
Returns: Returns focus areas with user-set weights, grouped by category.
- legacy: Old flat structure (weight_loss_pct, muscle_gain_pct, etc.)
- dynamic: New dynamic preferences (focus_area_id weight_pct)
""" """
pid = session['profile_id'] pid = session['profile_id']
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Get legacy preferences # Get dynamic preferences (Migration 032)
cur.execute(""" try:
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct
FROM user_focus_preferences
WHERE profile_id = %s
""", (pid,))
legacy = cur.fetchone()
if legacy:
legacy = r2d(legacy)
else:
# Create default if not exists
cur.execute(""" cur.execute("""
INSERT INTO user_focus_preferences (profile_id) SELECT
VALUES (%s) fa.id, fa.key, fa.name_de, fa.name_en, fa.icon,
RETURNING weight_loss_pct, muscle_gain_pct, strength_pct, fa.category, fa.description,
endurance_pct, flexibility_pct, health_pct ufw.weight
FROM user_focus_area_weights ufw
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
WHERE ufw.profile_id = %s AND ufw.weight > 0
ORDER BY fa.category, fa.name_de
""", (pid,)) """, (pid,))
legacy = r2d(cur.fetchone())
# TODO: Future - dynamic preferences from new table weights = [r2d(row) for row in cur.fetchall()]
# For now, return legacy structure
return { # Calculate percentages from weights
"legacy": legacy, total_weight = sum(w['weight'] for w in weights)
"dynamic": {} # Placeholder for future if total_weight > 0:
} for w in weights:
w['percentage'] = round((w['weight'] / total_weight) * 100)
else:
for w in weights:
w['percentage'] = 0
# Group by category
grouped = {}
for w in weights:
cat = w['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(w)
return {
"weights": weights,
"grouped": grouped,
"total_weight": total_weight
}
except Exception as e:
# Migration 032 not applied yet - return empty
print(f"[WARNING] user_focus_area_weights not found: {e}")
return {
"weights": [],
"grouped": {},
"total_weight": 0
}
@router.put("/user-preferences") @router.put("/user-preferences")
def update_user_focus_preferences( def update_user_focus_preferences(
@ -279,66 +295,44 @@ def update_user_focus_preferences(
session: dict = Depends(require_auth) session: dict = Depends(require_auth)
): ):
""" """
Update user's focus area weightings. Update user's focus area weightings (dynamic system).
Accepts flat structure (legacy) for now. Expects: { "weights": { "focus_area_id": weight, ... } }
Auto-normalizes to sum=100%. Weights are relative (0-100), normalized in display only.
""" """
pid = session['profile_id'] pid = session['profile_id']
# Extract percentages if 'weights' not in data:
percentages = { raise HTTPException(status_code=400, detail="'weights' field required")
'weight_loss_pct': data.get('weight_loss_pct', 0),
'muscle_gain_pct': data.get('muscle_gain_pct', 0),
'strength_pct': data.get('strength_pct', 0),
'endurance_pct': data.get('endurance_pct', 0),
'flexibility_pct': data.get('flexibility_pct', 0),
'health_pct': data.get('health_pct', 0)
}
# Normalize to 100% weights = data['weights'] # Dict: focus_area_id → weight
total = sum(percentages.values())
if total > 0:
for key in percentages:
percentages[key] = round((percentages[key] / total) * 100)
# Adjust largest if sum != 100 due to rounding
current_sum = sum(percentages.values())
if current_sum != 100 and total > 0:
largest_key = max(percentages, key=percentages.get)
percentages[largest_key] += (100 - current_sum)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Upsert # Delete existing weights
cur.execute(""" cur.execute(
INSERT INTO user_focus_preferences "DELETE FROM user_focus_area_weights WHERE profile_id = %s",
(profile_id, weight_loss_pct, muscle_gain_pct, strength_pct, (pid,)
endurance_pct, flexibility_pct, health_pct) )
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (profile_id) # Insert new weights (only non-zero)
DO UPDATE SET for focus_area_id, weight in weights.items():
weight_loss_pct = EXCLUDED.weight_loss_pct, weight_int = int(weight)
muscle_gain_pct = EXCLUDED.muscle_gain_pct, if weight_int > 0:
strength_pct = EXCLUDED.strength_pct, cur.execute("""
endurance_pct = EXCLUDED.endurance_pct, INSERT INTO user_focus_area_weights
flexibility_pct = EXCLUDED.flexibility_pct, (profile_id, focus_area_id, weight)
health_pct = EXCLUDED.health_pct, VALUES (%s, %s, %s)
updated_at = NOW() ON CONFLICT (profile_id, focus_area_id)
""", ( DO UPDATE SET
pid, weight = EXCLUDED.weight,
percentages['weight_loss_pct'], updated_at = NOW()
percentages['muscle_gain_pct'], """, (pid, focus_area_id, weight_int))
percentages['strength_pct'],
percentages['endurance_pct'],
percentages['flexibility_pct'],
percentages['health_pct']
))
return { return {
"message": "Focus Areas aktualisiert", "message": "Focus Area Gewichtungen aktualisiert",
"normalized": percentages "count": len([w for w in weights.values() if int(w) > 0])
} }
# ============================================================================ # ============================================================================

View File

@ -5,45 +5,6 @@ import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
// Goal Mode Definitions
const GOAL_MODES = [
{
id: 'weight_loss',
icon: '📉',
label: 'Gewichtsreduktion',
description: 'Kaloriendefizit, Fettabbau',
color: '#D85A30'
},
{
id: 'strength',
icon: '💪',
label: 'Kraftaufbau',
description: 'Muskelwachstum, progressive Belastung',
color: '#378ADD'
},
{
id: 'endurance',
icon: '🏃',
label: 'Ausdauer',
description: 'VO2Max, aerobe Kapazität',
color: '#1D9E75'
},
{
id: 'recomposition',
icon: '⚖️',
label: 'Körperkomposition',
description: 'Gleichzeitig Fett ab- & Muskeln aufbauen',
color: '#7B68EE'
},
{
id: 'health',
icon: '❤️',
label: 'Gesundheit',
description: 'Vitals, Regeneration, Wohlbefinden',
color: '#E67E22'
}
]
// Goal Categories // Goal Categories
const GOAL_CATEGORIES = { const GOAL_CATEGORIES = {
body: { label: 'Körper', icon: '📉', color: '#D85A30', description: 'Gewicht, Körperfett, Muskelmasse' }, body: { label: 'Körper', icon: '📉', color: '#D85A30', description: 'Gewicht, Körperfett, Muskelmasse' },
@ -96,21 +57,15 @@ const getCategoryForGoalType = (goalType) => {
export default function GoalsPage() { export default function GoalsPage() {
const [goalMode, setGoalMode] = useState(null) const [goalMode, setGoalMode] = useState(null)
const [focusPreferences, setFocusPreferences] = useState(null) // User's legacy focus preferences const [userFocusWeights, setUserFocusWeights] = useState([]) // v2.0: User's focus area weights
const [focusEditing, setFocusEditing] = useState(false) const [userFocusGrouped, setUserFocusGrouped] = useState({}) // Grouped by category
const [focusTemp, setFocusTemp] = useState({ const [focusWeightsEditing, setFocusWeightsEditing] = useState(false)
weight_loss_pct: 0, const [focusWeightsTemp, setFocusWeightsTemp] = useState({}) // Temp: {focus_area_id: weight}
muscle_gain_pct: 0,
strength_pct: 0,
endurance_pct: 0,
flexibility_pct: 0,
health_pct: 0
})
const [goals, setGoals] = useState([]) // Kept for backward compat const [goals, setGoals] = useState([]) // Kept for backward compat
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
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
const [focusAreas, setFocusAreas] = useState([]) // v2.0: Available focus areas const [focusAreas, setFocusAreas] = useState([]) // v2.0: All available focus areas (for selection)
const [focusAreasGrouped, setFocusAreasGrouped] = useState({}) // Grouped by category const [focusAreasGrouped, setFocusAreasGrouped] = useState({}) // Grouped by category
const [showGoalForm, setShowGoalForm] = useState(false) const [showGoalForm, setShowGoalForm] = useState(false)
const [editingGoal, setEditingGoal] = useState(null) const [editingGoal, setEditingGoal] = useState(null)
@ -148,32 +103,31 @@ export default function GoalsPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [modeData, goalsData, groupedData, typesData, focusData, focusAreasData] = await Promise.all([ const [modeData, goalsData, groupedData, typesData, userWeightsData, focusAreasData] = await Promise.all([
api.getGoalMode(), api.getGoalMode(),
api.listGoals(), api.listGoals(),
api.listGoalsGrouped(), // v2.1: Load grouped by category api.listGoalsGrouped(), // v2.1: Load grouped by category
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
api.getFocusAreas(), // v2.0: Load user focus preferences (legacy) api.getUserFocusPreferences(), // v2.0: Load user's focus area weights
api.listFocusAreaDefinitions(false) // v2.0: Load available focus areas api.listFocusAreaDefinitions(false) // v2.0: Load all available focus areas
]) ])
setGoalMode(modeData.goal_mode) setGoalMode(modeData.goal_mode)
setGoals(goalsData) setGoals(goalsData)
setGroupedGoals(groupedData) setGroupedGoals(groupedData)
// Ensure all focus fields are present and numeric // v2.0: User focus weights (dynamic)
const sanitizedFocus = { setUserFocusWeights(userWeightsData.weights || [])
weight_loss_pct: focusData?.weight_loss_pct ?? 0, setUserFocusGrouped(userWeightsData.grouped || {})
muscle_gain_pct: focusData?.muscle_gain_pct ?? 0,
strength_pct: focusData?.strength_pct ?? 0,
endurance_pct: focusData?.endurance_pct ?? 0,
flexibility_pct: focusData?.flexibility_pct ?? 0,
health_pct: focusData?.health_pct ?? 0,
custom: focusData?.custom,
updated_at: focusData?.updated_at
}
setFocusPreferences(sanitizedFocus) // Build temp object for editing: {focus_area_id: weight}
setFocusTemp(sanitizedFocus) const tempWeights = {}
if (userWeightsData.weights) {
userWeightsData.weights.forEach(w => {
tempWeights[w.id] = w.weight
})
}
setFocusWeightsTemp(tempWeights)
// Convert types array to map for quick lookup // Convert types array to map for quick lookup
const typesMap = {} const typesMap = {}
@ -192,7 +146,7 @@ export default function GoalsPage() {
setGoalTypes(typesData || []) setGoalTypes(typesData || [])
setGoalTypesMap(typesMap) setGoalTypesMap(typesMap)
// v2.0: Set focus area definitions // v2.0: All focus area definitions (for selection in goal form)
if (focusAreasData) { if (focusAreasData) {
setFocusAreas(focusAreasData.areas || []) setFocusAreas(focusAreasData.areas || [])
setFocusAreasGrouped(focusAreasData.grouped || {}) setFocusAreasGrouped(focusAreasData.grouped || {})
@ -210,17 +164,6 @@ export default function GoalsPage() {
setTimeout(() => setToast(null), duration) setTimeout(() => setToast(null), duration)
} }
const handleGoalModeChange = async (newMode) => {
try {
await api.updateGoalMode(newMode)
setGoalMode(newMode)
showToast('✓ Trainingsmodus aktualisiert')
} catch (err) {
console.error('Failed to update goal mode:', err)
setError('Fehler beim Aktualisieren des Trainingsmodus')
}
}
const handleCreateGoal = () => { const handleCreateGoal = () => {
if (goalTypes.length === 0) { if (goalTypes.length === 0) {
setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.') setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.')
@ -442,231 +385,158 @@ export default function GoalsPage() {
</div> </div>
)} )}
{/* Goal Mode Selection - Strategic Layer */} {/* Focus Areas (v2.0 - Dynamic) */}
<div className="card" style={{ marginBottom: 16 }}>
<h2 style={{ marginBottom: 8 }}>🎯 Trainingsmodus</h2>
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
Wähle deinen primären Trainingsmodus. Dies beeinflusst die Gewichtung der Fokus-Bereiche und KI-Analysen.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
{GOAL_MODES.map(mode => (
<div
key={mode.id}
onClick={() => handleGoalModeChange(mode.id)}
style={{
padding: 16,
borderRadius: 8,
border: `2px solid ${goalMode === mode.id ? mode.color : 'var(--border)'}`,
background: goalMode === mode.id ? `${mode.color}15` : 'var(--surface)',
cursor: 'pointer',
transition: 'all 0.2s',
position: 'relative'
}}
>
<div style={{ fontSize: 32, marginBottom: 8 }}>{mode.icon}</div>
<div style={{
fontWeight: 600,
marginBottom: 4,
color: goalMode === mode.id ? mode.color : 'var(--text1)'
}}>
{mode.label}
</div>
<div style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.4 }}>
{mode.description}
</div>
{goalMode === mode.id && (
<div style={{
position: 'absolute',
top: 8,
right: 8,
width: 20,
height: 20,
borderRadius: '50%',
background: mode.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 12,
fontWeight: 700
}}>
</div>
)}
</div>
))}
</div>
</div>
{/* Focus Areas (v2.0) */}
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2> <h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2>
{!focusEditing && focusPreferences && ( <button
<button className="btn-secondary"
className="btn-secondary" onClick={() => {
onClick={() => { // Initialize temp weights from current weights
setFocusTemp(focusPreferences) // Sync temp state before editing const tempWeights = {}
setFocusEditing(true) focusAreas.forEach(fa => {
}} tempWeights[fa.id] = focusWeightsTemp[fa.id] || 0
style={{ padding: '6px 12px' }} })
> setFocusWeightsTemp(tempWeights)
<Pencil size={14} /> Anpassen setFocusWeightsEditing(!focusWeightsEditing)
</button> }}
)} style={{ padding: '6px 12px' }}
>
<Pencil size={14} /> {focusWeightsEditing ? 'Abbrechen' : 'Anpassen'}
</button>
</div> </div>
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}> <p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile. Wähle deine Trainingsschwerpunkte und gewichte sie relativ zueinander. Prozente werden automatisch berechnet.
{focusPreferences && !focusPreferences.custom && (
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
</span>
)}
</p> </p>
{focusEditing ? ( {focusWeightsEditing ? (
<> <>
{/* Sliders */} {/* Edit Mode - Sliders grouped by category */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 20 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24, marginBottom: 20 }}>
{[ {Object.entries(focusAreasGrouped).map(([category, areas]) => (
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' }, <div key={category}>
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' }, <div style={{
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' }, fontSize: 11,
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' }, fontWeight: 700,
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' }, color: 'var(--text3)',
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' } textTransform: 'uppercase',
].map(area => { letterSpacing: '0.05em',
const rawValue = Number(focusTemp[area.key]) || 0 marginBottom: 12
const weight = Math.round(rawValue / 10) }}>
const sum = Object.entries(focusTemp) {category}
.filter(([k]) => k.endsWith('_pct'))
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
const actualPercent = sum > 0 ? Math.round(rawValue / sum * 100) : 0
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>
<input
type="range"
min="0"
max="10"
step="1"
value={weight}
onChange={e => setFocusTemp(f => ({ ...f, [area.key]: parseInt(e.target.value) * 10 }))}
style={{
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>
) <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
})} {areas.map(area => {
const weight = focusWeightsTemp[area.id] || 0
const totalWeight = Object.values(focusWeightsTemp).reduce((sum, w) => sum + (w || 0), 0)
const percentage = totalWeight > 0 ? Math.round((weight / totalWeight) * 100) : 0
return (
<div key={area.id}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18 }}>{area.icon}</span>
<span style={{ fontWeight: 500, fontSize: 14 }}>{area.name_de}</span>
</div>
<span style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--accent)',
minWidth: 80,
textAlign: 'right'
}}>
{weight} {percentage}%
</span>
</div>
<input
type="range"
min="0"
max="100"
step="5"
value={weight}
onChange={e => setFocusWeightsTemp(f => ({
...f,
[area.id]: parseInt(e.target.value)
}))}
style={{
width: '100%',
height: 6,
borderRadius: 3,
background: `linear-gradient(to right, var(--accent) 0%, var(--accent) ${weight}%, var(--border) ${weight}%, var(--border) 100%)`,
outline: 'none',
cursor: 'pointer'
}}
/>
</div>
)
})}
</div>
</div>
))}
</div> </div>
{/* Action Buttons */} {/* Save Button */}
<div style={{ display: 'flex', gap: 12 }}> <button
<button className="btn-primary btn-full"
className="btn-primary" onClick={async () => {
onClick={async () => { try {
// Calculate sum (filter out NaN/undefined) await api.updateUserFocusPreferences({ weights: focusWeightsTemp })
const sum = Object.entries(focusTemp) showToast('✓ Fokus-Bereiche aktualisiert')
.filter(([k]) => k.endsWith('_pct')) await loadData()
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0) setFocusWeightsEditing(false)
setError(null)
if (sum === 0 || isNaN(sum)) { } catch (err) {
setError('Mindestens ein Bereich muss gewichtet sein') setError(err.message || 'Fehler beim Speichern')
return }
} }}
>
// Normalize to percentages (ensure no NaN values) Speichern
const normalized = { </button>
weight_loss_pct: Math.round((Number(focusTemp.weight_loss_pct) || 0) / sum * 100), </>
muscle_gain_pct: Math.round((Number(focusTemp.muscle_gain_pct) || 0) / sum * 100), ) : (
strength_pct: Math.round((Number(focusTemp.strength_pct) || 0) / sum * 100), /* Display Mode - Cards for areas with weight > 0 */
endurance_pct: Math.round((Number(focusTemp.endurance_pct) || 0) / sum * 100), userFocusWeights.length === 0 ? (
flexibility_pct: Math.round((Number(focusTemp.flexibility_pct) || 0) / sum * 100), <div style={{
health_pct: Math.round((Number(focusTemp.health_pct) || 0) / sum * 100) textAlign: 'center',
} padding: '32px 16px',
color: 'var(--text3)',
// Ensure sum is exactly 100 (adjust largest value if needed due to rounding) background: 'var(--surface2)',
const normalizedSum = Object.values(normalized).reduce((a, b) => a + b, 0) borderRadius: 8
if (normalizedSum !== 100) { }}>
const largest = Object.entries(normalized).reduce((max, [k, v]) => v > max[1] ? [k, v] : max, ['', 0]) <p style={{ margin: 0, marginBottom: 12 }}>Keine Fokus-Bereiche definiert</p>
normalized[largest[0]] += (100 - normalizedSum)
}
try {
await api.updateFocusAreas(normalized)
showToast('✓ Fokus-Bereiche aktualisiert')
await loadData()
setFocusEditing(false)
setError(null)
} catch (err) {
setError(err.message || 'Fehler beim Speichern')
}
}}
style={{ flex: 1 }}
>
Speichern
</button>
<button <button
className="btn-secondary" className="btn-secondary"
onClick={() => { onClick={() => setFocusWeightsEditing(true)}
setFocusTemp(focusAreas) style={{ fontSize: 13 }}
setFocusEditing(false)
setError(null)
}}
style={{ flex: 1 }}
> >
Abbrechen Jetzt konfigurieren
</button> </button>
</div> </div>
</> ) : (
) : focusPreferences && ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
/* Display Mode */ {userFocusWeights.map(area => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}> <div
{[ key={area.id}
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' }, style={{
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' }, padding: 12,
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' }, background: 'var(--surface2)',
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' }, border: '1px solid var(--border)',
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' }, borderRadius: 8,
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' } textAlign: 'center'
].filter(area => focusPreferences[area.key] > 0).map(area => ( }}
<div >
key={area.key} <div style={{ fontSize: 24, marginBottom: 4 }}>{area.icon}</div>
style={{ <div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 4 }}>{area.name_de}</div>
padding: 12, <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{area.percentage}%</div>
background: 'var(--surface2)', </div>
border: '1px solid var(--border)', ))}
borderRadius: 8, </div>
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 }}>{focusPreferences[area.key]}%</div>
</div>
))}
</div>
)} )}
</div> </div>