feat: Phase 0a - Minimal Goal System (Strategic + Tactical)
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- Strategic Layer: Goal modes (weight_loss, strength, endurance, recomposition, health)
- Tactical Layer: Concrete goal targets with progress tracking
- Training phases (manual + auto-detection framework)
- Fitness tests (standardized performance tracking)

Backend:
- Migration 022: goal_mode in profiles, goals, training_phases, fitness_tests tables
- New router: routers/goals.py with full CRUD for goals, phases, tests
- API endpoints: /api/goals/* (mode, list, create, update, delete)

Frontend:
- GoalsPage: Goal mode selector + goal management UI
- Dashboard: Goals preview card with link
- API integration: goal mode, CRUD operations, progress calculation

Basis for 120+ placeholders and goal-aware analyses (Phase 0b)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-26 16:20:35 +01:00
parent ae93b9d428
commit 337667fc07
8 changed files with 1813 additions and 0 deletions

View File

@ -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 admin_activity_mappings, sleep, rest_days
from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored
from routers import evaluation # v9d/v9e Training Type Profiles (#15) from routers import evaluation # v9d/v9e Training Type Profiles (#15)
from routers import goals # v9e Goal System (Strategic + Tactical)
# ── App Configuration ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) 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(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(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(evaluation.router) # /api/evaluation/* (v9d/v9e Training Profiles #15)
app.include_router(goals.router) # /api/goals/* (v9e Goal System Strategic + Tactical)
# ── Health Check ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")

View File

@ -0,0 +1,147 @@
-- 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';
-- ============================================================================
-- VERSION UPDATE
-- ============================================================================
-- Track migration
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM schema_migrations WHERE version = '022') THEN
INSERT INTO schema_migrations (version, applied_at) VALUES ('022', NOW());
END IF;
END $$;

473
backend/routers/goals.py Normal file
View File

@ -0,0 +1,473 @@
"""
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 json
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/api/goals", tags=["goals"])
# ============================================================================
# Pydantic Models
# ============================================================================
class GoalModeUpdate(BaseModel):
"""Update strategic goal mode"""
goal_mode: str # weight_loss, strength, endurance, recomposition, health
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
target_value: float
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
target_date: Optional[date] = None
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
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
# ============================================================================
# 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')
# ============================================================================
# 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']
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()]
# Update current values for each goal
for goal in goals:
_update_goal_progress(conn, pid, goal)
return goals
@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, name, description
) VALUES (%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.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")
# 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.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"}
# ============================================================================
# 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 from latest data"""
cur = get_cursor(conn)
if goal_type == 'weight':
cur.execute("""
SELECT weight FROM weight_log
WHERE profile_id = %s
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['weight']) if row else None
elif goal_type == 'body_fat':
cur.execute("""
SELECT body_fat_pct FROM caliper_log
WHERE profile_id = %s
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['body_fat_pct']) if row else None
elif goal_type == 'vo2max':
cur.execute("""
SELECT vo2max FROM vitals_baseline
WHERE profile_id = %s AND vo2max IS NOT NULL
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['vo2max']) if row else None
elif goal_type == 'rhr':
cur.execute("""
SELECT resting_hr FROM vitals_baseline
WHERE profile_id = %s AND resting_hr IS NOT NULL
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
return float(row['resting_hr']) if row else None
return None
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

View File

@ -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

View File

@ -35,6 +35,7 @@ import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage' import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage' import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage' import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import './app.css' import './app.css'
function Nav() { function Nav() {
@ -172,6 +173,7 @@ function AppShell() {
<Route path="/sleep" element={<SleepPage/>}/> <Route path="/sleep" element={<SleepPage/>}/>
<Route path="/rest-days" element={<RestDaysPage/>}/> <Route path="/rest-days" element={<RestDaysPage/>}/>
<Route path="/vitals" element={<VitalsPage/>}/> <Route path="/vitals" element={<VitalsPage/>}/>
<Route path="/goals" element={<GoalsPage/>}/>
<Route path="/nutrition" element={<NutritionPage/>}/> <Route path="/nutrition" element={<NutritionPage/>}/>
<Route path="/activity" element={<ActivityPage/>}/> <Route path="/activity" element={<ActivityPage/>}/>
<Route path="/analysis" element={<Analysis/>}/> <Route path="/analysis" element={<Analysis/>}/>

View File

@ -497,6 +497,21 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* Goals Preview */}
<div className="card section-gap" style={{marginBottom:16,cursor:'pointer'}}
onClick={()=>nav('/goals')}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{fontWeight:600,fontSize:13}}>🎯 Ziele</div>
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
onClick={(e)=>{e.stopPropagation();nav('/goals')}}>
Verwalten
</button>
</div>
<div style={{fontSize:12,color:'var(--text2)',padding:'8px 0'}}>
Definiere deine Trainingsmodus und konkrete Ziele für bessere KI-Analysen
</div>
</div>
{/* Latest AI insight */} {/* Latest AI insight */}
<div className="card section-gap"> <div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>

