From d7cefdd9e9eafa510a15faff71ebefa845d6d104 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 22:14:45 +0200 Subject: [PATCH] feat: Update Gitea issues index and enhance data layer metrics - Updated the Gitea issues index to reflect the latest state as of 2026-04-11, adding issue #76 to the list. - Refined data handling in `activity_metrics.py`, `body_metrics.py`, `nutrition_metrics.py`, and `scores.py` to ensure consistent float conversions for calculations, improving accuracy in metric evaluations. - Enhanced the calculation logic for various metrics to handle potential None values more robustly, ensuring smoother data processing and improved reliability across the application. These changes improve the clarity of the Gitea issues documentation and enhance the overall accuracy and reliability of health and fitness metrics. --- .claude/docs/GITEA_ISSUES_INDEX.md | 3 +- backend/data_layer/activity_metrics.py | 29 +++++------ backend/data_layer/body_metrics.py | 51 ++++++++++++------- backend/data_layer/nutrition_metrics.py | 16 +++--- backend/data_layer/scores.py | 22 +++++--- docs/README.md | 1 + ...ue-76-training-quality-goal-list-filter.md | 49 ++++++++++++++++++ 7 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 docs/issues/issue-76-training-quality-goal-list-filter.md diff --git a/.claude/docs/GITEA_ISSUES_INDEX.md b/.claude/docs/GITEA_ISSUES_INDEX.md index 3b711b6..7c44f43 100644 --- a/.claude/docs/GITEA_ISSUES_INDEX.md +++ b/.claude/docs/GITEA_ISSUES_INDEX.md @@ -1,6 +1,6 @@ # Gitea Issues – Landkarte (Auswertung) -**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-09** (Abfrage `state=all`, ergänzt: #71). +**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76). **URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung. @@ -88,6 +88,7 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver | # | Titel | |---|--------| | 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren | +| 76 | Trainings-Qualität: zielbezogene Logik + Listen-Filter statt globalem „Hochwertig“-Hide | | 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error | --- diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index dea96f0..4b57358 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -604,26 +604,23 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - # Activity-related focus areas (English keys from DB) - # Strength training - strength = focus_weights.get('strength', 0) - strength_endurance = focus_weights.get('strength_endurance', 0) - power = focus_weights.get('power', 0) + # Activity-related focus areas (English keys from DB); Gewichte float (kein Decimal×float) + strength = float(focus_weights.get('strength', 0) or 0) + strength_endurance = float(focus_weights.get('strength_endurance', 0) or 0) + power = float(focus_weights.get('power', 0) or 0) total_strength = strength + strength_endurance + power - # Endurance training - aerobic = focus_weights.get('aerobic_endurance', 0) - anaerobic = focus_weights.get('anaerobic_endurance', 0) - cardiovascular = focus_weights.get('cardiovascular_health', 0) + aerobic = float(focus_weights.get('aerobic_endurance', 0) or 0) + anaerobic = float(focus_weights.get('anaerobic_endurance', 0) or 0) + cardiovascular = float(focus_weights.get('cardiovascular_health', 0) or 0) total_cardio = aerobic + anaerobic + cardiovascular - # Mobility/Coordination - flexibility = focus_weights.get('flexibility', 0) - mobility = focus_weights.get('mobility', 0) - balance = focus_weights.get('balance', 0) - reaction = focus_weights.get('reaction', 0) - rhythm = focus_weights.get('rhythm', 0) - coordination = focus_weights.get('coordination', 0) + flexibility = float(focus_weights.get('flexibility', 0) or 0) + mobility = float(focus_weights.get('mobility', 0) or 0) + balance = float(focus_weights.get('balance', 0) or 0) + reaction = float(focus_weights.get('reaction', 0) or 0) + rhythm = float(focus_weights.get('rhythm', 0) or 0) + coordination = float(focus_weights.get('coordination', 0) or 0) total_ability = flexibility + mobility + balance + reaction + rhythm + coordination total_activity_weight = total_strength + total_cardio + total_ability diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 4bfde2b..3fcbaba 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -445,12 +445,16 @@ def calculate_weight_7d_median(profile_id: str) -> Optional[float]: ORDER BY date DESC """, (profile_id,)) - weights = [row['weight'] for row in cur.fetchall()] + weights = [ + safe_float(row['weight']) + for row in cur.fetchall() + if row['weight'] is not None + ] if len(weights) < 4: # Need at least 4 measurements return None - return round(statistics.median(weights), 1) + return round(float(statistics.median(weights)), 1) def calculate_weight_28d_slope(profile_id: str) -> Optional[float]: @@ -478,7 +482,11 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]: ORDER BY date """, (profile_id, days)) - data = [(row['date'], row['weight']) for row in cur.fetchall()] + data = [ + (row['date'], safe_float(row['weight'])) + for row in cur.fetchall() + if row['weight'] is not None + ] # Need minimum data points based on period min_points = max(18, int(days * 0.6)) # 60% coverage @@ -488,21 +496,21 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]: # Convert dates to days since start start_date = data[0][0] x_values = [(date - start_date).days for date, _ in data] - y_values = [weight for _, weight in data] + y_values = [w for _, w in data] - # Linear regression + # Linear regression (alles float: PostgreSQL numeric → Decimal in Python) n = len(data) - x_mean = sum(x_values) / n - y_mean = sum(y_values) / n + x_mean = float(sum(x_values)) / n + y_mean = float(sum(y_values)) / n - numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values)) - denominator = sum((x - x_mean) ** 2 for x in x_values) + numerator = sum(float(x - x_mean) * float(y - y_mean) for x, y in zip(x_values, y_values)) + denominator = float(sum((x - x_mean) ** 2 for x in x_values)) if denominator == 0: return None slope = numerator / denominator - return round(slope, 4) # kg/day + return round(float(slope), 4) # kg/day def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]: @@ -594,19 +602,24 @@ def _calculate_body_composition_change(profile_id: str, metric: str, days: int) recent = data[0] oldest = data[-1] - # Calculate FM and LBM - recent_fm = recent['weight'] * (recent['bf_pct'] / 100) - recent_lbm = recent['weight'] - recent_fm + # Calculate FM and LBM (DB numeric → Decimal; für Regression/Scores nur float) + rw = float(safe_float(recent['weight']) or 0) + ob = float(safe_float(recent['bf_pct']) or 0) + ow = float(safe_float(oldest['weight']) or 0) + obf = float(safe_float(oldest['bf_pct']) or 0) - oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100) - oldest_lbm = oldest['weight'] - oldest_fm + recent_fm = rw * (ob / 100) + recent_lbm = rw - recent_fm + + oldest_fm = ow * (obf / 100) + oldest_lbm = ow - oldest_fm if metric == 'fm': change = recent_fm - oldest_fm else: change = recent_lbm - oldest_lbm - return round(change, 2) + return round(float(change), 2) # ── Circumference Calculations ────────────────────────────────────────────── @@ -731,9 +744,9 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - weight_loss = focus_weights.get('weight_loss', 0) - muscle_gain = focus_weights.get('muscle_gain', 0) - body_recomp = focus_weights.get('body_recomposition', 0) + weight_loss = float(focus_weights.get('weight_loss', 0) or 0) + muscle_gain = float(focus_weights.get('muscle_gain', 0) or 0) + body_recomp = float(focus_weights.get('body_recomposition', 0) or 0) total_body_weight = weight_loss + muscle_gain + body_recomp diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 6c17cf0..7ce9fa7 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -844,14 +844,16 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - # Nutrition-related focus areas (English keys from DB) - protein_intake = focus_weights.get('protein_intake', 0) - calorie_balance = focus_weights.get('calorie_balance', 0) - macro_consistency = focus_weights.get('macro_consistency', 0) - meal_timing = focus_weights.get('meal_timing', 0) - hydration = focus_weights.get('hydration', 0) + # Nutrition-related focus areas (English keys from DB; Gewichte immer float) + protein_intake = float(focus_weights.get('protein_intake', 0) or 0) + calorie_balance = float(focus_weights.get('calorie_balance', 0) or 0) + macro_consistency = float(focus_weights.get('macro_consistency', 0) or 0) + meal_timing = float(focus_weights.get('meal_timing', 0) or 0) + hydration = float(focus_weights.get('hydration', 0) or 0) - total_nutrition_weight = protein_intake + calorie_balance + macro_consistency + meal_timing + hydration + total_nutrition_weight = ( + protein_intake + calorie_balance + macro_consistency + meal_timing + hydration + ) if total_nutrition_weight == 0: return None # No nutrition goals diff --git a/backend/data_layer/scores.py b/backend/data_layer/scores.py index eca5b1f..fd9dd56 100644 --- a/backend/data_layer/scores.py +++ b/backend/data_layer/scores.py @@ -224,8 +224,8 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]: if total_weight == 0: return None - # Normalize to 0-100 - final_score = total_score / total_weight + # Normalize to 0-100 (Explizit float: Zwischensummen können Decimal aus DB sein) + final_score = float(total_score) / float(total_weight) return int(final_score) @@ -533,11 +533,19 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option if not goals: return None - # Weighted average by contribution_weight (Numeric → float) - total_progress = sum( - float(g['progress_pct']) * float(g['contribution_weight']) for g in goals - ) - total_weight = sum(float(g['contribution_weight']) for g in goals) + # Weighted average; progress_pct darf NULL sein (Ziele ohne quantitative Berechnung) + parts: List[tuple] = [] + for g in goals: + pct = g['progress_pct'] + if pct is None: + continue + parts.append((float(pct), float(g['contribution_weight']))) + + if not parts: + return None + + total_progress = sum(p * w for p, w in parts) + total_weight = sum(w for _, w in parts) return int(total_progress / total_weight) if total_weight > 0 else None diff --git a/docs/README.md b/docs/README.md index 9f39c3f..d3c7aea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ Dieser Ordner ist **immer mit Git versioniert**. Er ergänzt **`.claude/docs/`** | `issue-53-phase-0c-multi-layer-architecture.md` | | `issue-54-dynamic-placeholder-system.md` | | `issue-55-dynamic-aggregation-methods.md` | +| `issue-76-training-quality-goal-list-filter.md` | | `PHASE_PLAN_RESPONSIVE_UI.md` | | `REVIEW_OPEN_ISSUES_2026-04-04.md` | | `UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md` | diff --git a/docs/issues/issue-76-training-quality-goal-list-filter.md b/docs/issues/issue-76-training-quality-goal-list-filter.md new file mode 100644 index 0000000..9fe5344 --- /dev/null +++ b/docs/issues/issue-76-training-quality-goal-list-filter.md @@ -0,0 +1,49 @@ +# Issue #76: Trainings-Qualität – Zielbezug + Listen-Filter + +**Gitea:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/76 +**Status:** OFFEN +**Erstellt:** 11.04.2026 +**Typ:** Enhancement (Backend, Frontend, Produkt) +**Verwandt:** #31 (globaler Quality-Filter), #15 (Quality-Filter KI/Charts – historisch) + +--- + +## Kurzfassung + +Die heutige **`quality_label` / `quality_filter_level`**-Logik soll überdacht werden: nicht nur **Akzeptanz** (formales Label), sondern langfristig **Nutzen für die Zielerreichung**. Parallel: der **globale Profil-Filter** in der **Aktivitätsliste** versteckt u. a. **fehlende Einstufungen** (`NULL`) – das erschwert Datenpflege und Transparenz. + +--- + +## Ist-Zustand (technisch) + +| Bereich | Filter nach `quality_filter_level`? | +|---------|--------------------------------------| +| `GET` Aktivitätsliste (`routers/activity.py`) | Ja (`get_quality_filter_sql`) | +| KI-Rohkontext (`routers/insights.py`, `_get_profile_data`) | Ja (letzte 90 Zeilen `activity_log`) | +| Data-Layer / Platzhalter / Chart-Aggregate (`data_layer/activity_metrics.py`) | Nein – alle Einträge im Zeitfenster | + +Bei Stufen ≠ `all` schließt SQL-`IN` typischerweise **`NULL`** und nicht erlaubte Labels aus. + +--- + +## Ziele + +1. **Konzept „Qualität“:** Richtung Ziel-/Focus-/Phasenbezug; Umsetzung kann in Folge-Issues zerlegt werden. +2. **Listendarstellung:** Standard soll **alle** relevanten Einträge zeigen; **optionale lokale** Filter (inkl. „ohne Einstufung“), kein unbemerktes globales Ausblenden. +3. **KI/Insights:** Klarstellen, ob gefilterte vs. vollständige Aktivitätsmenge; ggf. konfigurierbar oder im Prompt kenntlich. +4. **Dokumentation:** Kurz festhalten, wo `get_quality_filter_sql` greift und wie das zu den Aggregationen passt. + +--- + +## Code-Referenzen + +- `backend/quality_filter.py` +- `backend/routers/activity.py` (`list_activity`) +- `backend/routers/insights.py` (`_get_profile_data`) +- `backend/data_layer/activity_metrics.py` + +--- + +## Akzeptanz (aus Gitea, Checkliste) + +Siehe Issue-Body in Gitea #76; bei Umsetzung dort Häkchen setzen und diese Datei ggf. um **Umsetzungsnotizen / Abnahme** ergänzen.