Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 979e734bd9 | |||
| 448f6ad4f4 | |||
| e4a2b63a48 | |||
| ce4cd7daf1 | |||
| 9ab36145e5 | |||
| eb5c099eca | |||
| 37ea1f8537 | |||
| 79cb3e0100 | |||
| 378bf434fc | |||
| 3116fbbc91 | |||
| dfcdfbe335 | |||
| 029530e078 | |||
| ba5d460e92 | |||
| 34ea51b8bd | |||
| 6ab0a8b631 | |||
| 6a961ce88f | |||
| d14157f7ad | |||
| f312dd0dbb | |||
| 2f64656d4d |
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -76,9 +76,40 @@ frontend/src/
|
|||
└── technical/ # MEMBERSHIP_SYSTEM.md
|
||||
```
|
||||
|
||||
## Aktuelle Version: v9e+ (Phase 1 Goal System Fixes) 🎯 Ready for Phase 0b - 27.03.2026
|
||||
## Aktuelle Version: v0.9g+ → v0.9h (Goals Complete + Dynamic Focus Areas) 🎯 27.03.2026
|
||||
|
||||
### Letzte Updates (27.03.2026 - Phase 1 Complete) 🆕
|
||||
**Status:** BEREIT FÜR RELEASE v0.9h
|
||||
**Branch:** develop
|
||||
**Nächster Schritt:** Testing → Prod Deploy → Code Splitting → Phase 0b (120+ Platzhalter)
|
||||
|
||||
### Letzte Updates (27.03.2026 - Dynamic Focus Areas v2.0 Complete) 🆕
|
||||
|
||||
#### Dynamic Focus Areas v2.0 System ✅
|
||||
- ✅ **Migration 031-032:** Vollständiges dynamisches System
|
||||
- `focus_area_definitions` - 26 Basis-Bereiche in 7 Kategorien (admin-erweiterbar)
|
||||
- `goal_focus_contributions` - Many-to-Many (Goals ↔ Focus Areas) mit Gewichtung
|
||||
- `user_focus_area_weights` - User-spezifische Präferenzen (dynamisch)
|
||||
- ✅ **Backend:** `routers/focus_areas.py` (~350 Zeilen)
|
||||
- CRUD für Focus Area Definitions (Admin only)
|
||||
- User preferences mit Auto-Normalisierung zu Prozenten
|
||||
- Stats endpoint (Progress per Focus Area)
|
||||
- ✅ **Frontend:** Komplett überarbeitet
|
||||
- GoalsPage: Dynamische Kacheln (nur Bereiche mit Gewicht > 0)
|
||||
- Edit-Modus: Alle 26 Bereiche mit Schiebereglern (gruppiert nach Kategorie)
|
||||
- Ziel-Formular: Nur gewichtete Focus Areas zur Auswahl (cleaner UX)
|
||||
- AdminFocusAreasPage: Volle CRUD-UI für Admin
|
||||
- ✅ **Architektur-Verbesserungen:**
|
||||
- Kein Goal Mode mehr (ersetzt durch dynamische Focus Areas)
|
||||
- M:N Relationship: Ein Ziel zahlt auf 1-n Focus Areas ein
|
||||
- Contribution Weights: Prozentuale Gewichtung pro Zuordnung
|
||||
- User-extensible: Admin kann beliebige neue Bereiche hinzufügen
|
||||
|
||||
#### Bug Fixes (alle deployed) ✅
|
||||
- ✅ **Focus Contributions speichern:** `focus_contributions` fehlte in API-Payload (GoalsPage:232)
|
||||
- ✅ **Focus Area Filtering:** Nur gewichtete Areas im Ziel-Formular (bessere UX)
|
||||
- ✅ **Vitals Baseline Fix:** Parameter mismatch in dynamischer Query-Generierung behoben
|
||||
|
||||
#### Custom Goals Page (Capture/Eigene Ziele) ✅
|
||||
- ✅ **Custom Goals Page (Capture/Eigene Ziele):**
|
||||
- Neue Seite für tägliche Werterfassung individueller Ziele
|
||||
- Dedizierte UI für custom goals (ohne automatische Datenquelle)
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ def _fetch_by_aggregation_method(
|
|||
- max_30d: Maximum value in last 30 days
|
||||
|
||||
Args:
|
||||
filter_conditions: Optional JSON filters (e.g., {"training_type": "strength"})
|
||||
filter_conditions: Optional JSON filters (e.g., {"training_category": "strength"})
|
||||
"""
|
||||
# Guard: source_table/column required for simple aggregation
|
||||
if not table or not column:
|
||||
|
|
@ -412,7 +412,21 @@ def _fetch_by_aggregation_method(
|
|||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Log detailed error for debugging
|
||||
print(f"[ERROR] Failed to fetch value from {table}.{column} using {method}: {e}")
|
||||
print(f"[ERROR] Filter conditions: {filter_conditions}")
|
||||
print(f"[ERROR] Filter SQL: {filter_sql}")
|
||||
print(f"[ERROR] Filter params: {filter_params}")
|
||||
|
||||
# CRITICAL: Rollback transaction to avoid InFailedSqlTransaction errors
|
||||
try:
|
||||
conn.rollback()
|
||||
print(f"[INFO] Transaction rolled back after query error")
|
||||
except Exception as rollback_err:
|
||||
print(f"[WARNING] Rollback failed: {rollback_err}")
|
||||
|
||||
# Return None so goal creation can continue without current_value
|
||||
# (current_value will be NULL in the goal record)
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +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)
|
||||
from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas)
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -99,6 +99,7 @@ app.include_router(vitals_baseline.router) # /api/vitals/baseline/* (v9d Ph
|
|||
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)
|
||||
app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic)
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
254
backend/migrations/031_focus_area_system_v2.sql
Normal file
254
backend/migrations/031_focus_area_system_v2.sql
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
-- Migration 031: Focus Area System v2.0
|
||||
-- Date: 2026-03-27
|
||||
-- Purpose: Dynamic, extensible focus areas with Many-to-Many goal contributions
|
||||
|
||||
-- ============================================================================
|
||||
-- Part 1: New Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Focus Area Definitions (dynamic, user-extensible)
|
||||
CREATE TABLE IF NOT EXISTS focus_area_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'strength', 'aerobic_endurance'
|
||||
name_de VARCHAR(100) NOT NULL,
|
||||
name_en VARCHAR(100),
|
||||
icon VARCHAR(10),
|
||||
description TEXT,
|
||||
category VARCHAR(50), -- 'body_composition', 'training', 'endurance', 'coordination', 'mental', 'recovery', 'health'
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_focus_area_key ON focus_area_definitions(key);
|
||||
CREATE INDEX idx_focus_area_category ON focus_area_definitions(category);
|
||||
|
||||
COMMENT ON TABLE focus_area_definitions IS 'Dynamic focus area registry - defines all available focus dimensions';
|
||||
COMMENT ON COLUMN focus_area_definitions.key IS 'Unique identifier for programmatic access';
|
||||
COMMENT ON COLUMN focus_area_definitions.category IS 'Grouping for UI display';
|
||||
|
||||
-- Many-to-Many: Goals contribute to Focus Areas
|
||||
CREATE TABLE IF NOT EXISTS goal_focus_contributions (
|
||||
goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
||||
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
|
||||
contribution_weight DECIMAL(5,2) DEFAULT 100.00 CHECK (contribution_weight >= 0 AND contribution_weight <= 100),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (goal_id, focus_area_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_gfc_goal ON goal_focus_contributions(goal_id);
|
||||
CREATE INDEX idx_gfc_focus_area ON goal_focus_contributions(focus_area_id);
|
||||
|
||||
COMMENT ON TABLE goal_focus_contributions IS 'Maps goals to focus areas with contribution weights (0-100%)';
|
||||
COMMENT ON COLUMN goal_focus_contributions.contribution_weight IS 'How much this goal contributes to the focus area (0-100%)';
|
||||
|
||||
-- ============================================================================
|
||||
-- Part 2: Rename existing focus_areas table
|
||||
-- ============================================================================
|
||||
|
||||
-- Old focus_areas table becomes user_focus_preferences
|
||||
ALTER TABLE focus_areas RENAME TO user_focus_preferences;
|
||||
|
||||
-- Add reference to new focus_area_definitions (for future use)
|
||||
ALTER TABLE user_focus_preferences ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
||||
COMMENT ON TABLE user_focus_preferences IS 'User-specific focus area weightings (legacy flat structure + new references)';
|
||||
|
||||
-- ============================================================================
|
||||
-- Part 3: Seed Data - Basis Focus Areas
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO focus_area_definitions (key, name_de, name_en, icon, category, description) VALUES
|
||||
-- Body Composition
|
||||
('weight_loss', 'Gewichtsverlust', 'Weight Loss', '📉', 'body_composition', 'Körpergewicht reduzieren'),
|
||||
('muscle_gain', 'Muskelaufbau', 'Muscle Gain', '💪', 'body_composition', 'Muskelmasse aufbauen'),
|
||||
('body_recomposition', 'Body Recomposition', 'Body Recomposition', '⚖️', 'body_composition', 'Gleichzeitig Fett abbauen und Muskeln aufbauen'),
|
||||
|
||||
-- Training - Kraft
|
||||
('strength', 'Maximalkraft', 'Strength', '🏋️', 'training', 'Maximale Kraftfähigkeit'),
|
||||
('strength_endurance', 'Kraftausdauer', 'Strength Endurance', '💪🏃', 'training', 'Kraft über längere Zeit aufrechterhalten'),
|
||||
('power', 'Schnellkraft', 'Power', '⚡', 'training', 'Kraft in kurzer Zeit entfalten'),
|
||||
|
||||
-- Training - Beweglichkeit
|
||||
('flexibility', 'Beweglichkeit', 'Flexibility', '🤸', 'training', 'Gelenkigkeit und Bewegungsumfang'),
|
||||
('mobility', 'Mobilität', 'Mobility', '🦴', 'training', 'Aktive Beweglichkeit und Kontrolle'),
|
||||
|
||||
-- Ausdauer
|
||||
('aerobic_endurance', 'Aerobe Ausdauer', 'Aerobic Endurance', '🫁', 'endurance', 'VO2Max, lange moderate Belastung'),
|
||||
('anaerobic_endurance', 'Anaerobe Ausdauer', 'Anaerobic Endurance', '⚡', 'endurance', 'Laktattoleranz, kurze intensive Belastung'),
|
||||
('cardiovascular_health', 'Herz-Kreislauf', 'Cardiovascular Health', '❤️', 'endurance', 'Herzgesundheit und Ausdauer'),
|
||||
|
||||
-- Koordination
|
||||
('balance', 'Gleichgewicht', 'Balance', '⚖️', 'coordination', 'Statisches und dynamisches Gleichgewicht'),
|
||||
('reaction', 'Reaktionsfähigkeit', 'Reaction', '⚡', 'coordination', 'Schnelligkeit der Reaktion auf Reize'),
|
||||
('rhythm', 'Rhythmusgefühl', 'Rhythm', '🎵', 'coordination', 'Zeitliche Abstimmung von Bewegungen'),
|
||||
('coordination', 'Koordination', 'Coordination', '🎯', 'coordination', 'Zusammenspiel verschiedener Bewegungen'),
|
||||
|
||||
-- Mental
|
||||
('stress_resistance', 'Stressresistenz', 'Stress Resistance', '🧘', 'mental', 'Umgang mit mentalem und physischem Stress'),
|
||||
('concentration', 'Konzentration', 'Concentration', '🎯', 'mental', 'Fokussierung und Aufmerksamkeit'),
|
||||
('willpower', 'Willenskraft', 'Willpower', '💎', 'mental', 'Durchhaltevermögen und Selbstdisziplin'),
|
||||
('mental_health', 'Mentale Gesundheit', 'Mental Health', '🧠', 'mental', 'Psychisches Wohlbefinden'),
|
||||
|
||||
-- Recovery
|
||||
('sleep_quality', 'Schlafqualität', 'Sleep Quality', '😴', 'recovery', 'Erholsamer Schlaf'),
|
||||
('regeneration', 'Regeneration', 'Regeneration', '♻️', 'recovery', 'Körperliche Erholung'),
|
||||
('rest', 'Ruhe', 'Rest', '🛌', 'recovery', 'Aktive und passive Erholung'),
|
||||
|
||||
-- Health
|
||||
('metabolic_health', 'Stoffwechselgesundheit', 'Metabolic Health', '🔥', 'health', 'Blutzucker, Insulin, Stoffwechsel'),
|
||||
('blood_pressure', 'Blutdruck', 'Blood Pressure', '❤️🩹', 'health', 'Gesunder Blutdruck'),
|
||||
('hrv', 'Herzratenvariabilität', 'HRV', '💓', 'health', 'Autonomes Nervensystem'),
|
||||
('general_health', 'Allgemeine Gesundheit', 'General Health', '🏥', 'health', 'Vitale Gesundheit und Wohlbefinden')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- Part 4: Auto-Mapping - Bestehende Goals zu Focus Areas
|
||||
-- ============================================================================
|
||||
|
||||
-- Helper function to get focus_area_id by key
|
||||
CREATE OR REPLACE FUNCTION get_focus_area_id(area_key VARCHAR)
|
||||
RETURNS UUID AS $$
|
||||
BEGIN
|
||||
RETURN (SELECT id FROM focus_area_definitions WHERE key = area_key LIMIT 1);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Weight goals → weight_loss (100%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, get_focus_area_id('weight_loss'), 100.00
|
||||
FROM goals g
|
||||
WHERE g.goal_type = 'weight'
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Body Fat goals → weight_loss (60%) + body_recomposition (40%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'weight_loss' THEN 60.00
|
||||
WHEN 'body_recomposition' THEN 40.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'body_fat'
|
||||
AND fa.key IN ('weight_loss', 'body_recomposition')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Lean Mass goals → muscle_gain (70%) + body_recomposition (30%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'muscle_gain' THEN 70.00
|
||||
WHEN 'body_recomposition' THEN 30.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'lean_mass'
|
||||
AND fa.key IN ('muscle_gain', 'body_recomposition')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Strength goals → strength (70%) + muscle_gain (30%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'strength' THEN 70.00
|
||||
WHEN 'muscle_gain' THEN 30.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'strength'
|
||||
AND fa.key IN ('strength', 'muscle_gain')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Flexibility goals → flexibility (100%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, get_focus_area_id('flexibility'), 100.00
|
||||
FROM goals g
|
||||
WHERE g.goal_type = 'flexibility'
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- VO2Max goals → aerobic_endurance (80%) + cardiovascular_health (20%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'aerobic_endurance' THEN 80.00
|
||||
WHEN 'cardiovascular_health' THEN 20.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'vo2max'
|
||||
AND fa.key IN ('aerobic_endurance', 'cardiovascular_health')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Resting Heart Rate goals → cardiovascular_health (100%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, get_focus_area_id('cardiovascular_health'), 100.00
|
||||
FROM goals g
|
||||
WHERE g.goal_type = 'rhr'
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Blood Pressure goals → blood_pressure (80%) + cardiovascular_health (20%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'blood_pressure' THEN 80.00
|
||||
WHEN 'cardiovascular_health' THEN 20.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'bp'
|
||||
AND fa.key IN ('blood_pressure', 'cardiovascular_health')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- HRV goals → hrv (70%) + stress_resistance (30%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'hrv' THEN 70.00
|
||||
WHEN 'stress_resistance' THEN 30.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'hrv'
|
||||
AND fa.key IN ('hrv', 'stress_resistance')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Sleep Quality goals → sleep_quality (100%)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, get_focus_area_id('sleep_quality'), 100.00
|
||||
FROM goals g
|
||||
WHERE g.goal_type = 'sleep_quality'
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Training Frequency goals → general catch-all (strength + endurance)
|
||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
||||
SELECT g.id, fa.id,
|
||||
CASE fa.key
|
||||
WHEN 'strength' THEN 40.00
|
||||
WHEN 'aerobic_endurance' THEN 40.00
|
||||
WHEN 'general_health' THEN 20.00
|
||||
END
|
||||
FROM goals g
|
||||
CROSS JOIN focus_area_definitions fa
|
||||
WHERE g.goal_type = 'training_frequency'
|
||||
AND fa.key IN ('strength', 'aerobic_endurance', 'general_health')
|
||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
||||
|
||||
-- Cleanup helper function
|
||||
DROP FUNCTION IF EXISTS get_focus_area_id(VARCHAR);
|
||||
|
||||
-- ============================================================================
|
||||
-- Summary
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE focus_area_definitions IS
|
||||
'v2.0: Dynamic focus areas - replaces hardcoded 6-dimension system.
|
||||
26 base areas across 7 categories. User-extensible via admin UI.';
|
||||
|
||||
COMMENT ON TABLE goal_focus_contributions IS
|
||||
'Many-to-Many mapping: Goals contribute to multiple focus areas with weights.
|
||||
Auto-mapped from goal_type, editable by user.';
|
||||
|
||||
COMMENT ON TABLE user_focus_preferences IS
|
||||
'Legacy flat structure (weight_loss_pct, muscle_gain_pct, etc.) remains for backward compatibility.
|
||||
Future: Use focus_area_definitions + dynamic preferences.';
|
||||
53
backend/migrations/032_user_focus_area_weights.sql
Normal file
53
backend/migrations/032_user_focus_area_weights.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Migration 032: User Focus Area Weights
|
||||
-- Date: 2026-03-27
|
||||
-- Purpose: Allow users to set custom weights for focus areas (dynamic preferences)
|
||||
|
||||
-- ============================================================================
|
||||
-- User Focus Area Weights (many-to-many with weights)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_focus_area_weights (
|
||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
|
||||
weight INTEGER NOT NULL DEFAULT 0 CHECK (weight >= 0 AND weight <= 100),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (profile_id, focus_area_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_focus_weights_profile ON user_focus_area_weights(profile_id);
|
||||
CREATE INDEX idx_user_focus_weights_area ON user_focus_area_weights(focus_area_id);
|
||||
|
||||
COMMENT ON TABLE user_focus_area_weights IS 'User-specific weights for focus areas (dynamic system)';
|
||||
COMMENT ON COLUMN user_focus_area_weights.weight IS 'Relative weight (0-100) - will be normalized to percentages in UI';
|
||||
|
||||
-- ============================================================================
|
||||
-- Migrate legacy preferences to dynamic weights
|
||||
-- ============================================================================
|
||||
|
||||
-- For each user with legacy preferences, create weights for the 6 base areas
|
||||
INSERT INTO user_focus_area_weights (profile_id, focus_area_id, weight)
|
||||
SELECT
|
||||
ufp.profile_id,
|
||||
fad.id as focus_area_id,
|
||||
CASE fad.key
|
||||
WHEN 'weight_loss' THEN ufp.weight_loss_pct
|
||||
WHEN 'muscle_gain' THEN ufp.muscle_gain_pct
|
||||
WHEN 'strength' THEN ufp.strength_pct
|
||||
WHEN 'aerobic_endurance' THEN ufp.endurance_pct
|
||||
WHEN 'flexibility' THEN ufp.flexibility_pct
|
||||
WHEN 'general_health' THEN ufp.health_pct
|
||||
ELSE 0
|
||||
END as weight
|
||||
FROM user_focus_preferences ufp
|
||||
CROSS JOIN focus_area_definitions fad
|
||||
WHERE fad.key IN ('weight_loss', 'muscle_gain', 'strength', 'aerobic_endurance', 'flexibility', 'general_health')
|
||||
AND (
|
||||
(fad.key = 'weight_loss' AND ufp.weight_loss_pct > 0) OR
|
||||
(fad.key = 'muscle_gain' AND ufp.muscle_gain_pct > 0) OR
|
||||
(fad.key = 'strength' AND ufp.strength_pct > 0) OR
|
||||
(fad.key = 'aerobic_endurance' AND ufp.endurance_pct > 0) OR
|
||||
(fad.key = 'flexibility' AND ufp.flexibility_pct > 0) OR
|
||||
(fad.key = 'general_health' AND ufp.health_pct > 0)
|
||||
)
|
||||
ON CONFLICT (profile_id, focus_area_id) DO NOTHING;
|
||||
378
backend/routers/focus_areas.py
Normal file
378
backend/routers/focus_areas.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
"""
|
||||
Focus Areas Router
|
||||
Manages dynamic focus area definitions and user preferences
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
|
||||
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
||||
|
||||
# ============================================================================
|
||||
# Models
|
||||
# ============================================================================
|
||||
|
||||
class FocusAreaCreate(BaseModel):
|
||||
"""Create new focus area definition"""
|
||||
key: str
|
||||
name_de: str
|
||||
name_en: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: str = 'custom'
|
||||
|
||||
class FocusAreaUpdate(BaseModel):
|
||||
"""Update focus area definition"""
|
||||
name_de: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class UserFocusPreferences(BaseModel):
|
||||
"""User's focus area weightings (dynamic)"""
|
||||
preferences: dict # {focus_area_id: weight_pct}
|
||||
|
||||
# ============================================================================
|
||||
# Focus Area Definitions (Admin)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/definitions")
|
||||
def list_focus_area_definitions(
|
||||
session: dict = Depends(require_auth),
|
||||
include_inactive: bool = False
|
||||
):
|
||||
"""
|
||||
List all available focus area definitions.
|
||||
|
||||
Query params:
|
||||
- include_inactive: Include inactive focus areas (default: false)
|
||||
|
||||
Returns focus areas grouped by category.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
query = """
|
||||
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
||||
created_at, updated_at
|
||||
FROM focus_area_definitions
|
||||
WHERE is_active = true OR %s
|
||||
ORDER BY category, name_de
|
||||
"""
|
||||
|
||||
cur.execute(query, (include_inactive,))
|
||||
areas = [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
# Group by category
|
||||
grouped = {}
|
||||
for area in areas:
|
||||
cat = area['category'] or 'other'
|
||||
if cat not in grouped:
|
||||
grouped[cat] = []
|
||||
grouped[cat].append(area)
|
||||
|
||||
return {
|
||||
"areas": areas,
|
||||
"grouped": grouped,
|
||||
"total": len(areas)
|
||||
}
|
||||
|
||||
@router.post("/definitions")
|
||||
def create_focus_area_definition(
|
||||
data: FocusAreaCreate,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
Create new focus area definition (Admin only).
|
||||
|
||||
Note: Requires admin role.
|
||||
"""
|
||||
# Admin check
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if key already exists
|
||||
cur.execute(
|
||||
"SELECT id FROM focus_area_definitions WHERE key = %s",
|
||||
(data.key,)
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Focus Area mit Key '{data.key}' existiert bereits"
|
||||
)
|
||||
|
||||
# Insert
|
||||
cur.execute("""
|
||||
INSERT INTO focus_area_definitions
|
||||
(key, name_de, name_en, icon, description, category)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.key, data.name_de, data.name_en,
|
||||
data.icon, data.description, data.category
|
||||
))
|
||||
|
||||
area_id = cur.fetchone()['id']
|
||||
|
||||
return {
|
||||
"id": area_id,
|
||||
"message": f"Focus Area '{data.name_de}' erstellt"
|
||||
}
|
||||
|
||||
@router.put("/definitions/{area_id}")
|
||||
def update_focus_area_definition(
|
||||
area_id: str,
|
||||
data: FocusAreaUpdate,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
"""Update focus area definition (Admin only)"""
|
||||
# Admin check
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Build dynamic UPDATE
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
if data.name_de is not None:
|
||||
updates.append("name_de = %s")
|
||||
values.append(data.name_de)
|
||||
if data.name_en is not None:
|
||||
updates.append("name_en = %s")
|
||||
values.append(data.name_en)
|
||||
if data.icon is not None:
|
||||
updates.append("icon = %s")
|
||||
values.append(data.icon)
|
||||
if data.description is not None:
|
||||
updates.append("description = %s")
|
||||
values.append(data.description)
|
||||
if data.category is not None:
|
||||
updates.append("category = %s")
|
||||
values.append(data.category)
|
||||
if data.is_active is not None:
|
||||
updates.append("is_active = %s")
|
||||
values.append(data.is_active)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
values.append(area_id)
|
||||
|
||||
query = f"""
|
||||
UPDATE focus_area_definitions
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
cur.execute(query, values)
|
||||
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
||||
|
||||
return {"message": "Focus Area aktualisiert"}
|
||||
|
||||
@router.delete("/definitions/{area_id}")
|
||||
def delete_focus_area_definition(
|
||||
area_id: str,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
Delete focus area definition (Admin only).
|
||||
|
||||
Cascades: Deletes all goal_focus_contributions referencing this area.
|
||||
"""
|
||||
# Admin check
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if area is used
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM goal_focus_contributions WHERE focus_area_id = %s",
|
||||
(area_id,)
|
||||
)
|
||||
count = cur.fetchone()['count']
|
||||
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Focus Area wird von {count} Ziel(en) verwendet. "
|
||||
"Bitte erst Zuordnungen entfernen oder auf 'inaktiv' setzen."
|
||||
)
|
||||
|
||||
# Delete
|
||||
cur.execute(
|
||||
"DELETE FROM focus_area_definitions WHERE id = %s RETURNING id",
|
||||
(area_id,)
|
||||
)
|
||||
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
||||
|
||||
return {"message": "Focus Area gelöscht"}
|
||||
|
||||
# ============================================================================
|
||||
# User Focus Preferences
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/user-preferences")
|
||||
def get_user_focus_preferences(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get user's focus area weightings (dynamic system).
|
||||
|
||||
Returns focus areas with user-set weights, grouped by category.
|
||||
"""
|
||||
pid = session['profile_id']
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get dynamic preferences (Migration 032)
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
fa.id, fa.key, fa.name_de, fa.name_en, fa.icon,
|
||||
fa.category, fa.description,
|
||||
ufw.weight
|
||||
FROM user_focus_area_weights ufw
|
||||
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
|
||||
WHERE ufw.profile_id = %s AND ufw.weight > 0
|
||||
ORDER BY fa.category, fa.name_de
|
||||
""", (pid,))
|
||||
|
||||
weights = [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
# Calculate percentages from weights
|
||||
total_weight = sum(w['weight'] for w in weights)
|
||||
if total_weight > 0:
|
||||
for w in weights:
|
||||
w['percentage'] = round((w['weight'] / total_weight) * 100)
|
||||
else:
|
||||
for w in weights:
|
||||
w['percentage'] = 0
|
||||
|
||||
# Group by category
|
||||
grouped = {}
|
||||
for w in weights:
|
||||
cat = w['category'] or 'other'
|
||||
if cat not in grouped:
|
||||
grouped[cat] = []
|
||||
grouped[cat].append(w)
|
||||
|
||||
return {
|
||||
"weights": weights,
|
||||
"grouped": grouped,
|
||||
"total_weight": total_weight
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Migration 032 not applied yet - return empty
|
||||
print(f"[WARNING] user_focus_area_weights not found: {e}")
|
||||
return {
|
||||
"weights": [],
|
||||
"grouped": {},
|
||||
"total_weight": 0
|
||||
}
|
||||
|
||||
@router.put("/user-preferences")
|
||||
def update_user_focus_preferences(
|
||||
data: dict,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
Update user's focus area weightings (dynamic system).
|
||||
|
||||
Expects: { "weights": { "focus_area_id": weight, ... } }
|
||||
Weights are relative (0-100), normalized in display only.
|
||||
"""
|
||||
pid = session['profile_id']
|
||||
|
||||
if 'weights' not in data:
|
||||
raise HTTPException(status_code=400, detail="'weights' field required")
|
||||
|
||||
weights = data['weights'] # Dict: focus_area_id → weight
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Delete existing weights
|
||||
cur.execute(
|
||||
"DELETE FROM user_focus_area_weights WHERE profile_id = %s",
|
||||
(pid,)
|
||||
)
|
||||
|
||||
# Insert new weights (only non-zero)
|
||||
for focus_area_id, weight in weights.items():
|
||||
weight_int = int(weight)
|
||||
if weight_int > 0:
|
||||
cur.execute("""
|
||||
INSERT INTO user_focus_area_weights
|
||||
(profile_id, focus_area_id, weight)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (profile_id, focus_area_id)
|
||||
DO UPDATE SET
|
||||
weight = EXCLUDED.weight,
|
||||
updated_at = NOW()
|
||||
""", (pid, focus_area_id, weight_int))
|
||||
|
||||
return {
|
||||
"message": "Focus Area Gewichtungen aktualisiert",
|
||||
"count": len([w for w in weights.values() if int(w) > 0])
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Stats & Analytics
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def get_focus_area_stats(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get focus area statistics for current user.
|
||||
|
||||
Returns:
|
||||
- Progress per focus area (avg of all contributing goals)
|
||||
- Goal count per focus area
|
||||
- Top/bottom performing areas
|
||||
"""
|
||||
pid = session['profile_id']
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
fa.id, fa.key, fa.name_de, fa.icon, fa.category,
|
||||
COUNT(DISTINCT gfc.goal_id) as goal_count,
|
||||
AVG(g.progress_pct) as avg_progress,
|
||||
SUM(gfc.contribution_weight) as total_contribution
|
||||
FROM focus_area_definitions fa
|
||||
LEFT JOIN goal_focus_contributions gfc ON fa.id = gfc.focus_area_id
|
||||
LEFT JOIN goals g ON gfc.goal_id = g.id AND g.profile_id = %s
|
||||
WHERE fa.is_active = true
|
||||
GROUP BY fa.id
|
||||
HAVING COUNT(DISTINCT gfc.goal_id) > 0 -- Only areas with goals
|
||||
ORDER BY avg_progress DESC NULLS LAST
|
||||
""", (pid,))
|
||||
|
||||
stats = [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"stats": stats,
|
||||
"top_area": stats[0] if stats else None,
|
||||
"bottom_area": stats[-1] if len(stats) > 1 else None
|
||||
}
|
||||
|
|
@ -39,6 +39,11 @@ class FocusAreasUpdate(BaseModel):
|
|||
flexibility_pct: int
|
||||
health_pct: int
|
||||
|
||||
class FocusContribution(BaseModel):
|
||||
"""Focus area contribution (v2.0)"""
|
||||
focus_area_id: str
|
||||
contribution_weight: float = 100.0 # 0-100%
|
||||
|
||||
class GoalCreate(BaseModel):
|
||||
"""Create or update a concrete goal"""
|
||||
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
||||
|
|
@ -50,6 +55,7 @@ class GoalCreate(BaseModel):
|
|||
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
|
||||
|
||||
class GoalUpdate(BaseModel):
|
||||
"""Update existing goal"""
|
||||
|
|
@ -61,6 +67,7 @@ class GoalUpdate(BaseModel):
|
|||
priority: Optional[int] = None # 1=high, 2=medium, 3=low
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
|
||||
|
||||
class TrainingPhaseCreate(BaseModel):
|
||||
"""Create training phase (manual or auto-detected)"""
|
||||
|
|
@ -194,7 +201,21 @@ def get_focus_areas(session: dict = Depends(require_auth)):
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Try to get custom focus areas
|
||||
# Try to get custom focus areas (user_focus_preferences after Migration 031)
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
|
||||
endurance_pct, flexibility_pct, health_pct,
|
||||
created_at, updated_at
|
||||
FROM user_focus_preferences
|
||||
WHERE profile_id = %s
|
||||
LIMIT 1
|
||||
""", (pid,))
|
||||
row = cur.fetchone()
|
||||
except Exception as e:
|
||||
# Migration 031 not applied yet, try old table name
|
||||
print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
|
||||
endurance_pct, flexibility_pct, health_pct,
|
||||
|
|
@ -203,8 +224,9 @@ def get_focus_areas(session: dict = Depends(require_auth)):
|
|||
WHERE profile_id = %s AND active = true
|
||||
LIMIT 1
|
||||
""", (pid,))
|
||||
|
||||
row = cur.fetchone()
|
||||
except:
|
||||
row = None
|
||||
|
||||
if row:
|
||||
return {
|
||||
|
|
@ -429,6 +451,17 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
|
|||
|
||||
goal_id = cur.fetchone()['id']
|
||||
|
||||
# v2.0: Insert focus area contributions
|
||||
if data.focus_contributions:
|
||||
for contrib in data.focus_contributions:
|
||||
cur.execute("""
|
||||
INSERT INTO goal_focus_contributions
|
||||
(goal_id, focus_area_id, contribution_weight)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (goal_id, focus_area_id) DO UPDATE
|
||||
SET contribution_weight = EXCLUDED.contribution_weight
|
||||
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
|
||||
|
||||
return {"id": goal_id, "message": "Ziel erstellt"}
|
||||
|
||||
@router.put("/{goal_id}")
|
||||
|
|
@ -492,9 +525,26 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
|
|||
updates.append("description = %s")
|
||||
params.append(data.description)
|
||||
|
||||
if not updates:
|
||||
# Handle focus_contributions separately (can be updated even if no other changes)
|
||||
if data.focus_contributions is not None:
|
||||
# Delete existing contributions
|
||||
cur.execute(
|
||||
"DELETE FROM goal_focus_contributions WHERE goal_id = %s",
|
||||
(goal_id,)
|
||||
)
|
||||
|
||||
# Insert new contributions
|
||||
for contrib in data.focus_contributions:
|
||||
cur.execute("""
|
||||
INSERT INTO goal_focus_contributions
|
||||
(goal_id, focus_area_id, contribution_weight)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
|
||||
|
||||
if not updates and data.focus_contributions is None:
|
||||
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = NOW()")
|
||||
params.extend([goal_id, pid])
|
||||
|
||||
|
|
@ -680,13 +730,50 @@ def get_goals_grouped(session: dict = Depends(require_auth)):
|
|||
|
||||
goals = cur.fetchall()
|
||||
|
||||
# Group by category
|
||||
# v2.0: Load focus_contributions for each goal
|
||||
goal_ids = [g['id'] for g in goals]
|
||||
focus_map = {} # goal_id → [contributions]
|
||||
|
||||
if goal_ids:
|
||||
try:
|
||||
placeholders = ','.join(['%s'] * len(goal_ids))
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
gfc.goal_id, gfc.contribution_weight,
|
||||
fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category
|
||||
FROM goal_focus_contributions gfc
|
||||
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
|
||||
WHERE gfc.goal_id IN ({placeholders})
|
||||
ORDER BY gfc.contribution_weight DESC
|
||||
""", tuple(goal_ids))
|
||||
|
||||
for row in cur.fetchall():
|
||||
gid = row['goal_id']
|
||||
if gid not in focus_map:
|
||||
focus_map[gid] = []
|
||||
focus_map[gid].append({
|
||||
'focus_area_id': row['focus_area_id'],
|
||||
'key': row['key'],
|
||||
'name_de': row['name_de'],
|
||||
'icon': row['icon'],
|
||||
'category': row['category'],
|
||||
'contribution_weight': float(row['contribution_weight'])
|
||||
})
|
||||
except Exception as e:
|
||||
# Migration 031 not yet applied - focus_contributions tables don't exist
|
||||
print(f"[WARNING] Could not load focus_contributions: {e}")
|
||||
# Continue without focus_contributions (backward compatible)
|
||||
|
||||
# Group by category and attach focus_contributions
|
||||
grouped = {}
|
||||
for goal in goals:
|
||||
cat = goal['category'] or 'other'
|
||||
if cat not in grouped:
|
||||
grouped[cat] = []
|
||||
grouped[cat].append(r2d(goal))
|
||||
|
||||
goal_dict = r2d(goal)
|
||||
goal_dict['focus_contributions'] = focus_map.get(goal['id'], [])
|
||||
grouped[cat].append(goal_dict)
|
||||
|
||||
return grouped
|
||||
|
||||
|
|
@ -997,7 +1084,7 @@ def list_goal_type_definitions(session: dict = Depends(require_auth)):
|
|||
cur.execute("""
|
||||
SELECT id, type_key, label_de, label_en, unit, icon, category,
|
||||
source_table, source_column, aggregation_method,
|
||||
calculation_formula, description, is_system, is_active,
|
||||
calculation_formula, filter_conditions, description, is_system, is_active,
|
||||
created_at, updated_at
|
||||
FROM goal_type_definitions
|
||||
WHERE is_active = true
|
||||
|
|
|
|||
|
|
@ -140,63 +140,66 @@ def create_vitals(
|
|||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
"""Create or update vitals entry (upsert)."""
|
||||
"""
|
||||
Create or update vitals entry (upsert).
|
||||
|
||||
Post-Migration-015: Routes to vitals_baseline (for RHR, HRV, etc.)
|
||||
Note: BP measurements should use /api/blood-pressure endpoint instead.
|
||||
"""
|
||||
pid = get_pid(x_profile_id, session)
|
||||
|
||||
# Validation: at least one vital must be provided
|
||||
has_data = any([
|
||||
entry.resting_hr, entry.hrv, entry.blood_pressure_systolic,
|
||||
entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2,
|
||||
entry.respiratory_rate
|
||||
# Validation: at least one baseline vital must be provided
|
||||
has_baseline = any([
|
||||
entry.resting_hr, entry.hrv, entry.vo2_max,
|
||||
entry.spo2, entry.respiratory_rate
|
||||
])
|
||||
if not has_data:
|
||||
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden")
|
||||
|
||||
if not has_baseline:
|
||||
raise HTTPException(400, "Mindestens ein Vitalwert muss angegeben werden (RHR, HRV, VO2Max, SpO2, oder Atemfrequenz)")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Upsert: insert or update if date already exists
|
||||
# Upsert into vitals_baseline (Migration 015)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO vitals_log (
|
||||
INSERT INTO vitals_baseline (
|
||||
profile_id, date, resting_hr, hrv,
|
||||
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
||||
vo2_max, spo2, respiratory_rate,
|
||||
irregular_heartbeat, possible_afib,
|
||||
note, source
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual')
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'manual')
|
||||
ON CONFLICT (profile_id, date)
|
||||
DO UPDATE SET
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr),
|
||||
hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv),
|
||||
blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic),
|
||||
blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic),
|
||||
pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse),
|
||||
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max),
|
||||
spo2 = COALESCE(EXCLUDED.spo2, vitals_log.spo2),
|
||||
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_log.respiratory_rate),
|
||||
irregular_heartbeat = COALESCE(EXCLUDED.irregular_heartbeat, vitals_log.irregular_heartbeat),
|
||||
possible_afib = COALESCE(EXCLUDED.possible_afib, vitals_log.possible_afib),
|
||||
note = COALESCE(EXCLUDED.note, vitals_log.note),
|
||||
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr),
|
||||
hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv),
|
||||
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max),
|
||||
spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2),
|
||||
respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate),
|
||||
note = COALESCE(EXCLUDED.note, vitals_baseline.note),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id, profile_id, date, resting_hr, hrv,
|
||||
blood_pressure_systolic, blood_pressure_diastolic, pulse,
|
||||
vo2_max, spo2, respiratory_rate,
|
||||
irregular_heartbeat, possible_afib,
|
||||
note, source, created_at, updated_at
|
||||
""",
|
||||
(pid, entry.date, entry.resting_hr, entry.hrv,
|
||||
entry.blood_pressure_systolic, entry.blood_pressure_diastolic, entry.pulse,
|
||||
entry.vo2_max, entry.spo2, entry.respiratory_rate,
|
||||
entry.irregular_heartbeat, entry.possible_afib,
|
||||
entry.note)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}")
|
||||
return r2d(row)
|
||||
logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}")
|
||||
|
||||
# Return in legacy format for backward compatibility
|
||||
result = r2d(row)
|
||||
result['blood_pressure_systolic'] = None
|
||||
result['blood_pressure_diastolic'] = None
|
||||
result['pulse'] = None
|
||||
result['irregular_heartbeat'] = None
|
||||
result['possible_afib'] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{vitals_id}")
|
||||
|
|
|
|||
|
|
@ -99,52 +99,90 @@ def create_or_update_baseline(
|
|||
"""Create or update baseline entry (upsert on date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Build dynamic update columns (only non-None fields)
|
||||
fields = []
|
||||
values = [pid, entry.date]
|
||||
# Build dynamic INSERT columns, placeholders, UPDATE fields, and values list
|
||||
# All arrays must stay synchronized
|
||||
insert_cols = []
|
||||
insert_placeholders = []
|
||||
update_fields = []
|
||||
param_values = [] # Will contain ALL values including pid and date
|
||||
|
||||
# Always include profile_id and date
|
||||
param_values.append(pid)
|
||||
param_values.append(entry.date)
|
||||
|
||||
if entry.resting_hr is not None:
|
||||
fields.append("resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr)")
|
||||
values.append(entry.resting_hr)
|
||||
insert_cols.append("resting_hr")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("resting_hr = EXCLUDED.resting_hr")
|
||||
param_values.append(entry.resting_hr)
|
||||
|
||||
if entry.hrv is not None:
|
||||
fields.append("hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv)")
|
||||
values.append(entry.hrv)
|
||||
insert_cols.append("hrv")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("hrv = EXCLUDED.hrv")
|
||||
param_values.append(entry.hrv)
|
||||
|
||||
if entry.vo2_max is not None:
|
||||
fields.append("vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max)")
|
||||
values.append(entry.vo2_max)
|
||||
insert_cols.append("vo2_max")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("vo2_max = EXCLUDED.vo2_max")
|
||||
param_values.append(entry.vo2_max)
|
||||
|
||||
if entry.spo2 is not None:
|
||||
fields.append("spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2)")
|
||||
values.append(entry.spo2)
|
||||
insert_cols.append("spo2")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("spo2 = EXCLUDED.spo2")
|
||||
param_values.append(entry.spo2)
|
||||
|
||||
if entry.respiratory_rate is not None:
|
||||
fields.append("respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate)")
|
||||
values.append(entry.respiratory_rate)
|
||||
insert_cols.append("respiratory_rate")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("respiratory_rate = EXCLUDED.respiratory_rate")
|
||||
param_values.append(entry.respiratory_rate)
|
||||
|
||||
if entry.body_temperature is not None:
|
||||
fields.append("body_temperature = COALESCE(EXCLUDED.body_temperature, vitals_baseline.body_temperature)")
|
||||
values.append(entry.body_temperature)
|
||||
insert_cols.append("body_temperature")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("body_temperature = EXCLUDED.body_temperature")
|
||||
param_values.append(entry.body_temperature)
|
||||
|
||||
if entry.resting_metabolic_rate is not None:
|
||||
fields.append("resting_metabolic_rate = COALESCE(EXCLUDED.resting_metabolic_rate, vitals_baseline.resting_metabolic_rate)")
|
||||
values.append(entry.resting_metabolic_rate)
|
||||
insert_cols.append("resting_metabolic_rate")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("resting_metabolic_rate = EXCLUDED.resting_metabolic_rate")
|
||||
param_values.append(entry.resting_metabolic_rate)
|
||||
|
||||
if entry.note:
|
||||
fields.append("note = COALESCE(EXCLUDED.note, vitals_baseline.note)")
|
||||
values.append(entry.note)
|
||||
insert_cols.append("note")
|
||||
insert_placeholders.append("%s")
|
||||
update_fields.append("note = EXCLUDED.note")
|
||||
param_values.append(entry.note)
|
||||
|
||||
# At least one field must be provided
|
||||
if not fields:
|
||||
if not insert_cols:
|
||||
raise HTTPException(400, "At least one baseline vital must be provided")
|
||||
|
||||
# Build value placeholders
|
||||
placeholders = ", ".join([f"${i}" for i in range(1, len(values) + 1)])
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Build complete column list and placeholder list
|
||||
# IMPORTANT: psycopg2 uses %s placeholders, NOT $1/$2/$3
|
||||
all_cols = f"profile_id, date, {', '.join(insert_cols)}"
|
||||
all_placeholders = f"%s, %s, {', '.join(insert_placeholders)}"
|
||||
|
||||
query = f"""
|
||||
INSERT INTO vitals_baseline (profile_id, date, {', '.join([f.split('=')[0].strip() for f in fields])})
|
||||
VALUES ($1, $2, {', '.join([f'${i}' for i in range(3, len(values) + 1)])})
|
||||
INSERT INTO vitals_baseline ({all_cols})
|
||||
VALUES ({all_placeholders})
|
||||
ON CONFLICT (profile_id, date)
|
||||
DO UPDATE SET {', '.join(fields)}, updated_at = NOW()
|
||||
DO UPDATE SET {', '.join(update_fields)}, updated_at = NOW()
|
||||
RETURNING *
|
||||
"""
|
||||
cur.execute(query, values)
|
||||
|
||||
# Debug logging
|
||||
print(f"[DEBUG] Vitals baseline query: {query}")
|
||||
print(f"[DEBUG] Param values ({len(param_values)}): {param_values}")
|
||||
|
||||
cur.execute(query, tuple(param_values))
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
|
|
|
|||
196
docs/DOCUMENTATION_COMPLETE_2026-03-27.md
Normal file
196
docs/DOCUMENTATION_COMPLETE_2026-03-27.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Dokumentation Abgeschlossen - 27. März 2026
|
||||
|
||||
## ✅ Was wurde dokumentiert?
|
||||
|
||||
### 1. Hauptstatus-Dokument
|
||||
📄 **`docs/STATUS_2026-03-27.md`** (NEU)
|
||||
- Vollständiger aktueller Zustand
|
||||
- Gitea Issues Status (offen/geschlossen)
|
||||
- Nächste Schritte (Testing → Release → Code Splitting → Phase 0b)
|
||||
- Code-Metriken und technische Schulden
|
||||
- Entscheidungspunkte und Risiken
|
||||
- **Wiederanstiegspunkt für zukünftige Sessions**
|
||||
|
||||
### 2. Neue Issue dokumentiert
|
||||
📄 **`docs/issues/issue-52-blood-pressure-dual-targets.md`** (NEU)
|
||||
- Blutdruck-Ziele benötigen zwei Zielfelder (systolisch/diastolisch)
|
||||
- Migration 033 geplant
|
||||
- UI-Anpassungen beschrieben
|
||||
- 2-3h Aufwand geschätzt
|
||||
|
||||
### 3. CLAUDE.md aktualisiert
|
||||
📄 **`CLAUDE.md`**
|
||||
- Version: v0.9g+ → v0.9h
|
||||
- Dynamic Focus Areas v2.0 Sektion hinzugefügt
|
||||
- Bug Fixes dokumentiert
|
||||
- Status: BEREIT FÜR RELEASE v0.9h
|
||||
|
||||
### 4. Roadmap aktualisiert
|
||||
📄 **`.claude/docs/ROADMAP.md`**
|
||||
- Phase 0a: ✅ COMPLETE
|
||||
- Phase 0b: 🎯 NEXT (detaillierter Plan)
|
||||
- Timeline aktualisiert
|
||||
- Phasen-Übersicht neu strukturiert
|
||||
|
||||
---
|
||||
|
||||
## 📋 Gitea Issues - Aktueller Stand
|
||||
|
||||
### Geprüft ✅
|
||||
- Alle offenen Issues durchgesehen (49, 47, 46, 45, 43, 42, 40, 39, 38, 37, 36, 35, 34, 33, 32, 30, 29, 27, 26, 25)
|
||||
- Geschlossene Issues verifiziert (#50, #51, #48, #44, #28)
|
||||
|
||||
### Manuelle Aktionen erforderlich ⚠️
|
||||
|
||||
Du musst noch in Gitea (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues):
|
||||
|
||||
1. **Issue #25 schließen:**
|
||||
- Titel: "[FEAT] Ziele-System (Goals) - v9e Kernfeature"
|
||||
- Status: ✅ KOMPLETT (Phase 0a + Dynamic Focus Areas v2.0)
|
||||
- Aktion: Manuell auf "Closed" setzen
|
||||
- Kommentar: "Completed in v0.9g-h: Phase 0a + Dynamic Focus Areas v2.0. See issue #50 and #51 for details."
|
||||
|
||||
2. **Issue #52 erstellen:**
|
||||
- Titel: "Enhancement: Blutdruck-Ziele benötigen zwei Zielfelder (systolisch/diastolisch)"
|
||||
- Labels: enhancement, goals, blood-pressure
|
||||
- Priorität: Medium
|
||||
- Beschreibung: Kopiere aus `docs/issues/issue-52-blood-pressure-dual-targets.md`
|
||||
- Aufwand: 2-3h
|
||||
- Milestone: v0.10a (nach Phase 0b)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Nächste Schritte (wenn du weitermachst)
|
||||
|
||||
### Sofort (nach Deployment-Test):
|
||||
1. **Teste Vitals Baseline Fix**
|
||||
- Ruhepuls eintragen (sollte jetzt funktionieren)
|
||||
- Andere Baseline-Werte testen
|
||||
|
||||
2. **Beginne Goals Testing**
|
||||
- Siehe Checklist in `STATUS_2026-03-27.md`
|
||||
- 2-3 Tage gründliches Testing
|
||||
|
||||
### Dann:
|
||||
3. **Release v0.9h vorbereiten**
|
||||
- Release Notes schreiben
|
||||
- Merge develop → main
|
||||
- Tag v0.9h
|
||||
- Deploy to Production
|
||||
|
||||
4. **Code Splitting durchführen**
|
||||
- goals.py → 5 separate Router
|
||||
- Optional: insights.py prüfen
|
||||
|
||||
5. **Phase 0b starten**
|
||||
- 120+ goal-aware Platzhalter
|
||||
- Score-System
|
||||
- 16-20h Aufwand
|
||||
|
||||
---
|
||||
|
||||
## 📚 Wichtige Dokumente - Lesereihenfolge
|
||||
|
||||
Wenn du zu diesem Punkt zurückkehrst:
|
||||
|
||||
### 1. Zuerst lesen:
|
||||
- **`docs/STATUS_2026-03-27.md`** ← START HIER
|
||||
- **`CLAUDE.md`** (aktuelle Version)
|
||||
- **`docs/NEXT_STEPS_2026-03-26.md`** (Phase 0b Details)
|
||||
|
||||
### 2. Bei Bedarf:
|
||||
- **`.claude/docs/ROADMAP.md`** (Gesamtübersicht)
|
||||
- **`docs/issues/issue-50-phase-0a-goal-system.md`** (Was wurde gebaut)
|
||||
- **`docs/issues/issue-52-blood-pressure-dual-targets.md`** (Nächstes Enhancement)
|
||||
|
||||
### 3. Funktionale Specs:
|
||||
- **`.claude/docs/functional/AI_PROMPTS.md`** (Prompt-System)
|
||||
- **`.claude/docs/functional/TRAINING_TYPES.md`** (Trainingstypen + Abilities)
|
||||
|
||||
### 4. Technische Specs:
|
||||
- **`.claude/docs/technical/MEMBERSHIP_SYSTEM.md`** (Feature-Enforcement)
|
||||
- **`.claude/docs/architecture/`** (Wenn vorhanden)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Wiederanstiegspunkt für Claude Code
|
||||
|
||||
### Context Prompt (copy-paste für neue Session):
|
||||
```
|
||||
Wir sind bei v0.9g/h Release-Vorbereitung.
|
||||
|
||||
AKTUELLER STAND:
|
||||
- Phase 0a (Goals System) + Dynamic Focus Areas v2.0: ✅ KOMPLETT
|
||||
- Vitals baseline fix: deployed (needs testing)
|
||||
- Branch: develop (6 commits ahead of main)
|
||||
- Status: BEREIT FÜR RELEASE v0.9h
|
||||
|
||||
NÄCHSTER SCHRITT:
|
||||
- Testing (Goals + Vitals)
|
||||
- Dann: Release v0.9h → Code Splitting → Phase 0b
|
||||
|
||||
LIES ZUERST:
|
||||
- docs/STATUS_2026-03-27.md (vollständiger Zustand)
|
||||
- CLAUDE.md (aktuelle Version)
|
||||
|
||||
FRAGE MICH:
|
||||
"Was ist der aktuelle Schritt?" → Dann sage ich dir Testing/Release/Splitting/Phase 0b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Zusammenfassung - Was ist fertig?
|
||||
|
||||
### ✅ Komplett implementiert
|
||||
- Goals System (Phase 0a)
|
||||
- Strategic Layer (goal_mode, goals CRUD)
|
||||
- Tactical Layer (CustomGoalsPage)
|
||||
- Training Phases Framework (tables, backend)
|
||||
- Fitness Tests Framework (tables, backend)
|
||||
- Dynamic Focus Areas v2.0
|
||||
- 26 Basis-Bereiche in 7 Kategorien
|
||||
- User-extensible (Admin CRUD UI)
|
||||
- Many-to-Many Goals ↔ Focus Areas
|
||||
- User preferences mit Gewichtungen
|
||||
- Bug Fixes
|
||||
- Focus contributions speichern
|
||||
- Filtering (nur gewichtete Areas)
|
||||
- Vitals baseline endpoint
|
||||
|
||||
### 🔲 Noch zu tun (dokumentiert)
|
||||
- Code Splitting (goals.py → 5 Router)
|
||||
- Phase 0b (120+ Platzhalter, Score-System)
|
||||
- Issue #52 (BP dual targets)
|
||||
- Responsive UI (Issue #30)
|
||||
- Weitere Features (siehe Roadmap)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Dokumentations-Qualität
|
||||
|
||||
**Vollständigkeit:** ⭐⭐⭐⭐⭐
|
||||
- Alle wichtigen Dokumente aktualisiert
|
||||
- Neue Dokumente erstellt
|
||||
- Gitea Issues geprüft
|
||||
- Wiederanstiegspunkt klar definiert
|
||||
|
||||
**Nachvollziehbarkeit:** ⭐⭐⭐⭐⭐
|
||||
- Status-Dokument mit allen Details
|
||||
- Entscheidungen dokumentiert
|
||||
- Nächste Schritte klar beschrieben
|
||||
|
||||
**Wartbarkeit:** ⭐⭐⭐⭐⭐
|
||||
- Strukturierte Dokumentation
|
||||
- Klare Verweise zwischen Dokumenten
|
||||
- Lesereihenfolge definiert
|
||||
|
||||
---
|
||||
|
||||
**Erstellt:** 27. März 2026, 23:00 Uhr
|
||||
**Von:** Claude Code (Sonnet 4.5)
|
||||
**Commit:** eb5c099 (docs: comprehensive status update v0.9h pre-release)
|
||||
|
||||
**Du kannst jetzt:**
|
||||
✅ Sicher pausieren
|
||||
✅ Deployment testen
|
||||
✅ Jederzeit exakt an diesem Punkt weitermachen
|
||||
272
docs/STATUS_2026-03-27.md
Normal file
272
docs/STATUS_2026-03-27.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Projekt-Status: 27. März 2026
|
||||
|
||||
**Branch:** `develop`
|
||||
**Letzte Version:** v0.9g+ (vor Release v0.9h)
|
||||
**Deployment:** dev.mitai.jinkendo.de
|
||||
**Nächster Meilenstein:** Release v0.9h → Code Splitting → Phase 0b
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Aktueller Zustand: BEREIT FÜR RELEASE v0.9h
|
||||
|
||||
### Was ist fertig? ✅
|
||||
|
||||
#### Goals System (Phase 0a + Dynamic Focus Areas v2.0)
|
||||
- ✅ **Migration 022:** goals, training_phases, fitness_tests tables
|
||||
- ✅ **Migration 027-032:** Dynamic Focus Areas
|
||||
- 26 Basis-Bereiche in 7 Kategorien (user-extensible)
|
||||
- Many-to-Many: Goals ↔ Focus Areas mit contribution weights
|
||||
- User preferences mit dynamischen Gewichtungen
|
||||
- ✅ **Backend:**
|
||||
- `routers/goals.py` - CRUD für Goals (~1200 Zeilen, **needs splitting**)
|
||||
- `routers/focus_areas.py` - Dynamic system CRUD (~350 Zeilen)
|
||||
- ✅ **Frontend:**
|
||||
- `GoalsPage.jsx` - Strategic layer (~1180 Zeilen, **needs component extraction**)
|
||||
- `CustomGoalsPage.jsx` - Tactical daily entry
|
||||
- `AdminFocusAreasPage.jsx` - Admin UI für Focus Areas
|
||||
- ✅ **Navigation:** Dashboard + Analysis integriert
|
||||
|
||||
#### Bug Fixes (alle committed, deployed pending)
|
||||
- ✅ Focus area contributions speichern (fehlte in API payload)
|
||||
- ✅ Filtering: Nur gewichtete Focus Areas im Ziel-Formular
|
||||
- ✅ Vitals baseline endpoint (parameter mismatch behoben)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Gitea Issues - Status
|
||||
|
||||
### Geschlossen ✅
|
||||
- ✅ **#50:** Goals System v1 (Phase 0a)
|
||||
- ✅ **#51:** Dynamic Focus Areas v2.0
|
||||
- ✅ **#48:** Flexibles KI Prompt System
|
||||
- ✅ **#44:** BUG - Analysen löschen
|
||||
- ✅ **#28:** AI-Prompts Flexibilisierung
|
||||
- ⏳ **#25:** Goals System (sollte geschlossen werden - ist fertig!)
|
||||
|
||||
### Offen - Priorisiert 🔲
|
||||
- 🔲 **#52:** NEW - Blutdruck-Ziele mit dual targets (systolic/diastolic) - 2-3h
|
||||
- 🔲 **#49:** Prompt-Zuordnung zu Verlaufsseiten (6-8h, Quick Win)
|
||||
- 🔲 **#47:** Wertetabelle Optimierung (4-6h, nach Phase 0b)
|
||||
- 🔲 **#30:** Responsive UI - Desktop Sidebar (8-10h)
|
||||
- 🔲 **#29:** Abilities-Matrix UI (6-8h)
|
||||
|
||||
### Offen - Backlog 📦
|
||||
- 📦 #46, #45: KI Prompt-Ersteller/-Optimierer (später)
|
||||
- 📦 #43, #42: Enhanced Debug UI (später)
|
||||
- 📦 #40: Logout-Button (kosmetisch)
|
||||
- 📦 #39: Usage-Badges Dashboard (kosmetisch)
|
||||
- 📦 #27: Korrelationen erweitern (Phase 2)
|
||||
- 📦 #26: Charts erweitern (Phase 1)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nächste Schritte (User-Plan APPROVED)
|
||||
|
||||
### Phase 1: Testing + Release (2-3 Tage)
|
||||
```
|
||||
Tag 1-2: Umfassende Tests des Goals-Moduls
|
||||
[ ] Goal Mode wechseln
|
||||
[ ] Focus Areas gewichten (alle 26 testen)
|
||||
[ ] Ziele erstellen mit focus_contributions
|
||||
[ ] Ziele bearbeiten (contributions ändern)
|
||||
[ ] Ist-Werte eintragen (CustomGoalsPage)
|
||||
[ ] Progress Modal testen
|
||||
[ ] Admin Focus Areas CRUD
|
||||
[ ] Edge Cases (leere Daten, Extremwerte)
|
||||
[ ] Vitals baseline entry (Ruhepuls) - nach neuem Deployment
|
||||
|
||||
Tag 3: Deploy + Release v0.9h
|
||||
[ ] Final commit & push
|
||||
[ ] Merge develop → main (PR in Gitea)
|
||||
[ ] Tag v0.9h in Git
|
||||
[ ] Deploy to Production
|
||||
[ ] Smoke Tests
|
||||
[ ] Release Notes schreiben
|
||||
```
|
||||
|
||||
### Phase 2: Code Splitting (1-2 Tage)
|
||||
```
|
||||
Tag 3-4: Backend Router Split
|
||||
[ ] goals.py → 5 separate Router
|
||||
- goals.py (core CRUD ~300 Zeilen)
|
||||
- goal_types.py (~200 Zeilen)
|
||||
- goal_progress.py (~150 Zeilen)
|
||||
- training_phases.py (~150 Zeilen)
|
||||
- fitness_tests.py (~150 Zeilen)
|
||||
[ ] Imports anpassen
|
||||
[ ] main.py: 5 neue Router registrieren
|
||||
[ ] Optional: insights.py prüfen (wenn >800 Zeilen)
|
||||
|
||||
Tag 5: Testing nach Split
|
||||
[ ] API-Endpoints vollständig testen
|
||||
[ ] Frontend funktioniert
|
||||
[ ] Deployment auf dev
|
||||
```
|
||||
|
||||
### Phase 3: Phase 0b - Goal-Aware Placeholders (4 Tage)
|
||||
```
|
||||
Aufwand: 16-20h
|
||||
Neue Platzhalter: 120+ Funktionen
|
||||
|
||||
Tag 6: KÖRPER + ERNÄHRUNG (40 Funktionen)
|
||||
- weight_7d_rolling_median, weight_28d_trend_slope
|
||||
- fm_28d_delta, lbm_28d_delta, recomposition_score
|
||||
- protein_g_per_kg, protein_g_per_kg_lbm
|
||||
- nutrition_adherence_score, energy_availability
|
||||
|
||||
Tag 7: AKTIVITÄT + RECOVERY (37 Funktionen)
|
||||
- activity_quality_avg_28d, activity_strain_28d
|
||||
- activity_monotony_28d, ability_balance_score
|
||||
- recovery_score, sleep_regularity_index, sleep_debt_hours
|
||||
|
||||
Tag 8: KORRELATIONEN + META + Scoring (20 Funktionen + System)
|
||||
- corr_energy_weight_lag, plateau_detected
|
||||
- goal_mode, data_quality_score, profile_age_years
|
||||
- Score-Gewichtung pro goal_mode implementieren
|
||||
|
||||
Tag 9: Integration + Testing
|
||||
- Prompts aktualisieren mit neuen Platzhaltern
|
||||
- Testing mit verschiedenen goal_modes
|
||||
- Dokumentation
|
||||
|
||||
Tag 10: Deploy v0.10a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Code-Metriken (Stand 27.03.2026)
|
||||
|
||||
### Große Dateien (Splitting-Kandidaten)
|
||||
```
|
||||
Backend:
|
||||
- routers/goals.py ~1200 Zeilen ⚠️ SPLIT NEEDED
|
||||
- routers/insights.py ~800 Zeilen (prüfen)
|
||||
- routers/focus_areas.py ~350 Zeilen ✓ OK
|
||||
|
||||
Frontend:
|
||||
- pages/GoalsPage.jsx ~1180 Zeilen ⚠️ Component extraction möglich
|
||||
- pages/AdminPanel.jsx ~700 Zeilen ✓ OK
|
||||
- pages/CustomGoalsPage.jsx ~350 Zeilen ✓ OK
|
||||
```
|
||||
|
||||
### Migrations Status
|
||||
```
|
||||
Letzte Migration: 032_user_focus_area_weights.sql
|
||||
Nächste: 033_dual_target_fields.sql (BP goals, Issue #52)
|
||||
|
||||
Alle Migrationen 001-032 erfolgreich angewandt auf dev ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technische Schulden
|
||||
|
||||
### Hoch-Priorität
|
||||
1. **Code Splitting:** goals.py zu groß für Context Window
|
||||
2. **Component Extraction:** GoalsPage.jsx komponenten-basiert
|
||||
3. **Testing Suite:** Automatisierte Tests fehlen komplett
|
||||
|
||||
### Mittel-Priorität
|
||||
4. **Responsive UI:** Desktop-Sidebar fehlt (Issue #30)
|
||||
5. **Error Handling:** Mehr defensive Programmierung nötig
|
||||
6. **API Documentation:** Swagger/OpenAPI fehlt
|
||||
|
||||
### Niedrig-Priorität
|
||||
7. **Type Hints:** Mehr Python Type Annotations
|
||||
8. **Performance:** Einige N+1 Queries optimieren
|
||||
9. **Caching:** Redis für häufige Abfragen
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation - Status
|
||||
|
||||
### Aktuell ✅
|
||||
- ✅ `CLAUDE.md` - Hauptdokumentation
|
||||
- ✅ `docs/STATUS_2026-03-27.md` - Dieser Status (NEU)
|
||||
- ✅ `docs/NEXT_STEPS_2026-03-26.md` - Roadmap Phase 0b
|
||||
- ✅ `docs/issues/issue-50-phase-0a-goal-system.md` - Phase 0a abgeschlossen
|
||||
- ✅ `docs/issues/issue-52-blood-pressure-dual-targets.md` - Neue Issue (NEU)
|
||||
- ✅ `.claude/docs/functional/AI_PROMPTS.md` - Prompt-System komplett
|
||||
- ✅ `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` - Feature-Enforcement
|
||||
|
||||
### Zu aktualisieren 📝
|
||||
- 📝 `CLAUDE.md` - v0.9g/h Updates eintragen
|
||||
- 📝 `.claude/docs/ROADMAP.md` - Phase 0a als ✅ markieren
|
||||
- 📝 `.claude/library/` - Nach v0.9h Release aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Decision Points
|
||||
|
||||
### Entschieden ✅
|
||||
1. **User-Plan APPROVED:** Testing → Release → Split → Phase 0b
|
||||
2. **Code Splitting:** Backend Router zuerst, Frontend optional
|
||||
3. **Phase 0b:** Szenario 2 (Strategic Depth first) - 120+ Platzhalter
|
||||
4. **Release Strategy:** v0.9h als stabiler Rollback-Punkt
|
||||
|
||||
### Offen 🤔
|
||||
1. **Issue #52 (BP dual targets):** Vor oder nach Phase 0b? → **Empfehlung: Nach Phase 0b**
|
||||
2. **Frontend Components:** Extract während oder nach Split? → **Empfehlung: Nach, wenn Zeit**
|
||||
3. **Issue #49 (Prompt pages):** Vor oder nach Phase 0b? → **Empfehlung: Nach Phase 0b**
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Aktuelle Blocker / Risiken
|
||||
|
||||
### Keine kritischen Blocker ✅
|
||||
|
||||
**Kleine Risiken:**
|
||||
1. ⚠️ **Vitals baseline fix:** Gerade deployed, needs testing
|
||||
2. ⚠️ **Migration 032:** Muss auf Prod laufen (dev läuft bereits)
|
||||
3. ⚠️ **Code Splitting:** Könnte Regressionen einführen → gründliches Testing
|
||||
|
||||
---
|
||||
|
||||
## 📞 Ansprechpunkte für Wiederaufnahme
|
||||
|
||||
**Wenn du zu diesem Stand zurückkehrst:**
|
||||
|
||||
1. **Lies zuerst:**
|
||||
- Dieses Dokument (STATUS_2026-03-27.md)
|
||||
- CLAUDE.md (aktuelle Version)
|
||||
- docs/NEXT_STEPS_2026-03-26.md (Roadmap)
|
||||
|
||||
2. **Prüfe:**
|
||||
- Ist v0.9h deployed? `git describe --tags`
|
||||
- Läuft dev/prod? `curl https://dev.mitai.jinkendo.de/api/version`
|
||||
- Gitea Issues-Status aktuell?
|
||||
|
||||
3. **Nächster Schritt:**
|
||||
- Falls v0.9h deployed: Start Code Splitting
|
||||
- Falls nicht: Führe Testing-Checklist aus (siehe Phase 1 oben)
|
||||
|
||||
4. **Claude Code Context:**
|
||||
```
|
||||
"Wir sind bei v0.9h Release. Goals-System ist komplett (Phase 0a + Dynamic Focus Areas v2.0).
|
||||
Nächster Schritt: [Testing/Code Splitting/Phase 0b] - siehe STATUS_2026-03-27.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Metriken seit letztem Stand
|
||||
|
||||
**Commits seit v0.9g:**
|
||||
- 6 Commits (Goals fixes, Focus Areas v2.0, Vitals baseline fix)
|
||||
- +1200 Zeilen (neue Features)
|
||||
- -400 Zeilen (Refactoring)
|
||||
|
||||
**Issues:**
|
||||
- 3 geschlossen (#50, #51, #48)
|
||||
- 1 neu (#52)
|
||||
- 1 sollte geschlossen werden (#25)
|
||||
|
||||
**Deployment:**
|
||||
- Letzte 3 Deployments erfolgreich
|
||||
- Dev-Environment stabil
|
||||
- Prod auf v0.9g (stabil)
|
||||
|
||||
---
|
||||
|
||||
**Erstellt:** 27. März 2026, 22:30 Uhr
|
||||
**Von:** Claude Code (Sonnet 4.5)
|
||||
**Nächstes Update:** Nach v0.9h Release
|
||||
157
docs/issues/issue-52-blood-pressure-dual-targets.md
Normal file
157
docs/issues/issue-52-blood-pressure-dual-targets.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Issue #52: Blutdruck-Ziele benötigen zwei Zielfelder
|
||||
|
||||
**Status:** 🔲 OFFEN
|
||||
**Erstellt:** 27.03.2026
|
||||
**Priorität:** Medium
|
||||
**Typ:** Enhancement
|
||||
**Labels:** goals, blood-pressure, enhancement
|
||||
**Aufwand:** 2-3h
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
**Aktuell:**
|
||||
- Blutdruck-Ziele (goal_type = 'bp') haben nur EIN Zielfeld (`target_value`)
|
||||
- Blutdruck besteht aber aus ZWEI Werten: Systolisch + Diastolisch
|
||||
- Beispiel-Ziel: "Blutdruck senken auf 120/80 mmHg"
|
||||
- Systolisch (oberer Wert): 120
|
||||
- Diastolisch (unterer Wert): 80
|
||||
|
||||
**Konsequenz:**
|
||||
- User kann nur einen Wert als Ziel eingeben
|
||||
- Unvollständige Zieldefinition
|
||||
- Progress-Tracking ungenau
|
||||
|
||||
---
|
||||
|
||||
## Lösung
|
||||
|
||||
### Option A: Dual Target Fields (empfohlen)
|
||||
|
||||
**Schema-Änderung:**
|
||||
```sql
|
||||
-- Migration 033
|
||||
ALTER TABLE goals ADD COLUMN target_value_secondary DECIMAL(10,2);
|
||||
ALTER TABLE goals ADD COLUMN current_value_secondary DECIMAL(10,2);
|
||||
ALTER TABLE goals ADD COLUMN start_value_secondary DECIMAL(10,2);
|
||||
|
||||
COMMENT ON COLUMN goals.target_value_secondary IS 'Secondary target (e.g., diastolic BP for bp goal type)';
|
||||
```
|
||||
|
||||
**Anwendung:**
|
||||
- `bp` goal type:
|
||||
- `target_value` = Systolisch (120)
|
||||
- `target_value_secondary` = Diastolisch (80)
|
||||
- Andere goal types: `target_value_secondary` = NULL
|
||||
|
||||
**UI-Anpassung:**
|
||||
```jsx
|
||||
// GoalForm - conditional rendering
|
||||
{formData.goal_type === 'bp' && (
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:12}}>
|
||||
<div>
|
||||
<label>Systolisch (oben)</label>
|
||||
<input type="number" value={formData.target_value} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Diastolisch (unten)</label>
|
||||
<input type="number" value={formData.target_value_secondary} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Progress-Berechnung:**
|
||||
```python
|
||||
def calculate_bp_progress(goal):
|
||||
"""
|
||||
Berechnet Progress für Blutdruck-Ziele.
|
||||
Nimmt Durchschnitt von systolischem und diastolischem Progress.
|
||||
"""
|
||||
systolic_progress = calculate_single_progress(
|
||||
goal.current_value, goal.start_value, goal.target_value
|
||||
)
|
||||
diastolic_progress = calculate_single_progress(
|
||||
goal.current_value_secondary,
|
||||
goal.start_value_secondary,
|
||||
goal.target_value_secondary
|
||||
)
|
||||
|
||||
return (systolic_progress + diastolic_progress) / 2
|
||||
```
|
||||
|
||||
**Display:**
|
||||
```
|
||||
🎯 Blutdruck
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
Ziel: 120/80 mmHg
|
||||
Aktuell: 135/88 mmHg
|
||||
Fortschritt: 68% (sys: 60%, dia: 75%)
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Option B: JSON Target (flexibler, komplexer)
|
||||
|
||||
```sql
|
||||
ALTER TABLE goals ADD COLUMN target_json JSONB;
|
||||
|
||||
-- Beispiel:
|
||||
{
|
||||
"systolic": 120,
|
||||
"diastolic": 80,
|
||||
"unit": "mmHg"
|
||||
}
|
||||
```
|
||||
|
||||
**Nachteil:** Komplexer zu abfragen, weniger SQL-freundlich.
|
||||
|
||||
---
|
||||
|
||||
## Betroffene Dateien
|
||||
|
||||
**Backend:**
|
||||
- `backend/migrations/033_dual_target_fields.sql` (NEU)
|
||||
- `backend/routers/goals.py` - Progress-Berechnung erweitern
|
||||
- `backend/routers/goal_utils.py` - `_get_current_value_for_goal_type()` für BP
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/pages/GoalsPage.jsx` - Form conditional rendering
|
||||
- `frontend/src/pages/GoalsPage.jsx` - Display conditional rendering
|
||||
- `frontend/src/pages/CustomGoalsPage.jsx` - Dual input für BP
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Migration 033 erstellt und angewandt
|
||||
- [ ] GoalForm zeigt zwei Felder für BP-Ziele (Systolisch/Diastolisch)
|
||||
- [ ] Progress-Berechnung berücksichtigt beide Werte
|
||||
- [ ] Display zeigt "120/80 mmHg" Format
|
||||
- [ ] CustomGoalsPage erlaubt Eingabe beider Werte
|
||||
- [ ] Backward compatible (alte BP-Ziele mit nur target_value funktionieren noch)
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Issues
|
||||
|
||||
- Issue #50: Goals System v1 ✅
|
||||
- Issue #51: Dynamic Focus Areas v2.0 ✅
|
||||
- Migration 022: goals table (Basis)
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
**Geschätzt:** 2-3 Stunden
|
||||
- Migration: 30 min
|
||||
- Backend Logic: 1h
|
||||
- Frontend UI: 1-1.5h
|
||||
- Testing: 30 min
|
||||
|
||||
---
|
||||
|
||||
**Erstellt von:** Claude Code
|
||||
**Review benötigt:** Vor Implementierung mit User abstimmen (Option A vs. B)
|
||||
|
|
@ -32,6 +32,7 @@ import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
|||
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
|
|
@ -192,6 +193,7 @@ function AppShell() {
|
|||
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
475
frontend/src/pages/AdminFocusAreasPage.jsx
Normal file
475
frontend/src/pages/AdminFocusAreasPage.jsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'body_composition', label: 'Körperzusammensetzung' },
|
||||
{ value: 'training', label: 'Training' },
|
||||
{ value: 'endurance', label: 'Ausdauer' },
|
||||
{ value: 'coordination', label: 'Koordination' },
|
||||
{ value: 'mental', label: 'Mental' },
|
||||
{ value: 'recovery', label: 'Erholung' },
|
||||
{ value: 'health', label: 'Gesundheit' },
|
||||
{ value: 'custom', label: 'Eigene' }
|
||||
]
|
||||
|
||||
export default function AdminFocusAreasPage() {
|
||||
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [showInactive])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.listFocusAreaDefinitions(showInactive)
|
||||
setData(result)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load focus areas:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.key || !formData.name_de) {
|
||||
setError('Key und Name (DE) sind erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createFocusAreaDefinition(formData)
|
||||
setCreating(false)
|
||||
setFormData({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (id) => {
|
||||
try {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
})
|
||||
setEditingId(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Focus Area wirklich löschen?')) return
|
||||
|
||||
try {
|
||||
await api.deleteFocusAreaDefinition(id)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (id) => {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
try {
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
is_active: !area.is_active
|
||||
})
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = (id, field, value) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
areas: prev.areas.map(a =>
|
||||
a.id === id ? { ...a, [field]: value } : a
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, paddingBottom: 80 }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
|
||||
🎯 Focus Areas ({data.total})
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowInactive(!showInactive)}
|
||||
style={{ padding: '8px 12px', fontSize: 13 }}
|
||||
>
|
||||
{showInactive ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
{showInactive ? 'Inaktive ausblenden' : 'Inaktive anzeigen'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setCreating(true)}
|
||||
style={{ padding: '8px 16px' }}
|
||||
>
|
||||
<Plus size={16} /> Neue Focus Area
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#FEE2E2',
|
||||
color: '#991B1B',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 14
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
{creating && (
|
||||
<div className="card" style={{ marginBottom: 16, background: 'var(--accent-light)' }}>
|
||||
<h3 style={{ fontSize: 16, marginBottom: 12, color: 'var(--accent)' }}>
|
||||
Neue Focus Area
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Key (Eindeutig, z.B. "explosive_power")
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
placeholder="explosive_power"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (Deutsch) *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_de}
|
||||
onChange={(e) => setFormData({ ...formData, name_de: e.target.value })}
|
||||
placeholder="Explosivkraft"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (English)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_en}
|
||||
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
|
||||
placeholder="Explosive Power"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Icon (Emoji)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
placeholder="💥"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{CATEGORIES.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kraft in kürzester Zeit explosiv entfalten"
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn-primary" onClick={handleCreate} style={{ flex: 1 }}>
|
||||
<Save size={14} /> Erstellen
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setCreating(false)
|
||||
setFormData({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
category: 'custom'
|
||||
})
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={14} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped Areas */}
|
||||
{Object.entries(data.grouped).map(([category, areas]) => (
|
||||
<div key={category} style={{ marginBottom: 24 }}>
|
||||
<h2 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{CATEGORIES.find(c => c.value === category)?.label || category} ({areas.length})
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{areas.map(area => {
|
||||
const isEditing = editingId === area.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={area.id}
|
||||
className="card"
|
||||
style={{
|
||||
opacity: area.is_active ? 1 : 0.5,
|
||||
borderLeft: area.is_active
|
||||
? '4px solid var(--accent)'
|
||||
: '4px solid var(--border)'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Name (DE)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={area.name_de}
|
||||
onChange={(e) => updateField(area.id, 'name_de', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Icon
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={area.icon || ''}
|
||||
onChange={(e) => updateField(area.id, 'icon', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={area.description || ''}
|
||||
onChange={(e) => updateField(area.id, 'description', e.target.value)}
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => handleUpdate(area.id)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Save size={14} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
loadData()
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={14} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
{area.icon && <span>{area.icon}</span>}
|
||||
<span>{area.name_de}</span>
|
||||
{!area.is_active && (
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Inaktiv
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 4 }}>
|
||||
Key: <code style={{
|
||||
background: 'var(--surface2)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{area.key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{area.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4 }}>
|
||||
{area.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => handleToggleActive(area.id)}
|
||||
style={{ padding: '6px 12px' }}
|
||||
title={area.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
>
|
||||
{area.is_active ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditingId(area.id)}
|
||||
style={{ padding: '6px 12px' }}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => handleDelete(area.id)}
|
||||
style={{ padding: '6px 12px', color: '#DC2626' }}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.areas.length === 0 && (
|
||||
<div style={{
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Keine Focus Areas vorhanden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -485,6 +485,23 @@ export default function AdminPanel() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Focus Areas (v9g)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Focus Area Definitionen: Dynamisches, erweiterbares System mit 26+ Bereichen über 7 Kategorien.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/focus-areas">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Focus Areas verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,20 +57,16 @@ const getCategoryForGoalType = (goalType) => {
|
|||
|
||||
export default function GoalsPage() {
|
||||
const [goalMode, setGoalMode] = useState(null)
|
||||
const [focusAreas, setFocusAreas] = useState(null)
|
||||
const [focusEditing, setFocusEditing] = useState(false)
|
||||
const [focusTemp, setFocusTemp] = useState({
|
||||
weight_loss_pct: 0,
|
||||
muscle_gain_pct: 0,
|
||||
strength_pct: 0,
|
||||
endurance_pct: 0,
|
||||
flexibility_pct: 0,
|
||||
health_pct: 0
|
||||
})
|
||||
const [userFocusWeights, setUserFocusWeights] = useState([]) // v2.0: User's focus area weights
|
||||
const [userFocusGrouped, setUserFocusGrouped] = useState({}) // Grouped by category
|
||||
const [focusWeightsEditing, setFocusWeightsEditing] = useState(false)
|
||||
const [focusWeightsTemp, setFocusWeightsTemp] = useState({}) // Temp: {focus_area_id: weight}
|
||||
const [goals, setGoals] = useState([]) // Kept for backward compat
|
||||
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
|
||||
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
|
||||
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
|
||||
const [focusAreas, setFocusAreas] = useState([]) // v2.0: All available focus areas (for selection)
|
||||
const [focusAreasGrouped, setFocusAreasGrouped] = useState({}) // Grouped by category
|
||||
const [showGoalForm, setShowGoalForm] = useState(false)
|
||||
const [editingGoal, setEditingGoal] = useState(null)
|
||||
const [showProgressModal, setShowProgressModal] = useState(false)
|
||||
|
|
@ -95,7 +91,8 @@ export default function GoalsPage() {
|
|||
unit: 'kg',
|
||||
target_date: '',
|
||||
name: '',
|
||||
description: ''
|
||||
description: '',
|
||||
focus_contributions: [] // v2.0: [{focus_area_id, contribution_weight}]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -106,31 +103,31 @@ export default function GoalsPage() {
|
|||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [modeData, goalsData, groupedData, typesData, focusData] = await Promise.all([
|
||||
const [modeData, goalsData, groupedData, typesData, userWeightsData, focusAreasData] = await Promise.all([
|
||||
api.getGoalMode(),
|
||||
api.listGoals(),
|
||||
api.listGoalsGrouped(), // v2.1: Load grouped by category
|
||||
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
|
||||
api.getFocusAreas() // v2.0: Load focus areas
|
||||
api.getUserFocusPreferences(), // v2.0: Load user's focus area weights
|
||||
api.listFocusAreaDefinitions(false) // v2.0: Load all available focus areas
|
||||
])
|
||||
|
||||
setGoalMode(modeData.goal_mode)
|
||||
setGoals(goalsData)
|
||||
setGroupedGoals(groupedData)
|
||||
|
||||
// Ensure all focus fields are present and numeric
|
||||
const sanitizedFocus = {
|
||||
weight_loss_pct: focusData?.weight_loss_pct ?? 0,
|
||||
muscle_gain_pct: focusData?.muscle_gain_pct ?? 0,
|
||||
strength_pct: focusData?.strength_pct ?? 0,
|
||||
endurance_pct: focusData?.endurance_pct ?? 0,
|
||||
flexibility_pct: focusData?.flexibility_pct ?? 0,
|
||||
health_pct: focusData?.health_pct ?? 0,
|
||||
custom: focusData?.custom,
|
||||
updated_at: focusData?.updated_at
|
||||
}
|
||||
// v2.0: User focus weights (dynamic)
|
||||
setUserFocusWeights(userWeightsData.weights || [])
|
||||
setUserFocusGrouped(userWeightsData.grouped || {})
|
||||
|
||||
setFocusAreas(sanitizedFocus)
|
||||
setFocusTemp(sanitizedFocus)
|
||||
// Build temp object for editing: {focus_area_id: weight}
|
||||
const tempWeights = {}
|
||||
if (userWeightsData.weights) {
|
||||
userWeightsData.weights.forEach(w => {
|
||||
tempWeights[w.id] = w.weight
|
||||
})
|
||||
}
|
||||
setFocusWeightsTemp(tempWeights)
|
||||
|
||||
// Convert types array to map for quick lookup
|
||||
const typesMap = {}
|
||||
|
|
@ -148,6 +145,12 @@ export default function GoalsPage() {
|
|||
|
||||
setGoalTypes(typesData || [])
|
||||
setGoalTypesMap(typesMap)
|
||||
|
||||
// v2.0: All focus area definitions (for selection in goal form)
|
||||
if (focusAreasData) {
|
||||
setFocusAreas(focusAreasData.areas || [])
|
||||
setFocusAreasGrouped(focusAreasData.grouped || {})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load goals:', err)
|
||||
setError(`Fehler beim Laden: ${err.message || err.toString()}`)
|
||||
|
|
@ -161,17 +164,6 @@ export default function GoalsPage() {
|
|||
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 = () => {
|
||||
if (goalTypes.length === 0) {
|
||||
setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.')
|
||||
|
|
@ -188,7 +180,8 @@ export default function GoalsPage() {
|
|||
unit: goalTypesMap[firstType]?.unit || 'kg',
|
||||
target_date: '',
|
||||
name: '',
|
||||
description: ''
|
||||
description: '',
|
||||
focus_contributions: [] // v2.0: Empty for new goal
|
||||
})
|
||||
setShowGoalForm(true)
|
||||
}
|
||||
|
|
@ -204,7 +197,8 @@ export default function GoalsPage() {
|
|||
unit: goal.unit,
|
||||
target_date: goal.target_date || '',
|
||||
name: goal.name || '',
|
||||
description: goal.description || ''
|
||||
description: goal.description || '',
|
||||
focus_contributions: goal.focus_contributions || [] // v2.0: Load existing contributions
|
||||
})
|
||||
setShowGoalForm(true)
|
||||
}
|
||||
|
|
@ -234,7 +228,8 @@ export default function GoalsPage() {
|
|||
unit: formData.unit,
|
||||
target_date: formData.target_date || null,
|
||||
name: formData.name || null,
|
||||
description: formData.description || null
|
||||
description: formData.description || null,
|
||||
focus_contributions: formData.focus_contributions || [] // v2.0: Focus area assignments
|
||||
}
|
||||
|
||||
console.log('[DEBUG] Saving goal:', { editingGoal, data })
|
||||
|
|
@ -391,80 +386,89 @@ export default function GoalsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus Areas (v2.0) */}
|
||||
{/* Focus Areas (v2.0 - Dynamic) */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2>
|
||||
{!focusEditing && focusAreas && (
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setFocusTemp(focusAreas) // Sync temp state before editing
|
||||
setFocusEditing(true)
|
||||
// Initialize temp weights from current weights
|
||||
const tempWeights = {}
|
||||
focusAreas.forEach(fa => {
|
||||
tempWeights[fa.id] = focusWeightsTemp[fa.id] || 0
|
||||
})
|
||||
setFocusWeightsTemp(tempWeights)
|
||||
setFocusWeightsEditing(!focusWeightsEditing)
|
||||
}}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<Pencil size={14} /> Anpassen
|
||||
<Pencil size={14} /> {focusWeightsEditing ? 'Abbrechen' : 'Anpassen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
|
||||
Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile.
|
||||
{focusAreas && !focusAreas.custom && (
|
||||
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
|
||||
ℹ️ Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
|
||||
</span>
|
||||
)}
|
||||
Wähle deine Trainingsschwerpunkte und gewichte sie relativ zueinander. Prozente werden automatisch berechnet.
|
||||
</p>
|
||||
|
||||
{focusEditing ? (
|
||||
{focusWeightsEditing ? (
|
||||
<>
|
||||
{/* Sliders */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 20 }}>
|
||||
{[
|
||||
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' },
|
||||
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' },
|
||||
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' },
|
||||
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' },
|
||||
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' },
|
||||
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' }
|
||||
].map(area => {
|
||||
const rawValue = Number(focusTemp[area.key]) || 0
|
||||
const weight = Math.round(rawValue / 10)
|
||||
const sum = Object.entries(focusTemp)
|
||||
.filter(([k]) => k.endsWith('_pct'))
|
||||
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
|
||||
const actualPercent = sum > 0 ? Math.round(rawValue / sum * 100) : 0
|
||||
{/* Edit Mode - Sliders grouped by category */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, marginBottom: 20 }}>
|
||||
{Object.entries(focusAreasGrouped).map(([category, areas]) => (
|
||||
<div key={category}>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{category}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{areas.map(area => {
|
||||
const weight = focusWeightsTemp[area.id] || 0
|
||||
const totalWeight = Object.values(focusWeightsTemp).reduce((sum, w) => sum + (w || 0), 0)
|
||||
const percentage = totalWeight > 0 ? Math.round((weight / totalWeight) * 100) : 0
|
||||
|
||||
return (
|
||||
<div key={area.key}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div key={area.id}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 20 }}>{area.icon}</span>
|
||||
<span style={{ fontWeight: 500 }}>{area.label}</span>
|
||||
<span style={{ fontSize: 18 }}>{area.icon}</span>
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>{area.name_de}</span>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: area.color,
|
||||
color: 'var(--accent)',
|
||||
minWidth: 80,
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
{weight} → {actualPercent}%
|
||||
{weight} → {percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="1"
|
||||
max="100"
|
||||
step="5"
|
||||
value={weight}
|
||||
onChange={e => setFocusTemp(f => ({ ...f, [area.key]: parseInt(e.target.value) * 10 }))}
|
||||
onChange={e => setFocusWeightsTemp(f => ({
|
||||
...f,
|
||||
[area.id]: parseInt(e.target.value)
|
||||
}))}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: `linear-gradient(to right, ${area.color} 0%, ${area.color} ${weight * 10}%, var(--border) ${weight * 10}%, var(--border) 100%)`,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: `linear-gradient(to right, var(--accent) 0%, var(--accent) ${weight}%, var(--border) ${weight}%, var(--border) 100%)`,
|
||||
outline: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
|
|
@ -473,79 +477,52 @@ export default function GoalsPage() {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{/* Save Button */}
|
||||
<button
|
||||
className="btn-primary"
|
||||
className="btn-primary btn-full"
|
||||
onClick={async () => {
|
||||
// Calculate sum (filter out NaN/undefined)
|
||||
const sum = Object.entries(focusTemp)
|
||||
.filter(([k]) => k.endsWith('_pct'))
|
||||
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
|
||||
|
||||
if (sum === 0 || isNaN(sum)) {
|
||||
setError('Mindestens ein Bereich muss gewichtet sein')
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to percentages (ensure no NaN values)
|
||||
const normalized = {
|
||||
weight_loss_pct: Math.round((Number(focusTemp.weight_loss_pct) || 0) / sum * 100),
|
||||
muscle_gain_pct: Math.round((Number(focusTemp.muscle_gain_pct) || 0) / sum * 100),
|
||||
strength_pct: Math.round((Number(focusTemp.strength_pct) || 0) / sum * 100),
|
||||
endurance_pct: Math.round((Number(focusTemp.endurance_pct) || 0) / sum * 100),
|
||||
flexibility_pct: Math.round((Number(focusTemp.flexibility_pct) || 0) / sum * 100),
|
||||
health_pct: Math.round((Number(focusTemp.health_pct) || 0) / sum * 100)
|
||||
}
|
||||
|
||||
// Ensure sum is exactly 100 (adjust largest value if needed due to rounding)
|
||||
const normalizedSum = Object.values(normalized).reduce((a, b) => a + b, 0)
|
||||
if (normalizedSum !== 100) {
|
||||
const largest = Object.entries(normalized).reduce((max, [k, v]) => v > max[1] ? [k, v] : max, ['', 0])
|
||||
normalized[largest[0]] += (100 - normalizedSum)
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateFocusAreas(normalized)
|
||||
await api.updateUserFocusPreferences({ weights: focusWeightsTemp })
|
||||
showToast('✓ Fokus-Bereiche aktualisiert')
|
||||
await loadData()
|
||||
setFocusEditing(false)
|
||||
setFocusWeightsEditing(false)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* Display Mode - Cards for areas with weight > 0 */
|
||||
userFocusWeights.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '32px 16px',
|
||||
color: 'var(--text3)',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<p style={{ margin: 0, marginBottom: 12 }}>Keine Fokus-Bereiche definiert</p>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setFocusTemp(focusAreas)
|
||||
setFocusEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => setFocusWeightsEditing(true)}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
Abbrechen
|
||||
Jetzt konfigurieren
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : focusAreas && (
|
||||
/* Display Mode */
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' },
|
||||
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' },
|
||||
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' },
|
||||
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' },
|
||||
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' },
|
||||
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' }
|
||||
].filter(area => focusAreas[area.key] > 0).map(area => (
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
{userFocusWeights.map(area => (
|
||||
<div
|
||||
key={area.key}
|
||||
key={area.id}
|
||||
style={{
|
||||
padding: 12,
|
||||
background: 'var(--surface2)',
|
||||
|
|
@ -555,11 +532,12 @@ export default function GoalsPage() {
|
|||
}}
|
||||
>
|
||||
<div style={{ fontSize: 24, marginBottom: 4 }}>{area.icon}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 4 }}>{area.label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: area.color }}>{focusAreas[area.key]}%</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 4 }}>{area.name_de}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{area.percentage}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -652,6 +630,38 @@ export default function GoalsPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Focus Area Badges (v2.0) */}
|
||||
{goal.focus_contributions && goal.focus_contributions.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginBottom: 12
|
||||
}}>
|
||||
{goal.focus_contributions.map(fc => (
|
||||
<span
|
||||
key={fc.focus_area_id}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '3px 8px',
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent-dark)',
|
||||
borderRadius: 4,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
title={`${fc.name_de}: ${fc.contribution_weight}%`}
|
||||
>
|
||||
{fc.icon && <span>{fc.icon}</span>}
|
||||
<span>{fc.name_de}</span>
|
||||
<span style={{ opacity: 0.7 }}>({fc.contribution_weight}%)</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, marginBottom: 12, fontSize: 14, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
|
||||
|
|
@ -848,6 +858,191 @@ export default function GoalsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas (v2.0) */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
color: 'var(--text1)'
|
||||
}}>
|
||||
🎯 Zahlt ein auf (Fokusbereiche)
|
||||
</label>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text3)',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
Wähle die Bereiche aus, auf die dieses Ziel einzahlt. Mehrfachauswahl möglich.
|
||||
</div>
|
||||
|
||||
{Object.keys(focusAreasGrouped).length === 0 ? (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Keine Focus Areas verfügbar
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{Object.entries(focusAreasGrouped).map(([category, areas]) => {
|
||||
// Filter to only show focus areas the user has weighted
|
||||
const userWeightedAreaIds = new Set(userFocusWeights.map(w => w.id))
|
||||
const filteredAreas = areas.filter(area => userWeightedAreaIds.has(area.id))
|
||||
|
||||
// Skip category if no weighted areas
|
||||
if (filteredAreas.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 6
|
||||
}}>
|
||||
{category}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6
|
||||
}}>
|
||||
{filteredAreas.map(area => {
|
||||
const isSelected = formData.focus_contributions?.some(
|
||||
fc => fc.focus_area_id === area.id
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={area.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
// Remove
|
||||
setFormData(f => ({
|
||||
...f,
|
||||
focus_contributions: f.focus_contributions.filter(
|
||||
fc => fc.focus_area_id !== area.id
|
||||
)
|
||||
}))
|
||||
} else {
|
||||
// Add with default weight 100%
|
||||
setFormData(f => ({
|
||||
...f,
|
||||
focus_contributions: [
|
||||
...(f.focus_contributions || []),
|
||||
{
|
||||
focus_area_id: area.id,
|
||||
contribution_weight: 100
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
||||
color: isSelected ? 'white' : 'var(--text2)',
|
||||
border: isSelected ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
fontFamily: 'var(--font)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
{area.icon && <span>{area.icon}</span>}
|
||||
<span>{area.name_de}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected areas with weights */}
|
||||
{formData.focus_contributions && formData.focus_contributions.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
background: 'var(--accent-light)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--accent)'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent-dark)',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
Gewichtung ({formData.focus_contributions.length} ausgewählt)
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{formData.focus_contributions.map((fc, idx) => {
|
||||
const area = focusAreas.find(a => a.id === fc.focus_area_id)
|
||||
if (!area) return null
|
||||
|
||||
return (
|
||||
<div key={fc.focus_area_id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent-dark)'
|
||||
}}>
|
||||
{area.icon} {area.name_de}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={fc.contribution_weight}
|
||||
onChange={(e) => {
|
||||
const newWeight = parseFloat(e.target.value) || 0
|
||||
setFormData(f => ({
|
||||
...f,
|
||||
focus_contributions: f.focus_contributions.map((item, i) =>
|
||||
i === idx ? { ...item, contribution_weight: newWeight } : item
|
||||
)
|
||||
}))
|
||||
}}
|
||||
style={{
|
||||
width: 70,
|
||||
padding: '4px 8px',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 6
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--accent-dark)' }}>%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zielwert */}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>
|
||||
🎯 Zielwert
|
||||
|
|
|
|||
|
|
@ -365,4 +365,13 @@ export const api = {
|
|||
// Fitness Tests
|
||||
listFitnessTests: () => req('/goals/tests'),
|
||||
createFitnessTest: (d) => req('/goals/tests', json(d)),
|
||||
|
||||
// Focus Areas (v2.0)
|
||||
listFocusAreaDefinitions: (includeInactive=false) => req(`/focus-areas/definitions?include_inactive=${includeInactive}`),
|
||||
createFocusAreaDefinition: (d) => req('/focus-areas/definitions', json(d)),
|
||||
updateFocusAreaDefinition: (id,d) => req(`/focus-areas/definitions/${id}`, jput(d)),
|
||||
deleteFocusAreaDefinition: (id) => req(`/focus-areas/definitions/${id}`, {method:'DELETE'}),
|
||||
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
||||
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
||||
getFocusAreaStats: () => req('/focus-areas/stats'),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user