feat: Update nutrition metrics and energy balance calculations
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced a single TDEE calculation based on current weight, replacing the fixed 2500 kcal value.
- Updated `get_energy_balance_data` to use daily totals for intake calculations and improved energy balance logic.
- Enhanced `get_nutrition_average_data` to calculate averages over calendar days instead of raw log entries.
- Adjusted placeholder resolution to ensure consistent metadata usage across requests.
- Fixed issues in the charts router to reflect the new energy balance logic and TDEE calculations.

These changes improve the accuracy of nutritional assessments and streamline data handling in the application.
This commit is contained in:
Lars 2026-04-11 19:04:27 +02:00
parent 549c31431e
commit 61a5bb39ae
7 changed files with 305 additions and 231 deletions

View File

@ -5,7 +5,9 @@ Folgende Ergebnisse des Tests:
- In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben. - In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben.
- Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren. - Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren.
- Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um. - Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um.
- Die Workflows werden aktuell nicht in Analyse und den verfügbaren KI-Asuwertungen angezeigt. ggf. weil wir sie aktuell noch keinem Bereich zuordnen können. Diesen könnten wir ggf. über die Start-Node im Workflow konfigurieren.
- Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen - Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen
- Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!) - Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!)
- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen - Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen
- Exportieren aller KI-Prompts/Templates/Workflows im Admin --> KI-Prompts führt zu einem "internal Server Error", Importieren konnte daraufhin nicht getestet werden
- Das duplizieren von Workflows funktioniert nicht
-

View File

@ -105,6 +105,16 @@ frontend/src/
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6) - **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
- **Follow-ups:** **Gitea #71** Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71)) - **Follow-ups:** **Gitea #71** Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
### Updates (11.04.2026 - Placeholder Phase A)
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (48 Keys) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
### Updates (11.04.2026 - Ernährung: eine TDEE-/Tageslogik)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz = **aktuelles Gewicht × 32,5 kcal/kg** (`estimate_tdee_kcal_from_latest_weight`); `get_energy_balance_data` und `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen** (nicht Rohzeilen). Makro-Durchschnitte über **Tagesmittel**; `protein_adequacy_28d`, `macro_consistency_score`, `get_protein_adequacy_data`, `get_macro_consistency_data` auf **Kalendertag** umgestellt. Entfernt: festes **2500 kcal** in `get_energy_balance_data`.
- **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**.
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05) ### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md`**P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen. Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md`**P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.

View File

