feat: Phase 0a - Minimal Goal System (Strategic + Tactical)
- 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:
parent
ae93b9d428
commit
337667fc07
|
|
@ -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("/")
|
||||
|
|
|
|||
147
backend/migrations/022_goal_system.sql
Normal file
147
backend/migrations/022_goal_system.sql
Normal 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
473
backend/routers/goals.py
Normal 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
|
||||
595
docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md
Normal file
595
docs/GOALS_SYSTEM_UNIFIED_ANALYSIS.md
Normal 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
|
||||
|
|
@ -35,6 +35,7 @@ import SubscriptionPage from './pages/SubscriptionPage'
|
|||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
import VitalsPage from './pages/VitalsPage'
|
||||
import GoalsPage from './pages/GoalsPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -172,6 +173,7 @@ function AppShell() {
|
|||
<Route path="/sleep" element={<SleepPage/>}/>
|
||||
<Route path="/rest-days" element={<RestDaysPage/>}/>
|
||||
<Route path="/vitals" element={<VitalsPage/>}/>
|
||||
<Route path="/goals" element={<GoalsPage/>}/>
|
||||
<Route path="/nutrition" element={<NutritionPage/>}/>
|
||||
<Route path="/activity" element={<ActivityPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
|
|
|
|||
|
|
@ -497,6 +497,21 @@ export default function Dashboard() {
|
|||
</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 */}
|
||||
<div className="card section-gap">
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||||
|
|
|
|||
562
frontend/src/pages/GoalsPage.jsx
Normal file
562
frontend/src/pages/GoalsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -330,4 +330,21 @@ export const api = {
|
|||
|
||||
// Placeholder Export
|
||||
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)),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user