From 3eb7ef3ae6bf03a27901a0121fe04a1636a2340c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 21 Apr 2026 08:19:34 +0200 Subject: [PATCH 1/9] feat: enhance navigation state handling for history overview - Updated NavLink components in App and DesktopSidebar to conditionally pass state for the '/history' route, improving user experience by maintaining the selected tab on navigation. --- frontend/src/App.jsx | 1 + frontend/src/components/DesktopSidebar.jsx | 1 + 2 files changed, 2 insertions(+) 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' + From 2453da0da181fa451ea1a948beca8c2278ef5dfd Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 07:00:24 +0200 Subject: [PATCH 2/9] feat: add body_history_viz widget and enhance configuration handling - Introduced the `body_history_viz` widget to the dashboard, allowing users to visualize body history data. - Updated widget configuration to include `body_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `body_history_viz` entry. - Added tests to ensure proper validation of the `body_history_viz` widget configuration. - Updated application version to reflect the addition of the new widget. --- .claude/docs/working/SHINKAN_PROJECT_SETUP.md | 1202 +++++++++++++++++ backend/dashboard_widget_config.py | 3 + backend/tests/test_dashboard_widget_config.py | 7 + backend/version.py | 2 +- backend/widget_catalog.py | 6 + .../BodyHistoryVizWidget.jsx | 29 + .../history/BodyHistoryVizSection.jsx | 552 ++++++++ .../components/history/historyPageChrome.jsx | 87 ++ frontend/src/pages/DashboardLabPage.jsx | 1 + frontend/src/pages/History.jsx | 559 +------- .../widgetSystem/registerPilotLabWidgets.js | 9 + shinkan-dev-screenshot.png | Bin 0 -> 24878 bytes test-shinkan.js | 40 + 13 files changed, 1952 insertions(+), 545 deletions(-) create mode 100644 .claude/docs/working/SHINKAN_PROJECT_SETUP.md create mode 100644 frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx create mode 100644 frontend/src/components/history/BodyHistoryVizSection.jsx create mode 100644 frontend/src/components/history/historyPageChrome.jsx create mode 100644 shinkan-dev-screenshot.png create mode 100644 test-shinkan.js 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_widget_config.py b/backend/dashboard_widget_config.py index 729c1ea..d84e9cf 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -14,6 +14,7 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072 WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", + "body_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -52,6 +53,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: if widget_id == "body_overview": return _validate_chart_days_only(raw, label="body_overview") + if widget_id == "body_history_viz": + return _validate_chart_days_only(raw, label="body_history_viz") if widget_id == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 9f30f19..e3bb2f1 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -14,6 +14,13 @@ def test_body_chart_days_bounds(): validate_widget_entry_config("body_overview", {"chart_days": 91}) +def test_body_history_viz_chart_days(): + assert validate_widget_entry_config("body_history_viz", {}) == {} + assert validate_widget_entry_config("body_history_viz", {"chart_days": 60}) == {"chart_days": 60} + with pytest.raises(ValueError): + validate_widget_entry_config("body_history_viz", {"chart_days": 5}) + + def test_welcome_config_rejected_unknown_key(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/version.py b/backend/version.py index 7393c96..98586bd 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "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.12.0", # Widget body_history_viz (Verlauf body-history-viz Bundle) "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..f94f75e 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": "Wie Verlauf → Körper: GET /charts/body-history-viz (optional chart_days 7–90); Feature weight_entries", + "requires_feature": "weight_entries", + }, { "id": "activity_overview", "title": "Aktivität", diff --git a/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx new file mode 100644 index 0000000..f7d2c04 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx @@ -0,0 +1,29 @@ +import { useNavigate } from 'react-router-dom' +import BodyHistoryVizSection from '../history/BodyHistoryVizSection' +import { useProfile } from '../../context/ProfileContext' +import { BODY_CHART_DAYS_DEFAULT, normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), optional chart_days 7–90. + * @param {{ refreshTick?: number, chartDays?: number }} props + */ +export default function BodyHistoryVizWidget({ refreshTick = 0, chartDays }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const days = chartDays != null ? normalizeBodyChartDays(chartDays) : BODY_CHART_DAYS_DEFAULT + + return ( +
+
+
+
Körper (Verlauf-Bundle)
+
body-history-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..1a32d03 --- /dev/null +++ b/frontend/src/components/history/BodyHistoryVizSection.jsx @@ -0,0 +1,552 @@ +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 { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome' + +dayjs.locale('de') + +const fmtDate = (d) => dayjs(d).format('DD.MM') + +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) + */ +export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false }) { + 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(() => { + 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 ( +
+ {!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 && } + + + +

+ 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) && } + + +
+ )} + + {footer} +
+ ) +} 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/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 7145d59..aaba5ac 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -17,6 +17,7 @@ import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSys /** 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', 'recovery_charts_panel', diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index d33d09c..098ab03 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -17,49 +17,14 @@ import FitnessDashboardOverview from '../components/FitnessDashboardOverview' import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts' import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import KpiTilesOverview from '../components/KpiTilesOverview' +import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' +import { EmptySection, NavToCaliper, NavToCircum, 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,142 +46,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) @@ -262,51 +91,6 @@ function NutritionGoalsStrip({ grouped }) { ) } -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,331 +138,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 || []) @@ -1844,7 +1303,19 @@ export default function History() {
{tab==='overview' && } - {tab==='body' && } + {tab==='body' && ( + + )} + /> + )} {tab==='nutrition' && } {tab==='activity' && } {tab==='photos' && } diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 1839b04..1cb1a78 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -14,6 +14,7 @@ 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 RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -57,6 +58,14 @@ export function ensurePilotLabWidgetsRegistered() { chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), }), }) + registerDashboardWidget({ + id: 'body_history_viz', + Component: BodyHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + chartDays: ctx.layoutEntry?.config?.chart_days, + }), + }) registerDashboardWidget({ id: 'activity_overview', Component: PilotActivitySection, diff --git a/shinkan-dev-screenshot.png b/shinkan-dev-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..5a2668149f459920183f7398b55edfdd49de5577 GIT binary patch literal 24878 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 y2>jbk`u~Sp{Y4A#%Z-hnGxD{;G { + 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(); +})(); From 01c0d1745ff97f844882d4e291c0c1f76f3e09a0 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 08:38:38 +0200 Subject: [PATCH 3/9] feat: implement merge_missing_catalog_widgets function to enhance dashboard layout - Added the `merge_missing_catalog_widgets` function to append missing widget IDs from the catalog to the dashboard layout while preserving the existing order. - Updated the admin and app dashboard routes to utilize the new function, ensuring that new catalog entries are visible without requiring users to reset their layouts. - Enhanced tests to validate the functionality of the new merging logic, ensuring proper integration with existing layouts. - Bumped application version to reflect these changes. --- backend/dashboard_layout_schema.py | 21 +++++++++++++++++++ backend/routers/admin.py | 11 +++++++--- backend/routers/app_dashboard.py | 5 ++++- backend/tests/test_dashboard_layout_schema.py | 17 +++++++++++++++ backend/version.py | 4 ++-- frontend/src/pages/DashboardConfigurePage.jsx | 1 + 6 files changed, 53 insertions(+), 6 deletions(-) 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/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/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/version.py b/backend/version.py index 98586bd..c772c0d 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.12.0", # Widget body_history_viz (Verlauf body-history-viz Bundle) + "app_dashboard": "1.12.1", # GET layout: merge_missing_catalog_widgets (neue Katalog-IDs sichtbar) "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/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 17e5337..5271bbb 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -20,6 +20,7 @@ import { const CHART_DAYS_WIDGET_IDS = new Set([ 'body_overview', + 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', 'recovery_charts_panel', From 20f195aca13284274c01ceec75d7c0660c260bc9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 08:51:06 +0200 Subject: [PATCH 4/9] feat: enhance body_history_viz widget configuration and validation - Added new configuration options for the `body_history_viz` widget, including visibility settings for various charts and KPIs. - Implemented default values for the widget's configuration to streamline user experience. - Enhanced validation logic to ensure proper handling of configuration inputs, including error handling for unknown keys and visibility requirements. - Updated tests to cover new configuration scenarios and validation rules for the `body_history_viz` widget. - Bumped application version to reflect these changes. --- backend/dashboard_widget_config.py | 79 +++++++++++++++- backend/tests/test_dashboard_widget_config.py | 29 +++++- backend/version.py | 2 +- backend/widget_catalog.py | 2 +- .../BodyHistoryVizWidget.jsx | 20 ++-- .../history/BodyHistoryVizSection.jsx | 83 ++++++++++++----- frontend/src/pages/DashboardConfigurePage.jsx | 18 ++++ frontend/src/pages/DashboardLabPage.jsx | 42 +++++++-- .../BodyHistoryVizConfigEditor.jsx | 91 +++++++++++++++++++ .../src/widgetSystem/bodyHistoryVizConfig.js | 81 +++++++++++++++++ .../widgetSystem/registerPilotLabWidgets.js | 3 +- 11 files changed, 400 insertions(+), 50 deletions(-) create mode 100644 frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/bodyHistoryVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index d84e9cf..b14e9ea 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -33,6 +33,32 @@ _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, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -40,21 +66,26 @@ 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({}) + return {} if widget_id == "body_overview": return _validate_chart_days_only(raw, label="body_overview") if widget_id == "body_history_viz": - return _validate_chart_days_only(raw, label="body_history_viz") + return _validate_body_history_viz_config(raw) if widget_id == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -153,6 +184,44 @@ 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_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index e3bb2f1..42b57d9 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -14,13 +14,36 @@ def test_body_chart_days_bounds(): validate_widget_entry_config("body_overview", {"chart_days": 91}) -def test_body_history_viz_chart_days(): - assert validate_widget_entry_config("body_history_viz", {}) == {} - assert validate_widget_entry_config("body_history_viz", {"chart_days": 60}) == {"chart_days": 60} +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_welcome_config_rejected_unknown_key(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/version.py b/backend/version.py index c772c0d..d1b2bb0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.12.1", # GET layout: merge_missing_catalog_widgets (neue Katalog-IDs sichtbar) + "app_dashboard": "1.13.0", # body_history_viz: Sichtbarkeits-Config + Defaults schlank "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 f94f75e..e6007b2 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -45,7 +45,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "body_history_viz", "title": "Körper (Verlauf-Bundle)", - "description": "Wie Verlauf → Körper: GET /charts/body-history-viz (optional chart_days 7–90); Feature weight_entries", + "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", }, { diff --git a/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx index f7d2c04..5cb1a9c 100644 --- a/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx +++ b/frontend/src/components/dashboard-widgets/BodyHistoryVizWidget.jsx @@ -1,16 +1,18 @@ import { useNavigate } from 'react-router-dom' import BodyHistoryVizSection from '../history/BodyHistoryVizSection' import { useProfile } from '../../context/ProfileContext' -import { BODY_CHART_DAYS_DEFAULT, normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' +import { normalizeBodyHistoryVizConfig } from '../../widgetSystem/bodyHistoryVizConfig' /** - * Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), optional chart_days 7–90. - * @param {{ refreshTick?: number, chartDays?: number }} props + * 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, chartDays }) { +export default function BodyHistoryVizWidget({ refreshTick = 0, bodyHistoryVizConfig }) { const nav = useNavigate() const { activeProfile } = useProfile() - const days = chartDays != null ? normalizeBodyChartDays(chartDays) : BODY_CHART_DAYS_DEFAULT + const cfg = normalizeBodyHistoryVizConfig(bodyHistoryVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) return (
@@ -23,7 +25,13 @@ export default function BodyHistoryVizWidget({ refreshTick = 0, chartDays }) { Verlauf →
- +
) } diff --git a/frontend/src/components/history/BodyHistoryVizSection.jsx b/frontend/src/components/history/BodyHistoryVizSection.jsx index 1a32d03..05a6ff8 100644 --- a/frontend/src/components/history/BodyHistoryVizSection.jsx +++ b/frontend/src/components/history/BodyHistoryVizSection.jsx @@ -17,12 +17,25 @@ 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' @@ -222,8 +235,15 @@ function BodyGoalsStrip({ grouped }) { * @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 }) { +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 @@ -234,6 +254,10 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer const [vizError, setVizError] = useState(null) useEffect(() => { + if (!display.show_goals_strip) { + setGroupedGoals({}) + return undefined + } let cancelled = false api.listGoalsGrouped() .then((g) => { @@ -245,7 +269,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer return () => { cancelled = true } - }, []) + }, [display.show_goals_strip]) useEffect(() => { let cancelled = false @@ -334,6 +358,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer weightTrendKpi: w?.trend_kpi, goalW, }) + const kpiTilesShown = display.show_kpis ? filterBodyHistoryKpiTiles(kpiTiles, display.kpi_detail || 'full') : [] const hasAnyData = (w?.data_points > 0) || @@ -378,23 +403,25 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer )} {showPeriodSelector && } - + {display.show_goals_strip && } -

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

+ {display.show_intro_blurb && ( +

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

+ )} - {viz?.meta?.layer_2a_alignment && ( + {display.show_layer_meta && viz?.meta?.layer_2a_alignment && (
{viz.meta.layer_2a_alignment}
)} - + {kpiTilesShown.length > 0 && } {vizLoading &&
Aktualisiere…
} - {hasWeight && ( + {display.show_weight_chart && hasWeight && (
@@ -404,7 +431,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer Daten
- + + @@ -420,7 +448,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer - + +
● Täglich Ø 7T @@ -433,13 +462,14 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
)} - {bfCd.length >= 2 && ( + {display.show_body_fat_chart && bfCd.length >= 2 && (
Körperfett (Caliper)
- + + @@ -448,12 +478,13 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer {goalBf != null && } - + +
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
)} - {propChartData.length >= 2 && ( + {display.show_proportion_chart && propChartData.length >= 2 && (
@@ -469,7 +500,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
- + + @@ -487,7 +519,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer {showBellyOnProp && } - + +
Brust − Taille gleitender Mittelwert @@ -496,7 +529,7 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
)} - {idxOk && ( + {display.show_circumference_index_chart && idxOk && (
@@ -505,7 +538,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
- + + @@ -516,7 +550,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer {idxSeries.some((d) => d.waist_idx != null) && } {idxSeries.some((d) => d.belly_idx != null) && } - + +
Brust Taille @@ -525,14 +560,15 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer
)} - {propChartData.length < 2 && cirCd.length >= 2 && ( + {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.
- + + @@ -542,7 +578,8 @@ export default function BodyHistoryVizSection({ profile, externalPeriod, footer {cirCd.some((d) => d.belly) && } - + +
)} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 5271bbb..5776033 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -11,6 +11,7 @@ import { } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' +import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -496,6 +497,23 @@ 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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index aaba5ac..26720f3 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -12,6 +12,7 @@ import { } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' +import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -319,11 +320,13 @@ 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 } } + }), + }) + ) + } + /> + )} ) })} 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/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/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 1cb1a78..96ad498 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -15,6 +15,7 @@ import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeig import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget' +import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -63,7 +64,7 @@ export function ensurePilotLabWidgetsRegistered() { Component: BodyHistoryVizWidget, mapProps: (ctx) => ({ refreshTick: ctx.refreshTick, - chartDays: ctx.layoutEntry?.config?.chart_days, + bodyHistoryVizConfig: normalizeBodyHistoryVizConfig(ctx.layoutEntry?.config), }), }) registerDashboardWidget({ From db5557e4aa01353213783a3b10ba4c6b16304daf Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 10:03:23 +0200 Subject: [PATCH 5/9] feat: add nutrition_history_viz widget and enhance configuration handling - Introduced the `nutrition_history_viz` widget to the dashboard, allowing users to visualize nutrition history data. - Updated widget configuration to include `nutrition_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `nutrition_history_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `nutrition_history_viz` widget configuration. - Bumped application version to reflect the addition of the new widget. --- backend/dashboard_widget_config.py | 73 +++ backend/tests/test_dashboard_widget_config.py | 31 + backend/version.py | 2 +- backend/widget_catalog.py | 6 + .../NutritionHistoryVizWidget.jsx | 34 + .../history/NutritionHistoryVizSection.jsx | 608 ++++++++++++++++++ frontend/src/pages/DashboardConfigurePage.jsx | 19 + frontend/src/pages/DashboardLabPage.jsx | 27 +- frontend/src/pages/History.jsx | 521 +-------------- .../NutritionHistoryVizConfigEditor.jsx | 92 +++ .../widgetSystem/nutritionHistoryVizConfig.js | 79 +++ .../widgetSystem/registerPilotLabWidgets.js | 10 + 12 files changed, 993 insertions(+), 509 deletions(-) create mode 100644 frontend/src/components/dashboard-widgets/NutritionHistoryVizWidget.jsx create mode 100644 frontend/src/components/history/NutritionHistoryVizSection.jsx create mode 100644 frontend/src/widgetSystem/NutritionHistoryVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/nutritionHistoryVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index b14e9ea..fb97e31 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -15,6 +15,7 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072 WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", "body_history_viz", + "nutrition_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -59,6 +60,34 @@ _BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "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, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -80,12 +109,16 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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({}) 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 == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -222,6 +255,46 @@ def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: 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_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 42b57d9..1a1244a 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -44,6 +44,37 @@ def test_body_history_viz_unknown_key(): 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_welcome_config_rejected_unknown_key(): with pytest.raises(ValueError): validate_widget_entry_config("welcome", {"x": 1}) diff --git a/backend/version.py b/backend/version.py index d1b2bb0..1e2cb34 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.13.0", # body_history_viz: Sichtbarkeits-Config + Defaults schlank + "app_dashboard": "1.14.0", # nutrition_history_viz: Verlauf-Bundle-Widget + Config wie Körper "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 e6007b2..5b6f0c8 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -100,6 +100,12 @@ 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": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", 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/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/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 5776033..3ec05a8 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -12,6 +12,7 @@ import { import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -24,6 +25,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', 'recovery_charts_panel', ]) @@ -514,6 +516,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 26720f3..7c3a0ee 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -13,6 +13,7 @@ import { import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -21,6 +22,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'body_history_viz', 'activity_overview', 'nutrition_detail_charts', + 'nutrition_history_viz', 'recovery_charts_panel', ]) @@ -326,7 +328,9 @@ export default function DashboardLabPage() { ? 'Aktivität (Verteilung & Konsistenz)' : w.id === 'nutrition_detail_charts' ? 'Ernährung — Charts' - : 'Erholung — Charts'}{' '} + : w.id === 'nutrition_history_viz' + ? 'Ernährung (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 098ab03..5ba9ed8 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -4,27 +4,23 @@ import { useProfile } from '../context/ProfileContext' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - ReferenceLine, PieChart, Pie, Cell, ComposedChart, + ReferenceLine, 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 BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' +import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' import { EmptySection, NavToCaliper, NavToCircum, 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 RuleCard({ item }) { const [open, setOpen] = useState(false) const color = getStatusColor(item.status) @@ -46,51 +42,6 @@ function RuleCard({ item }) { ) } -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 InsightBox({ insights, slugs, onRequest, loading }) { const [expanded, setExpanded] = useState(null) const relevant = insights?.filter(i=>slugs.includes(i.scope))||[] @@ -138,459 +89,6 @@ function InsightBox({ insights, slugs, onRequest, loading }) { ) } -/** 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) @@ -1316,7 +814,18 @@ export default function History() { )} /> )} - {tab==='nutrition' && } + {tab === 'nutrition' && ( + + )} + /> + )} {tab==='activity' && } {tab==='photos' && }
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/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/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 96ad498..0992c6b 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -15,7 +15,9 @@ import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeig 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 { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' +import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -122,6 +124,14 @@ 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: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget, From d22e0ba0a7d352c668147f09b98d3e18232149ae Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 10:13:21 +0200 Subject: [PATCH 6/9] feat: add fitness_history_viz widget and enhance configuration handling - Introduced the `fitness_history_viz` widget to the dashboard, enabling users to visualize fitness history data. - Updated widget configuration to include `fitness_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `fitness_history_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `fitness_history_viz` widget configuration. - Bumped application version to reflect the addition of the new widget. --- backend/dashboard_widget_config.py | 64 +++ backend/tests/test_dashboard_widget_config.py | 38 ++ .../test_system_dashboard_product_default.py | 6 +- backend/version.py | 2 +- backend/widget_catalog.py | 6 + .../components/FitnessDashboardOverview.jsx | 398 ++++++++++-------- .../FitnessHistoryVizWidget.jsx | 29 ++ .../history/FitnessHistoryVizSection.jsx | 33 ++ frontend/src/pages/DashboardConfigurePage.jsx | 19 + frontend/src/pages/DashboardLabPage.jsx | 27 +- frontend/src/pages/History.jsx | 4 +- .../FitnessHistoryVizConfigEditor.jsx | 89 ++++ .../widgetSystem/fitnessHistoryVizConfig.js | 70 +++ .../widgetSystem/registerPilotLabWidgets.js | 10 + 14 files changed, 616 insertions(+), 179 deletions(-) create mode 100644 frontend/src/components/dashboard-widgets/FitnessHistoryVizWidget.jsx create mode 100644 frontend/src/components/history/FitnessHistoryVizSection.jsx create mode 100644 frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/fitnessHistoryVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index fb97e31..f4fdff6 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -16,6 +16,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_overview", "body_history_viz", "nutrition_history_viz", + "fitness_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -88,6 +89,28 @@ _NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "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, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -111,6 +134,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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({}) return {} if widget_id == "body_overview": @@ -119,6 +144,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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 == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -295,6 +322,43 @@ def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any 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_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: allowed = frozenset({"chart_days"}) unknown = set(raw) - allowed diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 1a1244a..c72cbe2 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -75,6 +75,44 @@ def test_nutrition_history_viz_unknown_key(): 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_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..c1dd7a0 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: 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 1e2cb34..fd814bf 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.14.0", # nutrition_history_viz: Verlauf-Bundle-Widget + Config wie Körper + "app_dashboard": "1.15.0", # fitness_history_viz: Verlauf-Bundle-Widget + Config "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 5b6f0c8..1c5e0b4 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -106,6 +106,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "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_charts_panel", "title": "Erholung — Charts R1–R5", 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/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/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/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 3ec05a8..16d7553 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -13,6 +13,7 @@ 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 { moveWidget, moveWidgetToIndex, @@ -26,6 +27,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'activity_overview', 'nutrition_detail_charts', 'nutrition_history_viz', + 'fitness_history_viz', 'recovery_charts_panel', ]) @@ -533,6 +535,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 7c3a0ee..0e7cce2 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -14,6 +14,7 @@ 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 { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -23,6 +24,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'activity_overview', 'nutrition_detail_charts', 'nutrition_history_viz', + 'fitness_history_viz', 'recovery_charts_panel', ]) @@ -330,7 +332,9 @@ export default function DashboardLabPage() { ? 'Ernährung — Charts' : w.id === 'nutrition_history_viz' ? 'Ernährung (Verlauf-Bundle)' - : 'Erholung — Charts'}{' '} + : w.id === 'fitness_history_viz' + ? 'Fitness (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 5ba9ed8..071a950 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -12,7 +12,7 @@ import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' -import FitnessDashboardOverview from '../components/FitnessDashboardOverview' +import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' @@ -100,7 +100,7 @@ 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) 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/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/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 0992c6b..f0b2366 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -16,8 +16,10 @@ import NutritionActivitySummaryWidget from '../components/dashboard-widgets/Nutr 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 { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' +import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -132,6 +134,14 @@ export function ensurePilotLabWidgetsRegistered() { 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_charts_panel', Component: RecoveryChartsPanelWidget, From e20b321b64190e3a53b015e1c7c174eb68ea982f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 10:18:02 +0200 Subject: [PATCH 7/9] feat: add recovery_history_viz widget and enhance configuration handling - Introduced the `recovery_history_viz` widget to the dashboard, enabling users to visualize recovery history data. - Updated widget configuration to include `recovery_history_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `recovery_history_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `recovery_history_viz` widget configuration. - Bumped application version to reflect the addition of the new widget. --- backend/dashboard_widget_config.py | 76 +++ backend/tests/test_dashboard_widget_config.py | 40 ++ .../test_system_dashboard_product_default.py | 2 +- backend/version.py | 2 +- backend/widget_catalog.py | 5 + frontend/src/components/RecoveryCharts.jsx | 4 +- .../components/RecoveryDashboardOverview.jsx | 556 ++++++++++-------- .../RecoveryChartsPanelWidget.jsx | 2 +- .../RecoveryHistoryVizWidget.jsx | 29 + .../history/RecoveryHistoryVizSection.jsx | 26 + frontend/src/pages/DashboardConfigurePage.jsx | 19 + frontend/src/pages/DashboardLabPage.jsx | 27 +- frontend/src/pages/History.jsx | 4 +- .../RecoveryHistoryVizConfigEditor.jsx | 106 ++++ .../widgetSystem/recoveryHistoryVizConfig.js | 85 +++ .../widgetSystem/registerPilotLabWidgets.js | 10 + 16 files changed, 735 insertions(+), 258 deletions(-) create mode 100644 frontend/src/components/dashboard-widgets/RecoveryHistoryVizWidget.jsx create mode 100644 frontend/src/components/history/RecoveryHistoryVizSection.jsx create mode 100644 frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/recoveryHistoryVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index f4fdff6..e6310fc 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -17,6 +17,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "body_history_viz", "nutrition_history_viz", "fitness_history_viz", + "recovery_history_viz", "activity_overview", "kpi_board", "quick_capture", @@ -111,6 +112,38 @@ _FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "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, +} + def _config_json_size_bytes(config: dict[str, Any]) -> int: return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) @@ -136,6 +169,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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({}) return {} if widget_id == "body_overview": @@ -146,6 +181,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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 == "activity_overview": return _validate_chart_days_only(raw, label="activity_overview") if widget_id == "kpi_board": @@ -359,6 +396,45 @@ def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]: 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 _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/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index c72cbe2..496dcd2 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -113,6 +113,46 @@ def test_fitness_history_viz_unknown_key(): 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_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 c1dd7a0..4b3e662 100644 --- a/backend/tests/test_system_dashboard_product_default.py +++ b/backend/tests/test_system_dashboard_product_default.py @@ -33,7 +33,7 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch): "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], } # Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert - # (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz: leere config → volle Defaults in to_stored_dict). + # (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: diff --git a/backend/version.py b/backend/version.py index fd814bf..174510d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.15.0", # fitness_history_viz: Verlauf-Bundle-Widget + Config + "app_dashboard": "1.16.0", # recovery_history_viz: Verlauf-Bundle-Widget + Config "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 1c5e0b4..4d5947b 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -112,6 +112,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "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": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", 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/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/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/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 16d7553..8aabc7a 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -14,6 +14,7 @@ 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 { moveWidget, moveWidgetToIndex, @@ -28,6 +29,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_detail_charts', 'nutrition_history_viz', 'fitness_history_viz', + 'recovery_history_viz', 'recovery_charts_panel', ]) @@ -552,6 +554,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 0e7cce2..38f41b9 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -15,6 +15,7 @@ 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 { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ @@ -25,6 +26,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_detail_charts', 'nutrition_history_viz', 'fitness_history_viz', + 'recovery_history_viz', 'recovery_charts_panel', ]) @@ -334,7 +336,9 @@ export default function DashboardLabPage() { ? 'Ernährung (Verlauf-Bundle)' : w.id === 'fitness_history_viz' ? 'Fitness (Verlauf-Bundle)' - : 'Erholung — Charts'}{' '} + : w.id === 'recovery_history_viz' + ? 'Erholung (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 071a950..8cd9121 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -13,7 +13,7 @@ import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoD import { getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSection' -import RecoveryDashboardOverview from '../components/RecoveryDashboardOverview' +import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' @@ -105,7 +105,7 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f
Erholung (Schlaf, HRV, Vitalwerte)
- + {activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
, 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/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 f0b2366..35e478f 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -17,9 +17,11 @@ import NutritionDetailChartsWidget from '../components/dashboard-widgets/Nutriti 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 { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig' import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig' import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig' +import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' @@ -142,6 +144,14 @@ export function ensurePilotLabWidgetsRegistered() { fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config), }), }) + registerDashboardWidget({ + id: 'recovery_history_viz', + Component: RecoveryHistoryVizWidget, + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config), + }), + }) registerDashboardWidget({ id: 'recovery_charts_panel', Component: RecoveryChartsPanelWidget, From 97dbb0f80bba31b56ea08a443f142f41c529450c Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 11:55:11 +0200 Subject: [PATCH 8/9] feat: add history_overview_viz widget and enhance configuration handling - Introduced the `history_overview_viz` widget to the dashboard, allowing users to visualize consolidated history data across various metrics. - Updated widget configuration to include `history_overview_viz` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `history_overview_viz` entry. - Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs. - Added tests to ensure proper validation of the `history_overview_viz` widget configuration. - Bumped application version to reflect the addition of the new widget. --- backend/dashboard_widget_config.py | 56 ++ .../data_layer/correlation_chart_payloads.py | 256 +++++++++ backend/data_layer/history_overview_viz.py | 12 + backend/routers/charts.py | 269 +-------- backend/tests/test_dashboard_widget_config.py | 35 ++ backend/version.py | 2 +- backend/widget_catalog.py | 5 + .../HistoryOverviewVizWidget.jsx | 35 ++ .../history/HistoryOverviewVizSection.jsx | 521 ++++++++++++++++++ frontend/src/pages/DashboardConfigurePage.jsx | 19 + frontend/src/pages/DashboardLabPage.jsx | 35 +- frontend/src/pages/History.jsx | 477 +--------------- .../HistoryOverviewVizConfigEditor.jsx | 50 ++ .../widgetSystem/historyOverviewVizConfig.js | 50 ++ .../widgetSystem/registerPilotLabWidgets.js | 10 + 15 files changed, 1105 insertions(+), 727 deletions(-) create mode 100644 backend/data_layer/correlation_chart_payloads.py create mode 100644 frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx create mode 100644 frontend/src/components/history/HistoryOverviewVizSection.jsx create mode 100644 frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/historyOverviewVizConfig.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index e6310fc..193453b 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -18,6 +18,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "nutrition_history_viz", "fitness_history_viz", "recovery_history_viz", + "history_overview_viz", "activity_overview", "kpi_board", "quick_capture", @@ -144,6 +145,23 @@ _RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "show_vitals_extra_trends": False, } +_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({ + "show_confidence_banner", + "show_intro_blurb", + "show_area_summaries", + "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_area_summaries": 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")) @@ -171,6 +189,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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": @@ -183,6 +203,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: 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": @@ -435,6 +457,40 @@ def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any] return out +def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "history_overview_viz" + allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"}) + unknown = set(raw) - 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: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + 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 any( + out[k] + for k in ( + "show_confidence_banner", + "show_area_summaries", + "show_correlation_c1_c3", + "show_drivers_c4", + ) + ): + raise ValueError( + f"{label}: mindestens Datenlage-Banner, Bereichs-Kacheln, 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/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_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 496dcd2..a1e0038 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -153,6 +153,41 @@ def test_recovery_history_viz_unknown_key(): 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_area_summaries"] 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_area_summaries": False, + "show_correlation_c1_c3": False, + "show_drivers_c4": False, + }, + ) + + +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/version.py b/backend/version.py index 174510d..58b208d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.16.0", # recovery_history_viz: Verlauf-Bundle-Widget + Config + "app_dashboard": "1.17.0", # history_overview_viz Widget + chart_payloads im Overview-Bundle "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 4d5947b..5212aa2 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -117,6 +117,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ "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: konsolidierte Kurzinfos (Körper/Ernährung/Fitness/Erholung) + C1–C4; chart_payloads im Bundle; chart_days 7–90; Blöcke per show_*", + }, { "id": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", 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/history/HistoryOverviewVizSection.jsx b/frontend/src/components/history/HistoryOverviewVizSection.jsx new file mode 100644 index 0000000..bed2ccc --- /dev/null +++ b/frontend/src/components/history/HistoryOverviewVizSection.jsx @@ -0,0 +1,521 @@ +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 + + 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). +

+ )} + + {vis.show_area_summaries && (sections.length === 0 ? ( + + ) : ( +
+ {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}
+
+ ))} +
+ ) + })} +
+ ))} + + {vis.show_correlation_c1_c3 && ( + <> +
Lag-Korrelationen (C1–C3)
+
+ + + +
+ + )} + + {vis.show_drivers_c4 && ( + <> +
Einflussfaktoren (C4)
+ + + )} + + {footer} +
+ ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 8aabc7a..d8b5c55 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -15,6 +15,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' +import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' import { moveWidget, moveWidgetToIndex, @@ -30,6 +31,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_history_viz', 'fitness_history_viz', 'recovery_history_viz', + 'history_overview_viz', 'recovery_charts_panel', ]) @@ -571,6 +573,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {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 38f41b9..736adc1 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -16,6 +16,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit 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 */ @@ -27,6 +28,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([ 'nutrition_history_viz', 'fitness_history_viz', 'recovery_history_viz', + 'history_overview_viz', 'recovery_charts_panel', ]) @@ -336,9 +338,11 @@ export default function DashboardLabPage() { ? 'Ernährung (Verlauf-Bundle)' : w.id === 'fitness_history_viz' ? 'Fitness (Verlauf-Bundle)' - : w.id === 'recovery_history_viz' - ? 'Erholung (Verlauf-Bundle)' - : 'Erholung — Charts'}{' '} + : w.id === 'history_overview_viz' + ? 'Gesamtübersicht (Verlauf-Bundle)' + : w.id === 'recovery_history_viz' + ? 'Erholung (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {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 8cd9121..d31df96 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -1,12 +1,6 @@ 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, Cell, ComposedChart, - ScatterChart, Scatter, -} from 'recharts' import { Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay' @@ -16,7 +10,8 @@ import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSec import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection' import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection' import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection' -import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome' +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') @@ -138,459 +133,6 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f ) } -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) })) -} - -/** 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([]) @@ -800,7 +342,18 @@ export default function History() {
- {tab==='overview' && } + {tab === 'overview' && ( + + )} + /> + )} {tab==='body' && ( , 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): konsolidierte Kurzinfos und Korrelations-Kacheln — wie im Verlauf-Reiter «Gesamt». +
+
Bereiche
+
+ {TOGGLES.map(({ key, label }) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/widgetSystem/historyOverviewVizConfig.js b/frontend/src/widgetSystem/historyOverviewVizConfig.js new file mode 100644 index 0000000..77ba609 --- /dev/null +++ b/frontend/src/widgetSystem/historyOverviewVizConfig.js @@ -0,0 +1,50 @@ +/** + * 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_PAGE_FULL = { + chart_days: 30, + show_confidence_banner: true, + show_intro_blurb: true, + show_area_summaries: 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_area_summaries: true, + show_correlation_c1_c3: true, + show_drivers_c4: true, +} + +const BOOL_KEYS = [ + 'show_confidence_banner', + 'show_intro_blurb', + 'show_area_summaries', + 'show_correlation_c1_c3', + 'show_drivers_c4', +] + +/** + * @param {Record|null|undefined} raw + */ +export function normalizeHistoryOverviewVizConfig(raw) { + const base = { ...HISTORY_OVERVIEW_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.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/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 35e478f..7c7260c 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -18,10 +18,12 @@ import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryViz 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' @@ -152,6 +154,14 @@ export function ensurePilotLabWidgetsRegistered() { 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, From 725e7ffe4b4e8c9d31b46bff7e44a5cf2982d222 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 12:04:37 +0200 Subject: [PATCH 9/9] feat: update history_overview_viz configuration and validation - Replaced the `show_area_summaries` option with individual section visibility settings (`show_section_body`, `show_section_nutrition`, `show_section_fitness`, `show_section_recovery`) in the `history_overview_viz` widget configuration. - Implemented migration logic to handle legacy `show_area_summaries` settings, ensuring backward compatibility. - Updated validation logic to enforce visibility requirements for the new section keys. - Enhanced tests to cover new configuration scenarios and validate the migration logic. - Bumped application version to reflect these changes. --- backend/dashboard_widget_config.py | 48 ++++++++++++++----- backend/tests/test_dashboard_widget_config.py | 20 +++++++- backend/version.py | 2 +- backend/widget_catalog.py | 2 +- .../history/HistoryOverviewVizSection.jsx | 18 ++++++- .../HistoryOverviewVizConfigEditor.jsx | 25 ++++++++-- .../widgetSystem/historyOverviewVizConfig.js | 31 ++++++++++-- 7 files changed, 121 insertions(+), 25 deletions(-) diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 193453b..9e8a4a0 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -145,10 +145,17 @@ _RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = { "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", - "show_area_summaries", + *_HISTORY_OVERVIEW_VIZ_SECTION_KEYS, "show_correlation_c1_c3", "show_drivers_c4", }) @@ -157,7 +164,10 @@ _HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = { "chart_days": 30, "show_confidence_banner": True, "show_intro_blurb": True, - "show_area_summaries": 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, } @@ -457,36 +467,52 @@ def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any] 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) - allowed + 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: + if k not in raw_m: continue - v = raw[k] + 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: - v = _parse_chart_days(raw["chart_days"], label) + 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 - if not any( + 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_area_summaries", "show_correlation_c1_c3", "show_drivers_c4", ) - ): + ) + if not has_section and not has_other: raise ValueError( - f"{label}: mindestens Datenlage-Banner, Bereichs-Kacheln, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein" + f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein" ) return out diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index a1e0038..b39a998 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -157,7 +157,10 @@ 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_area_summaries"] 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 @@ -176,13 +179,26 @@ def test_history_overview_viz_requires_visible_block(): "history_overview_viz", { "show_confidence_banner": False, - "show_area_summaries": 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}) diff --git a/backend/version.py b/backend/version.py index 58b208d..e2fa4c9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) - "app_dashboard": "1.17.0", # history_overview_viz Widget + chart_payloads im Overview-Bundle + "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 5212aa2..e83720d 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -120,7 +120,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "history_overview_viz", "title": "Verlauf — Gesamtübersicht", - "description": "Layer-2b history-overview-viz: konsolidierte Kurzinfos (Körper/Ernährung/Fitness/Erholung) + C1–C4; chart_payloads im Bundle; chart_days 7–90; Blöcke per show_*", + "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", diff --git a/frontend/src/components/history/HistoryOverviewVizSection.jsx b/frontend/src/components/history/HistoryOverviewVizSection.jsx index bed2ccc..0432f7e 100644 --- a/frontend/src/components/history/HistoryOverviewVizSection.jsx +++ b/frontend/src/components/history/HistoryOverviewVizSection.jsx @@ -363,6 +363,20 @@ export default function HistoryOverviewVizSection({ 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 && } @@ -402,11 +416,11 @@ export default function HistoryOverviewVizSection({

)} - {vis.show_area_summaries && (sections.length === 0 ? ( + {wantsAnySectionTile && (visibleSections.length === 0 ? ( ) : (
- {sections.map((sec) => { + {visibleSections.map((sec) => { const tone = overviewSectionTone(sec) const stripe = getStatusColor(tone) const badgeBg = getStatusBg(tone) diff --git a/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx b/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx index 052db61..67c064e 100644 --- a/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx +++ b/frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx @@ -3,10 +3,16 @@ import { normalizeHistoryOverviewVizConfig, } from './historyOverviewVizConfig' -const TOGGLES = [ +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_area_summaries', label: 'Kacheln Körper · Ernährung · Fitness · Erholung' }, { key: 'show_correlation_c1_c3', label: 'Lag-Korrelationen C1–C3 (Charts)' }, { key: 'show_drivers_c4', label: 'Einflussfaktoren C4' }, ] @@ -34,11 +40,20 @@ export default function HistoryOverviewVizConfigEditor({ config, onChange }) { return (
- Gesamtübersicht (Verlauf-Bundle): konsolidierte Kurzinfos und Korrelations-Kacheln — wie im Verlauf-Reiter «Gesamt». + Gesamtübersicht (Verlauf-Bundle): welche Bereichs-Kacheln und weitere Blöcke erscheinen.
-
Bereiche
+
Bereichs-Kacheln
+
+ {SECTION_TOGGLES.map(({ key, label }) => ( + + ))} +
+
Weitere Bereiche
- {TOGGLES.map(({ key, label }) => ( + {OTHER_TOGGLES.map(({ key, label }) => (