@ -25,6 +25,28 @@ from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from data_layer.utils import calculate_confidence, safe_float, safe_int from data_layer.utils import calculate_confidence, safe_float, safe_int
# Single TDEE rule for placeholders, charts, and warnings (kcal/day = kg * factor).
# Replaces legacy fixed 2500 kcal so all consumers stay aligned.
TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5
def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]:
"""
Estimated TDEE (kcal/day) from latest body weight.
Returns None if no weight on record.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT weight FROM weight_log
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
(profile_id,),
)
row = cur.fetchone()
if not row or row["weight"] is None:
return None
return float(row["weight"]) * TDEE_KCAL_PER_KG_BODYWEIGHT
def get_nutrition_average_data( def get_nutrition_average_data(
profile_id: str, profile_id: str,
@ -56,20 +78,29 @@ def get_nutrition_average_data(
cur = get_cursor(conn) cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Mean over calendar days (per-day sums), not over raw log rows.
cur.execute( cur.execute(
"""SELECT """SELECT
AVG(kcal) as kcal_avg, AVG(daily_kcal) AS kcal_avg,
AVG(protein_g) as protein_avg, AVG(daily_protein) AS protein_avg,
AVG(carbs_g) as carbs_avg, AVG(daily_carbs) AS carbs_avg,
AVG(fat_g) as fat_avg, AVG(daily_fat) AS fat_avg,
COUNT(*) as data_points COUNT(*)::int AS day_count
FROM nutrition_log FROM (
WHERE profile_id=%s AND date >= %s""", SELECT date,
(profile_id, cutoff) COALESCE(SUM(kcal), 0)::float AS daily_kcal,
COALESCE(SUM(protein_g), 0)::float AS daily_protein,
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
COALESCE(SUM(fat_g), 0)::float AS daily_fat
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
GROUP BY date
) AS daily""",
(profile_id, cutoff),
) )
row = cur.fetchone() row = cur.fetchone()
if not row or row['data_points'] == 0: if not row or row["day_count"] == 0:
return { return {
"kcal_avg": 0.0, "kcal_avg": 0.0,
"protein_avg": 0.0, "protein_avg": 0.0,
@ -80,7 +111,7 @@ def get_nutrition_average_data(
"days_analyzed": days "days_analyzed": days
} }
data_points = row['data_points'] data_points = row["day_count"]
confidence = calculate_confidence(data_points, days, "general") confidence = calculate_confidence(data_points, days, "general")
return { return {
@ -190,79 +221,73 @@ def get_energy_balance_data(
days: int = 7 days: int = 7
) -> Dict: ) -> Dict:
""" """
Calculate energy balance (intake - estimated expenditure). Energy balance (intake - estimated expenditure), kcal/day.
Note: This is a simplified calculation. Intake: mean of daily total kcal (sum per calendar day).
For accurate TDEE, use profile-based calculations. TDEE: latest weight (kg) * TDEE_KCAL_PER_KG_BODYWEIGHT (same rule as placeholders).
Args:
profile_id: User profile ID
days: Analysis window (default 7)
Returns:
{
"energy_balance": float, # kcal/day (negative = deficit)
"avg_intake": float,
"estimated_tdee": float,
"status": str, # "deficit" | "surplus" | "maintenance"
"confidence": str,
"days_analyzed": int,
"data_points": int
}
""" """
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Get average intake
cur.execute( cur.execute(
"""SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt """SELECT date, SUM(kcal)::float AS daily_kcal
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""", WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
(profile_id, cutoff) GROUP BY date
ORDER BY date""",
(profile_id, cutoff),
) )
row = cur.fetchone() daily_rows = cur.fetchall()
if not row or row['cnt'] == 0:
return {
"energy_balance": 0.0,
"avg_intake": 0.0,
"estimated_tdee": 0.0,
"status": "unknown",
"confidence": "insufficient",
"days_analyzed": days,
"data_points": 0
}
avg_intake = safe_float(row['avg_kcal'])
data_points = row['cnt']
# Simple TDEE estimation (this should be improved with profile data)
# For now, use a rough estimate: 2500 kcal for average adult
estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity)
energy_balance = avg_intake - estimated_tdee
# Determine status
if energy_balance < -200:
status = "deficit"
elif energy_balance > 200:
status = "surplus"
else:
status = "maintenance"
confidence = calculate_confidence(data_points, days, "general")
if not daily_rows:
return { return {
"energy_balance": energy_balance, "energy_balance": 0.0,
"avg_intake": 0.0,
"estimated_tdee": 0.0,
"status": "unknown",
"confidence": "insufficient",
"days_analyzed": days,
"data_points": 0,
}
daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows]
avg_intake = sum(daily_totals) / len(daily_totals)
data_points = len(daily_totals)
estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
if estimated_tdee is None:
return {
"energy_balance": 0.0,
"avg_intake": avg_intake, "avg_intake": avg_intake,
"estimated_tdee": estimated_tdee, "estimated_tdee": 0.0,
"status": status, "status": "unknown",
"confidence": confidence, "confidence": "insufficient",
"days_analyzed": days, "days_analyzed": days,
"data_points": data_points "data_points": data_points
} }
energy_balance = avg_intake - estimated_tdee
if energy_balance < -200:
status = "deficit"
elif energy_balance > 200:
status = "surplus"
else:
status = "maintenance"
confidence = calculate_confidence(data_points, days, "general")
return {
"energy_balance": energy_balance,
"avg_intake": avg_intake,
"estimated_tdee": estimated_tdee,
"status": status,
"confidence": confidence,
"days_analyzed": days,
"data_points": data_points
}
def get_protein_adequacy_data( def get_protein_adequacy_data(
profile_id: str, profile_id: str,
@ -291,7 +316,6 @@ def get_protein_adequacy_data(
"confidence": str "confidence": str
} }
""" """
# Get protein targets
targets = get_protein_targets_data(profile_id) targets = get_protein_targets_data(profile_id)
with get_db() as conn: with get_db() as conn:
@ -299,60 +323,55 @@ def get_protein_adequacy_data(
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute( cur.execute(
"""SELECT """SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
AVG(protein_g) as avg_protein,
COUNT(*) as cnt,
SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""", WHERE profile_id=%s AND date >= %s
(targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff) GROUP BY date""",
(profile_id, cutoff),
) )
row = cur.fetchone() rows = cur.fetchall()
if not row or row['cnt'] == 0:
return {
"adequacy_score": 0,
"avg_protein_g": 0.0,
"target_protein_low": targets['protein_target_low'],
"target_protein_high": targets['protein_target_high'],
"protein_g_per_kg": 0.0,
"days_in_target": 0,
"days_with_data": 0,
"confidence": "insufficient"
}
avg_protein = safe_float(row['avg_protein'])
days_with_data = row['cnt']
days_in_target = row['days_in_target']
protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0
# Calculate adequacy score
# 100 = always in target range
# Scale based on percentage of days in target + average relative to target
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
# Bonus/penalty for average protein level
target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
# Weighted score: 70% target days, 30% average level
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100
confidence = calculate_confidence(days_with_data, days, "general")
if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0:
return { return {
"adequacy_score": adequacy_score, "adequacy_score": 0,
"avg_protein_g": avg_protein, "avg_protein_g": 0.0,
"target_protein_low": targets['protein_target_low'], "target_protein_low": targets['protein_target_low'],
"target_protein_high": targets['protein_target_high'], "target_protein_high": targets['protein_target_high'],
"protein_g_per_kg": protein_g_per_kg, "protein_g_per_kg": 0.0,
"days_in_target": days_in_target, "days_in_target": 0,
"days_with_data": days_with_data, "days_with_data": 0,
"confidence": confidence "confidence": "insufficient"
} }
daily_totals = [safe_float(r["daily_protein"]) for r in rows]
days_with_data = len(daily_totals)
low = targets["protein_target_low"]
high = targets["protein_target_high"]
days_in_target = sum(1 for d in daily_totals if low <= d <= high)
avg_protein = sum(daily_totals) / days_with_data
protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
target_mid = (low + high) / 2
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
adequacy_score = max(0, min(100, adequacy_score))
confidence = calculate_confidence(days_with_data, days, "general")
return {
"adequacy_score": adequacy_score,
"avg_protein_g": avg_protein,
"target_protein_low": targets['protein_target_low'],
"target_protein_high": targets['protein_target_high'],
"protein_g_per_kg": protein_g_per_kg,
"days_in_target": days_in_target,
"days_with_data": days_with_data,
"confidence": confidence
}
def get_macro_consistency_data( def get_macro_consistency_data(
profile_id: str, profile_id: str,
@ -387,16 +406,18 @@ def get_macro_consistency_data(
cur.execute( cur.execute(
"""SELECT """SELECT
protein_g, carbs_g, fat_g, kcal COALESCE(SUM(kcal), 0)::float AS kcal,
COALESCE(SUM(protein_g), 0)::float AS protein_g,
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
COALESCE(SUM(fat_g), 0)::float AS fat_g
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s WHERE profile_id=%s AND date >= %s
AND date >= %s GROUP BY date
AND protein_g IS NOT NULL HAVING COALESCE(SUM(kcal), 0) > 0
AND carbs_g IS NOT NULL AND COALESCE(SUM(protein_g), 0) > 0
AND fat_g IS NOT NULL AND COALESCE(SUM(carbs_g), 0) > 0
AND kcal > 0 AND COALESCE(SUM(fat_g), 0) > 0""",
ORDER BY date""", (profile_id, cutoff),
(profile_id, cutoff)
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -413,7 +434,6 @@ def get_macro_consistency_data(
"data_points": len(rows) "data_points": len(rows)
} }
# Calculate macro percentages for each day
import statistics import statistics
protein_pcts = [] protein_pcts = []
@ -425,7 +445,6 @@ def get_macro_consistency_data(
if total_kcal == 0: if total_kcal == 0:
continue continue
# Convert grams to kcal (protein=4, carbs=4, fat=9)
protein_kcal = safe_float(row['protein_g']) * 4 protein_kcal = safe_float(row['protein_g']) * 4
carbs_kcal = safe_float(row['carbs_g']) * 4 carbs_kcal = safe_float(row['carbs_g']) * 4
fat_kcal = safe_float(row['fat_g']) * 9 fat_kcal = safe_float(row['fat_g']) * 9
@ -491,50 +510,15 @@ def get_macro_consistency_data(
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]: def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
""" """
Calculate 7-day average energy balance (kcal/day) 7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7).
Positive = surplus, Negative = deficit
Migration from Phase 0b:
Used by placeholders that need single balance value
""" """
with get_db() as conn: data = get_energy_balance_data(profile_id, 7)
cur = get_cursor(conn) if data["data_points"] < 4:
cur.execute(""" return None
SELECT kcal tdee = data.get("estimated_tdee") or 0
FROM nutrition_log if tdee <= 0:
WHERE profile_id = %s return None
AND date >= CURRENT_DATE - INTERVAL '7 days' return round(float(data["energy_balance"]), 0)
ORDER BY date DESC
""", (profile_id,))
calories = [row['kcal'] for row in cur.fetchall()]
if len(calories) < 4: # Need at least 4 days
return None
avg_intake = float(sum(calories) / len(calories))
# Get estimated TDEE (simplified - could use Harris-Benedict)
# For now, use weight-based estimate
cur.execute("""
SELECT weight
FROM weight_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT 1
""", (profile_id,))
weight_row = cur.fetchone()
if not weight_row:
return None
# Simple TDEE estimate: bodyweight (kg) × 30-35
# TODO: Improve with activity level, age, gender
estimated_tdee = float(weight_row['weight']) * 32.5
balance = avg_intake - estimated_tdee
return round(balance, 0)
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]: def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
@ -654,15 +638,14 @@ def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, t
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]: def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
""" """
Protein adequacy score 0-100 (last 28 days) Protein adequacy score 0-100 (last 28 days).
Based on consistency and target achievement Uses per-calendar-day total protein vs. average weight in the window (g/kg per day).
""" """
import statistics import statistics
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Get average weight (28d)
cur.execute(""" cur.execute("""
SELECT AVG(weight) as avg_weight SELECT AVG(weight) as avg_weight
FROM weight_log FROM weight_log
@ -676,38 +659,29 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
weight = float(weight_row['avg_weight']) weight = float(weight_row['avg_weight'])
# Get protein intake (28d)
cur.execute(""" cur.execute("""
SELECT protein_g SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
FROM nutrition_log FROM nutrition_log
WHERE profile_id = %s WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days' AND date >= CURRENT_DATE - INTERVAL '28 days'
AND protein_g IS NOT NULL GROUP BY date
""", (profile_id,)) """, (profile_id,))
protein_values = [float(row['protein_g']) for row in cur.fetchall()] daily_totals = [float(row['daily_protein']) for row in cur.fetchall()]
if len(protein_values) < 18: # 60% coverage if len(daily_totals) < 18:
return None return None
# Calculate metrics protein_per_kg_values = [p / weight for p in daily_totals]
protein_per_kg_values = [p / weight for p in protein_values]
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values) avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
# Target range: 1.6-2.2 g/kg for active individuals
target_mid = 1.9
# Score based on distance from target
if 1.6 <= avg_protein_per_kg <= 2.2: if 1.6 <= avg_protein_per_kg <= 2.2:
base_score = 100 base_score = 100
elif avg_protein_per_kg < 1.6: elif avg_protein_per_kg < 1.6:
# Below target
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40)) base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
else: else:
# Above target (less penalty)
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10)) base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
# Consistency bonus/penalty
std_dev = statistics.stdev(protein_per_kg_values) std_dev = statistics.stdev(protein_per_kg_values)
if std_dev < 0.3: if std_dev < 0.3:
consistency_bonus = 10 consistency_bonus = 10
@ -723,20 +697,24 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]: def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
""" """
Macro consistency score 0-100 (last 28 days) Macro consistency score 0-100 (last 28 days).
Lower variability = higher score CV of daily totals (kcal and macros), not raw log rows.
""" """
import statistics import statistics
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute(""" cur.execute("""
SELECT kcal, protein_g, fat_g, carbs_g SELECT
COALESCE(SUM(kcal), 0)::float AS dk,
COALESCE(SUM(protein_g), 0)::float AS dp,
COALESCE(SUM(fat_g), 0)::float AS df,
COALESCE(SUM(carbs_g), 0)::float AS dc
FROM nutrition_log FROM nutrition_log
WHERE profile_id = %s WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days' AND date >= CURRENT_DATE - INTERVAL '28 days'
AND kcal IS NOT NULL GROUP BY date
ORDER BY date DESC HAVING COALESCE(SUM(kcal), 0) > 0
""", (profile_id,)) """, (profile_id,))
data = cur.fetchall() data = cur.fetchall()
@ -744,9 +722,7 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
if len(data) < 18: if len(data) < 18:
return None return None
# Calculate coefficient of variation for each macro
def cv(values): def cv(values):
"""Coefficient of variation (std_dev / mean)"""
if not values or len(values) < 2: if not values or len(values) < 2:
return None return None
mean = sum(values) / len(values) mean = sum(values) / len(values)
@ -755,10 +731,10 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
std_dev = statistics.stdev(values) std_dev = statistics.stdev(values)
return std_dev / mean return std_dev / mean
calories_cv = cv([d['kcal'] for d in data]) calories_cv = cv([d['dk'] for d in data])
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']]) protein_cv = cv([d['dp'] for d in data if d['dp']])
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']]) fat_cv = cv([d['df'] for d in data if d['df']])
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']]) carbs_cv = cv([d['dc'] for d in data if d['dc']])
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None] cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
@ -767,9 +743,6 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
avg_cv = sum(cv_values) / len(cv_values) avg_cv = sum(cv_values) / len(cv_values)
# Score: lower CV = higher score
# CV < 0.2 = excellent consistency
# CV > 0.5 = poor consistency
if avg_cv < 0.2: if avg_cv < 0.2:
score = 100 score = 100
elif avg_cv < 0.3: elif avg_cv < 0.3:

View File

@ -14,6 +14,10 @@ from slowapi.errors import RateLimitExceeded
from db import init_db from db import init_db
# Placeholder registry: load all register_placeholder() side-effects before any request
# so get_placeholder_catalog() and exports see consistent metadata (see Phase A plan).
import placeholder_registrations # noqa: F401
# Import routers # Import routers
from routers import auth, profiles, weight, circumference, caliper from routers import auth, profiles, weight, circumference, caliper
from routers import activity, nutrition, photos, insights, prompts from routers import activity, nutrition, photos, insights, prompts

View File

@ -1133,7 +1133,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
# --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) --- # --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) ---
'{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid), '{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid),
'{{top_goal_progress_pct}}': lambda pid: _safe_str('top_goal_progress_pct', pid), '{{top_goal_progress_pct}}': lambda pid: _safe_int('top_goal_progress_pct', pid),
'{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid), '{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid),
'{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid), '{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid),
'{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid), '{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid),

View File

@ -35,7 +35,8 @@ from data_layer.nutrition_metrics import (
get_nutrition_average_data, get_nutrition_average_data,
get_protein_targets_data, get_protein_targets_data,
get_protein_adequacy_data, get_protein_adequacy_data,
get_macro_consistency_data get_macro_consistency_data,
get_energy_balance_data,
) )
from data_layer.activity_metrics import ( from data_layer.activity_metrics import (
get_activity_summary_data, get_activity_summary_data,
@ -346,17 +347,20 @@ def get_energy_balance_chart(
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
balance_meta = get_energy_balance_data(profile_id, days)
from db import get_db, get_cursor from db import get_db, get_cursor
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute( cur.execute(
"""SELECT date, kcal """SELECT date, SUM(kcal)::float AS kcal
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
GROUP BY date
ORDER BY date""", ORDER BY date""",
(profile_id, cutoff) (profile_id, cutoff),
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -374,7 +378,21 @@ def get_energy_balance_chart(
} }
} }
# Prepare data estimated_tdee = balance_meta.get("estimated_tdee") or 0
if estimated_tdee <= 0:
return {
"chart_type": "line",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows),
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)"
}
}
labels = [] labels = []
daily_values = [] daily_values = []
avg_7d = [] avg_7d = []
@ -384,23 +402,19 @@ def get_energy_balance_chart(
labels.append(row['date'].isoformat()) labels.append(row['date'].isoformat())
daily_values.append(safe_float(row['kcal'])) daily_values.append(safe_float(row['kcal']))
# 7d rolling average
start_7d = max(0, i - 6) start_7d = max(0, i - 6)
window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)] window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)]
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None) avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
# 14d rolling average
start_14d = max(0, i - 13) start_14d = max(0, i - 13)
window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)] window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)]
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None) avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
# Calculate TDEE (estimated, should come from profile) avg_intake = float(balance_meta.get("avg_intake") or (sum(daily_values) / len(daily_values) if daily_values else 0))
# TODO: Calculate from profile (weight, height, age, activity level) energy_balance = float(balance_meta.get("energy_balance") or (avg_intake - estimated_tdee))
estimated_tdee = 2500.0 balance_status = balance_meta.get("status") or (
"deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance"
# Calculate deficit/surplus )
avg_intake = sum(daily_values) / len(daily_values) if daily_values else 0
energy_balance = avg_intake - estimated_tdee
datasets = [ datasets = [
{ {
@ -443,8 +457,7 @@ def get_energy_balance_chart(
} }
] ]
from data_layer.utils import calculate_confidence confidence = balance_meta.get("confidence") or "low"
confidence = calculate_confidence(len(rows), days, "general")
return { return {
"chart_type": "line", "chart_type": "line",
@ -458,7 +471,7 @@ def get_energy_balance_chart(
"avg_kcal": round(avg_intake, 1), "avg_kcal": round(avg_intake, 1),
"estimated_tdee": estimated_tdee, "estimated_tdee": estimated_tdee,
"energy_balance": round(energy_balance, 1), "energy_balance": round(energy_balance, 1),
"balance_status": "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance", "balance_status": balance_status,
"first_date": rows[0]['date'], "first_date": rows[0]['date'],
"last_date": rows[-1]['date'] "last_date": rows[-1]['date']
}) })
@ -587,11 +600,12 @@ def get_protein_adequacy_chart(
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute( cur.execute(
"""SELECT date, protein_g """SELECT date, SUM(protein_g)::float AS protein_g
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
GROUP BY date
ORDER BY date""", ORDER BY date""",
(profile_id, cutoff) (profile_id, cutoff),
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -687,7 +701,6 @@ def get_protein_adequacy_chart(
from data_layer.utils import calculate_confidence from data_layer.utils import calculate_confidence
confidence = calculate_confidence(len(rows), days, "general") confidence = calculate_confidence(len(rows), days, "general")
# Count days in target
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high) days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
return { return {
@ -978,16 +991,23 @@ def get_nutrition_adherence_score(
# Get nutrition data # Get nutrition data
cur.execute( cur.execute(
"""SELECT COUNT(*) as cnt, """WITH daily AS (
AVG(kcal) as avg_kcal, SELECT date,
STDDEV(kcal) as std_kcal, COALESCE(SUM(kcal), 0)::float AS dk,
AVG(protein_g) as avg_protein, COALESCE(SUM(protein_g), 0)::float AS dp,
AVG(carbs_g) as avg_carbs, COALESCE(SUM(carbs_g), 0)::float AS dc,
AVG(fat_g) as avg_fat COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
FROM nutrition_log WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
WHERE profile_id=%s AND date >= %s GROUP BY date
AND kcal IS NOT NULL""", )
(profile_id, cutoff) SELECT COUNT(*)::int AS cnt,
AVG(dk) AS avg_kcal,
STDDEV(dk) AS std_kcal,
AVG(dp) AS avg_protein,
AVG(dc) AS avg_carbs,
AVG(df) AS avg_fat
FROM daily""",
(profile_id, cutoff),
) )
stats = cur.fetchone() stats = cur.fetchone()

65
backend/test_export.py Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Test export-all endpoint"""
import sys
sys.path.insert(0, '/app')
from datetime import datetime
from db import get_db, get_cursor, r2d
print("Testing export-all logic...")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug")
prompts = [r2d(row) for row in cur.fetchall()]
print(f"Found {len(prompts)} prompts")
# Convert to export format (clean up DB-specific fields)
export_data = []
for idx, p in enumerate(prompts):
print(f"\nProcessing prompt {idx+1}: {p.get('slug')}")
try:
export_item = {
'slug': p['slug'],
'name': p['name'],
'display_name': p.get('display_name'),
'description': p.get('description'),
'type': p.get('type', 'pipeline'),
'category': p.get('category', 'ganzheitlich'),
'template': p.get('template'),
'stages': p.get('stages'),
'output_format': p.get('output_format', 'text'),
'output_schema': p.get('output_schema'),
'question_augmentations': p.get('question_augmentations'),
'graph_data': p.get('graph_data'),
'active': p.get('active', True),
'sort_order': p.get('sort_order', 0)
}
export_data.append(export_item)
print(f" ✓ OK")
except Exception as e:
print(f" ✗ ERROR: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
break
print(f"\n\nSuccessfully processed {len(export_data)} prompts")
# Try to create the response dict
try:
result = {
'export_date': datetime.now().isoformat(),
'count': len(export_data),
'prompts': export_data
}
print("✓ Result dict created successfully")
# Try JSON serialization
import json
json_str = json.dumps(result)
print(f"✓ JSON serialization OK, length: {len(json_str)}")
except Exception as e:
print(f"✗ ERROR creating result: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()