feat: Dynamic Focus Areas system v2.0 - fully implemented
**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:
parent
dfcdfbe335
commit
3116fbbc91
53
backend/migrations/032_user_focus_area_weights.sql
Normal file
53
backend/migrations/032_user_focus_area_weights.sql
Normal 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;
|
||||
|
|
@ -233,45 +233,61 @@ def delete_focus_area_definition(
|
|||
@router.get("/user-preferences")
|
||||
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:
|
||||
- legacy: Old flat structure (weight_loss_pct, muscle_gain_pct, etc.)
|
||||
- dynamic: New dynamic preferences (focus_area_id → weight_pct)
|
||||
Returns focus areas with user-set weights, grouped by category.
|
||||
"""
|
||||
pid = session['profile_id']
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get legacy preferences
|
||||
cur.execute("""
|
||||
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
|
||||
# Get dynamic preferences (Migration 032)
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO user_focus_preferences (profile_id)
|
||||
VALUES (%s)
|
||||
RETURNING weight_loss_pct, muscle_gain_pct, strength_pct,
|
||||
endurance_pct, flexibility_pct, health_pct
|
||||
SELECT
|
||||
fa.id, fa.key, fa.name_de, fa.name_en, fa.icon,
|
||||
fa.category, fa.description,
|
||||
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,))
|
||||
legacy = r2d(cur.fetchone())
|
||||
|
||||
# TODO: Future - dynamic preferences from new table
|
||||
# For now, return legacy structure
|
||||
weights = [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"legacy": legacy,
|
||||
"dynamic": {} # Placeholder for future
|
||||
}
|
||||
# Calculate percentages from weights
|
||||
total_weight = sum(w['weight'] for w in weights)
|
||||
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")
|
||||
def update_user_focus_preferences(
|
||||
|
|
@ -279,66 +295,44 @@ def update_user_focus_preferences(
|
|||
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.
|
||||
Auto-normalizes to sum=100%.
|
||||
Expects: { "weights": { "focus_area_id": weight, ... } }
|
||||
Weights are relative (0-100), normalized in display only.
|
||||
"""
|
||||
pid = session['profile_id']
|
||||
|
||||
# Extract percentages
|
||||
percentages = {
|
||||
'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)
|
||||
}
|
||||
if 'weights' not in data:
|
||||
raise HTTPException(status_code=400, detail="'weights' field required")
|
||||
|
||||
# Normalize to 100%
|
||||
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)
|
||||
weights = data['weights'] # Dict: focus_area_id → weight
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Upsert
|
||||
cur.execute("""
|
||||
INSERT INTO user_focus_preferences
|
||||
(profile_id, weight_loss_pct, muscle_gain_pct, strength_pct,
|
||||
endurance_pct, flexibility_pct, health_pct)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (profile_id)
|
||||
DO UPDATE SET
|
||||
weight_loss_pct = EXCLUDED.weight_loss_pct,
|
||||
muscle_gain_pct = EXCLUDED.muscle_gain_pct,
|
||||
strength_pct = EXCLUDED.strength_pct,
|
||||
endurance_pct = EXCLUDED.endurance_pct,
|
||||
flexibility_pct = EXCLUDED.flexibility_pct,
|
||||
health_pct = EXCLUDED.health_pct,
|
||||
updated_at = NOW()
|
||||
""", (
|
||||
pid,
|
||||
percentages['weight_loss_pct'],
|
||||
percentages['muscle_gain_pct'],
|
||||
percentages['strength_pct'],
|
||||
percentages['endurance_pct'],
|
||||
percentages['flexibility_pct'],
|
||||
percentages['health_pct']
|
||||
))
|
||||
# Delete existing weights
|
||||
cur.execute(
|
||||
"DELETE FROM user_focus_area_weights WHERE profile_id = %s",
|
||||
(pid,)
|
||||
)
|
||||
|
||||
# Insert new weights (only non-zero)
|
||||
for focus_area_id, weight in weights.items():
|
||||
weight_int = int(weight)
|
||||
if weight_int > 0:
|
||||
cur.execute("""
|
||||
INSERT INTO user_focus_area_weights
|
||||
(profile_id, focus_area_id, weight)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (profile_id, focus_area_id)
|
||||
DO UPDATE SET
|
||||
weight = EXCLUDED.weight,
|
||||
updated_at = NOW()
|
||||
""", (pid, focus_area_id, weight_int))
|
||||
|
||||
return {
|
||||
"message": "Focus Areas aktualisiert",
|
||||
"normalized": percentages
|
||||
"message": "Focus Area Gewichtungen aktualisiert",
|
||||
"count": len([w for w in weights.values() if int(w) > 0])
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -5,45 +5,6 @@ import dayjs from 'dayjs'
|
|||
import '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
|
||||
const GOAL_CATEGORIES = {
|
||||
body: { label: 'Körper', icon: '📉', color: '#D85A30', description: 'Gewicht, Körperfett, Muskelmasse' },
|
||||
|
|
@ -96,21 +57,15 @@ const getCategoryForGoalType = (goalType) => {
|
|||
|
||||
export default function GoalsPage() {
|
||||
const [goalMode, setGoalMode] = useState(null)
|
||||
const [focusPreferences, setFocusPreferences] = useState(null) // User's legacy focus preferences
|
||||
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 [userFocusWeights, setUserFocusWeights] = useState([]) // v2.0: User's focus area weights
|
||||
const [userFocusGrouped, setUserFocusGrouped] = useState({}) // Grouped by category
|
||||
const [focusWeightsEditing, setFocusWeightsEditing] = useState(false)
|
||||
const [focusWeightsTemp, setFocusWeightsTemp] = useState({}) // Temp: {focus_area_id: weight}
|
||||
const [goals, setGoals] = useState([]) // Kept for backward compat
|
||||
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
|
||||
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
||||
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 [showGoalForm, setShowGoalForm] = useState(false)
|
||||
const [editingGoal, setEditingGoal] = useState(null)
|
||||
|
|
@ -148,32 +103,31 @@ export default function GoalsPage() {
|
|||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [modeData, goalsData, groupedData, typesData, focusData, focusAreasData] = await Promise.all([
|
||||
const [modeData, goalsData, groupedData, typesData, userWeightsData, focusAreasData] = await Promise.all([
|
||||
api.getGoalMode(),
|
||||
api.listGoals(),
|
||||
api.listGoalsGrouped(), // v2.1: Load grouped by category
|
||||
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
|
||||
api.getFocusAreas(), // v2.0: Load user focus preferences (legacy)
|
||||
api.listFocusAreaDefinitions(false) // v2.0: Load available focus areas
|
||||
api.getUserFocusPreferences(), // v2.0: Load user's focus area weights
|
||||
api.listFocusAreaDefinitions(false) // v2.0: Load all available focus areas
|
||||
])
|
||||
|
||||
setGoalMode(modeData.goal_mode)
|
||||
setGoals(goalsData)
|
||||
setGroupedGoals(groupedData)
|
||||
|
||||
// Ensure all focus fields are present and numeric
|
||||
const sanitizedFocus = {
|
||||
weight_loss_pct: focusData?.weight_loss_pct ?? 0,
|
||||
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
|
||||
}
|
||||
// v2.0: User focus weights (dynamic)
|
||||
setUserFocusWeights(userWeightsData.weights || [])
|
||||
setUserFocusGrouped(userWeightsData.grouped || {})
|
||||
|
||||
setFocusPreferences(sanitizedFocus)
|
||||
setFocusTemp(sanitizedFocus)
|
||||
// Build temp object for editing: {focus_area_id: weight}
|
||||
const tempWeights = {}
|
||||
if (userWeightsData.weights) {
|
||||
userWeightsData.weights.forEach(w => {
|
||||
tempWeights[w.id] = w.weight
|
||||
})
|
||||
}
|
||||
setFocusWeightsTemp(tempWeights)
|
||||
|
||||
// Convert types array to map for quick lookup
|
||||
const typesMap = {}
|
||||
|
|
@ -192,7 +146,7 @@ export default function GoalsPage() {
|
|||
setGoalTypes(typesData || [])
|
||||
setGoalTypesMap(typesMap)
|
||||
|
||||
// v2.0: Set focus area definitions
|
||||
// v2.0: All focus area definitions (for selection in goal form)
|
||||
if (focusAreasData) {
|
||||
setFocusAreas(focusAreasData.areas || [])
|
||||
setFocusAreasGrouped(focusAreasData.grouped || {})
|
||||
|
|
@ -210,17 +164,6 @@ export default function GoalsPage() {
|
|||
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 = () => {
|
||||
if (goalTypes.length === 0) {
|
||||
setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.')
|
||||
|
|
@ -442,231 +385,158 @@ export default function GoalsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Goal Mode Selection - Strategic Layer */}
|
||||
<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) */}
|
||||
{/* Focus Areas (v2.0 - Dynamic) */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2>
|
||||
{!focusEditing && focusPreferences && (
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setFocusTemp(focusPreferences) // Sync temp state before editing
|
||||
setFocusEditing(true)
|
||||
}}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<Pencil size={14} /> Anpassen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
// Initialize temp weights from current weights
|
||||
const tempWeights = {}
|
||||
focusAreas.forEach(fa => {
|
||||
tempWeights[fa.id] = focusWeightsTemp[fa.id] || 0
|
||||
})
|
||||
setFocusWeightsTemp(tempWeights)
|
||||
setFocusWeightsEditing(!focusWeightsEditing)
|
||||
}}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<Pencil size={14} /> {focusWeightsEditing ? 'Abbrechen' : 'Anpassen'}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
|
||||
Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile.
|
||||
{focusPreferences && !focusPreferences.custom && (
|
||||
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
|
||||
ℹ️ Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
|
||||
</span>
|
||||
)}
|
||||
Wähle deine Trainingsschwerpunkte und gewichte sie relativ zueinander. Prozente werden automatisch berechnet.
|
||||
</p>
|
||||
|
||||
{focusEditing ? (
|
||||
{focusWeightsEditing ? (
|
||||
<>
|
||||
{/* Sliders */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 20 }}>
|
||||
{[
|
||||
{ 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' }
|
||||
].map(area => {
|
||||
const rawValue = Number(focusTemp[area.key]) || 0
|
||||
const weight = Math.round(rawValue / 10)
|
||||
const sum = Object.entries(focusTemp)
|
||||
.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'
|
||||
}}
|
||||
/>
|
||||
{/* Edit Mode - Sliders grouped by category */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, marginBottom: 20 }}>
|
||||
{Object.entries(focusAreasGrouped).map(([category, areas]) => (
|
||||
<div key={category}>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{category}
|
||||
</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>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={async () => {
|
||||
// Calculate sum (filter out NaN/undefined)
|
||||
const sum = Object.entries(focusTemp)
|
||||
.filter(([k]) => k.endsWith('_pct'))
|
||||
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
|
||||
|
||||
if (sum === 0 || isNaN(sum)) {
|
||||
setError('Mindestens ein Bereich muss gewichtet sein')
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to percentages (ensure no NaN values)
|
||||
const normalized = {
|
||||
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),
|
||||
endurance_pct: Math.round((Number(focusTemp.endurance_pct) || 0) / sum * 100),
|
||||
flexibility_pct: Math.round((Number(focusTemp.flexibility_pct) || 0) / sum * 100),
|
||||
health_pct: Math.round((Number(focusTemp.health_pct) || 0) / 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 {
|
||||
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>
|
||||
{/* Save Button */}
|
||||
<button
|
||||
className="btn-primary btn-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.updateUserFocusPreferences({ weights: focusWeightsTemp })
|
||||
showToast('✓ Fokus-Bereiche aktualisiert')
|
||||
await loadData()
|
||||
setFocusWeightsEditing(false)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* Display Mode - Cards for areas with weight > 0 */
|
||||
userFocusWeights.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 16px',
|
||||
color: 'var(--text3)',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<p style={{ margin: 0, marginBottom: 12 }}>Keine Fokus-Bereiche definiert</p>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setFocusTemp(focusAreas)
|
||||
setFocusEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => setFocusWeightsEditing(true)}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
Abbrechen
|
||||
Jetzt konfigurieren
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : focusPreferences && (
|
||||
/* 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 => focusPreferences[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 }}>{focusPreferences[area.key]}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
{userFocusWeights.map(area => (
|
||||
<div
|
||||
key={area.id}
|
||||
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.name_de}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{area.percentage}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user