diff --git a/backend/goal_utils.py b/backend/goal_utils.py index ef99871..e4aee5e 100644 --- a/backend/goal_utils.py +++ b/backend/goal_utils.py @@ -1,18 +1,22 @@ """ -Goal Utilities - Abstraction Layer for Focus Weights +Goal Utilities - Abstraction Layer for Focus Weights & Universal Value Fetcher -This module provides an abstraction layer between goal modes and focus weights. -This allows Phase 0b placeholders to work with the current simple goal_mode system, -while enabling future v2.0 redesign (focus_areas table) without rewriting 120+ placeholders. +This module provides: +1. Abstraction layer between goal modes and focus weights (Phase 1) +2. Universal value fetcher for dynamic goal types (Phase 1.5) Version History: -- V1 (current): Maps goal_mode to predefined weights +- V1 (Phase 1): Maps goal_mode to predefined weights +- V1.5 (Phase 1.5): Universal value fetcher for DB-registry goal types - V2 (future): Reads from focus_areas table with custom user weights -Part of Phase 1: Quick Fixes + Abstraction Layer +Part of Phase 1 + Phase 1.5: Flexible Goal System """ -from typing import Dict +from typing import Dict, Optional, Any +from datetime import date, timedelta +from decimal import Decimal +import json from db import get_cursor @@ -158,6 +162,224 @@ def get_focus_description(focus_area: str) -> str: return descriptions.get(focus_area, focus_area) +# ============================================================================ +# Phase 1.5: Universal Value Fetcher for Dynamic Goal Types +# ============================================================================ + +def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]: + """ + Get goal type configuration from database registry. + + Args: + conn: Database connection + type_key: Goal type key (e.g., 'weight', 'meditation_minutes') + + Returns: + Dict with config or None if not found/inactive + """ + cur = get_cursor(conn) + + cur.execute(""" + SELECT type_key, source_table, source_column, aggregation_method, + calculation_formula, label_de, unit, icon, category + FROM goal_type_definitions + WHERE type_key = %s AND is_active = true + LIMIT 1 + """, (type_key,)) + + return cur.fetchone() + + +def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optional[float]: + """ + Universal value fetcher for any goal type. + + Reads configuration from goal_type_definitions table and executes + appropriate query based on aggregation_method or calculation_formula. + + Args: + conn: Database connection + profile_id: User's profile ID + goal_type: Goal type key (e.g., 'weight', 'meditation_minutes') + + Returns: + Current value as float or None if not available + """ + config = get_goal_type_config(conn, goal_type) + + if not config: + print(f"[WARNING] Goal type '{goal_type}' not found or inactive") + return None + + # Complex calculation (e.g., lean_mass) + if config['calculation_formula']: + return _execute_calculation_formula(conn, profile_id, config['calculation_formula']) + + # Simple aggregation + return _fetch_by_aggregation_method( + conn, + profile_id, + config['source_table'], + config['source_column'], + config['aggregation_method'] + ) + + +def _fetch_by_aggregation_method( + conn, + profile_id: str, + table: str, + column: str, + method: str +) -> Optional[float]: + """ + Fetch value using specified aggregation method. + + Supported methods: + - latest: Most recent value + - avg_7d: 7-day average + - avg_30d: 30-day average + - sum_30d: 30-day sum + - count_7d: Count of entries in last 7 days + - count_30d: Count of entries in last 30 days + - min_30d: Minimum value in last 30 days + - max_30d: Maximum value in last 30 days + """ + cur = get_cursor(conn) + + if method == 'latest': + cur.execute(f""" + SELECT {column} FROM {table} + WHERE profile_id = %s AND {column} IS NOT NULL + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + row = cur.fetchone() + return float(row[column]) if row else None + + elif method == 'avg_7d': + days_ago = date.today() - timedelta(days=7) + cur.execute(f""" + SELECT AVG({column}) as avg_value FROM {table} + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['avg_value']) if row and row['avg_value'] is not None else None + + elif method == 'avg_30d': + days_ago = date.today() - timedelta(days=30) + cur.execute(f""" + SELECT AVG({column}) as avg_value FROM {table} + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['avg_value']) if row and row['avg_value'] is not None else None + + elif method == 'sum_30d': + days_ago = date.today() - timedelta(days=30) + cur.execute(f""" + SELECT SUM({column}) as sum_value FROM {table} + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['sum_value']) if row and row['sum_value'] is not None else None + + elif method == 'count_7d': + days_ago = date.today() - timedelta(days=7) + cur.execute(f""" + SELECT COUNT(*) as count_value FROM {table} + WHERE profile_id = %s AND date >= %s + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['count_value']) if row else 0.0 + + elif method == 'count_30d': + days_ago = date.today() - timedelta(days=30) + cur.execute(f""" + SELECT COUNT(*) as count_value FROM {table} + WHERE profile_id = %s AND date >= %s + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['count_value']) if row else 0.0 + + elif method == 'min_30d': + days_ago = date.today() - timedelta(days=30) + cur.execute(f""" + SELECT MIN({column}) as min_value FROM {table} + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['min_value']) if row and row['min_value'] is not None else None + + elif method == 'max_30d': + days_ago = date.today() - timedelta(days=30) + cur.execute(f""" + SELECT MAX({column}) as max_value FROM {table} + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL + """, (profile_id, days_ago)) + row = cur.fetchone() + return float(row['max_value']) if row and row['max_value'] is not None else None + + else: + print(f"[WARNING] Unknown aggregation method: {method}") + return None + + +def _execute_calculation_formula(conn, profile_id: str, formula_json: str) -> Optional[float]: + """ + Execute complex calculation formula. + + Currently supports: + - lean_mass: weight - (weight * body_fat_pct / 100) + + Future: Parse JSON formula and execute dynamically. + + Args: + conn: Database connection + profile_id: User's profile ID + formula_json: JSON string with calculation config + + Returns: + Calculated value or None + """ + try: + formula = json.loads(formula_json) + calc_type = formula.get('type') + + if calc_type == 'lean_mass': + # Get dependencies + cur = get_cursor(conn) + + cur.execute(""" + SELECT weight FROM weight_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + weight_row = cur.fetchone() + + cur.execute(""" + SELECT body_fat_pct FROM caliper_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + bf_row = cur.fetchone() + + if weight_row and bf_row: + weight = float(weight_row['weight']) + bf_pct = float(bf_row['body_fat_pct']) + lean_mass = weight - (weight * bf_pct / 100.0) + return round(lean_mass, 2) + + return None + + else: + print(f"[WARNING] Unknown calculation type: {calc_type}") + return None + + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e: + print(f"[ERROR] Formula execution failed: {e}, formula={formula_json}") + return None + + # Future V2 Implementation (commented out for reference): """ def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]: diff --git a/backend/migrations/024_goal_type_registry.sql b/backend/migrations/024_goal_type_registry.sql new file mode 100644 index 0000000..a2182e9 --- /dev/null +++ b/backend/migrations/024_goal_type_registry.sql @@ -0,0 +1,179 @@ +-- Migration 024: Goal Type Registry (Flexible Goal System) +-- Date: 2026-03-27 +-- Purpose: Enable dynamic goal types without code changes + +-- ============================================================================ +-- Goal Type Definitions +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS goal_type_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Unique identifier (used in code) + type_key VARCHAR(50) UNIQUE NOT NULL, + + -- Display metadata + label_de VARCHAR(100) NOT NULL, + label_en VARCHAR(100), + unit VARCHAR(20) NOT NULL, + icon VARCHAR(10), + category VARCHAR(50), -- body, mind, activity, nutrition, recovery, custom + + -- Data source configuration + source_table VARCHAR(50), -- Which table to query + source_column VARCHAR(50), -- Which column to fetch + aggregation_method VARCHAR(20), -- How to aggregate: latest, avg_7d, avg_30d, sum_30d, count_7d, count_30d, min_30d, max_30d + + -- Complex calculations (optional) + -- For types like lean_mass that need custom logic + -- JSON format: {"type": "formula", "dependencies": ["weight", "body_fat"], "expression": "..."} + calculation_formula TEXT, + + -- Metadata + description TEXT, + is_active BOOLEAN DEFAULT true, + is_system BOOLEAN DEFAULT false, -- System types cannot be deleted + + -- Audit + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by UUID REFERENCES profiles(id) ON DELETE SET NULL, + updated_by UUID REFERENCES profiles(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_active ON goal_type_definitions(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_category ON goal_type_definitions(category); + +COMMENT ON TABLE goal_type_definitions IS 'Registry of available goal types - allows dynamic goal creation without code changes'; +COMMENT ON COLUMN goal_type_definitions.type_key IS 'Unique key used in code (e.g., weight, meditation_minutes)'; +COMMENT ON COLUMN goal_type_definitions.aggregation_method IS 'latest = most recent value, avg_7d = 7-day average, count_7d = count in last 7 days, etc.'; +COMMENT ON COLUMN goal_type_definitions.calculation_formula IS 'JSON for complex calculations like lean_mass = weight - (weight * bf_pct / 100)'; +COMMENT ON COLUMN goal_type_definitions.is_system IS 'System types are protected from deletion (core functionality)'; + +-- ============================================================================ +-- Seed Data: Migrate existing 8 goal types +-- ============================================================================ + +-- 1. Weight (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'weight', 'Gewicht', 'Weight', 'kg', '⚖️', 'body', + 'weight_log', 'weight', 'latest', + 'Aktuelles Körpergewicht', true +); + +-- 2. Body Fat (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'body_fat', 'Körperfett', 'Body Fat', '%', '📊', 'body', + 'caliper_log', 'body_fat_pct', 'latest', + 'Körperfettanteil aus Caliper-Messung', true +); + +-- 3. Lean Mass (complex - calculation formula) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + calculation_formula, + description, is_system +) VALUES ( + 'lean_mass', 'Muskelmasse', 'Lean Mass', 'kg', '💪', 'body', + '{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}', + 'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', true +); + +-- 4. VO2 Max (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'vo2max', 'VO2Max', 'VO2Max', 'ml/kg/min', '🫁', 'recovery', + 'vitals_baseline', 'vo2_max', 'latest', + 'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', true +); + +-- 5. Resting Heart Rate (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'rhr', 'Ruhepuls', 'Resting Heart Rate', 'bpm', '💓', 'recovery', + 'vitals_baseline', 'resting_hr', 'latest', + 'Ruhepuls morgens vor dem Aufstehen', true +); + +-- 6. Blood Pressure (placeholder - compound goal for v2.0) +-- Currently limited to single value, v2.0 will support systolic/diastolic +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'bp', 'Blutdruck', 'Blood Pressure', 'mmHg', '❤️', 'recovery', + 'blood_pressure_log', 'systolic', 'latest', + 'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', true +); + +-- 7. Strength (placeholder - no data source yet) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'strength', 'Kraft', 'Strength', 'kg', '🏋️', 'activity', + 'Maximalkraft (Platzhalter, Datenquelle in v2.0)', true, false +); + +-- 8. Flexibility (placeholder - no data source yet) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'flexibility', 'Beweglichkeit', 'Flexibility', 'cm', '🤸', 'activity', + 'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', true, false +); + +-- ============================================================================ +-- Example: Future custom goal types (commented out, for reference) +-- ============================================================================ + +/* +-- Meditation Minutes (avg last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'meditation_minutes', 'Meditation', 'min/Tag', '🧘', 'mind', + 'meditation_log', 'duration_minutes', 'avg_7d', + 'Durchschnittliche Meditationsdauer pro Tag (7 Tage)', false +); + +-- Training Frequency (count last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'training_frequency', 'Trainingshäufigkeit', 'x/Woche', '📅', 'activity', + 'activity_log', 'id', 'count_7d', + 'Anzahl Trainingseinheiten pro Woche', false +); + +-- Sleep Quality (avg last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'sleep_quality', 'Schlafqualität', '%', '💤', 'recovery', + 'sleep_log', 'quality_score', 'avg_7d', + 'Durchschnittliche Schlafqualität (Deep+REM Anteil)', false +); +*/ diff --git a/backend/routers/goals.py b/backend/routers/goals.py index a8239ce..7e82907 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -17,6 +17,7 @@ from decimal import Decimal from db import get_db, get_cursor, r2d from auth import require_auth +from goal_utils import get_current_value_for_goal router = APIRouter(prefix="/api/goals", tags=["goals"]) @@ -62,6 +63,34 @@ class FitnessTestCreate(BaseModel): test_date: date test_conditions: Optional[str] = None +class GoalTypeCreate(BaseModel): + """Create custom goal type definition""" + type_key: str + label_de: str + label_en: Optional[str] = None + unit: str + icon: Optional[str] = None + category: Optional[str] = 'custom' + source_table: Optional[str] = None + source_column: Optional[str] = None + aggregation_method: Optional[str] = 'latest' + calculation_formula: Optional[str] = None + description: Optional[str] = None + +class GoalTypeUpdate(BaseModel): + """Update goal type definition""" + label_de: Optional[str] = None + label_en: Optional[str] = None + unit: Optional[str] = None + icon: Optional[str] = None + category: Optional[str] = None + source_table: Optional[str] = None + source_column: Optional[str] = None + aggregation_method: Optional[str] = None + calculation_formula: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + # ============================================================================ # Strategic Layer: Goal Modes # ============================================================================ @@ -405,77 +434,22 @@ def create_fitness_test(data: FitnessTestCreate, session: dict = Depends(require # ============================================================================ def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> Optional[float]: - """Get current value for a goal type from latest data""" - cur = get_cursor(conn) + """ + Get current value for a goal type. - if goal_type == 'weight': - cur.execute(""" - SELECT weight FROM weight_log - WHERE profile_id = %s - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - row = cur.fetchone() - return float(row['weight']) if row else None + DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py. + Phase 1.5: All goal types are now defined in goal_type_definitions table. - elif goal_type == 'body_fat': - cur.execute(""" - SELECT body_fat_pct FROM caliper_log - WHERE profile_id = %s - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - row = cur.fetchone() - return float(row['body_fat_pct']) if row else None + Args: + conn: Database connection + profile_id: User's profile ID + goal_type: Goal type key (e.g., 'weight', 'meditation_minutes') - elif goal_type == 'lean_mass': - # Calculate lean mass: weight - (weight * body_fat_pct / 100) - # Need both latest weight and latest body fat percentage - cur.execute(""" - SELECT weight FROM weight_log - WHERE profile_id = %s - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - weight_row = cur.fetchone() - - cur.execute(""" - SELECT body_fat_pct FROM caliper_log - WHERE profile_id = %s - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - bf_row = cur.fetchone() - - if weight_row and bf_row: - try: - weight = float(weight_row['weight']) - bf_pct = float(bf_row['body_fat_pct']) - lean_mass = weight - (weight * bf_pct / 100.0) - return round(lean_mass, 2) - except (ValueError, TypeError) as e: - print(f"[DEBUG] lean_mass calculation error: {e}, weight={weight_row}, bf={bf_row}") - return None - - # Debug: Log why calculation failed - print(f"[DEBUG] lean_mass calc failed - weight_row: {weight_row is not None}, bf_row: {bf_row is not None}") - return None - - elif goal_type == 'vo2max': - cur.execute(""" - SELECT vo2_max FROM vitals_baseline - WHERE profile_id = %s AND vo2_max IS NOT NULL - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - row = cur.fetchone() - return float(row['vo2_max']) if row else None - - elif goal_type == 'rhr': - cur.execute(""" - SELECT resting_hr FROM vitals_baseline - WHERE profile_id = %s AND resting_hr IS NOT NULL - ORDER BY date DESC LIMIT 1 - """, (profile_id,)) - row = cur.fetchone() - return float(row['resting_hr']) if row else None - - return None + Returns: + Current value or None + """ + # Delegate to universal fetcher (Phase 1.5) + return get_current_value_for_goal(conn, profile_id, goal_type) def _update_goal_progress(conn, profile_id: str, goal: dict): """Update goal progress (modifies goal dict in-place)""" @@ -513,3 +487,258 @@ def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optiona """ # Placeholder - should use proper norm tables return None + +# ============================================================================ +# Goal Type Definitions (Phase 1.5 - Flexible Goal System) +# ============================================================================ + +@router.get("/goal-types") +def list_goal_type_definitions(session: dict = Depends(require_auth)): + """ + Get all active goal type definitions. + + Public endpoint - returns all available goal types for dropdown. + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, description, is_system, + created_at, updated_at + FROM goal_type_definitions + WHERE is_active = true + ORDER BY + CASE + WHEN is_system = true THEN 0 + ELSE 1 + END, + label_de + """) + + return [r2d(row) for row in cur.fetchall()] + +@router.post("/goal-types") +def create_goal_type_definition( + data: GoalTypeCreate, + session: dict = Depends(require_auth) +): + """ + Create custom goal type definition. + + Admin-only endpoint for creating new goal types. + Users with admin role can define custom metrics. + """ + pid = session['profile_id'] + + # Check admin role + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Validate type_key is unique + cur.execute( + "SELECT id FROM goal_type_definitions WHERE type_key = %s", + (data.type_key,) + ) + if cur.fetchone(): + raise HTTPException( + status_code=400, + detail=f"Goal Type '{data.type_key}' existiert bereits" + ) + + # Insert new goal type + cur.execute(""" + INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, description, is_active, is_system, + created_by, updated_by + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.type_key, data.label_de, data.label_en, data.unit, data.icon, + data.category, data.source_table, data.source_column, + data.aggregation_method, data.calculation_formula, data.description, + True, False, # is_active=True, is_system=False + pid, pid + )) + + goal_type_id = cur.fetchone()['id'] + + return { + "id": goal_type_id, + "message": f"Goal Type '{data.label_de}' erstellt" + } + +@router.put("/goal-types/{goal_type_id}") +def update_goal_type_definition( + goal_type_id: str, + data: GoalTypeUpdate, + session: dict = Depends(require_auth) +): + """ + Update goal type definition. + + Admin-only. System goal types can be updated but not deleted. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Check admin role + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Check goal type exists + cur.execute( + "SELECT id FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") + + # Build update query + updates = [] + params = [] + + if data.label_de is not None: + updates.append("label_de = %s") + params.append(data.label_de) + + if data.label_en is not None: + updates.append("label_en = %s") + params.append(data.label_en) + + if data.unit is not None: + updates.append("unit = %s") + params.append(data.unit) + + if data.icon is not None: + updates.append("icon = %s") + params.append(data.icon) + + if data.category is not None: + updates.append("category = %s") + params.append(data.category) + + if data.source_table is not None: + updates.append("source_table = %s") + params.append(data.source_table) + + if data.source_column is not None: + updates.append("source_column = %s") + params.append(data.source_column) + + if data.aggregation_method is not None: + updates.append("aggregation_method = %s") + params.append(data.aggregation_method) + + if data.calculation_formula is not None: + updates.append("calculation_formula = %s") + params.append(data.calculation_formula) + + if data.description is not None: + updates.append("description = %s") + params.append(data.description) + + if data.is_active is not None: + updates.append("is_active = %s") + params.append(data.is_active) + + if not updates: + raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") + + updates.append("updated_at = NOW()") + updates.append("updated_by = %s") + params.append(pid) + params.append(goal_type_id) + + cur.execute( + f"UPDATE goal_type_definitions SET {', '.join(updates)} WHERE id = %s", + tuple(params) + ) + + return {"message": "Goal Type aktualisiert"} + +@router.delete("/goal-types/{goal_type_id}") +def delete_goal_type_definition( + goal_type_id: str, + session: dict = Depends(require_auth) +): + """ + Delete (deactivate) goal type definition. + + Admin-only. System goal types cannot be deleted, only deactivated. + Custom goal types can be fully deleted if no goals reference them. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Check admin role + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Get goal type info + cur.execute( + "SELECT id, type_key, is_system FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + goal_type = cur.fetchone() + + if not goal_type: + raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") + + # Check if any goals use this type + cur.execute( + "SELECT COUNT(*) as count FROM goals WHERE goal_type = %s", + (goal_type['type_key'],) + ) + count = cur.fetchone()['count'] + + if count > 0: + # Deactivate instead of delete + cur.execute( + "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", + (goal_type_id,) + ) + return { + "message": f"Goal Type deaktiviert ({count} Ziele nutzen diesen Typ)" + } + else: + if goal_type['is_system']: + # System types: only deactivate + cur.execute( + "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", + (goal_type_id,) + ) + return {"message": "System Goal Type deaktiviert"} + else: + # Custom types: delete + cur.execute( + "DELETE FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + return {"message": "Goal Type gelöscht"} diff --git a/docs/TODO_GOAL_SYSTEM.md b/docs/TODO_GOAL_SYSTEM.md index 6eda38e..e14ae05 100644 --- a/docs/TODO_GOAL_SYSTEM.md +++ b/docs/TODO_GOAL_SYSTEM.md @@ -24,9 +24,45 @@ ## 🔲 Nächste Schritte (Priorität) -### Phase 0b: Goal-Aware Placeholders (NEXT - 16-20h) +### Phase 1.5: Flexibles Goal System - DB-Registry (NEXT - 8-12h) 🆕 -**Status:** 🔲 OFFEN +**Status:** 🔲 IN ARBEIT +**Priorität:** CRITICAL (blockt Phase 0b) +**Aufwand:** 8-12h +**Entscheidung:** 27.03.2026 - Option B gewählt + +**Problem:** +- Aktuelles System: Hardcoded goal types (nur 8 Typen möglich) +- Jedes neue Ziel braucht Code-Änderung + Deploy +- Zukünftige Ziele (Meditation, Rituale, Planabweichung) nicht möglich + +**Lösung: DB-Registry** +- Goal Types in Datenbank definiert +- Admin UI: Neue Ziele ohne Code erstellen +- Universal Value Fetcher (konfigurierbar) +- User kann eigene Custom-Metriken definieren + +**Tasks:** +- [ ] Migration 024: goal_type_definitions Tabelle +- [ ] Backend: Universal Value Fetcher (_fetch_latest, _fetch_avg, _fetch_count) +- [ ] Backend: CRUD API für Goal Type Definitions +- [ ] Frontend: Dynamisches Goal Types Dropdown +- [ ] Admin UI: Goal Type Management Page +- [ ] Seed Data: 8 existierende Typen migrieren +- [ ] Testing: Alle Goals + Custom Goal erstellen + +**Warum JETZT (vor Phase 0b)?** +- Phase 0b Platzhalter nutzen Goals für Score-Berechnungen +- Flexible Goals → automatisch in Platzhaltern verfügbar +- Später umbauen = 120+ Platzhalter anpassen (Doppelarbeit) + +**Dokumentation:** Siehe unten "Flexibles Goal System Details" + +--- + +### Phase 0b: Goal-Aware Placeholders (NACH 1.5 - 16-20h) + +**Status:** 🔲 WARTET AUF PHASE 1.5 **Priorität:** HIGH (strategisch kritisch) **Aufwand:** 16-20h **Blockt:** Intelligente KI-Analysen @@ -98,11 +134,14 @@ |-------|-----|--------|---------| | **Phase 0a** | Minimal Goal System | ✅ DONE | 3-4h | | **Phase 1** | Quick Fixes + Abstraction | ✅ DONE | 4-6h | -| **Phase 0b** | Goal-Aware Placeholders | 🔲 NEXT | 16-20h | +| **Phase 1.5** | 🆕 **Flexibles Goal System (DB-Registry)** | 🔲 IN ARBEIT | 8-12h | +| **Phase 0b** | Goal-Aware Placeholders | 🔲 BLOCKED | 16-20h | | **Issue #49** | Prompt Page Assignment | 🔲 OPEN | 6-8h | | **v2.0** | Redesign (Focus Areas) | 📋 LATER | 8-10h | -**Total Roadmap:** ~37-48h bis vollständiges intelligentes Goal System +**Total Roadmap:** ~45-60h bis vollständiges intelligentes Goal System + +**KRITISCH:** Phase 1.5 MUSS vor Phase 0b abgeschlossen sein, sonst Doppelarbeit! --- @@ -140,5 +179,84 @@ get_focus_weights(conn, profile_id) --- -**Letzte Aktualisierung:** 27. März 2026 -**Nächste Aktualisierung:** Nach Phase 0b Completion +## 🔧 Flexibles Goal System - Technische Details + +### Architektur: DB-Registry Pattern + +**Vorher (Phase 0a/1):** +```javascript +// Frontend: Hardcoded +const GOAL_TYPES = { + weight: { label: 'Gewicht', unit: 'kg', icon: '⚖️' } +} + +// Backend: Hardcoded if/elif +if goal_type == 'weight': + cur.execute("SELECT weight FROM weight_log...") +elif goal_type == 'body_fat': + cur.execute("SELECT body_fat_pct FROM caliper_log...") +``` + +**Nachher (Phase 1.5):** +```sql +-- Datenbank: Konfigurierbare Goal Types +CREATE TABLE goal_type_definitions ( + type_key VARCHAR(50) UNIQUE, + label_de VARCHAR(100), + unit VARCHAR(20), + icon VARCHAR(10), + category VARCHAR(50), + source_table VARCHAR(50), + source_column VARCHAR(50), + aggregation_method VARCHAR(20), -- latest, avg_7d, count_7d, etc. + calculation_formula TEXT, -- JSON für komplexe Berechnungen + is_system BOOLEAN -- System-Typen nicht löschbar +); +``` + +```python +# Backend: Universal Fetcher +def get_current_value_for_goal(conn, profile_id, goal_type): + """Liest Config aus DB, führt Query aus""" + config = get_goal_type_config(conn, goal_type) + + if config['calculation_formula']: + return execute_formula(conn, profile_id, config['calculation_formula']) + else: + return fetch_by_method( + conn, profile_id, + config['source_table'], + config['source_column'], + config['aggregation_method'] + ) +``` + +```javascript +// Frontend: Dynamisch +const goalTypes = await api.getGoalTypeDefinitions() +// Lädt aktuell verfügbare Typen von API +``` + +### Vorteile: + +**Flexibilität:** +- ✅ Neue Ziele via Admin UI (KEIN Code-Deploy) +- ✅ User kann Custom-Metriken definieren +- ✅ Zukünftige Module automatisch integriert + +**Beispiele neuer Ziele:** +- 🧘 Meditation (min/Tag) → `meditation_log.duration_minutes`, avg_7d +- 📅 Trainingshäufigkeit (x/Woche) → `activity_log.id`, count_7d +- 📊 Planabweichung (%) → `activity_log.planned_vs_actual`, avg_30d +- 🎯 Ritual-Adherence (%) → `rituals_log.completed`, avg_30d +- 💤 Schlafqualität (%) → `sleep_log.quality_score`, avg_7d + +**Integration mit Phase 0b:** +- Platzhalter nutzen `get_current_value_for_goal()` → automatisch alle Typen verfügbar +- Neue Ziele → sofort in KI-Analysen nutzbar +- Keine Platzhalter-Anpassungen nötig + +--- + +**Letzte Aktualisierung:** 27. März 2026 (Phase 1.5 gestartet) +**Nächste Aktualisierung:** Nach Phase 1.5 Completion diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index d380289..92c3e41 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -339,6 +339,12 @@ export const api = { updateGoal: (id,d) => req(`/goals/${id}`, jput(d)), deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}), + // Goal Type Definitions (Phase 1.5) + listGoalTypeDefinitions: () => req('/goals/goal-types'), + createGoalType: (d) => req('/goals/goal-types', json(d)), + updateGoalType: (id,d) => req(`/goals/goal-types/${id}`, jput(d)), + deleteGoalType: (id) => req(`/goals/goal-types/${id}`, {method:'DELETE'}), + // Training Phases listTrainingPhases: () => req('/goals/phases'), createTrainingPhase: (d) => req('/goals/phases', json(d)),