V 0.9h dynamic focus area system #51

Merged
Lars merged 11 commits from develop into main 2026-03-27 21:14:40 +01:00
11 changed files with 1718 additions and 244 deletions

View File

@ -23,7 +23,7 @@ from routers import user_restrictions, access_grants, training_types, admin_trai
from routers import admin_activity_mappings, sleep, rest_days
from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored
from routers import evaluation # v9d/v9e Training Type Profiles (#15)
from routers import goals # v9e Goal System (Strategic + Tactical)
from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas)
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -99,6 +99,7 @@ app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Ph
app.include_router(blood_pressure.router) # /api/blood-pressure/* (v9d Phase 2d Refactored)
app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15)
app.include_router(goals.router) # /api/goals/* (v9e Goal System Strategic + Tactical)
app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic)
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,254 @@
-- Migration 031: Focus Area System v2.0
-- Date: 2026-03-27
-- Purpose: Dynamic, extensible focus areas with Many-to-Many goal contributions
-- ============================================================================
-- Part 1: New Tables
-- ============================================================================
-- Focus Area Definitions (dynamic, user-extensible)
CREATE TABLE IF NOT EXISTS focus_area_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'strength', 'aerobic_endurance'
name_de VARCHAR(100) NOT NULL,
name_en VARCHAR(100),
icon VARCHAR(10),
description TEXT,
category VARCHAR(50), -- 'body_composition', 'training', 'endurance', 'coordination', 'mental', 'recovery', 'health'
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_focus_area_key ON focus_area_definitions(key);
CREATE INDEX idx_focus_area_category ON focus_area_definitions(category);
COMMENT ON TABLE focus_area_definitions IS 'Dynamic focus area registry - defines all available focus dimensions';
COMMENT ON COLUMN focus_area_definitions.key IS 'Unique identifier for programmatic access';
COMMENT ON COLUMN focus_area_definitions.category IS 'Grouping for UI display';
-- Many-to-Many: Goals contribute to Focus Areas
CREATE TABLE IF NOT EXISTS goal_focus_contributions (
goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
contribution_weight DECIMAL(5,2) DEFAULT 100.00 CHECK (contribution_weight >= 0 AND contribution_weight <= 100),
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (goal_id, focus_area_id)
);
CREATE INDEX idx_gfc_goal ON goal_focus_contributions(goal_id);
CREATE INDEX idx_gfc_focus_area ON goal_focus_contributions(focus_area_id);
COMMENT ON TABLE goal_focus_contributions IS 'Maps goals to focus areas with contribution weights (0-100%)';
COMMENT ON COLUMN goal_focus_contributions.contribution_weight IS 'How much this goal contributes to the focus area (0-100%)';
-- ============================================================================
-- Part 2: Rename existing focus_areas table
-- ============================================================================
-- Old focus_areas table becomes user_focus_preferences
ALTER TABLE focus_areas RENAME TO user_focus_preferences;
-- Add reference to new focus_area_definitions (for future use)
ALTER TABLE user_focus_preferences ADD COLUMN IF NOT EXISTS notes TEXT;
COMMENT ON TABLE user_focus_preferences IS 'User-specific focus area weightings (legacy flat structure + new references)';
-- ============================================================================
-- Part 3: Seed Data - Basis Focus Areas
-- ============================================================================
INSERT INTO focus_area_definitions (key, name_de, name_en, icon, category, description) VALUES
-- Body Composition
('weight_loss', 'Gewichtsverlust', 'Weight Loss', '📉', 'body_composition', 'Körpergewicht reduzieren'),
('muscle_gain', 'Muskelaufbau', 'Muscle Gain', '💪', 'body_composition', 'Muskelmasse aufbauen'),
('body_recomposition', 'Body Recomposition', 'Body Recomposition', '⚖️', 'body_composition', 'Gleichzeitig Fett abbauen und Muskeln aufbauen'),
-- Training - Kraft
('strength', 'Maximalkraft', 'Strength', '🏋️', 'training', 'Maximale Kraftfähigkeit'),
('strength_endurance', 'Kraftausdauer', 'Strength Endurance', '💪🏃', 'training', 'Kraft über längere Zeit aufrechterhalten'),
('power', 'Schnellkraft', 'Power', '', 'training', 'Kraft in kurzer Zeit entfalten'),
-- Training - Beweglichkeit
('flexibility', 'Beweglichkeit', 'Flexibility', '🤸', 'training', 'Gelenkigkeit und Bewegungsumfang'),
('mobility', 'Mobilität', 'Mobility', '🦴', 'training', 'Aktive Beweglichkeit und Kontrolle'),
-- Ausdauer
('aerobic_endurance', 'Aerobe Ausdauer', 'Aerobic Endurance', '🫁', 'endurance', 'VO2Max, lange moderate Belastung'),
('anaerobic_endurance', 'Anaerobe Ausdauer', 'Anaerobic Endurance', '', 'endurance', 'Laktattoleranz, kurze intensive Belastung'),
('cardiovascular_health', 'Herz-Kreislauf', 'Cardiovascular Health', '❤️', 'endurance', 'Herzgesundheit und Ausdauer'),
-- Koordination
('balance', 'Gleichgewicht', 'Balance', '⚖️', 'coordination', 'Statisches und dynamisches Gleichgewicht'),
('reaction', 'Reaktionsfähigkeit', 'Reaction', '', 'coordination', 'Schnelligkeit der Reaktion auf Reize'),
('rhythm', 'Rhythmusgefühl', 'Rhythm', '🎵', 'coordination', 'Zeitliche Abstimmung von Bewegungen'),
('coordination', 'Koordination', 'Coordination', '🎯', 'coordination', 'Zusammenspiel verschiedener Bewegungen'),
-- Mental
('stress_resistance', 'Stressresistenz', 'Stress Resistance', '🧘', 'mental', 'Umgang mit mentalem und physischem Stress'),
('concentration', 'Konzentration', 'Concentration', '🎯', 'mental', 'Fokussierung und Aufmerksamkeit'),
('willpower', 'Willenskraft', 'Willpower', '💎', 'mental', 'Durchhaltevermögen und Selbstdisziplin'),
('mental_health', 'Mentale Gesundheit', 'Mental Health', '🧠', 'mental', 'Psychisches Wohlbefinden'),
-- Recovery
('sleep_quality', 'Schlafqualität', 'Sleep Quality', '😴', 'recovery', 'Erholsamer Schlaf'),
('regeneration', 'Regeneration', 'Regeneration', '♻️', 'recovery', 'Körperliche Erholung'),
('rest', 'Ruhe', 'Rest', '🛌', 'recovery', 'Aktive und passive Erholung'),
-- Health
('metabolic_health', 'Stoffwechselgesundheit', 'Metabolic Health', '🔥', 'health', 'Blutzucker, Insulin, Stoffwechsel'),
('blood_pressure', 'Blutdruck', 'Blood Pressure', '❤️‍🩹', 'health', 'Gesunder Blutdruck'),
('hrv', 'Herzratenvariabilität', 'HRV', '💓', 'health', 'Autonomes Nervensystem'),
('general_health', 'Allgemeine Gesundheit', 'General Health', '🏥', 'health', 'Vitale Gesundheit und Wohlbefinden')
ON CONFLICT (key) DO NOTHING;
-- ============================================================================
-- Part 4: Auto-Mapping - Bestehende Goals zu Focus Areas
-- ============================================================================
-- Helper function to get focus_area_id by key
CREATE OR REPLACE FUNCTION get_focus_area_id(area_key VARCHAR)
RETURNS UUID AS $$
BEGIN
RETURN (SELECT id FROM focus_area_definitions WHERE key = area_key LIMIT 1);
END;
$$ LANGUAGE plpgsql;
-- Weight goals → weight_loss (100%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, get_focus_area_id('weight_loss'), 100.00
FROM goals g
WHERE g.goal_type = 'weight'
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Body Fat goals → weight_loss (60%) + body_recomposition (40%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'weight_loss' THEN 60.00
WHEN 'body_recomposition' THEN 40.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'body_fat'
AND fa.key IN ('weight_loss', 'body_recomposition')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Lean Mass goals → muscle_gain (70%) + body_recomposition (30%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'muscle_gain' THEN 70.00
WHEN 'body_recomposition' THEN 30.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'lean_mass'
AND fa.key IN ('muscle_gain', 'body_recomposition')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Strength goals → strength (70%) + muscle_gain (30%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'strength' THEN 70.00
WHEN 'muscle_gain' THEN 30.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'strength'
AND fa.key IN ('strength', 'muscle_gain')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Flexibility goals → flexibility (100%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, get_focus_area_id('flexibility'), 100.00
FROM goals g
WHERE g.goal_type = 'flexibility'
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- VO2Max goals → aerobic_endurance (80%) + cardiovascular_health (20%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'aerobic_endurance' THEN 80.00
WHEN 'cardiovascular_health' THEN 20.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'vo2max'
AND fa.key IN ('aerobic_endurance', 'cardiovascular_health')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Resting Heart Rate goals → cardiovascular_health (100%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, get_focus_area_id('cardiovascular_health'), 100.00
FROM goals g
WHERE g.goal_type = 'rhr'
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Blood Pressure goals → blood_pressure (80%) + cardiovascular_health (20%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'blood_pressure' THEN 80.00
WHEN 'cardiovascular_health' THEN 20.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'bp'
AND fa.key IN ('blood_pressure', 'cardiovascular_health')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- HRV goals → hrv (70%) + stress_resistance (30%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'hrv' THEN 70.00
WHEN 'stress_resistance' THEN 30.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'hrv'
AND fa.key IN ('hrv', 'stress_resistance')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Sleep Quality goals → sleep_quality (100%)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, get_focus_area_id('sleep_quality'), 100.00
FROM goals g
WHERE g.goal_type = 'sleep_quality'
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Training Frequency goals → general catch-all (strength + endurance)
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
SELECT g.id, fa.id,
CASE fa.key
WHEN 'strength' THEN 40.00
WHEN 'aerobic_endurance' THEN 40.00
WHEN 'general_health' THEN 20.00
END
FROM goals g
CROSS JOIN focus_area_definitions fa
WHERE g.goal_type = 'training_frequency'
AND fa.key IN ('strength', 'aerobic_endurance', 'general_health')
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
-- Cleanup helper function
DROP FUNCTION IF EXISTS get_focus_area_id(VARCHAR);
-- ============================================================================
-- Summary
-- ============================================================================
COMMENT ON TABLE focus_area_definitions IS
'v2.0: Dynamic focus areas - replaces hardcoded 6-dimension system.
26 base areas across 7 categories. User-extensible via admin UI.';
COMMENT ON TABLE goal_focus_contributions IS
'Many-to-Many mapping: Goals contribute to multiple focus areas with weights.
Auto-mapped from goal_type, editable by user.';
COMMENT ON TABLE user_focus_preferences IS
'Legacy flat structure (weight_loss_pct, muscle_gain_pct, etc.) remains for backward compatibility.
Future: Use focus_area_definitions + dynamic preferences.';

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

@ -0,0 +1,378 @@
"""
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
}

View File

@ -39,6 +39,11 @@ class FocusAreasUpdate(BaseModel):
flexibility_pct: int
health_pct: int
class FocusContribution(BaseModel):
"""Focus area contribution (v2.0)"""
focus_area_id: str
contribution_weight: float = 100.0 # 0-100%
class GoalCreate(BaseModel):
"""Create or update a concrete goal"""
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
@ -50,6 +55,7 @@ class GoalCreate(BaseModel):
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
name: Optional[str] = None
description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
class GoalUpdate(BaseModel):
"""Update existing goal"""
@ -61,6 +67,7 @@ class GoalUpdate(BaseModel):
priority: Optional[int] = None # 1=high, 2=medium, 3=low
name: Optional[str] = None
description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
class TrainingPhaseCreate(BaseModel):
"""Create training phase (manual or auto-detected)"""
@ -194,17 +201,32 @@ def get_focus_areas(session: dict = Depends(require_auth)):
with get_db() as conn:
cur = get_cursor(conn)
# Try to get custom focus areas
cur.execute("""
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct,
created_at, updated_at
FROM focus_areas
WHERE profile_id = %s AND active = true
LIMIT 1
""", (pid,))
row = cur.fetchone()
# Try to get custom focus areas (user_focus_preferences after Migration 031)
try:
cur.execute("""
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct,
created_at, updated_at
FROM user_focus_preferences
WHERE profile_id = %s
LIMIT 1
""", (pid,))
row = cur.fetchone()
except Exception as e:
# Migration 031 not applied yet, try old table name
print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}")
try:
cur.execute("""
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
endurance_pct, flexibility_pct, health_pct,
created_at, updated_at
FROM focus_areas
WHERE profile_id = %s AND active = true
LIMIT 1
""", (pid,))
row = cur.fetchone()
except:
row = None
if row:
return {
@ -429,6 +451,17 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
goal_id = cur.fetchone()['id']
# v2.0: Insert focus area contributions
if data.focus_contributions:
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
ON CONFLICT (goal_id, focus_area_id) DO UPDATE
SET contribution_weight = EXCLUDED.contribution_weight
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
return {"id": goal_id, "message": "Ziel erstellt"}
@router.put("/{goal_id}")
@ -492,16 +525,33 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
updates.append("description = %s")
params.append(data.description)
if not updates:
# Handle focus_contributions separately (can be updated even if no other changes)
if data.focus_contributions is not None:
# Delete existing contributions
cur.execute(
"DELETE FROM goal_focus_contributions WHERE goal_id = %s",
(goal_id,)
)
# Insert new contributions
for contrib in data.focus_contributions:
cur.execute("""
INSERT INTO goal_focus_contributions
(goal_id, focus_area_id, contribution_weight)
VALUES (%s, %s, %s)
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
if not updates and data.focus_contributions is None:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
if updates:
updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params)
)
cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params)
)
return {"message": "Ziel aktualisiert"}
@ -680,13 +730,50 @@ def get_goals_grouped(session: dict = Depends(require_auth)):
goals = cur.fetchall()
# Group by category
# v2.0: Load focus_contributions for each goal
goal_ids = [g['id'] for g in goals]
focus_map = {} # goal_id → [contributions]
if goal_ids:
try:
placeholders = ','.join(['%s'] * len(goal_ids))
cur.execute(f"""
SELECT
gfc.goal_id, gfc.contribution_weight,
fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category
FROM goal_focus_contributions gfc
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
WHERE gfc.goal_id IN ({placeholders})
ORDER BY gfc.contribution_weight DESC
""", tuple(goal_ids))
for row in cur.fetchall():
gid = row['goal_id']
if gid not in focus_map:
focus_map[gid] = []
focus_map[gid].append({
'focus_area_id': row['focus_area_id'],
'key': row['key'],
'name_de': row['name_de'],
'icon': row['icon'],
'category': row['category'],
'contribution_weight': float(row['contribution_weight'])
})
except Exception as e:
# Migration 031 not yet applied - focus_contributions tables don't exist
print(f"[WARNING] Could not load focus_contributions: {e}")
# Continue without focus_contributions (backward compatible)
# Group by category and attach focus_contributions
grouped = {}
for goal in goals:
cat = goal['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(r2d(goal))
goal_dict = r2d(goal)
goal_dict['focus_contributions'] = focus_map.get(goal['id'], [])
grouped[cat].append(goal_dict)
return grouped

View File

@ -140,63 +140,66 @@ def create_vitals(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth)
):
"""Create or update vitals entry (upsert)."""
"""
Create or update vitals entry (upsert).
Post-Migration-015: Routes to vitals_baseline (for RHR, HRV, etc.)
Note: BP measurements should use /api/blood-pressure endpoint instead.
"""
pid = get_pid(x_profile_id, session)
# Validation: at least one vital must be provided
has_data = any([
entry.resting_hr, entry.hrv, entry.blood_pressure_systolic,
entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2,
entry.respiratory_rate
# Validation: at least one baseline vital must be provided
has_baseline = any([
entry.resting_hr, entry.hrv, entry.vo2_max,
entry.spo2, entry.respiratory_rate
])
if not has_data:
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden")
if not has_baseline:
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden (RHR, HRV, VO2Max, SpO2, oder Atemfrequenz)")
with get_db() as conn:
cur = get_cursor(conn)
# Upsert: insert or update if date already exists
# Upsert into vitals_baseline (Migration 015)
cur.execute(
"""
INSERT INTO vitals_log (
INSERT INTO vitals_baseline (
profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual')
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'manual')
ON CONFLICT (profile_id, date)
DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr),
hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv),
blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic),
blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic),
pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse),
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max),
spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2),
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate),
irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat),
possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib),
note = COALESCE(EXCLUDED.note, vitals_log.note),
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr),
hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv),
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max),
spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2),
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate),
note = COALESCE(EXCLUDED.note, vitals_baseline.note),
updated_at = CURRENT_TIMESTAMP
RETURNING id, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source, created_at, updated_at
""",
(pid, entry.date, entry.resting_hr, entry.hrv,
entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.pulse,
entry.vo2_max, entry.spo2, entry.respiratory_rate,
entry.irregular_heartbeat, entry.possible_afib,
entry.note)
)
row = cur.fetchone()
conn.commit()
logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}")
return r2d(row)
logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}")
# Return in legacy format for backward compatibility
result = r2d(row)
result['blood_pressure_systolic'] = None
result['blood_pressure_diastolic'] = None
result['pulse'] = None
result['irregular_heartbeat'] = None
result['possible_afib'] = None
return result
@router.put("/{vitals_id}")

View File

@ -32,6 +32,7 @@ import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage'
@ -192,6 +193,7 @@ function AppShell() {
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>

View File

@ -0,0 +1,475 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
import { api } from '../utils/api'
const CATEGORIES = [
{ value: 'body_composition', label: 'Körperzusammensetzung' },
{ value: 'training', label: 'Training' },
{ value: 'endurance', label: 'Ausdauer' },
{ value: 'coordination', label: 'Koordination' },
{ value: 'mental', label: 'Mental' },
{ value: 'recovery', label: 'Erholung' },
{ value: 'health', label: 'Gesundheit' },
{ value: 'custom', label: 'Eigene' }
]
export default function AdminFocusAreasPage() {
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showInactive, setShowInactive] = useState(false)
const [editingId, setEditingId] = useState(null)
const [creating, setCreating] = useState(false)
const [formData, setFormData] = useState({
key: '',
name_de: '',
name_en: '',
icon: '',
description: '',
category: 'custom'
})
useEffect(() => {
loadData()
}, [showInactive])
const loadData = async () => {
try {
setLoading(true)
const result = await api.listFocusAreaDefinitions(showInactive)
setData(result)
setError(null)
} catch (err) {
console.error('Failed to load focus areas:', err)
setError(err.message)
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!formData.key || !formData.name_de) {
setError('Key und Name (DE) sind erforderlich')
return
}
try {
await api.createFocusAreaDefinition(formData)
setCreating(false)
setFormData({
key: '',
name_de: '',
name_en: '',
icon: '',
description: '',
category: 'custom'
})
await loadData()
} catch (err) {
setError(err.message)
}
}
const handleUpdate = async (id) => {
try {
const area = data.areas.find(a => a.id === id)
await api.updateFocusAreaDefinition(id, {
name_de: area.name_de,
name_en: area.name_en,
icon: area.icon,
description: area.description,
category: area.category,
is_active: area.is_active
})
setEditingId(null)
await loadData()
} catch (err) {
setError(err.message)
}
}
const handleDelete = async (id) => {
if (!confirm('Focus Area wirklich löschen?')) return
try {
await api.deleteFocusAreaDefinition(id)
await loadData()
} catch (err) {
setError(err.message)
}
}
const handleToggleActive = async (id) => {
const area = data.areas.find(a => a.id === id)
try {
await api.updateFocusAreaDefinition(id, {
is_active: !area.is_active
})
await loadData()
} catch (err) {
setError(err.message)
}
}
const updateField = (id, field, value) => {
setData(prev => ({
...prev,
areas: prev.areas.map(a =>
a.id === id ? { ...a, [field]: value } : a
)
}))
}
if (loading) {
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
return (
<div style={{ padding: 16, paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16
}}>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
🎯 Focus Areas ({data.total})
</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn-secondary"
onClick={() => setShowInactive(!showInactive)}
style={{ padding: '8px 12px', fontSize: 13 }}
>
{showInactive ? <Eye size={14} /> : <EyeOff size={14} />}
{showInactive ? 'Inaktive ausblenden' : 'Inaktive anzeigen'}
</button>
<button
className="btn-primary"
onClick={() => setCreating(true)}
style={{ padding: '8px 16px' }}
>
<Plus size={16} /> Neue Focus Area
</button>
</div>
</div>
{error && (
<div style={{
padding: 12,
background: '#FEE2E2',
color: '#991B1B',
borderRadius: 8,
marginBottom: 16,
fontSize: 14
}}>
{error}
</div>
)}
{/* Create Form */}
{creating && (
<div className="card" style={{ marginBottom: 16, background: 'var(--accent-light)' }}>
<h3 style={{ fontSize: 16, marginBottom: 12, color: 'var(--accent)' }}>
Neue Focus Area
</h3>
<div style={{ display: 'grid', gap: 12 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Key (Eindeutig, z.B. "explosive_power")
</label>
<input
className="form-input"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="explosive_power"
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Name (Deutsch) *
</label>
<input
className="form-input"
value={formData.name_de}
onChange={(e) => setFormData({ ...formData, name_de: e.target.value })}
placeholder="Explosivkraft"
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Name (English)
</label>
<input
className="form-input"
value={formData.name_en}
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
placeholder="Explosive Power"
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Icon (Emoji)
</label>
<input
className="form-input"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
placeholder="💥"
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Kategorie
</label>
<select
className="form-input"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
style={{ width: '100%' }}
>
{CATEGORIES.map(cat => (
<option key={cat.value} value={cat.value}>{cat.label}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Beschreibung
</label>
<textarea
className="form-input"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kraft in kürzester Zeit explosiv entfalten"
rows={2}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-primary" onClick={handleCreate} style={{ flex: 1 }}>
<Save size={14} /> Erstellen
</button>
<button
className="btn-secondary"
onClick={() => {
setCreating(false)
setFormData({
key: '',
name_de: '',
name_en: '',
icon: '',
description: '',
category: 'custom'
})
}}
style={{ flex: 1 }}
>
<X size={14} /> Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Grouped Areas */}
{Object.entries(data.grouped).map(([category, areas]) => (
<div key={category} style={{ marginBottom: 24 }}>
<h2 style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text2)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 12
}}>
{CATEGORIES.find(c => c.value === category)?.label || category} ({areas.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{areas.map(area => {
const isEditing = editingId === area.id
return (
<div
key={area.id}
className="card"
style={{
opacity: area.is_active ? 1 : 0.5,
borderLeft: area.is_active
? '4px solid var(--accent)'
: '4px solid var(--border)'
}}
>
{isEditing ? (
<div style={{ display: 'grid', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Name (DE)
</label>
<input
className="form-input"
value={area.name_de}
onChange={(e) => updateField(area.id, 'name_de', e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Icon
</label>
<input
className="form-input"
value={area.icon || ''}
onChange={(e) => updateField(area.id, 'icon', e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
Beschreibung
</label>
<textarea
className="form-input"
value={area.description || ''}
onChange={(e) => updateField(area.id, 'description', e.target.value)}
rows={2}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn-primary"
onClick={() => handleUpdate(area.id)}
style={{ flex: 1 }}
>
<Save size={14} /> Speichern
</button>
<button
className="btn-secondary"
onClick={() => {
setEditingId(null)
loadData()
}}
style={{ flex: 1 }}
>
<X size={14} /> Abbrechen
</button>
</div>
</div>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12
}}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 16,
fontWeight: 600,
marginBottom: 4,
display: 'flex',
alignItems: 'center',
gap: 8
}}>
{area.icon && <span>{area.icon}</span>}
<span>{area.name_de}</span>
{!area.is_active && (
<span style={{
fontSize: 11,
padding: '2px 6px',
background: 'var(--border)',
borderRadius: 4,
color: 'var(--text3)'
}}>
Inaktiv
</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 4 }}>
Key: <code style={{
background: 'var(--surface2)',
padding: '2px 4px',
borderRadius: 4
}}>
{area.key}
</code>
</div>
{area.description && (
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4 }}>
{area.description}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn-secondary"
onClick={() => handleToggleActive(area.id)}
style={{ padding: '6px 12px' }}
title={area.is_active ? 'Deaktivieren' : 'Aktivieren'}
>
{area.is_active ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
className="btn-secondary"
onClick={() => setEditingId(area.id)}
style={{ padding: '6px 12px' }}
title="Bearbeiten"
>
<Pencil size={14} />
</button>
<button
className="btn-secondary"
onClick={() => handleDelete(area.id)}
style={{ padding: '6px 12px', color: '#DC2626' }}
title="Löschen"
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
))}
{data.areas.length === 0 && (
<div style={{
padding: 40,
textAlign: 'center',
color: 'var(--text3)'
}}>
Keine Focus Areas vorhanden
</div>
)}
</div>
)
}

View File

@ -485,6 +485,23 @@ export default function AdminPanel() {
</Link>
</div>
</div>
{/* Focus Areas Section */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Focus Areas (v9g)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Focus Area Definitionen: Dynamisches, erweiterbares System mit 26+ Bereichen über 7 Kategorien.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/focus-areas">
<button className="btn btn-secondary btn-full">
🎯 Focus Areas verwalten
</button>
</Link>
</div>
</div>
</div>
)
}

View File

@ -57,20 +57,16 @@ const getCategoryForGoalType = (goalType) => {
export default function GoalsPage() {
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 [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: All available focus areas (for selection)
const [focusAreasGrouped, setFocusAreasGrouped] = useState({}) // Grouped by category
const [showGoalForm, setShowGoalForm] = useState(false)
const [editingGoal, setEditingGoal] = useState(null)
const [showProgressModal, setShowProgressModal] = useState(false)
@ -95,7 +91,8 @@ export default function GoalsPage() {
unit: 'kg',
target_date: '',
name: '',
description: ''
description: '',
focus_contributions: [] // v2.0: [{focus_area_id, contribution_weight}]
})
useEffect(() => {
@ -106,31 +103,31 @@ export default function GoalsPage() {
setLoading(true)
setError(null)
try {
const [modeData, goalsData, groupedData, typesData, focusData] = 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 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 || {})
setFocusAreas(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 = {}
@ -148,6 +145,12 @@ export default function GoalsPage() {
setGoalTypes(typesData || [])
setGoalTypesMap(typesMap)
// v2.0: All focus area definitions (for selection in goal form)
if (focusAreasData) {
setFocusAreas(focusAreasData.areas || [])
setFocusAreasGrouped(focusAreasData.grouped || {})
}
} catch (err) {
console.error('Failed to load goals:', err)
setError(`Fehler beim Laden: ${err.message || err.toString()}`)
@ -161,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.')
@ -188,7 +180,8 @@ export default function GoalsPage() {
unit: goalTypesMap[firstType]?.unit || 'kg',
target_date: '',
name: '',
description: ''
description: '',
focus_contributions: [] // v2.0: Empty for new goal
})
setShowGoalForm(true)
}
@ -204,7 +197,8 @@ export default function GoalsPage() {
unit: goal.unit,
target_date: goal.target_date || '',
name: goal.name || '',
description: goal.description || ''
description: goal.description || '',
focus_contributions: goal.focus_contributions || [] // v2.0: Load existing contributions
})
setShowGoalForm(true)
}
@ -234,7 +228,8 @@ export default function GoalsPage() {
unit: formData.unit,
target_date: formData.target_date || null,
name: formData.name || null,
description: formData.description || null
description: formData.description || null,
focus_contributions: formData.focus_contributions || [] // v2.0: Focus area assignments
}
console.log('[DEBUG] Saving goal:', { editingGoal, data })
@ -391,175 +386,158 @@ export default function GoalsPage() {
</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 && focusAreas && (
<button
className="btn-secondary"
onClick={() => {
setFocusTemp(focusAreas) // 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.
{focusAreas && !focusAreas.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>
</>
) : 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 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>
@ -652,6 +630,38 @@ export default function GoalsPage() {
</span>
</div>
{/* Focus Area Badges (v2.0) */}
{goal.focus_contributions && goal.focus_contributions.length > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
marginBottom: 12
}}>
{goal.focus_contributions.map(fc => (
<span
key={fc.focus_area_id}
style={{
fontSize: 11,
padding: '3px 8px',
background: 'var(--accent-light)',
color: 'var(--accent-dark)',
borderRadius: 4,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4
}}
title={`${fc.name_de}: ${fc.contribution_weight}%`}
>
{fc.icon && <span>{fc.icon}</span>}
<span>{fc.name_de}</span>
<span style={{ opacity: 0.7 }}>({fc.contribution_weight}%)</span>
</span>
))}
</div>
)}
<div style={{ display: 'flex', gap: 20, marginBottom: 12, fontSize: 14, flexWrap: 'wrap' }}>
<div>
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
@ -848,6 +858,191 @@ export default function GoalsPage() {
/>
</div>
{/* Focus Areas (v2.0) */}
<div style={{ marginBottom: 16 }}>
<label style={{
display: 'block',
fontSize: 13,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
🎯 Zahlt ein auf (Fokusbereiche)
</label>
<div style={{
fontSize: 12,
color: 'var(--text3)',
marginBottom: 8
}}>
Wähle die Bereiche aus, auf die dieses Ziel einzahlt. Mehrfachauswahl möglich.
</div>
{Object.keys(focusAreasGrouped).length === 0 ? (
<div style={{
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
fontSize: 13,
color: 'var(--text3)'
}}>
Keine Focus Areas verfügbar
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{Object.entries(focusAreasGrouped).map(([category, areas]) => {
// Filter to only show focus areas the user has weighted
const userWeightedAreaIds = new Set(userFocusWeights.map(w => w.id))
const filteredAreas = areas.filter(area => userWeightedAreaIds.has(area.id))
// Skip category if no weighted areas
if (filteredAreas.length === 0) return null
return (
<div key={category}>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 6
}}>
{category}
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6
}}>
{filteredAreas.map(area => {
const isSelected = formData.focus_contributions?.some(
fc => fc.focus_area_id === area.id
)
return (
<button
key={area.id}
type="button"
onClick={() => {
if (isSelected) {
// Remove
setFormData(f => ({
...f,
focus_contributions: f.focus_contributions.filter(
fc => fc.focus_area_id !== area.id
)
}))
} else {
// Add with default weight 100%
setFormData(f => ({
...f,
focus_contributions: [
...(f.focus_contributions || []),
{
focus_area_id: area.id,
contribution_weight: 100
}
]
}))
}
}}
style={{
padding: '6px 12px',
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
color: isSelected ? 'white' : 'var(--text2)',
border: isSelected ? '2px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 8,
fontSize: 13,
fontWeight: isSelected ? 600 : 400,
cursor: 'pointer',
transition: 'all 0.15s',
fontFamily: 'var(--font)',
display: 'flex',
alignItems: 'center',
gap: 4
}}
>
{area.icon && <span>{area.icon}</span>}
<span>{area.name_de}</span>
</button>
)
})}
</div>
</div>
)
})}
</div>
)}
{/* Selected areas with weights */}
{formData.focus_contributions && formData.focus_contributions.length > 0 && (
<div style={{
marginTop: 12,
padding: 12,
background: 'var(--accent-light)',
borderRadius: 8,
border: '1px solid var(--accent)'
}}>
<div style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--accent-dark)',
marginBottom: 8
}}>
Gewichtung ({formData.focus_contributions.length} ausgewählt)
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{formData.focus_contributions.map((fc, idx) => {
const area = focusAreas.find(a => a.id === fc.focus_area_id)
if (!area) return null
return (
<div key={fc.focus_area_id} style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{
flex: 1,
fontSize: 13,
fontWeight: 500,
color: 'var(--accent-dark)'
}}>
{area.icon} {area.name_de}
</div>
<input
type="number"
min="0"
max="100"
step="5"
value={fc.contribution_weight}
onChange={(e) => {
const newWeight = parseFloat(e.target.value) || 0
setFormData(f => ({
...f,
focus_contributions: f.focus_contributions.map((item, i) =>
i === idx ? { ...item, contribution_weight: newWeight } : item
)
}))
}}
style={{
width: 70,
padding: '4px 8px',
fontSize: 13,
textAlign: 'center',
border: '1px solid var(--accent)',
borderRadius: 6
}}
/>
<span style={{ fontSize: 12, color: 'var(--accent-dark)' }}>%</span>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Zielwert */}
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>
🎯 Zielwert

View File

@ -365,4 +365,13 @@ export const api = {
// Fitness Tests
listFitnessTests: () => req('/goals/tests'),
createFitnessTest: (d) => req('/goals/tests', json(d)),
// Focus Areas (v2.0)
listFocusAreaDefinitions: (includeInactive=false) => req(`/focus-areas/definitions?include_inactive=${includeInactive}`),
createFocusAreaDefinition: (d) => req('/focus-areas/definitions', json(d)),
updateFocusAreaDefinition: (id,d) => req(`/focus-areas/definitions/${id}`, jput(d)),
deleteFocusAreaDefinition: (id) => req(`/focus-areas/definitions/${id}`, {method:'DELETE'}),
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
getFocusAreaStats: () => req('/focus-areas/stats'),
}