diff --git a/.claude/docs/working/SHINKAN_PROJECT_SETUP.md b/.claude/docs/working/SHINKAN_PROJECT_SETUP.md new file mode 100644 index 0000000..0af3a3e --- /dev/null +++ b/.claude/docs/working/SHINKAN_PROJECT_SETUP.md @@ -0,0 +1,1202 @@ +# Shinkan Jinkendo – Technisches Setup + +> **Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung** +> Basis: Mitai Jinkendo Ökosystem +> Domain: shinkan.jinkendo.de (真観 - "Wahre Beobachtung") + +--- + +## 1. Fachliche Einordnung + +**Shinkan ist KEINE persönliche Tracking-App.** + +Shinkan ist eine **trainer- und vereinszentrierte Planungs- und Inhaltsplattform** mit Fokus auf: +- Übungsverwaltung und -suche +- Trainingsplanung für Gruppen +- Standardisierung und Wiederverwendung +- Freigabe und Governance von Inhalten +- Import aus bestehendem MediaWiki + +**Primäre Nutzer in MVP:** Trainer, Vereinsadmins, Redakteure +**Nicht in MVP:** Individuelle Sportler-Apps, persönliches Tracking, Gürtel-Tracking + +**Abgrenzung zu Mitai:** +- Mitai = persönliches Körper- und Trainings-Tracking +- Shinkan = Trainer-Arbeit, Übungen, Trainingsplanung, Vereinsstandards + +--- + +## 2. Übernommene Infrastruktur von Mitai + +| Komponente | Status | Verwendung | +|-----------|---------|------------| +| Auth-System | ✅ 1:1 übernehmen | Token + bcrypt | +| Membership-Basis | ✅ Übernehmen | Tiers, Features (vereinfacht) | +| User-Profiles | ✅ Übernehmen | Multi-Profile pro Account | +| Tech-Stack | ✅ 1:1 | React 18 + FastAPI + PostgreSQL 16 | +| Docker/Compose | ✅ 1:1 | Dev + Prod Umgebungen | +| PWA | ✅ 1:1 | Mobile + Desktop | +| Design-System | ✅ Basis | CSS-Variablen, mobile-first | +| Versioning | ✅ 1:1 | version.py + Migrations | +| Gitea CI/CD | ✅ 1:1 | Auto-Deploy dev/prod | + +**Ports:** +- Prod: 3003/8003 (Frontend/Backend) +- Dev: 3098/8098 (Frontend/Backend) + +--- + +## 3. Technische Basis (identisch zu Mitai) + +``` +Frontend: React 18 + Vite + PWA (Node 20) +Backend: FastAPI Python 3.12 +Datenbank: PostgreSQL 16 Alpine +Container: Docker + Docker Compose +Auth: Token-basiert + bcrypt +KI: OpenRouter API (optional, nicht MVP-kritisch) +``` + +--- + +## 4. Verzeichnisstruktur (angepasst) + +``` +shinkan-jinkendo/ +├── backend/ +│ ├── main.py # App-Setup + Router-Registration +│ ├── db.py # PostgreSQL Connection Pool +│ ├── db_init.py # DB-Init + Migrations +│ ├── auth.py # Hash, Verify, Sessions, Feature-Access +│ ├── models.py # Pydantic Models +│ ├── version.py # Versionskontrolle +│ ├── migrations/ # SQL-Migrationen (001_*.sql) +│ └── routers/ +│ ├── auth.py # Login, Register, Sessions +│ ├── profiles.py # User-Profiles +│ ├── clubs.py # Vereine / Sparten +│ ├── groups.py # Trainingsgruppen +│ ├── skills.py # Fähigkeiten (Admin + CRUD) +│ ├── methods.py # Trainingsmethoden (Admin + CRUD) +│ ├── exercises.py # Übungen (Kern-Modul) +│ ├── training_units.py # Trainingseinheiten +│ ├── training_programs.py # Trainingsprogramme +│ ├── planning.py # Planungsansichten +│ ├── import_wiki.py # MediaWiki-Import +│ ├── admin.py # Admin-Panel +│ └── membership.py # Subscription (vereinfacht) +│ +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Root, Auth-Gates, Navigation +│ │ ├── app.css # CSS-Variablen + Styles +│ │ ├── version.js # Frontend-Versionierung +│ │ ├── config/ +│ │ │ ├── appNav.js # Hauptnavigation +│ │ │ └── adminNav.js # Admin-Navigation +│ │ ├── context/ +│ │ │ ├── AuthContext.jsx +│ │ │ └── ProfileContext.jsx +│ │ ├── pages/ +│ │ │ ├── Dashboard.jsx +│ │ │ ├── ExercisesPage.jsx # Übungssuche + CRUD +│ │ │ ├── PlanningPage.jsx # Trainingsplanung +│ │ │ ├── GroupsPage.jsx # Gruppenverwaltung +│ │ │ ├── SkillsPage.jsx # Fähigkeiten (Admin) +│ │ │ ├── MethodsPage.jsx # Methoden (Admin) +│ │ │ ├── ImportPage.jsx # MediaWiki-Import +│ │ │ └── SettingsPage.jsx +│ │ └── utils/ +│ │ ├── api.js # ALLE API-Calls +│ │ └── planning.js # Planungshelfer +│ └── public/ +│ ├── manifest.json # PWA-Manifest +│ └── icons/ +│ +├── .claude/ +│ ├── commands/ # Slash-Commands +│ ├── docs/ +│ │ ├── functional/ +│ │ │ └── SHINKAN_REQUIREMENTS.md # Anforderungsdokument +│ │ ├── technical/ +│ │ │ └── SHINKAN_DOMAIN_MODEL.md # Domänenmodell +│ │ └── rules/ # Architektur-Regeln (wie Mitai) +│ └── library/ +│ +├── docker-compose.yml # Production +├── docker-compose.dev-env.yml # Development +├── .env +├── .gitignore +└── CLAUDE.md # Agent-Einstieg +``` + +--- + +## 5. Domänenmodell (MVP Core) + +### 5.1 Organisationsstruktur + +```sql +-- Vereine +clubs ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + abbreviation VARCHAR(50), + description TEXT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +) + +-- Sparten (optional, kann auch später) +divisions ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id), + name VARCHAR(200) NOT NULL, + focus_area VARCHAR(100), -- karate, selbstverteidigung, gewaltschutz + created_at TIMESTAMP DEFAULT NOW() +) + +-- Trainingsgruppen +training_groups ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id), + division_id INT REFERENCES divisions(id), + name VARCHAR(200) NOT NULL, + focus VARCHAR(100), + level VARCHAR(50), + age_group VARCHAR(50), + weekday VARCHAR(20), + time_start TIME, + time_end TIME, + location VARCHAR(200), + trainer_id INT REFERENCES profiles(id), + co_trainer_ids JSONB, -- [1, 2, 3] + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### 5.2 Fähigkeiten und Methoden (Kataloge) + +```sql +-- Fähigkeiten (global) +skills ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + category VARCHAR(100), -- kihon, kumite, kata, selbstverteidigung, fitness + description TEXT, + importance INT, -- 1-5 + keywords JSONB, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +) + +-- Trainingsmethoden (global) +training_methods ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + abbreviation VARCHAR(20), + category VARCHAR(100), -- intervall, rollenspiel, zirkel, koordination, etc. + description TEXT, + typical_duration INT, -- in Minuten + typical_group_size VARCHAR(50), + related_skills JSONB, -- [skill_id, skill_id, ...] + keywords JSONB, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### 5.3 Übungen (Kernobjekt) + +```sql +-- Übungen +exercises ( + id SERIAL PRIMARY KEY, + title VARCHAR(300) NOT NULL, + summary TEXT, -- Kurzbeschreibung + goal TEXT NOT NULL, -- Ziel der Übung + execution TEXT NOT NULL, -- Durchführung + preparation TEXT, -- Vorbereitung / Aufbau + trainer_notes TEXT, -- Hinweise für Trainer + equipment JSONB, -- ["Matten", "Pratzen", "Bälle"] + + -- Metadaten + duration_min INT, + duration_max INT, + group_size_min INT, + group_size_max INT, + age_groups JSONB, -- ["minis", "kinder", "erwachsene"] + + -- Fachliche Zuordnung + focus_area VARCHAR(100), -- karate, selbstverteidigung, gewaltschutz + secondary_areas JSONB, + training_character VARCHAR(50), -- grundlage, aufbau, vertiefung, festigung, diagnose + + -- Methode + primary_method_id INT REFERENCES training_methods(id), + secondary_method_ids JSONB, + + -- Freigabe + visibility VARCHAR(50) DEFAULT 'private', -- private, club, official + status VARCHAR(50) DEFAULT 'draft', -- draft, in_review, approved, archived + created_by INT REFERENCES profiles(id), + club_id INT REFERENCES clubs(id), + + -- Import + import_source VARCHAR(50), -- mediawiki, manual + import_id VARCHAR(200), + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +) + +-- Übungs-Fähigkeiten (M:N) +exercise_skills ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + skill_id INT REFERENCES skills(id), + is_primary BOOLEAN DEFAULT false, + intensity INT, -- 1-5 + development_contribution VARCHAR(50), -- low, medium, high + required_level INT, -- Eingangs-Niveau + target_level INT, -- Ziel-Niveau + created_at TIMESTAMP DEFAULT NOW() +) + +-- Übungsvarianten +exercise_variants ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + variant_name VARCHAR(200) NOT NULL, + description TEXT, + execution_changes TEXT, + duration_min INT, + duration_max INT, + equipment_changes JSONB, + difficulty_adjustment VARCHAR(50), -- easier, harder, adapted + age_group_override JSONB, + skill_focus_override JSONB, + created_at TIMESTAMP DEFAULT NOW() +) + +-- Medien (1:N) +exercise_media ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + media_type VARCHAR(50), -- image, video, document, sketch + file_path TEXT, + title VARCHAR(200), + description TEXT, + sort_order INT, + is_primary BOOLEAN DEFAULT false, + context VARCHAR(100), -- ablauf, detail, trainer_hint + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### 5.4 Trainingsplanung + +```sql +-- Training Templates / Standards (vereinbar) +training_templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(300) NOT NULL, + type VARCHAR(50), -- template, standard + club_id INT REFERENCES clubs(id), + division_id INT REFERENCES divisions(id), + + goal TEXT, + focus_areas JSONB, + duration_total INT, + + visibility VARCHAR(50) DEFAULT 'private', + status VARCHAR(50) DEFAULT 'active', + version INT DEFAULT 1, + created_by INT REFERENCES profiles(id), + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +) + +-- Trainingsabschnitte (für Templates + Units) +training_sections ( + id SERIAL PRIMARY KEY, + parent_type VARCHAR(50), -- template, unit + parent_id INT, + + title VARCHAR(200) NOT NULL, + section_type VARCHAR(100), -- aufwaermen, kihon, kumite, kata, abschluss, etc. + sort_order INT, + duration_planned INT, + goal TEXT, + notes TEXT, + + is_combination BOOLEAN DEFAULT false, -- Übungen als Komplex + combination_method_id INT REFERENCES training_methods(id), + + created_at TIMESTAMP DEFAULT NOW() +) + +-- Übungen in Abschnitten +section_exercises ( + id SERIAL PRIMARY KEY, + section_id INT REFERENCES training_sections(id) ON DELETE CASCADE, + exercise_id INT REFERENCES exercises(id), + variant_id INT REFERENCES exercise_variants(id), + sort_order INT, + duration_planned INT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +) + +-- Konkrete Trainingseinheiten +training_units ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES training_groups(id), + date DATE NOT NULL, + time_start TIME, + time_end TIME, + + derived_from_template_id INT REFERENCES training_templates(id), + derived_from_unit_id INT REFERENCES training_units(id), + + title VARCHAR(300), + goal TEXT, + focus_areas JSONB, + + -- Durchführung + actual_time_start TIME, + actual_time_end TIME, + completion_status VARCHAR(50), -- planned, in_progress, completed + + -- Reflexion + reflection_text TEXT, + what_worked_well TEXT, + what_to_improve TEXT, + + created_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +) + +-- Trainingsprogramme +training_programs ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id), + division_id INT REFERENCES divisions(id), + + name VARCHAR(300) NOT NULL, + description TEXT, + goal TEXT, + duration_weeks INT, + focus_areas JSONB, + + visibility VARCHAR(50) DEFAULT 'private', + status VARCHAR(50) DEFAULT 'active', + created_by INT REFERENCES profiles(id), + + created_at TIMESTAMP DEFAULT NOW() +) + +-- Programm-Einheiten (Reihenfolge) +program_units ( + id SERIAL PRIMARY KEY, + program_id INT REFERENCES training_programs(id) ON DELETE CASCADE, + unit_order INT, + template_id INT REFERENCES training_templates(id), + week_number INT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### 5.5 Governance + +```sql +-- Änderungsanfragen (für geschützte Inhalte) +content_change_requests ( + id SERIAL PRIMARY KEY, + content_type VARCHAR(50), -- exercise, template, skill, method + content_id INT, + requested_by INT REFERENCES profiles(id), + change_description TEXT, + change_details JSONB, + status VARCHAR(50) DEFAULT 'pending', -- pending, approved, rejected + reviewed_by INT REFERENCES profiles(id), + review_notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + reviewed_at TIMESTAMP +) +``` + +### 5.6 MediaWiki-Import + +```sql +-- Import-Tracking +wiki_import_log ( + id SERIAL PRIMARY KEY, + import_type VARCHAR(50), -- skill, method, exercise + import_status VARCHAR(50), -- success, partial, failed + items_total INT, + items_imported INT, + items_failed INT, + error_log JSONB, + imported_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW() +) + +-- Import-Referenzen (für Re-Import-Schutz) +wiki_import_references ( + id SERIAL PRIMARY KEY, + wiki_page_title VARCHAR(300), + wiki_page_id VARCHAR(100), + content_type VARCHAR(50), -- skill, method, exercise + local_id INT, + last_imported TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(wiki_page_title, content_type) +) +``` + +--- + +## 6. MVP Core-Features (Release 1) + +### 6.1 Pflicht in MVP + +**Organisationsstruktur:** +- ✅ Verein anlegen/verwalten +- ✅ Trainingsgruppen anlegen/verwalten +- ✅ Trainer zuordnen + +**Kataloge:** +- ✅ Fähigkeiten-Katalog (Admin CRUD) +- ✅ Methoden-Katalog (Admin CRUD) + +**Übungen:** +- ✅ Übung anlegen (minimal: Titel, Ziel, Durchführung) +- ✅ Übung vollständig beschreiben (alle Felder) +- ✅ Übungsvarianten anlegen +- ✅ Fähigkeiten zuordnen (Haupt/Neben, Intensität) +- ✅ Methoden zuordnen (Haupt/Neben) +- ✅ Medien hochladen (Bilder, Videos) +- ✅ Übung suchen und filtern + - Schnellsuche (Titel, Schlagworte) + - Filter (Bereich, Zielgruppe, Fähigkeit, Dauer, Gruppengröße) +- ✅ Übung drucken +- ✅ Freigabelogik (privat / Verein / offiziell) + +**Trainingsplanung:** +- ✅ Trainingseinheit für Gruppe + Termin planen +- ✅ Training aus Vorlage ableiten +- ✅ Training aus altem Training kopieren +- ✅ Trainingsabschnitte verwalten +- ✅ Übungen zu Abschnitten zuordnen +- ✅ Kombinations-Flag für Abschnitte +- ✅ Kalenderansicht (Gruppe, Datum) +- ✅ Druckansicht +- ✅ Mobile Durchführungssicht +- ✅ Notizen zu Training + Übungen +- ✅ Reflexion nach Training + +**Import:** +- ✅ Einseitiger MediaWiki-Import + - Fähigkeiten + - Methoden + - Übungen +- ✅ Import-Tracking (Erfolg/Fehler) +- ✅ Duplikat-Erkennung (Wiki-Referenz) + +### 6.2 Bewusst NICHT in MVP + +**Kern-Features, die warten:** +- ❌ KI-Trainingsplanung +- ❌ KI-Suche mit externen Calls +- ❌ Sportler-Tracking (individuelle Entwicklung) +- ❌ Gürtel/Graduierungs-System +- ❌ Technik-Mastery-Tracking +- ❌ Reifegradmodell (vollständig) +- ❌ Turnier-/Wettkampfbegleitung +- ❌ Community/Marktplatz +- ❌ Komplexe Membership-Matrix +- ❌ Vollhistorisierung jeder Änderung +- ❌ Bidirektionale Wiki-Sync +- ❌ Offline-Sync (vollständig) + +**Rollen/Governance (vereinfacht):** +- ❌ Feingranulare Rechte-Matrix +- ❌ Workflow-basierte Freigabe +- ❌ Multi-Tenant-Administration + +--- + +## 7. Initiale Migrationen (MVP) + +### 7.1 001_auth_membership.sql + +```sql +-- Von Mitai übernehmen: +-- profiles, sessions, features, tier_limits, subscriptions +-- (siehe Mitai 001_*.sql + 002_*.sql) +``` + +### 7.2 002_organization.sql + +```sql +-- Vereine +CREATE TABLE clubs ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + abbreviation VARCHAR(50), + description TEXT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Sparten (optional, kann später) +CREATE TABLE divisions ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + focus_area VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Trainingsgruppen +CREATE TABLE training_groups ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id) ON DELETE CASCADE, + division_id INT REFERENCES divisions(id), + name VARCHAR(200) NOT NULL, + focus VARCHAR(100), + level VARCHAR(50), + age_group VARCHAR(50), + weekday VARCHAR(20), + time_start TIME, + time_end TIME, + location VARCHAR(200), + trainer_id INT REFERENCES profiles(id), + co_trainer_ids JSONB, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_groups_club ON training_groups(club_id); +CREATE INDEX idx_groups_trainer ON training_groups(trainer_id); +``` + +### 7.3 003_catalogs.sql + +```sql +-- Fähigkeiten +CREATE TABLE skills ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + category VARCHAR(100), + description TEXT, + importance INT CHECK (importance BETWEEN 1 AND 5), + keywords JSONB, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Trainingsmethoden +CREATE TABLE training_methods ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + abbreviation VARCHAR(20), + category VARCHAR(100), + description TEXT, + typical_duration INT, + typical_group_size VARCHAR(50), + related_skills JSONB, + keywords JSONB, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_skills_category ON skills(category); +CREATE INDEX idx_methods_category ON training_methods(category); +``` + +### 7.4 004_exercises.sql + +```sql +-- Übungen +CREATE TABLE exercises ( + id SERIAL PRIMARY KEY, + title VARCHAR(300) NOT NULL, + summary TEXT, + goal TEXT NOT NULL, + execution TEXT NOT NULL, + preparation TEXT, + trainer_notes TEXT, + equipment JSONB, + + duration_min INT, + duration_max INT, + group_size_min INT, + group_size_max INT, + age_groups JSONB, + + focus_area VARCHAR(100), + secondary_areas JSONB, + training_character VARCHAR(50), + + primary_method_id INT REFERENCES training_methods(id), + secondary_method_ids JSONB, + + visibility VARCHAR(50) DEFAULT 'private', + status VARCHAR(50) DEFAULT 'draft', + created_by INT REFERENCES profiles(id), + club_id INT REFERENCES clubs(id), + + import_source VARCHAR(50), + import_id VARCHAR(200), + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Übungs-Fähigkeiten +CREATE TABLE exercise_skills ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + skill_id INT REFERENCES skills(id), + is_primary BOOLEAN DEFAULT false, + intensity INT CHECK (intensity BETWEEN 1 AND 5), + development_contribution VARCHAR(50), + required_level INT, + target_level INT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Varianten +CREATE TABLE exercise_variants ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + variant_name VARCHAR(200) NOT NULL, + description TEXT, + execution_changes TEXT, + duration_min INT, + duration_max INT, + equipment_changes JSONB, + difficulty_adjustment VARCHAR(50), + age_group_override JSONB, + skill_focus_override JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Medien +CREATE TABLE exercise_media ( + id SERIAL PRIMARY KEY, + exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE, + media_type VARCHAR(50), + file_path TEXT, + title VARCHAR(200), + description TEXT, + sort_order INT, + is_primary BOOLEAN DEFAULT false, + context VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_exercises_created_by ON exercises(created_by); +CREATE INDEX idx_exercises_club ON exercises(club_id); +CREATE INDEX idx_exercises_visibility ON exercises(visibility); +CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id); +``` + +### 7.5 005_training_planning.sql + +```sql +-- Templates / Standards +CREATE TABLE training_templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(300) NOT NULL, + type VARCHAR(50), + club_id INT REFERENCES clubs(id), + division_id INT REFERENCES divisions(id), + goal TEXT, + focus_areas JSONB, + duration_total INT, + visibility VARCHAR(50) DEFAULT 'private', + status VARCHAR(50) DEFAULT 'active', + version INT DEFAULT 1, + created_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Abschnitte +CREATE TABLE training_sections ( + id SERIAL PRIMARY KEY, + parent_type VARCHAR(50), + parent_id INT, + title VARCHAR(200) NOT NULL, + section_type VARCHAR(100), + sort_order INT, + duration_planned INT, + goal TEXT, + notes TEXT, + is_combination BOOLEAN DEFAULT false, + combination_method_id INT REFERENCES training_methods(id), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Übungen in Abschnitten +CREATE TABLE section_exercises ( + id SERIAL PRIMARY KEY, + section_id INT REFERENCES training_sections(id) ON DELETE CASCADE, + exercise_id INT REFERENCES exercises(id), + variant_id INT REFERENCES exercise_variants(id), + sort_order INT, + duration_planned INT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Trainingseinheiten +CREATE TABLE training_units ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES training_groups(id) ON DELETE CASCADE, + date DATE NOT NULL, + time_start TIME, + time_end TIME, + derived_from_template_id INT REFERENCES training_templates(id), + derived_from_unit_id INT REFERENCES training_units(id), + title VARCHAR(300), + goal TEXT, + focus_areas JSONB, + actual_time_start TIME, + actual_time_end TIME, + completion_status VARCHAR(50), + reflection_text TEXT, + what_worked_well TEXT, + what_to_improve TEXT, + created_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Programme +CREATE TABLE training_programs ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id), + division_id INT REFERENCES divisions(id), + name VARCHAR(300) NOT NULL, + description TEXT, + goal TEXT, + duration_weeks INT, + focus_areas JSONB, + visibility VARCHAR(50) DEFAULT 'private', + status VARCHAR(50) DEFAULT 'active', + created_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE program_units ( + id SERIAL PRIMARY KEY, + program_id INT REFERENCES training_programs(id) ON DELETE CASCADE, + unit_order INT, + template_id INT REFERENCES training_templates(id), + week_number INT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_units_group_date ON training_units(group_id, date); +CREATE INDEX idx_sections_parent ON training_sections(parent_type, parent_id); +``` + +### 7.6 006_governance.sql + +```sql +-- Änderungsanfragen +CREATE TABLE content_change_requests ( + id SERIAL PRIMARY KEY, + content_type VARCHAR(50), + content_id INT, + requested_by INT REFERENCES profiles(id), + change_description TEXT, + change_details JSONB, + status VARCHAR(50) DEFAULT 'pending', + reviewed_by INT REFERENCES profiles(id), + review_notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + reviewed_at TIMESTAMP +); + +CREATE INDEX idx_change_requests_status ON content_change_requests(status); +``` + +### 7.7 007_wiki_import.sql + +```sql +-- Import-Log +CREATE TABLE wiki_import_log ( + id SERIAL PRIMARY KEY, + import_type VARCHAR(50), + import_status VARCHAR(50), + items_total INT, + items_imported INT, + items_failed INT, + error_log JSONB, + imported_by INT REFERENCES profiles(id), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Import-Referenzen +CREATE TABLE wiki_import_references ( + id SERIAL PRIMARY KEY, + wiki_page_title VARCHAR(300), + wiki_page_id VARCHAR(100), + content_type VARCHAR(50), + local_id INT, + last_imported TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(wiki_page_title, content_type) +); + +CREATE INDEX idx_wiki_refs_page ON wiki_import_references(wiki_page_title); +``` + +--- + +## 8. MediaWiki-Import-Strategie + +### 8.1 Grundprinzip + +**Einseitig, einmalig, dann Shinkan-nativ weiter** + +- ✅ Import aus MediaWiki nach Shinkan +- ❌ KEINE bidirektionale Synchronisation +- ❌ KEINE Live-Kopplung +- ❌ KEINE kontinuierliche Aktualisierung + +### 8.2 Import-Reihenfolge + +1. **Fähigkeiten** (skills) - Basis-Katalog +2. **Methoden** (training_methods) - Methodenkatalog +3. **Übungen** (exercises) - Hauptinhalt + +**Programme/Trainings:** Nur wenn Datenqualität ausreicht + +### 8.3 Import-Logik + +```python +# Pseudo-Code Import-Workflow +for wiki_page in wiki_pages: + # Duplikat-Check + existing = check_wiki_reference(wiki_page.title, content_type) + if existing: + log_skip(wiki_page) + continue + + # Parse + Transform + data = parse_wiki_page(wiki_page) + + # Validate + if not validate_minimal_fields(data): + log_error(wiki_page, "missing required fields") + continue + + # Import + try: + local_id = create_content(data) + create_wiki_reference(wiki_page, local_id) + log_success(wiki_page, local_id) + except Exception as e: + log_error(wiki_page, str(e)) +``` + +### 8.4 Import-Metadaten + +Jeder importierte Inhalt erhält: +- `import_source = 'mediawiki'` +- `import_id = wiki_page_id` +- Referenz in `wiki_import_references` +- Status = `draft` (muss nachbearbeitet werden) + +### 8.5 Nach-Import-Workflow + +Nach Import müssen Trainer/Admins: +- Importierte Inhalte prüfen +- Unvollständige Felder ergänzen +- Fähigkeiten/Methoden zuordnen +- Status auf `approved` setzen + +--- + +## 9. Docker-Setup (wie Mitai) + +### 9.1 docker-compose.yml (Production) + +```yaml +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: shinkan-db-prod + environment: + POSTGRES_DB: shinkan + POSTGRES_USER: shinkan_user + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - shinkan-db-data:/var/lib/postgresql/data + ports: + - "5434:5432" + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: shinkan-api + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: shinkan + DB_USER: shinkan_user + DB_PASSWORD: ${DB_PASSWORD} + APP_URL: https://shinkan.jinkendo.de + ALLOWED_ORIGINS: https://shinkan.jinkendo.de + volumes: + - shinkan-media:/app/media + ports: + - "8003:8000" + depends_on: + - postgres + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: https://shinkan.jinkendo.de + container_name: shinkan-ui + ports: + - "3003:80" + restart: unless-stopped + +volumes: + shinkan-db-data: + shinkan-media: +``` + +### 9.2 docker-compose.dev-env.yml (Development) + +```yaml +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: dev-shinkan-postgres + environment: + POSTGRES_DB: shinkan_dev + POSTGRES_USER: shinkan_dev + POSTGRES_PASSWORD: dev_password + volumes: + - dev-shinkan-db-data:/var/lib/postgresql/data + ports: + - "5435:5432" + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: dev-shinkan-api + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: shinkan_dev + DB_USER: shinkan_dev + DB_PASSWORD: dev_password + APP_URL: https://dev.shinkan.jinkendo.de + ALLOWED_ORIGINS: https://dev.shinkan.jinkendo.de + volumes: + - dev-shinkan-media:/app/media + ports: + - "8098:8000" + depends_on: + - postgres + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: https://dev.shinkan.jinkendo.de + container_name: dev-shinkan-ui + ports: + - "3098:80" + restart: unless-stopped + +volumes: + dev-shinkan-db-data: + dev-shinkan-media: +``` + +--- + +## 10. Initiale Version + +```python +# backend/version.py + +APP_VERSION = "0.1.0" +BUILD_DATE = "2026-04-21" +DB_SCHEMA_VERSION = "20260421" + +MODULE_VERSIONS = { + "auth": "1.0.0", + "profiles": "1.0.0", + "clubs": "0.1.0", + "groups": "0.1.0", + "skills": "0.1.0", + "methods": "0.1.0", + "exercises": "0.1.0", + "training_units": "0.1.0", + "training_programs": "0.1.0", + "planning": "0.1.0", + "import_wiki": "0.1.0", + "admin": "1.0.0", + "membership": "1.0.0", +} + +CHANGELOG = [ + { + "version": "0.1.0", + "date": "2026-04-21", + "changes": [ + "Initial MVP Setup", + "Feature: Übungsverwaltung (Kern-Modul)", + "Feature: Fähigkeiten- und Methodenkataloge", + "Feature: Trainingsplanung für Gruppen", + "Feature: Trainingsabschnitte mit Kombinations-Flag", + "Feature: MediaWiki-Import (einseitig)", + "Feature: Freigabelogik (privat/Verein/offiziell)", + ] + } +] +``` + +--- + +## 11. Umsetzungs-Reihenfolge (Empfehlung) + +### Phase 1: Basis (2-3 Tage) +- Repository + Docker-Setup +- Auth + Membership von Mitai kopieren +- DB-Migrationen 001-003 +- Version.py anpassen + +### Phase 2: Kataloge (2 Tage) +- Skills CRUD (Admin) +- Methods CRUD (Admin) +- Frontend Admin-Pages + +### Phase 3: Übungen (5-7 Tage) +- Exercises CRUD +- Exercise-Skills M:N +- Exercise-Variants +- Exercise-Media +- Suche + Filter +- Druckansicht + +### Phase 4: Trainingsplanung (5-7 Tage) +- Training-Groups CRUD +- Training-Templates/Standards +- Training-Sections +- Training-Units +- Kalenderansicht +- Mobile Durchführungssicht + +### Phase 5: Import (3-4 Tage) +- MediaWiki-Parser +- Import-Workflow (Skills, Methods, Exercises) +- Import-Log + Duplikat-Erkennung + +### Phase 6: Governance (2-3 Tage) +- Freigabelogik +- Change-Requests (Basis) +- Eigene vs. Vereins-Inhalte + +**Geschätzte Gesamtdauer MVP:** 4-5 Wochen + +--- + +## 12. Bewusst nur vorbereitet, nicht umgesetzt (in MVP) + +Diese Elemente sollen durch das Datenmodell **nicht verbaut** werden, gehören aber **nicht in MVP**: + +**Reifegradmodelle:** +- Skill-Levels pro Modell +- Maturity-Stages +- Target-Descriptions pro Level + +**Individuelle Sportler-Entwicklung:** +- Athlete-Profiles +- Skill-Progress-Tracking +- Assessment-History + +**Erweiterte Governance:** +- Workflow-basierte Freigabe +- Review-Zyklen +- Versionierung mit Branches + +**Community:** +- Shared Content-Pool +- Bewertungen +- Kommentare + +**KI:** +- Trainingsplanung +- Übungssuche mit Semantik +- Automatische Programm-Generierung + +--- + +## 13. Offene Punkte / Entscheidungsbedarf + +``` +🤔 MediaWiki-Parser: Welches Format nutzt das aktuelle Wiki? (Semantic MediaWiki) +🤔 Medien-Upload: Lokal oder S3/Cloud? +🤔 Suche: PostgreSQL Full-Text oder ElasticSearch später? +🤔 Offline: Service Worker + IndexedDB oder nur gute Mobile-UX? +🤔 Druckformat: PDF-Export oder nur HTML-Print? +🤔 Sparten: Schon in MVP oder erst später? +``` + +--- + +## 14. Nächste konkrete Schritte + +1. **Repository erstellen** auf Gitea: `shinkan-jinkendo` +2. **Basis-Dateien kopieren** aus Mitai: + - `backend/auth.py`, `db.py`, `db_init.py` + - `frontend/src/context/`, `utils/api.js` +3. **Migrationen schreiben** (001-007) +4. **Version.py anpassen** +5. **CLAUDE.md erstellen** für Shinkan +6. **Erste Router implementieren:** + - `skills.py` + - `methods.py` + - `exercises.py` + +--- + +**Version:** 2.0 (komplett überarbeitet) +**Stand:** 21.04.2026 +**Autor:** Claude Code (basierend auf Anforderungsdokument) diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index 8ed8713..017ab32 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -5,6 +5,7 @@ Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG. """ from __future__ import annotations +import copy from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator @@ -25,6 +26,7 @@ __all__ = [ "coalesce_effective_layout", "default_layout_dict", "lab_default_layout_dict", + "merge_missing_catalog_widgets", "product_default_layout_dict", ] @@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]: return product_default_layout_dict() +def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]: + """ + Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config). + Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in + „Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen. + """ + out = copy.deepcopy(layout) + widgets: list[dict[str, Any]] = list(out.get("widgets") or []) + seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")} + for e in WIDGET_CATALOG: + wid = e["id"] + if wid not in seen: + widgets.append({"id": wid, "enabled": False, "config": {}}) + seen.add(wid) + out["version"] = out.get("version", 1) + out["widgets"] = widgets + return out + + class DashboardWidgetEntry(BaseModel): id: str = Field(min_length=1, max_length=64) enabled: bool = True diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 729c1ea..9e8a4a0 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -14,6 +14,11 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072 WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", + "body_history_viz", + "nutrition_history_viz", + "fitness_history_viz", + "recovery_history_viz", + "history_overview_viz", "activity_overview", "kpi_board", "quick_capture", @@ -32,6 +37,141 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ _KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) _KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") +_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_goals_strip", + "show_intro_blurb", + "show_layer_meta", + "show_kpis", + "show_weight_chart", + "show_body_fat_chart", + "show_proportion_chart", + "show_circumference_index_chart", + "show_circumference_lines_chart", +}) + +_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_goals_strip": False, + "show_intro_blurb": False, + "show_layer_meta": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_weight_chart": True, + "show_body_fat_chart": False, + "show_proportion_chart": False, + "show_circumference_index_chart": False, + "show_circumference_lines_chart": False, +} + +_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_goals_strip", + "show_intro_blurb", + "show_kpis", + "show_kcal_vs_weight", + "show_calorie_balance_chart", + "show_protein_lean_chart", + "show_heuristics", + "show_macro_daily_bars", + "show_macro_distribution_pair", + "show_energy_protein_charts", +}) + +_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_goals_strip": False, + "show_intro_blurb": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_kcal_vs_weight": True, + "show_calorie_balance_chart": False, + "show_protein_lean_chart": False, + "show_heuristics": False, + "show_macro_daily_bars": True, + "show_macro_distribution_pair": True, + "show_energy_protein_charts": False, +} + +_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_layer_meta", + "show_kpis", + "show_progress_insights", + "show_chart_training_volume", + "show_chart_training_type_distribution", + "show_chart_quality_sessions", + "show_chart_load_monitoring", +}) + +_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_layer_meta": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_progress_insights": False, + "show_chart_training_volume": True, + "show_chart_training_type_distribution": True, + "show_chart_quality_sessions": False, + "show_chart_load_monitoring": False, +} + +_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_layer_meta", + "show_kpis", + "show_progress_insights", + "show_sleep_section_heading", + "show_chart_recovery_score", + "show_chart_sleep_quality", + "show_chart_sleep_debt", + "show_heart_section_heading", + "show_heart_context_card", + "show_chart_hrv_rhr", + "show_vitals_extra_heading", + "show_vitals_extra_trends", +}) + +_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_layer_meta": False, + "show_kpis": True, + "kpi_detail": "compact", + "show_progress_insights": False, + "show_sleep_section_heading": True, + "show_chart_recovery_score": True, + "show_chart_sleep_quality": True, + "show_chart_sleep_debt": False, + "show_heart_section_heading": True, + "show_heart_context_card": False, + "show_chart_hrv_rhr": True, + "show_vitals_extra_heading": False, + "show_vitals_extra_trends": False, +} + +_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({ + "show_section_body", + "show_section_nutrition", + "show_section_fitness", + "show_section_recovery", +}) + +_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_confidence_banner", + "show_intro_blurb", + *_HISTORY_OVERVIEW_VIZ_SECTION_KEYS, + "show_correlation_c1_c3", + "show_drivers_c4", +}) + +_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = { + "chart_days": 30, + "show_confidence_banner": True, + "show_intro_blurb": True, + "show_section_body": True, + "show_section_nutrition": True, + "show_section_fitness": True, + "show_section_recovery": True, + "show_correlation_c1_c3": True, + "show_drivers_c4": True, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -39,19 +179,42 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int: def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: if raw is None: - return {} + raw = {} if not isinstance(raw, dict): raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein") if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES: raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)") - if not raw: - return {} if widget_id not in WIDGETS_ALLOWING_CONFIG: - raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + if raw: + raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + return {} + + if not raw: + if widget_id == "body_history_viz": + return _validate_body_history_viz_config({}) + if widget_id == "nutrition_history_viz": + return _validate_nutrition_history_viz_config({}) + if widget_id == "fitness_history_viz": + return _validate_fitness_history_viz_config({}) + if widget_id == "recovery_history_viz": + return _validate_recovery_history_viz_config({}) + if widget_id == "history_overview_viz": + return _validate_history_overview_viz_config({}) + return {} if widget_id == "body_overview": return _validate_chart_days_only(raw, label="body_overview") + if widget_id == "body_history_viz": + return _validate_body_history_viz_config(raw) + if widget_id == "nutrition_history_viz": + return _validate_nutrition_history_viz_config(raw) + if widget_id == "fitness_history_viz": + return _validate_fitness_history_viz_config(raw) + if widget_id == "recovery_history_viz": + return _validate_recovery_history_viz_config(raw) + if widget_id == "history_overview_viz": + return _validate_history_overview_viz_config(raw) if widget_id == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -150,6 +313,210 @@ def _parse_chart_days(v: Any, label: str) -> int: raise ValueError(f"{label}: chart_days muss ganze Zahl sein") +def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "body_history_viz" + allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS) + for k in _BODY_HISTORY_VIZ_BOOL_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "kpi_detail" in raw: + kd = raw["kpi_detail"] + if kd not in ("compact", "full"): + raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") + out["kpi_detail"] = kd + if "chart_days" in raw: + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + if not out["show_kpis"] and not any( + out[k] + for k in ( + "show_weight_chart", + "show_body_fat_chart", + "show_proportion_chart", + "show_circumference_index_chart", + "show_circumference_lines_chart", + ) + ): + raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein") + return out + + +def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "nutrition_history_viz" + allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS) + for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "kpi_detail" in raw: + kd = raw["kpi_detail"] + if kd not in ("compact", "full"): + raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") + out["kpi_detail"] = kd + if "chart_days" in raw: + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + if not out["show_kpis"] and not any( + out[k] + for k in ( + "show_kcal_vs_weight", + "show_calorie_balance_chart", + "show_protein_lean_chart", + "show_heuristics", + "show_macro_daily_bars", + "show_macro_distribution_pair", + "show_energy_protein_charts", + ) + ): + raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein") + return out + + +def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "fitness_history_viz" + allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS) + for k in _FITNESS_HISTORY_VIZ_BOOL_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "kpi_detail" in raw: + kd = raw["kpi_detail"] + if kd not in ("compact", "full"): + raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") + out["kpi_detail"] = kd + if "chart_days" in raw: + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + if not out["show_kpis"] and not out["show_progress_insights"] and not any( + out[k] + for k in ( + "show_chart_training_volume", + "show_chart_training_type_distribution", + "show_chart_quality_sessions", + "show_chart_load_monitoring", + ) + ): + raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein") + return out + + +def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "recovery_history_viz" + allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS) + for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "kpi_detail" in raw: + kd = raw["kpi_detail"] + if kd not in ("compact", "full"): + raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein") + out["kpi_detail"] = kd + if "chart_days" in raw: + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[ + "show_vitals_extra_trends" + ] and not any( + out[k] + for k in ( + "show_chart_recovery_score", + "show_chart_sleep_quality", + "show_chart_sleep_debt", + "show_chart_hrv_rhr", + ) + ): + raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein") + return out + + +def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]: + """Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt).""" + r = dict(raw) + if "show_area_summaries" not in r: + return r + leg = r.pop("show_area_summaries") + if not isinstance(leg, bool): + raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)") + for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS: + if k not in r: + r[k] = leg + return r + + +def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "history_overview_viz" + raw_m = _migrate_history_overview_viz_raw(raw) + allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"}) + unknown = set(raw_m) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS) + for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS: + if k not in raw_m: + continue + v = raw_m[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + if "chart_days" in raw_m: + v = _parse_chart_days(raw_m["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS) + has_other = any( + out[k] + for k in ( + "show_confidence_banner", + "show_correlation_c1_c3", + "show_drivers_c4", + ) + ) + if not has_section and not has_other: + raise ValueError( + f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein" + ) + return out + + def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/data_layer/correlation_chart_payloads.py b/backend/data_layer/correlation_chart_payloads.py new file mode 100644 index 0000000..b2ea43f --- /dev/null +++ b/backend/data_layer/correlation_chart_payloads.py @@ -0,0 +1,256 @@ +""" +Chart.js-kompatible Payloads für Lag-Korrelationen C1–C3 und Treiber C4. + +Gemeinsame Quelle für GET /charts/* und history_overview_viz.chart_payloads (Issue 53). +""" + +from __future__ import annotations + +from typing import Any, Dict + +from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers + + +def build_weight_energy_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]: + corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag) + + if not corr_data or corr_data.get("correlation") is None: + msg = "Nicht genug Daten für Korrelationsanalyse" + if isinstance(corr_data, dict): + msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg) + return { + "chart_type": "scatter", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0, + "message": msg, + "lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None, + "tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None, + }, + } + + best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0)) + correlation = corr_data.get("correlation", 0) + + return { + "chart_type": "scatter", + "data": { + "labels": [f"Lag {best_lag} Tage"], + "datasets": [ + { + "label": "Korrelation", + "data": [{"x": best_lag, "y": correlation}], + "backgroundColor": "#1D9E75", + "borderColor": "#085041", + "borderWidth": 2, + "pointRadius": 8, + } + ], + }, + "metadata": { + "confidence": corr_data.get("confidence", "low"), + "correlation": round(float(correlation), 3), + "best_lag_days": best_lag, + "interpretation": corr_data.get("interpretation", ""), + "data_points": corr_data.get("data_points", 0), + "lag_details": corr_data.get("lag_details"), + "tdee_kcal_used": corr_data.get("tdee_kcal_used"), + "layer_1": "correlations._correlate_energy_weight", + }, + } + + +def build_lbm_protein_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]: + corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag) + + if not corr_data or corr_data.get("correlation") is None: + msg = "Nicht genug Daten für LBM-Protein Korrelation" + if isinstance(corr_data, dict): + msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg) + return { + "chart_type": "scatter", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0, + "message": msg, + "lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None, + }, + } + + best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0)) + correlation = corr_data.get("correlation", 0) + + return { + "chart_type": "scatter", + "data": { + "labels": [f"Lag {best_lag} Tage"], + "datasets": [ + { + "label": "Korrelation", + "data": [{"x": best_lag, "y": correlation}], + "backgroundColor": "#3B82F6", + "borderColor": "#1E40AF", + "borderWidth": 2, + "pointRadius": 8, + } + ], + }, + "metadata": { + "confidence": corr_data.get("confidence", "low"), + "correlation": round(float(correlation), 3), + "best_lag_days": best_lag, + "interpretation": corr_data.get("interpretation", ""), + "data_points": corr_data.get("data_points", 0), + "lag_details": corr_data.get("lag_details"), + "layer_1": "correlations._correlate_protein_lbm", + }, + } + + +def build_load_vitals_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]: + corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag) + corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag) + + def _abs_corr(c: Any) -> float: + if not c or c.get("correlation") is None: + return -1.0 + try: + return abs(float(c["correlation"])) + except (TypeError, ValueError): + return -1.0 + + if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0: + msg = "Nicht genug Daten für Load-Vitals Korrelation" + h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None + r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None + if h_msg or r_msg: + msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}" + return { + "chart_type": "scatter", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": msg, + "lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None, + "lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None, + }, + } + + if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr): + corr_data = corr_hrv + metric_name = "HRV" + else: + corr_data = corr_rhr + metric_name = "RHR" + + if not corr_data or corr_data.get("correlation") is None: + return { + "chart_type": "scatter", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"), + }, + } + + best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0)) + correlation = corr_data.get("correlation", 0) + + return { + "chart_type": "scatter", + "data": { + "labels": [f"Load → {metric_name} (Lag {best_lag}d)"], + "datasets": [ + { + "label": "Korrelation", + "data": [{"x": best_lag, "y": correlation}], + "backgroundColor": "#F59E0B", + "borderColor": "#D97706", + "borderWidth": 2, + "pointRadius": 8, + } + ], + }, + "metadata": { + "confidence": corr_data.get("confidence", "low"), + "correlation": round(float(correlation), 3), + "best_lag_days": best_lag, + "metric": metric_name, + "interpretation": corr_data.get("interpretation", ""), + "data_points": corr_data.get("data_points", 0), + "lag_details": corr_data.get("lag_details"), + "layer_1": "correlations._correlate_load_vitals", + }, + } + + +def build_recovery_performance_chart_payload(profile_id: str) -> Dict[str, Any]: + drivers = calculate_top_drivers(profile_id) + + if not drivers or len(drivers) == 0: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Nicht genug Daten für Driver-Analyse", + }, + } + + hindering = [d for d in drivers if d.get("impact", "") == "hindering"] + helpful = [d for d in drivers if d.get("impact", "") == "helpful"] + + top_hindering = hindering[:3] + top_helpful = helpful[:3] + + labels = [] + values = [] + colors = [] + + for d in top_hindering: + labels.append(f"❌ {d.get('factor', '')}") + values.append(-abs(d.get("score", 0))) + colors.append("#EF4444") + + for d in top_helpful: + labels.append(f"✅ {d.get('factor', '')}") + values.append(abs(d.get("score", 0))) + colors.append("#1D9E75") + + if not labels: + return { + "chart_type": "bar", + "data": {"labels": [], "datasets": []}, + "metadata": { + "confidence": "low", + "data_points": 0, + "message": "Keine signifikanten Treiber gefunden", + }, + } + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Impact Score", + "data": values, + "backgroundColor": colors, + "borderColor": "#085041", + "borderWidth": 1, + } + ], + }, + "metadata": { + "confidence": "medium", + "hindering_count": len(top_hindering), + "helpful_count": len(top_helpful), + "total_factors": len(drivers), + }, + } diff --git a/backend/data_layer/history_overview_viz.py b/backend/data_layer/history_overview_viz.py index 4d278c2..c4ad9e6 100644 --- a/backend/data_layer/history_overview_viz.py +++ b/backend/data_layer/history_overview_viz.py @@ -9,6 +9,12 @@ from __future__ import annotations from typing import Any, Dict, List, Optional from data_layer.body_viz import get_body_history_viz_bundle +from data_layer.correlation_chart_payloads import ( + build_lbm_protein_correlation_chart_payload, + build_load_vitals_correlation_chart_payload, + build_recovery_performance_chart_payload, + build_weight_energy_correlation_chart_payload, +) from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle @@ -181,6 +187,12 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any "drivers": drv_list[:8], }, }, + "chart_payloads": { + "c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14), + "c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14), + "c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14), + "c4_recovery_performance": build_recovery_performance_chart_payload(profile_id), + }, "meta": { "layer_1": "composed_metrics", "layer_2b": "history_overview_viz", diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 940c5d4..a43dc0a 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -14,7 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends from db import get_db, get_cursor, r2d from auth import require_admin, hash_pin from models import AdminProfileUpdate -from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict +from dashboard_layout_schema import ( + ALLOWED_WIDGET_IDS, + DashboardLayoutPayload, + merge_missing_catalog_widgets, + product_default_layout_dict, +) from dashboard_widget_entitlements import widgets_catalog_admin_payload from widget_catalog import WIDGET_CATALOG from widget_feature_requirements_db import ( @@ -184,7 +189,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)): """Aktueller Produkt-Dashboard-Standard (DB oder Code).""" _ = session with get_db() as conn: - layout = get_product_default_base_dict(conn) + layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn)) from_database = get_stored_product_default_validated(conn) is not None code_ref = product_default_layout_dict() return { @@ -217,7 +222,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin _ = session with get_db() as conn: delete_product_default_override(conn) - layout = get_product_default_base_dict(conn) + layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn)) return {"ok": True, "layout": layout, "from_database": False} diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index 3f0aff6..fe8fdc5 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -13,6 +13,7 @@ from dashboard_layout_schema import ( DashboardLayoutPayload, coalesce_effective_layout, lab_default_layout_dict, + merge_missing_catalog_widgets, ) from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload from db import get_cursor, get_db @@ -51,9 +52,11 @@ def get_dashboard_layout( raw = row["dashboard_layout"] if row else None custom, effective = coalesce_effective_layout(raw) with get_db() as conn: - base_product = get_product_default_base_dict(conn) + base_product = merge_missing_catalog_widgets(get_product_default_base_dict(conn)) if not custom: effective = base_product + else: + effective = merge_missing_catalog_widgets(effective) effective = apply_entitlements_to_layout_dict(effective, pid, conn) product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn) lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn) diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 220f8c0..fd72d82 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -69,10 +69,11 @@ from data_layer.recovery_metrics import ( calculate_rhr_vs_baseline_pct, calculate_sleep_debt_hours ) -from data_layer.correlations import ( - calculate_lag_correlation, - calculate_correlation_sleep_recovery, - calculate_top_drivers +from data_layer.correlation_chart_payloads import ( + build_lbm_protein_correlation_chart_payload, + build_load_vitals_correlation_chart_payload, + build_recovery_performance_chart_payload, + build_weight_energy_correlation_chart_payload, ) from data_layer.utils import serialize_dates, safe_float, calculate_confidence from data_layer.nutrition_chart_payloads import ( @@ -362,7 +363,8 @@ def get_history_overview_viz( session: dict = Depends(require_auth), ) -> Dict: """ - Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1–C4 (Metadaten). + Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles, + Lag-Korrelationen C1–C4 (Metadaten) und Chart.js-Payloads C1–C4 (chart_payloads, wie /charts/*). """ profile_id = session["profile_id"] bundle = get_history_overview_viz_bundle(profile_id, days) @@ -1111,58 +1113,7 @@ def get_weight_energy_correlation_chart( Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] - - corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag) - - if not corr_data or corr_data.get('correlation') is None: - msg = "Nicht genug Daten für Korrelationsanalyse" - if isinstance(corr_data, dict): - msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg) - return { - "chart_type": "scatter", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0, - "message": msg, - "lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None, - "tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None, - } - } - - # Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53) - best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0)) - correlation = corr_data.get('correlation', 0) - - return { - "chart_type": "scatter", - "data": { - "labels": [f"Lag {best_lag} Tage"], - "datasets": [ - { - "label": "Korrelation", - "data": [{"x": best_lag, "y": correlation}], - "backgroundColor": "#1D9E75", - "borderColor": "#085041", - "borderWidth": 2, - "pointRadius": 8 - } - ] - }, - "metadata": { - "confidence": corr_data.get('confidence', 'low'), - "correlation": round(float(correlation), 3), - "best_lag_days": best_lag, - "interpretation": corr_data.get('interpretation', ''), - "data_points": corr_data.get('data_points', 0), - "lag_details": corr_data.get("lag_details"), - "tdee_kcal_used": corr_data.get("tdee_kcal_used"), - "layer_1": "correlations._correlate_energy_weight", - } - } + return build_weight_energy_correlation_chart_payload(profile_id, max_lag) @router.get("/lbm-protein-correlation") @@ -1183,55 +1134,7 @@ def get_lbm_protein_correlation_chart( Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] - - corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag) - - if not corr_data or corr_data.get('correlation') is None: - msg = "Nicht genug Daten für LBM-Protein Korrelation" - if isinstance(corr_data, dict): - msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg) - return { - "chart_type": "scatter", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0, - "message": msg, - "lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None, - } - } - - best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0)) - correlation = corr_data.get('correlation', 0) - - return { - "chart_type": "scatter", - "data": { - "labels": [f"Lag {best_lag} Tage"], - "datasets": [ - { - "label": "Korrelation", - "data": [{"x": best_lag, "y": correlation}], - "backgroundColor": "#3B82F6", - "borderColor": "#1E40AF", - "borderWidth": 2, - "pointRadius": 8 - } - ] - }, - "metadata": { - "confidence": corr_data.get('confidence', 'low'), - "correlation": round(float(correlation), 3), - "best_lag_days": best_lag, - "interpretation": corr_data.get('interpretation', ''), - "data_points": corr_data.get('data_points', 0), - "lag_details": corr_data.get("lag_details"), - "layer_1": "correlations._correlate_protein_lbm", - } - } + return build_lbm_protein_correlation_chart_payload(profile_id, max_lag) @router.get("/load-vitals-correlation") @@ -1252,83 +1155,7 @@ def get_load_vitals_correlation_chart( Chart.js scatter chart with correlation data """ profile_id = session['profile_id'] - - corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag) - corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag) - - def _abs_corr(c): - if not c or c.get("correlation") is None: - return -1.0 - try: - return abs(float(c["correlation"])) - except (TypeError, ValueError): - return -1.0 - - if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0: - msg = "Nicht genug Daten für Load-Vitals Korrelation" - h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None - r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None - if h_msg or r_msg: - msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}" - return { - "chart_type": "scatter", - "data": {"labels": [], "datasets": []}, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": msg, - "lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None, - "lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None, - }, - } - - if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr): - corr_data = corr_hrv - metric_name = "HRV" - else: - corr_data = corr_rhr - metric_name = "RHR" - - if not corr_data or corr_data.get("correlation") is None: - return { - "chart_type": "scatter", - "data": {"labels": [], "datasets": []}, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"), - }, - } - - best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0)) - correlation = corr_data.get('correlation', 0) - - return { - "chart_type": "scatter", - "data": { - "labels": [f"Load → {metric_name} (Lag {best_lag}d)"], - "datasets": [ - { - "label": "Korrelation", - "data": [{"x": best_lag, "y": correlation}], - "backgroundColor": "#F59E0B", - "borderColor": "#D97706", - "borderWidth": 2, - "pointRadius": 8 - } - ] - }, - "metadata": { - "confidence": corr_data.get('confidence', 'low'), - "correlation": round(float(correlation), 3), - "best_lag_days": best_lag, - "metric": metric_name, - "interpretation": corr_data.get('interpretation', ''), - "data_points": corr_data.get('data_points', 0), - "lag_details": corr_data.get("lag_details"), - "layer_1": "correlations._correlate_load_vitals", - } - } + return build_load_vitals_correlation_chart_payload(profile_id, max_lag) @router.get("/recovery-performance") @@ -1347,81 +1174,7 @@ def get_recovery_performance_chart( Chart.js bar chart with top drivers """ profile_id = session['profile_id'] - - # Get top drivers (hindering/helpful factors) - drivers = calculate_top_drivers(profile_id) - - if not drivers or len(drivers) == 0: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "insufficient", - "data_points": 0, - "message": "Nicht genug Daten für Driver-Analyse" - } - } - - # Separate hindering and helpful - hindering = [d for d in drivers if d.get('impact', '') == 'hindering'] - helpful = [d for d in drivers if d.get('impact', '') == 'helpful'] - - # Take top 3 of each - top_hindering = hindering[:3] - top_helpful = helpful[:3] - - labels = [] - values = [] - colors = [] - - for d in top_hindering: - labels.append(f"❌ {d.get('factor', '')}") - values.append(-abs(d.get('score', 0))) # Negative for hindering - colors.append("#EF4444") - - for d in top_helpful: - labels.append(f"✅ {d.get('factor', '')}") - values.append(abs(d.get('score', 0))) # Positive for helpful - colors.append("#1D9E75") - - if not labels: - return { - "chart_type": "bar", - "data": { - "labels": [], - "datasets": [] - }, - "metadata": { - "confidence": "low", - "data_points": 0, - "message": "Keine signifikanten Treiber gefunden" - } - } - - return { - "chart_type": "bar", - "data": { - "labels": labels, - "datasets": [ - { - "label": "Impact Score", - "data": values, - "backgroundColor": colors, - "borderColor": "#085041", - "borderWidth": 1 - } - ] - }, - "metadata": { - "confidence": "medium", - "hindering_count": len(top_hindering), - "helpful_count": len(top_helpful), - "total_factors": len(drivers) - } - } + return build_recovery_performance_chart_payload(profile_id) # ── Health Endpoint ────────────────────────────────────────────────────────── diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py index 60bc7ac..31112cc 100644 --- a/backend/tests/test_dashboard_layout_schema.py +++ b/backend/tests/test_dashboard_layout_schema.py @@ -5,6 +5,7 @@ from dashboard_layout_schema import ( DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict, + merge_missing_catalog_widgets, ) from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS @@ -56,3 +57,19 @@ def test_coalesce_valid_raw(): custom, eff = coalesce_effective_layout(raw) assert custom is True assert eff == raw + + +def test_merge_missing_catalog_widgets_keeps_order_and_fills_ids(): + raw = { + "version": 1, + "widgets": [ + {"id": "kpi_board", "enabled": True, "config": {}}, + {"id": "welcome", "enabled": False, "config": {}}, + ], + } + merged = merge_missing_catalog_widgets(raw) + assert [w["id"] for w in merged["widgets"][:2]] == ["kpi_board", "welcome"] + assert {w["id"] for w in merged["widgets"]} == ALLOWED_WIDGET_IDS + extra = [w for w in merged["widgets"] if w["id"] not in ("kpi_board", "welcome")] + assert all(w["enabled"] is False for w in extra) + DashboardLayoutPayload.model_validate(merged) diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 9f30f19..b39a998 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -14,6 +14,196 @@ def test_body_chart_days_bounds(): validate_widget_entry_config("body_overview", {"chart_days": 91}) +def test_body_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("body_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_weight_chart"] is True + assert d["kpi_detail"] == "compact" + assert d["show_body_fat_chart"] is False + + +def test_body_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("body_history_viz", {"chart_days": 60}) + assert d["chart_days"] == 60 + assert d["show_goals_strip"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("body_history_viz", {"chart_days": 5}) + + +def test_body_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "body_history_viz", + {"show_kpis": False, "show_weight_chart": False}, + ) + + +def test_body_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("body_history_viz", {"evil": True}) + + +def test_nutrition_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("nutrition_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_kcal_vs_weight"] is True + assert d["kpi_detail"] == "compact" + assert d["show_calorie_balance_chart"] is False + assert d["show_energy_protein_charts"] is False + + +def test_nutrition_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45}) + assert d["chart_days"] == 45 + assert d["show_goals_strip"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5}) + + +def test_nutrition_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "nutrition_history_viz", + {"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False}, + ) + + +def test_nutrition_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("nutrition_history_viz", {"evil": True}) + + +def test_fitness_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("fitness_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_chart_training_volume"] is True + assert d["kpi_detail"] == "compact" + assert d["show_layer_meta"] is False + assert d["show_chart_load_monitoring"] is False + + +def test_fitness_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60}) + assert d["chart_days"] == 60 + assert d["show_progress_insights"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("fitness_history_viz", {"chart_days": 5}) + + +def test_fitness_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "fitness_history_viz", + { + "show_kpis": False, + "show_progress_insights": False, + "show_chart_training_volume": False, + "show_chart_training_type_distribution": False, + "show_chart_quality_sessions": False, + "show_chart_load_monitoring": False, + }, + ) + + +def test_fitness_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("fitness_history_viz", {"evil": True}) + + +def test_recovery_history_viz_empty_expands_defaults(): + d = validate_widget_entry_config("recovery_history_viz", {}) + assert d["chart_days"] == 30 + assert d["show_kpis"] is True + assert d["show_chart_recovery_score"] is True + assert d["kpi_detail"] == "compact" + assert d["show_heart_context_card"] is False + assert d["show_vitals_extra_trends"] is False + + +def test_recovery_history_viz_chart_days_and_merge(): + d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42}) + assert d["chart_days"] == 42 + assert d["show_layer_meta"] is False + with pytest.raises(ValueError): + validate_widget_entry_config("recovery_history_viz", {"chart_days": 3}) + + +def test_recovery_history_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "recovery_history_viz", + { + "show_kpis": False, + "show_progress_insights": False, + "show_heart_context_card": False, + "show_vitals_extra_trends": False, + "show_chart_recovery_score": False, + "show_chart_sleep_quality": False, + "show_chart_sleep_debt": False, + "show_chart_hrv_rhr": False, + }, + ) + + +def test_recovery_history_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("recovery_history_viz", {"evil": True}) + + +def test_history_overview_viz_empty_expands_defaults(): + d = validate_widget_entry_config("history_overview_viz", {}) + assert d["chart_days"] == 30 + assert d["show_confidence_banner"] is True + assert d["show_section_body"] is True + assert d["show_section_nutrition"] is True + assert d["show_section_fitness"] is True + assert d["show_section_recovery"] is True + assert d["show_correlation_c1_c3"] is True + assert d["show_drivers_c4"] is True + + +def test_history_overview_viz_chart_days_and_merge(): + d = validate_widget_entry_config("history_overview_viz", {"chart_days": 60}) + assert d["chart_days"] == 60 + assert d["show_intro_blurb"] is True + with pytest.raises(ValueError): + validate_widget_entry_config("history_overview_viz", {"chart_days": 5}) + + +def test_history_overview_viz_requires_visible_block(): + with pytest.raises(ValueError): + validate_widget_entry_config( + "history_overview_viz", + { + "show_confidence_banner": False, + "show_section_body": False, + "show_section_nutrition": False, + "show_section_fitness": False, + "show_section_recovery": False, + "show_correlation_c1_c3": False, + "show_drivers_c4": False, + }, + ) + + +def test_history_overview_viz_legacy_show_area_summaries_maps_sections(): + d = validate_widget_entry_config( + "history_overview_viz", + {"show_area_summaries": False, "show_correlation_c1_c3": True}, + ) + assert d["show_section_body"] is False + assert d["show_section_fitness"] is False + assert d["show_correlation_c1_c3"] is True + + +def test_history_overview_viz_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("history_overview_viz", {"evil": True}) + + def test_welcome_config_rejected_unknown_key(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/tests/test_system_dashboard_product_default.py b/backend/tests/test_system_dashboard_product_default.py index 8bd9af6..4b3e662 100644 --- a/backend/tests/test_system_dashboard_product_default.py +++ b/backend/tests/test_system_dashboard_product_default.py @@ -32,7 +32,9 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch): "version": 1, "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], } - DashboardLayoutPayload.model_validate(small) + # Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert + # (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz / recovery_history_viz: leere config → volle Defaults in to_stored_dict). + expected = DashboardLayoutPayload.model_validate(small).to_stored_dict() class _Cur: def execute(self, *a, **k): @@ -42,4 +44,4 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch): return {"value": small} monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) - assert get_product_default_base_dict(object()) == small + assert get_product_default_base_dict(object()) == expected diff --git a/backend/version.py b/backend/version.py index 7393c96..e2fa4c9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -24,13 +24,13 @@ MODULE_VERSIONS = { "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", - "admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041) + "admin": "1.4.1", # Produkt-Dashboard-Standard GET/DELETE: merge_missing_catalog_widgets "stats": "1.0.1", "exportdata": "1.1.0", "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog + "app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_* "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) } diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index f89a908..e83720d 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -42,6 +42,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries", "requires_feature": "weight_entries", }, + { + "id": "body_history_viz", + "title": "Körper (Verlauf-Bundle)", + "description": "Layer-2b body-history-viz: schlanker Standard (KPI kompakt + Gewicht); optional Blöcke/Charts per config (show_* , kpi_detail); chart_days 7–90; Feature weight_entries", + "requires_feature": "weight_entries", + }, { "id": "activity_overview", "title": "Aktivität", @@ -94,6 +100,28 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries", "requires_feature": "nutrition_entries", }, + { + "id": "nutrition_history_viz", + "title": "Ernährung (Verlauf-Bundle)", + "description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, + { + "id": "fitness_history_viz", + "title": "Fitness (Verlauf-Bundle)", + "description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature activity_entries", + "requires_feature": "activity_entries", + }, + { + "id": "recovery_history_viz", + "title": "Erholung (Verlauf-Bundle)", + "description": "Layer-2b recovery-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90", + }, + { + "id": "history_overview_viz", + "title": "Verlauf — Gesamtübersicht", + "description": "Layer-2b history-overview-viz: Kurzinfos pro Bereich (show_section_body/nutrition/fitness/recovery) + C1–C4; chart_payloads; chart_days 7–90", + }, { "id": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f1ad5c1..4e397fe 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -79,6 +79,7 @@ function Nav({ isAdmin }) { 'nav-item' + diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index 2e99b8a..bacd287 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -35,6 +35,7 @@ export default function DesktopSidebar({ 'desktop-sidebar__link' + diff --git a/frontend/src/components/FitnessDashboardOverview.jsx b/frontend/src/components/FitnessDashboardOverview.jsx index 447a4b2..bf6d4fa 100644 --- a/frontend/src/components/FitnessDashboardOverview.jsx +++ b/frontend/src/components/FitnessDashboardOverview.jsx @@ -17,6 +17,11 @@ import { import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor } from '../utils/interpret' +import { + FITNESS_HISTORY_VIZ_HISTORY_FULL, + filterFitnessHistoryKpiTiles, + normalizeFitnessHistoryVizConfig, +} from '../widgetSystem/fitnessHistoryVizConfig' import dayjs from 'dayjs' const PERIODS = [ @@ -28,21 +33,35 @@ const PERIODS = [ /** * Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics). + * @param {number} [props.externalPeriod] — feste Tage (z. B. Dashboard-Widget 7–90) + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {import('react').ReactNode} [props.footer] */ export default function FitnessDashboardOverview({ period: periodProp, onPeriodChange, hidePeriodSelector = false, + externalPeriod, + embedded = false, + visibility, + footer = null, }) { const nav = useNavigate() const [internalPeriod, setInternalPeriod] = useState(28) const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' - const period = controlled ? periodProp : internalPeriod - const setPeriod = controlled ? onPeriodChange : setInternalPeriod + const period = + externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod + const setPeriod = + externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod const [viz, setViz] = useState(null) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) + const display = visibility === undefined ? FITNESS_HISTORY_VIZ_HISTORY_FULL : normalizeFitnessHistoryVizConfig(visibility) + const chartH = embedded ? 176 : 200 + const chartLoadH = embedded ? 200 : 220 + useEffect(() => { let cancelled = false setLoading(true) @@ -63,10 +82,13 @@ export default function FitnessDashboardOverview({ } }, [period]) + const outerClass = embedded ? '' : 'card section-gap' + const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled + if (loading) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}
) @@ -74,8 +96,8 @@ export default function FitnessDashboardOverview({ if (err) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}
{err}
) @@ -83,8 +105,8 @@ export default function FitnessDashboardOverview({ if (!viz?.has_activity_entries) { return ( -
-
Fitness-Übersicht
+
+ {!embedded &&
Fitness-Übersicht
}

Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.

@@ -130,11 +152,14 @@ export default function FitnessDashboardOverview({ })) const loadMeta = loadCh?.metadata || {} - const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ + const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, })) + const kpiTilesShown = display.show_kpis + ? filterFitnessHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') + : [] const insights = viz.progress_insights || [] const eff = viz.effective_window_days @@ -142,49 +167,59 @@ export default function FitnessDashboardOverview({ const dTyp = viz.training_type_dist_days_used const loadDays = viz.load_chart_days_used - const showPeriodDropdown = !hidePeriodSelector && !controlled + const gridWrapStyle = { width: '100%', minWidth: 0 } return ( -
-
- Fitness-Übersicht - {showPeriodDropdown ? ( - - ) : null} -
+ Zeitraum + + + ) : null} +
+ )} -

- Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. {eff} Tage · Volumen{' '} - {wUsed} Wochen · Kategorien {dTyp} Tage · Load-Zeitreihe{' '} - {loadDays ?? '—'} Tage - {viz.last_updated ? ( - <> - {' '} - · letzte Aktivität {viz.last_updated} - - ) : null} - . -

+ {embedded && viz?.last_updated ? ( +
+ Letzte Aktivität {viz.last_updated} +
+ ) : null} - + {display.show_layer_meta ? ( +

+ Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. {eff} Tage · Volumen{' '} + {wUsed} Wochen · Kategorien {dTyp} Tage · Load-Zeitreihe{' '} + {loadDays ?? '—'} Tage + {viz.last_updated ? ( + <> + {' '} + · letzte Aktivität {viz.last_updated} + + ) : null} + . +

+ ) : null} - {insights.length > 0 ? ( + {kpiTilesShown.length > 0 ? : null} + + {display.show_progress_insights && insights.length > 0 ? (
Einschätzungen
@@ -213,136 +248,155 @@ export default function FitnessDashboardOverview({ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16, marginTop: 8, + minWidth: 0, }} > -
-
- Trainingsvolumen (Minuten / Woche) -
- {volRows.length >= 1 ? ( - - - - - - [`${Math.round(v)} min`, 'Volumen']} - /> - - - - ) : ( -
Keine Wochendaten im gewählten Fenster.
- )} -
- -
-
- Training nach Kategorie -
- {pieData.length >= 1 ? ( - - - `${name} ${(percent * 100).toFixed(0)}%`} - /> - - - - ) : ( -
Keine kategorisierten Sessions im Fenster.
- )} -
- -
-
- Qualitäts-Sessions (Schätzung) -
- {qualBar.length >= 1 ? ( - - - - - - - - {qualBar.map((entry, i) => ( - - ))} - - - - ) : ( -
Keine Daten.
- )} -
- -
-
- Belastung (Proxy-Load · duration×RPE / Tag) -
- {loadRows.length >= 1 ? ( - <> - - - - - - - - - -
- ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} ( - {loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy) + {display.show_chart_training_volume ? ( +
+
+ Trainingsvolumen (Minuten / Woche) +
+ {volRows.length >= 1 ? ( +
+ + + + + + [`${Math.round(v)} min`, 'Volumen']} + /> + + +
- - ) : ( -
Keine Load-Daten im Fenster.
- )} -
+ ) : ( +
Keine Wochendaten im gewählten Fenster.
+ )} +
+ ) : null} + + {display.show_chart_training_type_distribution ? ( +
+
+ Training nach Kategorie +
+ {pieData.length >= 1 ? ( +
+ + + `${name} ${(percent * 100).toFixed(0)}%`} + /> + + + +
+ ) : ( +
Keine kategorisierten Sessions im Fenster.
+ )} +
+ ) : null} + + {display.show_chart_quality_sessions ? ( +
+
+ Qualitäts-Sessions (Schätzung) +
+ {qualBar.length >= 1 ? ( +
+ + + + + + + + {qualBar.map((entry, i) => ( + + ))} + + + +
+ ) : ( +
Keine Daten.
+ )} +
+ ) : null} + + {display.show_chart_load_monitoring ? ( +
+
+ Belastung (Proxy-Load · duration×RPE / Tag) +
+ {loadRows.length >= 1 ? ( + <> +
+ + + + + + + + + +
+
+ ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} ( + {loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy) +
+ + ) : ( +
Keine Load-Daten im Fenster.
+ )} +
+ ) : null}
+ + {footer}
) } diff --git a/frontend/src/components/RecoveryCharts.jsx b/frontend/src/components/RecoveryCharts.jsx index 6cad7bd..efe6db2 100644 --- a/frontend/src/components/RecoveryCharts.jsx +++ b/frontend/src/components/RecoveryCharts.jsx @@ -1,8 +1,8 @@ import RecoveryDashboardOverview from './RecoveryDashboardOverview' /** - * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period). + * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper (days → externalPeriod). */ export default function RecoveryCharts({ days = 28 }) { - return + return } diff --git a/frontend/src/components/RecoveryDashboardOverview.jsx b/frontend/src/components/RecoveryDashboardOverview.jsx index f826030..d7f959f 100644 --- a/frontend/src/components/RecoveryDashboardOverview.jsx +++ b/frontend/src/components/RecoveryDashboardOverview.jsx @@ -4,6 +4,11 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG import { api } from '../utils/api' import KpiTilesOverview from './KpiTilesOverview' import { getStatusColor, getStatusBg } from '../utils/interpret' +import { + RECOVERY_HISTORY_VIZ_HISTORY_FULL, + filterRecoveryHistoryKpiTiles, + normalizeRecoveryHistoryVizConfig, +} from '../widgetSystem/recoveryHistoryVizConfig' import dayjs from 'dayjs' const fmtDate = (d) => dayjs(d).format('DD.MM.') @@ -194,17 +199,32 @@ function ChartCard({ title, loading, error, children, description }) { /** * Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). + * @param {number} [props.externalPeriod] — Widget: feste Tage (7–90) + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {import('react').ReactNode} [props.footer] */ export default function RecoveryDashboardOverview({ period: periodProp, onPeriodChange, hidePeriodSelector = false, + externalPeriod, + embedded = false, + visibility, + footer = null, }) { const nav = useNavigate() const [internalPeriod, setInternalPeriod] = useState(28) const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' - const period = controlled ? periodProp : internalPeriod - const setPeriod = controlled ? onPeriodChange : setInternalPeriod + const period = + externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod + const setPeriod = + externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod + + const display = + visibility === undefined ? RECOVERY_HISTORY_VIZ_HISTORY_FULL : normalizeRecoveryHistoryVizConfig(visibility) + const chartH = embedded ? 176 : 200 + const chartHVitals = embedded ? 200 : 220 const [viz, setViz] = useState(null) const [loading, setLoading] = useState(true) @@ -230,10 +250,13 @@ export default function RecoveryDashboardOverview({ } }, [period]) + const outerClass = embedded ? '' : 'card section-gap' + const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled + if (loading) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}
) @@ -241,8 +264,8 @@ export default function RecoveryDashboardOverview({ if (err) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}
{err}
) @@ -250,8 +273,8 @@ export default function RecoveryDashboardOverview({ if (!viz?.has_recovery_data) { return ( -
-
Erholung & Vitalwerte
+
+ {!embedded &&
Erholung & Vitalwerte
}

{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst oder importierst, erscheinen Auswertungen hier. @@ -280,18 +303,19 @@ export default function RecoveryDashboardOverview({ const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2') const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean) - const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ + const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ ...t, sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel, })) + const kpiTilesShown = display.show_kpis + ? filterRecoveryHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') + : [] const insights = viz.progress_insights || [] const eff = viz.effective_window_days const cDays = viz.chart_days_used const vDays = viz.vital_matrix_days_used - const showPeriodDropdown = !hidePeriodSelector && !controlled - const renderRecoveryScore = () => { if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { return ( @@ -306,41 +330,43 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} - tickCount={6} - width={36} - /> - - - - +

+ + + + + (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} + tickCount={6} + width={36} + /> + + + + +
KPI Recovery-Score (aktuell): {recoveryData.metadata.current_score}/100 · Datenpunkte Kurve:{' '} {recoveryData.metadata.data_points} @@ -364,44 +390,46 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - - - - - - - +
+ + + + + + + + + + + +
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
@@ -424,29 +452,31 @@ export default function RecoveryDashboardOverview({ })) return ( <> - - - - - - - - - - - +
+ + + + + + + + + + + +
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
@@ -469,35 +499,37 @@ export default function RecoveryDashboardOverview({ const curDebt = debtData.metadata?.current_debt_hours return ( <> - - - - - - - - - +
+ + + + + + + + + +
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
@@ -577,7 +609,7 @@ export default function RecoveryDashboardOverview({ Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
) : ( -
+
@@ -632,37 +664,43 @@ export default function RecoveryDashboardOverview({ } return ( -
-
- Erholung & Vitalwerte - {showPeriodDropdown ? ( - - ) : null} -
+ Zeitraum + + + ) : null} +
+ )} -

- Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · - Vital-Snapshot {vDays} Tage. -

+ {display.show_layer_meta ? ( +

+ Daten-Layer Auswertung · Fenster ca. {eff} Tage · Chart-Horizont {cDays} Tage · + Vital-Snapshot {vDays} Tage. +

+ ) : null} - + {kpiTilesShown.length > 0 ? ( + + ) : null} - {insights.length > 0 ? ( + {display.show_progress_insights && insights.length > 0 ? (
Überblick: Recovery & Schlaf @@ -690,93 +728,113 @@ export default function RecoveryDashboardOverview({
) : null} - - - {renderRecoveryScore()} - - - {renderSleepQuality()} - - - {renderSleepDebt()} - + {display.show_sleep_section_heading ? ( + + ) : null} + {display.show_chart_recovery_score ? ( + + {renderRecoveryScore()} + + ) : null} + {display.show_chart_sleep_quality ? ( + + {renderSleepQuality()} + + ) : null} + {display.show_chart_sleep_debt ? ( + + {renderSleepDebt()} + + ) : null} - -
-
Einordnung & Kontext
- - {heartSectionInsights.length > 0 ? ( -
- {heartSectionInsights.map((ins) => ( - - ))} -
- ) : null} -
Letzte Messwerte (Zonen)
- - {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( -
- {vitalsData?.metadata?.vitals_measured_at ? ( - <> - Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} - - ) : null} - {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} - {vitalsData?.metadata?.blood_pressure_measured_at ? ( - <> - Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} - - ) : null} -
- ) : null} - {vitalsData?.metadata?.disclaimer_de ? ( -
- {vitalsData.metadata.disclaimer_de} -
- ) : null} -
- - {renderHrvRhr()} - + {display.show_heart_section_heading ? ( + + ) : null} + {display.show_heart_context_card ? ( +
+
Einordnung & Kontext
+ + {heartSectionInsights.length > 0 ? ( +
+ {heartSectionInsights.map((ins) => ( + + ))} +
+ ) : null} +
Letzte Messwerte (Zonen)
+ + {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( +
+ {vitalsData?.metadata?.vitals_measured_at ? ( + <> + Baseline-Vitals: {fmtDate(vitalsData.metadata.vitals_measured_at)} + + ) : null} + {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} + {vitalsData?.metadata?.blood_pressure_measured_at ? ( + <> + Blutdruck: {fmtDate(vitalsData.metadata.blood_pressure_measured_at)} + + ) : null} +
+ ) : null} + {vitalsData?.metadata?.disclaimer_de ? ( +
+ {vitalsData.metadata.disclaimer_de} +
+ ) : null} +
+ ) : null} + {display.show_chart_hrv_rhr ? ( + + {renderHrvRhr()} + + ) : null} - -
-
Verläufe
- {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} -
+ {display.show_vitals_extra_heading ? ( + + ) : null} + {display.show_vitals_extra_trends ? ( +
+
Verläufe
+ {renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} +
+ ) : null} + + {footer}
) } diff --git a/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx new file mode 100644 index 0000000..5cb1a9c --- /dev/null +++ b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx @@ -0,0 +1,37 @@ +import { useNavigate } from 'react-router-dom' +import BodyHistoryVizSection from '../history/BodyHistoryVizSection' +import { useProfile } from '../../context/ProfileContext' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeBodyHistoryVizConfig } from '../../widgetSystem/bodyHistoryVizConfig' + +/** + * Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, bodyHistoryVizConfig?: Record }} props + */ +export default function BodyHistoryVizWidget({ refreshTick = 0, bodyHistoryVizConfig }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const cfg = normalizeBodyHistoryVizConfig(bodyHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Körper (Verlauf-Bundle)
+
body-history-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx new file mode 100644 index 0000000..6b57196 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx @@ -0,0 +1,29 @@ +import { useNavigate } from 'react-router-dom' +import FitnessHistoryVizSection from '../history/FitnessHistoryVizSection' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeFitnessHistoryVizConfig } from '../../widgetSystem/fitnessHistoryVizConfig' + +/** + * Verlauf → Fitness als Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, fitnessHistoryVizConfig?: Record }} props + */ +export default function FitnessHistoryVizWidget({ refreshTick = 0, fitnessHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeFitnessHistoryVizConfig(fitnessHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Fitness (Verlauf-Bundle)
+
fitness-dashboard-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx b/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx new file mode 100644 index 0000000..5350390 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx @@ -0,0 +1,35 @@ +import { useNavigate } from 'react-router-dom' +import HistoryOverviewVizSection from '../history/HistoryOverviewVizSection' +import { normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * Verlauf — Gesamtübersicht als Dashboard-Widget: GET /charts/history-overview-viz (inkl. chart_payloads C1–C4). + * @param {{ refreshTick?: number, historyOverviewVizConfig?: Record }} props + */ +export default function HistoryOverviewVizWidget({ refreshTick = 0, historyOverviewVizConfig }) { + const nav = useNavigate() + const cfg = normalizeHistoryOverviewVizConfig(historyOverviewVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Gesamtübersicht (Verlauf)
+
history-overview-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx new file mode 100644 index 0000000..655a440 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx @@ -0,0 +1,34 @@ +import { useNavigate } from 'react-router-dom' +import NutritionHistoryVizSection from '../history/NutritionHistoryVizSection' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeNutritionHistoryVizConfig } from '../../widgetSystem/nutritionHistoryVizConfig' + +/** + * Verlauf → Ernährung als Dashboard-Widget: GET /charts/nutrition-history-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, nutritionHistoryVizConfig?: Record }} props + */ +export default function NutritionHistoryVizWidget({ refreshTick = 0, nutritionHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeNutritionHistoryVizConfig(nutritionHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Ernährung (Verlauf-Bundle)
+
nutrition-history-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx index 218cb81..38c10df 100644 --- a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx +++ b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx @@ -26,7 +26,7 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays } Verlauf →
- +
) } diff --git a/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx new file mode 100644 index 0000000..260b636 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx @@ -0,0 +1,29 @@ +import { useNavigate } from 'react-router-dom' +import RecoveryHistoryVizSection from '../history/RecoveryHistoryVizSection' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeRecoveryHistoryVizConfig } from '../../widgetSystem/recoveryHistoryVizConfig' + +/** + * Verlauf → Erholung als Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b), Umfang über Layout-Config. + * @param {{ refreshTick?: number, recoveryHistoryVizConfig?: Record }} props + */ +export default function RecoveryHistoryVizWidget({ refreshTick = 0, recoveryHistoryVizConfig }) { + const nav = useNavigate() + const cfg = normalizeRecoveryHistoryVizConfig(recoveryHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Erholung (Verlauf-Bundle)
+
recovery-dashboard-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/history/BodyHistoryVizSection.jsx b/frontend/src/components/history/BodyHistoryVizSection.jsx new file mode 100644 index 0000000..05a6ff8 --- /dev/null +++ b/frontend/src/components/history/BodyHistoryVizSection.jsx @@ -0,0 +1,589 @@ +import { useState, useEffect } from 'react' +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + ReferenceLine, + ComposedChart, +} from 'recharts' +import { useNavigate } from 'react-router-dom' +import { ChevronRight } from 'lucide-react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { api } from '../../utils/api' +import { getStatusColor } from '../../utils/interpret' +import KpiTilesOverview from '../KpiTilesOverview' +import { + BODY_HISTORY_VIZ_HISTORY_FULL, + filterBodyHistoryKpiTiles, +} from '../../widgetSystem/bodyHistoryVizConfig' +import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome' + +dayjs.locale('de') + +const fmtDate = (d) => dayjs(d).format('DD.MM') + +/** Recharts: in schmalen Flex-Spalten sonst Breite 0. */ +function ChartFrame({ heightPx, children }) { + return ( +
+ {children} +
+ ) +} + +function verdictShort(status) { + if (status === 'good') return 'Gut' + if (status === 'warn') return 'Hinweis' + return 'Achtung' +} + +/** KPI-Kacheln aus Summary + Interpretationstiles — Trend aus Bundle ``weight.trend_kpi`` (Layer 2b). */ +function buildBodyKpiTiles({ + summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW, +}) { + const tiles = [] + + if (summary.weight_kg != null) { + const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' } + const trendBits = trendPeriods.length + ? trendPeriods.map((t) => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ') + : '' + const hoverBody = [ + 'Gewicht im gewählten Zeitraum (letzter Messwert).', + avgAll != null ? `Durchschnitt: ${avgAll} kg` : null, + minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null, + trendBits ? `Änderung: ${trendBits}` : null, + goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null, + ].filter(Boolean).join('\n') + + tiles.push({ + key: 'weight', + category: 'Gewicht', + icon: '⚖️', + value: `${summary.weight_kg} kg`, + sublabel: dataPoints ? `${dataPoints} Messwerte` : '', + verdict: wt.verdict, + status: wt.status, + hoverTop: 'Gewicht', + hoverBody, + keys: ['weight_aktuell', 'weight_trend'], + }) + } + + const kfRule = rules.find((r) => r.category === 'Körperfett') + if (summary.body_fat_pct != null) { + tiles.push({ + key: 'bf', + category: 'Körperfett', + icon: '🫧', + value: `${summary.body_fat_pct}%`, + valueColor: kfRule ? getStatusColor(kfRule.status) : undefined, + sublabel: summary.bf_category_label || '', + verdict: verdictShort(kfRule?.status || 'good'), + status: kfRule?.status || 'good', + hoverTop: kfRule?.title || 'Körperfettanteil', + hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + const mmRule = rules.find((r) => r.category === 'Muskelmasse') + if (summary.lean_mass_kg != null || summary.ffmi != null) { + const valParts = [] + if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`) + if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`) + tiles.push({ + key: 'lean_ffmi', + category: 'Magermasse', + icon: '💪', + value: valParts.join(' · ') || '—', + sublabel: 'Lean / FFMI', + verdict: mmRule ? verdictShort(mmRule.status) : '—', + status: mmRule?.status || 'good', + hoverTop: mmRule?.title || 'Muskelmasse', + hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + const bmiRule = rules.find((r) => r.category === 'BMI') + if (bmiRule) { + tiles.push({ + key: 'bmi', + category: 'BMI', + icon: '📋', + value: bmiRule.value || '—', + sublabel: 'Body-Mass-Index', + verdict: verdictShort(bmiRule.status), + status: bmiRule.status, + hoverTop: bmiRule.title, + hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + const whrRule = rules.find((r) => r.category === 'Fettverteilung') + if (summary.whr != null && whrRule) { + tiles.push({ + key: 'whr', + category: 'Fettverteilung', + icon: '📐', + value: String(summary.whr), + sublabel: 'WHR · Taille ÷ Hüfte', + verdict: verdictShort(whrRule.status), + status: whrRule.status, + hoverTop: whrRule.title || 'Waist-Hip-Ratio', + hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + const whtrRule = rules.find((r) => r.category === 'Taille/Größe') + if (summary.whtr != null && whtrRule) { + tiles.push({ + key: 'whtr', + category: 'Taille/Größe', + icon: '📏', + value: String(summary.whtr), + sublabel: 'WHtR · Taille ÷ Größe', + verdict: verdictShort(whtrRule.status), + status: whtrRule.status, + hoverTop: whtrRule.title || 'Waist-to-Height-Ratio', + hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + const lastRule = rules.find((r) => r.category.startsWith('Seit letzter')) + if (lastRule) { + tiles.push({ + key: 'delta', + category: 'Messvergleich', + icon: '📊', + value: lastRule.value || '—', + sublabel: 'seit Vorperiode', + verdict: verdictShort(lastRule.status), + status: lastRule.status, + hoverTop: lastRule.title, + hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), + }) + } + + return tiles +} + +function BodyGoalsStrip({ grouped }) { + const nav = useNavigate() + const goals = (grouped?.body || []).filter((g) => g.status === 'active').slice(0, 4) + if (!goals.length) return null + return ( +
+
+
Körperbezogene Ziele
+ +
+
+ {goals.map((g) => ( +
+
+ {g.name || g.label_de || g.goal_type} +
+
+
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value} + {g.unit ? ` ${g.unit}` : ''} +
+
+ ))} +
+
+ ) +} + +/** + * Verlauf → Körper: nur GET /api/charts/body-history-viz (Layer 2b). + * @param {object} props + * @param {object} props.profile — aktives Profil (Zielgewicht-Fallback) + * @param {number} [props.externalPeriod] — festes Fenster (Dashboard); sonst interner Zeitraum + PeriodSelector + * @param {import('react').ReactNode} [props.footer] — z. B. KI-InsightBox im Verlauf + * @param {boolean} [props.embedded] — true im Dashboard-Widget: keine große Section-Überschrift (Karte hat eigenen Titel) + * @param {object} [props.visibility] — Sichtbarkeit (Dashboard-Config); fehlt → wie Verlauf (alles an, kpi_detail full) + */ +export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false, visibility }) { + const display = visibility === undefined ? BODY_HISTORY_VIZ_HISTORY_FULL : visibility + const chartHMain = embedded ? 176 : 200 + const chartHSecondary = embedded ? 152 : 170 + const chartHIndex = embedded ? 160 : 180 + const chartHFallback = embedded ? 140 : 150 + + const [internalPeriod, setInternalPeriod] = useState(90) + const period = externalPeriod !== undefined ? externalPeriod : internalPeriod + const showPeriodSelector = externalPeriod === undefined + + const [groupedGoals, setGroupedGoals] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoading, setVizLoading] = useState(true) + const [vizError, setVizError] = useState(null) + + useEffect(() => { + if (!display.show_goals_strip) { + setGroupedGoals({}) + return undefined + } + let cancelled = false + api.listGoalsGrouped() + .then((g) => { + if (!cancelled) setGroupedGoals(g) + }) + .catch(() => { + if (!cancelled) setGroupedGoals({}) + }) + return () => { + cancelled = true + } + }, [display.show_goals_strip]) + + useEffect(() => { + let cancelled = false + setVizLoading(true) + setVizError(null) + api.getBodyHistoryViz(period) + .then((data) => { + if (!cancelled) { + setViz(data) + setVizLoading(false) + } + }) + .catch((e) => { + if (!cancelled) { + setVizError(e.message || 'Laden fehlgeschlagen') + setVizLoading(false) + } + }) + return () => { + cancelled = true + } + }, [period]) + + const w = viz?.weight + const cal = viz?.caliper + const circ = viz?.circumference + const summary = viz?.summary || {} + + const wCd = (w?.series || []).map((row) => ({ + date: fmtDate(row.date), + weight: row.weight, + avg7: row.avg7, + avg14: row.avg14, + })) + const hasWeight = (w?.data_points || 0) >= 2 + const avgAll = w?.overall_avg_kg + const minW = w?.min_kg + const maxW = w?.max_kg + const trendPeriods = w?.trend_periods || [] + + const bfCd = (cal?.series || []).map((s) => ({ + date: fmtDate(s.date), + bf: s.body_fat_pct, + })) + + const propChartData = (circ?.proportion_series || []).map((p) => ({ + date: fmtDate(p.date), + vTaper: p.v_taper_cm, + vTaper_avg: p.v_taper_cm_avg, + belly: p.belly_cm, + })) + const showBellyOnProp = propChartData.some((d) => d.belly != null && d.belly !== undefined) + + const idxSeriesRaw = circ?.index_series || [] + const idxSeries = idxSeriesRaw.map((row) => ({ ...row, date: fmtDate(row.date) })) + const idxOk = circ?.index_usable + + const cirCd = (circ?.fallback_multiline || []).map((r) => ({ + date: fmtDate(r.date), + waist: r.waist, + hip: r.hip, + belly: r.belly, + })) + + const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight + const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct + + const rules = (viz?.interpretation_tiles || []).map((t) => ({ + category: t.category, + icon: t.icon, + status: t.status, + title: t.title, + detail: t.detail, + value: t.value, + related_placeholder_keys: t.related_placeholder_keys, + })) + + const kpiTiles = buildBodyKpiTiles({ + summary, + rules, + trendPeriods, + minW, + maxW, + avgAll, + dataPoints: w?.data_points, + weightTrendKpi: w?.trend_kpi, + goalW, + }) + const kpiTilesShown = display.show_kpis ? filterBodyHistoryKpiTiles(kpiTiles, display.kpi_detail || 'full') : [] + + const hasAnyData = + (w?.data_points > 0) || + (cal?.data_points > 0) || + (cirCd.length > 0) + + if (vizLoading && !viz) { + return ( +
+ {!embedded && } +
+
+
+
+ ) + } + if (vizError) { + return ( +
+ {!embedded && } +
{vizError}
+
+ ) + } + if (!hasAnyData) { + return ( +
+ {!embedded && } + {showPeriodSelector && } + +
+ ) + } + + return ( +
+ {!embedded && } + {embedded && viz?.last_updated && ( +
+ Stand {dayjs(viz.last_updated).format('DD.MM.YY')} +
+ )} + {showPeriodSelector && } + + {display.show_goals_strip && } + + {display.show_intro_blurb && ( +

+ Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Fitness. +

+ )} + + {display.show_layer_meta && viz?.meta?.layer_2a_alignment && ( +
+ {viz.meta.layer_2a_alignment} +
+ )} + + {kpiTilesShown.length > 0 && } + + {vizLoading &&
Aktualisiere…
} + + {display.show_weight_chart && hasWeight && ( +
+
+
+ Gewicht · {w?.data_points || 0} Einträge +
+ +
+ + + + + + + {avgAll != null && ( + + )} + {goalW != null && ( + + )} + [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} /> + + + + + + +
+ ● Täglich + Ø 7T + + + Ø 14T + + Ø Gesamt +
+
+ )} + + {display.show_body_fat_chart && bfCd.length >= 2 && ( +
+
+
Körperfett (Caliper)
+ +
+ + + + + + + [`${v}%`, 'KF%']} /> + {goalBf != null && } + + + + +
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
+
+ )} + + {display.show_proportion_chart && propChartData.length >= 2 && ( +
+
+
+
Silhouette & Proportion
+
+ V-Taper (Brust − Taille) in cm. + {showBellyOnProp && ( + <> + Bauch (rechte Achse). + + )} +
+
+ +
+ + + + + + + {showBellyOnProp && } + { + if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille'] + if (name === 'belly') return [`${v} cm`, 'Bauch'] + return [v, name] + }} + /> + + + {showBellyOnProp && } + + + +
+ Brust − Taille + gleitender Mittelwert + {showBellyOnProp && Bauch (cm)} +
+
+ )} + + {display.show_circumference_index_chart && idxOk && ( +
+
+
+
Relative Entwicklung der Umfänge
+
Index 100 = erste Messung im Zeitraum.
+
+ +
+ + + + + + + + [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} /> + {idxSeries.some((d) => d.chest_idx != null) && } + {idxSeries.some((d) => d.waist_idx != null) && } + {idxSeries.some((d) => d.belly_idx != null) && } + + + +
+ Brust + Taille + Bauch +
+
+ )} + + {display.show_circumference_lines_chart && propChartData.length < 2 && cirCd.length >= 2 && ( +
+
+
Umfänge (Taille / Hüfte / Bauch)
+ +
+
Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.
+ + + + + + + [`${v} cm`, n]} /> + + + {cirCd.some((d) => d.belly) && } + + + +
+ )} + + {footer} +
+ ) +} diff --git a/frontend/src/components/history/FitnessHistoryVizSection.jsx b/frontend/src/components/history/FitnessHistoryVizSection.jsx new file mode 100644 index 0000000..f07f39f --- /dev/null +++ b/frontend/src/components/history/FitnessHistoryVizSection.jsx @@ -0,0 +1,33 @@ +import FitnessDashboardOverview from '../FitnessDashboardOverview' + +/** + * Verlauf → Fitness bzw. Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b). + * @param {number} [props.externalPeriod] — Widget: feste Tage (7–90) + * @param {number} [props.period] — Verlauf: gesteuerter Zeitraum (inkl. 9999) + * @param {(n: number) => void} [props.onPeriodChange] + * @param {boolean} [props.hidePeriodSelector] + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — undefined = volle Übersicht (wie bisher) + * @param {import('react').ReactNode} [props.footer] + */ +export default function FitnessHistoryVizSection({ + externalPeriod, + period, + onPeriodChange, + hidePeriodSelector, + embedded, + visibility, + footer, +}) { + return ( + + ) +} diff --git a/frontend/src/components/history/HistoryOverviewVizSection.jsx b/frontend/src/components/history/HistoryOverviewVizSection.jsx new file mode 100644 index 0000000..0432f7e --- /dev/null +++ b/frontend/src/components/history/HistoryOverviewVizSection.jsx @@ -0,0 +1,535 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + ReferenceLine, + ComposedChart, + ScatterChart, + Scatter, + Line, + Cell, +} from 'recharts' +import { api } from '../../utils/api' +import { getStatusColor, getStatusBg } from '../../utils/interpret' +import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome' +import { HISTORY_OVERVIEW_VIZ_PAGE_FULL, normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig' + +function overviewSectionTone(sec) { + const kpis = sec.kpi_short || [] + if (kpis.some((k) => k.status === 'bad')) return 'bad' + if (kpis.some((k) => k.status === 'warn')) return 'warn' + const interp = sec.interpretation_short || [] + if (interp.some((x) => x.status === 'bad')) return 'bad' + if (interp.some((x) => x.status === 'warn')) return 'warn' + const heur = sec.heuristic_short || [] + if (heur.some((h) => h.status === 'warn')) return 'warn' + return 'good' +} + +function overviewConfidenceUi(conf) { + if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' } + if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' } + return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' } +} + +function chartJsScatterPoints(payload) { + const raw = payload?.data?.datasets?.[0]?.data || [] + if (!Array.isArray(raw)) return [] + return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) })) +} + +function lagDetailsToCurve(meta) { + let ld = meta?.lag_details + if (!Array.isArray(ld) || ld.length === 0) { + const m = String(meta?.metric || '').toUpperCase() + if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv + else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr + else { + const h = meta?.lag_details_hrv + const r = meta?.lag_details_rhr + const hl = Array.isArray(h) ? h.length : 0 + const rl = Array.isArray(r) ? r.length : 0 + if (hl >= rl && hl > 0) ld = h + else if (rl > 0) ld = r + else ld = [] + } + } + if (!Array.isArray(ld) || ld.length === 0) return [] + return ld + .map((d) => ({ + lag: Number(d?.lag), + r: d?.r == null || d?.r === '' ? null : Number(d.r), + n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null, + })) + .filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r)) + .sort((a, b) => a.lag - b.lag) +} + +function driverBarFromStatus(st) { + const s = String(st || '').toLowerCase() + if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' } + if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' } + return { v: 0.15, fill: '#6B7280' } +} + +function chartJsBarRows(payload, fallbackDrivers) { + const labels = payload?.data?.labels || [] + const values = payload?.data?.datasets?.[0]?.data || [] + const colors = payload?.data?.datasets?.[0]?.backgroundColor + if (labels.length && values.length) { + return labels.map((name, i) => ({ + name: name.length > 42 ? `${name.slice(0, 40)}…` : name, + value: Number(values[i]), + fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75', + })) + } + if (fallbackDrivers?.length) { + return fallbackDrivers.map((d) => { + const { v, fill } = driverBarFromStatus(d.status) + return { + name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'), + value: v, + fill, + subtitle: d.reason, + } + }) + } + return [] +} + +function CorrelationScatterTile({ title, accent, payload }) { + const meta = payload?.metadata || {} + const pts = chartJsScatterPoints(payload) + const curve = lagDetailsToCurve(meta) + const hasChart = pts.length > 0 && meta.correlation != null + const r = Number(meta.correlation) + const strength = + !Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad' + const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null + const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28 + + return ( +
+
{title}
+
+ r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'} + {meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''} + {meta.metric ? ` · ${meta.metric}` : ''} + {meta.confidence ? ` · ${meta.confidence}` : ''} +
+ {!hasChart ? ( + <> +
+ {meta.message || 'Keine Daten für diese Korrelation.'} +
+ {curve.length > 0 && ( +
+ Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung. +
+ )} + {curve.length > 0 && ( + + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + + + + )} + + ) : curve.length >= 1 ? ( + <> +
+ Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag. +
+ + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + { + const { cx, cy, payload: pl } = props + if (cx == null || cy == null || !pl) return null + const isBest = bestLag != null && Number(pl.lag) === bestLag + return ( + + ) + }} + /> + + + + ) : ( + + + + + + + + + + + )} + {meta.interpretation ? ( +
{meta.interpretation}
+ ) : null} +
+ ) +} + +function DriversImpactTile({ payload, driversFallback }) { + const meta = payload?.metadata || {} + const rows = chartJsBarRows(payload, driversFallback) + if (!rows.length) { + return ( +
+
C4 Einflussfaktoren
+
{meta.message || 'Keine Treiber-Daten.'}
+
+ ) + } + const h = Math.min(220, Math.max(96, rows.length * 34)) + return ( +
+
C4 Einflussfaktoren
+ + + + + { + if (!active || !pp?.length) return null + const p = pp[0].payload + return ( +
+
{p.name}
+ {p.subtitle ?
{p.subtitle}
: null} +
+ ) + }} + /> + + {rows.map((e, i) => ( + + ))} + +
+
+
+ ) +} + +/** + * Verlauf «Gesamt» / Dashboard-Widget: Layer-2b history-overview-viz (+ chart_payloads C1–C4). + * + * @param {object} props + * @param {import('react').ReactNode} [props.footer] + * @param {number} [props.externalPeriod] — feste Tage (Widget); sonst interner PeriodSelector (30…9999) + * @param {boolean} [props.hidePeriodSelector] + * @param {boolean} [props.embedded] + * @param {Record} [props.visibility] — normalisierte Widget-Config; undefined = Verlauf volle Ansicht + */ +export default function HistoryOverviewVizSection({ + footer = null, + externalPeriod, + hidePeriodSelector = false, + embedded = false, + visibility: visibilityProp, +}) { + const navigate = useNavigate() + const [period, setPeriod] = useState(30) + const [bundle, setBundle] = useState(null) + const [err, setErr] = useState(null) + const [loading, setLoading] = useState(true) + + const effPeriod = externalPeriod != null ? externalPeriod : period + const daysReq = effPeriod === 9999 ? 3650 : effPeriod + + useEffect(() => { + let cancelled = false + setLoading(true) + + const attachCharts = (overview, c1, c2, c3, c4) => { + if (!cancelled) { + setBundle({ overview, chartC1: c1, chartC2: c2, chartC3: c3, chartC4: c4 }) + setErr(null) + } + } + + const run = async () => { + try { + const overview = await api.getHistoryOverviewViz(daysReq) + const cp = overview?.chart_payloads + if (cp && cp.c1_weight_energy != null && cp.c2_protein_lbm != null && cp.c3_load_vitals != null && cp.c4_recovery_performance != null) { + attachCharts(overview, cp.c1_weight_energy, cp.c2_protein_lbm, cp.c3_load_vitals, cp.c4_recovery_performance) + } else { + const [chartC1, chartC2, chartC3, chartC4] = await Promise.all([ + api.getWeightEnergyCorrelationChart(14), + api.getLbmProteinCorrelationChart(14), + api.getLoadVitalsCorrelationChart(14), + api.getRecoveryPerformanceChart(), + ]) + attachCharts(overview, chartC1, chartC2, chartC3, chartC4) + } + } catch (e) { + if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') + } finally { + if (!cancelled) setLoading(false) + } + } + + run() + return () => { + cancelled = true + } + }, [daysReq]) + + if (loading) { + return ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } +
+
+ ) + } + if (err) { + return ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } +
{err}
+
+ ) + } + + const data = bundle?.overview + const chartC1 = bundle?.chartC1 + const chartC2 = bundle?.chartC2 + const chartC3 = bundle?.chartC3 + const chartC4 = bundle?.chartC4 + + const lag = data?.lag_correlations || {} + const c4drivers = lag.recovery_performance?.drivers || [] + const sections = data?.sections || [] + const confUi = overviewConfidenceUi(data?.confidence) + const vis = + visibilityProp != null ? normalizeHistoryOverviewVizConfig(visibilityProp) : HISTORY_OVERVIEW_VIZ_PAGE_FULL + + const sectionTileEnabled = (id) => { + if (id === 'body') return vis.show_section_body + if (id === 'nutrition') return vis.show_section_nutrition + if (id === 'fitness') return vis.show_section_fitness + if (id === 'recovery') return vis.show_section_recovery + return true + } + const wantsAnySectionTile = + vis.show_section_body || + vis.show_section_nutrition || + vis.show_section_fitness || + vis.show_section_recovery + const visibleSections = wantsAnySectionTile ? sections.filter((s) => sectionTileEnabled(s.id)) : [] + + return ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } + + {vis.show_confidence_banner && ( +
+ {confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'} +
+
{confUi.label}
+
{confUi.hint}
+
+
+ )} + + {vis.show_intro_blurb && ( +

+ KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '} + Ehem. «Korrelation»-Charts (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '} + + . Die Kacheln C1–C4 entsprechen denselben Chart.js-Payloads wie /api/charts/* (bei aktuellem Backend im Overview-Bundle enthalten). +

+ )} + + {wantsAnySectionTile && (visibleSections.length === 0 ? ( + + ) : ( +
+ {visibleSections.map((sec) => { + const tone = overviewSectionTone(sec) + const stripe = getStatusColor(tone) + const badgeBg = getStatusBg(tone) + return ( +
+
+
+ + {tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'} + +
{sec.title}
+
+ +
+
{sec.summary_line}
+ + {(sec.kpi_short || []).length > 0 && ( +
+ {(sec.kpi_short || []).map((k, i) => ( +
+
{k.category}
+
{k.value}
+ {k.sublabel ?
{k.sublabel}
: null} +
+ ))} +
+ )} + + {(sec.interpretation_short || []).map((it, i) => ( +
+ {it.title} +
{it.detail}
+
+ ))} + {(sec.heuristic_short || []).map((h, i) => ( +
+ {h.title} +
{h.detail}
+
+ ))} + {(sec.insights_short || []).map((ins, i) => ( +
+ {ins.title} +
{ins.body}
+
+ ))} +
+ ) + })} +
+ ))} + + {vis.show_correlation_c1_c3 && ( + <> +
Lag-Korrelationen (C1–C3)
+
+ + + +
+ + )} + + {vis.show_drivers_c4 && ( + <> +
Einflussfaktoren (C4)
+ + + )} + + {footer} +
+ ) +} diff --git a/frontend/src/components/history/NutritionHistoryVizSection.jsx b/frontend/src/components/history/NutritionHistoryVizSection.jsx new file mode 100644 index 0000000..aedd450 --- /dev/null +++ b/frontend/src/components/history/NutritionHistoryVizSection.jsx @@ -0,0 +1,608 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + ReferenceLine, + BarChart, + Bar, + PieChart, + Pie, + Cell, +} from 'recharts' +import { ChevronRight } from 'lucide-react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { api } from '../../utils/api' +import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../../utils/macroChartTheme' +import NutritionCharts, { WeeklyMacroDistributionPanel } from '../NutritionCharts' +import KpiTilesOverview from '../KpiTilesOverview' +import { + NUTRITION_HISTORY_VIZ_HISTORY_FULL, + filterNutritionHistoryKpiTiles, +} from '../../widgetSystem/nutritionHistoryVizConfig' +import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome' + +dayjs.locale('de') +const fmtDate = (d) => dayjs(d).format('DD.MM') + +function ChartFrame({ heightPx, children }) { + return ( +
+ {children} +
+ ) +} + +function NutritionGoalsStrip({ grouped }) { + const nav = useNavigate() + const goals = (grouped?.nutrition || []).filter((g) => g.status === 'active').slice(0, 4) + if (!goals.length) return null + return ( +
+
+
Ernährungsbezogene Ziele
+ +
+
+ {goals.map((g) => ( +
+
+ {g.name || g.label_de || g.goal_type} +
+
+
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value} + {g.unit ? ` ${g.unit}` : ''} +
+
+ ))} +
+
+ ) +} + +function kcalVsWeightKcalDomain(points, tdeeRef) { + const vals = (points || []) + .map((d) => Number(d.kcal_avg)) + .filter((v) => !Number.isNaN(v)) + if (!vals.length) return ['auto', 'auto'] + let lo = Math.min(...vals) + let hi = Math.max(...vals) + const t = tdeeRef != null ? Number(tdeeRef) : NaN + if (!Number.isNaN(t)) { + lo = Math.min(lo, t) + hi = Math.max(hi, t) + } + const span = hi - lo || 400 + const pad = Math.max(100, span * 0.1) + return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)] +} + +const TDEE_REF_LINE_COLOR = '#475569' + +function KcalVsWeightLegend({ showTdee }) { + const line = (color) => ({ + display: 'inline-block', + width: 22, + height: 3, + background: color, + borderRadius: 1, + verticalAlign: 'middle', + marginRight: 6, + }) + return ( +
+ + + Ø Kalorien (7-Tage-Mittel) + + + + Gewicht (kg) + + {showTdee ? ( + + + TDEE-Referenz (geschätzt) + + ) : null} +
+ ) +} + +/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics). */ +function KcalVsWeightChart({ vizKcalWeight, chartHeight = 200 }) { + const n = vizKcalWeight?.points?.length ?? 0 + if (n < 5) { + if (n === 0) return null + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum). +
+
+ ) + } + + const tdee = vizKcalWeight.tdee_reference_kcal + const kcalVsW = vizKcalWeight.points.map((d) => ({ + ...d, + date: fmtDate(d.date), + })) + const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length + const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null + const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) + return ( +
+
+ Kalorien (Ø 7 Tage) vs. Gewicht +
+
+ Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. +
+ + + + + + + + [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} + /> + {tdeeLabel != null && ( + + )} + + + + + + +
+ {tdeeLabel != null + ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` + : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`} +
+
+ ) +} + +/** + * Verlauf → Ernährung: GET /charts/nutrition-history-viz (Layer 2b) + optional NutritionCharts mit Bundle-Payloads. + * @param {object} [props.visibility] — Dashboard-Config; undefined = voller Verlauf + * @param {number} [props.externalPeriod] — feste Tage (7–90) im Widget + * @param {boolean} [props.embedded] + * @param {import('react').ReactNode} [props.footer] + */ +export default function NutritionHistoryVizSection({ externalPeriod, embedded = false, visibility, footer = null }) { + const display = visibility === undefined ? NUTRITION_HISTORY_VIZ_HISTORY_FULL : visibility + const chartHMain = embedded ? 176 : 200 + const chartHBal = embedded ? 160 : 180 + const chartHPlm = embedded ? 160 : 180 + + const [internalPeriod, setInternalPeriod] = useState(30) + const period = externalPeriod !== undefined ? externalPeriod : internalPeriod + const showPeriodSelector = externalPeriod === undefined + + const [groupedGoals, setGroupedGoals] = useState(null) + const [viz, setViz] = useState(null) + const [vizLoad, setVizLoad] = useState(true) + const [vizErr, setVizErr] = useState(null) + + useEffect(() => { + if (!display.show_goals_strip) { + setGroupedGoals({}) + return undefined + } + let cancelled = false + api.listGoalsGrouped() + .then((g) => { + if (!cancelled) setGroupedGoals(g) + }) + .catch(() => { + if (!cancelled) setGroupedGoals({}) + }) + return () => { + cancelled = true + } + }, [display.show_goals_strip]) + + useEffect(() => { + let cancelled = false + setViz(null) + setVizLoad(true) + setVizErr(null) + const daysReq = period === 9999 ? 9999 : period + api.getNutritionHistoryViz(daysReq) + .then((v) => { + if (!cancelled) setViz(v) + }) + .catch((e) => { + if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') + }) + .finally(() => { + if (!cancelled) setVizLoad(false) + }) + return () => { + cancelled = true + } + }, [period]) + + if (vizLoad) { + return ( +
+ {!embedded && } + {showPeriodSelector && } +
+
+ ) + } + + if (vizErr) { + return ( +
+ {!embedded && } +
{vizErr}
+
+ ) + } + + if (!viz?.has_nutrition_entries) { + return ( +
+ {!embedded && } + {showPeriodSelector && } + +
+ ) + } + + const summary = viz.summary || {} + const n = Math.max(0, Number(summary.data_points) || 0) + const avgKcal = Math.round(Number(summary.kcal_avg) || 0) + const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0) + const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period) + const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({ + ...t, + sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, + })) + const kpiTilesShown = display.show_kpis + ? filterNutritionHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full') + : [] + const pieData = viz.donut_avg_pct || [] + const cdMacro = (viz.daily_macros || []).map((d) => ({ + date: fmtDate(d.date), + Protein: d.Protein, + KH: d.KH, + Fett: d.Fett, + kcal: d.kcal, + })) + const weeklyMacro = viz.weekly_macro_chart + const balDaily = viz.calorie_balance_daily || [] + const plm = viz.protein_vs_lean_mass || {} + const plmPts = plm.points || [] + const nutHeur = viz.nutrition_correlation_heuristics || [] + const tdeeRef = viz.tdee_reference_kcal + + if (!cdMacro.length || n === 0) { + return ( +
+ {!embedded && } + {showPeriodSelector && } + +
+ ) + } + + return ( +
+ {!embedded && } + {embedded && viz?.last_updated && ( +
+ Stand {dayjs(viz.last_updated).format('DD.MM.YY')} +
+ )} + {showPeriodSelector && } + + {display.show_intro_blurb && ( +

+ Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} + Kalorien vs. Gewicht und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg). + {' '} + Kalorienbilanz, Protein vs. Magermasse und den Block{' '} + «Kurz-Einordnung» finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle). +

+ )} + + {display.show_goals_strip && } + + {kpiTilesShown.length > 0 && } + + {display.show_kcal_vs_weight && } + + {display.show_calorie_balance_chart && balDaily.length > 0 && tdeeRef != null && ( +
+
+ Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal) +
+
+ Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer). +
+ + + ({ ...d, date: fmtDate(d.date) }))} + margin={{ top: 4, right: 8, bottom: 0, left: -16 }} + > + + + + + [`${v > 0 ? '+' : ''}${v} kcal`, name === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']} + /> + + + + + +
+ )} + + {display.show_protein_lean_chart && plmPts.length >= 3 && ( +
+
+ Protein vs. Magermasse (Caliper, forward-filled) +
+
+ Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar. +
+ + + ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}> + + + + + {plm.protein_target_low_g > 0 && ( + + )} + [`${v}${name === 'protein' ? 'g' : ' kg'}`, name === 'protein' ? 'Protein' : 'Mager']} + /> + + + + + +
+ )} + + {display.show_heuristics && nutHeur.length > 0 && ( +
+
Ernährung — Kurz-Einordnung
+ {nutHeur.map((item, i) => ( +
+
+ {item.icon || '•'} +
+
{item.title}
+
{item.detail}
+
+
+
+ ))} +
+ )} + + {display.show_macro_daily_bars && ( +
+
+ Makroverteilung täglich (g) · Fokus Protein +
+
+ Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht). +
+ + + + + + + {ptLow > 0 && ( + + )} + [`${v}g`, name]} /> + + + + + + +
+ Protein (unten) + Fett (Mitte) + KH (oben) +
+
+ )} + + {display.show_macro_distribution_pair && ( +
+
+
+ Ø Makro-Quote ({n} Tage) +
+ {pieData.length > 0 ? ( +
+
+ + + + + {pieData.map((e, i) => ( + + ))} + + [`${v}%`, name]} /> + + + +
+
+ {pieData.map((p) => { + const fill = macroFillByName(p.name) + return ( +
+
+
{p.name}
+
{p.value}%
+
+ {p.grams != null ? `${p.grams}g` : '—'} +
+
+ ) + })} +
+ Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz +
+
+
+ ) : ( +
Keine Makro-Mittelwerte im Zeitraum.
+ )} +
+
+
+ Wöchentliche Makro-Verteilung (Backend) +
+ +
+
+ )} + + {display.show_energy_protein_charts && ( + <> +
+ Zeitverläufe (Energie & Protein) +
+ + + )} + + {footer} +
+ ) +} diff --git a/frontend/src/components/history/RecoveryHistoryVizSection.jsx b/frontend/src/components/history/RecoveryHistoryVizSection.jsx new file mode 100644 index 0000000..1f0c736 --- /dev/null +++ b/frontend/src/components/history/RecoveryHistoryVizSection.jsx @@ -0,0 +1,26 @@ +import RecoveryDashboardOverview from '../RecoveryDashboardOverview' + +/** + * Verlauf → Erholung bzw. Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b). + */ +export default function RecoveryHistoryVizSection({ + externalPeriod, + period, + onPeriodChange, + hidePeriodSelector, + embedded, + visibility, + footer, +}) { + return ( + + ) +} diff --git a/frontend/src/components/history/historyPageChrome.jsx b/frontend/src/components/history/historyPageChrome.jsx new file mode 100644 index 0000000..6bb3396 --- /dev/null +++ b/frontend/src/components/history/historyPageChrome.jsx @@ -0,0 +1,87 @@ +import { useNavigate } from 'react-router-dom' +import { ChevronRight } from 'lucide-react' +import dayjs from 'dayjs' + +export function NavToCaliper() { + const nav = useNavigate() + return ( + + ) +} + +export function NavToCircum() { + const nav = useNavigate() + return ( + + ) +} + +export function EmptySection({ text, to, toLabel }) { + const nav = useNavigate() + return ( +
+
{text}
+ {to && ( + + )} +
+ ) +} + +export function SectionHeader({ title, to, toLabel, lastUpdated }) { + const nav = useNavigate() + return ( +
+

{title}

+
+ {lastUpdated && {dayjs(lastUpdated).format('DD.MM.YY')}} + {to && ( + + )} +
+
+ ) +} + +export function PeriodSelector({ value, onChange }) { + const opts = [ + { v: 30, l: '30 Tage' }, + { v: 90, l: '90 Tage' }, + { v: 180, l: '6 Monate' }, + { v: 365, l: '1 Jahr' }, + { v: 9999, l: 'Alles' }, + ] + return ( +
+ {opts.map((o) => ( + + ))} +
+ ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 17e5337..d8b5c55 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -11,6 +11,11 @@ import { } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' +import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' +import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' +import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' +import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -20,8 +25,13 @@ import { const CHART_DAYS_WIDGET_IDS = new Set([ 'body_overview', + 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', + 'fitness_history_viz', + 'recovery_history_viz', + 'history_overview_viz', 'recovery_charts_panel', ]) @@ -495,6 +505,91 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { />
)} + {w.id === 'body_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'nutrition_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'fitness_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'recovery_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'history_overview_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 7145d59..736adc1 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -12,13 +12,23 @@ import { } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' +import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' +import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' +import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' +import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ const CHART_DAYS_WIDGET_IDS = new Set([ 'body_overview', + 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', + 'fitness_history_viz', + 'recovery_history_viz', + 'history_overview_viz', 'recovery_charts_panel', ]) @@ -318,11 +328,21 @@ export default function DashboardLabPage() {
)} + {w.id === 'body_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'nutrition_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'fitness_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'recovery_history_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} + {w.id === 'history_overview_viz' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => { + if (j !== i) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index d33d09c..d31df96 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -1,65 +1,21 @@ import { useState, useEffect } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' +import { useLocation } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' -import { - LineChart, Line, BarChart, Bar, - XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - ReferenceLine, PieChart, Pie, Cell, ComposedChart, - ScatterChart, Scatter, -} from 'recharts' -import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' +import { Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getStatusColor, getStatusBg } from '../utils/interpret' -import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme' import Markdown from '../utils/Markdown' -import FitnessDashboardOverview from '../components/FitnessDashboardOverview' -import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' -import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' -import KpiTilesOverview from '../components/KpiTilesOverview' +import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' +import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection' +import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' +import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' +import HistoryOverviewVizSection from '../components/history/HistoryOverviewVizSection' +import { EmptySection, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -const fmtDate = d => dayjs(d).format('DD.MM') - -function NavToCaliper() { - const nav = useNavigate() - return -} -function NavToCircum() { - const nav = useNavigate() - return -} -function EmptySection({ text, to, toLabel }) { - const nav = useNavigate() - return ( -
-
{text}
- {to && } -
- ) -} - -function SectionHeader({ title, to, toLabel, lastUpdated }) { - const nav = useNavigate() - return ( -
-

{title}

-
- {lastUpdated && {dayjs(lastUpdated).format('DD.MM.YY')}} - {to && ( - - )} -
-
- ) -} - function RuleCard({ item }) { const [open, setOpen] = useState(false) const color = getStatusColor(item.status) @@ -81,232 +37,6 @@ function RuleCard({ item }) { ) } -function verdictShort(status) { - if (status === 'good') return 'Gut' - if (status === 'warn') return 'Hinweis' - return 'Achtung' -} - -/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */ -function buildBodyKpiTiles({ - summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW, -}) { - const tiles = [] - - if (summary.weight_kg != null) { - const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' } - const trendBits = trendPeriods.length - ? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ') - : '' - const hoverBody = [ - 'Gewicht im gewählten Zeitraum (letzter Messwert).', - avgAll != null ? `Durchschnitt: ${avgAll} kg` : null, - minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null, - trendBits ? `Änderung: ${trendBits}` : null, - goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null, - ].filter(Boolean).join('\n') - - tiles.push({ - key: 'weight', - category: 'Gewicht', - icon: '⚖️', - value: `${summary.weight_kg} kg`, - sublabel: dataPoints ? `${dataPoints} Messwerte` : '', - verdict: wt.verdict, - status: wt.status, - hoverTop: 'Gewicht', - hoverBody, - keys: ['weight_aktuell', 'weight_trend'], - }) - } - - const kfRule = rules.find(r => r.category === 'Körperfett') - if (summary.body_fat_pct != null) { - tiles.push({ - key: 'bf', - category: 'Körperfett', - icon: '🫧', - value: `${summary.body_fat_pct}%`, - valueColor: kfRule ? getStatusColor(kfRule.status) : undefined, - sublabel: summary.bf_category_label || '', - verdict: verdictShort(kfRule?.status || 'good'), - status: kfRule?.status || 'good', - hoverTop: kfRule?.title || 'Körperfettanteil', - hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - const mmRule = rules.find(r => r.category === 'Muskelmasse') - if (summary.lean_mass_kg != null || summary.ffmi != null) { - const valParts = [] - if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`) - if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`) - tiles.push({ - key: 'lean_ffmi', - category: 'Magermasse', - icon: '💪', - value: valParts.join(' · ') || '—', - sublabel: 'Lean / FFMI', - verdict: mmRule ? verdictShort(mmRule.status) : '—', - status: mmRule?.status || 'good', - hoverTop: mmRule?.title || 'Muskelmasse', - hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - const bmiRule = rules.find(r => r.category === 'BMI') - if (bmiRule) { - tiles.push({ - key: 'bmi', - category: 'BMI', - icon: '📋', - value: bmiRule.value || '—', - sublabel: 'Body-Mass-Index', - verdict: verdictShort(bmiRule.status), - status: bmiRule.status, - hoverTop: bmiRule.title, - hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - const whrRule = rules.find(r => r.category === 'Fettverteilung') - if (summary.whr != null && whrRule) { - tiles.push({ - key: 'whr', - category: 'Fettverteilung', - icon: '📐', - value: String(summary.whr), - sublabel: 'WHR · Taille ÷ Hüfte', - verdict: verdictShort(whrRule.status), - status: whrRule.status, - hoverTop: whrRule.title || 'Waist-Hip-Ratio', - hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - const whtrRule = rules.find(r => r.category === 'Taille/Größe') - if (summary.whtr != null && whtrRule) { - tiles.push({ - key: 'whtr', - category: 'Taille/Größe', - icon: '📏', - value: String(summary.whtr), - sublabel: 'WHtR · Taille ÷ Größe', - verdict: verdictShort(whtrRule.status), - status: whtrRule.status, - hoverTop: whtrRule.title || 'Waist-to-Height-Ratio', - hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - const lastRule = rules.find(r => r.category.startsWith('Seit letzter')) - if (lastRule) { - tiles.push({ - key: 'delta', - category: 'Messvergleich', - icon: '📊', - value: lastRule.value || '—', - sublabel: 'seit Vorperiode', - verdict: verdictShort(lastRule.status), - status: lastRule.status, - hoverTop: lastRule.title, - hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'), - }) - } - - return tiles -} - -function NutritionGoalsStrip({ grouped }) { - const nav = useNavigate() - const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4) - if (!goals.length) return null - return ( -
-
-
Ernährungsbezogene Ziele
- -
-
- {goals.map(g => ( -
-
{g.name || g.label_de || g.goal_type}
-
-
-
-
- {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''} -
-
- ))} -
-
- ) -} - -function BodyGoalsStrip({ grouped }) { - const nav = useNavigate() - const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4) - if (!goals.length) return null - return ( -
-
-
Körperbezogene Ziele
- -
-
- {goals.map(g => ( -
-
{g.name || g.label_de || g.goal_type}
-
-
-
-
- {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''} -
-
- ))} -
-
- ) -} - function InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] @@ -354,784 +84,6 @@ function InsightBox({ insights, slugs, onRequest, loading }) { ) } -// ── Period selector ─────────────────────────────────────────────────────────── -function PeriodSelector({ value, onChange }) { - const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}] - return ( -
- {opts.map(o=>( - - ))} -
- ) -} - -// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ── -function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(90) - const [groupedGoals, setGroupedGoals] = useState(null) - const [viz, setViz] = useState(null) - const [vizLoading, setVizLoading] = useState(true) - const [vizError, setVizError] = useState(null) - - useEffect(() => { - let cancelled = false - api.listGoalsGrouped() - .then(g => { if (!cancelled) setGroupedGoals(g) }) - .catch(() => { if (!cancelled) setGroupedGoals({}) }) - return () => { cancelled = true } - }, []) - - useEffect(() => { - let cancelled = false - setVizLoading(true) - setVizError(null) - api.getBodyHistoryViz(period) - .then(data => { - if (!cancelled) { - setViz(data) - setVizLoading(false) - } - }) - .catch(e => { - if (!cancelled) { - setVizError(e.message || 'Laden fehlgeschlagen') - setVizLoading(false) - } - }) - return () => { cancelled = true } - }, [period]) - - const w = viz?.weight - const cal = viz?.caliper - const circ = viz?.circumference - const summary = viz?.summary || {} - - const wCd = (w?.series || []).map(row => ({ - date: fmtDate(row.date), - weight: row.weight, - avg7: row.avg7, - avg14: row.avg14, - })) - const hasWeight = (w?.data_points || 0) >= 2 - const avgAll = w?.overall_avg_kg - const minW = w?.min_kg - const maxW = w?.max_kg - const trendPeriods = w?.trend_periods || [] - - const bfCd = (cal?.series || []).map(s => ({ - date: fmtDate(s.date), - bf: s.body_fat_pct, - })) - - const propChartData = (circ?.proportion_series || []).map(p => ({ - date: fmtDate(p.date), - vTaper: p.v_taper_cm, - vTaper_avg: p.v_taper_cm_avg, - belly: p.belly_cm, - })) - const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined) - - const idxSeriesRaw = circ?.index_series || [] - const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) })) - const idxOk = circ?.index_usable - - const cirCd = (circ?.fallback_multiline || []).map(r => ({ - date: fmtDate(r.date), - waist: r.waist, - hip: r.hip, - belly: r.belly, - })) - - const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight - const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct - - const rules = (viz?.interpretation_tiles || []).map(t => ({ - category: t.category, - icon: t.icon, - status: t.status, - title: t.title, - detail: t.detail, - value: t.value, - related_placeholder_keys: t.related_placeholder_keys, - })) - - const kpiTiles = buildBodyKpiTiles({ - summary, - rules, - trendPeriods, - minW, - maxW, - avgAll, - dataPoints: w?.data_points, - weightTrendKpi: w?.trend_kpi, - goalW, - }) - - const hasAnyData = - (w?.data_points > 0) || - (cal?.data_points > 0) || - (cirCd.length > 0) - - if (vizLoading && !viz) { - return ( -
- -
-
- ) - } - if (vizError) { - return ( -
- -
{vizError}
-
- ) - } - if (!hasAnyData) { - return ( -
- - - -
- ) - } - - return ( -
- - - - - -

- Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Fitness. -

- - {viz?.meta?.layer_2a_alignment && ( -
- {viz.meta.layer_2a_alignment} -
- )} - - - - {vizLoading && ( -
Aktualisiere…
- )} - - {hasWeight && ( -
-
-
- Gewicht · {w?.data_points || 0} Einträge -
- -
- - - - - - {avgAll != null && ( - - )} - {goalW != null && ( - - )} - [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} /> - - - - - -
- ● Täglich - Ø 7T - Ø 14T - Ø Gesamt -
-
- )} - - {bfCd.length >= 2 && ( -
-
-
Körperfett (Caliper)
- -
- - - - - - [`${v}%`, 'KF%']} /> - {goalBf != null && } - - - -
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
-
- )} - - {propChartData.length >= 2 && ( -
-
-
-
Silhouette & Proportion
-
- V-Taper (Brust − Taille) in cm. - {showBellyOnProp && <> Bauch (rechte Achse).} -
-
- -
- - - - - - {showBellyOnProp && } - { - if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille'] - if (name === 'belly') return [`${v} cm`, 'Bauch'] - return [v, name] - }} - /> - - - {showBellyOnProp && } - - -
- Brust − Taille - gleitender Mittelwert - {showBellyOnProp && Bauch (cm)} -
-
- )} - - {idxOk && ( -
-
-
-
Relative Entwicklung der Umfänge
-
Index 100 = erste Messung im Zeitraum.
-
- -
- - - - - - - [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} /> - {idxSeries.some(d => d.chest_idx != null) && } - {idxSeries.some(d => d.waist_idx != null) && } - {idxSeries.some(d => d.belly_idx != null) && } - - -
- Brust - Taille - Bauch -
-
- )} - - {propChartData.length < 2 && cirCd.length >= 2 && ( -
-
-
Umfänge (Taille / Hüfte / Bauch)
- -
-
Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.
- - - - - - [`${v} cm`, n]} /> - - - {cirCd.some(d => d.belly) && } - - -
- )} - - -
- ) -} - -/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */ -function kcalVsWeightKcalDomain(points, tdeeRef) { - const vals = (points || []) - .map(d => Number(d.kcal_avg)) - .filter(v => !Number.isNaN(v)) - if (!vals.length) return ['auto', 'auto'] - let lo = Math.min(...vals) - let hi = Math.max(...vals) - const t = tdeeRef != null ? Number(tdeeRef) : NaN - if (!Number.isNaN(t)) { - lo = Math.min(lo, t) - hi = Math.max(hi, t) - } - const span = hi - lo || 400 - const pad = Math.max(100, span * 0.1) - return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)] -} - -const TDEE_REF_LINE_COLOR = '#475569' - -/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */ -function KcalVsWeightLegend({ showTdee }) { - const line = (color) => ({ - display: 'inline-block', - width: 22, - height: 3, - background: color, - borderRadius: 1, - verticalAlign: 'middle', - marginRight: 6, - }) - return ( -
- - - Ø Kalorien (7-Tage-Mittel) - - - - Gewicht (kg) - - {showTdee ? ( - - - TDEE-Referenz (geschätzt) - - ) : null} -
- ) -} - -/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */ -function KcalVsWeightChart({ vizKcalWeight }) { - const n = vizKcalWeight?.points?.length ?? 0 - if (n < 5) { - if (n === 0) return null - return ( -
-
- Kalorien (Ø 7 Tage) vs. Gewicht -
-
- Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum). -
-
- ) - } - - const tdee = vizKcalWeight.tdee_reference_kcal - const kcalVsW = vizKcalWeight.points.map(d => ({ - ...d, - date: fmtDate(d.date), - })) - const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length - const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null - const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel) - return ( -
-
- Kalorien (Ø 7 Tage) vs. Gewicht -
-
- Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg. -
- - - - - - - [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']} - /> - {tdeeLabel != null && ( - - )} - - - - - -
- {tdeeLabel != null - ? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage` - : `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`} -
-
- ) -} - -// ── Nutrition Section ───────────────────────────────────────────────────────── -/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */ -function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { - const [period, setPeriod] = useState(30) - const [groupedGoals, setGroupedGoals] = useState(null) - const [viz, setViz] = useState(null) - const [vizLoad, setVizLoad] = useState(true) - const [vizErr, setVizErr] = useState(null) - - useEffect(() => { - let cancelled = false - api.listGoalsGrouped() - .then(g => { if (!cancelled) setGroupedGoals(g) }) - .catch(() => { if (!cancelled) setGroupedGoals({}) }) - return () => { cancelled = true } - }, []) - - useEffect(() => { - let cancelled = false - setViz(null) - setVizLoad(true) - setVizErr(null) - const daysReq = period === 9999 ? 9999 : period - api.getNutritionHistoryViz(daysReq) - .then(v => { if (!cancelled) setViz(v) }) - .catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') }) - .finally(() => { if (!cancelled) setVizLoad(false) }) - return () => { cancelled = true } - }, [period]) - - if (vizLoad) { - return ( -
- - -
-
- ) - } - - if (vizErr) { - return ( -
- -
{vizErr}
-
- ) - } - - if (!viz?.has_nutrition_entries) { - return ( - - ) - } - - const summary = viz.summary || {} - const n = Math.max(0, Number(summary.data_points) || 0) - const avgKcal = Math.round(Number(summary.kcal_avg) || 0) - const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0) - const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period) - const kpiTiles = (viz.kpi_tiles || []).map(t => ({ - ...t, - sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel, - })) - const pieData = viz.donut_avg_pct || [] - const cdMacro = (viz.daily_macros || []).map(d => ({ - date: fmtDate(d.date), - Protein: d.Protein, - KH: d.KH, - Fett: d.Fett, - kcal: d.kcal, - })) - const weeklyMacro = viz.weekly_macro_chart - const wmLoading = false - const wmError = null - const balDaily = viz.calorie_balance_daily || [] - const plm = viz.protein_vs_lean_mass || {} - const plmPts = plm.points || [] - const nutHeur = viz.nutrition_correlation_heuristics || [] - const tdeeRef = viz.tdee_reference_kcal - - if (!cdMacro.length || n === 0) { - return ( -
- - - -
- ) - } - - return ( -
- - - -

- Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '} - Kalorien vs. Gewicht und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg). - {' '} - Kalorienbilanz, Protein vs. Magermasse und den Block{' '} - «Kurz-Einordnung» finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle). -

- - - - - - - - {balDaily.length > 0 && tdeeRef != null && ( -
-
- Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal) -
-
- Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer). -
- - ({ ...d, date: fmtDate(d.date) }))} - margin={{ top: 4, right: 8, bottom: 0, left: -16 }} - > - - - - - [`${v > 0 ? '+' : ''}${v} kcal`, n === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']} - /> - - - - -
- )} - - {plmPts.length >= 3 && ( -
-
- Protein vs. Magermasse (Caliper, forward-filled) -
-
- Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar. -
- - ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}> - - - - - {plm.protein_target_low_g > 0 && ( - - )} - [`${v}${n === 'protein' ? 'g' : ' kg'}`, n === 'protein' ? 'Protein' : 'Mager']} - /> - - - - -
- )} - - {nutHeur.length > 0 && ( -
-
Ernährung — Kurz-Einordnung
- {nutHeur.map((item, i) => ( -
-
- {item.icon || '•'} -
-
{item.title}
-
{item.detail}
-
-
-
- ))} -
- )} - -
-
- Makroverteilung täglich (g) · Fokus Protein -
-
- Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht). -
- - - - - - {ptLow > 0 && ( - - )} - [`${v}g`, name]} /> - - - - - -
- Protein (unten) - Fett (Mitte) - KH (oben) -
-
- -
-
-
- Ø Makro-Quote ({n} Tage) -
- {pieData.length > 0 ? ( -
-
- - - - {pieData.map((e, i) => ( - - ))} - - [`${v}%`, name]} /> - - -
-
- {pieData.map(p => { - const fill = macroFillByName(p.name) - return ( -
-
-
{p.name}
-
{p.value}%
-
- {p.grams != null ? `${p.grams}g` : '—'} -
-
- ) - })} -
- Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz -
-
-
- ) : ( -
Keine Makro-Mittelwerte im Zeitraum.
- )} -
-
-
- Wöchentliche Makro-Verteilung (Backend) -
- -
-
- -
- Zeitverläufe (Energie & Protein) -
- - - -
- ) -} - // ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) { const [period, setPeriod] = useState(30) @@ -1143,12 +95,12 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f

Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.

- +
Erholung (Schlaf, HRV, Vitalwerte)
- + {activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
k.status === 'bad')) return 'bad' - if (kpis.some((k) => k.status === 'warn')) return 'warn' - const interp = sec.interpretation_short || [] - if (interp.some((x) => x.status === 'bad')) return 'bad' - if (interp.some((x) => x.status === 'warn')) return 'warn' - const heur = sec.heuristic_short || [] - if (heur.some((h) => h.status === 'warn')) return 'warn' - return 'good' -} - -function overviewConfidenceUi(conf) { - if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' } - if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' } - return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' } -} - -function chartJsScatterPoints(payload) { - const raw = payload?.data?.datasets?.[0]?.data || [] - if (!Array.isArray(raw)) return [] - return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) })) -} - -/** Backend metadata.lag_details: [{ lag, n_pairs, r }] — für Lag-Kurve L → r (C3: ggf. lag_details_hrv / lag_details_rhr) */ -function lagDetailsToCurve(meta) { - let ld = meta?.lag_details - if (!Array.isArray(ld) || ld.length === 0) { - const m = String(meta?.metric || '').toUpperCase() - if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv - else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr - else { - const h = meta?.lag_details_hrv - const r = meta?.lag_details_rhr - const hl = Array.isArray(h) ? h.length : 0 - const rl = Array.isArray(r) ? r.length : 0 - if (hl >= rl && hl > 0) ld = h - else if (rl > 0) ld = r - else ld = [] - } - } - if (!Array.isArray(ld) || ld.length === 0) return [] - return ld - .map((d) => ({ - lag: Number(d?.lag), - r: d?.r == null || d?.r === '' ? null : Number(d.r), - n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null, - })) - .filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r)) - .sort((a, b) => a.lag - b.lag) -} - -function driverBarFromStatus(st) { - const s = String(st || '').toLowerCase() - if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' } - if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' } - return { v: 0.15, fill: '#6B7280' } -} - -function chartJsBarRows(payload, fallbackDrivers) { - const labels = payload?.data?.labels || [] - const values = payload?.data?.datasets?.[0]?.data || [] - const colors = payload?.data?.datasets?.[0]?.backgroundColor - if (labels.length && values.length) { - return labels.map((name, i) => ({ - name: name.length > 42 ? `${name.slice(0, 40)}…` : name, - value: Number(values[i]), - fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75', - })) - } - if (fallbackDrivers?.length) { - return fallbackDrivers.map((d) => { - const { v, fill } = driverBarFromStatus(d.status) - return { - name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'), - value: v, - fill, - subtitle: d.reason, - } - }) - } - return [] -} - -function CorrelationScatterTile({ title, accent, payload }) { - const meta = payload?.metadata || {} - const pts = chartJsScatterPoints(payload) - const curve = lagDetailsToCurve(meta) - const hasChart = pts.length > 0 && meta.correlation != null - const r = Number(meta.correlation) - const strength = - !Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad' - const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null - const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28 - - return ( -
-
{title}
-
- r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'} - {meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''} - {meta.metric ? ` · ${meta.metric}` : ''} - {meta.confidence ? ` · ${meta.confidence}` : ''} -
- {!hasChart ? ( - <> -
- {meta.message || 'Keine Daten für diese Korrelation.'} -
- {curve.length > 0 && ( -
- Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung. -
- )} - {curve.length > 0 && ( - - - - - - - [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} - /> - - - - )} - - ) : curve.length >= 1 ? ( - <> -
- Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag. -
- - - - - - - [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} - /> - { - const { cx, cy, payload: pl } = props - if (cx == null || cy == null || !pl) return null - const isBest = bestLag != null && Number(pl.lag) === bestLag - return ( - - ) - }} - /> - - - - ) : ( - - - - - - - - - - - )} - {meta.interpretation ? ( -
{meta.interpretation}
- ) : null} -
- ) -} - -function DriversImpactTile({ payload, driversFallback }) { - const meta = payload?.metadata || {} - const rows = chartJsBarRows(payload, driversFallback) - if (!rows.length) { - return ( -
-
C4 Einflussfaktoren
-
{meta.message || 'Keine Treiber-Daten.'}
-
- ) - } - const h = Math.min(220, Math.max(96, rows.length * 34)) - return ( -
-
C4 Einflussfaktoren
- - - - - { - if (!active || !pp?.length) return null - const p = pp[0].payload - return ( -
-
{p.name}
- {p.subtitle ?
{p.subtitle}
: null} -
- ) - }} - /> - - {rows.map((e, i) => ( - - ))} - -
-
-
- ) -} - -// ── Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1–C4) ────────────────── -function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) { - const navigate = useNavigate() - const [period, setPeriod] = useState(30) - const [bundle, setBundle] = useState(null) - const [err, setErr] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - const daysReq = period === 9999 ? 3650 : period - setLoading(true) - Promise.all([ - api.getHistoryOverviewViz(daysReq), - api.getWeightEnergyCorrelationChart(14), - api.getLbmProteinCorrelationChart(14), - api.getLoadVitalsCorrelationChart(14), - api.getRecoveryPerformanceChart(), - ]) - .then(([overview, chartC1, chartC2, chartC3, chartC4]) => { - if (!cancelled) { - setBundle({ overview, chartC1, chartC2, chartC3, chartC4 }) - setErr(null) - } - }) - .catch((e) => { - if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen') - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - return () => { - cancelled = true - } - }, [period]) - - if (loading) { - return ( -
- - -
-
- ) - } - if (err) { - return ( -
- -
{err}
-
- ) - } - - const data = bundle?.overview - const chartC1 = bundle?.chartC1 - const chartC2 = bundle?.chartC2 - const chartC3 = bundle?.chartC3 - const chartC4 = bundle?.chartC4 - - const lag = data?.lag_correlations || {} - const c4drivers = lag.recovery_performance?.drivers || [] - const sections = data?.sections || [] - const confUi = overviewConfidenceUi(data?.confidence) - - return ( -
- - - -
- {confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'} -
-
{confUi.label}
-
{confUi.hint}
-
-
- -

- KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '} - Ehem. «Korrelation»-Charts (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '} - - . Die Kacheln C1–C4 unten nutzen dieselben Chart-Endpunkte wie die API (/api/charts/*). -

- -
- {sections.map((sec) => { - const tone = overviewSectionTone(sec) - const stripe = getStatusColor(tone) - const badgeBg = getStatusBg(tone) - return ( -
-
-
- - {tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'} - -
{sec.title}
-
- -
-
{sec.summary_line}
- - {(sec.kpi_short || []).length > 0 && ( -
- {(sec.kpi_short || []).map((k, i) => ( -
-
{k.category}
-
{k.value}
- {k.sublabel ?
{k.sublabel}
: null} -
- ))} -
- )} - - {(sec.interpretation_short || []).map((it, i) => ( -
- {it.title} -
{it.detail}
-
- ))} - {(sec.heuristic_short || []).map((h, i) => ( -
- {h.title} -
{h.detail}
-
- ))} - {(sec.insights_short || []).map((ins, i) => ( -
- {ins.title} -
{ins.body}
-
- ))} -
- ) - })} -
- -
Lag-Korrelationen (C1–C3)
-
- - - -
- -
Einflussfaktoren (C4)
- - - -
- ) -} - // ── Photo Grid ──────────────────────────────────────────────────────────────── function PhotoGrid() { const [photos, setPhotos] = useState([]) @@ -1843,9 +342,43 @@ export default function History() {
- {tab==='overview' && } - {tab==='body' && } - {tab==='nutrition' && } + {tab === 'overview' && ( + + )} + /> + )} + {tab==='body' && ( + + )} + /> + )} + {tab === 'nutrition' && ( + + )} + /> + )} {tab==='activity' && } {tab==='photos' && }
diff --git a/frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..83e6cd4 --- /dev/null +++ b/frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx @@ -0,0 +1,91 @@ +import { BODY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_weight_chart', label: 'Gewichts-Chart' }, + { key: 'show_body_fat_chart', label: 'Körperfett (Caliper)' }, + { key: 'show_proportion_chart', label: 'Silhouette & Proportion' }, + { key: 'show_circumference_index_chart', label: 'Umfänge — Index' }, + { key: 'show_circumference_lines_chart', label: 'Umfänge — Linien (Fallback)' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_goals_strip', label: 'Körper-Ziele (Strip)' }, + { key: 'show_intro_blurb', label: 'Hinweistext unter Zielen' }, + { key: 'show_layer_meta', label: 'Layer-2a-Hinweis (Meta)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function BodyHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeBodyHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = BODY_HISTORY_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Körper (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (nur KPI kompakt + Gewicht). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..b46b940 --- /dev/null +++ b/frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx @@ -0,0 +1,89 @@ +import { FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_chart_training_volume', label: 'Trainingsvolumen (Balken)' }, + { key: 'show_chart_training_type_distribution', label: 'Training nach Kategorie (Kuchen)' }, + { key: 'show_chart_quality_sessions', label: 'Qualitäts-Sessions' }, + { key: 'show_chart_load_monitoring', label: 'Belastung / Load-Zeitreihe' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Issue-53-Hinweis)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, + { key: 'show_progress_insights', label: 'Einschätzungen (Progress-Insights)' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function FitnessHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeFitnessHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Fitness (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, Volumen + Kategorien). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx b/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx new file mode 100644 index 0000000..67c064e --- /dev/null +++ b/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx @@ -0,0 +1,65 @@ +import { + HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS, + normalizeHistoryOverviewVizConfig, +} from './historyOverviewVizConfig' + +const SECTION_TOGGLES = [ + { key: 'show_section_body', label: 'Körper' }, + { key: 'show_section_nutrition', label: 'Ernährung' }, + { key: 'show_section_fitness', label: 'Fitness' }, + { key: 'show_section_recovery', label: 'Erholung' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_confidence_banner', label: 'Banner «Datenlage»' }, + { key: 'show_intro_blurb', label: 'Hinweistext (Ernährung / API)' }, + { key: 'show_correlation_c1_c3', label: 'Lag-Korrelationen C1–C3 (Charts)' }, + { key: 'show_drivers_c4', label: 'Einflussfaktoren C4' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function HistoryOverviewVizConfigEditor({ config, onChange }) { + const merged = normalizeHistoryOverviewVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Gesamtübersicht (Verlauf-Bundle): welche Bereichs-Kacheln und weitere Blöcke erscheinen. +
+
Bereichs-Kacheln
+
+ {SECTION_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Weitere Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..b2c2057 --- /dev/null +++ b/frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx @@ -0,0 +1,92 @@ +import { NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_kcal_vs_weight', label: 'Kalorien vs. Gewicht' }, + { key: 'show_calorie_balance_chart', label: 'Kalorienbilanz' }, + { key: 'show_protein_lean_chart', label: 'Protein vs. Magermasse' }, + { key: 'show_heuristics', label: 'Kurz-Einordnung (Heuristiken)' }, + { key: 'show_macro_daily_bars', label: 'Makros täglich (Balken)' }, + { key: 'show_macro_distribution_pair', label: 'Donut + Wochen-Makros' }, + { key: 'show_energy_protein_charts', label: 'Zeitverläufe (NutritionCharts, Bundle-Payloads)' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_goals_strip', label: 'Ernährungs-Ziele (Strip)' }, + { key: 'show_intro_blurb', label: 'Hinweistext (Data-Layer)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function NutritionHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeNutritionHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Ernährung (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, kcal vs. Gewicht, Makro-Balken + Donut/Woche). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx b/frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx new file mode 100644 index 0000000..6126aa6 --- /dev/null +++ b/frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx @@ -0,0 +1,106 @@ +import { RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig' + +const CHART_TOGGLES = [ + { key: 'show_chart_recovery_score', label: 'HRV-/Recovery-Score-Verlauf' }, + { key: 'show_chart_sleep_quality', label: 'Schlaf: Dauer & Qualität' }, + { key: 'show_chart_sleep_debt', label: 'Schlafschuld' }, + { key: 'show_chart_hrv_rhr', label: 'HRV & Ruhepuls (Zeitverlauf)' }, +] + +const SECTION_TOGGLES = [ + { key: 'show_sleep_section_heading', label: 'Zwischenüberschrift «Schlaf & Erholung»' }, + { key: 'show_heart_section_heading', label: 'Zwischenüberschrift «Herz & Kreislauf»' }, + { key: 'show_heart_context_card', label: 'Herz: Einordnung, Zonen, Snapshots' }, + { key: 'show_vitals_extra_heading', label: 'Überschrift «Weitere Vitalparameter»' }, + { key: 'show_vitals_extra_trends', label: 'VO2 / SpO2 / Atemfrequenz (Verläufe)' }, +] + +const OTHER_TOGGLES = [ + { key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Data-Layer)' }, + { key: 'show_kpis', label: 'KPI-Kacheln' }, + { key: 'show_progress_insights', label: 'Überblick: Recovery & Schlaf (Karten)' }, +] + +/** + * @param {{ config: Record, onChange: (next: Record) => void }} props + */ +export default function RecoveryHistoryVizConfigEditor({ config, onChange }) { + const merged = normalizeRecoveryHistoryVizConfig(config) + + const patch = (partial) => { + const next = { ...merged, ...partial } + const def = RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS + const stored = {} + for (const k of Object.keys(def)) { + if (next[k] !== def[k]) stored[k] = next[k] + } + onChange(stored) + } + + const setBool = (key, checked) => { + patch({ [key]: checked }) + } + + return ( +
+
+ Erholung (Verlauf-Bundle): welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker + Standard (KPI kompakt, Schlaf-Charts, HRV/RHR — ohne Kontextkarte und Extra-Vitals). +
+
KPI-Umfang
+ + +
Bereiche
+
+ {OTHER_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Abschnitte
+
+ {SECTION_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Charts
+
+ {CHART_TOGGLES.map(({ key, label }) => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/widgetSystem/bodyHistoryVizConfig.js b/frontend/src/widgetSystem/bodyHistoryVizConfig.js new file mode 100644 index 0000000..682952b --- /dev/null +++ b/frontend/src/widgetSystem/bodyHistoryVizConfig.js @@ -0,0 +1,81 @@ +/** + * Sichtbarkeit / Umfang für body_history_viz (sync mit backend dashboard_widget_config). + * `null` / fehlend → Verlauf: alles sichtbar (vollständige Parität zur History-Seite). + */ + +/** Verlauf-Tab Körper: volle Parität (kein Layout-Config). */ +export const BODY_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 90, + show_goals_strip: true, + show_intro_blurb: true, + show_layer_meta: true, + show_kpis: true, + kpi_detail: 'full', + show_weight_chart: true, + show_body_fat_chart: true, + show_proportion_chart: true, + show_circumference_index_chart: true, + show_circumference_lines_chart: true, +} + +/** Default für Dashboard-Widget (schlank). */ +export const BODY_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_goals_strip: false, + show_intro_blurb: false, + show_layer_meta: false, + show_kpis: true, + kpi_detail: 'compact', + show_weight_chart: true, + show_body_fat_chart: false, + show_proportion_chart: false, + show_circumference_index_chart: false, + show_circumference_lines_chart: false, +} + +const BOOL_KEYS = [ + 'show_goals_strip', + 'show_intro_blurb', + 'show_layer_meta', + 'show_kpis', + 'show_weight_chart', + 'show_body_fat_chart', + 'show_proportion_chart', + 'show_circumference_index_chart', + 'show_circumference_lines_chart', +] + +/** + * @param {Record|null|undefined} raw — aus Layout-Config (Backend liefert nach Save ggf. alle Keys) + * @returns {typeof BODY_HISTORY_VIZ_WIDGET_DEFAULTS} + */ +export function normalizeBodyHistoryVizConfig(raw) { + const base = { ...BODY_HISTORY_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') { + base.kpi_detail = raw.kpi_detail + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} + +const COMPACT_KPI_KEYS = new Set(['weight', 'bf', 'lean_ffmi']) + +/** + * @param {Array<{ key: string }>} tiles + * @param {'compact'|'full'} detail + */ +export function filterBodyHistoryKpiTiles(tiles, detail) { + if (detail === 'full' || !Array.isArray(tiles)) return tiles + return tiles.filter((t) => COMPACT_KPI_KEYS.has(t.key)) +} diff --git a/frontend/src/widgetSystem/fitnessHistoryVizConfig.js b/frontend/src/widgetSystem/fitnessHistoryVizConfig.js new file mode 100644 index 0000000..f1a23d7 --- /dev/null +++ b/frontend/src/widgetSystem/fitnessHistoryVizConfig.js @@ -0,0 +1,70 @@ +/** + * Sichtbarkeit für fitness_history_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf: alles an (wie bisherige Fitness-Übersicht). + */ + +export const FITNESS_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 30, + show_layer_meta: true, + show_kpis: true, + kpi_detail: 'full', + show_progress_insights: true, + show_chart_training_volume: true, + show_chart_training_type_distribution: true, + show_chart_quality_sessions: true, + show_chart_load_monitoring: true, +} + +export const FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_layer_meta: false, + show_kpis: true, + kpi_detail: 'compact', + show_progress_insights: false, + show_chart_training_volume: true, + show_chart_training_type_distribution: true, + show_chart_quality_sessions: false, + show_chart_load_monitoring: false, +} + +const BOOL_KEYS = [ + 'show_layer_meta', + 'show_kpis', + 'show_progress_insights', + 'show_chart_training_volume', + 'show_chart_training_type_distribution', + 'show_chart_quality_sessions', + 'show_chart_load_monitoring', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeFitnessHistoryVizConfig(raw) { + const base = { ...FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') { + base.kpi_detail = raw.kpi_detail + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} + +/** + * @param {Array} kpiTiles + * @param {'compact'|'full'} detail + */ +export function filterFitnessHistoryKpiTiles(kpiTiles, detail) { + if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles + return kpiTiles.slice(0, 4) +} diff --git a/frontend/src/widgetSystem/historyOverviewVizConfig.js b/frontend/src/widgetSystem/historyOverviewVizConfig.js new file mode 100644 index 0000000..d32b573 --- /dev/null +++ b/frontend/src/widgetSystem/historyOverviewVizConfig.js @@ -0,0 +1,75 @@ +/** + * Sichtbarkeit für history_overview_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf-Tab: volle Gesamtübersicht (wie bisher). + */ + +export const HISTORY_OVERVIEW_VIZ_SECTION_KEYS = [ + 'show_section_body', + 'show_section_nutrition', + 'show_section_fitness', + 'show_section_recovery', +] + +export const HISTORY_OVERVIEW_VIZ_PAGE_FULL = { + chart_days: 30, + show_confidence_banner: true, + show_intro_blurb: true, + show_section_body: true, + show_section_nutrition: true, + show_section_fitness: true, + show_section_recovery: true, + show_correlation_c1_c3: true, + show_drivers_c4: true, +} + +export const HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_confidence_banner: true, + show_intro_blurb: true, + show_section_body: true, + show_section_nutrition: true, + show_section_fitness: true, + show_section_recovery: true, + show_correlation_c1_c3: true, + show_drivers_c4: true, +} + +const BOOL_KEYS = [ + 'show_confidence_banner', + 'show_intro_blurb', + ...HISTORY_OVERVIEW_VIZ_SECTION_KEYS, + 'show_correlation_c1_c3', + 'show_drivers_c4', +] + +function hasExplicitSectionKeys(raw) { + return HISTORY_OVERVIEW_VIZ_SECTION_KEYS.some((k) => Object.prototype.hasOwnProperty.call(raw, k)) +} + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeHistoryOverviewVizConfig(raw) { + const base = { ...HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + + if (Object.prototype.hasOwnProperty.call(raw, 'show_area_summaries') && !hasExplicitSectionKeys(raw)) { + const v = raw.show_area_summaries === true + for (const k of HISTORY_OVERVIEW_VIZ_SECTION_KEYS) { + base[k] = v + } + } + + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} diff --git a/frontend/src/widgetSystem/nutritionHistoryVizConfig.js b/frontend/src/widgetSystem/nutritionHistoryVizConfig.js new file mode 100644 index 0000000..eda8b3a --- /dev/null +++ b/frontend/src/widgetSystem/nutritionHistoryVizConfig.js @@ -0,0 +1,79 @@ +/** + * Sichtbarkeit für nutrition_history_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf-Tab: alles an. + */ + +export const NUTRITION_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 30, + show_goals_strip: true, + show_intro_blurb: true, + show_kpis: true, + kpi_detail: 'full', + show_kcal_vs_weight: true, + show_calorie_balance_chart: true, + show_protein_lean_chart: true, + show_heuristics: true, + show_macro_daily_bars: true, + show_macro_distribution_pair: true, + show_energy_protein_charts: true, +} + +export const NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_goals_strip: false, + show_intro_blurb: false, + show_kpis: true, + kpi_detail: 'compact', + show_kcal_vs_weight: true, + show_calorie_balance_chart: false, + show_protein_lean_chart: false, + show_heuristics: false, + show_macro_daily_bars: true, + show_macro_distribution_pair: true, + show_energy_protein_charts: false, +} + +const BOOL_KEYS = [ + 'show_goals_strip', + 'show_intro_blurb', + 'show_kpis', + 'show_kcal_vs_weight', + 'show_calorie_balance_chart', + 'show_protein_lean_chart', + 'show_heuristics', + 'show_macro_daily_bars', + 'show_macro_distribution_pair', + 'show_energy_protein_charts', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeNutritionHistoryVizConfig(raw) { + const base = { ...NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') { + base.kpi_detail = raw.kpi_detail + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} + +/** + * @param {Array} kpiTiles + * @param {'compact'|'full'} detail + */ +export function filterNutritionHistoryKpiTiles(kpiTiles, detail) { + if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles + return kpiTiles.slice(0, 4) +} diff --git a/frontend/src/widgetSystem/recoveryHistoryVizConfig.js b/frontend/src/widgetSystem/recoveryHistoryVizConfig.js new file mode 100644 index 0000000..212fb14 --- /dev/null +++ b/frontend/src/widgetSystem/recoveryHistoryVizConfig.js @@ -0,0 +1,85 @@ +/** + * Sichtbarkeit für recovery_history_viz (sync mit backend dashboard_widget_config). + * `visibility === undefined` → Verlauf: volle Erholungs-Übersicht (wie bisher). + */ + +export const RECOVERY_HISTORY_VIZ_HISTORY_FULL = { + chart_days: 30, + show_layer_meta: true, + show_kpis: true, + kpi_detail: 'full', + show_progress_insights: true, + show_sleep_section_heading: true, + show_chart_recovery_score: true, + show_chart_sleep_quality: true, + show_chart_sleep_debt: true, + show_heart_section_heading: true, + show_heart_context_card: true, + show_chart_hrv_rhr: true, + show_vitals_extra_heading: true, + show_vitals_extra_trends: true, +} + +export const RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS = { + chart_days: 30, + show_layer_meta: false, + show_kpis: true, + kpi_detail: 'compact', + show_progress_insights: false, + show_sleep_section_heading: true, + show_chart_recovery_score: true, + show_chart_sleep_quality: true, + show_chart_sleep_debt: false, + show_heart_section_heading: true, + show_heart_context_card: false, + show_chart_hrv_rhr: true, + show_vitals_extra_heading: false, + show_vitals_extra_trends: false, +} + +const BOOL_KEYS = [ + 'show_layer_meta', + 'show_kpis', + 'show_progress_insights', + 'show_sleep_section_heading', + 'show_chart_recovery_score', + 'show_chart_sleep_quality', + 'show_chart_sleep_debt', + 'show_heart_section_heading', + 'show_heart_context_card', + 'show_chart_hrv_rhr', + 'show_vitals_extra_heading', + 'show_vitals_extra_trends', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeRecoveryHistoryVizConfig(raw) { + const base = { ...RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS } + if (!raw || typeof raw !== 'object') return base + for (const k of BOOL_KEYS) { + if (Object.prototype.hasOwnProperty.call(raw, k)) { + base[k] = raw[k] === true + } + } + if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') { + base.kpi_detail = raw.kpi_detail + } + if (raw.chart_days != null) { + const n = Number(raw.chart_days) + if (Number.isFinite(n)) { + base.chart_days = Math.min(90, Math.max(7, Math.round(n))) + } + } + return base +} + +/** + * @param {Array} kpiTiles + * @param {'compact'|'full'} detail + */ +export function filterRecoveryHistoryKpiTiles(kpiTiles, detail) { + if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles + return kpiTiles.slice(0, 4) +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 1839b04..7c7260c 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -14,6 +14,16 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget' import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' +import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' +import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget' +import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget' +import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget' +import HistoryOverviewVizWidget from '../components/dashboard-widgets/HistoryOverviewVizWidget' +import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' +import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' +import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' +import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig' +import { normalizeHistoryOverviewVizConfig } from './historyOverviewVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -57,6 +67,14 @@ export function ensurePilotLabWidgetsRegistered() { chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), }), }) + registerDashboardWidget({ + id: 'body_history_viz', + Component: BodyHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + bodyHistoryVizConfig: normalizeBodyHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'activity_overview', Component: PilotActivitySection, @@ -112,6 +130,38 @@ export function ensurePilotLabWidgetsRegistered() { chartDays: ctx.layoutEntry?.config?.chart_days, }), }) + registerDashboardWidget({ + id: 'nutrition_history_viz', + Component: NutritionHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) + registerDashboardWidget({ + id: 'fitness_history_viz', + Component: FitnessHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) + registerDashboardWidget({ + id: 'recovery_history_viz', + Component: RecoveryHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) + registerDashboardWidget({ + id: 'history_overview_viz', + Component: HistoryOverviewVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + historyOverviewVizConfig: normalizeHistoryOverviewVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget, diff --git a/shinkan-dev-screenshot.png b/shinkan-dev-screenshot.png new file mode 100644 index 0000000..5a26681 Binary files /dev/null and b/shinkan-dev-screenshot.png differ diff --git a/test-shinkan.js b/test-shinkan.js new file mode 100644 index 0000000..cfb0fc1 --- /dev/null +++ b/test-shinkan.js @@ -0,0 +1,40 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + console.log('=== Testing Shinkan Frontend ===\n'); + + try { + await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 }); + + const title = await page.title(); + console.log('📄 Title:', title); + + const h1 = await page.textContent('h1').catch(() => null); + console.log('🥋 Heading:', h1); + + const bodyText = await page.evaluate(() => document.body.innerText); + console.log('\n📝 Page Content:\n' + '='.repeat(60)); + console.log(bodyText); + console.log('='.repeat(60)); + + const buttons = await page.locator('button').count(); + const forms = await page.locator('form').count(); + const inputs = await page.locator('input').count(); + + console.log('\n🔍 Elements Found:'); + console.log(' - Buttons:', buttons); + console.log(' - Forms:', forms); + console.log(' - Inputs:', inputs); + + await page.screenshot({ path: 'shinkan-dev-screenshot.png', fullPage: true }); + console.log('\n📸 Screenshot: shinkan-dev-screenshot.png'); + + } catch (error) { + console.error('❌ Error:', error.message); + } + + await browser.close(); +})();