**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)
379 lines
12 KiB
Python
379 lines
12 KiB
Python
"""
|
|
Focus Areas Router
|
|
Manages dynamic focus area definitions and user preferences
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
from db import get_db, get_cursor, r2d
|
|
from auth import require_auth
|
|
|
|
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
|
|
|
# ============================================================================
|
|
# Models
|
|
# ============================================================================
|
|
|
|
class FocusAreaCreate(BaseModel):
|
|
"""Create new focus area definition"""
|
|
key: str
|
|
name_de: str
|
|
name_en: Optional[str] = None
|
|
icon: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: str = 'custom'
|
|
|
|
class FocusAreaUpdate(BaseModel):
|
|
"""Update focus area definition"""
|
|
name_de: Optional[str] = None
|
|
name_en: Optional[str] = None
|
|
icon: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class UserFocusPreferences(BaseModel):
|
|
"""User's focus area weightings (dynamic)"""
|
|
preferences: dict # {focus_area_id: weight_pct}
|
|
|
|
# ============================================================================
|
|
# Focus Area Definitions (Admin)
|
|
# ============================================================================
|
|
|
|
@router.get("/definitions")
|
|
def list_focus_area_definitions(
|
|
session: dict = Depends(require_auth),
|
|
include_inactive: bool = False
|
|
):
|
|
"""
|
|
List all available focus area definitions.
|
|
|
|
Query params:
|
|
- include_inactive: Include inactive focus areas (default: false)
|
|
|
|
Returns focus areas grouped by category.
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
query = """
|
|
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
|
created_at, updated_at
|
|
FROM focus_area_definitions
|
|
WHERE is_active = true OR %s
|
|
ORDER BY category, name_de
|
|
"""
|
|
|
|
cur.execute(query, (include_inactive,))
|
|
areas = [r2d(row) for row in cur.fetchall()]
|
|
|
|
# Group by category
|
|
grouped = {}
|
|
for area in areas:
|
|
cat = area['category'] or 'other'
|
|
if cat not in grouped:
|
|
grouped[cat] = []
|
|
grouped[cat].append(area)
|
|
|
|
return {
|
|
"areas": areas,
|
|
"grouped": grouped,
|
|
"total": len(areas)
|
|
}
|
|
|
|
@router.post("/definitions")
|
|
def create_focus_area_definition(
|
|
data: FocusAreaCreate,
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Create new focus area definition (Admin only).
|
|
|
|
Note: Requires admin role.
|
|
"""
|
|
# Admin check
|
|
if session.get('role') != 'admin':
|
|
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Check if key already exists
|
|
cur.execute(
|
|
"SELECT id FROM focus_area_definitions WHERE key = %s",
|
|
(data.key,)
|
|
)
|
|
if cur.fetchone():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Focus Area mit Key '{data.key}' existiert bereits"
|
|
)
|
|
|
|
# Insert
|
|
cur.execute("""
|
|
INSERT INTO focus_area_definitions
|
|
(key, name_de, name_en, icon, description, category)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
data.key, data.name_de, data.name_en,
|
|
data.icon, data.description, data.category
|
|
))
|
|
|
|
area_id = cur.fetchone()['id']
|
|
|
|
return {
|
|
"id": area_id,
|
|
"message": f"Focus Area '{data.name_de}' erstellt"
|
|
}
|
|
|
|
@router.put("/definitions/{area_id}")
|
|
def update_focus_area_definition(
|
|
area_id: str,
|
|
data: FocusAreaUpdate,
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""Update focus area definition (Admin only)"""
|
|
# Admin check
|
|
if session.get('role') != 'admin':
|
|
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Build dynamic UPDATE
|
|
updates = []
|
|
values = []
|
|
|
|
if data.name_de is not None:
|
|
updates.append("name_de = %s")
|
|
values.append(data.name_de)
|
|
if data.name_en is not None:
|
|
updates.append("name_en = %s")
|
|
values.append(data.name_en)
|
|
if data.icon is not None:
|
|
updates.append("icon = %s")
|
|
values.append(data.icon)
|
|
if data.description is not None:
|
|
updates.append("description = %s")
|
|
values.append(data.description)
|
|
if data.category is not None:
|
|
updates.append("category = %s")
|
|
values.append(data.category)
|
|
if data.is_active is not None:
|
|
updates.append("is_active = %s")
|
|
values.append(data.is_active)
|
|
|
|
if not updates:
|
|
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
|
|
|
updates.append("updated_at = NOW()")
|
|
values.append(area_id)
|
|
|
|
query = f"""
|
|
UPDATE focus_area_definitions
|
|
SET {', '.join(updates)}
|
|
WHERE id = %s
|
|
RETURNING id
|
|
"""
|
|
|
|
cur.execute(query, values)
|
|
|
|
if not cur.fetchone():
|
|
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
|
|
|
return {"message": "Focus Area aktualisiert"}
|
|
|
|
@router.delete("/definitions/{area_id}")
|
|
def delete_focus_area_definition(
|
|
area_id: str,
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Delete focus area definition (Admin only).
|
|
|
|
Cascades: Deletes all goal_focus_contributions referencing this area.
|
|
"""
|
|
# Admin check
|
|
if session.get('role') != 'admin':
|
|
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Check if area is used
|
|
cur.execute(
|
|
"SELECT COUNT(*) as count FROM goal_focus_contributions WHERE focus_area_id = %s",
|
|
(area_id,)
|
|
)
|
|
count = cur.fetchone()['count']
|
|
|
|
if count > 0:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Focus Area wird von {count} Ziel(en) verwendet. "
|
|
"Bitte erst Zuordnungen entfernen oder auf 'inaktiv' setzen."
|
|
)
|
|
|
|
# Delete
|
|
cur.execute(
|
|
"DELETE FROM focus_area_definitions WHERE id = %s RETURNING id",
|
|
(area_id,)
|
|
)
|
|
|
|
if not cur.fetchone():
|
|
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
|
|
|
return {"message": "Focus Area gelöscht"}
|
|
|
|
# ============================================================================
|
|
# User Focus Preferences
|
|
# ============================================================================
|
|
|
|
@router.get("/user-preferences")
|
|
def get_user_focus_preferences(session: dict = Depends(require_auth)):
|
|
"""
|
|
Get user's focus area weightings (dynamic system).
|
|
|
|
Returns focus areas with user-set weights, grouped by category.
|
|
"""
|
|
pid = session['profile_id']
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Get dynamic preferences (Migration 032)
|
|
try:
|
|
cur.execute("""
|
|
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,))
|
|
|
|
weights = [r2d(row) for row in cur.fetchall()]
|
|
|
|
# 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(
|
|
data: dict,
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Update user's focus area weightings (dynamic system).
|
|
|
|
Expects: { "weights": { "focus_area_id": weight, ... } }
|
|
Weights are relative (0-100), normalized in display only.
|
|
"""
|
|
pid = session['profile_id']
|
|
|
|
if 'weights' not in data:
|
|
raise HTTPException(status_code=400, detail="'weights' field required")
|
|
|
|
weights = data['weights'] # Dict: focus_area_id → weight
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# 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 Area Gewichtungen aktualisiert",
|
|
"count": len([w for w in weights.values() if int(w) > 0])
|
|
}
|
|
|
|
# ============================================================================
|
|
# Stats & Analytics
|
|
# ============================================================================
|
|
|
|
@router.get("/stats")
|
|
def get_focus_area_stats(session: dict = Depends(require_auth)):
|
|
"""
|
|
Get focus area statistics for current user.
|
|
|
|
Returns:
|
|
- Progress per focus area (avg of all contributing goals)
|
|
- Goal count per focus area
|
|
- Top/bottom performing areas
|
|
"""
|
|
pid = session['profile_id']
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
cur.execute("""
|
|
SELECT
|
|
fa.id, fa.key, fa.name_de, fa.icon, fa.category,
|
|
COUNT(DISTINCT gfc.goal_id) as goal_count,
|
|
AVG(g.progress_pct) as avg_progress,
|
|
SUM(gfc.contribution_weight) as total_contribution
|
|
FROM focus_area_definitions fa
|
|
LEFT JOIN goal_focus_contributions gfc ON fa.id = gfc.focus_area_id
|
|
LEFT JOIN goals g ON gfc.goal_id = g.id AND g.profile_id = %s
|
|
WHERE fa.is_active = true
|
|
GROUP BY fa.id
|
|
HAVING COUNT(DISTINCT gfc.goal_id) > 0 -- Only areas with goals
|
|
ORDER BY avg_progress DESC NULLS LAST
|
|
""", (pid,))
|
|
|
|
stats = [r2d(row) for row in cur.fetchall()]
|
|
|
|
return {
|
|
"stats": stats,
|
|
"top_area": stats[0] if stats else None,
|
|
"bottom_area": stats[-1] if len(stats) > 1 else None
|
|
}
|