diff --git a/backend/goal_utils.py b/backend/goal_utils.py index 1178005..34fd725 100644 --- a/backend/goal_utils.py +++ b/backend/goal_utils.py @@ -181,7 +181,7 @@ def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]: cur.execute(""" SELECT type_key, source_table, source_column, aggregation_method, - calculation_formula, label_de, unit, icon, category + calculation_formula, filter_conditions, label_de, unit, icon, category FROM goal_type_definitions WHERE type_key = %s AND is_active = true LIMIT 1 @@ -221,7 +221,8 @@ def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optiona profile_id, config['source_table'], config['source_column'], - config['aggregation_method'] + config['aggregation_method'], + config.get('filter_conditions') ) @@ -230,7 +231,8 @@ def _fetch_by_aggregation_method( profile_id: str, table: str, column: str, - method: str + method: str, + filter_conditions: Optional[Any] = None ) -> Optional[float]: """ Fetch value using specified aggregation method. @@ -244,84 +246,119 @@ def _fetch_by_aggregation_method( - 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 + + Args: + filter_conditions: Optional JSON filters (e.g., {"training_type": "strength"}) """ # Guard: source_table/column required for simple aggregation if not table or not column: print(f"[WARNING] Missing source_table or source_column for aggregation") return None + # Build filter SQL from JSON conditions + filter_sql = "" + filter_params = [] + + if filter_conditions: + try: + if isinstance(filter_conditions, str): + filters = json.loads(filter_conditions) + else: + filters = filter_conditions + + for filter_col, filter_val in filters.items(): + if isinstance(filter_val, list): + # IN clause for multiple values + placeholders = ', '.join(['%s'] * len(filter_val)) + filter_sql += f" AND {filter_col} IN ({placeholders})" + filter_params.extend(filter_val) + else: + # Single value equality + filter_sql += f" AND {filter_col} = %s" + filter_params.append(filter_val) + except (json.JSONDecodeError, TypeError, AttributeError) as e: + print(f"[WARNING] Invalid filter_conditions: {e}, ignoring filters") + cur = get_cursor(conn) try: if method == 'latest': + params = [profile_id] + filter_params cur.execute(f""" SELECT {column} FROM {table} - WHERE profile_id = %s AND {column} IS NOT NULL + WHERE profile_id = %s AND {column} IS NOT NULL{filter_sql} ORDER BY date DESC LIMIT 1 - """, (profile_id,)) + """, params) row = cur.fetchone() return float(row[column]) if row else None elif method == 'avg_7d': days_ago = date.today() - timedelta(days=7) + params = [profile_id, days_ago] + filter_params 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)) + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql} + """, params) 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) + params = [profile_id, days_ago] + filter_params 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)) + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql} + """, params) 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) + params = [profile_id, days_ago] + filter_params 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)) + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql} + """, params) 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) + params = [profile_id, days_ago] + filter_params cur.execute(f""" SELECT COUNT(*) as count_value FROM {table} - WHERE profile_id = %s AND date >= %s - """, (profile_id, days_ago)) + WHERE profile_id = %s AND date >= %s{filter_sql} + """, params) row = cur.fetchone() return float(row['count_value']) if row else 0.0 elif method == 'count_30d': days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params cur.execute(f""" SELECT COUNT(*) as count_value FROM {table} - WHERE profile_id = %s AND date >= %s - """, (profile_id, days_ago)) + WHERE profile_id = %s AND date >= %s{filter_sql} + """, params) row = cur.fetchone() return float(row['count_value']) if row else 0.0 elif method == 'min_30d': days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params 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)) + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql} + """, params) 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) + params = [profile_id, days_ago] + filter_params 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)) + WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql} + """, params) row = cur.fetchone() return float(row['max_value']) if row and row['max_value'] is not None else None diff --git a/backend/migrations/026_goal_type_filters.sql b/backend/migrations/026_goal_type_filters.sql new file mode 100644 index 0000000..46a70fa --- /dev/null +++ b/backend/migrations/026_goal_type_filters.sql @@ -0,0 +1,40 @@ +-- Migration 026: Goal Type Filters +-- Date: 2026-03-27 +-- Purpose: Enable filtered counting/aggregation (e.g., count only strength training) + +-- Add filter_conditions column for flexible filtering +ALTER TABLE goal_type_definitions +ADD COLUMN IF NOT EXISTS filter_conditions JSONB; + +COMMENT ON COLUMN goal_type_definitions.filter_conditions IS +'Optional filter conditions as JSON. Example: {"training_type": "strength"} to count only strength training sessions. +Supports any column in the source table. Format: {"column_name": "value"} or {"column_name": ["value1", "value2"]} for IN clause.'; + +-- Example usage (commented out): +/* +-- Count only strength training sessions per week +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + filter_conditions, + description, is_system +) VALUES ( + 'strength_frequency', 'Krafttraining Häufigkeit', 'x/Woche', '🏋️', 'activity', + 'activity_log', 'id', 'count_7d', + '{"training_type": "strength"}', + 'Anzahl Krafttraining-Einheiten pro Woche', false +) ON CONFLICT (type_key) DO NOTHING; + +-- Count only cardio sessions per week +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + filter_conditions, + description, is_system +) VALUES ( + 'cardio_frequency', 'Cardio Häufigkeit', 'x/Woche', '🏃', 'activity', + 'activity_log', 'id', 'count_7d', + '{"training_type": "cardio"}', + 'Anzahl Cardio-Einheiten pro Woche', false +) ON CONFLICT (type_key) DO NOTHING; +*/ diff --git a/backend/routers/goals.py b/backend/routers/goals.py index 76220e2..1f2f4a8 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -76,6 +76,7 @@ class GoalTypeCreate(BaseModel): source_column: Optional[str] = None aggregation_method: Optional[str] = 'latest' calculation_formula: Optional[str] = None + filter_conditions: Optional[dict] = None description: Optional[str] = None class GoalTypeUpdate(BaseModel): @@ -89,6 +90,7 @@ class GoalTypeUpdate(BaseModel): source_column: Optional[str] = None aggregation_method: Optional[str] = None calculation_formula: Optional[str] = None + filter_conditions: Optional[dict] = None description: Optional[str] = None is_active: Optional[bool] = None @@ -685,17 +687,20 @@ def create_goal_type_definition( ) # Insert new goal type + import json as json_lib + filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None + 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 - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + calculation_formula, filter_conditions, description, is_active, is_system + ) VALUES (%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, + data.aggregation_method, data.calculation_formula, filter_json, data.description, True, False # is_active=True, is_system=False )) @@ -780,6 +785,12 @@ def update_goal_type_definition( updates.append("calculation_formula = %s") params.append(data.calculation_formula) + if data.filter_conditions is not None: + import json as json_lib + filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None + updates.append("filter_conditions = %s") + params.append(filter_json) + if data.description is not None: updates.append("description = %s") params.append(data.description) diff --git a/frontend/src/pages/AdminGoalTypesPage.jsx b/frontend/src/pages/AdminGoalTypesPage.jsx index ca8ec55..edc4173 100644 --- a/frontend/src/pages/AdminGoalTypesPage.jsx +++ b/frontend/src/pages/AdminGoalTypesPage.jsx @@ -20,6 +20,7 @@ export default function AdminGoalTypesPage() { source_table: '', source_column: '', aggregation_method: 'latest', + filter_conditions: '', description: '' }) @@ -75,6 +76,7 @@ export default function AdminGoalTypesPage() { source_table: '', source_column: '', aggregation_method: 'latest', + filter_conditions: '', description: '' }) setShowForm(true) @@ -91,6 +93,7 @@ export default function AdminGoalTypesPage() { source_table: type.source_table || '', source_column: type.source_column || '', aggregation_method: type.aggregation_method || 'latest', + filter_conditions: type.filter_conditions ? JSON.stringify(type.filter_conditions, null, 2) : '', description: type.description || '' }) setShowForm(true) @@ -102,16 +105,29 @@ export default function AdminGoalTypesPage() { return } + // Parse filter_conditions from string to JSON + let payload = { ...formData } + if (formData.filter_conditions && formData.filter_conditions.trim()) { + try { + payload.filter_conditions = JSON.parse(formData.filter_conditions) + } catch (e) { + setError('Ungültiges JSON in Filter-Bedingungen') + return + } + } else { + payload.filter_conditions = null + } + try { if (editingType) { - await api.updateGoalType(editingType, formData) + await api.updateGoalType(editingType, payload) showToast('✓ Goal Type aktualisiert') } else { if (!formData.type_key) { setError('Bitte eindeutigen Key angeben (z.B. meditation_minutes)') return } - await api.createGoalType(formData) + await api.createGoalType(payload) showToast('✓ Goal Type erstellt') } @@ -452,6 +468,21 @@ export default function AdminGoalTypesPage() { + {/* Filter Conditions */} +