From 2453da0da181fa451ea1a948beca8c2278ef5dfd Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 07:00:24 +0200 Subject: [PATCH] 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 zcmb@uXH*mG*EbwR#R8%S>FroikRrWvEPzTADFNwHmELO-5do#DNN)<#n@9;Qgd!ll zgOEn=5FkKEe~0s5_ge4$tmnhiK20WTGS|#p``W*<_vFJfJuTMryyrn65bKl2kDh}- zr-6?rhtHk_Hvb4cJplq;0zG+j-zYG1lXk(^h?pfzcK?uKtxy-fb?ez$*XP2k-@~_( zhFJwVpZ~peK~?)*|88xD%d{x`bcgJd!B~t_m%^*k#q3i!wZ2T*tl7g8S$-1YZ1+00 zZaM4Ck}cSKt|zGKZtv!WaIdoWWPktrjV={>bzdQ5O4~ShpL4lNJ;&&4$stU=bg>hR zWH6A0EexzVa8)3S&4M$)<`UCMNnlg;H;4(?x$wWW^Nh!$EZ*Jv9;}VpZr6F3muGc= zNr8JDc{`R%lwpth`P%Fi8X-&6+y$P#ISU&L#80Jd^Jk&wiGHU*pjKhm8je{u_04U3 zDRqF)gV7-xRYBTMVvyA7DVr1y4vs?ogUszUHArK?dd-~}#tuQ+QHL@AS;J67v~cJw z*O|nP@lpYO)q`K#h^T~Y6_OQwV6ED5pkYqC4~%UHUdrXG$h@pGT#VquhuXsqgNSn& zT%*qz_k`P4t2Y z@_6X}T=9Nscdmo&udNL>#`d-UuC;9(P!Z8PIE3tK=UhmE-n!THGgk_AINn{T9*As`Fciv8P082}T=d*|l2+wS zz!o@EOAQqcS<~_g_h$u|z-%Cpf_2>R=|+k#o+1>=zpvjHzs%~h_2rJCqYiR2F9F90 zuMDL#2n&a<)zNE)Y82GeX^ys~6ic3EOmshkLJ?J?*Hb!(1y;Mo=LTdU>xJh2E#SSu zG70q`4M7LFifOwog;-VcdYDt48@483^!zVr_j;c`pMW#5F{R8PP-c#15s0Bg%b4}8 zqh^E%_tzq2Dezn;1R^Ib#PZ1hsD5b~_E(D+W^<#!Navgs-kt$#Y$DQ#Kx}0{yL{B@ zGnCX!-(x$hLstjtLF-C+z=LK#sM!M{Y(js6yQ#p^0QK^UgOEgQw-5}S@#JTO`ZdEs zJ=U1kxkL#ZIb?(zcjvy}!R8KLw6eZLeRb#&?(4@CeMl%75une^?p3nClvG`D7q!`& zzU;@Iec}QTWijKPS&8~o^1xT8*Uv8JPiK!6cAzsFwhYFRig!;k_EIVjv$k6{_ju$@ z1}^rs=d>J-8H!5R&P!efo0u3ymL3otLXYkf^HX#i7Dw;2egD%GM1jBF=E40QHPp%p z@?FSlg3(_T=pXEx5mI2KcY;=Q^FK5K0l#s0vDu=^&F1ym&zSZ&_lxquHa2;DpPgKN z74ZIOxGM%b?~Wx1{h=hb-^mU>Xr;$H`KO?%ugGOV@(|KsEv~WJaqufeO#sv4JzWrn zoh8Vrv14*qF)2Auf!}3;oWb zOM)vGT28yfT=ZJ1dpP|t)OTSy_o2mM`8T%R@sapKK}>V`!cNa9-c*ve5UWr_{NUC^ z6bdBOqQ7a%lNxR5>rHrM$kJPvq4sVd*Lq3WpFaAv8=x29l)Nm`Xq&z@Koy8PlP%aF z6Y9MI<`OgAncnFstY~f=CMcG1fFBt&NSVuQt4+}8r=nX4E-gmnt~p&%7hbqf;DV9l ztwQXdrn$a8@tvFRfiSOw{wT2Z`(+z}#i$gQ!^(O>|h{s-b*!{ZL~UZPQO zqVUBeEjd?p-q`MtCVZ36Lf9T3<_lW2q<(L&fa0HWsy-SS-v?MzxJ7e^Gs-jHNzPaN}J!E`um1q9#%y1Q}`_AXgtphH# zp}AOC&R!I6*#FEkCV?QAly-o@)`T^k1*+s6-R7wgee1$rSVLhU5fK2S#dsIFG9FQR zH}iYd{cjVQvjaU3=1+@mhrIr!WVJIhB(3O7E*s`S1-qdPquzQ2y5q6!nuTmufi@`Y zC%c0_T^H?w`fTKG@qnxYd2%q5u{7b50o~6NicHXwuSf-ZUrBK1Q{q6>1z0a%d@kLW zMGR5SSv@j^g;ZfXpiQ{jjWtJ>bDYd@ZbvL7=a$Ao@|qm{2oJNj<|@oJmWBd|?T< znaGo3!7JWC-RmpV&-zqJT$c9K(D1N^S>yjXv#sVwrUi7Fooe~%NT9m~4Dr2Ob1^8K zcF?_kxRQlC8V>_!hg2*yUuk&j;8c}%VF4~3MhDs?A>mnD|Tw6#`)_&WdL4K{K|ke zWq~P*?VT{4&;cXnCFOrwPou5;JQHeCi47CeWjZ3&j#Z(}C7G{JXcBr2A(L~{nBNbupQimQb@XCrox>v^xGyZ4T`42$F3h!QJ}b+*;aH`EZ9aTUfYkfwaMB;&z11F@+djPVY?;KQ zK6xzM*@N(pI%Yc{YFj9DGHarAYS(C_?kxu~h3;s(yXVk02x;INsZ`&#mMo-Rbr)Cq z<@ZovzL?fT#SxH6zg!>3ZjaIKTf+)2_<4BXyYaA4jK{#mTh3jzF?cjJ zQCS{A*74qgL!Xc0YeH~`PI41eujx}NG*|RP$6>k3ELlB;T(TlS$lnJ%l>jc(ub6knDo#I{UdaYj$r!|-^&tX$JKS^zE5)HUBx|2 zMz0P&q{MEacXXd`phSkzSBh*2c3bEjDd_A8*q;~$sk%%GTg3BEOehB|_w&#!W3^Vs z8#aqCf&@@t(+Mn-IL4V3{sRhi{W#gBS^QKukQ4Lx&oHL)Sk`V1qkW-09_g6{&(o`&{i?e3dz&3vr1l7T!Hu0aWYu zlpKWZ;eb5Ny?!wtOs@(&_yIY1YX&>gV%X1d319iB1pVPp`3P4GUa()=9N^NEOZDnd zO~8Uxst!hyoA9F9Jxcv)8WEF0eB0L0t$z-vg#(O=E2?hHR5Gvm)qM9rrCeCw)hNii|g+xM)aa+!f)ug0vla@b%{dOhZ zx8Fe85=X)eTJSB@RaGJv?9Uju%!(f%eG8I)U$#WZZ~J{sM$p4ZpNI2EB=Vk5cV55c zThaDC&mDcn8u+I~HM`U4#B$0`i!Kq5k_pXA3qY$DL(GO6`6k`1DC6s4C{i;#U<>|Q zDf_q1uJTG`Mzmw;IyQ)y!d6+9CVySQuf`4+w%c!}YK0eoy02UxQZ<0i%`daA+>K`q zIC!gAiac1-)#%wpKyWXbJtpTlWE+}zvy_5u7-4yswr1vJX7+$FRbr^4)2>^ZRgnEb zu9o9qy6koC*UQ_vWA*qxRCIzbBfO7OH5CX=zrDq;YBT~KiClK=UjeL)dy?eyp4P+Q zPYw5;t6`E=28^Q!3^uZ``pE=mdRJDDk6~=)o6|&wv)dE4-5d{#jEs?2yS*GY9Xb;$ zH!-wCOq29lz@U@bt{2>)-{NpJZ)G2<(qVy^?M=#nmvhTJBQJ(Qn(|yy8)CJD369U? zLq!|Nu?ExlER{5UBw=NU&l@AkI2v&)bxJZ1@FZt7cyF*1Gud*0ShM)3Dey*mq)wu7 z3eUq+FNrt4%A{X0GTyWdp=nWa8dnm9VwBEtq8Y0KH%HcXt`i*|pkvb)larPT8q-Pn zx+ppE^Mh4peS(-Khoy!Vfvt~V315eSWtzThyyv%DZYF!_x)fM_BlYaQSt;BN39D}* zfhS#q9X|)f>3^aSG$I+tRVWfk#wTg$yH%?W^WOHY!(4RWu(#jx&JixuTu2Ipv zM$f9Gc4IPl^Ca{QR8=Vx)Ad`0l~nsZkrJ>tZXb=?!W!)d>E_77mY7h7>ea6n(Mk)E z%5YU;uRh$ZSy^Q~ty4=0uU#tdj-fo&>Qj|}f&^CdWez-&IO#k?>xe;8XAxs57QwF^ zxFbY=F2F>+842`~+s$VgsY;NUSZyZ@dCF&P3BE0EJ79?@lxp(50i2lGw*?OXh5pao zv#r3lzutma{!e#|&anIWQld{2@Ed2vWdw*>F&!5xC#k0sktpYCQMeH6E%C;?1g?JT zdw2Mh^@$48QtBrO62EiaKF_0?sd1_A2@nU6JCzcEJ7nP?4rPIsHG3}ne)otmDDJbZ z1+R?lKq5QFEJ9YpXi;ne*npwT*+kDS!935Tp7sdgyvtD6n@E%2&{Fj@wz7qmr3_A2 zRsQVu<@8x}bPK`(nkg}h69Y`R@98h96{%4n`*&ln^<_8OF;WwUGD8jp8ingk$IGmE z%0kxD@`zt7BJ+k+4rZF;P`9q)-hnrE8X8~Nez%~HXAQB0w%c00;Byu2b*pUId+}p7 zQvVh?8=QnQ4Dn@^!XYW z2vq-(h38H6L;GsWC)Jj>r7>@YHjT{2jb-Kkl3h=DQHOUh2-pa7?{eC94ji$)LUa=o zq${i#wK4=I zO>5eum3NbybNJ3EE-r~RJPMzA6k^u0Ry$j(+gI(OiL!2zID1$=AmCFoT^kZ^gKx|g zThW4FRBPUh!hW*kMOb^E-s7_Us^vc22k8>*kbkOITyIC<3E`jb2bW>s-sZ2!V)Wn9 zb=K6*zMQ>QQZ|WIqOnAS+1S@u8qaB6g=Ws;b5rw_J#$bcgwddHcpmtTOuSG&#A2pB z^bSFL>itUmzuFOgzy}R?%qvSe)CD(XnKItGTIjUHIno?Zb_YY#c>`8 z>{~-WIA#$W{*`LJN1k<;rgm3F`SQZz=j<3vZ3RmYsmeRRt@&3D%AxA!Yjs^>!nbE^ zT--09mOWmW>{p^b3+N6%W4`1kq*}9^ws~UHy5>%_mBWA;bftNo*VBL|KUFPLz1uP5 zs!~(2YTZ%kM!rA%7edJI*}#GDkafjFm#C+ADU))JQ%W_kg=T~cbGwhbz3cMbr!zxC zIhMyAywkj%_32~`r{>ZQu+)F=o(WtTes$w^}s%&a<#&>>#T^X$fgMTo+ zarxhBJ;l>RrB|0Ot*Fa|6HQ;@3$(06i5k4y-eu@?b54ly8N^*gjVPS+TJXSfd4zt6 zk)z5^E;?{lk?1O@Fq12)xRUJ_-jZzB@OuGJkAcuscrwLS&=Po9P^(@2wwCrv@5mOt zhn7Wub-LI`-fiA{J@P7<62?37j*)i))EZ&?hXP{D&bnO^b>->bgx*TSqX4(!+oKVF z)sUOIecQLw)9&EzQm1xOW7?P4{svH0Yj@BL+TEhz&MUCwI7motMprBWPh z0v?8rF0uJNXF+x+q3X`KOo=t&Bc+|};-7xXtLN`9Olt9fQ`-U z@cn#=`WkNfjNBmh8{*F1^t&@>?|mY4Kz$)9XwgM#R7=IRdKJBWn{l0s_H^}A?Ht=< zQ>xIuEvwhJGagshV|IVLkiZ)1<_nZb-FBD^kO2SI5pZZK(#ID}X%O_nLjJCY}g(q%tzuTpaVIwRInRB@7hRsfgrf z-N_T#8qR%gHLf&UcPie|Nmqx(wOT9Bq%MEZoN zHHx&FryQR9Em5|#16EcknmtNt*&92l!NeXTJj?TQ?owon0%fv-JN2uRTUwxGzp(Mo z+kYJ_s;Z2G=A6$ixoC`C7re?L+#6OKOZ}mt+|la+Rh_> z`DCDFvUSg-1!bREmbl1P7P^A{roNJ^gZ08SBJOX)MeLe?k1)%%T*h?X+DMQ6TRPR> za3lfU8Xrjt;+}uNai`&Inp~b@>TYOl*S~3IFeGp5q<}6}8it|vtIMspp&PxhjrR`I zS$uk=+oG5HmeoH)1WfK|C0X>j6+0{pI=FDz$2)Y^(b)K3)cMG`Y@vk8Oq2QA__iUa z-X)hP^Q%;0LSs?k5>3PQ#@!zG3(G71S<{rcklxB>(r}$kTWjh{l6jP~!k4h!(UAz= zvyVjURF9q(gt$4DE|Kln2v3%xI9K3i+|gz&)$T1?yI5-2qEc42sw_aiQ;~8Mo`@z? zqxW=uFg(&>HZCf~?S&d6sXR*F16wD>xZfXQgX#)$>7KyIH-Cv_{uQ}Vt|Kb)nc7p(n+urRP5&_IJZ`wU!Ik{e?(+$Nww!HkoxWr;7!%c2+0Syj z{_E(%S83BKcl>v36+h^WKGwM##1z|^$;WvGs4%$(5R@d|IsJf3s)U5XZ!&{ssV`an zt=WD(`F~7L|F8M&|JxIMVQZz)XsW6ZRRjbAnH!@6Bn33+k;K7~l*BPspqnO@|I;sa?T3q85e4VzaZe6V%A73x=4n0u#L~rJMa7 zoo*sn7{0tcCOaE&z93)=ug?4wD_-D7t*39ZbO?p+v}p-J6zs_3^RGR{K~XVH9qk_0 zo(B`)sGGRs>kzlcU@-V*!r+%X9Akw`WZ9KmPkj$-4-bayl*M%#FudpGR5b{>P@T3W zxqi66?|}Po(lZs>)D+S&z2MPmgcO3QAfs zENr~_*YTlAuf_^2{ihuYLjap4DUDCyv@Hm5l|Zw(>}Rl&hmmvepKv(-v$puT<1b$t z85msd!b(z2A|qybq#c3UI>_@=x*8-{-vdAXp{-5m>u_!3d;Ipz)m6g3VCil;>j)}n?UksMNx`V25gE+2j z{zAANw;U|UDxRn(r=5Fm_hq{2O;a69wEC{`@BC#LQ8V(tQZny)MCXw=+gWm)by zwaAU=FbpBV z5J{S?#Dz+%;_>EY#I*wk-0Z%_PBj;n4p&#aY(&(@9U_G2gRDNwQzjOBxYH|eU+sZW zdnZZv4}Lx5f=Edwu&nws;JJW22X$pY$jO6J(L!VQqeR>(k3*vI88&w|D~bb9D#1@7 zpI22JKEhS8Bai%k1Zm@%g!ZkVL5%AR@Ab0YhqOX#wJEa&^*U=B-LrGJ>ODGgnoc5r_Sc} zib_;cbGCDW?Q*Y$5`^e|8Et=R+LuRod6L|4CSFKZ|<6yrx53dq9HM zUTs>%4{{fWEdRk!y)+k~F$oR*6;T_XEqxz5DA{lY2e8G83x?jfo@vt%UhMX&&%-_w z{O_}s9|2KejJ!K31FLN0@PhH!9LBgRBzH}$C(M$22n!(^5WQ;gNf z5{2*9;7Exhd4>-jcEk{5>w2Z%f$kiz)R551e)(F#vZwPyq-v)`l;AEU4aHj`t*>O= ztFbVc;;l7GS{$23lVZZtri@uddnYpU9;-yVPq^{zMHwZj{BU@QDU4EVI}FjjSRmR^ zu-Xo+Hj8(+)Xtdz<18jfFh~*w#;203tw$MKVBg+w7-WrENVj3{XD?${nKt1Q zE=i*&Fe;-DnQSADdTr3_0pAn&wUNUXNs#HJ;NYWE=!qKNuk*pnGJO-e68snBQ%i;r z?wASA8;mEavl<6jZ?J{7%4g{;_s9Yx)g%`Yei-AlZoY~BCp_j;4NXd(=S zw;X#BdOoe%G*GkJx9?$um*$$zwQ@LP|@d8mj!9Lg-n4o0>u# z-NkgyTTW1^cBB&bmzd=Hm*GAx4=Y5PL=OJg_h-reI#-A9O^VnsOqg;^$lskK1A#jG zQVs}(JR}4U!#Ce3Tc=wyel+d66uzZLcOWwlhZ)|+b%&JSWJBYewF_f-BrPDNmWJ7v zydi-oo`)T!#Ju$qNfC?UIy30nARFw<(wkunk1gIbgZRij(&Uk%`IbnF9Lm|p)H5rO zk(4}X0<}&R>7o~-oIDqXorb5;!(ZG?e&@5x{5n^lTy3DLk{7l_ss;)K^^bv-3Ht-{ zbC6u(gL$rJS3<8$LK(Z@F(JJH@=o5Jss6Gf`V5=cN7IQ2DwnQ1G2fb}BjMr0m3&n*-7m0>VeHRh&AV##cP|It|AdapMy+@IjuC&-| zn!p`VH|6Q{lI?)O9=8v-?x((yzFbSl*G)UoYM>|&R&c`{(E2k?J7p)XO~7N(ol0l7 zgT1vXguFM>S?%7pMy%Cq-Z4N(B44i5%OAr`SlTM&O$MV7CO{`4x5>DQ@{(*=A1i~9 z!|MJ+oN$&F1tazw$8bRpyQbSt-@M!9UMw0rgSe*?Z>#6(7S}cG7=y4it5=-n;a5rJ zVMlwKPy6PWdV!4%Wd^*#W1}G+I!@ih0=Ro%aA$JYyuY9M^U@d61Ua}|8Itz6A%2)- zcUJ!3F{;ryeKq&^;?{XFybid@#YyJ%T*?i${t+O&0qlAA{1cQ@J-)c%KRkAnw0%?Tgi=zcywJO9w+kY+e*UsFGJ#_I0*3 zep$~ds5oq-G-5P;$RsTnWzb-8rUK$RTc-)2C@XgJ_PE;}fCIY$7&-gkxfk_lccT)i zmYT}&n_`$`t({cW$zbf@v(0=@_=m;E>`%_zOj9f>P0xY(Vy+q5&fT)c<@M4i4?>f5QRDiUG}9ng2=DSm zK^2SRq=pXg0;W>|srwv6NB>^X;<{UMQNj9AfX8dG2K-2g(e#r-058Qk6Q$1ppbQ#x z0tQXZJbz63GFML70GpThK|pl=&H3MUM8d1TyFp&M>uM|2&(`4nMdJ~Hz{aJ?>DrZ! zCz{|8NQmcQK74ZU7^_1bnoEkW3~&GoYThaHgaYPy;_p-6!nzR)v5y?;ARDXKZ|8ik zW7SUQEQrrf;72__21N_gThC5Z0Fd_hON3MclP>ermoK3kg8D*h&N`>;CJ$-LUmHtA z5B3$Gw$>&HXsb85^D{$twHfm^&mca2o@xfqfy>r(`Pul7FmH}(atYqG#U(chb!Zt< z!s~ydL)R_6?6VXwj?Oyw>`dGMQxa;_pt#erAWA@OrmnP>T!H#)q~JwBT?!yDez-k^ zDl_>Zoo!y5h7Rs$2^OgPr#<8ioQ8V1hmUa2qSvR_Uz+<@H*^fLy&d5H$4N5wbFy20 zL~mI2?d{DhjNiT!eKWce^;OBK&TRQ!;6A>n!hd9rL*SurZ)fSFjNZF(*>_*9pI6FA zVcD#5){+%-o7TU|P3Ip{X#X58p;R2!@t&6p_H~UKpDP@b(e$=rabCI-=!t9nJ5lMJ z$$VGD{S|Ge@9mPUcXeeK$pGSn{;>URlE>l<0J?R4hK69?;CQ z>iCr;bc#>yvqZ(Yv8hW=QaXK-JQe5mVxC{fNakGWn3QdDPr!O4G$z1R_vSm+atUG< zaLL+!K z-VSzvCq7MCVn)Gc9W}8Jpu4&AKZ7pCl|gN0CD-dzl3|x=nqNI@%oEEeI#X&? zRdAUjR$DXOdtfP_;_z@Jw;fFeZQukc`SGby%s@pDqd1Ab{cd8W+nx9*zfMB-3+dhzDv0EA_gZ4bXmwJ3wj$p6{{Jz*}pSe?YAct zB6u({nEZ*^BY0d2DaF11k_8s8ks|Y(KDyW=`De;uP}oor&pX!&B$h6_=S({vzd0MP zKmKN<@V)3Y@2P?Ox$+=G^98#^|GumTMHSfELi%BMc;nZWxo?GIqU}Fa8ts)8OFlDe zj#6G@>>-Qf%jf-_wVjZg1&XRLrYB@3Y`g;x;IF7`XQjNqIXN zdrJi4CrNbm1uOeN(sP976g>-s;IrXPt`kFQ%>xJgP15+Z4+a z@dX<`+>ISx68`$sNP_=&&RTNrtD5x|l##J-cX2<7GdXVgHCJ##PF1kus^1=^VM3ZK z=)GY-K+^vhpdxcW)JLj!SAFrRQ4Nb(8$K_vEsi+B%5Rd zK+NeEFOG->r6zyy+mbj{tIa9=nRFfYEpJmU_gwDsvgWAC%>WtW@*ceC$kArl>||%d zhR~$9VQI45l}X;+*IH32B}4Gy*LpWg`JPAU;+>z04T_cEc0>1OpCR(J18PJL9uV4Y zMwGJz#LqRLGc&V(3wj3b3^}T!A1x#f=r4nd^)(EGxayLh79=l=JFdn5ly(*SQ1N{K z8;%nI5tjVC3XXY>e82Qr2W^JE8av0(gt>*=ivGw!L zq5=iOOt{gTq4;(4w~_5<|MdTZ?pA9+-GxY%v>V=SbTVH1=z6UB1|$9h=Usi=Ho$Q6 z4?qAGc9$UC{>uyaAK?7IVf+8vom;$b0uVF1COAq5e64tsQr=fd>N?N{NC`+m>(-Sw zQ)&oOmS|IDYRJUSK!ILyrrbdsBVBC|@oVc?$Y)+iYCA=R=V|E$K`+rK zAd3jxDJN?RqMDspciO4j40H6R?%+d%tk@dxqc=}vzJbs0Uff_i>q7rIBaa3TEUE?)Hm=#eVWSJ-gb@o z1;dr~b-vE7ySdU@D~>6gCU@MMzYWW+!!5=>yQ7{#xtQ?_ChF!g6dG}VozdI0K?5^@0(3OxiZ_^48xIA`CCOoC3%S2E?;To zKfjL}5Qc{ST)|Yi0tBZcn>@ZyOG-&-1`2hCUgU^nfFpdg>{zXr0YKT(*ItV@J5Xkw z&XR7WFqyrk!@oR~UmUqYKq>xt_9ZNQaQUXP$B_8b^7W?`JuCf^vExNNJ4UhH+|+)~Ws#IVU`|WOZ}$=0^CWCDXaUzl$d!@Zu6e zPhp2{)2(k8-Q7%PQFG@84~-+GOFxrlRg+7R=YrxQStcB#RpJm0+L*K5oDZ zm2_w4W6J!{q>rS`wuix;C9 z_%K_Lm(Mq_*Gi0~F)KWY^&PWNN!%#DDoiDf-q{xB%&Yp;rC~xIoHDJGs#nTu!g5(L zrcpD6fBg?I+rsEZ$JLgB4_)@ApR8OTyaK}fgXp`2IVzP# z?x(Y?6G29R2NC*F=wfDuSa1!W*qx5acN61(C+Q|JrsVZ0zvs!&^5Fzht-;DlM4G3S_!%wf2SOogK|k6cNx*#FD)63LQ5T}i zx=fZ$lp;sIgeq{C>NKiirYI@GKzrqw0|5$jU_`-aUHh<5OyeFU=9V#b!mg?*fQTx#fS9w%kVDI%%E5G+2cN^ zr!xNbXA~1w08Rw@U4m|*_5A2M8tr53fYs677{960cFCoC+1neWz6BTok6U+EYg|BJ z+4^)@RM&OuAjpPNazzo$CpXyE@vEz5qr<>IT&vFf9!`YM4-9JD9`4>9uyv@t!8C=j z5~`77F?u#8wxN}2py(r%R}dNYLkKYSWx*6Sj(Snr?mK`Dvbn9xb)J-gY(014bv+>PDubn5a z&2ya3KGzbg1ZsUCo0li*krJW%#DhcNsN8F-ihhmr9p5qlktzOaT_nh5f|PEFlIsVo zu|Q+wb(*Q=@m&N}N~M0Je}%p4)|Fl!$nbaDS8-Lz1oQ?VG#3SjgxTc!vn)}cFAj>@ zJ_9D|F-N?Q7VY-rtA+!r@@3$rkJ4;Ff^Ab3^=m^FG;&FrT{c~0s3%c408d9PFFtHbJ+M|?`q?O|X8kG1D)4R& zDZ;}FkT`4b41Ma6{ZLa|;EfWlGnNwmj@fXxvx$rC0u3$Q5;8)r+^b-Ihu@LmB*Ag! zbk2)2dZ@^oy7%|~Ap^$q-^y48Kk&ERHdzfG1)UPM)B2h*bze$1Z}9@uZ0ptS6zvW> z)AZMYEvq}ZUouRm1h;cVip~?Ei>4uV*SHNf(tlMb@6lSrmv2e|{zL2H?X{Db$-`=! zP5REyAz3Se7K1tl=h(yV0q>p=)|a}2vE&$bW!!p@y8KbVrp9cEc}cbYs2E!<01MWu z9+{>oPfY^y4;_BmqOSK0l8v;~EN}4&nS-y$Nc)&f0FrCyP}i0ldv92wq2zm!Ib-L? z9odm1SGkmnB14hgmt<7aa322=kQ1D-4@X`(?)iJM0&R|Ob~FBP&+4EdWDFM@biXvc zkBh!0Yu%TRb4kuopbc8{hlr#=Dcyw3rD{tWSqfc!Tj_%+ ziX4#OE)`oGk%ppR1L?9Z2kRa(H-#>Ci5O!3ljrHT%9x@6Be@V4dNiev7$ev|**cyE z-@f$aucR=Y=d2g1Nf(Dm`TB*8CGN%Nb8fm@aBuSDHtn`ARx_u+Ey!mYd#NEi;pMNV zX6P3P0<75%p@%%oO=t8R{+#qcRZM=R`?Ko;X+r01?-nXY2XM&$d&1AyC;3_rSqm~U zUlZIpqcX8GA2-YN9aAP%KbE<> z74fA0wSk>C`^p}d&r^SRXncPoJuE17%v?S;IK9}nk33(um66_9!s{M$F?air{I%8= z5|Wl2-gr;g-KA*zDO7#xryFf{aJ1azd}2r~uBpK9Hx>irfY^a?o0H@Rg_QFW`a~ji zv7EslYbX<0r^vXLF4tf$SJEh^<$ua&>V~&hRpVNOUBuFduK1Pk^nB7pho5$ccoa`y zx3n)!Qb+#ws(C+9?SF(G7kZ$vhj9A$u#uieOml}bwl6_%AYDD$tK(F>@W>d zcoF80MePlxy`9z>KK7Aulj6>Xo8Dp!(6(eKMlB!t&0l(YuEv5 zK0s2y#}wY;ERf@Y53>*MeY@8ReBpUNb@g9eQvd8fwcYkdOgj}|W$bzg>_|>E34-(^ z$>QpvPk>`VE5F~J+?gqqNBTGrIeaR4Tc*eFB#jlc>{^d$wWQPoZ=QnQ_VOI_$-YezOFG!rvssmG)rUT{-DC;eC;Y@oucZARHR07p~qmeGlA`~@Kz z1Oe%gFD*J7td~`B{nb=3i923O_qb>1AVK?2;++07y<0k3+LFM$mu z=pu<(9v*?TgQ@U)yanW@&1emx1#Ln;7^{n*RnzG<{l{8=0w{MioZ>eQUKt!z+e@1B z?JKWNxuqOlQIJ1JiBR%1G8w4VL8CZXn$3=tg=FCM(<75-Dmi#e^8@WYCEI%`1N zADfN&J-g>!?+nNwmjbJz9j`ignF`a+lu|D2jkgpLn!vX!N??_1o!{y$Q4!>JB#wGnlSj?j z>aWPrd2aOK5AODI=V12au)9KT(0bC^S_pdTFnxmHNP2Ctd@}$i|-^U;Pmlu!{ z;$OR@AL`4+bG$}nz5y*&Uc|KkYij#Vq#>Yd#vlkrp9dY*=Oj*EmhIEegsw=$s~_m$ zqnO0H#jWWBB57g`B~;$E!SMUwjrm7Ej|PlM!hnonet6r`2%>HLaa{*^H|z^Hr(ItB zWH_Aj)CCWIr5QmTY-2&G{nHg7Fk>ZNwFxP%@=Ao&n?08th@-+Y$EIvLx>G{8gflO=iMZe=OTtkE-8(4HOnLYX3I|Hr_ofCC#kX+llE<#idrJ_EkfucVs1z*(dGvEjm0~)fR zfM*ybu?{WB7W^9mZU0k%Jay3p2@9I{xCoE~pdw7(&tPMaivxny__;6hUs2_${V#~* z4pExmDQGhIHRJrpbED$JMa#HbP8F}~#ri)3b>DE~;;WiF7hP=krkQl__xywYq>;1| zZo8!T7+vrA)oHKD2=g41G$S7--j|2`4dfNB@BiC4IlY#}wVdd*>uQ>2qaw*7|JX?E zu3!ou>zJFRv}A~3@J6J;fFpXk`Gz`|QLxLypVa`Fso8jNEw0AR`44;SebYUGt4xLF zlUzcUfHQ8fb8%hpeLW6n_IE|h#b=st)%tNm#$7u)==aLVADeethsa{LRI+s~mBMIQ#o(cO*Teoh(vhsANgTjmMIh(HUzw*sg^#gFAN_${7(kYa z7XNMa>>~$UadxjQ6Y$RK&d8TL))nM%;2_Z8r*p>tLgHJ=k^hLS+y4{S&Gx~GBPU8W zjI_c`p-{A3%?!*)o%z;By+1pTVJPzg`}x)oD0P3M8z<_DH)wTns(G!sOIOY zZMpO0)&CFqa?mZnphv%ew+$|J;aceQ>@mc3HFA4o+k2p(_Ze+yY>a6v72~e;)z$^H z%bJbFac@nz}EGfLZ9DEpgkN>#lwsJhQ={Remn|=WPeL~UPdyd)t7U3q7 zn>nsUuOUSanIHUJM*-fr9&p7tW25di%I9T5Uhn7AX7$KQ2;wm>ba$euqF4&M;tG zZ*P7EU~ncY>$i$#(Lmj{tNOw}?E{l_EJ|9`*|Q z+g?bsd_)lmJ_o?~^z#M}92OAXIVwTk#BHRTo|;TSQl9v5Tajq%b>A@ekQ>uxun?m+ z}&xYEKScTXRt1)Y2LE)Y+{(_W1f=Ll{=qaaEO(JsGz(W)y5K(40YnqaBwvm7jnvp ziRmquyZB?W%@Lvv3ikqbwR_t8WUu{y?Ol5~)a%-|mZAu&teQ$vDYb}k3OSdMC@JT0 zj1b09hDAAVm1RXKXDTLRW-yfFj2tJ45XNbkLF70LIfgOz`_o?E_pSZ4_qDIRukZV- zf5*%_@B7aCyPx~HpZj@1`4sJH`&M$^v};Y$Ij~KNPN8dSdVJL@5BK5AF`7Mv9&C(z zPU_R9$dBiSDHJbBkqnTCISTk4;?eg&XYuKdLzUCGfjoh5B1|nJQhD^6A7l8s>*NI> z@L6}{k^P)*nYCfVYct6+N@fCqr*SNhwO2c*wC)|Od|suzqnaVEwfd@aC?oQ)0&vMO2%T0BJ$nGz*J&NpjcnV4QS2j2 z$dz5QIl|XqlFE)rP(%yRlps+KoTx!zlba$h$mDz;C#u+JFO^XtUczKDMTtMA^{4#K zI#(poX3b{lNQ4z5HVHUoQTl6)Wm%S6GanFk)M zX~>$!juhY!^1+ZXZeC|(Y1;U1*nC#v#>f2NvJo5z@mkY@*Bug#Z{A2FGw<8VM#a(pRYwg3)zDc35&-b8=~AN2_UBUABgJPAAbB< zKRE|&c6g&?0O6;ZHbg^2;?Y-k|j}GdQmZ#io<3$N?0q zQQ(zy!o|2)=US&}C$-7KWz`h5^20rk2yl1UMX};+$5!M+J*I=pY7SkTezVQAJ$Xx- zr)p9%M-pC$8Greu;qFJ%Wt1<<5hC*Wf0R*VWkLeS zs>Ydud+23uZxZg6+a;@3++@Zy*K9pnQS4_hNrb8XJ_Zt!vetAJpKI>2KO{Dnu`Tpt z+1bjy`qZ+(n%9Hjar=_#-l+?qYT|8@bb0HS%&rngR>~o?XEf(MI5V)yDDzOb`Sfy& zms>Nv`8?#RD6_R(rsHuiH~BIbYnr*i_+S<^;!&k_?S0HTFJSV`JNJVswwP67fcdX+-6fiBKfQ=i zw-b4DrO8+fI%yD+>u)eOrcfV5UygMA0_UMDJdqKZeW3|`oAGto9~{}!%$b?f47DK) z+;i^&uTeNKinyZOz?!-CK8|YskpZ5D%BQS;lT%YLDCDTP{zDYqUh-ck3Qxk}z9hd> zcSp4AqPljx<_llC?RuQmY1_{;#C^$;CKF*;x*KaT>|QXx%c*+&v$i`2dDg zIMF}1uWz49+>|6uw^%yFg06Y{2sfOH@R%=`=`|(EqkJEFSVufB&S)LoJAf!dFc@Tnuf!l`etbYB1AOo~P{3=qhnAG$+;Wo$>v6phWE% zY53SV^NzQU%**GIRVsl1SZkB`4ppF0ELnAy42th)L$sH{@UTfs-X30nk-ie zUM3Qz-n)7D?-o2ZDVWYM?dS{`dK(N``(W8|Ts9WVoerL>6dN_NTy+Jk{bZ|&@vsx5 zzB<>}yYxq2D5Kf60>&Fnbuz0I_u;hlTPj&~0V)36PSuK;hC?#di;$tMD%|nv2bV(3NJb7kanuIxh$539fk=bjFIh~?rbcP00IQdK_=M74@38_Rfx^A|>jrtB~SWQt>YTt$3i4362f`h5SUX$rA^}a#2D@mSL%a zzu#QKOqU>udLoyHn~|*mk1!)067!mDaPwnM`S~$4O=07;KaJ3X;v_aU_e^g$c+9If ztIP|UmIj|=F7N_Jhb!zSrNx!W?$J|FYgNsd>9uSf-1r^aM`*ly^V6qoJ z>h6K6%dj`Po1l6hvwiCzzg1@7$EP5hn1fTkD921B!foWVfHHF^MnZAZ2Pd;lSd<&& z_*^i)uV(e6%Um2<<(NHZrG~@d>_nKGng{z#z$#yP`ha(Fjn)1=^7B`Nm(!#GMXv~2 zuUaD!^6yM9>wD5(Zd9kDD)*&{LSaXxhs`cz?t0wjDG!w~3>eqLuR{#r8_e9)cmn8G zcQsrqZR6EjWmEtmmNfvy^|jTt+n1JOdR_te-?1|kGROs}2g>&c-k5f(Z-3kk77-fV z{>CMYC3FjE59l^-f6O)FNiaDT!>^Ix>GP|Ve`o>U;MV^mDxi!+V##_RYzGdmpN9PR zMxRTNI$zY6EeD>WdV*&l^L^b1pm|Z03`^;%ysx zraO_@>=IizzOJwvsnmO$!=|dln1PK^6#u=u;zM^y0A~=5OHcT1b~OuPDx{!IT{_me8R3ZPBRmn zNr$_@zf(VmZz8XuYZ7~W{;}=xuHtNSKzO)b zTI@5@{!?g3lFpS$|He_lNe_mXK~bt)&SCG3pXifn~@4e7A9I>T$s_l1@E*{NrN!{*^9==XB(ixkZV zrwB5wCe-abgC(G$kkC=cYze+`>jQDfgpwYiW_^6WTBclSM2di0U+4Y~A(d2CofJtu}sVrvKHa%WDJRBAy}d}d-Hn3w&S{r5+=^sw!>8+KF*#Uxq|lpi=0|= z-%aue5!;sgL{yi4$AWX2iV%rQ2W!-xx;Uj-4erVLY0>+jZaKtCg_&0xI@*sFrea_@ zSMX+e0yVVDf0O#Xl$MDXDq8P&8%UN;IZ)e47irmxcqi@=`l@IH40e2!hh!G>^JuGN z|5VH>$zxYu+dSb@43==pm`9w@7iS&sW|?-Lr|pgddmh?XJZ_-f#qLOt2Ve ztQ~|b*D-4_D;sUe7%{D2pfd9g&t>~_qAOVrH4mcoJOj#@@p!6KDXZszjWQWq`nSEL72ytu%|3ZB0)9d{P%(%Cf(Ja+XC*)8;EFM-C>HCp2yWup zdScyo^6WEHQc(BP0zF&cdggl53y@T(G(@Xxyl4PHCCS;(AQ4rHWa(KlzvKoM;k6#T zSV#&)0jEqxF({tQmuZ7Mb2chzaPMIB@M&@HP(KaqHneb#> z`vdBIp}}kvYagyH^`Y*CDWBZnp>Z8Z*;641r4pbe4g8r)#v8PdRjo9mutxZ8+^D7W z-XP2(|4(tX!X%pbaCTLcUfio4$po5KK=wg%#y544847da{fArbFv!2eb zgZ3_?XPQA5^uUs#amR8%-22-01}>!pbvD^LJ9($c=dx-lT&|$oOE8n?BO*~iM6BDd5An7MGx~>Bn@0vCjlwkeYR{jkU>|5^h?{E9Q zZ{N?(_vh!@{|7jP0?bnRj~iFN5Ag3b{C?lJ@B0QG!2i5+{QE)rS2nS*#;6{UvsCE+ 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(); +})();