Compare commits

...

19 Commits
V0.9g ... main

Author SHA1 Message Date
979e734bd9 Merge pull request 'Bug Fix' (#52) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #52
2026-03-27 22:16:18 +01:00
448f6ad4f4 fix: use psycopg2 placeholders (%s) not PostgreSQL ($N)
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
Bug 1 Final Fix:
- Changed all placeholders from $1, $2, $3 to %s
- psycopg2 expects Python-style %s, converts to $N internally
- Using $N directly causes 'there is no parameter $1' error
- Removed param_idx counter (not needed with %s)

Root cause: Mixing PostgreSQL native syntax with psycopg2 driver
This is THE fix that will finally work!
2026-03-27 22:14:28 +01:00
e4a2b63a48 fix: vitals baseline parameter sync + goal utils transaction rollback
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Bug 1 Fix (Ruhepuls):
- Completely rewrote vitals_baseline POST endpoint
- Clear separation: param_values array contains ALL values (pid, date, ...)
- Synchronized insert_cols, insert_placeholders, and param_values
- Added debug logging
- Simplified UPDATE logic (EXCLUDED.col instead of COALESCE)

Bug 2 Fix (Custom Goal Type Transaction Error):
- Added transaction rollback in goal_utils._fetch_by_aggregation_method()
- When SQL query fails (e.g., invalid column name), rollback transaction
- Prevents 'InFailedSqlTransaction' errors on subsequent queries
- Enhanced error logging (shows filter conditions, SQL, params)
- Returns None gracefully so goal creation can continue

User Action Required for Bug 2:
- Edit goal type 'Trainingshäufigkeit Krafttraining'
- Change filter from {"training_type": "strength"}
  to {"training_category": "strength"}
- activity_log has training_category, NOT training_type column
2026-03-27 22:09:52 +01:00
ce4cd7daf1 fix: include filter_conditions in goal type list query
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
Bug 3 Fix: filter_conditions was missing from SELECT statement in
list_goal_type_definitions(), preventing edit form from loading
existing filter JSON.

- Added filter_conditions to line 1087
- Now edit form correctly populates filter textarea
2026-03-27 21:57:25 +01:00
9ab36145e5 docs: documentation completion summary
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Final documentation summary for v0.9h pre-release state.

**Includes:**
- Complete documentation checklist
- Gitea manual actions required
- Resumption guide for future sessions
- Reading order for all documents
- Context prompt for Claude Code

**Status:** All documentation up to date, ready to pause/resume safely.
2026-03-27 21:36:23 +01:00
eb5c099eca docs: comprehensive status update v0.9h pre-release
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
**STATUS_2026-03-27.md (NEW):**
- Complete current state documentation
- Testing checklist for v0.9h
- Code splitting plan
- Phase 0b roadmap (120+ placeholders)
- Resumption guide for future sessions

**Issue #52 (NEW):**
- Blood pressure goals need dual targets (systolic/diastolic)
- Migration 033 planned
- 2-3h estimated effort

**CLAUDE.md Updated:**
- Version: v0.9g+ → v0.9h
- Dynamic Focus Areas v2.0 section added
- Bug fixes documented
- Current status: READY FOR RELEASE

**Updates:**
- Phase 0a: COMPLETE 
- Phase 0b: NEXT (after code splitting)
- All Gitea issues reviewed
- Comprehensive resumption documentation

**Action Items for User:**
- [ ] Manually close Gitea Issue #25 (Goals System - complete)
- [ ] Create Gitea Issue #52 from docs/issues/issue-52-*.md
- [ ] Review STATUS document before next session
2026-03-27 21:35:18 +01:00
37ea1f8537 fix: vitals_baseline dynamic query parameter mismatch
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
**Bug:** POST /api/vitals/baseline threw UndefinedParameter
**Cause:** Dynamic SQL generation had desynchronized column names and placeholders
**Fix:** Rewrote to use synchronized insert_cols, insert_placeholders, update_fields arrays

- Track param_idx correctly (start at 3 after pid and date)
- Build INSERT columns and placeholders in parallel
- Cleaner, more maintainable code
- Fixes Ruhepuls entry error
2026-03-27 21:23:56 +01:00
79cb3e0100 Merge pull request 'V 0.9h dynamic focus area system' (#51) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Reviewed-on: #51
2026-03-27 21:14:40 +01:00
378bf434fc fix: 3 critical bugs in Goals and Vitals
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
**Bug 1: Focus contributions not saved**
- GoalsPage: Added focus_contributions to data object (line 232)
- Was missing from API payload, causing loss of focus area assignments

**Bug 2: Filter focus areas in goal form**
- Only show focus areas user has weighted (weight > 0)
- Cleaner UX, avoids confusion with non-prioritized areas
- Filters focusAreasGrouped by userFocusWeights

**Bug 3: Vitals RHR entry - Internal Server Error**
- Fixed: Endpoint tried to INSERT into vitals_log (renamed in Migration 015)
- Now uses vitals_baseline table (correct post-migration table)
- Removed BP fields from baseline endpoint (use /blood-pressure instead)
- Backward compatible return format

All fixes tested and ready for production.
2026-03-27 21:04:28 +01:00
3116fbbc91 feat: Dynamic Focus Areas system v2.0 - fully implemented
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
**Migration 032:**
- user_focus_area_weights table (profile_id, focus_area_id, weight)
- Migrates legacy 6 preferences to dynamic weights

**Backend (focus_areas.py):**
- GET /user-preferences: Returns dynamic focus weights with percentages
- PUT /user-preferences: Saves user weights (dict: focus_area_id → weight)
- Auto-calculates percentages from relative weights
- Graceful fallback if Migration 032 not applied

**Frontend (GoalsPage.jsx):**
- REMOVED: Goal Mode cards (obsolete)
- REMOVED: 6 hardcoded legacy focus sliders
- NEW: Dynamic focus area cards (weight > 0 only)
- NEW: Edit mode with sliders for all 26 areas (grouped by category)
- Clean responsive design

**How it works:**
1. Admin defines focus areas in /admin/focus-areas (26 default)
2. User sets weights for areas they care about (0-100 relative)
3. System calculates percentages automatically
4. Cards show only weighted areas
5. Goals assign to 1-n focus areas (existing functionality)
2026-03-27 20:51:19 +01:00
dfcdfbe335 fix: restore Goal Mode cards and fix focus areas display
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
- Restored GOAL_MODES constant and selection cards at top
- Fixed focusAreas/focusPreferences variable confusion
- Legacy 6 focus preferences show correctly in Fokus-Bereiche card
- Dynamic 26 focus areas should display in goal form
- Goal Mode cards now visible and functional again
2026-03-27 20:40:33 +01:00
029530e078 fix: backward compatibility for focus_areas migration
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- get_focus_areas now tries user_focus_preferences first (Migration 031)
- Falls back to old focus_areas table if Migration 031 not applied
- get_goals_grouped wraps focus_contributions loading in try/catch
- Graceful degradation until migrations run
2026-03-27 20:34:06 +01:00
ba5d460e92 fix: Graceful fallback if Migration 031 not yet applied
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
- Wrap focus_contributions loading in try/catch
- If tables don't exist (migration not run), continue without them
- Backward compatible with pre-migration state
- Logs warning but doesn't crash
2026-03-27 20:24:16 +01:00
34ea51b8bd fix: Add /api prefix to focus_areas router
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
- Changed prefix from '/focus-areas' to '/api/focus-areas'
- Consistent with all other routers (goals, prompts, etc.)
- Fixes 404 Not Found on /admin/focus-areas page
2026-03-27 20:00:41 +01:00
6ab0a8b631 fix: Rename focusAreas → focusPreferences (duplicate state variable)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Line 60: focusPreferences (user's legacy preferences)
- Line 74: focusAreas (focus area definitions)
- Updated all references to avoid name collision
- Fixes build error in vite
2026-03-27 19:58:35 +01:00
6a961ce88f feat: Frontend Phase 3.2 - Goal Form Focus Areas + Badges
Some checks failed
Deploy Development / deploy (push) Failing after 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
**Goal Form Extended:**
- Load focus area definitions on page load
- Multi-Select UI grouped by category (7 categories)
- Chip-style selection (click to toggle)
- Weight sliders per selected area (0-100%)
- Selected areas highlighted in accent color
- Focus contributions saved/loaded on create/edit

**Goal Cards:**
- Focus Area badges below status
- Shows icon + name + weight percentage
- Hover shows full details
- Color-coded (accent-light background)

**Integration Complete:**
- State: focusAreas, focusAreasGrouped
- Handlers: handleCreateGoal, handleEditGoal
- Data flow: Backend → Frontend → Display

**Result:**
- User can assign goals to multiple focus areas
- Visual indication of what each goal contributes to
- Foundation for Phase 0b (goal-aware AI scoring)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 19:54:45 +01:00
d14157f7ad feat: Frontend Phase 3.1 - Focus Areas Admin UI
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
- AdminFocusAreasPage: Full CRUD for focus area definitions
- Route: /admin/focus-areas
- AdminPanel: Link zu Focus Areas (neben Goal Types)
- api.js: 7 neue Focus Area Endpoints

Features:
- Category-grouped display (7 categories)
- Inline editing
- Active/Inactive toggle
- Create form with validation
- Show/Hide inactive areas

Next: Goal Form Multi-Select
2026-03-27 19:51:18 +01:00
f312dd0dbb feat: Backend Phase 2 - Focus Areas API + Goals integration
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
**New Router: focus_areas.py**
- GET /focus-areas/definitions (list all, grouped by category)
- POST/PUT/DELETE /focus-areas/definitions (Admin CRUD)
- GET /focus-areas/user-preferences (legacy + future dynamic)
- PUT /focus-areas/user-preferences (auto-normalize to 100%)
- GET /focus-areas/stats (progress per focus area)

**Goals Router Extended:**
- FocusContribution model (focus_area_id + contribution_weight)
- GoalCreate/Update: focus_contributions field
- create_goal: Insert contributions after goal creation
- update_goal: Delete old + insert new contributions
- get_goals_grouped: Load focus_contributions per goal

**Main.py:**
- Registered focus_areas router

**Features:**
- Many-to-Many mapping (goals ↔ focus areas)
- Contribution weights (0-100%)
- Auto-mapped by Migration 031
- User can edit via UI (next: frontend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 19:48:05 +01:00
2f64656d4d feat: Migration 031 - Focus Area System v2.0 (dynamic, extensible)
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
2026-03-27 19:44:18 +01:00
17 changed files with 2457 additions and 275 deletions

View File

@ -76,9 +76,40 @@ frontend/src/
└── technical/ # MEMBERSHIP_SYSTEM.md └── 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):** - ✅ **Custom Goals Page (Capture/Eigene Ziele):**
- Neue Seite für tägliche Werterfassung individueller Ziele - Neue Seite für tägliche Werterfassung individueller Ziele
- Dedizierte UI für custom goals (ohne automatische Datenquelle) - Dedizierte UI für custom goals (ohne automatische Datenquelle)

View File

@ -278,7 +278,7 @@ def _fetch_by_aggregation_method(
- max_30d: Maximum value in last 30 days - max_30d: Maximum value in last 30 days
Args: 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 # Guard: source_table/column required for simple aggregation
if not table or not column: if not table or not column:
@ -412,7 +412,21 @@ def _fetch_by_aggregation_method(
return None return None
except Exception as e: 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] 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 return None

View File

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

View 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.';

View 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;

View 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
}

View File

@ -39,6 +39,11 @@ class FocusAreasUpdate(BaseModel):
flexibility_pct: int flexibility_pct: int
health_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): class GoalCreate(BaseModel):
"""Create or update a concrete goal""" """Create or update a concrete goal"""
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr 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 priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
class GoalUpdate(BaseModel): class GoalUpdate(BaseModel):
"""Update existing goal""" """Update existing goal"""
@ -61,6 +67,7 @@ class GoalUpdate(BaseModel):
priority: Optional[int] = None # 1=high, 2=medium, 3=low priority: Optional[int] = None # 1=high, 2=medium, 3=low
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
class TrainingPhaseCreate(BaseModel): class TrainingPhaseCreate(BaseModel):
"""Create training phase (manual or auto-detected)""" """Create training phase (manual or auto-detected)"""
@ -194,17 +201,32 @@ def get_focus_areas(session: dict = Depends(require_auth)):
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Try to get custom focus areas # Try to get custom focus areas (user_focus_preferences after Migration 031)
cur.execute(""" try:
SELECT weight_loss_pct, muscle_gain_pct, strength_pct, cur.execute("""
endurance_pct, flexibility_pct, health_pct, SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
created_at, updated_at endurance_pct, flexibility_pct, health_pct,
FROM focus_areas created_at, updated_at
WHERE profile_id = %s AND active = true FROM user_focus_preferences
LIMIT 1 WHERE profile_id = %s
""", (pid,)) LIMIT 1
""", (pid,))
row = cur.fetchone() 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,
created_at, updated_at
FROM focus_areas
WHERE profile_id = %s AND active = true
LIMIT 1
""", (pid,))
row = cur.fetchone()
except:
row = None
if row: if row:
return { return {
@ -429,6 +451,17 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
goal_id = cur.fetchone()['id'] 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"} return {"id": goal_id, "message": "Ziel erstellt"}
@router.put("/{goal_id}") @router.put("/{goal_id}")
@ -492,16 +525,33 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
updates.append("description = %s") updates.append("description = %s")
params.append(data.description) 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") raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("updated_at = NOW()") if updates:
params.extend([goal_id, pid]) updates.append("updated_at = NOW()")
params.extend([goal_id, pid])
cur.execute( cur.execute(
f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s", f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s",
tuple(params) tuple(params)
) )
return {"message": "Ziel aktualisiert"} return {"message": "Ziel aktualisiert"}
@ -680,13 +730,50 @@ def get_goals_grouped(session: dict = Depends(require_auth)):
goals = cur.fetchall() 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 = {} grouped = {}
for goal in goals: for goal in goals:
cat = goal['category'] or 'other' cat = goal['category'] or 'other'
if cat not in grouped: if cat not in grouped:
grouped[cat] = [] 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 return grouped
@ -997,7 +1084,7 @@ def list_goal_type_definitions(session: dict = Depends(require_auth)):
cur.execute(""" cur.execute("""
SELECT id, type_key, label_de, label_en, unit, icon, category, SELECT id, type_key, label_de, label_en, unit, icon, category,
source_table, source_column, aggregation_method, 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 created_at, updated_at
FROM goal_type_definitions FROM goal_type_definitions
WHERE is_active = true WHERE is_active = true

View File

@ -140,63 +140,66 @@ def create_vitals(
x_profile_id: Optional[str] = Header(default=None), x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth) 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) pid = get_pid(x_profile_id, session)
# Validation: at least one vital must be provided # Validation: at least one baseline vital must be provided
has_data = any([ has_baseline = any([
entry.resting_hr, entry.hrv, entry.blood_pressure_systolic, entry.resting_hr, entry.hrv, entry.vo2_max,
entry.blood_pressure_diastolic, entry.vo2_max, entry.spo2, entry.spo2, entry.respiratory_rate
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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Upsert: insert or update if date already exists # Upsert into vitals_baseline (Migration 015)
cur.execute( cur.execute(
""" """
INSERT INTO vitals_log ( INSERT INTO vitals_baseline (
profile_id, date, resting_hr, hrv, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate, vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source 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) ON CONFLICT (profile_id, date)
DO UPDATE SET DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_log.resting_hr), resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr),
hrv = COALESCE(EXCLUDED.hrv, vitals_log.hrv), hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv),
blood_pressure_systolic = COALESCE(EXCLUDED.blood_pressure_systolic, vitals_log.blood_pressure_systolic), vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max),
blood_pressure_diastolic = COALESCE(EXCLUDED.blood_pressure_diastolic, vitals_log.blood_pressure_diastolic), spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2),
pulse = COALESCE(EXCLUDED.pulse, vitals_log.pulse), respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate),
vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_log.vo2_max), note = COALESCE(EXCLUDED.note, vitals_baseline.note),
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),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING id, profile_id, date, resting_hr, hrv, RETURNING id, profile_id, date, resting_hr, hrv,
blood_pressure_systolic, blood_pressure_diastolic, pulse,
vo2_max, spo2, respiratory_rate, vo2_max, spo2, respiratory_rate,
irregular_heartbeat, possible_afib,
note, source, created_at, updated_at note, source, created_at, updated_at
""", """,
(pid, entry.date, entry.resting_hr, entry.hrv, (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.vo2_max, entry.spo2, entry.respiratory_rate,
entry.irregular_heartbeat, entry.possible_afib,
entry.note) entry.note)
) )
row = cur.fetchone() row = cur.fetchone()
conn.commit() conn.commit()
logger.info(f"[VITALS] Upserted vitals for {pid} on {entry.date}") logger.info(f"[VITALS] Upserted baseline vitals for {pid} on {entry.date}")
return r2d(row)
# 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}") @router.put("/{vitals_id}")

View File

@ -99,52 +99,90 @@ def create_or_update_baseline(
"""Create or update baseline entry (upsert on date).""" """Create or update baseline entry (upsert on date)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Build dynamic update columns (only non-None fields) # Build dynamic INSERT columns, placeholders, UPDATE fields, and values list
fields = [] # All arrays must stay synchronized
values = [pid, entry.date] 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: if entry.resting_hr is not None:
fields.append("resting_hr = COALESCE(EXCLUDED.resting_hr, vitals_baseline.resting_hr)") insert_cols.append("resting_hr")
values.append(entry.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: if entry.hrv is not None:
fields.append("hrv = COALESCE(EXCLUDED.hrv, vitals_baseline.hrv)") insert_cols.append("hrv")
values.append(entry.hrv) insert_placeholders.append("%s")
update_fields.append("hrv = EXCLUDED.hrv")
param_values.append(entry.hrv)
if entry.vo2_max is not None: if entry.vo2_max is not None:
fields.append("vo2_max = COALESCE(EXCLUDED.vo2_max, vitals_baseline.vo2_max)") insert_cols.append("vo2_max")
values.append(entry.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: if entry.spo2 is not None:
fields.append("spo2 = COALESCE(EXCLUDED.spo2, vitals_baseline.spo2)") insert_cols.append("spo2")
values.append(entry.spo2) insert_placeholders.append("%s")
update_fields.append("spo2 = EXCLUDED.spo2")
param_values.append(entry.spo2)
if entry.respiratory_rate is not None: if entry.respiratory_rate is not None:
fields.append("respiratory_rate = COALESCE(EXCLUDED.respiratory_rate, vitals_baseline.respiratory_rate)") insert_cols.append("respiratory_rate")
values.append(entry.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: if entry.body_temperature is not None:
fields.append("body_temperature = COALESCE(EXCLUDED.body_temperature, vitals_baseline.body_temperature)") insert_cols.append("body_temperature")
values.append(entry.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: if entry.resting_metabolic_rate is not None:
fields.append("resting_metabolic_rate = COALESCE(EXCLUDED.resting_metabolic_rate, vitals_baseline.resting_metabolic_rate)") insert_cols.append("resting_metabolic_rate")
values.append(entry.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: if entry.note:
fields.append("note = COALESCE(EXCLUDED.note, vitals_baseline.note)") insert_cols.append("note")
values.append(entry.note) insert_placeholders.append("%s")
update_fields.append("note = EXCLUDED.note")
param_values.append(entry.note)
# At least one field must be provided # 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") 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: with get_db() as conn:
cur = get_cursor(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""" query = f"""
INSERT INTO vitals_baseline (profile_id, date, {', '.join([f.split('=')[0].strip() for f in fields])}) INSERT INTO vitals_baseline ({all_cols})
VALUES ($1, $2, {', '.join([f'${i}' for i in range(3, len(values) + 1)])}) VALUES ({all_placeholders})
ON CONFLICT (profile_id, date) ON CONFLICT (profile_id, date)
DO UPDATE SET {', '.join(fields)}, updated_at = NOW() DO UPDATE SET {', '.join(update_fields)}, updated_at = NOW()
RETURNING * 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()) return r2d(cur.fetchone())

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

View 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)

View File

@ -32,6 +32,7 @@ import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage' import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
import SubscriptionPage from './pages/SubscriptionPage' import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage' import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage' import RestDaysPage from './pages/RestDaysPage'
@ -192,6 +193,7 @@ function AppShell() {
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/> <Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/> <Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/> <Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/> <Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes> </Routes>
</main> </main>

View 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>
)
}

View File

@ -485,6 +485,23 @@ export default function AdminPanel() {
</Link> </Link>
</div> </div>
</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> </div>
) )
} }

View File

@ -57,20 +57,16 @@ const getCategoryForGoalType = (goalType) => {
export default function GoalsPage() { export default function GoalsPage() {
const [goalMode, setGoalMode] = useState(null) const [goalMode, setGoalMode] = useState(null)
const [focusAreas, setFocusAreas] = useState(null) const [userFocusWeights, setUserFocusWeights] = useState([]) // v2.0: User's focus area weights
const [focusEditing, setFocusEditing] = useState(false) const [userFocusGrouped, setUserFocusGrouped] = useState({}) // Grouped by category
const [focusTemp, setFocusTemp] = useState({ const [focusWeightsEditing, setFocusWeightsEditing] = useState(false)
weight_loss_pct: 0, const [focusWeightsTemp, setFocusWeightsTemp] = useState({}) // Temp: {focus_area_id: weight}
muscle_gain_pct: 0,
strength_pct: 0,
endurance_pct: 0,
flexibility_pct: 0,
health_pct: 0
})
const [goals, setGoals] = useState([]) // Kept for backward compat const [goals, setGoals] = useState([]) // Kept for backward compat
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5) const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup 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 [showGoalForm, setShowGoalForm] = useState(false)
const [editingGoal, setEditingGoal] = useState(null) const [editingGoal, setEditingGoal] = useState(null)
const [showProgressModal, setShowProgressModal] = useState(false) const [showProgressModal, setShowProgressModal] = useState(false)
@ -95,7 +91,8 @@ export default function GoalsPage() {
unit: 'kg', unit: 'kg',
target_date: '', target_date: '',
name: '', name: '',
description: '' description: '',
focus_contributions: [] // v2.0: [{focus_area_id, contribution_weight}]
}) })
useEffect(() => { useEffect(() => {
@ -106,31 +103,31 @@ export default function GoalsPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [modeData, goalsData, groupedData, typesData, focusData] = await Promise.all([ const [modeData, goalsData, groupedData, typesData, userWeightsData, focusAreasData] = await Promise.all([
api.getGoalMode(), api.getGoalMode(),
api.listGoals(), api.listGoals(),
api.listGoalsGrouped(), // v2.1: Load grouped by category api.listGoalsGrouped(), // v2.1: Load grouped by category
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB 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) setGoalMode(modeData.goal_mode)
setGoals(goalsData) setGoals(goalsData)
setGroupedGoals(groupedData) setGroupedGoals(groupedData)
// Ensure all focus fields are present and numeric // v2.0: User focus weights (dynamic)
const sanitizedFocus = { setUserFocusWeights(userWeightsData.weights || [])
weight_loss_pct: focusData?.weight_loss_pct ?? 0, setUserFocusGrouped(userWeightsData.grouped || {})
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
}
setFocusAreas(sanitizedFocus) // Build temp object for editing: {focus_area_id: weight}
setFocusTemp(sanitizedFocus) const tempWeights = {}
if (userWeightsData.weights) {
userWeightsData.weights.forEach(w => {
tempWeights[w.id] = w.weight
})
}
setFocusWeightsTemp(tempWeights)
// Convert types array to map for quick lookup // Convert types array to map for quick lookup
const typesMap = {} const typesMap = {}
@ -148,6 +145,12 @@ export default function GoalsPage() {
setGoalTypes(typesData || []) setGoalTypes(typesData || [])
setGoalTypesMap(typesMap) setGoalTypesMap(typesMap)
// v2.0: All focus area definitions (for selection in goal form)
if (focusAreasData) {
setFocusAreas(focusAreasData.areas || [])
setFocusAreasGrouped(focusAreasData.grouped || {})
}
} catch (err) { } catch (err) {
console.error('Failed to load goals:', err) console.error('Failed to load goals:', err)
setError(`Fehler beim Laden: ${err.message || err.toString()}`) setError(`Fehler beim Laden: ${err.message || err.toString()}`)
@ -161,17 +164,6 @@ export default function GoalsPage() {
setTimeout(() => setToast(null), duration) 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 = () => { const handleCreateGoal = () => {
if (goalTypes.length === 0) { if (goalTypes.length === 0) {
setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.') setError('Keine Goal Types verfügbar. Bitte Admin kontaktieren.')
@ -188,7 +180,8 @@ export default function GoalsPage() {
unit: goalTypesMap[firstType]?.unit || 'kg', unit: goalTypesMap[firstType]?.unit || 'kg',
target_date: '', target_date: '',
name: '', name: '',
description: '' description: '',
focus_contributions: [] // v2.0: Empty for new goal
}) })
setShowGoalForm(true) setShowGoalForm(true)
} }
@ -204,7 +197,8 @@ export default function GoalsPage() {
unit: goal.unit, unit: goal.unit,
target_date: goal.target_date || '', target_date: goal.target_date || '',
name: goal.name || '', name: goal.name || '',
description: goal.description || '' description: goal.description || '',
focus_contributions: goal.focus_contributions || [] // v2.0: Load existing contributions
}) })
setShowGoalForm(true) setShowGoalForm(true)
} }
@ -234,7 +228,8 @@ export default function GoalsPage() {
unit: formData.unit, unit: formData.unit,
target_date: formData.target_date || null, target_date: formData.target_date || null,
name: formData.name || 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 }) console.log('[DEBUG] Saving goal:', { editingGoal, data })
@ -391,175 +386,158 @@ export default function GoalsPage() {
</div> </div>
)} )}
{/* Focus Areas (v2.0) */} {/* Focus Areas (v2.0 - Dynamic) */}
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2> <h2 style={{ margin: 0 }}>🎯 Fokus-Bereiche</h2>
{!focusEditing && focusAreas && ( <button
<button className="btn-secondary"
className="btn-secondary" onClick={() => {
onClick={() => { // Initialize temp weights from current weights
setFocusTemp(focusAreas) // Sync temp state before editing const tempWeights = {}
setFocusEditing(true) focusAreas.forEach(fa => {
}} tempWeights[fa.id] = focusWeightsTemp[fa.id] || 0
style={{ padding: '6px 12px' }} })
> setFocusWeightsTemp(tempWeights)
<Pencil size={14} /> Anpassen setFocusWeightsEditing(!focusWeightsEditing)
</button> }}
)} style={{ padding: '6px 12px' }}
>
<Pencil size={14} /> {focusWeightsEditing ? 'Abbrechen' : 'Anpassen'}
</button>
</div> </div>
<p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}> <p style={{ color: 'var(--text2)', fontSize: 14, marginBottom: 16 }}>
Setze relative Gewichte für deine Trainingsziele. Das System berechnet automatisch die Prozentanteile. Wähle deine Trainingsschwerpunkte und gewichte sie relativ zueinander. Prozente werden automatisch berechnet.
{focusAreas && !focusAreas.custom && (
<span style={{ display: 'block', marginTop: 4, fontStyle: 'italic' }}>
Aktuell abgeleitet aus Trainingsmodus "{goalMode}" - klicke "Anpassen" für individuelle Gewichtung
</span>
)}
</p> </p>
{focusEditing ? ( {focusWeightsEditing ? (
<> <>
{/* Sliders */} {/* Edit Mode - Sliders grouped by category */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 20 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24, marginBottom: 20 }}>
{[ {Object.entries(focusAreasGrouped).map(([category, areas]) => (
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' }, <div key={category}>
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' }, <div style={{
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' }, fontSize: 11,
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' }, fontWeight: 700,
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' }, color: 'var(--text3)',
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' } textTransform: 'uppercase',
].map(area => { letterSpacing: '0.05em',
const rawValue = Number(focusTemp[area.key]) || 0 marginBottom: 12
const weight = Math.round(rawValue / 10) }}>
const sum = Object.entries(focusTemp) {category}
.filter(([k]) => k.endsWith('_pct'))
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
const actualPercent = sum > 0 ? Math.round(rawValue / sum * 100) : 0
return (
<div key={area.key}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 20 }}>{area.icon}</span>
<span style={{ fontWeight: 500 }}>{area.label}</span>
</div>
<span style={{
fontSize: 16,
fontWeight: 600,
color: area.color,
minWidth: 80,
textAlign: 'right'
}}>
{weight} {actualPercent}%
</span>
</div>
<input
type="range"
min="0"
max="10"
step="1"
value={weight}
onChange={e => setFocusTemp(f => ({ ...f, [area.key]: parseInt(e.target.value) * 10 }))}
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%)`,
outline: 'none',
cursor: 'pointer'
}}
/>
</div> </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.id}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18 }}>{area.icon}</span>
<span style={{ fontWeight: 500, fontSize: 14 }}>{area.name_de}</span>
</div>
<span style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--accent)',
minWidth: 80,
textAlign: 'right'
}}>
{weight} {percentage}%
</span>
</div>
<input
type="range"
min="0"
max="100"
step="5"
value={weight}
onChange={e => setFocusWeightsTemp(f => ({
...f,
[area.id]: parseInt(e.target.value)
}))}
style={{
width: '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'
}}
/>
</div>
)
})}
</div>
</div>
))}
</div> </div>
{/* Action Buttons */} {/* Save Button */}
<div style={{ display: 'flex', gap: 12 }}> <button
<button className="btn-primary btn-full"
className="btn-primary" onClick={async () => {
onClick={async () => { try {
// Calculate sum (filter out NaN/undefined) await api.updateUserFocusPreferences({ weights: focusWeightsTemp })
const sum = Object.entries(focusTemp) showToast('✓ Fokus-Bereiche aktualisiert')
.filter(([k]) => k.endsWith('_pct')) await loadData()
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0) setFocusWeightsEditing(false)
setError(null)
if (sum === 0 || isNaN(sum)) { } catch (err) {
setError('Mindestens ein Bereich muss gewichtet sein') setError(err.message || 'Fehler beim Speichern')
return }
} }}
>
// Normalize to percentages (ensure no NaN values) Speichern
const normalized = { </button>
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), /* Display Mode - Cards for areas with weight > 0 */
endurance_pct: Math.round((Number(focusTemp.endurance_pct) || 0) / sum * 100), userFocusWeights.length === 0 ? (
flexibility_pct: Math.round((Number(focusTemp.flexibility_pct) || 0) / sum * 100), <div style={{
health_pct: Math.round((Number(focusTemp.health_pct) || 0) / sum * 100) textAlign: 'center',
} padding: '32px 16px',
color: 'var(--text3)',
// Ensure sum is exactly 100 (adjust largest value if needed due to rounding) background: 'var(--surface2)',
const normalizedSum = Object.values(normalized).reduce((a, b) => a + b, 0) borderRadius: 8
if (normalizedSum !== 100) { }}>
const largest = Object.entries(normalized).reduce((max, [k, v]) => v > max[1] ? [k, v] : max, ['', 0]) <p style={{ margin: 0, marginBottom: 12 }}>Keine Fokus-Bereiche definiert</p>
normalized[largest[0]] += (100 - normalizedSum)
}
try {
await api.updateFocusAreas(normalized)
showToast('✓ Fokus-Bereiche aktualisiert')
await loadData()
setFocusEditing(false)
setError(null)
} catch (err) {
setError(err.message || 'Fehler beim Speichern')
}
}}
style={{ flex: 1 }}
>
Speichern
</button>
<button <button
className="btn-secondary" className="btn-secondary"
onClick={() => { onClick={() => setFocusWeightsEditing(true)}
setFocusTemp(focusAreas) style={{ fontSize: 13 }}
setFocusEditing(false)
setError(null)
}}
style={{ flex: 1 }}
> >
Abbrechen Jetzt konfigurieren
</button> </button>
</div> </div>
</> ) : (
) : focusAreas && ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
/* Display Mode */ {userFocusWeights.map(area => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}> <div
{[ key={area.id}
{ key: 'weight_loss_pct', label: 'Fettabbau', icon: '📉', color: '#D85A30' }, style={{
{ key: 'muscle_gain_pct', label: 'Muskelaufbau', icon: '💪', color: '#378ADD' }, padding: 12,
{ key: 'strength_pct', label: 'Kraftsteigerung', icon: '🏋️', color: '#7B68EE' }, background: 'var(--surface2)',
{ key: 'endurance_pct', label: 'Ausdauer', icon: '🏃', color: '#1D9E75' }, border: '1px solid var(--border)',
{ key: 'flexibility_pct', label: 'Beweglichkeit', icon: '🤸', color: '#E67E22' }, borderRadius: 8,
{ key: 'health_pct', label: 'Gesundheit', icon: '❤️', color: '#F59E0B' } textAlign: 'center'
].filter(area => focusAreas[area.key] > 0).map(area => ( }}
<div >
key={area.key} <div style={{ fontSize: 24, marginBottom: 4 }}>{area.icon}</div>
style={{ <div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 4 }}>{area.name_de}</div>
padding: 12, <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{area.percentage}%</div>
background: 'var(--surface2)', </div>
border: '1px solid var(--border)', ))}
borderRadius: 8, </div>
textAlign: 'center' )
}}
>
<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>
))}
</div>
)} )}
</div> </div>
@ -652,6 +630,38 @@ export default function GoalsPage() {
</span> </span>
</div> </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 style={{ display: 'flex', gap: 20, marginBottom: 12, fontSize: 14, flexWrap: 'wrap' }}>
<div> <div>
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '} <span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
@ -848,6 +858,191 @@ export default function GoalsPage() {
/> />
</div> </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 */} {/* Zielwert */}
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}> <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>
🎯 Zielwert 🎯 Zielwert

View File

@ -365,4 +365,13 @@ export const api = {
// Fitness Tests // Fitness Tests
listFitnessTests: () => req('/goals/tests'), listFitnessTests: () => req('/goals/tests'),
createFitnessTest: (d) => req('/goals/tests', json(d)), 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'),
} }