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.
This commit is contained in:
parent
4868e44882
commit
d7cefdd9e9
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
49
docs/issues/issue-76-training-quality-goal-list-filter.md
Normal file
49
docs/issues/issue-76-training-quality-goal-list-filter.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user