feat: filtered goal types - count specific training types
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

NEW FEATURE: Filter conditions for goal types
Enables counting/aggregating specific subsets of data.

Example use case: Count only strength training sessions per week
- Create goal type with filter: {"training_type": "strength"}
- count_7d now counts only strength training, not all activities

Implementation:
- Migration 026: filter_conditions JSONB column
- Backend: Dynamic WHERE clause building from JSON filters
- Supports single value: {"training_type": "strength"}
- Supports multiple values: {"training_type": ["strength", "hiit"]}
- Works with all 8 aggregation methods (count, avg, sum, min, max)
- Frontend: JSON textarea with example + validation
- Pydantic models: filter_conditions field added

Technical details:
- SQL injection safe (parameterized queries)
- Graceful degradation (invalid JSON ignored with warning)
- Backward compatible (NULL filters = no filtering)

Answers user question: 'Kann ich Trainingstypen wie Krafttraining separat zählen?'
Answer: YES! 🎯
This commit is contained in:
Lars 2026-03-27 08:14:22 +01:00
parent 2c978bf948
commit 2303c04123
4 changed files with 143 additions and 24 deletions

View File

@ -181,7 +181,7 @@ def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]:
cur.execute(""" cur.execute("""
SELECT type_key, source_table, source_column, aggregation_method, 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 FROM goal_type_definitions
WHERE type_key = %s AND is_active = true WHERE type_key = %s AND is_active = true
LIMIT 1 LIMIT 1
@ -221,7 +221,8 @@ def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optiona
profile_id, profile_id,
config['source_table'], config['source_table'],
config['source_column'], 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, profile_id: str,
table: str, table: str,
column: str, column: str,
method: str method: str,
filter_conditions: Optional[Any] = None
) -> Optional[float]: ) -> Optional[float]:
""" """
Fetch value using specified aggregation method. Fetch value using specified aggregation method.
@ -244,84 +246,119 @@ def _fetch_by_aggregation_method(
- count_30d: Count of entries in last 30 days - count_30d: Count of entries in last 30 days
- min_30d: Minimum value in last 30 days - min_30d: Minimum value in last 30 days
- max_30d: Maximum 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 # Guard: source_table/column required for simple aggregation
if not table or not column: if not table or not column:
print(f"[WARNING] Missing source_table or source_column for aggregation") print(f"[WARNING] Missing source_table or source_column for aggregation")
return None 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) cur = get_cursor(conn)
try: try:
if method == 'latest': if method == 'latest':
params = [profile_id] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT {column} FROM {table} 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 ORDER BY date DESC LIMIT 1
""", (profile_id,)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row[column]) if row else None return float(row[column]) if row else None
elif method == 'avg_7d': elif method == 'avg_7d':
days_ago = date.today() - timedelta(days=7) days_ago = date.today() - timedelta(days=7)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT AVG({column}) as avg_value FROM {table} SELECT AVG({column}) as avg_value FROM {table}
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['avg_value']) if row and row['avg_value'] is not None else None return float(row['avg_value']) if row and row['avg_value'] is not None else None
elif method == 'avg_30d': elif method == 'avg_30d':
days_ago = date.today() - timedelta(days=30) days_ago = date.today() - timedelta(days=30)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT AVG({column}) as avg_value FROM {table} SELECT AVG({column}) as avg_value FROM {table}
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['avg_value']) if row and row['avg_value'] is not None else None return float(row['avg_value']) if row and row['avg_value'] is not None else None
elif method == 'sum_30d': elif method == 'sum_30d':
days_ago = date.today() - timedelta(days=30) days_ago = date.today() - timedelta(days=30)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT SUM({column}) as sum_value FROM {table} SELECT SUM({column}) as sum_value FROM {table}
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['sum_value']) if row and row['sum_value'] is not None else None return float(row['sum_value']) if row and row['sum_value'] is not None else None
elif method == 'count_7d': elif method == 'count_7d':
days_ago = date.today() - timedelta(days=7) days_ago = date.today() - timedelta(days=7)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT COUNT(*) as count_value FROM {table} SELECT COUNT(*) as count_value FROM {table}
WHERE profile_id = %s AND date >= %s WHERE profile_id = %s AND date >= %s{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['count_value']) if row else 0.0 return float(row['count_value']) if row else 0.0
elif method == 'count_30d': elif method == 'count_30d':
days_ago = date.today() - timedelta(days=30) days_ago = date.today() - timedelta(days=30)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT COUNT(*) as count_value FROM {table} SELECT COUNT(*) as count_value FROM {table}
WHERE profile_id = %s AND date >= %s WHERE profile_id = %s AND date >= %s{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['count_value']) if row else 0.0 return float(row['count_value']) if row else 0.0
elif method == 'min_30d': elif method == 'min_30d':
days_ago = date.today() - timedelta(days=30) days_ago = date.today() - timedelta(days=30)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT MIN({column}) as min_value FROM {table} SELECT MIN({column}) as min_value FROM {table}
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['min_value']) if row and row['min_value'] is not None else None return float(row['min_value']) if row and row['min_value'] is not None else None
elif method == 'max_30d': elif method == 'max_30d':
days_ago = date.today() - timedelta(days=30) days_ago = date.today() - timedelta(days=30)
params = [profile_id, days_ago] + filter_params
cur.execute(f""" cur.execute(f"""
SELECT MAX({column}) as max_value FROM {table} SELECT MAX({column}) as max_value FROM {table}
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
""", (profile_id, days_ago)) """, params)
row = cur.fetchone() row = cur.fetchone()
return float(row['max_value']) if row and row['max_value'] is not None else None return float(row['max_value']) if row and row['max_value'] is not None else None

View File

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

View File

@ -76,6 +76,7 @@ class GoalTypeCreate(BaseModel):
source_column: Optional[str] = None source_column: Optional[str] = None
aggregation_method: Optional[str] = 'latest' aggregation_method: Optional[str] = 'latest'
calculation_formula: Optional[str] = None calculation_formula: Optional[str] = None
filter_conditions: Optional[dict] = None
description: Optional[str] = None description: Optional[str] = None
class GoalTypeUpdate(BaseModel): class GoalTypeUpdate(BaseModel):
@ -89,6 +90,7 @@ class GoalTypeUpdate(BaseModel):
source_column: Optional[str] = None source_column: Optional[str] = None
aggregation_method: Optional[str] = None aggregation_method: Optional[str] = None
calculation_formula: Optional[str] = None calculation_formula: Optional[str] = None
filter_conditions: Optional[dict] = None
description: Optional[str] = None description: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@ -685,17 +687,20 @@ def create_goal_type_definition(
) )
# Insert new goal type # 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(""" cur.execute("""
INSERT INTO goal_type_definitions ( INSERT INTO goal_type_definitions (
type_key, label_de, label_en, unit, icon, category, type_key, label_de, label_en, unit, icon, category,
source_table, source_column, aggregation_method, source_table, source_column, aggregation_method,
calculation_formula, description, is_active, is_system calculation_formula, filter_conditions, description, is_active, is_system
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
data.type_key, data.label_de, data.label_en, data.unit, data.icon, data.type_key, data.label_de, data.label_en, data.unit, data.icon,
data.category, data.source_table, data.source_column, 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 True, False # is_active=True, is_system=False
)) ))
@ -780,6 +785,12 @@ def update_goal_type_definition(
updates.append("calculation_formula = %s") updates.append("calculation_formula = %s")
params.append(data.calculation_formula) 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: if data.description is not None:
updates.append("description = %s") updates.append("description = %s")
params.append(data.description) params.append(data.description)

View File

@ -20,6 +20,7 @@ export default function AdminGoalTypesPage() {
source_table: '', source_table: '',
source_column: '', source_column: '',
aggregation_method: 'latest', aggregation_method: 'latest',
filter_conditions: '',
description: '' description: ''
}) })
@ -75,6 +76,7 @@ export default function AdminGoalTypesPage() {
source_table: '', source_table: '',
source_column: '', source_column: '',
aggregation_method: 'latest', aggregation_method: 'latest',
filter_conditions: '',
description: '' description: ''
}) })
setShowForm(true) setShowForm(true)
@ -91,6 +93,7 @@ export default function AdminGoalTypesPage() {
source_table: type.source_table || '', source_table: type.source_table || '',
source_column: type.source_column || '', source_column: type.source_column || '',
aggregation_method: type.aggregation_method || 'latest', aggregation_method: type.aggregation_method || 'latest',
filter_conditions: type.filter_conditions ? JSON.stringify(type.filter_conditions, null, 2) : '',
description: type.description || '' description: type.description || ''
}) })
setShowForm(true) setShowForm(true)
@ -102,16 +105,29 @@ export default function AdminGoalTypesPage() {
return 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 { try {
if (editingType) { if (editingType) {
await api.updateGoalType(editingType, formData) await api.updateGoalType(editingType, payload)
showToast('✓ Goal Type aktualisiert') showToast('✓ Goal Type aktualisiert')
} else { } else {
if (!formData.type_key) { if (!formData.type_key) {
setError('Bitte eindeutigen Key angeben (z.B. meditation_minutes)') setError('Bitte eindeutigen Key angeben (z.B. meditation_minutes)')
return return
} }
await api.createGoalType(formData) await api.createGoalType(payload)
showToast('✓ Goal Type erstellt') showToast('✓ Goal Type erstellt')
} }
@ -452,6 +468,21 @@ export default function AdminGoalTypesPage() {
</select> </select>
</div> </div>
{/* Filter Conditions */}
<div>
<label className="form-label">Filter (optional, JSON)</label>
<textarea
className="form-input"
style={{ width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: 13 }}
value={formData.filter_conditions}
onChange={e => setFormData(f => ({ ...f, filter_conditions: e.target.value }))}
placeholder={'Beispiel:\n{\n "training_type": "strength"\n}\n\nOder mehrere Werte:\n{\n "training_type": ["strength", "hiit"]\n}'}
/>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>
💡 Filtert Einträge nach Spalten. Beispiel: <code>{`{"training_type": "strength"}`}</code> zählt nur Krafttraining
</div>
</div>
{/* Description */} {/* Description */}
<div> <div>
<label className="form-label">Beschreibung (optional)</label> <label className="form-label">Beschreibung (optional)</label>