diff --git a/CLAUDE.md b/CLAUDE.md index 04004e4..886f3a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,46 @@ frontend/src/ └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9e (Issue #28, #47 Complete) 🚀 Ready for Production 26.03.2026 +## Aktuelle Version: v9e+ (Phase 1 Goal System Fixes) 🎯 Ready for Phase 0b - 27.03.2026 + +### Letzte Updates (27.03.2026 - Phase 1 Complete) 🆕 +- ✅ **Custom Goals Page (Capture/Eigene Ziele):** + - Neue Seite für tägliche Werterfassung individueller Ziele + - Dedizierte UI für custom goals (ohne automatische Datenquelle) + - Verhindert Verwechslung mit automatischem Tracking (Gewicht, Aktivität, etc.) + - Clean UX: Zielauswahl → Schnellerfassung → Verlauf (letzte 5 Einträge) + - Navigation: Capture Hub + direkter Link +- ✅ **UX-Improvements Progress Modal:** + - Volle Breite Eingabefelder, Labels als Überschriften, linksbündiger Text + - Progress-Button nur bei custom goals sichtbar (source_table IS NULL) +- ✅ **Architektur-Klarstellung:** + - Analysis/Goals → Strategisch (Ziele definieren, Prioritäten setzen) + - Capture/Custom Goals → Taktisch (tägliche Ist-Wert-Erfassung) + - History → Auswertung (Zielerreichungs-Analysen) + +### Updates (27.03.2026 - Phase 1 Fixes) +- ✅ **Abstraction Layer:** goal_utils.py für zukunftssichere Phase 0b Platzhalter +- ✅ **Primary Goal Toggle Fix:** is_primary Update funktioniert korrekt +- ✅ **Lean Mass Berechnung:** Magermasse current_value wird berechnet +- ✅ **VO2Max Fix:** Spaltenname vo2_max (statt vo2max) korrigiert +- ✅ **Keine Doppelarbeit:** Phase 0b Platzhalter (120+) müssen bei v2.0 nicht umgeschrieben werden + +### Phase 0a Completion (26.03.2026) 🎯 +- ✅ **Phase 0a: Minimal Goal System:** Strategic + Tactical Layers implementiert +- ✅ **Migration 022:** goal_mode, goals, training_phases, fitness_tests tables +- ✅ **Backend Router:** goals.py mit vollständigem CRUD (490 Zeilen) +- ✅ **Frontend:** GoalsPage mit mobile-friendly Design (570 Zeilen) +- ✅ **Navigation:** Goals Preview (Dashboard) + Ziele Button (Analysis) +- ✅ **Basis geschaffen:** Für 120+ goal-aware Platzhalter (Phase 0b) +- ✅ **Dokumentation:** issue-50, NEXT_STEPS_2026-03-26.md, GOALS_SYSTEM_UNIFIED_ANALYSIS.md + +### Frühere Updates (26.03.2026 - Vormittag) +- ✅ **circ_summary erweitert:** Best-of-Each Strategie mit Altersangaben +- ✅ **Stage Outputs Fix:** Debug-Info für Experten-Modus +- ✅ **Collapsible JSON:** Stage-Rohdaten aufklappbar +- ✅ **Gitea #28 geschlossen:** AI-Prompts Flexibilisierung +- ✅ **Gitea #44 geschlossen:** Analysen löschen behoben +- ✅ **Gitea #47 erstellt:** Wertetabelle Optimierung ### Implementiert ✅ - Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting @@ -208,7 +247,8 @@ frontend/src/ 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` -### Issue #28: Unified Prompt System ✅ (Completed 26.03.2026) +### Feature: Unified Prompt System ✅ (Completed 26.03.2026) +> **Gitea:** Issue #28 (AI-Prompts Flexibilisierung) - CLOSED **AI-Prompts Flexibilisierung - Komplett überarbeitet:** @@ -316,14 +356,15 @@ frontend/src/ 📚 Details: `.claude/docs/functional/AI_PROMPTS.md` **Related Gitea Issues:** -- #28: Unified Prompt System - ✅ CLOSED (26.03.2026) -- #43: Enhanced Debug UI - 🔲 OPEN (Future enhancement) -- #44: BUG - Analysen löschen - 🔲 OPEN (High priority) -- #45: KI Prompt-Optimierer - 🔲 OPEN (Future feature) -- #46: KI Prompt-Ersteller - 🔲 OPEN (Future feature) -- #47: Value Table - ✅ CLOSED (26.03.2026) +- Gitea #28: AI-Prompts Flexibilisierung - ✅ CLOSED (26.03.2026) +- Gitea #42, #43: Enhanced Debug UI - 🔲 OPEN (Future enhancement) +- Gitea #44: BUG - Analysen löschen - ✅ CLOSED (26.03.2026) +- Gitea #45: KI Prompt-Optimierer - 🔲 OPEN (Future feature) +- Gitea #46: KI Prompt-Ersteller - 🔲 OPEN (Future feature) +- Gitea #47: Wertetabelle Optimierung - 🔲 OPEN (Refinement, siehe docs/issues/issue-50) -### Issue #47: Comprehensive Value Table ✅ (Completed 26.03.2026) +### Feature: Comprehensive Value Table ✅ (Completed 26.03.2026) +> **Gitea:** Basis-Implementierung abgeschlossen. Issue #47 (Wertetabelle Optimierung) für Refinement offen. **AI-Analyse Transparenz - Vollständige Platzhalter-Anzeige:** @@ -371,6 +412,65 @@ frontend/src/ 📚 Details: `.claude/docs/functional/AI_PROMPTS.md` +### Phase 0a: Minimal Goal System ✅ (Completed 26.03.2026) +> **Gitea:** Issue #50 (zu erstellen) - COMPLETED +> **Dokumentation:** `docs/issues/issue-50-phase-0a-goal-system.md`, `docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md` + +**Zwei-Ebenen-Ziel-Architektur für goal-aware KI-Analysen:** + +- ✅ **Strategic Layer (Goal Modes):** + - `goal_mode` in profiles table (weight_loss, strength, endurance, recomposition, health) + - Bestimmt Score-Gewichtung für alle KI-Analysen + - UI: 5 Goal Mode Cards mit Icons und Beschreibungen + +- ✅ **Tactical Layer (Concrete Goals):** + - `goals` table mit vollständigem Progress-Tracking + - 8 Goal-Typen: weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr + - Auto-calculated progress percentage + - Linear projection für target_date + - Primary/Secondary goal concept + - UI: Goal CRUD mit Fortschrittsbalken, mobile-friendly + +- ✅ **Training Phases Framework:** + - `training_phases` table (Auto-Detection vorbereitet) + - 5 Phase-Typen: calorie_deficit, calorie_surplus, deload, maintenance, periodization + - Status-Flow: suggested → accepted → active → completed + - Confidence scoring für KI-basierte Erkennung + +- ✅ **Fitness Tests:** + - `fitness_tests` table für standardisierte Tests + - 8 Test-Typen: Cooper, Step Test, Pushups, Plank, VO2Max, Strength (Squat/Bench) + - Norm-Kategorisierung vorbereitet + +**Backend:** +- Migration 022: goal_mode, goals, training_phases, fitness_tests tables +- Router: `routers/goals.py` (490 Zeilen) - vollständiges CRUD +- API Endpoints: `/api/goals/*` (mode, list, create, update, delete, phases, tests) + +**Frontend:** +- GoalsPage: `frontend/src/pages/GoalsPage.jsx` (570 Zeilen) +- Mobile-friendly Design (full-width inputs, labels above) +- Navigation: Dashboard (Goals Preview Card) + Analysis (🎯 Ziele Button) +- api.js: 15+ neue Goal-Funktionen + +**Commits:** +- `337667f` - feat: Phase 0a - Minimal Goal System +- `906a3b7` - fix: Migration 022 tracking +- `75f0a5d` - refactor: mobile-friendly design +- `5be52bc` - feat: goals navigation + UX + +**Basis für Phase 0b:** +- Foundation für 120+ goal-aware Platzhalter +- Score-Berechnungen abhängig von goal_mode +- Intelligente Coaching-Funktionen +- Automatische Trainingsphasen-Erkennung + +**Nächste Schritte:** +- Option A: Issue #49 - Prompt Page Assignment (6-8h, Quick Win) +- Option B: Phase 0b - Goal-Aware Placeholders (16-20h, Strategic) + +📚 Details: `docs/NEXT_STEPS_2026-03-26.md` + ## Feature-Roadmap > 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten) diff --git a/backend/check_migration_024.py b/backend/check_migration_024.py new file mode 100644 index 0000000..c31cf1d --- /dev/null +++ b/backend/check_migration_024.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Quick diagnostic: Check Migration 024 state + +Run this inside the backend container: +docker exec bodytrack-dev-backend-1 python check_migration_024.py +""" + +import psycopg2 +import os +from psycopg2.extras import RealDictCursor + +# Database connection +DB_HOST = os.getenv('DB_HOST', 'db') +DB_PORT = os.getenv('DB_PORT', '5432') +DB_NAME = os.getenv('DB_NAME', 'bodytrack') +DB_USER = os.getenv('DB_USER', 'bodytrack') +DB_PASS = os.getenv('DB_PASSWORD', '') + +def main(): + print("=" * 70) + print("Migration 024 Diagnostic") + print("=" * 70) + + # Connect to database + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASS + ) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 1. Check if table exists + print("\n1. Checking if goal_type_definitions table exists...") + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'goal_type_definitions' + ) + """) + exists = cur.fetchone()['exists'] + print(f" ✓ Table exists: {exists}") + + if not exists: + print("\n❌ TABLE DOES NOT EXIST - Migration 024 did not run!") + print("\nRECOMMENDED ACTION:") + print(" 1. Restart backend container: docker restart bodytrack-dev-backend-1") + print(" 2. Check logs: docker logs bodytrack-dev-backend-1 | grep 'Migration'") + cur.close() + conn.close() + return + + # 2. Check row count + print("\n2. Checking row count...") + cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions") + count = cur.fetchone()['count'] + print(f" Row count: {count}") + + if count == 0: + print("\n❌ TABLE IS EMPTY - Seed data was not inserted!") + print("\nPOSSIBLE CAUSES:") + print(" - INSERT statements failed (constraint violation?)") + print(" - Migration ran partially") + print("\nRECOMMENDED ACTION:") + print(" Run the seed statements manually (see below)") + else: + print(f" ✓ Table has {count} entries") + + # 3. Show all entries + print("\n3. Current goal type definitions:") + cur.execute(""" + SELECT type_key, label_de, unit, is_system, is_active, created_at + FROM goal_type_definitions + ORDER BY is_system DESC, type_key + """) + + entries = cur.fetchall() + if entries: + print(f"\n {'Type Key':<20} {'Label':<20} {'Unit':<10} {'System':<8} {'Active':<8}") + print(" " + "-" * 70) + for row in entries: + status = "SYSTEM" if row['is_system'] else "CUSTOM" + active = "YES" if row['is_active'] else "NO" + print(f" {row['type_key']:<20} {row['label_de']:<20} {row['unit']:<10} {status:<8} {active:<8}") + else: + print(" (empty)") + + # 4. Check schema_migrations + print("\n4. Checking schema_migrations tracking...") + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'schema_migrations' + ) + """) + sm_exists = cur.fetchone()['exists'] + + if sm_exists: + cur.execute(""" + SELECT filename, executed_at + FROM schema_migrations + WHERE filename = '024_goal_type_registry.sql' + """) + tracked = cur.fetchone() + if tracked: + print(f" ✓ Migration 024 is tracked (executed: {tracked['executed_at']})") + else: + print(" ❌ Migration 024 is NOT tracked in schema_migrations") + else: + print(" ⚠️ schema_migrations table does not exist") + + # 5. Check for errors + print("\n5. Potential issues:") + issues = [] + + if count == 0: + issues.append("No seed data - INSERTs failed") + + if count > 0 and count < 6: + issues.append(f"Only {count} types (expected 8) - partial seed") + + cur.execute(""" + SELECT COUNT(*) as inactive_count + FROM goal_type_definitions + WHERE is_active = false + """) + inactive = cur.fetchone()['inactive_count'] + if inactive > 2: + issues.append(f"{inactive} inactive types (expected 2)") + + if not issues: + print(" ✓ No issues detected") + else: + for issue in issues: + print(f" ❌ {issue}") + + # 6. Test query that frontend uses + print("\n6. Testing frontend query (WHERE is_active = true)...") + cur.execute(""" + SELECT COUNT(*) as active_count + FROM goal_type_definitions + WHERE is_active = true + """) + active_count = cur.fetchone()['active_count'] + print(f" Active types returned: {active_count}") + + if active_count == 0: + print(" ❌ This is why frontend shows empty list!") + + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + + if count == 0: + print("\n🔴 PROBLEM: Table exists but has no data") + print("\nQUICK FIX: Run these SQL commands manually:") + print("\n```sql") + print("-- Connect to database:") + print("docker exec -it bodytrack-dev-db-1 psql -U bodytrack -d bodytrack") + print("\n-- Then paste migration content:") + print("-- (copy from backend/migrations/024_goal_type_registry.sql)") + print("-- Skip CREATE TABLE (already exists), run INSERT statements only") + print("```") + elif active_count >= 6: + print("\n🟢 EVERYTHING LOOKS GOOD") + print(f" {active_count} active goal types available") + print("\nIf frontend still shows error, check:") + print(" 1. Backend logs: docker logs bodytrack-dev-backend-1 -f") + print(" 2. Network tab in browser DevTools") + print(" 3. API endpoint: curl -H 'X-Auth-Token: YOUR_TOKEN' http://localhost:8099/api/goals/goal-types") + else: + print(f"\n🟡 PARTIAL DATA: {active_count} active types (expected 6)") + print(" Some INSERTs might have failed") + + cur.close() + conn.close() + +if __name__ == '__main__': + main() diff --git a/backend/fix_seed_goal_types.py b/backend/fix_seed_goal_types.py new file mode 100644 index 0000000..59cc0e6 --- /dev/null +++ b/backend/fix_seed_goal_types.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Quick Fix: Insert seed data for goal_type_definitions + +This script ONLY inserts the 8 standard goal types. +Safe to run multiple times (uses ON CONFLICT DO NOTHING). + +Run inside backend container: +docker exec bodytrack-dev-backend-1 python fix_seed_goal_types.py +""" + +import psycopg2 +import os +from psycopg2.extras import RealDictCursor + +# Database connection +DB_HOST = os.getenv('DB_HOST', 'db') +DB_PORT = os.getenv('DB_PORT', '5432') +DB_NAME = os.getenv('DB_NAME', 'bodytrack') +DB_USER = os.getenv('DB_USER', 'bodytrack') +DB_PASS = os.getenv('DB_PASSWORD', '') + +SEED_DATA = [ + { + 'type_key': 'weight', + 'label_de': 'Gewicht', + 'label_en': 'Weight', + 'unit': 'kg', + 'icon': '⚖️', + 'category': 'body', + 'source_table': 'weight_log', + 'source_column': 'weight', + 'aggregation_method': 'latest', + 'description': 'Aktuelles Körpergewicht', + 'is_system': True + }, + { + 'type_key': 'body_fat', + 'label_de': 'Körperfett', + 'label_en': 'Body Fat', + 'unit': '%', + 'icon': '📊', + 'category': 'body', + 'source_table': 'caliper_log', + 'source_column': 'body_fat_pct', + 'aggregation_method': 'latest', + 'description': 'Körperfettanteil aus Caliper-Messung', + 'is_system': True + }, + { + 'type_key': 'lean_mass', + 'label_de': 'Muskelmasse', + 'label_en': 'Lean Mass', + 'unit': 'kg', + 'icon': '💪', + 'category': 'body', + 'calculation_formula': '{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}', + 'description': 'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', + 'is_system': True + }, + { + 'type_key': 'vo2max', + 'label_de': 'VO2Max', + 'label_en': 'VO2Max', + 'unit': 'ml/kg/min', + 'icon': '🫁', + 'category': 'recovery', + 'source_table': 'vitals_baseline', + 'source_column': 'vo2_max', + 'aggregation_method': 'latest', + 'description': 'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', + 'is_system': True + }, + { + 'type_key': 'rhr', + 'label_de': 'Ruhepuls', + 'label_en': 'Resting Heart Rate', + 'unit': 'bpm', + 'icon': '💓', + 'category': 'recovery', + 'source_table': 'vitals_baseline', + 'source_column': 'resting_hr', + 'aggregation_method': 'latest', + 'description': 'Ruhepuls morgens vor dem Aufstehen', + 'is_system': True + }, + { + 'type_key': 'bp', + 'label_de': 'Blutdruck', + 'label_en': 'Blood Pressure', + 'unit': 'mmHg', + 'icon': '❤️', + 'category': 'recovery', + 'source_table': 'blood_pressure_log', + 'source_column': 'systolic', + 'aggregation_method': 'latest', + 'description': 'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', + 'is_system': True + }, + { + 'type_key': 'strength', + 'label_de': 'Kraft', + 'label_en': 'Strength', + 'unit': 'kg', + 'icon': '🏋️', + 'category': 'activity', + 'description': 'Maximalkraft (Platzhalter, Datenquelle in v2.0)', + 'is_system': True, + 'is_active': False + }, + { + 'type_key': 'flexibility', + 'label_de': 'Beweglichkeit', + 'label_en': 'Flexibility', + 'unit': 'cm', + 'icon': '🤸', + 'category': 'activity', + 'description': 'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', + 'is_system': True, + 'is_active': False + } +] + +def main(): + print("=" * 70) + print("Goal Type Definitions - Seed Data Fix") + print("=" * 70) + + # Connect to database + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASS + ) + conn.autocommit = False + cur = conn.cursor(cursor_factory=RealDictCursor) + + try: + # Check current state + cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions") + before_count = cur.fetchone()['count'] + print(f"\nBefore: {before_count} goal types in database") + + # Insert seed data + print(f"\nInserting {len(SEED_DATA)} standard goal types...") + inserted = 0 + skipped = 0 + + for data in SEED_DATA: + columns = list(data.keys()) + values = [data[col] for col in columns] + placeholders = ', '.join(['%s'] * len(values)) + cols_str = ', '.join(columns) + + sql = f""" + INSERT INTO goal_type_definitions ({cols_str}) + VALUES ({placeholders}) + ON CONFLICT (type_key) DO NOTHING + RETURNING id + """ + + cur.execute(sql, values) + result = cur.fetchone() + + if result: + inserted += 1 + print(f" ✓ {data['type_key']}: {data['label_de']}") + else: + skipped += 1 + print(f" - {data['type_key']}: already exists (skipped)") + + conn.commit() + + # Check final state + cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions") + after_count = cur.fetchone()['count'] + + print(f"\nAfter: {after_count} goal types in database") + print(f" Inserted: {inserted}") + print(f" Skipped: {skipped}") + + # Show summary + cur.execute(""" + SELECT type_key, label_de, is_active, is_system + FROM goal_type_definitions + ORDER BY is_system DESC, type_key + """) + + print("\n" + "=" * 70) + print("Current Goal Types:") + print("=" * 70) + print(f"\n{'Type Key':<20} {'Label':<20} {'System':<8} {'Active':<8}") + print("-" * 70) + + for row in cur.fetchall(): + status = "YES" if row['is_system'] else "NO" + active = "YES" if row['is_active'] else "NO" + print(f"{row['type_key']:<20} {row['label_de']:<20} {status:<8} {active:<8}") + + print("\n✅ DONE! Goal types seeded successfully.") + print("\nNext step: Reload frontend to see the changes.") + + except Exception as e: + conn.rollback() + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + finally: + cur.close() + conn.close() + +if __name__ == '__main__': + main() diff --git a/backend/goal_utils.py b/backend/goal_utils.py new file mode 100644 index 0000000..09bd9cc --- /dev/null +++ b/backend/goal_utils.py @@ -0,0 +1,504 @@ +""" +Goal Utilities - Abstraction Layer for Focus Weights & Universal Value Fetcher + +This module provides: +1. Abstraction layer between goal modes and focus weights (Phase 1) +2. Universal value fetcher for dynamic goal types (Phase 1.5) + +Version History: +- V1 (Phase 1): Maps goal_mode to predefined weights +- V1.5 (Phase 1.5): Universal value fetcher for DB-registry goal types +- V2 (future): Reads from focus_areas table with custom user weights + +Part of Phase 1 + Phase 1.5: Flexible Goal System +""" + +from typing import Dict, Optional, Any +from datetime import date, timedelta +from decimal import Decimal +import json +from db import get_cursor + + +def get_focus_weights(conn, profile_id: str) -> Dict[str, float]: + """ + Get focus area weights for a profile. + + V2 (Goal System v2.0): Reads from focus_areas table with custom user weights. + Falls back to goal_mode mapping if focus_areas not set. + + Args: + conn: Database connection + profile_id: User's profile ID + + Returns: + Dict with focus weights (sum = 1.0): + { + 'weight_loss': 0.3, # Fat loss priority + 'muscle_gain': 0.2, # Muscle gain priority + 'strength': 0.25, # Strength training priority + 'endurance': 0.25, # Cardio/endurance priority + 'flexibility': 0.0, # Mobility priority + 'health': 0.0 # General health maintenance + } + + Example Usage in Phase 0b: + weights = get_focus_weights(conn, profile_id) + + # Score calculation considers user's focus + overall_score = ( + body_score * weights['weight_loss'] + + strength_score * weights['strength'] + + cardio_score * weights['endurance'] + ) + """ + cur = get_cursor(conn) + + # V2: Try to fetch from focus_areas table + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + """, (profile_id,)) + + row = cur.fetchone() + + if row: + # Convert percentages to weights (0-1 range) + return { + 'weight_loss': row['weight_loss_pct'] / 100.0, + 'muscle_gain': row['muscle_gain_pct'] / 100.0, + 'strength': row['strength_pct'] / 100.0, + 'endurance': row['endurance_pct'] / 100.0, + 'flexibility': row['flexibility_pct'] / 100.0, + 'health': row['health_pct'] / 100.0 + } + + # V1 Fallback: Use goal_mode if focus_areas not set + cur.execute( + "SELECT goal_mode FROM profiles WHERE id = %s", + (profile_id,) + ) + row = cur.fetchone() + + if not row: + # Ultimate fallback: balanced health focus + return { + 'weight_loss': 0.0, + 'muscle_gain': 0.0, + 'strength': 0.10, + 'endurance': 0.20, + 'flexibility': 0.15, + 'health': 0.55 + } + + goal_mode = row['goal_mode'] + + if not goal_mode: + return { + 'weight_loss': 0.0, + 'muscle_gain': 0.0, + 'strength': 0.10, + 'endurance': 0.20, + 'flexibility': 0.15, + 'health': 0.55 + } + + # V1: Predefined weight mappings per goal_mode (fallback) + WEIGHT_MAPPINGS = { + 'weight_loss': { + 'weight_loss': 0.60, + 'endurance': 0.20, + 'muscle_gain': 0.0, + 'strength': 0.10, + 'flexibility': 0.05, + 'health': 0.05 + }, + 'strength': { + 'strength': 0.50, + 'muscle_gain': 0.40, + 'endurance': 0.10, + 'weight_loss': 0.0, + 'flexibility': 0.0, + 'health': 0.0 + }, + 'endurance': { + 'endurance': 0.70, + 'health': 0.20, + 'flexibility': 0.10, + 'weight_loss': 0.0, + 'muscle_gain': 0.0, + 'strength': 0.0 + }, + 'recomposition': { + 'weight_loss': 0.30, + 'muscle_gain': 0.30, + 'strength': 0.25, + 'endurance': 0.10, + 'flexibility': 0.05, + 'health': 0.0 + }, + 'health': { + 'health': 0.50, + 'endurance': 0.20, + 'flexibility': 0.15, + 'strength': 0.10, + 'weight_loss': 0.05, + 'muscle_gain': 0.0 + } + } + + return WEIGHT_MAPPINGS.get(goal_mode, WEIGHT_MAPPINGS['health']) + + +def get_primary_focus(conn, profile_id: str) -> str: + """ + Get the primary focus area for a profile. + + Returns the focus area with the highest weight. + Useful for UI labels and simple decision logic. + + Args: + conn: Database connection + profile_id: User's profile ID + + Returns: + Primary focus area name (e.g., 'weight_loss', 'strength') + """ + weights = get_focus_weights(conn, profile_id) + return max(weights.items(), key=lambda x: x[1])[0] + + +def get_focus_description(focus_area: str) -> str: + """ + Get human-readable description for a focus area. + + Args: + focus_area: Focus area key (e.g., 'weight_loss') + + Returns: + German description for UI display + """ + descriptions = { + 'weight_loss': 'Gewichtsreduktion & Fettabbau', + 'muscle_gain': 'Muskelaufbau & Hypertrophie', + 'strength': 'Kraftsteigerung & Performance', + 'endurance': 'Ausdauer & aerobe Kapazität', + 'flexibility': 'Beweglichkeit & Mobilität', + 'health': 'Allgemeine Gesundheit & Erhaltung' + } + return descriptions.get(focus_area, focus_area) + + +# ============================================================================ +# Phase 1.5: Universal Value Fetcher for Dynamic Goal Types +# ============================================================================ + +def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]: + """ + Get goal type configuration from database registry. + + Args: + conn: Database connection + type_key: Goal type key (e.g., 'weight', 'meditation_minutes') + + Returns: + Dict with config or None if not found/inactive + """ + cur = get_cursor(conn) + + cur.execute(""" + SELECT type_key, source_table, source_column, aggregation_method, + calculation_formula, filter_conditions, label_de, unit, icon, category + FROM goal_type_definitions + WHERE type_key = %s AND is_active = true + LIMIT 1 + """, (type_key,)) + + return cur.fetchone() + + +def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optional[float]: + """ + Universal value fetcher for any goal type. + + Reads configuration from goal_type_definitions table and executes + appropriate query based on aggregation_method or calculation_formula. + + Args: + conn: Database connection + profile_id: User's profile ID + goal_type: Goal type key (e.g., 'weight', 'meditation_minutes') + + Returns: + Current value as float or None if not available + """ + config = get_goal_type_config(conn, goal_type) + + if not config: + print(f"[WARNING] Goal type '{goal_type}' not found or inactive") + return None + + # Complex calculation (e.g., lean_mass) + if config['calculation_formula']: + return _execute_calculation_formula(conn, profile_id, config['calculation_formula']) + + # Simple aggregation + return _fetch_by_aggregation_method( + conn, + profile_id, + config['source_table'], + config['source_column'], + config['aggregation_method'], + config.get('filter_conditions') + ) + + +def _fetch_by_aggregation_method( + conn, + profile_id: str, + table: str, + column: str, + method: str, + filter_conditions: Optional[Any] = None +) -> Optional[float]: + """ + Fetch value using specified aggregation method. + + Supported methods: + - latest: Most recent value + - avg_7d: 7-day average + - avg_30d: 30-day average + - sum_30d: 30-day sum + - count_7d: Count of entries in last 7 days + - count_30d: Count of entries in last 30 days + - min_30d: Minimum 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 + if not table or not column: + print(f"[WARNING] Missing source_table or source_column for aggregation") + return None + + # Table-specific date column mapping (some tables use different column names) + DATE_COLUMN_MAP = { + 'blood_pressure_log': 'measured_at', + 'activity_log': 'date', + 'weight_log': 'date', + 'circumference_log': 'date', + 'caliper_log': 'date', + 'nutrition_log': 'date', + 'sleep_log': 'date', + 'vitals_baseline': 'date', + 'rest_days': 'date', + 'fitness_tests': 'test_date' + } + date_col = DATE_COLUMN_MAP.get(table, 'date') + + # 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) + + try: + if method == 'latest': + params = [profile_id] + filter_params + cur.execute(f""" + SELECT {column} FROM {table} + WHERE profile_id = %s AND {column} IS NOT NULL{filter_sql} + ORDER BY {date_col} DESC LIMIT 1 + """, params) + row = cur.fetchone() + return float(row[column]) if row else None + + elif method == 'avg_7d': + days_ago = date.today() - timedelta(days=7) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT AVG({column}) as avg_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql} + """, params) + row = cur.fetchone() + return float(row['avg_value']) if row and row['avg_value'] is not None else None + + elif method == 'avg_30d': + days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT AVG({column}) as avg_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql} + """, params) + row = cur.fetchone() + return float(row['avg_value']) if row and row['avg_value'] is not None else None + + elif method == 'sum_30d': + days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT SUM({column}) as sum_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql} + """, params) + row = cur.fetchone() + return float(row['sum_value']) if row and row['sum_value'] is not None else None + + elif method == 'count_7d': + days_ago = date.today() - timedelta(days=7) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT COUNT(*) as count_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s{filter_sql} + """, params) + row = cur.fetchone() + return float(row['count_value']) if row else 0.0 + + elif method == 'count_30d': + days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT COUNT(*) as count_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s{filter_sql} + """, params) + row = cur.fetchone() + return float(row['count_value']) if row else 0.0 + + elif method == 'min_30d': + days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT MIN({column}) as min_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql} + """, params) + row = cur.fetchone() + return float(row['min_value']) if row and row['min_value'] is not None else None + + elif method == 'max_30d': + days_ago = date.today() - timedelta(days=30) + params = [profile_id, days_ago] + filter_params + cur.execute(f""" + SELECT MAX({column}) as max_value FROM {table} + WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql} + """, params) + row = cur.fetchone() + return float(row['max_value']) if row and row['max_value'] is not None else None + + else: + print(f"[WARNING] Unknown aggregation method: {method}") + return None + + except Exception as e: + print(f"[ERROR] Failed to fetch value from {table}.{column} using {method}: {e}") + return None + + +def _execute_calculation_formula(conn, profile_id: str, formula_json: str) -> Optional[float]: + """ + Execute complex calculation formula. + + Currently supports: + - lean_mass: weight - (weight * body_fat_pct / 100) + + Future: Parse JSON formula and execute dynamically. + + Args: + conn: Database connection + profile_id: User's profile ID + formula_json: JSON string with calculation config + + Returns: + Calculated value or None + """ + try: + formula = json.loads(formula_json) + calc_type = formula.get('type') + + if calc_type == 'lean_mass': + # Get dependencies + cur = get_cursor(conn) + + cur.execute(""" + SELECT weight FROM weight_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + weight_row = cur.fetchone() + + cur.execute(""" + SELECT body_fat_pct FROM caliper_log + WHERE profile_id = %s + ORDER BY date DESC LIMIT 1 + """, (profile_id,)) + bf_row = cur.fetchone() + + if weight_row and bf_row: + weight = float(weight_row['weight']) + bf_pct = float(bf_row['body_fat_pct']) + lean_mass = weight - (weight * bf_pct / 100.0) + return round(lean_mass, 2) + + return None + + else: + print(f"[WARNING] Unknown calculation type: {calc_type}") + return None + + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e: + print(f"[ERROR] Formula execution failed: {e}, formula={formula_json}") + return None + + +# Future V2 Implementation (commented out for reference): +""" +def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]: + '''V2: Read from focus_areas table with custom user weights''' + cur = get_cursor(conn) + + cur.execute(''' + SELECT weight_loss_pct, muscle_gain_pct, endurance_pct, + strength_pct, flexibility_pct, health_pct + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + ''', (profile_id,)) + + row = cur.fetchone() + + if not row: + # Fallback to V1 behavior + return get_focus_weights(conn, profile_id) + + # Convert percentages to weights (0-1 range) + return { + 'weight_loss': row['weight_loss_pct'] / 100.0, + 'muscle_gain': row['muscle_gain_pct'] / 100.0, + 'endurance': row['endurance_pct'] / 100.0, + 'strength': row['strength_pct'] / 100.0, + 'flexibility': row['flexibility_pct'] / 100.0, + 'health': row['health_pct'] / 100.0 + } +""" diff --git a/backend/main.py b/backend/main.py index 63faff3..738f07e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,6 +23,7 @@ from routers import user_restrictions, access_grants, training_types, admin_trai from routers import admin_activity_mappings, sleep, rest_days from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored from routers import evaluation # v9d/v9e Training Type Profiles (#15) +from routers import goals # v9e Goal System (Strategic + Tactical) # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -97,6 +98,7 @@ app.include_router(rest_days.router) # /api/rest-days/* (v9d Phase 2a app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Phase 2d Refactored) app.include_router(blood_pressure.router) # /api/blood-pressure/* (v9d Phase 2d Refactored) app.include_router(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15) +app.include_router(goals.router) # /api/goals/* (v9e Goal System Strategic + Tactical) # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/022_goal_system.sql b/backend/migrations/022_goal_system.sql new file mode 100644 index 0000000..925433d --- /dev/null +++ b/backend/migrations/022_goal_system.sql @@ -0,0 +1,135 @@ +-- Migration 022: Goal System (Strategic + Tactical) +-- Date: 2026-03-26 +-- Purpose: Two-level goal architecture for AI-driven coaching + +-- ============================================================================ +-- STRATEGIC LAYER: Goal Modes +-- ============================================================================ + +-- Add goal_mode to profiles (strategic training direction) +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS goal_mode VARCHAR(50) DEFAULT 'health'; + +COMMENT ON COLUMN profiles.goal_mode IS + 'Strategic goal mode: weight_loss, strength, endurance, recomposition, health. + Determines score weights and interpretation context for all analyses.'; + +-- ============================================================================ +-- TACTICAL LAYER: Concrete Goal Targets +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Goal Classification + goal_type VARCHAR(50) NOT NULL, -- weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr + is_primary BOOLEAN DEFAULT false, + status VARCHAR(20) DEFAULT 'active', -- draft, active, reached, abandoned, expired + + -- Target Values + target_value DECIMAL(10,2), + current_value DECIMAL(10,2), + start_value DECIMAL(10,2), + unit VARCHAR(20), -- kg, %, ml/kg/min, bpm, mmHg, cm, reps + + -- Timeline + start_date DATE DEFAULT CURRENT_DATE, + target_date DATE, + reached_date DATE, + + -- Metadata + name VARCHAR(100), -- e.g., "Sommerfigur 2026" + description TEXT, + + -- Progress Tracking + progress_pct DECIMAL(5,2), -- Auto-calculated: (current - start) / (target - start) * 100 + projection_date DATE, -- Prognose wann Ziel erreicht wird + on_track BOOLEAN, -- true wenn Prognose <= target_date + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_goals_profile ON goals(profile_id); +CREATE INDEX IF NOT EXISTS idx_goals_status ON goals(profile_id, status); +CREATE INDEX IF NOT EXISTS idx_goals_primary ON goals(profile_id, is_primary) WHERE is_primary = true; + +COMMENT ON TABLE goals IS 'Concrete user goals (tactical targets)'; +COMMENT ON COLUMN goals.goal_type IS 'Type of goal: weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr'; +COMMENT ON COLUMN goals.is_primary IS 'Primary goal gets highest priority in scoring and charts'; +COMMENT ON COLUMN goals.status IS 'draft = not yet started, active = in progress, reached = successfully completed, abandoned = given up, expired = deadline passed'; +COMMENT ON COLUMN goals.progress_pct IS 'Percentage progress: (current_value - start_value) / (target_value - start_value) * 100'; +COMMENT ON COLUMN goals.projection_date IS 'Projected date when goal will be reached based on current trend'; +COMMENT ON COLUMN goals.on_track IS 'true if projection_date <= target_date (goal reachable on time)'; + +-- ============================================================================ +-- TRAINING PHASES (Auto-Detection) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS training_phases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Phase Classification + phase_type VARCHAR(50) NOT NULL, -- calorie_deficit, calorie_surplus, deload, maintenance, periodization + detected_automatically BOOLEAN DEFAULT false, + confidence_score DECIMAL(3,2), -- 0.00 - 1.00 (Wie sicher ist die Erkennung?) + status VARCHAR(20) DEFAULT 'suggested', -- suggested, accepted, active, completed, rejected + + -- Timeframe + start_date DATE NOT NULL, + end_date DATE, + duration_days INT, + + -- Detection Criteria (JSONB für Flexibilität) + detection_params JSONB, -- { "avg_calories": 1800, "weight_trend": -0.3, ... } + + -- User Notes + notes TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_training_phases_profile ON training_phases(profile_id); +CREATE INDEX IF NOT EXISTS idx_training_phases_status ON training_phases(profile_id, status); +CREATE INDEX IF NOT EXISTS idx_training_phases_dates ON training_phases(profile_id, start_date, end_date); + +COMMENT ON TABLE training_phases IS 'Training phases detected from data patterns or manually defined'; +COMMENT ON COLUMN training_phases.phase_type IS 'calorie_deficit, calorie_surplus, deload, maintenance, periodization'; +COMMENT ON COLUMN training_phases.detected_automatically IS 'true if AI detected this phase from data patterns'; +COMMENT ON COLUMN training_phases.confidence_score IS 'AI confidence in detection (0.0 - 1.0)'; +COMMENT ON COLUMN training_phases.status IS 'suggested = AI proposed, accepted = user confirmed, active = currently running, completed = finished, rejected = user dismissed'; +COMMENT ON COLUMN training_phases.detection_params IS 'JSON with detection criteria: avg_calories, weight_trend, activity_volume, etc.'; + +-- ============================================================================ +-- FITNESS TESTS (Standardized Performance Tests) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS fitness_tests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Test Type + test_type VARCHAR(50) NOT NULL, -- cooper_12min, step_test, pushups_max, plank_max, flexibility_sit_reach, vo2max_est, strength_1rm_squat, strength_1rm_bench + result_value DECIMAL(10,2) NOT NULL, + result_unit VARCHAR(20) NOT NULL, -- meters, bpm, reps, seconds, cm, ml/kg/min, kg + + -- Test Metadata + test_date DATE NOT NULL, + test_conditions TEXT, -- Optional: Notizen zu Bedingungen + norm_category VARCHAR(30), -- sehr gut, gut, durchschnitt, unterdurchschnitt, schlecht + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fitness_tests_profile ON fitness_tests(profile_id); +CREATE INDEX IF NOT EXISTS idx_fitness_tests_type ON fitness_tests(profile_id, test_type); +CREATE INDEX IF NOT EXISTS idx_fitness_tests_date ON fitness_tests(profile_id, test_date); + +COMMENT ON TABLE fitness_tests IS 'Standardized fitness tests (Cooper, step test, strength tests, etc.)'; +COMMENT ON COLUMN fitness_tests.test_type IS 'cooper_12min, step_test, pushups_max, plank_max, flexibility_sit_reach, vo2max_est, strength_1rm_squat, strength_1rm_bench'; +COMMENT ON COLUMN fitness_tests.norm_category IS 'Performance category based on age/gender norms'; diff --git a/backend/migrations/024_goal_type_registry.sql b/backend/migrations/024_goal_type_registry.sql new file mode 100644 index 0000000..704fc95 --- /dev/null +++ b/backend/migrations/024_goal_type_registry.sql @@ -0,0 +1,185 @@ +-- Migration 024: Goal Type Registry (Flexible Goal System) +-- Date: 2026-03-27 +-- Purpose: Enable dynamic goal types without code changes + +-- ============================================================================ +-- Goal Type Definitions +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS goal_type_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Unique identifier (used in code) + type_key VARCHAR(50) UNIQUE NOT NULL, + + -- Display metadata + label_de VARCHAR(100) NOT NULL, + label_en VARCHAR(100), + unit VARCHAR(20) NOT NULL, + icon VARCHAR(10), + category VARCHAR(50), -- body, mind, activity, nutrition, recovery, custom + + -- Data source configuration + source_table VARCHAR(50), -- Which table to query + source_column VARCHAR(50), -- Which column to fetch + aggregation_method VARCHAR(20), -- How to aggregate: latest, avg_7d, avg_30d, sum_30d, count_7d, count_30d, min_30d, max_30d + + -- Complex calculations (optional) + -- For types like lean_mass that need custom logic + -- JSON format: {"type": "formula", "dependencies": ["weight", "body_fat"], "expression": "..."} + calculation_formula TEXT, + + -- Metadata + description TEXT, + is_active BOOLEAN DEFAULT true, + is_system BOOLEAN DEFAULT false, -- System types cannot be deleted + + -- Audit + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_active ON goal_type_definitions(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_category ON goal_type_definitions(category); + +COMMENT ON TABLE goal_type_definitions IS 'Registry of available goal types - allows dynamic goal creation without code changes'; +COMMENT ON COLUMN goal_type_definitions.type_key IS 'Unique key used in code (e.g., weight, meditation_minutes)'; +COMMENT ON COLUMN goal_type_definitions.aggregation_method IS 'latest = most recent value, avg_7d = 7-day average, count_7d = count in last 7 days, etc.'; +COMMENT ON COLUMN goal_type_definitions.calculation_formula IS 'JSON for complex calculations like lean_mass = weight - (weight * bf_pct / 100)'; +COMMENT ON COLUMN goal_type_definitions.is_system IS 'System types are protected from deletion (core functionality)'; + +-- ============================================================================ +-- Seed Data: Migrate existing 8 goal types +-- ============================================================================ + +-- 1. Weight (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'weight', 'Gewicht', 'Weight', 'kg', '⚖️', 'body', + 'weight_log', 'weight', 'latest', + 'Aktuelles Körpergewicht', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 2. Body Fat (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'body_fat', 'Körperfett', 'Body Fat', '%', '📊', 'body', + 'caliper_log', 'body_fat_pct', 'latest', + 'Körperfettanteil aus Caliper-Messung', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 3. Lean Mass (complex - calculation formula) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + calculation_formula, + description, is_system +) VALUES ( + 'lean_mass', 'Muskelmasse', 'Lean Mass', 'kg', '💪', 'body', + '{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}', + 'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 4. VO2 Max (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'vo2max', 'VO2Max', 'VO2Max', 'ml/kg/min', '🫁', 'recovery', + 'vitals_baseline', 'vo2_max', 'latest', + 'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 5. Resting Heart Rate (simple - latest value) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'rhr', 'Ruhepuls', 'Resting Heart Rate', 'bpm', '💓', 'recovery', + 'vitals_baseline', 'resting_hr', 'latest', + 'Ruhepuls morgens vor dem Aufstehen', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 6. Blood Pressure (placeholder - compound goal for v2.0) +-- Currently limited to single value, v2.0 will support systolic/diastolic +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'bp', 'Blutdruck', 'Blood Pressure', 'mmHg', '❤️', 'recovery', + 'blood_pressure_log', 'systolic', 'latest', + 'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 7. Strength (placeholder - no data source yet) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'strength', 'Kraft', 'Strength', 'kg', '🏋️', 'activity', + 'Maximalkraft (Platzhalter, Datenquelle in v2.0)', true, false +) +ON CONFLICT (type_key) DO NOTHING; + +-- 8. Flexibility (placeholder - no data source yet) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'flexibility', 'Beweglichkeit', 'Flexibility', 'cm', '🤸', 'activity', + 'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', true, false +) +ON CONFLICT (type_key) DO NOTHING; + +-- ============================================================================ +-- Example: Future custom goal types (commented out, for reference) +-- ============================================================================ + +/* +-- Meditation Minutes (avg last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'meditation_minutes', 'Meditation', 'min/Tag', '🧘', 'mind', + 'meditation_log', 'duration_minutes', 'avg_7d', + 'Durchschnittliche Meditationsdauer pro Tag (7 Tage)', false +); + +-- Training Frequency (count last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'training_frequency', 'Trainingshäufigkeit', 'x/Woche', '📅', 'activity', + 'activity_log', 'id', 'count_7d', + 'Anzahl Trainingseinheiten pro Woche', false +); + +-- Sleep Quality (avg last 7 days) +INSERT INTO goal_type_definitions ( + type_key, label_de, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'sleep_quality', 'Schlafqualität', '%', '💤', 'recovery', + 'sleep_log', 'quality_score', 'avg_7d', + 'Durchschnittliche Schlafqualität (Deep+REM Anteil)', false +); +*/ diff --git a/backend/migrations/025_cleanup_goal_type_definitions.sql b/backend/migrations/025_cleanup_goal_type_definitions.sql new file mode 100644 index 0000000..db97771 --- /dev/null +++ b/backend/migrations/025_cleanup_goal_type_definitions.sql @@ -0,0 +1,103 @@ +-- Migration 025: Cleanup goal_type_definitions +-- Date: 2026-03-27 +-- Purpose: Remove problematic FK columns and ensure seed data + +-- Remove created_by/updated_by columns if they exist +-- (May have been created by failed Migration 024) +ALTER TABLE goal_type_definitions DROP COLUMN IF EXISTS created_by; +ALTER TABLE goal_type_definitions DROP COLUMN IF EXISTS updated_by; + +-- Re-insert seed data (ON CONFLICT ensures idempotency) +-- This fixes cases where Migration 024 created table but failed to seed + +-- 1. Weight +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'weight', 'Gewicht', 'Weight', 'kg', '⚖️', 'body', + 'weight_log', 'weight', 'latest', + 'Aktuelles Körpergewicht', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 2. Body Fat +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'body_fat', 'Körperfett', 'Body Fat', '%', '📊', 'body', + 'caliper_log', 'body_fat_pct', 'latest', + 'Körperfettanteil aus Caliper-Messung', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 3. Lean Mass +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + calculation_formula, + description, is_system +) VALUES ( + 'lean_mass', 'Muskelmasse', 'Lean Mass', 'kg', '💪', 'body', + '{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}', + 'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 4. VO2 Max +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'vo2max', 'VO2Max', 'VO2Max', 'ml/kg/min', '🫁', 'recovery', + 'vitals_baseline', 'vo2_max', 'latest', + 'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 5. Resting Heart Rate +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'rhr', 'Ruhepuls', 'Resting Heart Rate', 'bpm', '💓', 'recovery', + 'vitals_baseline', 'resting_hr', 'latest', + 'Ruhepuls morgens vor dem Aufstehen', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 6. Blood Pressure +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + description, is_system +) VALUES ( + 'bp', 'Blutdruck', 'Blood Pressure', 'mmHg', '❤️', 'recovery', + 'blood_pressure_log', 'systolic', 'latest', + 'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', true +) +ON CONFLICT (type_key) DO NOTHING; + +-- 7. Strength (inactive placeholder) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'strength', 'Kraft', 'Strength', 'kg', '🏋️', 'activity', + 'Maximalkraft (Platzhalter, Datenquelle in v2.0)', true, false +) +ON CONFLICT (type_key) DO NOTHING; + +-- 8. Flexibility (inactive placeholder) +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + description, is_system, is_active +) VALUES ( + 'flexibility', 'Beweglichkeit', 'Flexibility', 'cm', '🤸', 'activity', + 'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', true, false +) +ON CONFLICT (type_key) DO NOTHING; diff --git a/backend/migrations/026_goal_type_filters.sql b/backend/migrations/026_goal_type_filters.sql new file mode 100644 index 0000000..46a70fa --- /dev/null +++ b/backend/migrations/026_goal_type_filters.sql @@ -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; +*/ diff --git a/backend/migrations/027_focus_areas_system.sql b/backend/migrations/027_focus_areas_system.sql new file mode 100644 index 0000000..4e7fac8 --- /dev/null +++ b/backend/migrations/027_focus_areas_system.sql @@ -0,0 +1,125 @@ +-- Migration 027: Focus Areas System (Goal System v2.0) +-- Date: 2026-03-27 +-- Purpose: Replace single primary goal with weighted multi-goal system + +-- ============================================================================ +-- Focus Areas Table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS focus_areas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Six focus dimensions (percentages, sum = 100) + weight_loss_pct INTEGER DEFAULT 0 CHECK (weight_loss_pct >= 0 AND weight_loss_pct <= 100), + muscle_gain_pct INTEGER DEFAULT 0 CHECK (muscle_gain_pct >= 0 AND muscle_gain_pct <= 100), + strength_pct INTEGER DEFAULT 0 CHECK (strength_pct >= 0 AND strength_pct <= 100), + endurance_pct INTEGER DEFAULT 0 CHECK (endurance_pct >= 0 AND endurance_pct <= 100), + flexibility_pct INTEGER DEFAULT 0 CHECK (flexibility_pct >= 0 AND flexibility_pct <= 100), + health_pct INTEGER DEFAULT 0 CHECK (health_pct >= 0 AND health_pct <= 100), + + -- Status + active BOOLEAN DEFAULT true, + + -- Audit + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT sum_equals_100 CHECK ( + weight_loss_pct + muscle_gain_pct + strength_pct + + endurance_pct + flexibility_pct + health_pct = 100 + ) +); + +-- Only one active focus_areas per profile +CREATE UNIQUE INDEX IF NOT EXISTS idx_focus_areas_profile_active +ON focus_areas(profile_id) WHERE active = true; + +COMMENT ON TABLE focus_areas IS 'User-defined focus area weights (replaces simple goal_mode). Enables multi-goal prioritization with custom percentages.'; +COMMENT ON COLUMN focus_areas.weight_loss_pct IS 'Focus on fat loss (0-100%)'; +COMMENT ON COLUMN focus_areas.muscle_gain_pct IS 'Focus on muscle growth (0-100%)'; +COMMENT ON COLUMN focus_areas.strength_pct IS 'Focus on strength gains (0-100%)'; +COMMENT ON COLUMN focus_areas.endurance_pct IS 'Focus on aerobic capacity (0-100%)'; +COMMENT ON COLUMN focus_areas.flexibility_pct IS 'Focus on mobility/flexibility (0-100%)'; +COMMENT ON COLUMN focus_areas.health_pct IS 'Focus on general health (0-100%)'; + +-- ============================================================================ +-- Migrate existing goal_mode to focus_areas +-- ============================================================================ + +-- For each profile with a goal_mode, create initial focus_areas +INSERT INTO focus_areas ( + profile_id, + weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct +) +SELECT + id AS profile_id, + CASE goal_mode + WHEN 'weight_loss' THEN 60 + WHEN 'recomposition' THEN 30 + WHEN 'health' THEN 5 + ELSE 0 + END AS weight_loss_pct, + + CASE goal_mode + WHEN 'strength' THEN 40 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 30 ELSE 0 + END AS muscle_gain_pct, + + CASE goal_mode + WHEN 'strength' THEN 50 + WHEN 'recomposition' THEN 25 + WHEN 'weight_loss' THEN 10 + WHEN 'health' THEN 10 + ELSE 0 + END AS strength_pct, + + CASE goal_mode + WHEN 'endurance' THEN 70 + WHEN 'recomposition' THEN 10 + WHEN 'weight_loss' THEN 20 + WHEN 'health' THEN 20 + ELSE 0 + END AS endurance_pct, + + CASE goal_mode + WHEN 'endurance' THEN 10 ELSE 0 + END + + CASE goal_mode + WHEN 'health' THEN 15 ELSE 0 + END + + CASE goal_mode + WHEN 'recomposition' THEN 5 ELSE 0 + END + + CASE goal_mode + WHEN 'weight_loss' THEN 5 ELSE 0 + END AS flexibility_pct, + + CASE goal_mode + WHEN 'health' THEN 50 + WHEN 'endurance' THEN 20 + WHEN 'strength' THEN 10 + WHEN 'weight_loss' THEN 5 + ELSE 0 + END AS health_pct +FROM profiles +WHERE goal_mode IS NOT NULL +ON CONFLICT DO NOTHING; + +-- For profiles without goal_mode, use balanced health focus +INSERT INTO focus_areas ( + profile_id, + weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct +) +SELECT + id AS profile_id, + 0, 0, 10, 20, 15, 55 +FROM profiles +WHERE goal_mode IS NULL + AND id NOT IN (SELECT profile_id FROM focus_areas WHERE active = true) +ON CONFLICT DO NOTHING; diff --git a/backend/migrations/028_goal_categories_priorities.sql b/backend/migrations/028_goal_categories_priorities.sql new file mode 100644 index 0000000..43a2a7f --- /dev/null +++ b/backend/migrations/028_goal_categories_priorities.sql @@ -0,0 +1,57 @@ +-- Migration 028: Goal Categories and Priorities +-- Date: 2026-03-27 +-- Purpose: Multi-dimensional goal priorities (one primary goal per category) + +-- ============================================================================ +-- Add category and priority columns +-- ============================================================================ + +ALTER TABLE goals +ADD COLUMN category VARCHAR(50), +ADD COLUMN priority INTEGER DEFAULT 2 CHECK (priority >= 1 AND priority <= 3); + +COMMENT ON COLUMN goals.category IS 'Goal category: body, training, nutrition, recovery, health, other'; +COMMENT ON COLUMN goals.priority IS 'Priority level: 1=high, 2=medium, 3=low'; + +-- ============================================================================ +-- Migrate existing goals to categories based on goal_type +-- ============================================================================ + +UPDATE goals SET category = CASE + -- Body composition goals + WHEN goal_type IN ('weight', 'body_fat', 'lean_mass') THEN 'body' + + -- Training goals + WHEN goal_type IN ('strength', 'flexibility', 'training_frequency') THEN 'training' + + -- Health/cardio goals + WHEN goal_type IN ('vo2max', 'rhr', 'bp', 'hrv') THEN 'health' + + -- Recovery goals + WHEN goal_type IN ('sleep_quality', 'sleep_duration', 'rest_days') THEN 'recovery' + + -- Nutrition goals + WHEN goal_type IN ('calories', 'protein', 'healthy_eating') THEN 'nutrition' + + -- Default + ELSE 'other' +END +WHERE category IS NULL; + +-- ============================================================================ +-- Set priority based on is_primary +-- ============================================================================ + +UPDATE goals SET priority = CASE + WHEN is_primary = true THEN 1 -- Primary goals get priority 1 + ELSE 2 -- Others get priority 2 (medium) +END; + +-- ============================================================================ +-- Create index for category-based queries +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_goals_category_priority +ON goals(profile_id, category, priority); + +COMMENT ON INDEX idx_goals_category_priority IS 'Fast lookup for category-grouped goals sorted by priority'; diff --git a/backend/migrations/029_fix_missing_goal_types.sql b/backend/migrations/029_fix_missing_goal_types.sql new file mode 100644 index 0000000..b40f5e8 --- /dev/null +++ b/backend/migrations/029_fix_missing_goal_types.sql @@ -0,0 +1,74 @@ +-- Migration 029: Fix Missing Goal Types (flexibility, strength) +-- Date: 2026-03-27 +-- Purpose: Ensure flexibility and strength goal types are active and properly configured + +-- These types were created earlier but are inactive or misconfigured +-- This migration fixes them without breaking if they don't exist + +-- ============================================================================ +-- Upsert flexibility goal type +-- ============================================================================ + +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, filter_conditions, description, is_active +) VALUES ( + 'flexibility', + 'Beweglichkeit', + 'Flexibility', + 'cm', + '🤸', + 'training', + NULL, -- No automatic data source + NULL, + 'latest', + NULL, + NULL, + 'Beweglichkeit und Mobilität - manuelle Erfassung', + true +) +ON CONFLICT (type_key) +DO UPDATE SET + label_de = 'Beweglichkeit', + label_en = 'Flexibility', + unit = 'cm', + icon = '🤸', + category = 'training', + is_active = true, + description = 'Beweglichkeit und Mobilität - manuelle Erfassung'; + +-- ============================================================================ +-- Upsert strength goal type +-- ============================================================================ + +INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, filter_conditions, description, is_active +) VALUES ( + 'strength', + 'Kraftniveau', + 'Strength', + 'Punkte', + '💪', + 'training', + NULL, -- No automatic data source + NULL, + 'latest', + NULL, + NULL, + 'Allgemeines Kraftniveau - manuelle Erfassung', + true +) +ON CONFLICT (type_key) +DO UPDATE SET + label_de = 'Kraftniveau', + label_en = 'Strength', + unit = 'Punkte', + icon = '💪', + category = 'training', + is_active = true, + description = 'Allgemeines Kraftniveau - manuelle Erfassung'; + +COMMENT ON TABLE goal_type_definitions IS 'Goal type registry - defines all available goal types (v1.5: DB-driven, flexible system)'; diff --git a/backend/migrations/030_goal_progress_log.sql b/backend/migrations/030_goal_progress_log.sql new file mode 100644 index 0000000..48080d4 --- /dev/null +++ b/backend/migrations/030_goal_progress_log.sql @@ -0,0 +1,64 @@ +-- Migration 030: Goal Progress Log +-- Date: 2026-03-27 +-- Purpose: Track progress history for all goals (especially custom goals without data source) + +-- ============================================================================ +-- Goal Progress Log Table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS goal_progress_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Progress data + date DATE NOT NULL, + value DECIMAL(10,2) NOT NULL, + note TEXT, + + -- Metadata + source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'automatic', 'import')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_progress_per_day UNIQUE(goal_id, date) +); + +CREATE INDEX idx_goal_progress_goal_date ON goal_progress_log(goal_id, date DESC); +CREATE INDEX idx_goal_progress_profile ON goal_progress_log(profile_id); + +COMMENT ON TABLE goal_progress_log IS 'Progress history for goals - enables manual tracking for custom goals and charts'; +COMMENT ON COLUMN goal_progress_log.value IS 'Progress value in goal unit (e.g., kg, cm, points)'; +COMMENT ON COLUMN goal_progress_log.source IS 'manual: user entered, automatic: computed from data source, import: CSV/API'; + +-- ============================================================================ +-- Function: Update goal current_value from latest progress +-- ============================================================================ + +CREATE OR REPLACE FUNCTION update_goal_current_value() +RETURNS TRIGGER AS $$ +BEGIN + -- Update current_value in goals table with latest progress entry + UPDATE goals + SET current_value = ( + SELECT value + FROM goal_progress_log + WHERE goal_id = NEW.goal_id + ORDER BY date DESC + LIMIT 1 + ), + updated_at = NOW() + WHERE id = NEW.goal_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger: Auto-update current_value when progress is added/updated +CREATE TRIGGER trigger_update_goal_current_value +AFTER INSERT OR UPDATE ON goal_progress_log +FOR EACH ROW +EXECUTE FUNCTION update_goal_current_value(); + +COMMENT ON FUNCTION update_goal_current_value IS 'Auto-update goal.current_value when new progress is logged'; diff --git a/backend/routers/goals.py b/backend/routers/goals.py new file mode 100644 index 0000000..be2af28 --- /dev/null +++ b/backend/routers/goals.py @@ -0,0 +1,1252 @@ +""" +Goals Router - Goal System (Strategic + Tactical) + +Endpoints for managing: +- Strategic goal modes (weight_loss, strength, etc.) +- Tactical goal targets (concrete values with deadlines) +- Training phase detection +- Fitness tests + +Part of v9e Goal System implementation. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime, timedelta +from decimal import Decimal +import traceback + +from db import get_db, get_cursor, r2d +from auth import require_auth +from goal_utils import get_current_value_for_goal + +router = APIRouter(prefix="/api/goals", tags=["goals"]) + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class GoalModeUpdate(BaseModel): + """Update strategic goal mode (deprecated - use FocusAreasUpdate)""" + goal_mode: str # weight_loss, strength, endurance, recomposition, health + +class FocusAreasUpdate(BaseModel): + """Update focus area weights (v2.0)""" + weight_loss_pct: int + muscle_gain_pct: int + strength_pct: int + endurance_pct: int + flexibility_pct: int + health_pct: int + +class GoalCreate(BaseModel): + """Create or update a concrete goal""" + goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr + is_primary: bool = False # Kept for backward compatibility + target_value: float + unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps + target_date: Optional[date] = None + category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other + priority: Optional[int] = 2 # 1=high, 2=medium, 3=low + name: Optional[str] = None + description: Optional[str] = None + +class GoalUpdate(BaseModel): + """Update existing goal""" + target_value: Optional[float] = None + target_date: Optional[date] = None + status: Optional[str] = None # active, reached, abandoned, expired + is_primary: Optional[bool] = None # Kept for backward compatibility + category: Optional[str] = None # body, training, nutrition, recovery, health, other + priority: Optional[int] = None # 1=high, 2=medium, 3=low + name: Optional[str] = None + description: Optional[str] = None + +class TrainingPhaseCreate(BaseModel): + """Create training phase (manual or auto-detected)""" + phase_type: str # calorie_deficit, calorie_surplus, deload, maintenance, periodization + start_date: date + end_date: Optional[date] = None + notes: Optional[str] = None + +class FitnessTestCreate(BaseModel): + """Record fitness test result""" + test_type: str + result_value: float + result_unit: str + test_date: date + test_conditions: Optional[str] = None + +class GoalProgressCreate(BaseModel): + """Log progress for a goal""" + date: date + value: float + note: Optional[str] = None + +class GoalProgressUpdate(BaseModel): + """Update progress entry""" + value: Optional[float] = None + note: Optional[str] = None + +class GoalTypeCreate(BaseModel): + """Create custom goal type definition""" + type_key: str + label_de: str + label_en: Optional[str] = None + unit: str + icon: Optional[str] = None + category: Optional[str] = 'custom' + source_table: Optional[str] = None + source_column: Optional[str] = None + aggregation_method: Optional[str] = 'latest' + calculation_formula: Optional[str] = None + filter_conditions: Optional[dict] = None + description: Optional[str] = None + +class GoalTypeUpdate(BaseModel): + """Update goal type definition""" + label_de: Optional[str] = None + label_en: Optional[str] = None + unit: Optional[str] = None + icon: Optional[str] = None + category: Optional[str] = None + source_table: Optional[str] = None + source_column: Optional[str] = None + aggregation_method: Optional[str] = None + calculation_formula: Optional[str] = None + filter_conditions: Optional[dict] = None + description: Optional[str] = None + is_active: Optional[bool] = None + +# ============================================================================ +# Strategic Layer: Goal Modes +# ============================================================================ + +@router.get("/mode") +def get_goal_mode(session: dict = Depends(require_auth)): + """Get user's current strategic goal mode""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT goal_mode FROM profiles WHERE id = %s", + (pid,) + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Profil nicht gefunden") + + return { + "goal_mode": row['goal_mode'] or 'health', + "description": _get_goal_mode_description(row['goal_mode'] or 'health') + } + +@router.put("/mode") +def update_goal_mode(data: GoalModeUpdate, session: dict = Depends(require_auth)): + """Update user's strategic goal mode""" + pid = session['profile_id'] + + # Validate goal mode + valid_modes = ['weight_loss', 'strength', 'endurance', 'recomposition', 'health'] + if data.goal_mode not in valid_modes: + raise HTTPException( + status_code=400, + detail=f"Ungültiger Goal Mode. Erlaubt: {', '.join(valid_modes)}" + ) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE profiles SET goal_mode = %s WHERE id = %s", + (data.goal_mode, pid) + ) + + return { + "goal_mode": data.goal_mode, + "description": _get_goal_mode_description(data.goal_mode) + } + +def _get_goal_mode_description(mode: str) -> str: + """Get description for goal mode""" + descriptions = { + 'weight_loss': 'Gewichtsreduktion (Kaloriendefizit, Fettabbau)', + 'strength': 'Kraftaufbau (Muskelwachstum, progressive Belastung)', + 'endurance': 'Ausdauer (VO2Max, aerobe Kapazität)', + 'recomposition': 'Körperkomposition (gleichzeitig Fett ab- und Muskeln aufbauen)', + 'health': 'Allgemeine Gesundheit (ausgewogen, präventiv)' + } + return descriptions.get(mode, 'Unbekannt') + +# ============================================================================ +# Focus Areas (v2.0): Weighted Multi-Goal System +# ============================================================================ + +@router.get("/focus-areas") +def get_focus_areas(session: dict = Depends(require_auth)): + """ + Get current focus area weights. + + Returns custom weights if set, otherwise derives from goal_mode. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Try to get custom focus areas + cur.execute(""" + SELECT weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct, + created_at, updated_at + FROM focus_areas + WHERE profile_id = %s AND active = true + LIMIT 1 + """, (pid,)) + + row = cur.fetchone() + + if row: + return { + "custom": True, + "weight_loss_pct": row['weight_loss_pct'], + "muscle_gain_pct": row['muscle_gain_pct'], + "strength_pct": row['strength_pct'], + "endurance_pct": row['endurance_pct'], + "flexibility_pct": row['flexibility_pct'], + "health_pct": row['health_pct'], + "updated_at": row['updated_at'] + } + + # Fallback: Derive from goal_mode + cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or not profile['goal_mode']: + # Default balanced health + return { + "custom": False, + "weight_loss_pct": 0, + "muscle_gain_pct": 0, + "strength_pct": 10, + "endurance_pct": 20, + "flexibility_pct": 15, + "health_pct": 55, + "source": "default" + } + + # Derive from goal_mode (using same logic as migration) + mode = profile['goal_mode'] + mode_mappings = { + 'weight_loss': { + 'weight_loss_pct': 60, + 'muscle_gain_pct': 0, + 'strength_pct': 10, + 'endurance_pct': 20, + 'flexibility_pct': 5, + 'health_pct': 5 + }, + 'strength': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 40, + 'strength_pct': 50, + 'endurance_pct': 10, + 'flexibility_pct': 0, + 'health_pct': 0 + }, + 'endurance': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 0, + 'strength_pct': 0, + 'endurance_pct': 70, + 'flexibility_pct': 10, + 'health_pct': 20 + }, + 'recomposition': { + 'weight_loss_pct': 30, + 'muscle_gain_pct': 30, + 'strength_pct': 25, + 'endurance_pct': 10, + 'flexibility_pct': 5, + 'health_pct': 0 + }, + 'health': { + 'weight_loss_pct': 0, + 'muscle_gain_pct': 0, + 'strength_pct': 10, + 'endurance_pct': 20, + 'flexibility_pct': 15, + 'health_pct': 55 + } + } + + mapping = mode_mappings.get(mode, mode_mappings['health']) + mapping['custom'] = False + mapping['source'] = f"goal_mode:{mode}" + return mapping + +@router.put("/focus-areas") +def update_focus_areas(data: FocusAreasUpdate, session: dict = Depends(require_auth)): + """ + Update focus area weights (upsert). + + Validates that sum = 100 and all values are 0-100. + """ + pid = session['profile_id'] + + # Validate sum = 100 + total = ( + data.weight_loss_pct + data.muscle_gain_pct + data.strength_pct + + data.endurance_pct + data.flexibility_pct + data.health_pct + ) + + if total != 100: + raise HTTPException( + status_code=400, + detail=f"Summe muss 100% sein (aktuell: {total}%)" + ) + + # Validate range 0-100 + values = [ + data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, + data.endurance_pct, data.flexibility_pct, data.health_pct + ] + + if any(v < 0 or v > 100 for v in values): + raise HTTPException( + status_code=400, + detail="Alle Werte müssen zwischen 0 und 100 liegen" + ) + + with get_db() as conn: + cur = get_cursor(conn) + + # Deactivate old focus_areas + cur.execute( + "UPDATE focus_areas SET active = false WHERE profile_id = %s", + (pid,) + ) + + # Insert new focus_areas + cur.execute(""" + INSERT INTO focus_areas ( + profile_id, weight_loss_pct, muscle_gain_pct, strength_pct, + endurance_pct, flexibility_pct, health_pct + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + pid, data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct, + data.endurance_pct, data.flexibility_pct, data.health_pct + )) + + return { + "message": "Fokus-Bereiche aktualisiert", + "weight_loss_pct": data.weight_loss_pct, + "muscle_gain_pct": data.muscle_gain_pct, + "strength_pct": data.strength_pct, + "endurance_pct": data.endurance_pct, + "flexibility_pct": data.flexibility_pct, + "health_pct": data.health_pct + } + +# ============================================================================ +# Tactical Layer: Concrete Goals +# ============================================================================ + +@router.get("/list") +def list_goals(session: dict = Depends(require_auth)): + """List all goals for current user""" + pid = session['profile_id'] + + try: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, goal_type, is_primary, status, + target_value, current_value, start_value, unit, + start_date, target_date, reached_date, + name, description, + progress_pct, projection_date, on_track, + created_at, updated_at + FROM goals + WHERE profile_id = %s + ORDER BY is_primary DESC, created_at DESC + """, (pid,)) + + goals = [r2d(row) for row in cur.fetchall()] + print(f"[DEBUG] Loaded {len(goals)} goals for profile {pid}") + + # Update current values for each goal + for goal in goals: + try: + _update_goal_progress(conn, pid, goal) + except Exception as e: + print(f"[ERROR] Failed to update progress for goal {goal.get('id')}: {e}") + # Continue with other goals even if one fails + + return goals + + except Exception as e: + print(f"[ERROR] list_goals failed: {e}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=500, + detail=f"Fehler beim Laden der Ziele: {str(e)}" + ) + +@router.post("/create") +def create_goal(data: GoalCreate, session: dict = Depends(require_auth)): + """Create new goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # If this is set as primary, unset other primary goals + if data.is_primary: + cur.execute( + "UPDATE goals SET is_primary = false WHERE profile_id = %s", + (pid,) + ) + + # Get current value for this goal type + current_value = _get_current_value_for_goal_type(conn, pid, data.goal_type) + + # Insert goal + cur.execute(""" + INSERT INTO goals ( + profile_id, goal_type, is_primary, + target_value, current_value, start_value, unit, + target_date, category, priority, name, description + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + pid, data.goal_type, data.is_primary, + data.target_value, current_value, current_value, data.unit, + data.target_date, data.category, data.priority, data.name, data.description + )) + + goal_id = cur.fetchone()['id'] + + return {"id": goal_id, "message": "Ziel erstellt"} + +@router.put("/{goal_id}") +def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_auth)): + """Update existing goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # If setting this goal as primary, unset all other primary goals + if data.is_primary is True: + cur.execute( + "UPDATE goals SET is_primary = false WHERE profile_id = %s AND id != %s", + (pid, goal_id) + ) + + # Build update query dynamically + updates = [] + params = [] + + if data.target_value is not None: + updates.append("target_value = %s") + params.append(data.target_value) + + if data.target_date is not None: + updates.append("target_date = %s") + params.append(data.target_date) + + if data.status is not None: + updates.append("status = %s") + params.append(data.status) + if data.status == 'reached': + updates.append("reached_date = CURRENT_DATE") + + if data.is_primary is not None: + updates.append("is_primary = %s") + params.append(data.is_primary) + + if data.category is not None: + updates.append("category = %s") + params.append(data.category) + + if data.priority is not None: + updates.append("priority = %s") + params.append(data.priority) + + if data.name is not None: + updates.append("name = %s") + params.append(data.name) + + if data.description is not None: + updates.append("description = %s") + params.append(data.description) + + if not updates: + raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") + + updates.append("updated_at = NOW()") + params.extend([goal_id, pid]) + + cur.execute( + f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s", + tuple(params) + ) + + return {"message": "Ziel aktualisiert"} + +@router.delete("/{goal_id}") +def delete_goal(goal_id: str, session: dict = Depends(require_auth)): + """Delete goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + return {"message": "Ziel gelöscht"} + +# ============================================================================ +# Goal Progress Endpoints +# ============================================================================ + +@router.get("/{goal_id}/progress") +def get_goal_progress(goal_id: str, session: dict = Depends(require_auth)): + """Get progress history for a goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Get progress entries + cur.execute(""" + SELECT id, date, value, note, source, created_at + FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + """, (goal_id,)) + + entries = cur.fetchall() + return [r2d(e) for e in entries] + +@router.post("/{goal_id}/progress") +def create_goal_progress(goal_id: str, data: GoalProgressCreate, session: dict = Depends(require_auth)): + """Log new progress for a goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership and check if manual entry is allowed + cur.execute(""" + SELECT g.id, g.unit, gt.source_table + FROM goals g + LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key + WHERE g.id = %s AND g.profile_id = %s + """, (goal_id, pid)) + goal = cur.fetchone() + if not goal: + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Prevent manual entries for goals with automatic data sources + if goal['source_table']: + raise HTTPException( + status_code=400, + detail=f"Manuelle Einträge nicht erlaubt für automatisch erfasste Ziele. " + f"Bitte nutze die entsprechende Erfassungsseite (z.B. Gewicht, Aktivität)." + ) + + # Insert progress entry + try: + cur.execute(""" + INSERT INTO goal_progress_log (goal_id, profile_id, date, value, note, source) + VALUES (%s, %s, %s, %s, %s, 'manual') + RETURNING id + """, (goal_id, pid, data.date, data.value, data.note)) + + progress_id = cur.fetchone()['id'] + + # Trigger will auto-update goals.current_value + return { + "id": progress_id, + "message": f"Fortschritt erfasst: {data.value} {goal['unit']}" + } + + except Exception as e: + if "unique_progress_per_day" in str(e): + raise HTTPException( + status_code=400, + detail=f"Für {data.date} existiert bereits ein Eintrag. Bitte bearbeite den existierenden Eintrag." + ) + raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {str(e)}") + +@router.delete("/{goal_id}/progress/{progress_id}") +def delete_goal_progress(goal_id: str, progress_id: str, session: dict = Depends(require_auth)): + """Delete progress entry""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Delete progress entry + cur.execute( + "DELETE FROM goal_progress_log WHERE id = %s AND goal_id = %s AND profile_id = %s", + (progress_id, goal_id, pid) + ) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Progress-Eintrag nicht gefunden") + + # After deletion, recalculate current_value from remaining entries + cur.execute(""" + UPDATE goals + SET current_value = ( + SELECT value FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + LIMIT 1 + ) + WHERE id = %s + """, (goal_id, goal_id)) + + return {"message": "Progress-Eintrag gelöscht"} + +@router.get("/grouped") +def get_goals_grouped(session: dict = Depends(require_auth)): + """ + Get goals grouped by category, sorted by priority. + + Returns structure: + { + "body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...], + "training": [...], + "nutrition": [...], + "recovery": [...], + "health": [...], + "other": [...] + } + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all active goals with type definitions + cur.execute(""" + SELECT + g.id, g.goal_type, g.target_value, g.current_value, g.start_value, + g.unit, g.target_date, g.status, g.is_primary, g.category, g.priority, + g.name, g.description, g.progress_pct, g.on_track, g.projection_date, + g.created_at, g.updated_at, + gt.label_de, gt.icon, gt.category as type_category, + gt.source_table, gt.source_column + FROM goals g + LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key + WHERE g.profile_id = %s + ORDER BY g.category, g.priority ASC, g.created_at DESC + """, (pid,)) + + goals = cur.fetchall() + + # Group by category + grouped = {} + for goal in goals: + cat = goal['category'] or 'other' + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append(r2d(goal)) + + return grouped + +# ============================================================================ +# Training Phases +# ============================================================================ + +@router.get("/phases") +def list_training_phases(session: dict = Depends(require_auth)): + """List training phases""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, phase_type, detected_automatically, confidence_score, + status, start_date, end_date, duration_days, + detection_params, notes, created_at + FROM training_phases + WHERE profile_id = %s + ORDER BY start_date DESC + """, (pid,)) + + return [r2d(row) for row in cur.fetchall()] + +@router.post("/phases") +def create_training_phase(data: TrainingPhaseCreate, session: dict = Depends(require_auth)): + """Create training phase (manual)""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + duration = None + if data.end_date: + duration = (data.end_date - data.start_date).days + + cur.execute(""" + INSERT INTO training_phases ( + profile_id, phase_type, detected_automatically, + status, start_date, end_date, duration_days, notes + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + pid, data.phase_type, False, + 'active', data.start_date, data.end_date, duration, data.notes + )) + + phase_id = cur.fetchone()['id'] + + return {"id": phase_id, "message": "Trainingsphase erstellt"} + +@router.put("/phases/{phase_id}/status") +def update_phase_status( + phase_id: str, + status: str, + session: dict = Depends(require_auth) +): + """Update training phase status (accept/reject auto-detected phases)""" + pid = session['profile_id'] + + valid_statuses = ['suggested', 'accepted', 'active', 'completed', 'rejected'] + if status not in valid_statuses: + raise HTTPException( + status_code=400, + detail=f"Ungültiger Status. Erlaubt: {', '.join(valid_statuses)}" + ) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE training_phases SET status = %s WHERE id = %s AND profile_id = %s", + (status, phase_id, pid) + ) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Trainingsphase nicht gefunden") + + return {"message": "Status aktualisiert"} + +# ============================================================================ +# Fitness Tests +# ============================================================================ + +@router.get("/tests") +def list_fitness_tests(session: dict = Depends(require_auth)): + """List all fitness tests""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, test_type, result_value, result_unit, + test_date, test_conditions, norm_category, created_at + FROM fitness_tests + WHERE profile_id = %s + ORDER BY test_date DESC + """, (pid,)) + + return [r2d(row) for row in cur.fetchall()] + +@router.post("/tests") +def create_fitness_test(data: FitnessTestCreate, session: dict = Depends(require_auth)): + """Record fitness test result""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Calculate norm category (simplified for now) + norm_category = _calculate_norm_category( + data.test_type, + data.result_value, + data.result_unit + ) + + cur.execute(""" + INSERT INTO fitness_tests ( + profile_id, test_type, result_value, result_unit, + test_date, test_conditions, norm_category + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + pid, data.test_type, data.result_value, data.result_unit, + data.test_date, data.test_conditions, norm_category + )) + + test_id = cur.fetchone()['id'] + + return {"id": test_id, "norm_category": norm_category} + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> Optional[float]: + """ + Get current value for a goal type. + + DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py. + Phase 1.5: All goal types are now defined in goal_type_definitions table. + + Args: + conn: Database connection + profile_id: User's profile ID + goal_type: Goal type key (e.g., 'weight', 'meditation_minutes') + + Returns: + Current value or None + """ + # Delegate to universal fetcher (Phase 1.5) + return get_current_value_for_goal(conn, profile_id, goal_type) + +def _update_goal_progress(conn, profile_id: str, goal: dict): + """Update goal progress (modifies goal dict in-place)""" + # Get current value + current = _get_current_value_for_goal_type(conn, profile_id, goal['goal_type']) + + if current is not None and goal['start_value'] is not None and goal['target_value'] is not None: + goal['current_value'] = current + + # Calculate progress percentage + total_delta = float(goal['target_value']) - float(goal['start_value']) + current_delta = current - float(goal['start_value']) + + if total_delta != 0: + progress_pct = (current_delta / total_delta) * 100 + goal['progress_pct'] = round(progress_pct, 2) + + # Simple linear projection + if goal['start_date'] and current_delta != 0: + days_elapsed = (date.today() - goal['start_date']).days + if days_elapsed > 0: + days_per_unit = days_elapsed / current_delta + remaining_units = float(goal['target_value']) - current + remaining_days = int(days_per_unit * remaining_units) + goal['projection_date'] = date.today() + timedelta(days=remaining_days) + + # Check if on track + if goal['target_date'] and goal['projection_date']: + goal['on_track'] = goal['projection_date'] <= goal['target_date'] + +def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optional[str]: + """ + Calculate norm category for fitness test + (Simplified - would need age/gender-specific norms) + """ + # Placeholder - should use proper norm tables + return None + +# ============================================================================ +# Goal Type Definitions (Phase 1.5 - Flexible Goal System) +# ============================================================================ + +@router.get("/schema-info") +def get_schema_info(session: dict = Depends(require_auth)): + """ + Get available tables and columns for goal type creation. + + Admin-only endpoint for building custom goal types. + Returns structure with descriptions for UX guidance. + """ + pid = session['profile_id'] + + # Check admin role + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException(status_code=403, detail="Admin-Zugriff erforderlich") + + # Define relevant tables with descriptions + # Only include tables that make sense for goal tracking + schema = { + "weight_log": { + "description": "Gewichtsverlauf", + "columns": { + "weight": {"type": "DECIMAL", "description": "Körpergewicht in kg"} + } + }, + "caliper_log": { + "description": "Caliper-Messungen (Hautfalten)", + "columns": { + "body_fat_pct": {"type": "DECIMAL", "description": "Körperfettanteil in %"}, + "sum_mm": {"type": "DECIMAL", "description": "Summe Hautfalten in mm"} + } + }, + "circumference_log": { + "description": "Umfangsmessungen", + "columns": { + "c_neck": {"type": "DECIMAL", "description": "Nackenumfang in cm"}, + "c_chest": {"type": "DECIMAL", "description": "Brustumfang in cm"}, + "c_waist": {"type": "DECIMAL", "description": "Taillenumfang in cm"}, + "c_hips": {"type": "DECIMAL", "description": "Hüftumfang in cm"}, + "c_thigh_l": {"type": "DECIMAL", "description": "Oberschenkel links in cm"}, + "c_thigh_r": {"type": "DECIMAL", "description": "Oberschenkel rechts in cm"}, + "c_calf_l": {"type": "DECIMAL", "description": "Wade links in cm"}, + "c_calf_r": {"type": "DECIMAL", "description": "Wade rechts in cm"}, + "c_bicep_l": {"type": "DECIMAL", "description": "Bizeps links in cm"}, + "c_bicep_r": {"type": "DECIMAL", "description": "Bizeps rechts in cm"} + } + }, + "activity_log": { + "description": "Trainingseinheiten", + "columns": { + "id": {"type": "UUID", "description": "ID (für Zählung von Einheiten)"}, + "duration_minutes": {"type": "INTEGER", "description": "Trainingsdauer in Minuten"}, + "perceived_exertion": {"type": "INTEGER", "description": "Belastungsempfinden (1-10)"}, + "quality_rating": {"type": "INTEGER", "description": "Qualitätsbewertung (1-10)"} + } + }, + "nutrition_log": { + "description": "Ernährungstagebuch", + "columns": { + "calories": {"type": "INTEGER", "description": "Kalorien in kcal"}, + "protein_g": {"type": "DECIMAL", "description": "Protein in g"}, + "carbs_g": {"type": "DECIMAL", "description": "Kohlenhydrate in g"}, + "fat_g": {"type": "DECIMAL", "description": "Fett in g"} + } + }, + "sleep_log": { + "description": "Schlafprotokoll", + "columns": { + "total_minutes": {"type": "INTEGER", "description": "Gesamtschlafdauer in Minuten"} + } + }, + "vitals_baseline": { + "description": "Vitalwerte (morgens)", + "columns": { + "resting_hr": {"type": "INTEGER", "description": "Ruhepuls in bpm"}, + "hrv_rmssd": {"type": "INTEGER", "description": "Herzratenvariabilität (RMSSD) in ms"}, + "vo2_max": {"type": "DECIMAL", "description": "VO2 Max in ml/kg/min"}, + "spo2": {"type": "INTEGER", "description": "Sauerstoffsättigung in %"}, + "respiratory_rate": {"type": "INTEGER", "description": "Atemfrequenz pro Minute"} + } + }, + "blood_pressure_log": { + "description": "Blutdruckmessungen", + "columns": { + "systolic": {"type": "INTEGER", "description": "Systolischer Blutdruck in mmHg"}, + "diastolic": {"type": "INTEGER", "description": "Diastolischer Blutdruck in mmHg"}, + "pulse": {"type": "INTEGER", "description": "Puls in bpm"} + } + }, + "rest_days": { + "description": "Ruhetage", + "columns": { + "id": {"type": "UUID", "description": "ID (für Zählung von Ruhetagen)"} + } + } + } + + return schema + +@router.get("/goal-types") +def list_goal_type_definitions(session: dict = Depends(require_auth)): + """ + Get all active goal type definitions. + + Public endpoint - returns all available goal types for dropdown. + """ + try: + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT id, type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, description, is_system, is_active, + created_at, updated_at + FROM goal_type_definitions + WHERE is_active = true + ORDER BY + CASE + WHEN is_system = true THEN 0 + ELSE 1 + END, + label_de + """) + + results = [r2d(row) for row in cur.fetchall()] + print(f"[DEBUG] Loaded {len(results)} goal types") + return results + + except Exception as e: + print(f"[ERROR] list_goal_type_definitions failed: {e}") + print(traceback.format_exc()) + raise HTTPException( + status_code=500, + detail=f"Fehler beim Laden der Goal Types: {str(e)}" + ) + +@router.post("/goal-types") +def create_goal_type_definition( + data: GoalTypeCreate, + session: dict = Depends(require_auth) +): + """ + Create custom goal type definition. + + Admin-only endpoint for creating new goal types. + Users with admin role can define custom metrics. + """ + pid = session['profile_id'] + + # Check admin role + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Validate type_key is unique + cur.execute( + "SELECT id FROM goal_type_definitions WHERE type_key = %s", + (data.type_key,) + ) + if cur.fetchone(): + raise HTTPException( + status_code=400, + detail=f"Goal Type '{data.type_key}' existiert bereits" + ) + + # 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(""" + INSERT INTO goal_type_definitions ( + type_key, label_de, label_en, unit, icon, category, + source_table, source_column, aggregation_method, + calculation_formula, filter_conditions, description, is_active, is_system + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.type_key, data.label_de, data.label_en, data.unit, data.icon, + data.category, data.source_table, data.source_column, + data.aggregation_method, data.calculation_formula, filter_json, data.description, + True, False # is_active=True, is_system=False + )) + + goal_type_id = cur.fetchone()['id'] + + return { + "id": goal_type_id, + "message": f"Goal Type '{data.label_de}' erstellt" + } + +@router.put("/goal-types/{goal_type_id}") +def update_goal_type_definition( + goal_type_id: str, + data: GoalTypeUpdate, + session: dict = Depends(require_auth) +): + """ + Update goal type definition. + + Admin-only. System goal types can be updated but not deleted. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Check admin role + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Check goal type exists + cur.execute( + "SELECT id FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") + + # Build update query + updates = [] + params = [] + + if data.label_de is not None: + updates.append("label_de = %s") + params.append(data.label_de) + + if data.label_en is not None: + updates.append("label_en = %s") + params.append(data.label_en) + + if data.unit is not None: + updates.append("unit = %s") + params.append(data.unit) + + if data.icon is not None: + updates.append("icon = %s") + params.append(data.icon) + + if data.category is not None: + updates.append("category = %s") + params.append(data.category) + + if data.source_table is not None: + updates.append("source_table = %s") + params.append(data.source_table) + + if data.source_column is not None: + updates.append("source_column = %s") + params.append(data.source_column) + + if data.aggregation_method is not None: + updates.append("aggregation_method = %s") + params.append(data.aggregation_method) + + if data.calculation_formula is not None: + updates.append("calculation_formula = %s") + 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: + updates.append("description = %s") + params.append(data.description) + + if data.is_active is not None: + updates.append("is_active = %s") + params.append(data.is_active) + + if not updates: + raise HTTPException(status_code=400, detail="Keine Änderungen angegeben") + + updates.append("updated_at = NOW()") + params.append(goal_type_id) + + cur.execute( + f"UPDATE goal_type_definitions SET {', '.join(updates)} WHERE id = %s", + tuple(params) + ) + + return {"message": "Goal Type aktualisiert"} + +@router.delete("/goal-types/{goal_type_id}") +def delete_goal_type_definition( + goal_type_id: str, + session: dict = Depends(require_auth) +): + """ + Delete (deactivate) goal type definition. + + Admin-only. System goal types cannot be deleted, only deactivated. + Custom goal types can be fully deleted if no goals reference them. + """ + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Check admin role + cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,)) + profile = cur.fetchone() + + if not profile or profile['role'] != 'admin': + raise HTTPException( + status_code=403, + detail="Admin-Zugriff erforderlich" + ) + + # Get goal type info + cur.execute( + "SELECT id, type_key, is_system FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + goal_type = cur.fetchone() + + if not goal_type: + raise HTTPException(status_code=404, detail="Goal Type nicht gefunden") + + # Check if any goals use this type + cur.execute( + "SELECT COUNT(*) as count FROM goals WHERE goal_type = %s", + (goal_type['type_key'],) + ) + count = cur.fetchone()['count'] + + if count > 0: + # Deactivate instead of delete + cur.execute( + "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", + (goal_type_id,) + ) + return { + "message": f"Goal Type deaktiviert ({count} Ziele nutzen diesen Typ)" + } + else: + if goal_type['is_system']: + # System types: only deactivate + cur.execute( + "UPDATE goal_type_definitions SET is_active = false WHERE id = %s", + (goal_type_id,) + ) + return {"message": "System Goal Type deaktiviert"} + else: + # Custom types: delete + cur.execute( + "DELETE FROM goal_type_definitions WHERE id = %s", + (goal_type_id,) + ) + return {"message": "Goal Type gelöscht"} diff --git a/backend/run_migration_024.py b/backend/run_migration_024.py new file mode 100644 index 0000000..b3cc132 --- /dev/null +++ b/backend/run_migration_024.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Manual Migration 024 Runner + +Run this to manually execute Migration 024 if it didn't run automatically. +""" + +import psycopg2 +import os +from psycopg2.extras import RealDictCursor + +# Database connection +DB_HOST = os.getenv('DB_HOST', 'localhost') +DB_PORT = os.getenv('DB_PORT', '5432') +DB_NAME = os.getenv('DB_NAME', 'bodytrack') +DB_USER = os.getenv('DB_USER', 'bodytrack') +DB_PASS = os.getenv('DB_PASSWORD', '') + +def main(): + print("🔧 Manual Migration 024 Runner") + print("=" * 60) + + # Connect to database + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASS + ) + conn.autocommit = False + cur = conn.cursor(cursor_factory=RealDictCursor) + + try: + # Check if table exists + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'goal_type_definitions' + ) + """) + exists = cur.fetchone()['exists'] + + if exists: + print("✓ goal_type_definitions table already exists") + + # Check if it has data + cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions") + count = cur.fetchone()['count'] + print(f"✓ Table has {count} entries") + + if count > 0: + print("\n📊 Existing Goal Types:") + cur.execute(""" + SELECT type_key, label_de, unit, is_system, is_active + FROM goal_type_definitions + ORDER BY is_system DESC, label_de + """) + for row in cur.fetchall(): + status = "SYSTEM" if row['is_system'] else "CUSTOM" + active = "ACTIVE" if row['is_active'] else "INACTIVE" + print(f" - {row['type_key']}: {row['label_de']} ({row['unit']}) [{status}] [{active}]") + + print("\n✅ Migration 024 is already complete!") + return + + # Run migration + print("\n🚀 Running Migration 024...") + + with open('migrations/024_goal_type_registry.sql', 'r', encoding='utf-8') as f: + migration_sql = f.read() + + cur.execute(migration_sql) + conn.commit() + + print("✅ Migration 024 executed successfully!") + + # Verify + cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions") + count = cur.fetchone()['count'] + print(f"✓ {count} goal types seeded") + + # Show created types + cur.execute(""" + SELECT type_key, label_de, unit, is_system + FROM goal_type_definitions + WHERE is_active = true + ORDER BY is_system DESC, label_de + """) + + print("\n📊 Created Goal Types:") + for row in cur.fetchall(): + status = "SYSTEM" if row['is_system'] else "CUSTOM" + print(f" - {row['type_key']}: {row['label_de']} ({row['unit']}) [{status}]") + + # Update schema_migrations + cur.execute(""" + INSERT INTO schema_migrations (filename, executed_at) + VALUES ('024_goal_type_registry.sql', NOW()) + ON CONFLICT (filename) DO NOTHING + """) + conn.commit() + + print("\n✅ Migration 024 complete!") + + except Exception as e: + conn.rollback() + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + finally: + cur.close() + conn.close() + +if __name__ == '__main__': + main() diff --git a/docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md b/docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md new file mode 100644 index 0000000..f69134a --- /dev/null +++ b/docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md @@ -0,0 +1,595 @@ +# Zielesystem: Vereinheitlichte Analyse beider Fachkonzepte + +**Datum:** 26. März 2026 +**Basis:** +- `.claude/docs/functional/GOALS_VITALS.md` (v9e Spec) +- `.claude/docs/functional/mitai_jinkendo_konzept_diagramme_auswertungen_v2.md` + +--- + +## 1. Wichtige Erkenntnis: BEIDE Konzepte sind komplementär! + +### GOALS_VITALS.md definiert: +- **Konkrete Zielwerte** (z.B. "82kg bis 30.06.2026") +- 8 Zieltypen (Gewicht, KF%, VO2Max, etc.) +- Primär-/Nebenziel-Konzept +- Trainingsphasen (automatische Erkennung) +- Aktive Tests (Cooper, Liegestütze, etc.) +- 13 neue KI-Platzhalter + +### Konzept v2 definiert: +- **Goal Modes** (strategische Ausrichtung: weight_loss, strength, etc.) +- Score-Gewichtung je Goal Mode +- Chart-Priorisierung je Goal Mode +- Regelbasierte Interpretationen + +### Zusammenspiel: +``` +Goal MODE (v2) → "weight_loss" (strategische Ausrichtung) + ↓ +Primary GOAL (v9e) → "82kg bis 30.06.2026" (konkretes Ziel) +Secondary GOAL → "16% Körperfett" + ↓ +Training PHASE (v9e) → "Kaloriendefizit" (automatisch erkannt) + ↓ +Score Weights (v2) → body_progress: 0.30, nutrition: 0.25, ... + ↓ +Charts (v2) → Zeigen gewichtete Scores + Fortschritt zu Zielen +``` + +--- + +## 2. Zwei-Ebenen-Architektur + +### Ebene 1: STRATEGIC (Goal Modes aus v2) +**Was:** Grundsätzliche Trainingsausrichtung +**Werte:** weight_loss, strength, endurance, recomposition, health +**Zweck:** Bestimmt Score-Gewichtung und Interpretations-Kontext +**Beispiel:** "Ich will Kraft aufbauen" → mode: strength + +### Ebene 2: TACTICAL (Goal Targets aus v9e) +**Was:** Konkrete messbare Ziele +**Werte:** "82kg bis 30.06.2026", "VO2Max 55 ml/kg/min", "50 Liegestütze" +**Zweck:** Fortschritts-Tracking, Prognosen, Motivation +**Beispiel:** "Ich will 82kg wiegen" → target: Gewichtsziel + +### Beide zusammen = Vollständiges Zielesystem + +--- + +## 3. Überarbeitetes Datenmodell + +### Tabelle: `profiles` (erweitern) +```sql +-- Strategic Goal Mode (aus v2) +ALTER TABLE profiles ADD COLUMN goal_mode VARCHAR(50) DEFAULT 'health'; + +COMMENT ON COLUMN profiles.goal_mode IS + 'Strategic goal mode: weight_loss, strength, endurance, recomposition, health. + Determines score weights and interpretation context.'; +``` + +### Tabelle: `goals` (NEU, aus v9e) +```sql +CREATE TABLE goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Goal Classification + goal_type VARCHAR(50) NOT NULL, -- weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr + is_primary BOOLEAN DEFAULT false, + status VARCHAR(20) DEFAULT 'active', -- draft, active, reached, abandoned, expired + + -- Target Values + target_value DECIMAL(10,2), + current_value DECIMAL(10,2), + start_value DECIMAL(10,2), + unit VARCHAR(20), -- kg, %, ml/kg/min, bpm, mmHg, cm, reps + + -- Timeline + start_date DATE DEFAULT CURRENT_DATE, + target_date DATE, + reached_date DATE, + + -- Metadata + name VARCHAR(100), -- z.B. "Sommerfigur 2026" + description TEXT, + + -- Progress Tracking + progress_pct DECIMAL(5,2), -- Auto-calculated: (current - start) / (target - start) * 100 + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CHECK (progress_pct >= 0 AND progress_pct <= 100), + CHECK (status IN ('draft', 'active', 'reached', 'abandoned', 'expired')) +); + +-- Only one primary goal per profile +CREATE UNIQUE INDEX idx_goals_primary ON goals(profile_id, is_primary) WHERE is_primary = true; + +-- Index for active goals lookup +CREATE INDEX idx_goals_active ON goals(profile_id, status) WHERE status = 'active'; +``` + +### Tabelle: `training_phases` (NEU, aus v9e) +```sql +CREATE TABLE training_phases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Phase Type + phase_type VARCHAR(50) NOT NULL, + -- Werte: calorie_deficit, calorie_maintenance, calorie_surplus, + -- conditioning, hiit, max_strength, regeneration, competition_prep + + -- Detection + detected_automatically BOOLEAN DEFAULT false, + confidence_score DECIMAL(3,2), -- 0.00-1.00 + + -- Status + status VARCHAR(20) DEFAULT 'suggested', -- suggested, confirmed, active, ended + + -- Timeline + start_date DATE, + end_date DATE, + + -- Metadata + detection_reason TEXT, -- Why was this phase detected? + user_notes TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Only one active phase per profile +CREATE UNIQUE INDEX idx_phases_active ON training_phases(profile_id, status) WHERE status = 'active'; +``` + +### Tabelle: `fitness_tests` (NEU, aus v9e) +```sql +CREATE TABLE fitness_tests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Test Type + test_type VARCHAR(50) NOT NULL, + -- Standard: cooper, step_test, pushups, squats, sit_reach, balance, grip_strength + -- Custom: user_defined + + -- Result + result_value DECIMAL(10,2) NOT NULL, + result_unit VARCHAR(20) NOT NULL, -- meters, bpm, reps, cm, seconds, kg + + -- Test Date + test_date DATE NOT NULL, + + -- Evaluation + norm_category VARCHAR(30), -- very_good, good, average, needs_improvement + percentile DECIMAL(5,2), -- Where user ranks vs. norm (0-100) + + -- Trend + improvement_vs_last DECIMAL(10,2), -- % change from previous test + + -- Metadata + notes TEXT, + conditions TEXT, -- e.g., "Nach 3h Schlaf, erkältet" + + -- Next Test Recommendation + recommended_retest_date DATE, + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_fitness_tests_profile_type ON fitness_tests(profile_id, test_type, test_date DESC); +``` + +--- + +## 4. Vereinheitlichte API-Struktur + +### Goal Modes (Strategic) +```python +# routers/goals.py + +@router.get("/modes") +def get_goal_modes(): + """Get all strategic goal modes with score weights.""" + return GOAL_MODES # From v2 concept + +@router.post("/set-mode") +def set_goal_mode(goal_mode: str, session=Depends(require_auth)): + """Set user's strategic goal mode.""" + # Updates profiles.goal_mode +``` + +### Goal Targets (Tactical) +```python +@router.get("/targets") +def get_goal_targets(session=Depends(require_auth)): + """Get all active goal targets.""" + profile_id = session['profile_id'] + # Returns list from goals table + # Includes: primary + all secondary goals + +@router.post("/targets") +def create_goal_target(goal: GoalCreate, session=Depends(require_auth)): + """Create a new goal target.""" + # Inserts into goals table + # Auto-calculates progress_pct + +@router.get("/targets/{goal_id}") +def get_goal_detail(goal_id: str, session=Depends(require_auth)): + """Get detailed goal info with history.""" + # Returns goal + progress history + prognosis + +@router.put("/targets/{goal_id}/progress") +def update_goal_progress(goal_id: str, session=Depends(require_auth)): + """Recalculate goal progress.""" + # Auto-called after new measurements + # Updates current_value, progress_pct + +@router.post("/targets/{goal_id}/reach") +def mark_goal_reached(goal_id: str, session=Depends(require_auth)): + """Mark goal as reached.""" + # Sets status='reached', reached_date=today +``` + +### Training Phases +```python +@router.get("/phases/current") +def get_current_phase(session=Depends(require_auth)): + """Get active training phase.""" + +@router.get("/phases/detect") +def detect_phase(session=Depends(require_auth)): + """Run phase detection algorithm.""" + # Analyzes last 14 days + # Returns suggested phase + confidence + reasoning + +@router.post("/phases/confirm") +def confirm_phase(phase_id: str, session=Depends(require_auth)): + """Confirm detected phase.""" + # Sets status='active' +``` + +### Fitness Tests +```python +@router.get("/tests/types") +def get_test_types(): + """Get all available fitness tests.""" + +@router.post("/tests/{test_type}/execute") +def record_test_result( + test_type: str, + result_value: float, + result_unit: str, + session=Depends(require_auth) +): + """Record a fitness test result.""" + # Inserts into fitness_tests + # Auto-calculates norm_category, percentile, improvement + +@router.get("/tests/due") +def get_due_tests(session=Depends(require_auth)): + """Get tests that are due for retesting.""" +``` + +--- + +## 5. Neue KI-Platzhalter (kombiniert aus beiden Konzepten) + +### Strategic (aus v2) +```python +{{goal_mode}} # "weight_loss" +{{goal_mode_label}} # "Gewichtsreduktion" +{{goal_mode_description}} # "Fettabbau bei Erhalt der Magermasse" +``` + +### Tactical - Primary Goal (aus v9e) +```python +{{primary_goal_type}} # "weight" +{{primary_goal_name}} # "Sommerfigur 2026" +{{primary_goal_target}} # "82 kg bis 30.06.2026" +{{primary_goal_current}} # "85.2 kg" +{{primary_goal_start}} # "86.1 kg" +{{primary_goal_progress_pct}} # "72%" +{{primary_goal_progress_text}} # "72% erreicht (4 kg von 5,5 kg)" +{{primary_goal_days_remaining}} # "45 Tage" +{{primary_goal_prognosis}} # "Ziel voraussichtlich in 6 Wochen erreicht (3 Wochen früher!)" +{{primary_goal_on_track}} # "true" +``` + +### Tactical - Secondary Goals (aus v9e) +```python +{{secondary_goals_count}} # "2" +{{secondary_goals_list}} # "16% Körperfett, VO2Max 55 ml/kg/min" +{{secondary_goal_1_type}} # "body_fat" +{{secondary_goal_1_progress}} # "45%" +``` + +### Training Phase (aus v9e) +```python +{{current_phase}} # "calorie_deficit" +{{current_phase_label}} # "Kaloriendefizit" +{{phase_since}} # "seit 14 Tagen" +{{phase_confidence}} # "0.92" +{{phase_recommendation}} # "Krafttraining erhalten, Cardio moderat, Proteinzufuhr 2g/kg" +{{phase_detected_automatically}} # "true" +``` + +### Fitness Tests (aus v9e) +```python +{{test_last_cooper}} # "2.800m (VO2Max ~52) vor 3 Wochen" +{{test_last_cooper_date}} # "2026-03-05" +{{test_last_cooper_result}} # "2800" +{{test_last_cooper_vo2max}} # "52.3" +{{test_last_cooper_category}} # "good" +{{test_due_list}} # "Sit & Reach (seit 5 Wochen), Liegestütze (seit 4 Wochen)" +{{test_next_recommended}} # "Cooper-Test (in 2 Wochen fällig)" +{{fitness_score_overall}} # "72/100" +{{fitness_score_endurance}} # "good" +{{fitness_score_strength}} # "average" +{{fitness_score_flexibility}} # "needs_improvement" +``` + +### GESAMT: 35+ neue Platzhalter aus v9e +Plus die 84 aus v2 = **120+ neue Platzhalter total** + +--- + +## 6. Überarbeitete Implementierungs-Roadmap + +### Phase 0a: Minimal Goal System (3-4h) ⭐ **JETZT** + +**Strategic Layer:** +- DB: `goal_mode` in profiles +- Backend: GOAL_MODES aus v2 +- API: GET/SET goal mode +- UI: Goal Mode Selector (5 Modi) + +**Tactical Layer:** +- DB: `goals` table +- API: CRUD für goal targets +- UI: Goal Management Page (minimal) + - Liste aktiver Ziele + - Fortschrittsbalken + - "+ Neues Ziel" Button + +**Aufwand:** 3-4h (erweitert wegen Tactical Layer) + +--- + +### Phase 0b: Goal-Aware Placeholders (16-20h) + +**Strategic Placeholders:** +```python +{{goal_mode}} # Aus profiles.goal_mode +{{goal_mode_label}} # Aus GOAL_MODES mapping +``` + +**Tactical Placeholders:** +```python +{{primary_goal_type}} # Aus goals WHERE is_primary=true +{{primary_goal_target}} +{{primary_goal_progress_pct}} +{{primary_goal_prognosis}} # Berechnet aus Trend +``` + +**Score Calculations (goal-aware):** +```python +def get_body_progress_score(profile_id: str) -> str: + profile = get_profile_data(profile_id) + goal_mode = profile.get('goal_mode', 'health') + + # Get weights from v2 concept + weights = GOAL_MODES[goal_mode]['score_weights'] + + # Calculate sub-scores + fm_score = calculate_fm_progress(profile_id) + lbm_score = calculate_lbm_progress(profile_id) + + # Weight according to goal mode + if goal_mode == 'weight_loss': + total = 0.50 * fm_score + 0.30 * weight_score + 0.20 * lbm_score + elif goal_mode == 'strength': + total = 0.60 * lbm_score + 0.30 * fm_score + 0.10 * weight_score + # ... + + return f"{int(total)}/100" +``` + +--- + +### Phase 0c: Training Phases (4-6h) **PARALLEL** + +**DB:** +- `training_phases` table + +**Detection Algorithm:** +```python +def detect_current_phase(profile_id: str) -> dict: + """Detects training phase from last 14 days of data.""" + + # Analyze data + kcal_balance = get_kcal_balance_14d(profile_id) + training_dist = get_training_distribution_14d(profile_id) + weight_trend = get_weight_trend_14d(profile_id) + hrv_avg = get_hrv_avg_14d(profile_id) + volume_change = get_volume_change_14d(profile_id) + + # Phase Detection Rules + if kcal_balance < -300 and weight_trend < 0: + return { + 'phase': 'calorie_deficit', + 'confidence': 0.85, + 'reason': f'Avg kcal balance {kcal_balance}/day, weight -0.5kg/week' + } + + if training_dist['endurance'] > 60 and vo2max_trend > 0: + return { + 'phase': 'conditioning', + 'confidence': 0.78, + 'reason': f'{training_dist["endurance"]}% cardio, VO2max improving' + } + + if volume_change < -40 and hrv_avg < hrv_baseline * 0.85: + return { + 'phase': 'regeneration', + 'confidence': 0.92, + 'reason': f'Volume -40%, HRV below baseline, recovery needed' + } + + # Default + return { + 'phase': 'maintenance', + 'confidence': 0.50, + 'reason': 'No clear pattern detected' + } +``` + +**API:** +- GET /phases/current +- GET /phases/detect +- POST /phases/confirm + +**UI:** +- Dashboard Badge: "📊 Phase: Kaloriendefizit" +- Phase Detection Banner: "Wir haben erkannt: Kaloriendefizit-Phase. Stimmt das?" + +--- + +### Phase 0d: Fitness Tests (4-6h) **SPÄTER** + +**DB:** +- `fitness_tests` table + +**Test Definitions:** +```python +FITNESS_TESTS = { + 'cooper': { + 'name': 'Cooper-Test', + 'description': '12 Minuten laufen, maximale Distanz', + 'unit': 'meters', + 'interval_weeks': 6, + 'norm_tables': { # Simplified + 'male_30-39': {'very_good': 2800, 'good': 2500, 'average': 2200}, + 'female_30-39': {'very_good': 2500, 'good': 2200, 'average': 1900} + }, + 'calculate_vo2max': lambda distance: (distance - 504.9) / 44.73 + }, + 'pushups': { + 'name': 'Liegestütze-Test', + 'description': 'Maximale Anzahl ohne Pause', + 'unit': 'reps', + 'interval_weeks': 4, + 'norm_tables': { ... } + }, + # ... weitere Tests +} +``` + +**UI:** +- Tests Page mit Testliste +- Test Execution Flow (Anleitung → Eingabe → Auswertung) +- Test History mit Trend-Chart + +--- + +## 7. Priorisierte Reihenfolge + +### SOFORT (3-4h) +**Phase 0a:** Minimal Goal System (Strategic + Tactical) +- Basis für alles andere +- User kann Ziele setzen +- Score-Berechnungen können goal_mode nutzen + +### DIESE WOCHE (16-20h) +**Phase 0b:** Goal-Aware Placeholders +- 84 Platzhalter aus v2 +- 35+ Platzhalter aus v9e +- **TOTAL: 120+ Platzhalter** + +### PARALLEL (4-6h) +**Phase 0c:** Training Phases +- Automatische Erkennung +- Phase-aware Recommendations + +### SPÄTER (4-6h) +**Phase 0d:** Fitness Tests +- Enhancement, nicht kritisch für Charts + +--- + +## 8. Kritische Erkenntnisse + +### 1. GOALS_VITALS.md ist detaillierter +- Konkrete Implementierungs-Specs +- DB-Schema-Vorschläge +- 13 definierte KI-Platzhalter +- **ABER:** Fehlt Score-Gewichtung (das hat v2) + +### 2. Konzept v2 ist strategischer +- Goal Modes mit Score-Gewichtung +- Chart-Interpretationen +- Regelbasierte Logik +- **ABER:** Fehlt konkrete Ziel-Tracking (das hat v9e) + +### 3. Beide zusammen = Vollständig +- v2 (Goal Modes) + v9e (Goal Targets) = Komplettes Zielesystem +- v2 (Scores) + v9e (Tests) = Vollständiges Assessment +- v2 (Charts) + v9e (Phases) = Kontext-aware Visualisierung + +### 4. Meine ursprüngliche Analyse war incomplete +- Ich hatte nur v2 betrachtet +- v9e fügt kritische Details hinzu +- **Neue Gesamt-Schätzung:** 120+ Platzhalter (statt 84) + +--- + +## 9. Aktualisierte Empfehlung + +**JA zu Phase 0a (Minimal Goal System), ABER erweitert:** + +### Was Phase 0a umfassen muss (3-4h): + +1. **Strategic Layer (aus v2):** + - goal_mode in profiles + - GOAL_MODES Definition + - GET/SET endpoints + +2. **Tactical Layer (aus v9e):** + - goals Tabelle + - CRUD für Ziele + - Fortschritts-Berechnung + +3. **UI:** + - Goal Mode Selector (Settings) + - Goal Management Page (Basic) + - Dashboard Goal Widget + +### Was kann warten: +- Training Phases → Phase 0c (parallel) +- Fitness Tests → Phase 0d (später) +- Vollständige Test-Integration → v9f + +--- + +## 10. Nächste Schritte + +**JETZT:** +1. Phase 0a implementieren (3-4h) + - Strategic + Tactical Goal System +2. Dann Phase 0b (Goal-Aware Placeholders, 16-20h) +3. Parallel Phase 0c (Training Phases, 4-6h) + +**Soll ich mit Phase 0a (erweitert) starten?** +- Beide Goal-Konzepte integriert +- Ready für 120+ Platzhalter +- Basis für intelligentes Coach-System + +**Commit:** ae93b9d (muss aktualisiert werden) +**Neue Analyse:** GOALS_SYSTEM_UNIFIED_ANALYSIS.md diff --git a/docs/GOAL_SYSTEM_PRIORITY_ANALYSIS.md b/docs/GOAL_SYSTEM_PRIORITY_ANALYSIS.md new file mode 100644 index 0000000..e9cac76 --- /dev/null +++ b/docs/GOAL_SYSTEM_PRIORITY_ANALYSIS.md @@ -0,0 +1,538 @@ +# Zielesystem: Prioritäts-Analyse + +**Datum:** 26. März 2026 +**Frage:** Zielesystem vor oder nach Platzhaltern/Charts? +**Antwort:** **Minimales Zielesystem VOR Platzhaltern, volles System parallel** + +--- + +## 1. Kritische Erkenntnis aus Fachkonzept + +### Zitat Fachkonzept (Zeile 20-28): +> **Wichtig ist, dass das System zielabhängig interpretiert:** +> - Gewichtsreduktion +> - Muskel-/Kraftaufbau +> - Konditions-/Ausdaueraufbau +> - Körperrekomposition +> - allgemeine Gesundheit +> +> **Dasselbe Rohsignal kann je nach Ziel anders bewertet werden.** +> Ein Kaloriendefizit ist z. B. bei Gewichtsreduktion oft positiv, +> bei Kraftaufbau aber potenziell hinderlich. + +### Konsequenz +❌ **Charts OHNE Zielesystem = falsche Interpretationen** +✅ **Charts MIT Zielesystem = korrekte, zielspezifische Aussagen** + +--- + +## 2. Abhängigkeits-Matrix + +### Was hängt vom Zielesystem ab? + +| Komponente | Zielabhängig? | Beispiel | +|------------|---------------|----------| +| **Rohdaten-Charts** | ❌ Nein | Gewichtsverlauf, Umfänge-Trend | +| **Score-Gewichtung** | ✅ JA | Body Progress Score: 30% bei weight_loss, 20% bei strength | +| **Interpretationen** | ✅ JA | Kaloriendefizit: "gut" bei weight_loss, "kritisch" bei strength | +| **Hinweise** | ✅ JA | "Gewicht stagniert" → bei weight_loss: Warnung, bei strength: egal | +| **Platzhalter (Berechnungen)** | ⚠️ TEILWEISE | Trends: Nein, Scores: JA | +| **KI-Prompts** | ✅ JA | Analyse-Kontext ändert sich komplett | + +### Fachkonzept: Score-Gewichtung (Zeile 185-216) + +```yaml +score_weights: + weight_loss: + body_progress: 0.30 # Körper wichtig + nutrition: 0.25 + activity: 0.20 + recovery: 0.15 + health_risk: 0.10 + + strength: + body_progress: 0.20 + nutrition: 0.25 + activity: 0.30 # Training wichtiger + recovery: 0.20 + health_risk: 0.05 # Weniger kritisch + + endurance: + body_progress: 0.10 # Körper unwichtiger + activity: 0.35 # Training am wichtigsten + recovery: 0.25 # Recovery sehr wichtig +``` + +### Beispiel: Body Progress Score + +**OHNE Zielesystem:** +```python +def calculate_body_progress_score(): + # Generisch, für niemanden wirklich passend + fm_delta_score = calculate_fm_change() # -5kg + lbm_delta_score = calculate_lbm_change() # -2kg + return (fm_delta_score + lbm_delta_score) / 2 + # Score: 50/100 (FM gut runter, aber LBM auch runter) +``` + +**MIT Zielesystem:** +```python +def calculate_body_progress_score(goal_mode): + fm_delta_score = calculate_fm_change() # -5kg + lbm_delta_score = calculate_lbm_change() # -2kg + + if goal_mode == "weight_loss": + # FM runter: sehr gut, LBM runter: tolerierbar wenn nicht zu viel + return 0.70 * fm_delta_score + 0.30 * lbm_delta_score + # Score: 78/100 (FM wichtiger, LBM-Verlust weniger kritisch) + + elif goal_mode == "strength": + # FM runter: ok, LBM runter: SEHR SCHLECHT + return 0.30 * fm_delta_score + 0.70 * lbm_delta_score + # Score: 32/100 (LBM-Verlust ist Hauptproblem!) + + elif goal_mode == "recomposition": + # FM runter: gut, LBM runter: schlecht + return 0.50 * fm_delta_score + 0.50 * lbm_delta_score + # Score: 50/100 (ausgewogen bewertet) +``` + +**Ergebnis:** +- Gleiche Daten (-5kg FM, -2kg LBM) +- ABER: 78/100 bei weight_loss, 32/100 bei strength +- **Ohne Ziel: völlig falsche Bewertung!** + +--- + +## 3. Ziel-Erkennung aus Daten + +### Fachkonzept erwähnt dies NICHT explizit, aber logisch ableitbar: + +**Pattern-Erkennung:** +```python +def suggest_goal_from_data(profile_id): + """Schlägt Ziel basierend auf Daten-Mustern vor.""" + + # Analyse der letzten 28 Tage + training_types = get_training_distribution_28d(profile_id) + nutrition = get_nutrition_pattern_28d(profile_id) + body_changes = get_body_changes_28d(profile_id) + + # Pattern 1: Viel Kraft + viel Protein + LBM steigt + if (training_types['strength'] > 60% and + nutrition['protein_g_per_kg'] > 1.8 and + body_changes['lbm_trend'] > 0): + return { + 'suggested_goal': 'strength', + 'confidence': 'high', + 'reasoning': 'Krafttraining dominant + hohe Proteinzufuhr + Muskelaufbau erkennbar' + } + + # Pattern 2: Viel Cardio + Kaloriendefizit + Gewicht sinkt + if (training_types['endurance'] > 50% and + nutrition['kcal_balance_avg'] < -300 and + body_changes['weight_trend'] < 0): + return { + 'suggested_goal': 'weight_loss', + 'confidence': 'high', + 'reasoning': 'Ausdauertraining + Kaloriendefizit + Gewichtsverlust' + } + + # Pattern 3: Mixed Training + Protein hoch + Gewicht stabil + Rekomposition + if (training_types['mixed'] == True and + nutrition['protein_g_per_kg'] > 1.6 and + abs(body_changes['weight_trend']) < 0.05 and + body_changes['fm_trend'] < 0 and + body_changes['lbm_trend'] > 0): + return { + 'suggested_goal': 'recomposition', + 'confidence': 'medium', + 'reasoning': 'Gemischtes Training + Rekomposition sichtbar (FM↓, LBM↑)' + } + + # Default: Nicht genug Muster erkennbar + return { + 'suggested_goal': 'health', + 'confidence': 'low', + 'reasoning': 'Keine klaren Muster erkennbar, gesundheitsorientiertes Training angenommen' + } +``` + +### Voraussetzungen für Ziel-Erkennung: +1. ✅ Mindestens 21-28 Tage Daten +2. ✅ Training-Type Distribution +3. ✅ Ernährungs-Pattern +4. ✅ Körper-Trends (FM, LBM, Gewicht) +5. ✅ Berechnet → **braucht Platzhalter!** + +**ABER:** Ziel-Erkennung ist **nachgelagert**, nicht Voraussetzung. + +--- + +## 4. Empfohlene Implementierungs-Strategie + +### Hybrid-Ansatz: Minimal-Ziele SOFORT, Voll-System parallel + +## Phase 0a: Minimal-Zielesystem (2-3h) ⭐ **START HIER** + +### Ziel +User kann manuell Ziel setzen, System nutzt es für Berechnungen. + +### Implementierung + +**1. DB-Schema erweitern:** +```sql +-- Migration 023 +ALTER TABLE profiles ADD COLUMN goal_mode VARCHAR(50) DEFAULT 'health'; +ALTER TABLE profiles ADD COLUMN goal_weight DECIMAL(5,2); +ALTER TABLE profiles ADD COLUMN goal_bf_pct DECIMAL(4,1); +ALTER TABLE profiles ADD COLUMN goal_set_date DATE; +ALTER TABLE profiles ADD COLUMN goal_target_date DATE; + +COMMENT ON COLUMN profiles.goal_mode IS + 'Primary goal: weight_loss, strength, endurance, recomposition, health'; +``` + +**2. Goal-Mode Konstanten:** +```python +# backend/goals.py (NEU) +GOAL_MODES = { + 'weight_loss': { + 'label': 'Gewichtsreduktion', + 'description': 'Fettabbau bei Erhalt der Magermasse', + 'score_weights': { + 'body_progress': 0.30, + 'nutrition': 0.25, + 'activity': 0.20, + 'recovery': 0.15, + 'health_risk': 0.10 + }, + 'focus_areas': ['fettmasse', 'gewichtstrend', 'kalorienbilanz', 'protein_sicherung'] + }, + 'strength': { + 'label': 'Kraftaufbau', + 'description': 'Muskelaufbau und Kraftsteigerung', + 'score_weights': { + 'body_progress': 0.20, + 'nutrition': 0.25, + 'activity': 0.30, + 'recovery': 0.20, + 'health_risk': 0.05 + }, + 'focus_areas': ['trainingsqualitaet', 'protein', 'lbm', 'recovery'] + }, + 'endurance': { + 'label': 'Ausdaueraufbau', + 'description': 'Kondition und VO2max verbessern', + 'score_weights': { + 'body_progress': 0.10, + 'nutrition': 0.20, + 'activity': 0.35, + 'recovery': 0.25, + 'health_risk': 0.10 + }, + 'focus_areas': ['trainingsvolumen', 'intensitaetsverteilung', 'vo2max', 'recovery'] + }, + 'recomposition': { + 'label': 'Körperrekomposition', + 'description': 'Fettabbau bei gleichzeitigem Muskelaufbau', + 'score_weights': { + 'body_progress': 0.30, + 'nutrition': 0.25, + 'activity': 0.25, + 'recovery': 0.15, + 'health_risk': 0.05 + }, + 'focus_areas': ['lbm', 'fettmasse', 'protein', 'trainingsqualitaet'] + }, + 'health': { + 'label': 'Allgemeine Gesundheit', + 'description': 'Ausgeglichenes Gesundheits- und Fitnesstraining', + 'score_weights': { + 'body_progress': 0.20, + 'nutrition': 0.20, + 'activity': 0.20, + 'recovery': 0.20, + 'health_risk': 0.20 + }, + 'focus_areas': ['bewegung', 'blutdruck', 'schlaf', 'gewicht', 'regelmaessigkeit'] + } +} +``` + +**3. API-Endpoint:** +```python +# routers/goals.py (NEU) +from fastapi import APIRouter, Depends +from auth import require_auth +from goals import GOAL_MODES + +router = APIRouter(prefix="/api/goals", tags=["goals"]) + +@router.get("/modes") +def get_goal_modes(): + """Return all available goal modes with descriptions.""" + return GOAL_MODES + +@router.get("/current") +def get_current_goal(session: dict = Depends(require_auth)): + """Get user's current goal settings.""" + profile_id = session['profile_id'] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT goal_mode, goal_weight, goal_bf_pct, + goal_set_date, goal_target_date + FROM profiles WHERE id=%s""", + (profile_id,) + ) + row = r2d(cur.fetchone()) + return { + **row, + 'mode_config': GOAL_MODES.get(row['goal_mode'], GOAL_MODES['health']) + } + +@router.post("/set") +def set_goal( + goal_mode: str, + goal_weight: Optional[float] = None, + goal_bf_pct: Optional[float] = None, + target_date: Optional[str] = None, + session: dict = Depends(require_auth) +): + """Set user's goal.""" + if goal_mode not in GOAL_MODES: + raise HTTPException(400, f"Invalid goal_mode. Must be one of: {list(GOAL_MODES.keys())}") + + profile_id = session['profile_id'] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """UPDATE profiles + SET goal_mode=%s, goal_weight=%s, goal_bf_pct=%s, + goal_set_date=CURRENT_DATE, goal_target_date=%s + WHERE id=%s""", + (goal_mode, goal_weight, goal_bf_pct, target_date, profile_id) + ) + conn.commit() + + return {"success": True, "goal_mode": goal_mode} +``` + +**4. Frontend UI (Settings.jsx):** +```jsx +// Minimal Goal Selector +function GoalSettings() { + const [goalModes, setGoalModes] = useState({}) + const [currentGoal, setCurrentGoal] = useState(null) + const [selectedMode, setSelectedMode] = useState('health') + + useEffect(() => { + loadGoalModes() + loadCurrentGoal() + }, []) + + const loadGoalModes = async () => { + const modes = await api.getGoalModes() + setGoalModes(modes) + } + + const loadCurrentGoal = async () => { + const goal = await api.getCurrentGoal() + setCurrentGoal(goal) + setSelectedMode(goal.goal_mode || 'health') + } + + const saveGoal = async () => { + await api.setGoal({ + goal_mode: selectedMode, + goal_weight: goalWeight, + goal_bf_pct: goalBfPct, + target_date: targetDate + }) + loadCurrentGoal() + } + + return ( +
+ {goalModes[selectedMode]?.description} +
+{error}
++ {goalTypes.length} Types registriert ({goalTypes.filter(t => t.is_system).length} System, {goalTypes.filter(t => !t.is_system).length} Custom) +
+{error}
++ Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile. + {focusAreas && !focusAreas.custom && ( + + ℹ️ Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung + + )} +
+ + {focusEditing ? ( + <> + {/* Sliders */} +Noch keine Ziele definiert
+ +{catInfo.description}
+