From d928dfe3169d30b88a28c62a1806f031f1f1bb80 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 1 Sep 2026 15:48:43 +0200 Subject: [PATCH] UI --- plugins/trainingsplan/TOOL-README.md | 31 +-- plugins/trainingsplan/app.py | 41 +--- plugins/trainingsplan/plugin.json | 10 +- plugins/trainingsplan/schema_contract.py | 182 +++++++++++------ plugins/trainingsplan/static/index.html | 195 +++++++++++++++---- plugins/trainingsplan/tests/test_contract.py | 59 +++++- 6 files changed, 364 insertions(+), 154 deletions(-) diff --git a/plugins/trainingsplan/TOOL-README.md b/plugins/trainingsplan/TOOL-README.md index 14aa7b7..2e3f57e 100644 --- a/plugins/trainingsplan/TOOL-README.md +++ b/plugins/trainingsplan/TOOL-README.md @@ -6,29 +6,30 @@ Es verwaltet: - Plaene - Uebungen in einer zentralen Uebungsbibliothek +- einfache Progressionsvarianten pro Uebung - Trainingstage - Warm-up und Cool-down pro Trainingstag - Uebungen, die an Trainingstagen hinterlegt sind -Nicht mehr enthalten sind PDF-Erzeugung, Rezeptsammlungen, Progressionen, -Planvorschlaege, Analyse-/ChatGPT-Anbindungen und Farbdefinitionen fuer eine -Ausgabeoberflaeche. +Nicht mehr enthalten sind PDF-Erzeugung, Rezeptsammlungen, +Progressionsregeln, Wochenlogik, Tracking, Pruefungen, Planvorschlaege, +Analyse-/ChatGPT-Anbindungen und Farbdefinitionen fuer eine Ausgabeoberflaeche. ## Datenvertrag Trainingsplaene verwenden: ```text -schema_version: 4 -contract_version: 3 +schema_version: 5 +contract_version: 4 ``` Eine Plan-Config ist bewusst flach: ```json { - "schema_version": 4, - "contract_version": 3, + "schema_version": 5, + "contract_version": 4, "plan_id": "basis-plan", "meta": { "title": "Basis-Plan" @@ -37,7 +38,12 @@ Eine Plan-Config ist bewusst flach: { "id": "exercise-squat", "name": "Squat", - "cue": "Sauber und kontrolliert" + "progressions": [ + { "id": "progression-deep-squat-hold", "name": "Deep Squat Hold" }, + { "id": "progression-negative-squat", "name": "Negative Squat" }, + { "id": "progression-full-squat", "name": "Full Squat" } + ], + "current_progression_id": "progression-deep-squat-hold" } ], "days": [ @@ -50,9 +56,7 @@ Eine Plan-Config ist bewusst flach: "exercises": [ { "id": "day-exercise-1", - "exercise_id": "exercise-squat", - "sets": 3, - "reps": "8-12" + "exercise_id": "exercise-squat" } ] } @@ -92,5 +96,8 @@ Der alte aktive Config-Zugriff bleibt fuer einfache Clients erhalten: Aeltere Trainingsplaene werden beim Laden auf den flachen Vertrag reduziert. Uebungen aus alten Rotationen werden in die Uebungsbibliothek uebernommen und -als Tagesuebungen referenziert. Warm-up und Cool-down werden aus den bisherigen +als Tagesuebungen referenziert. Alte `stages.steps` oder `variants` werden +dedupliziert als einfache Progressionsvarianten an die Uebung gehaengt; die +erste Variante wird als aktuelle Wahrheit gesetzt, wenn noch keine aktuelle +Progression gespeichert ist. Warm-up und Cool-down werden aus den bisherigen Tages- oder `prepost`-Feldern uebernommen. diff --git a/plugins/trainingsplan/app.py b/plugins/trainingsplan/app.py index 199c295..9a4a860 100644 --- a/plugins/trainingsplan/app.py +++ b/plugins/trainingsplan/app.py @@ -393,17 +393,15 @@ def api_exercise_create(pid: str): return jsonify({"error": "name required"}), 400 def mutate(cfg: dict[str, Any]) -> dict[str, Any]: + progressions = body.get("progressions") if isinstance(body.get("progressions"), list) else [] exercise = { "id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), name), "name": name, + "progressions": progressions, } - for key in ("cue", "description", "equipment", "notes"): - value = str(body.get(key) or "").strip() - if value: - exercise[key] = value - tags = body.get("tags") - if isinstance(tags, list): - exercise["tags"] = [str(tag).strip() for tag in tags if str(tag).strip()] + current_progression_id = str(body.get("current_progression_id") or "").strip() + if current_progression_id: + exercise["current_progression_id"] = current_progression_id cfg.setdefault("exercises", []).append(exercise) return exercise @@ -424,15 +422,15 @@ def api_exercise_update(pid: str, exercise_id: str): if not exercise: return None updated = deepcopy(exercise) - for key in ("name", "cue", "description", "equipment", "notes"): + for key in ("name", "current_progression_id"): if key in body: value = str(body.get(key) or "").strip() if value: updated[key] = value else: updated.pop(key, None) - if isinstance(body.get("tags"), list): - updated["tags"] = [str(tag).strip() for tag in body["tags"] if str(tag).strip()] + if isinstance(body.get("progressions"), list): + updated["progressions"] = body["progressions"] _replace_list_item(exercises, exercise_id, updated) return updated @@ -514,7 +512,7 @@ def api_day_update(pid: str, day_id: str): if not day: return None updated = deepcopy(day) - for key in ("name", "focus", "notes"): + for key in ("name",): if key in body: value = str(body.get(key) or "").strip() if value: @@ -576,13 +574,6 @@ def api_day_exercise_add(pid: str, day_id: str): "id": stable_id("day-exercise", day_id, exercise_id, len(day.get("exercises", []))), "exercise_id": exercise_id, } - for key in ("sets", "duration_seconds"): - if body.get(key): - item[key] = int(body[key]) - for key in ("reps", "tempo", "rest", "notes"): - value = str(body.get(key) or "").strip() - if value: - item[key] = value day.setdefault("exercises", []).append(item) return item @@ -614,20 +605,6 @@ def api_day_exercise_update(pid: str, day_id: str, item_id: str): if not _find_by_id(cfg.get("exercises", []), exercise_id): return "exercise" updated["exercise_id"] = exercise_id - for key in ("sets", "duration_seconds"): - if key in body: - value = int(body.get(key) or 0) - if value > 0: - updated[key] = value - else: - updated.pop(key, None) - for key in ("reps", "tempo", "rest", "notes"): - if key in body: - value = str(body.get(key) or "").strip() - if value: - updated[key] = value - else: - updated.pop(key, None) _replace_list_item(day["exercises"], item_id, updated) return updated diff --git a/plugins/trainingsplan/plugin.json b/plugins/trainingsplan/plugin.json index b23f38d..6a502fa 100644 --- a/plugins/trainingsplan/plugin.json +++ b/plugins/trainingsplan/plugin.json @@ -1,8 +1,8 @@ { "id": "trainingsplan", "name": "Trainingsplan-Backend", - "summary": "Schlankes Backend zum Anlegen von Trainingsplänen, Trainingstagen und Übungen", - "description": "Eintragebackend für Trainingspläne mit zentraler Übungsbibliothek, Trainingstagen, Warm-up, Cool-down und Tagesübungen.", + "summary": "Schlankes Backend zum Anlegen von Trainingsplänen, Trainingstagen und Übungsprogressionen", + "description": "Eintragebackend für Trainingspläne mit zentraler Übungsbibliothek, aktuellen Progressionsvarianten, Trainingstagen, Warm-up, Cool-down und Tagesübungen.", "icon": "🏋", "category": "Planung", "version": "3.0.0", @@ -10,12 +10,12 @@ "order": 10, "requires": [], "contract": { - "plan_schema": 4, - "training_contract": 3 + "plan_schema": 5, + "training_contract": 4 }, "features": [ "Pläne anlegen, umbenennen, auswählen und löschen", - "Übungen in einer zentralen Bibliothek verwalten", + "Übungen mit einfachen Progressionsvarianten verwalten", "Trainingstage mit Warm-up und Cool-down verwalten", "Übungen an Trainingstagen hinterlegen", "Atomisches Speichern mit Backups und Revisionskonfliktschutz" diff --git a/plugins/trainingsplan/schema_contract.py b/plugins/trainingsplan/schema_contract.py index 635a9a8..71bfee9 100644 --- a/plugins/trainingsplan/schema_contract.py +++ b/plugins/trainingsplan/schema_contract.py @@ -4,11 +4,12 @@ from __future__ import annotations import copy import hashlib +import html import re from typing import Any -PLAN_SCHEMA_VERSION = 4 -CONTRACT_VERSION = 3 +PLAN_SCHEMA_VERSION = 5 +CONTRACT_VERSION = 4 def slug(value: Any) -> str: @@ -59,23 +60,102 @@ def _text_items(value: Any, fallback: Any = None) -> dict[str, list[str]]: return {"items": items} -def _normalize_exercise(raw: Any, plan_id: str, index: int) -> dict[str, Any] | None: +def _html_text(value: Any) -> str: + return html.unescape(_string(value)) + + +def _legacy_stage_lookup(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: + stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {} + lookup: dict[str, dict[str, Any]] = {} + for key, stage in stages.items(): + if not isinstance(stage, dict): + continue + for candidate in (key, stage.get("key"), stage.get("id")): + sid = _string(candidate) + if sid: + lookup[sid] = stage + return lookup + + +def _legacy_stage_for(raw: dict[str, Any], lookup: dict[str, dict[str, Any]]) -> dict[str, Any] | None: + for candidate in (raw.get("key"), raw.get("progression_id"), raw.get("stage_id")): + sid = _string(candidate) + if sid and sid in lookup: + return lookup[sid] + return None + + +def _progression_sources(raw: dict[str, Any], stage: dict[str, Any] | None) -> list[Any]: + for key in ("progressions", "variants"): + value = raw.get(key) + if isinstance(value, list) and value: + return value + if isinstance(stage, dict) and isinstance(stage.get("steps"), list): + return stage["steps"] + return [] + + +def _normalize_progressions( + raw: dict[str, Any], + plan_id: str, + exercise_id: str, + stage: dict[str, Any] | None, +) -> tuple[list[dict[str, Any]], str]: + progressions: list[dict[str, Any]] = [] + seen: set[str] = set() + selected = _string(raw.get("current_progression_id")) + selected_name = _html_text(raw.get("current_progression")) + + for index, item in enumerate(_progression_sources(raw, stage)): + if isinstance(item, dict): + name = _html_text(item.get("name") or item.get("title")) + else: + name = _html_text(item) + if not name: + continue + fingerprint = slug(name) + if fingerprint in seen: + continue + seen.add(fingerprint) + progression_id = _string(item.get("id")) if isinstance(item, dict) else "" + if not progression_id: + progression_id = stable_id("progression", plan_id, exercise_id, index, name) + progressions.append({"id": progression_id, "name": name}) + if selected_name and slug(selected_name) == fingerprint: + selected = progression_id + + ids = {item["id"] for item in progressions} + if selected not in ids: + selected = progressions[0]["id"] if progressions else "" + return progressions, selected + + +def _exercise_identity(raw: dict[str, Any], plan_id: str, index: int, name: str) -> str: + exercise_id = _string(raw.get("exercise_id") or raw.get("id")) + if exercise_id: + return exercise_id + key = _string(raw.get("key") or raw.get("progression_id")) + if key: + return stable_id("exercise", plan_id, key) + return stable_id("exercise", plan_id, slug(name or "uebung")) + + +def _normalize_exercise( + raw: Any, + plan_id: str, + index: int, + stage_lookup: dict[str, dict[str, Any]] | None = None, +) -> dict[str, Any] | None: if not isinstance(raw, dict): return None - name = _string(raw.get("name") or raw.get("title")) - exercise_id = _string(raw.get("id") or raw.get("exercise_id")) - if not exercise_id: - exercise_id = stable_id("exercise", plan_id, index, name or "uebung") + name = _html_text(raw.get("name") or raw.get("title")) + exercise_id = _exercise_identity(raw, plan_id, index, name) exercise = {"id": exercise_id, "name": name} - for key in ("cue", "description", "equipment", "notes"): - value = _string(raw.get(key)) - if value: - exercise[key] = value - tags = raw.get("tags") - if isinstance(tags, list): - clean_tags = [_string(tag) for tag in tags if _string(tag)] - if clean_tags: - exercise["tags"] = clean_tags + stage = _legacy_stage_for(raw, stage_lookup or {}) + progressions, selected = _normalize_progressions(raw, plan_id, exercise_id, stage) + if progressions: + exercise["progressions"] = progressions + exercise["current_progression_id"] = selected return exercise @@ -107,15 +187,12 @@ def _legacy_day_exercises(day: dict[str, Any]) -> list[dict[str, Any]]: for rotation in rotations: if not isinstance(rotation, dict): continue - label = _string(rotation.get("label")) exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else [] for exercise in exercises: if not isinstance(exercise, dict): continue item = copy.deepcopy(exercise) item["__legacy_catalog"] = True - if label and not _string(item.get("notes")): - item["notes"] = label result.append(item) return result @@ -125,24 +202,22 @@ def _ensure_catalog_entry( raw: dict[str, Any], plan_id: str, index: int, + stage_lookup: dict[str, dict[str, Any]], ) -> str: - exercise_id = _string(raw.get("exercise_id") or raw.get("id")) - name = _string(raw.get("name") or raw.get("title")) - if not exercise_id: - exercise_id = stable_id("exercise", plan_id, index, name or "uebung") + name = _html_text(raw.get("name") or raw.get("title")) + exercise_id = _exercise_identity(raw, plan_id, index, name) if exercise_id not in catalog: - source = { - "id": exercise_id, - "name": name, - "cue": raw.get("cue"), - "description": raw.get("description"), - "equipment": raw.get("equipment"), - "tags": raw.get("tags"), - } - catalog[exercise_id] = _normalize_exercise(source, plan_id, index) or { + source = {**raw, "id": exercise_id, "name": name} + catalog[exercise_id] = _normalize_exercise(source, plan_id, index, stage_lookup) or { "id": exercise_id, "name": name, } + elif not catalog[exercise_id].get("progressions"): + source = {**raw, "id": exercise_id, "name": name} + exercise = _normalize_exercise(source, plan_id, index, stage_lookup) + if exercise and exercise.get("progressions"): + catalog[exercise_id]["progressions"] = exercise["progressions"] + catalog[exercise_id]["current_progression_id"] = exercise["current_progression_id"] return exercise_id @@ -150,16 +225,7 @@ def _normalize_day_exercise(raw: dict[str, Any], day_id: str, exercise_id: str, item_id = _string(raw.get("id")) if not item_id or item_id == exercise_id: item_id = stable_id("day-exercise", day_id, exercise_id, index) - item: dict[str, Any] = {"id": item_id, "exercise_id": exercise_id} - for key in ("sets", "duration_seconds"): - value = _int(raw.get(key), 0) - if value > 0: - item[key] = value - for key in ("reps", "tempo", "rest", "notes", "legacy_id"): - value = _string(raw.get(key)) - if value: - item[key] = value - return item + return {"id": item_id, "exercise_id": exercise_id} def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]: @@ -180,8 +246,9 @@ def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[s normalized["meta"]["subtitle"] = subtitle catalog: dict[str, dict[str, Any]] = {} + stage_lookup = _legacy_stage_lookup(cfg) for index, raw in enumerate(_catalog_sources(cfg)): - exercise = _normalize_exercise(raw, resolved_plan_id, index) + exercise = _normalize_exercise(raw, resolved_plan_id, index, stage_lookup) if exercise: catalog[exercise["id"]] = exercise @@ -203,20 +270,17 @@ def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[s "cooldown": _text_items(raw_day.get("cooldown"), prepost.get("cooldown")), "exercises": [], } - focus = _string(raw_day.get("focus")) - if focus and focus != name: - day["focus"] = focus - notes = _string(raw_day.get("notes") or raw_day.get("note")) - if notes: - day["notes"] = notes - + seen_day_exercises: set[str] = set() for item_index, raw_item in enumerate(_legacy_day_exercises(raw_day)): should_create = bool(raw_item.pop("__legacy_catalog", False) or _string(raw_item.get("name"))) if should_create: - exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter) + exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter, stage_lookup) exercise_counter += 1 else: exercise_id = _string(raw_item.get("exercise_id")) + if exercise_id in seen_day_exercises: + continue + seen_day_exercises.add(exercise_id) day["exercises"].append(_normalize_day_exercise(raw_item, day_id, exercise_id, item_index)) normalized["days"].append(day) @@ -254,6 +318,16 @@ def validate_training_config(config: Any) -> dict[str, Any]: exercise_ids.add(_string(exercise.get("id"))) if not _string(exercise.get("name")): errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."}) + progression_ids: set[str] = set() + for progression_index, progression in enumerate(exercise.get("progressions", [])): + progression_location = f"{location}, Progression {progression_index + 1}" + register(progression.get("id"), progression_location) + progression_ids.add(_string(progression.get("id"))) + if not _string(progression.get("name")): + errors.append({"code": "empty_progression", "location": progression_location, "message": "Progressionsname fehlt."}) + current_progression_id = _string(exercise.get("current_progression_id")) + if current_progression_id and current_progression_id not in progression_ids: + errors.append({"code": "unknown_progression", "location": location, "message": "Aktuelle Progression existiert nicht in dieser Übung."}) if not cfg.get("days"): warnings.append({"code": "no_days", "location": "Plan", "message": "Noch keine Trainingstage angelegt."}) @@ -271,11 +345,5 @@ def validate_training_config(config: Any) -> dict[str, Any]: exercise_id = _string(item.get("exercise_id")) if exercise_id not in exercise_ids: errors.append({"code": "unknown_exercise", "location": item_location, "message": "Hinterlegte Übung existiert nicht in der Übungsbibliothek."}) - sets = _int(item.get("sets"), 0) - if "sets" in item and not 1 <= sets <= 40: - errors.append({"code": "invalid_sets", "location": item_location, "message": "Sätze müssen zwischen 1 und 40 liegen."}) - duration = _int(item.get("duration_seconds"), 0) - if "duration_seconds" in item and duration <= 0: - errors.append({"code": "invalid_duration", "location": item_location, "message": "Dauer muss größer als 0 sein."}) return {"errors": errors, "warnings": warnings, "config": cfg} diff --git a/plugins/trainingsplan/static/index.html b/plugins/trainingsplan/static/index.html index c7a3d86..7e0daac 100644 --- a/plugins/trainingsplan/static/index.html +++ b/plugins/trainingsplan/static/index.html @@ -18,22 +18,27 @@ .tp-day-body { padding: 14px 16px 16px; } .tp-day-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; flex-wrap: wrap; } .tp-textareas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; } - .tp-exercise-row { display: grid; grid-template-columns: minmax(180px, 2fr) 72px minmax(80px, 1fr) 86px minmax(110px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); } + .tp-exercise-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); } .tp-exercise-row:last-child { border-bottom: none; } .tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; } - .tp-addline { display: grid; grid-template-columns: minmax(200px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; } - .tp-table-input { min-width: 130px; } + .tp-library { display: grid; gap: 12px; } + .tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 14px 16px; } + .tp-library-row:last-child { border-bottom: none; } + .tp-library-main { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; gap: 8px; align-items: end; } + .tp-progressions { display: grid; gap: 8px; margin-top: 10px; } + .tp-progression-row { display: grid; grid-template-columns: 34px minmax(180px, 1fr) auto; gap: 8px; align-items: center; } + .tp-progression-number { color: var(--bt-muted); font-size: 12px; text-align: center; } + .tp-addline, .tp-formline { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; } + .tp-table-input { width: 100%; min-width: 0; } .tp-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; } .tp-warn { color: var(--bt-warn); } @media (max-width: 980px) { - .tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline { grid-template-columns: 1fr; } + .tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline, .tp-formline { grid-template-columns: 1fr; } .tp-side { position: static; } .tp-actions, .tp-day-actions { justify-content: flex-start; } - .tp-exercise-row { grid-template-columns: 1fr 86px 1fr 86px; } - .tp-exercise-row label:first-child, .tp-exercise-row label:nth-child(5), .tp-exercise-actions { grid-column: 1 / -1; } - } - @media (max-width: 620px) { - .tp-exercise-row { grid-template-columns: 1fr; } + .tp-exercise-row, .tp-library-main, .tp-progression-row { grid-template-columns: 1fr; } + .tp-exercise-actions { justify-content: flex-start; } + .tp-progression-number { text-align: left; } } @@ -133,6 +138,20 @@ function exerciseName(id) { return (state.cfg?.exercises || []).find((exercise) => exercise.id === id)?.name || id || "Übung"; } +function currentProgressionName(exerciseId) { + const exercise = (state.cfg?.exercises || []).find((item) => item.id === exerciseId); + if (!exercise) return ""; + const progressions = exercise.progressions || []; + const selected = progressions.find((item) => item.id === exercise.current_progression_id) || progressions[0]; + return selected?.name || ""; +} + +function progressionOptions(exercise, selected = "") { + return (exercise.progressions || []).map((progression) => + `` + ).join(""); +} + function optionList(selected = "") { return (state.cfg?.exercises || []).map((exercise) => `` @@ -177,7 +196,7 @@ function renderStatus() { ? `${state.activePlanId} · Bearbeitung R${wrapper.revision || 1} · veröffentlicht R${wrapper.published_revision || 1}` : "Kein Trainingsplan gefunden."; $("statusPills").innerHTML = [ - `Schema ${cfg.schema_version || 4}/${cfg.contract_version || 3}`, + `Schema ${cfg.schema_version || 5}/${cfg.contract_version || 4}`, `${(cfg.days || []).length} Tage`, `${(cfg.exercises || []).length} Übungen`, state.dirty || wrapper.has_unpublished_changes @@ -231,10 +250,7 @@ function dayHtml(day, index) { const items = (day.exercises || []).map((item, itemIndex) => `
- - - - +
@@ -271,32 +287,44 @@ function dayHtml(day, index) { function renderExercises() { const rows = (state.cfg.exercises || []).map((exercise) => ` - - - - - - ${esc(exercise.id)} - - +
+
+ + +
+ + +
+
+
+ ${(exercise.progressions || []).map((progression, index) => ` +
+
${index + 1}
+ +
+ + + +
+
+ `).join("") || `
Keine Progressionen hinterlegt.
`} +
+
${esc(exercise.id)}
+
`).join(""); $("tab-uebungen").innerHTML = `

Übung anlegen

-
+
- - -
+
- ${rows ? ` - - - ${rows} -
NameCueEquipmentBeschreibungID
- ` : `
Noch keine Übungen.
`} + ${rows ? `
${rows}
` : `
Noch keine Übungen.
`}
`; } @@ -322,8 +350,8 @@ function addExerciseFromForm() { state.cfg.exercises.push({ id: uid("exercise"), name, - cue: $("newExerciseCue")?.value.trim() || "", - equipment: $("newExerciseEquipment")?.value.trim() || "", + progressions: [], + current_progression_id: "", }); markDirty(); renderAll(); @@ -345,8 +373,56 @@ function move(list, index, delta) { renderAll(); } +function syncFormStateFromDom() { + if (!state.cfg) return; + state.cfg.meta = state.cfg.meta || {}; + state.cfg.meta.title = $("metaTitle").value; + if ($("metaSubtitle").value.trim()) state.cfg.meta.subtitle = $("metaSubtitle").value; + else delete state.cfg.meta.subtitle; + + document.querySelectorAll("[data-exercise-id]").forEach((row) => { + const exercise = state.cfg.exercises.find((item) => item.id === row.dataset.exerciseId); + if (!exercise) return; + row.querySelectorAll("[data-exercise-field]").forEach((input) => { + const key = input.dataset.exerciseField; + if (key === "name") exercise.name = input.value; + else if (input.value.trim()) exercise[key] = input.value; + else delete exercise[key]; + }); + row.querySelectorAll("[data-progression-id]").forEach((progressionRow) => { + const progression = (exercise.progressions || []).find((item) => item.id === progressionRow.dataset.progressionId); + const input = progressionRow.querySelector('[data-progression-field="name"]'); + if (progression && input) progression.name = input.value; + }); + }); + + document.querySelectorAll("[data-day-id]").forEach((shell) => { + const day = state.cfg.days.find((item) => item.id === shell.dataset.dayId); + if (!day) return; + const num = shell.querySelector('[data-day-prop="num"]'); + const name = shell.querySelector('[data-day-prop="name"]'); + const warmup = shell.querySelector('[data-day-text="warmup"]'); + const cooldown = shell.querySelector('[data-day-text="cooldown"]'); + if (num) day.num = Number(num.value || 1); + if (name) { + day.name = name.value; + delete day.focus; + } + if (warmup) day.warmup = {items: warmup.value.split("\n").map((item) => item.trim()).filter(Boolean)}; + if (cooldown) day.cooldown = {items: cooldown.value.split("\n").map((item) => item.trim()).filter(Boolean)}; + + shell.querySelectorAll("[data-day-item]").forEach((row) => { + const item = (day.exercises || []).find((entry) => entry.id === row.dataset.dayItem); + if (!item) return; + const exercise = row.querySelector('[data-day-field="exercise_id"]'); + if (exercise) item.exercise_id = exercise.value; + }); + }); +} + async function savePlan() { if (!state.activePlanId || !state.cfg) return; + syncFormStateFromDom(); const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}`, { method: "POST", body: JSON.stringify({expected_revision: state.wrapper.revision, config: state.cfg}), @@ -362,6 +438,7 @@ async function savePlan() { async function publishPlan() { if (!state.activePlanId) return; + syncFormStateFromDom(); if (state.dirty) await savePlan(); const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}/publish`, { method: "POST", @@ -382,7 +459,7 @@ $("tabs").addEventListener("click", (event) => { if (state.tab === "json") renderJson(); }); -document.addEventListener("input", (event) => { +function handleFormEdit(event) { const target = event.target; if (!state.cfg) return; if (target.id === "metaTitle") { @@ -404,6 +481,14 @@ document.addEventListener("input", (event) => { else delete exercise[key]; markDirty(); } + const progressionRow = target.closest("[data-progression-id]"); + if (exerciseRow && progressionRow && target.dataset.progressionField) { + const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId); + const progression = (exercise?.progressions || []).find((item) => item.id === progressionRow.dataset.progressionId); + if (!progression) return; + progression[target.dataset.progressionField] = target.value; + markDirty(); + } const dayShell = target.closest("[data-day-id]"); if (dayShell) { const day = state.cfg.days.find((item) => item.id === dayShell.dataset.dayId); @@ -418,16 +503,15 @@ document.addEventListener("input", (event) => { const item = (day.exercises || []).find((entry) => entry.id === itemRow.dataset.dayItem); if (!item) return; const key = target.dataset.dayField; - if (key === "sets" || key === "duration_seconds") { - const value = Number(target.value || 0); - if (value > 0) item[key] = value; - else delete item[key]; - } else if (target.value.trim()) item[key] = target.value; + if (target.value.trim()) item[key] = target.value; else delete item[key]; markDirty(); } } -}); +} + +document.addEventListener("input", handleFormEdit); +document.addEventListener("change", handleFormEdit); document.addEventListener("click", async (event) => { const button = event.target.closest("button"); @@ -498,7 +582,16 @@ document.addEventListener("click", async (event) => { } const exerciseRow = button.closest("[data-exercise-id]"); - if (exerciseRow && button.dataset.removeExercise !== undefined) { + if (exerciseRow && button.dataset.addProgression !== undefined) { + const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId); + if (!exercise) return; + exercise.progressions = exercise.progressions || []; + const progression = {id: uid("progression"), name: "Neue Progression"}; + exercise.progressions.push(progression); + if (!exercise.current_progression_id) exercise.current_progression_id = progression.id; + markDirty(); + renderAll(); + } else if (exerciseRow && button.dataset.removeExercise !== undefined) { const id = exerciseRow.dataset.exerciseId; const used = state.cfg.days.some((day) => (day.exercises || []).some((item) => item.exercise_id === id)); if (used && !confirm("Übung ist in Tagen hinterlegt. Trotzdem entfernen?")) return; @@ -509,6 +602,24 @@ document.addEventListener("click", async (event) => { markDirty(); renderAll(); } + if (exerciseRow) { + const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId); + const progressionRow = button.closest("[data-progression-id]"); + if (exercise && progressionRow) { + exercise.progressions = exercise.progressions || []; + const progressionIndex = exercise.progressions.findIndex((item) => item.id === progressionRow.dataset.progressionId); + if (button.dataset.moveProgression) { + move(exercise.progressions, progressionIndex, Number(button.dataset.moveProgression)); + } else if (button.dataset.removeProgression !== undefined && progressionIndex >= 0) { + const [removed] = exercise.progressions.splice(progressionIndex, 1); + if (removed?.id === exercise.current_progression_id) { + exercise.current_progression_id = exercise.progressions[0]?.id || ""; + } + markDirty(); + renderAll(); + } + } + } } catch (err) { toast(err.message, "err"); } diff --git a/plugins/trainingsplan/tests/test_contract.py b/plugins/trainingsplan/tests/test_contract.py index 65c2b16..a9287a2 100644 --- a/plugins/trainingsplan/tests/test_contract.py +++ b/plugins/trainingsplan/tests/test_contract.py @@ -41,6 +41,11 @@ def legacy_config(): "name": "Squat", "key": "squat", "cue": "tief", + "equipment": "Körpergewicht", + "description": "alter Beschreibungstext", + "sets": 3, + "reps": "8-12", + "duration_seconds": 30, "result_schema": {"mode": "reps"}, } ], @@ -54,8 +59,8 @@ def legacy_config(): def test_migration_flattens_legacy_plan_to_days_and_exercises(): migrated = migrate_training_config(legacy_config(), plan_id="test") - assert migrated["schema_version"] == 4 - assert migrated["contract_version"] == 3 + assert migrated["schema_version"] == 5 + assert migrated["contract_version"] == 4 assert migrated["plan_id"] == "test" assert "colors" not in migrated assert "training_format" not in migrated @@ -64,7 +69,10 @@ def test_migration_flattens_legacy_plan_to_days_and_exercises(): assert "exercise_catalog" not in migrated assert migrated["meta"] == {"title": "Alter Plan", "subtitle": "wird reduziert"} - assert migrated["exercises"] == [{"id": "exercise-squat", "name": "Squat", "cue": "tief"}] + assert migrated["exercises"][0]["id"] == "exercise-squat" + assert migrated["exercises"][0]["name"] == "Squat" + assert [item["name"] for item in migrated["exercises"][0]["progressions"]] == ["leicht", "schwer"] + assert migrated["exercises"][0]["current_progression_id"] == migrated["exercises"][0]["progressions"][0]["id"] day = migrated["days"][0] assert day["name"] == "Beine" @@ -72,12 +80,13 @@ def test_migration_flattens_legacy_plan_to_days_and_exercises(): assert day["cooldown"]["items"] == ["Atmen", "Locker gehen"] assert "stretch" not in day assert "rotations" not in day + assert "focus" not in day + assert "notes" not in day item = day["exercises"][0] assert item == { "id": "old-placement", "exercise_id": "exercise-squat", - "notes": "Block A", } @@ -87,10 +96,48 @@ def test_migration_is_stable_across_repeated_runs(): assert first == second +def test_root_json_style_keys_create_one_exercise_with_stage_progressions(): + cfg = { + "meta": {"title": "Root Import"}, + "stages": { + "ls": { + "name": "Push-up-Progression", + "steps": [ + {"name": "High Plank"}, + {"name": "Negative"}, + {"name": "Negative"}, + {"name": "voll"}, + ], + } + }, + "days": [ + { + "num": 1, + "focus": "A", + "rotations": [{"exercises": [{"key": "ls", "name": "Push-up-Progression"}]}], + }, + { + "num": 2, + "focus": "B", + "rotations": [{"exercises": [{"key": "ls", "name": "Push-up-Progression"}]}], + }, + ], + } + + migrated = migrate_training_config(cfg, plan_id="root") + + assert len(migrated["exercises"]) == 1 + exercise = migrated["exercises"][0] + assert [item["name"] for item in exercise["progressions"]] == ["High Plank", "Negative", "voll"] + assert len(migrated["days"][0]["exercises"]) == 1 + assert migrated["days"][0]["exercises"][0]["exercise_id"] == exercise["id"] + assert migrated["days"][1]["exercises"][0]["exercise_id"] == exercise["id"] + + def test_validation_accepts_complete_minimal_plan(): cfg = { "meta": {"title": "Minimal"}, - "exercises": [{"id": "exercise-pushup", "name": "Push-up"}], + "exercises": [{"id": "exercise-pushup", "name": "Push-up", "progressions": [{"id": "progression-high-plank", "name": "High Plank"}], "current_progression_id": "progression-high-plank"}], "days": [ { "id": "day-1", @@ -98,7 +145,7 @@ def test_validation_accepts_complete_minimal_plan(): "name": "Tag 1", "warmup": {"items": ["Mobilisieren"]}, "cooldown": {"items": ["Atmen"]}, - "exercises": [{"id": "item-1", "exercise_id": "exercise-pushup", "sets": 3, "reps": "8-12"}], + "exercises": [{"id": "item-1", "exercise_id": "exercise-pushup"}], } ], }