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 0000000..5a26681 Binary files /dev/null and b/shinkan-dev-screenshot.png differ diff --git a/test-shinkan.js b/test-shinkan.js new file mode 100644 index 0000000..cfb0fc1 --- /dev/null +++ b/test-shinkan.js @@ -0,0 +1,40 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + console.log('=== Testing Shinkan Frontend ===\n'); + + try { + await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 }); + + const title = await page.title(); + console.log('📄 Title:', title); + + const h1 = await page.textContent('h1').catch(() => null); + console.log('🥋 Heading:', h1); + + const bodyText = await page.evaluate(() => document.body.innerText); + console.log('\n📝 Page Content:\n' + '='.repeat(60)); + console.log(bodyText); + console.log('='.repeat(60)); + + const buttons = await page.locator('button').count(); + const forms = await page.locator('form').count(); + const inputs = await page.locator('input').count(); + + console.log('\n🔍 Elements Found:'); + console.log(' - Buttons:', buttons); + console.log(' - Forms:', forms); + console.log(' - Inputs:', inputs); + + await page.screenshot({ path: 'shinkan-dev-screenshot.png', fullPage: true }); + console.log('\n📸 Screenshot: shinkan-dev-screenshot.png'); + + } catch (error) { + console.error('❌ Error:', error.message); + } + + await browser.close(); +})();