Goalsystem V1 #50
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
40
backend/migrations/026_goal_type_filters.sql
Normal file
40
backend/migrations/026_goal_type_filters.sql
Normal 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;
|
||||||
|
*/
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user