View File

@ -0,0 +1,562 @@
import { useState, useEffect } from 'react'
import { Target, Plus, Pencil, Trash2, TrendingUp, Calendar } from 'lucide-react'
import { api } from '../utils/api'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
// Goal Mode Definitions
const GOAL_MODES = [
{
id: 'weight_loss',
icon: '📉',
label: 'Gewichtsreduktion',
description: 'Kaloriendefizit, Fettabbau',
color: '#D85A30'
},
{
id: 'strength',
icon: '💪',
label: 'Kraftaufbau',
description: 'Muskelwachstum, progressive Belastung',
color: '#378ADD'
},
{
id: 'endurance',
icon: '🏃',
label: 'Ausdauer',
description: 'VO2Max, aerobe Kapazität',
color: '#1D9E75'
},
{
id: 'recomposition',
icon: '⚖️',
label: 'Körperkomposition',
description: 'Gleichzeitig Fett ab- & Muskeln aufbauen',
color: '#7B68EE'
},
{
id: 'health',
icon: '❤️',
label: 'Allgemeine Gesundheit',
description: 'Ausgewogen, präventiv',
color: '#E67E22'
}
]
// Goal Type Definitions
const GOAL_TYPES = {
weight: { label: 'Gewicht', unit: 'kg', icon: '⚖️' },
body_fat: { label: 'Körperfett', unit: '%', icon: '📊' },
lean_mass: { label: 'Muskelmasse', unit: 'kg', icon: '💪' },
vo2max: { label: 'VO2Max', unit: 'ml/kg/min', icon: '🫁' },
strength: { label: 'Kraft', unit: 'kg', icon: '🏋️' },
flexibility: { label: 'Beweglichkeit', unit: 'cm', icon: '🤸' },
bp: { label: 'Blutdruck', unit: 'mmHg', icon: '❤️' },
rhr: { label: 'Ruhepuls', unit: 'bpm', icon: '💓' }
}
export default function GoalsPage() {
const [goalMode, setGoalMode] = useState(null)
const [goals, setGoals] = useState([])
const [showGoalForm, setShowGoalForm] = useState(false)
const [editingGoal, setEditingGoal] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [toast, setToast] = useState(null)
// Form state
const [formData, setFormData] = useState({
goal_type: 'weight',
is_primary: false,
target_value: '',
unit: 'kg',
target_date: '',
name: '',
description: ''
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [modeData, goalsData] = await Promise.all([
api.getGoalMode(),
api.listGoals()
])
setGoalMode(modeData.goal_mode)
setGoals(goalsData)
} catch (err) {
console.error('Failed to load goals:', err)
setError('Fehler beim Laden der Ziele')
} finally {
setLoading(false)
}
}
const showToast = (message, duration = 2000) => {
setToast(message)
setTimeout(() => setToast(null), duration)
}
const handleGoalModeChange = async (newMode) => {
try {
await api.updateGoalMode(newMode)
setGoalMode(newMode)
showToast('✓ Trainingsmodus aktualisiert')
} catch (err) {
console.error('Failed to update goal mode:', err)
setError('Fehler beim Aktualisieren des Trainingsmodus')
}
}
const handleCreateGoal = () => {
setEditingGoal(null)
setFormData({
goal_type: 'weight',
is_primary: goals.length === 0, // First goal is primary by default
target_value: '',
unit: GOAL_TYPES['weight'].unit,
target_date: '',
name: '',
description: ''
})
setShowGoalForm(true)
}
const handleEditGoal = (goal) => {
setEditingGoal(goal.id)
setFormData({
goal_type: goal.goal_type,
is_primary: goal.is_primary,
target_value: goal.target_value,
unit: goal.unit,
target_date: goal.target_date || '',
name: goal.name || '',
description: goal.description || ''
})
setShowGoalForm(true)
}
const handleGoalTypeChange = (type) => {
setFormData(f => ({
...f,
goal_type: type,
unit: GOAL_TYPES[type].unit
}))
}
const handleSaveGoal = async () => {
if (!formData.target_value) {
setError('Bitte Zielwert eingeben')
return
}
try {
const data = {
goal_type: formData.goal_type,
is_primary: formData.is_primary,
target_value: parseFloat(formData.target_value),
unit: formData.unit,
target_date: formData.target_date || null,
name: formData.name || null,
description: formData.description || null
}
if (editingGoal) {
await api.updateGoal(editingGoal, data)
showToast('✓ Ziel aktualisiert')
} else {
await api.createGoal(data)
showToast('✓ Ziel erstellt')
}
await loadData()
setShowGoalForm(false)
setEditingGoal(null)
} catch (err) {
console.error('Failed to save goal:', err)
setError(err.message || 'Fehler beim Speichern')
}
}
const handleDeleteGoal = async (goalId) => {
if (!confirm('Ziel wirklich löschen?')) return
try {
await api.deleteGoal(goalId)
showToast('✓ Ziel gelöscht')
await loadData()
} catch (err) {
console.error('Failed to delete goal:', err)
setError('Fehler beim Löschen')
}
}
const getProgressColor = (progress) => {
if (progress >= 100) return 'var(--accent)'
if (progress >= 75) return '#1D9E75'
if (progress >= 50) return '#378ADD'
if (progress >= 25) return '#E67E22'
return '#D85A30'
}
if (loading) {
return (
<div className="page">
<div style={{ textAlign: 'center', padding: '40px' }}>
<div className="spinner"></div>
</div>
</div>
)
}
return (
<div className="page">
<div className="page-header">
<h1><Target size={24} /> Ziele</h1>
</div>
{error && (
<div className="card" style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', marginBottom: 16 }}>
<p style={{ color: '#DC2626', margin: 0 }}>{error}</p>
</div>
)}
{toast && (
<div style={{
position: 'fixed',
top: 16,
right: 16,
background: 'var(--accent)',
color: 'white',
padding: '12px 20px',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 1000
}}>
{toast}
</div>
)}
{/* Strategic Goal Mode Selection */}
<div className="card" style={{ marginBottom: 16 }}>
<h2 style={{ marginTop: 0 }}>🎯 Trainingsmodus</h2>
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
Wähle deine grundlegende Trainingsausrichtung. Dies beeinflusst die Gewichtung
und Interpretation aller Analysen.
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 12
}}>
{GOAL_MODES.map(mode => (
<button
key={mode.id}
onClick={() => handleGoalModeChange(mode.id)}
className={goalMode === mode.id ? 'btn-primary' : 'btn-secondary'}
style={{
padding: 16,
textAlign: 'left',
background: goalMode === mode.id ? mode.color : 'var(--surface)',
borderColor: goalMode === mode.id ? mode.color : 'var(--border)',
color: goalMode === mode.id ? 'white' : 'var(--text1)'
}}
>
<div style={{ fontSize: 24, marginBottom: 8 }}>{mode.icon}</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mode.label}</div>
<div style={{
fontSize: 12,
opacity: goalMode === mode.id ? 0.9 : 0.7
}}>
{mode.description}
</div>
</button>
))}
</div>
</div>
{/* Tactical Goals List */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>🎯 Konkrete Ziele</h2>
<button className="btn-primary" onClick={handleCreateGoal}>
<Plus size={16} /> Ziel hinzufügen
</button>
</div>
{goals.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text2)' }}>
<Target size={48} style={{ opacity: 0.3, marginBottom: 16 }} />
<p>Noch keine Ziele definiert</p>
<button className="btn-primary" onClick={handleCreateGoal} style={{ marginTop: 16 }}>
Erstes Ziel erstellen
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{goals.map(goal => {
const typeInfo = GOAL_TYPES[goal.goal_type] || {}
return (
<div
key={goal.id}
className="card"
style={{
background: 'var(--surface2)',
border: goal.is_primary ? '2px solid var(--accent)' : '1px solid var(--border)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{typeInfo.icon}</span>
<span style={{ fontWeight: 600 }}>
{goal.name || typeInfo.label}
</span>
{goal.is_primary && (
<span style={{
background: 'var(--accent)',
color: 'white',
fontSize: 11,
padding: '2px 8px',
borderRadius: 4
}}>
PRIMÄR
</span>
)}
<span style={{
background: goal.status === 'active' ? '#E6F4F1' : '#F3F4F6',
color: goal.status === 'active' ? 'var(--accent)' : 'var(--text2)',
fontSize: 11,
padding: '2px 8px',
borderRadius: 4
}}>
{goal.status === 'active' ? 'AKTIV' : goal.status?.toUpperCase()}
</span>
</div>
<div style={{ display: 'flex', gap: 24, marginBottom: 12, fontSize: 14 }}>
<div>
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
<strong>{goal.start_value} {goal.unit}</strong>
</div>
<div>
<span style={{ color: 'var(--text2)' }}>Aktuell:</span>{' '}
<strong>{goal.current_value || '—'} {goal.unit}</strong>
</div>
<div>
<span style={{ color: 'var(--text2)' }}>Ziel:</span>{' '}
<strong>{goal.target_value} {goal.unit}</strong>
</div>
{goal.target_date && (
<div>
<Calendar size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{dayjs(goal.target_date).format('DD.MM.YYYY')}
</div>
)}
</div>
{goal.progress_pct !== null && (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
marginBottom: 4
}}>
<span>Fortschritt</span>
<span style={{ fontWeight: 600 }}>{goal.progress_pct}%</span>
</div>
<div style={{
width: '100%',
height: 6,
background: 'var(--surface)',
borderRadius: 3,
overflow: 'hidden'
}}>
<div style={{
width: `${Math.min(100, Math.max(0, goal.progress_pct))}%`,
height: '100%',
background: getProgressColor(goal.progress_pct),
transition: 'width 0.3s ease'
}} />
</div>
{goal.on_track !== null && (
<div style={{ marginTop: 8, fontSize: 12 }}>
{goal.on_track ? (
<span style={{ color: 'var(--accent)' }}>
Ziel voraussichtlich erreichbar bis {dayjs(goal.target_date).format('DD.MM.YYYY')}
</span>
) : (
<span style={{ color: '#D85A30' }}>
Prognose: {goal.projection_date ? dayjs(goal.projection_date).format('DD.MM.YYYY') : 'Offen'}
{goal.target_date && ' (später als geplant)'}
</span>
)}
</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn-secondary"
onClick={() => handleEditGoal(goal)}
style={{ padding: '6px 12px' }}
>
<Pencil size={14} />
</button>
<button
className="btn-secondary"
onClick={() => handleDeleteGoal(goal.id)}
style={{ padding: '6px 12px', color: '#DC2626' }}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Goal Form Modal */}
{showGoalForm && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: 16
}}>
<div className="card" style={{
maxWidth: 500,
width: '100%',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h2 style={{ marginTop: 0 }}>
{editingGoal ? 'Ziel bearbeiten' : 'Neues Ziel'}
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label className="form-label">Zieltyp</label>
<select
className="form-input"
value={formData.goal_type}
onChange={e => handleGoalTypeChange(e.target.value)}
>
{Object.entries(GOAL_TYPES).map(([key, info]) => (
<option key={key} value={key}>
{info.icon} {info.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label">Name (optional)</label>
<input
type="text"
className="form-input"
value={formData.name}
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
placeholder="z.B. Sommerfigur 2026"
/>
</div>
<div>
<label className="form-label">Zielwert *</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="number"
step="0.01"
className="form-input"
value={formData.target_value}
onChange={e => setFormData(f => ({ ...f, target_value: e.target.value }))}
placeholder="Zielwert"
/>
<input
type="text"
className="form-input"
value={formData.unit}
readOnly
style={{ width: 80 }}
/>
</div>
</div>
<div>
<label className="form-label">Zieldatum (optional)</label>
<input
type="date"
className="form-input"
value={formData.target_date}
onChange={e => setFormData(f => ({ ...f, target_date: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Beschreibung (optional)</label>
<textarea
className="form-input"
value={formData.description}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
rows={3}
placeholder="Warum ist dir dieses Ziel wichtig?"
/>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.is_primary}
onChange={e => setFormData(f => ({ ...f, is_primary: e.target.checked }))}
/>
<span>Als Primärziel setzen</span>
</label>
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4, marginLeft: 24 }}>
Dein Primärziel hat höchste Priorität in Analysen und Charts
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
<button className="btn-primary" onClick={handleSaveGoal} style={{ flex: 1 }}>
{editingGoal ? 'Aktualisieren' : 'Erstellen'}
</button>
<button
className="btn-secondary"
onClick={() => {
setShowGoalForm(false)
setEditingGoal(null)
}}
style={{ flex: 1 }}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -330,4 +330,21 @@ export const api = {
// Placeholder Export // Placeholder Export
exportPlaceholderValues: () => req('/prompts/placeholders/export-values'), exportPlaceholderValues: () => req('/prompts/placeholders/export-values'),
// v9e: Goals System (Strategic + Tactical)
getGoalMode: () => req('/goals/mode'),
updateGoalMode: (mode) => req('/goals/mode', jput({goal_mode: mode})),
listGoals: () => req('/goals/list'),
createGoal: (d) => req('/goals/create', json(d)),
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),
// Training Phases
listTrainingPhases: () => req('/goals/phases'),
createTrainingPhase: (d) => req('/goals/phases', json(d)),
updatePhaseStatus: (id,status) => req(`/goals/phases/${id}/status?status=${status}`, jput({})),
// Fitness Tests
listFitnessTests: () => req('/goals/tests'),
createFitnessTest: (d) => req('/goals/tests', json(d)),
} }