Goalsystem V1 #50

Merged
Lars merged 51 commits from develop into main 2026-03-27 17:40:51 +01:00
5 changed files with 835 additions and 81 deletions
Showing only changes of commit 65ee5f898f - Show all commits

View File

@ -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 module provides:
This allows Phase 0b placeholders to work with the current simple goal_mode system, 1. Abstraction layer between goal modes and focus weights (Phase 1)
while enabling future v2.0 redesign (focus_areas table) without rewriting 120+ placeholders. 2. Universal value fetcher for dynamic goal types (Phase 1.5)
Version History: 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 - 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 from db import get_cursor
@ -158,6 +162,224 @@ def get_focus_description(focus_area: str) -> str:
return descriptions.get(focus_area, focus_area) 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): # Future V2 Implementation (commented out for reference):
""" """
def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]: def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]:

View File

@ -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
);
*/

View File

@ -17,6 +17,7 @@ from decimal import Decimal
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth
from goal_utils import get_current_value_for_goal
router = APIRouter(prefix="/api/goals", tags=["goals"]) router = APIRouter(prefix="/api/goals", tags=["goals"])
@ -62,6 +63,34 @@ class FitnessTestCreate(BaseModel):
test_date: date test_date: date
test_conditions: Optional[str] = None 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 # 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]: 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': DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py.
cur.execute(""" Phase 1.5: All goal types are now defined in goal_type_definitions table.
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
elif goal_type == 'body_fat': Args:
cur.execute(""" conn: Database connection
SELECT body_fat_pct FROM caliper_log profile_id: User's profile ID
WHERE profile_id = %s goal_type: Goal type key (e.g., 'weight', 'meditation_minutes')
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['body_fat_pct']) if row else None
elif goal_type == 'lean_mass': Returns:
# Calculate lean mass: weight - (weight * body_fat_pct / 100) Current value or None
# Need both latest weight and latest body fat percentage """
cur.execute(""" # Delegate to universal fetcher (Phase 1.5)
SELECT weight FROM weight_log return get_current_value_for_goal(conn, profile_id, goal_type)
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
def _update_goal_progress(conn, profile_id: str, goal: dict): def _update_goal_progress(conn, profile_id: str, goal: dict):
"""Update goal progress (modifies goal dict in-place)""" """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 # Placeholder - should use proper norm tables
return None 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"}

View File

@ -24,9 +24,45 @@
## 🔲 Nächste Schritte (Priorität) ## 🔲 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) **Priorität:** HIGH (strategisch kritisch)
**Aufwand:** 16-20h **Aufwand:** 16-20h
**Blockt:** Intelligente KI-Analysen **Blockt:** Intelligente KI-Analysen
@ -98,11 +134,14 @@
|-------|-----|--------|---------| |-------|-----|--------|---------|
| **Phase 0a** | Minimal Goal System | ✅ DONE | 3-4h | | **Phase 0a** | Minimal Goal System | ✅ DONE | 3-4h |
| **Phase 1** | Quick Fixes + Abstraction | ✅ DONE | 4-6h | | **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 | | **Issue #49** | Prompt Page Assignment | 🔲 OPEN | 6-8h |
| **v2.0** | Redesign (Focus Areas) | 📋 LATER | 8-10h | | **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 ## 🔧 Flexibles Goal System - Technische Details
**Nächste Aktualisierung:** Nach Phase 0b Completion
### 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

View File

@ -339,6 +339,12 @@ export const api = {
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)), updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}), 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 // Training Phases
listTrainingPhases: () => req('/goals/phases'), listTrainingPhases: () => req('/goals/phases'),
createTrainingPhase: (d) => req('/goals/phases', json(d)), createTrainingPhase: (d) => req('/goals/phases', json(d)),