diff --git a/backend/calculations/activity_metrics.py b/backend/calculations/activity_metrics.py index 27859e2..c11eabe 100644 --- a/backend/calculations/activity_metrics.py +++ b/backend/calculations/activity_metrics.py @@ -275,7 +275,7 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]: else: intensity = 'moderate' - load = duration * intensity_factors[intensity] * quality_factors.get(quality, 1.0) + load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0) total_load += load return int(total_load) @@ -298,7 +298,7 @@ def calculate_monotony_score(profile_id: str) -> Optional[float]: ORDER BY date """, (profile_id,)) - daily_loads = [row['daily_duration'] for row in cur.fetchall()] + daily_loads = [float(row['daily_duration']) for row in cur.fetchall() if row['daily_duration']] if len(daily_loads) < 4: return None @@ -495,7 +495,7 @@ def calculate_rest_day_compliance(profile_id: str) -> Optional[int]: # Get planned rest days cur.execute(""" - SELECT date, type as rest_type + SELECT date, rest_config->>'focus' as rest_type FROM rest_days WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' @@ -585,8 +585,8 @@ def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]: # Activity entries last 28 days cur.execute(""" SELECT COUNT(*) as total, - COUNT(avg_heart_rate) as with_hr, - COUNT(quality_label) as with_quality + COUNT(hr_avg) as with_hr, + COUNT(rpe) as with_quality FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' diff --git a/backend/calculations/body_metrics.py b/backend/calculations/body_metrics.py index 314557d..5bb6d39 100644 --- a/backend/calculations/body_metrics.py +++ b/backend/calculations/body_metrics.py @@ -225,7 +225,7 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]: def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]: """Calculate 28-day thigh circumference change (cm, average of L/R)""" - left = _calculate_circumference_delta(profile_id, 'c_thigh_l', 28) + left = _calculate_circumference_delta(profile_id, 'c_thigh', 28) right = _calculate_circumference_delta(profile_id, 'c_thigh_r', 28) if left is None or right is None: diff --git a/backend/calculations/recovery_metrics.py b/backend/calculations/recovery_metrics.py index 80b47ea..821c6bc 100644 --- a/backend/calculations/recovery_metrics.py +++ b/backend/calculations/recovery_metrics.py @@ -149,7 +149,7 @@ def _score_rhr_vs_baseline(profile_id: str) -> Optional[int]: SELECT AVG(resting_hr) as recent_rhr FROM vitals_baseline WHERE profile_id = %s - AND resting_heart_rate IS NOT NULL + AND resting_hr IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '3 days' """, (profile_id,)) @@ -164,7 +164,7 @@ def _score_rhr_vs_baseline(profile_id: str) -> Optional[int]: SELECT AVG(resting_heart_rate) as baseline_rhr FROM vitals_baseline WHERE profile_id = %s - AND resting_heart_rate IS NOT NULL + AND resting_hr IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '28 days' AND date < CURRENT_DATE - INTERVAL '3 days' """, (profile_id,)) @@ -339,7 +339,7 @@ def calculate_rhr_vs_baseline_pct(profile_id: str) -> Optional[float]: SELECT AVG(resting_hr) as recent_rhr FROM vitals_baseline WHERE profile_id = %s - AND resting_heart_rate IS NOT NULL + AND resting_hr IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '3 days' """, (profile_id,)) @@ -354,7 +354,7 @@ def calculate_rhr_vs_baseline_pct(profile_id: str) -> Optional[float]: SELECT AVG(resting_heart_rate) as baseline_rhr FROM vitals_baseline WHERE profile_id = %s - AND resting_heart_rate IS NOT NULL + AND resting_hr IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '28 days' AND date < CURRENT_DATE - INTERVAL '3 days' """, (profile_id,)) @@ -378,7 +378,7 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]: FROM sleep_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' - AND total_sleep_min IS NOT NULL + AND duration_minutes IS NOT NULL """, (profile_id,)) row = cur.fetchone() @@ -403,7 +403,7 @@ def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]: FROM sleep_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '14 days' - AND total_sleep_min IS NOT NULL + AND duration_minutes IS NOT NULL ORDER BY date DESC """, (profile_id,)) @@ -473,7 +473,7 @@ def calculate_recent_load_balance_3d(profile_id: str) -> Optional[int]: with get_db() as conn: cur = get_cursor(conn) cur.execute(""" - SELECT SUM(duration) as total_duration + SELECT SUM(duration_min) as total_duration FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '3 days' @@ -499,7 +499,7 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]: FROM sleep_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' - AND total_sleep_min IS NOT NULL + AND duration_minutes IS NOT NULL """, (profile_id,)) sleep_data = cur.fetchall() @@ -555,7 +555,7 @@ def calculate_recovery_data_quality(profile_id: str) -> Dict[str, any]: SELECT COUNT(*) as rhr_count FROM vitals_baseline WHERE profile_id = %s - AND resting_heart_rate IS NOT NULL + AND resting_hr IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) rhr_count = cur.fetchone()['rhr_count'] diff --git a/backend/calculations/scores.py b/backend/calculations/scores.py index 129bae8..3cf146f 100644 --- a/backend/calculations/scores.py +++ b/backend/calculations/scores.py @@ -109,12 +109,12 @@ def calculate_category_weight(profile_id: str, category: str) -> float: with get_db() as conn: cur = get_cursor(conn) cur.execute(""" - SELECT focus_area_id + SELECT key FROM focus_area_definitions WHERE category = %s """, (category,)) - focus_areas = [row['focus_area_id'] for row in cur.fetchall()] + focus_areas = [row['key'] for row in cur.fetchall()] total_weight = sum( focus_weights.get(fa, 0) @@ -446,9 +446,9 @@ def get_top_focus_area(profile_id: str) -> Optional[Dict]: with get_db() as conn: cur = get_cursor(conn) cur.execute(""" - SELECT focus_area_id, label_de, category + SELECT key, name_de, category FROM focus_area_definitions - WHERE focus_area_id = %s + WHERE key = %s """, (top_fa_id,)) fa_def = cur.fetchone() @@ -460,7 +460,7 @@ def get_top_focus_area(profile_id: str) -> Optional[Dict]: return { 'focus_area_id': top_fa_id, - 'label': fa_def['label_de'], + 'label': fa_def['name_de'], 'category': fa_def['category'], 'weight': focus_weights[top_fa_id], 'progress': progress @@ -495,3 +495,46 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option total_weight = sum(g['contribution_weight'] for g in goals) return int(total_progress / total_weight) if total_weight > 0 else None + +def calculate_category_progress(profile_id: str, category: str) -> Optional[int]: + """ + Calculate progress score for a focus area category (0-100). + + Args: + profile_id: User's profile ID + category: Category name ('körper', 'ernährung', 'aktivität', 'recovery', 'vitalwerte', 'mental', 'lebensstil') + + Returns: + Progress score 0-100 or None if no data + """ + # Map category to score calculation functions + category_scores = { + 'körper': 'body_progress_score', + 'ernährung': 'nutrition_score', + 'aktivität': 'activity_score', + 'recovery': 'recovery_score', + 'vitalwerte': 'recovery_score', # Use recovery score as proxy for vitals + 'mental': 'recovery_score', # Use recovery score as proxy for mental (sleep quality) + 'lebensstil': 'data_quality_score', # Use data quality as proxy for lifestyle consistency + } + + score_func_name = category_scores.get(category.lower()) + if not score_func_name: + return None + + # Call the appropriate score function + if score_func_name == 'body_progress_score': + from calculations.body_metrics import calculate_body_progress_score + return calculate_body_progress_score(profile_id) + elif score_func_name == 'nutrition_score': + from calculations.nutrition_metrics import calculate_nutrition_score + return calculate_nutrition_score(profile_id) + elif score_func_name == 'activity_score': + from calculations.activity_metrics import calculate_activity_score + return calculate_activity_score(profile_id) + elif score_func_name == 'recovery_score': + return calculate_recovery_score_v2(profile_id) + elif score_func_name == 'data_quality_score': + return calculate_data_quality_score(profile_id) + + return None diff --git a/backend/goal_utils.py b/backend/goal_utils.py index 421450c..c680b97 100644 --- a/backend/goal_utils.py +++ b/backend/goal_utils.py @@ -13,11 +13,11 @@ Version History: Part of Phase 1 + Phase 1.5: Flexible Goal System """ -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, List from datetime import date, timedelta from decimal import Decimal import json -from db import get_cursor +from db import get_cursor, get_db def get_focus_weights(conn, profile_id: str) -> Dict[str, float]: @@ -516,3 +516,37 @@ def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]: 'health': row['health_pct'] / 100.0 } """ + + +def get_active_goals(profile_id: str) -> List[Dict]: + """ + Get all active goals for a profile. + Returns list of goal dicts with id, type, target_value, current_value, etc. + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, goal_type, target_value, target_date, + current_value, progress_pct, status, is_primary + FROM goals + WHERE profile_id = %s + AND status IN ('active', 'in_progress') + ORDER BY is_primary DESC, created_at DESC + """, (profile_id,)) + + return [dict(row) for row in cur.fetchall()] + + +def get_goal_by_id(goal_id: str) -> Optional[Dict]: + """Get a single goal by ID""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, profile_id, goal_type, target_value, target_date, + current_value, progress_pct, status, is_primary + FROM goals + WHERE id = %s + """, (goal_id,)) + + row = cur.fetchone() + return dict(row) if row else None diff --git a/backend/routers/vitals_baseline.py b/backend/routers/vitals_baseline.py index 3538775..cc8d454 100644 --- a/backend/routers/vitals_baseline.py +++ b/backend/routers/vitals_baseline.py @@ -199,32 +199,31 @@ def update_baseline( # Build SET clause dynamically updates = [] values = [] - idx = 1 if entry.resting_hr is not None: - updates.append(f"resting_hr = ${idx}") + updates.append("resting_hr = %s") values.append(entry.resting_hr) - idx += 1 if entry.hrv is not None: - updates.append(f"hrv = ${idx}") + updates.append("hrv = %s") values.append(entry.hrv) - idx += 1 if entry.vo2_max is not None: - updates.append(f"vo2_max = ${idx}") + updates.append("vo2_max = %s") values.append(entry.vo2_max) - idx += 1 if entry.spo2 is not None: - updates.append(f"spo2 = ${idx}") + updates.append("spo2 = %s") values.append(entry.spo2) - idx += 1 if entry.respiratory_rate is not None: - updates.append(f"respiratory_rate = ${idx}") + updates.append("respiratory_rate = %s") values.append(entry.respiratory_rate) - idx += 1 + if entry.body_temperature is not None: + updates.append("body_temperature = %s") + values.append(entry.body_temperature) + if entry.resting_metabolic_rate is not None: + updates.append("resting_metabolic_rate = %s") + values.append(entry.resting_metabolic_rate) if entry.note: - updates.append(f"note = ${idx}") + updates.append("note = %s") values.append(entry.note) - idx += 1 if not updates: raise HTTPException(400, "No fields to update") @@ -237,7 +236,7 @@ def update_baseline( query = f""" UPDATE vitals_baseline SET {', '.join(updates)} - WHERE id = ${idx} AND profile_id = ${idx + 1} + WHERE id = %s AND profile_id = %s RETURNING * """ cur.execute(query, values)