From 3116fbbc91d71e268243dd446fd20a7b40ad85df Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 20:51:19 +0100 Subject: [PATCH] feat: Dynamic Focus Areas system v2.0 - fully implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- .../032_user_focus_area_weights.sql | 53 +++ backend/routers/focus_areas.py | 148 +++--- frontend/src/pages/GoalsPage.jsx | 444 +++++++----------- 3 files changed, 281 insertions(+), 364 deletions(-) create mode 100644 backend/migrations/032_user_focus_area_weights.sql diff --git a/backend/migrations/032_user_focus_area_weights.sql b/backend/migrations/032_user_focus_area_weights.sql new file mode 100644 index 0000000..b48087f --- /dev/null +++ b/backend/migrations/032_user_focus_area_weights.sql @@ -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; diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py index 6ff8d96..d41d6b0 100644 --- a/backend/routers/focus_areas.py +++ b/backend/routers/focus_areas.py @@ -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]) } # ============================================================================ diff --git a/frontend/src/pages/GoalsPage.jsx b/frontend/src/pages/GoalsPage.jsx index 2afcb27..b085279 100644 --- a/frontend/src/pages/GoalsPage.jsx +++ b/frontend/src/pages/GoalsPage.jsx @@ -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() { )} - {/* Goal Mode Selection - Strategic Layer */} -
-

🎯 Trainingsmodus

-

- Wähle deinen primären Trainingsmodus. Dies beeinflusst die Gewichtung der Fokus-Bereiche und KI-Analysen. -

-
- {GOAL_MODES.map(mode => ( -
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' - }} - > -
{mode.icon}
-
- {mode.label} -
-
- {mode.description} -
- {goalMode === mode.id && ( -
- ✓ -
- )} -
- ))} -
-
- - {/* Focus Areas (v2.0) */} + {/* Focus Areas (v2.0 - Dynamic) */}

🎯 Fokus-Bereiche

- {!focusEditing && focusPreferences && ( - - )} +

- Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile. - {focusPreferences && !focusPreferences.custom && ( - - ℹ️ Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung - - )} + Wähle deine Trainingsschwerpunkte und gewichte sie relativ zueinander. Prozente werden automatisch berechnet.

- {focusEditing ? ( + {focusWeightsEditing ? ( <> - {/* Sliders */} -
- {[ - { 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 ( -
-
-
- {area.icon} - {area.label} -
- - {weight} → {actualPercent}% - -
- 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 */} +
+ {Object.entries(focusAreasGrouped).map(([category, areas]) => ( +
+
+ {category}
- ) - })} +
+ {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 ( +
+
+
+ {area.icon} + {area.name_de} +
+ + {weight} → {percentage}% + +
+ 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' + }} + /> +
+ ) + })} +
+
+ ))}
- {/* Action Buttons */} -
- + {/* Save Button */} + + + ) : ( + /* Display Mode - Cards for areas with weight > 0 */ + userFocusWeights.length === 0 ? ( +
+

Keine Fokus-Bereiche definiert

- - ) : focusPreferences && ( - /* Display Mode */ -
- {[ - { 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 => ( -
-
{area.icon}
-
{area.label}
-
{focusPreferences[area.key]}%
-
- ))} -
+ ) : ( +
+ {userFocusWeights.map(area => ( +
+
{area.icon}
+
{area.name_de}
+
{area.percentage}%
+
+ ))} +
+ ) )}