This commit is contained in:
2026-09-01 15:48:43 +02:00
parent e274ece128
commit d928dfe316
6 changed files with 364 additions and 154 deletions
+19 -12
View File
@@ -6,29 +6,30 @@ Es verwaltet:
- Plaene - Plaene
- Uebungen in einer zentralen Uebungsbibliothek - Uebungen in einer zentralen Uebungsbibliothek
- einfache Progressionsvarianten pro Uebung
- Trainingstage - Trainingstage
- Warm-up und Cool-down pro Trainingstag - Warm-up und Cool-down pro Trainingstag
- Uebungen, die an Trainingstagen hinterlegt sind - Uebungen, die an Trainingstagen hinterlegt sind
Nicht mehr enthalten sind PDF-Erzeugung, Rezeptsammlungen, Progressionen, Nicht mehr enthalten sind PDF-Erzeugung, Rezeptsammlungen,
Planvorschlaege, Analyse-/ChatGPT-Anbindungen und Farbdefinitionen fuer eine Progressionsregeln, Wochenlogik, Tracking, Pruefungen, Planvorschlaege,
Ausgabeoberflaeche. Analyse-/ChatGPT-Anbindungen und Farbdefinitionen fuer eine Ausgabeoberflaeche.
## Datenvertrag ## Datenvertrag
Trainingsplaene verwenden: Trainingsplaene verwenden:
```text ```text
schema_version: 4 schema_version: 5
contract_version: 3 contract_version: 4
``` ```
Eine Plan-Config ist bewusst flach: Eine Plan-Config ist bewusst flach:
```json ```json
{ {
"schema_version": 4, "schema_version": 5,
"contract_version": 3, "contract_version": 4,
"plan_id": "basis-plan", "plan_id": "basis-plan",
"meta": { "meta": {
"title": "Basis-Plan" "title": "Basis-Plan"
@@ -37,7 +38,12 @@ Eine Plan-Config ist bewusst flach:
{ {
"id": "exercise-squat", "id": "exercise-squat",
"name": "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": [ "days": [
@@ -50,9 +56,7 @@ Eine Plan-Config ist bewusst flach:
"exercises": [ "exercises": [
{ {
"id": "day-exercise-1", "id": "day-exercise-1",
"exercise_id": "exercise-squat", "exercise_id": "exercise-squat"
"sets": 3,
"reps": "8-12"
} }
] ]
} }
@@ -92,5 +96,8 @@ Der alte aktive Config-Zugriff bleibt fuer einfache Clients erhalten:
Aeltere Trainingsplaene werden beim Laden auf den flachen Vertrag reduziert. Aeltere Trainingsplaene werden beim Laden auf den flachen Vertrag reduziert.
Uebungen aus alten Rotationen werden in die Uebungsbibliothek uebernommen und 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. Tages- oder `prepost`-Feldern uebernommen.
+9 -32
View File
@@ -393,17 +393,15 @@ def api_exercise_create(pid: str):
return jsonify({"error": "name required"}), 400 return jsonify({"error": "name required"}), 400
def mutate(cfg: dict[str, Any]) -> dict[str, Any]: def mutate(cfg: dict[str, Any]) -> dict[str, Any]:
progressions = body.get("progressions") if isinstance(body.get("progressions"), list) else []
exercise = { exercise = {
"id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), name), "id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), name),
"name": name, "name": name,
"progressions": progressions,
} }
for key in ("cue", "description", "equipment", "notes"): current_progression_id = str(body.get("current_progression_id") or "").strip()
value = str(body.get(key) or "").strip() if current_progression_id:
if value: exercise["current_progression_id"] = current_progression_id
exercise[key] = value
tags = body.get("tags")
if isinstance(tags, list):
exercise["tags"] = [str(tag).strip() for tag in tags if str(tag).strip()]
cfg.setdefault("exercises", []).append(exercise) cfg.setdefault("exercises", []).append(exercise)
return exercise return exercise
@@ -424,15 +422,15 @@ def api_exercise_update(pid: str, exercise_id: str):
if not exercise: if not exercise:
return None return None
updated = deepcopy(exercise) updated = deepcopy(exercise)
for key in ("name", "cue", "description", "equipment", "notes"): for key in ("name", "current_progression_id"):
if key in body: if key in body:
value = str(body.get(key) or "").strip() value = str(body.get(key) or "").strip()
if value: if value:
updated[key] = value updated[key] = value
else: else:
updated.pop(key, None) updated.pop(key, None)
if isinstance(body.get("tags"), list): if isinstance(body.get("progressions"), list):
updated["tags"] = [str(tag).strip() for tag in body["tags"] if str(tag).strip()] updated["progressions"] = body["progressions"]
_replace_list_item(exercises, exercise_id, updated) _replace_list_item(exercises, exercise_id, updated)
return updated return updated
@@ -514,7 +512,7 @@ def api_day_update(pid: str, day_id: str):
if not day: if not day:
return None return None
updated = deepcopy(day) updated = deepcopy(day)
for key in ("name", "focus", "notes"): for key in ("name",):
if key in body: if key in body:
value = str(body.get(key) or "").strip() value = str(body.get(key) or "").strip()
if value: 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", []))), "id": stable_id("day-exercise", day_id, exercise_id, len(day.get("exercises", []))),
"exercise_id": exercise_id, "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) day.setdefault("exercises", []).append(item)
return 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): if not _find_by_id(cfg.get("exercises", []), exercise_id):
return "exercise" return "exercise"
updated["exercise_id"] = exercise_id 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) _replace_list_item(day["exercises"], item_id, updated)
return updated return updated
+5 -5
View File
@@ -1,8 +1,8 @@
{ {
"id": "trainingsplan", "id": "trainingsplan",
"name": "Trainingsplan-Backend", "name": "Trainingsplan-Backend",
"summary": "Schlankes Backend zum Anlegen von Trainingsplänen, Trainingstagen und Übungen", "summary": "Schlankes Backend zum Anlegen von Trainingsplänen, Trainingstagen und Übungsprogressionen",
"description": "Eintragebackend für Trainingspläne mit zentraler Übungsbibliothek, Trainingstagen, Warm-up, Cool-down und Tagesübungen.", "description": "Eintragebackend für Trainingspläne mit zentraler Übungsbibliothek, aktuellen Progressionsvarianten, Trainingstagen, Warm-up, Cool-down und Tagesübungen.",
"icon": "🏋", "icon": "🏋",
"category": "Planung", "category": "Planung",
"version": "3.0.0", "version": "3.0.0",
@@ -10,12 +10,12 @@
"order": 10, "order": 10,
"requires": [], "requires": [],
"contract": { "contract": {
"plan_schema": 4, "plan_schema": 5,
"training_contract": 3 "training_contract": 4
}, },
"features": [ "features": [
"Pläne anlegen, umbenennen, auswählen und löschen", "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", "Trainingstage mit Warm-up und Cool-down verwalten",
"Übungen an Trainingstagen hinterlegen", "Übungen an Trainingstagen hinterlegen",
"Atomisches Speichern mit Backups und Revisionskonfliktschutz" "Atomisches Speichern mit Backups und Revisionskonfliktschutz"
+125 -57
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
import html
import re import re
from typing import Any from typing import Any
PLAN_SCHEMA_VERSION = 4 PLAN_SCHEMA_VERSION = 5
CONTRACT_VERSION = 3 CONTRACT_VERSION = 4
def slug(value: Any) -> str: def slug(value: Any) -> str:
@@ -59,23 +60,102 @@ def _text_items(value: Any, fallback: Any = None) -> dict[str, list[str]]:
return {"items": items} 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): if not isinstance(raw, dict):
return None return None
name = _string(raw.get("name") or raw.get("title")) name = _html_text(raw.get("name") or raw.get("title"))
exercise_id = _string(raw.get("id") or raw.get("exercise_id")) exercise_id = _exercise_identity(raw, plan_id, index, name)
if not exercise_id:
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
exercise = {"id": exercise_id, "name": name} exercise = {"id": exercise_id, "name": name}
for key in ("cue", "description", "equipment", "notes"): stage = _legacy_stage_for(raw, stage_lookup or {})
value = _string(raw.get(key)) progressions, selected = _normalize_progressions(raw, plan_id, exercise_id, stage)
if value: if progressions:
exercise[key] = value exercise["progressions"] = progressions
tags = raw.get("tags") exercise["current_progression_id"] = selected
if isinstance(tags, list):
clean_tags = [_string(tag) for tag in tags if _string(tag)]
if clean_tags:
exercise["tags"] = clean_tags
return exercise return exercise
@@ -107,15 +187,12 @@ def _legacy_day_exercises(day: dict[str, Any]) -> list[dict[str, Any]]:
for rotation in rotations: for rotation in rotations:
if not isinstance(rotation, dict): if not isinstance(rotation, dict):
continue continue
label = _string(rotation.get("label"))
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else [] exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
for exercise in exercises: for exercise in exercises:
if not isinstance(exercise, dict): if not isinstance(exercise, dict):
continue continue
item = copy.deepcopy(exercise) item = copy.deepcopy(exercise)
item["__legacy_catalog"] = True item["__legacy_catalog"] = True
if label and not _string(item.get("notes")):
item["notes"] = label
result.append(item) result.append(item)
return result return result
@@ -125,24 +202,22 @@ def _ensure_catalog_entry(
raw: dict[str, Any], raw: dict[str, Any],
plan_id: str, plan_id: str,
index: int, index: int,
stage_lookup: dict[str, dict[str, Any]],
) -> str: ) -> str:
exercise_id = _string(raw.get("exercise_id") or raw.get("id")) name = _html_text(raw.get("name") or raw.get("title"))
name = _string(raw.get("name") or raw.get("title")) exercise_id = _exercise_identity(raw, plan_id, index, name)
if not exercise_id:
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
if exercise_id not in catalog: if exercise_id not in catalog:
source = { source = {**raw, "id": exercise_id, "name": name}
"id": exercise_id, catalog[exercise_id] = _normalize_exercise(source, plan_id, index, stage_lookup) or {
"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 {
"id": exercise_id, "id": exercise_id,
"name": name, "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 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")) item_id = _string(raw.get("id"))
if not item_id or item_id == exercise_id: if not item_id or item_id == exercise_id:
item_id = stable_id("day-exercise", day_id, exercise_id, index) item_id = stable_id("day-exercise", day_id, exercise_id, index)
item: dict[str, Any] = {"id": item_id, "exercise_id": exercise_id} return {"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
def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]: 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 normalized["meta"]["subtitle"] = subtitle
catalog: dict[str, dict[str, Any]] = {} catalog: dict[str, dict[str, Any]] = {}
stage_lookup = _legacy_stage_lookup(cfg)
for index, raw in enumerate(_catalog_sources(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: if exercise:
catalog[exercise["id"]] = 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")), "cooldown": _text_items(raw_day.get("cooldown"), prepost.get("cooldown")),
"exercises": [], "exercises": [],
} }
focus = _string(raw_day.get("focus")) seen_day_exercises: set[str] = set()
if focus and focus != name:
day["focus"] = focus
notes = _string(raw_day.get("notes") or raw_day.get("note"))
if notes:
day["notes"] = notes
for item_index, raw_item in enumerate(_legacy_day_exercises(raw_day)): 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"))) should_create = bool(raw_item.pop("__legacy_catalog", False) or _string(raw_item.get("name")))
if should_create: 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 exercise_counter += 1
else: else:
exercise_id = _string(raw_item.get("exercise_id")) 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)) day["exercises"].append(_normalize_day_exercise(raw_item, day_id, exercise_id, item_index))
normalized["days"].append(day) normalized["days"].append(day)
@@ -254,6 +318,16 @@ def validate_training_config(config: Any) -> dict[str, Any]:
exercise_ids.add(_string(exercise.get("id"))) exercise_ids.add(_string(exercise.get("id")))
if not _string(exercise.get("name")): if not _string(exercise.get("name")):
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."}) 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"): if not cfg.get("days"):
warnings.append({"code": "no_days", "location": "Plan", "message": "Noch keine Trainingstage angelegt."}) 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")) exercise_id = _string(item.get("exercise_id"))
if exercise_id not in exercise_ids: if exercise_id not in exercise_ids:
errors.append({"code": "unknown_exercise", "location": item_location, "message": "Hinterlegte Übung existiert nicht in der Übungsbibliothek."}) 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} return {"errors": errors, "warnings": warnings, "config": cfg}
+153 -42
View File
@@ -18,22 +18,27 @@
.tp-day-body { padding: 14px 16px 16px; } .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-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-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-row:last-child { border-bottom: none; }
.tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; } .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-library { display: grid; gap: 12px; }
.tp-table-input { min-width: 130px; } .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-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; }
.tp-warn { color: var(--bt-warn); } .tp-warn { color: var(--bt-warn); }
@media (max-width: 980px) { @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-side { position: static; }
.tp-actions, .tp-day-actions { justify-content: flex-start; } .tp-actions, .tp-day-actions { justify-content: flex-start; }
.tp-exercise-row { grid-template-columns: 1fr 86px 1fr 86px; } .tp-exercise-row, .tp-library-main, .tp-progression-row { grid-template-columns: 1fr; }
.tp-exercise-row label:first-child, .tp-exercise-row label:nth-child(5), .tp-exercise-actions { grid-column: 1 / -1; } .tp-exercise-actions { justify-content: flex-start; }
} .tp-progression-number { text-align: left; }
@media (max-width: 620px) {
.tp-exercise-row { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -133,6 +138,20 @@ function exerciseName(id) {
return (state.cfg?.exercises || []).find((exercise) => exercise.id === id)?.name || id || "Übung"; 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) =>
`<option value="${esc(progression.id)}"${progression.id === selected ? " selected" : ""}>${esc(progression.name || progression.id)}</option>`
).join("");
}
function optionList(selected = "") { function optionList(selected = "") {
return (state.cfg?.exercises || []).map((exercise) => return (state.cfg?.exercises || []).map((exercise) =>
`<option value="${esc(exercise.id)}"${exercise.id === selected ? " selected" : ""}>${esc(exercise.name || exercise.id)}</option>` `<option value="${esc(exercise.id)}"${exercise.id === selected ? " selected" : ""}>${esc(exercise.name || exercise.id)}</option>`
@@ -177,7 +196,7 @@ function renderStatus() {
? `${state.activePlanId} · Bearbeitung R${wrapper.revision || 1} · veröffentlicht R${wrapper.published_revision || 1}` ? `${state.activePlanId} · Bearbeitung R${wrapper.revision || 1} · veröffentlicht R${wrapper.published_revision || 1}`
: "Kein Trainingsplan gefunden."; : "Kein Trainingsplan gefunden.";
$("statusPills").innerHTML = [ $("statusPills").innerHTML = [
`<span class="bt-badge">Schema ${cfg.schema_version || 4}/${cfg.contract_version || 3}</span>`, `<span class="bt-badge">Schema ${cfg.schema_version || 5}/${cfg.contract_version || 4}</span>`,
`<span class="bt-badge">${(cfg.days || []).length} Tage</span>`, `<span class="bt-badge">${(cfg.days || []).length} Tage</span>`,
`<span class="bt-badge">${(cfg.exercises || []).length} Übungen</span>`, `<span class="bt-badge">${(cfg.exercises || []).length} Übungen</span>`,
state.dirty || wrapper.has_unpublished_changes state.dirty || wrapper.has_unpublished_changes
@@ -231,10 +250,7 @@ function dayHtml(day, index) {
const items = (day.exercises || []).map((item, itemIndex) => ` const items = (day.exercises || []).map((item, itemIndex) => `
<div class="tp-exercise-row" data-day-item="${esc(item.id)}"> <div class="tp-exercise-row" data-day-item="${esc(item.id)}">
<label>Übung<select data-day-field="exercise_id">${optionList(item.exercise_id)}</select></label> <label>Übung<select data-day-field="exercise_id">${optionList(item.exercise_id)}</select></label>
<label>Sätze<input type="number" min="1" max="40" data-day-field="sets" value="${esc(item.sets || "")}"></label> <label>Aktuell<input type="text" value="${esc(currentProgressionName(item.exercise_id) || "Keine Progression")}" readonly></label>
<label>Wdh.<input type="text" data-day-field="reps" value="${esc(item.reps || "")}"></label>
<label>Sek.<input type="number" min="1" data-day-field="duration_seconds" value="${esc(item.duration_seconds || "")}"></label>
<label>Notiz<input type="text" data-day-field="notes" value="${esc(item.notes || "")}"></label>
<div class="tp-exercise-actions"> <div class="tp-exercise-actions">
<button class="mini ghost" data-move-day-item="-1" title="Nach oben">↑</button> <button class="mini ghost" data-move-day-item="-1" title="Nach oben">↑</button>
<button class="mini ghost" data-move-day-item="1" title="Nach unten">↓</button> <button class="mini ghost" data-move-day-item="1" title="Nach unten">↓</button>
@@ -271,32 +287,44 @@ function dayHtml(day, index) {
function renderExercises() { function renderExercises() {
const rows = (state.cfg.exercises || []).map((exercise) => ` const rows = (state.cfg.exercises || []).map((exercise) => `
<tr data-exercise-id="${esc(exercise.id)}"> <div class="tp-library-row" data-exercise-id="${esc(exercise.id)}">
<td><input class="tp-table-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></td> <div class="tp-library-main">
<td><input class="tp-table-input" data-exercise-field="cue" value="${esc(exercise.cue || "")}"></td> <label>Name<input class="tp-table-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></label>
<td><input class="tp-table-input" data-exercise-field="equipment" value="${esc(exercise.equipment || "")}"></td> <label>Aktuelle Progression<select data-exercise-field="current_progression_id">
<td><input class="tp-table-input" data-exercise-field="description" value="${esc(exercise.description || "")}"></td> <option value="">Keine Progression</option>
<td class="bt-mono bt-muted">${esc(exercise.id)}</td> ${progressionOptions(exercise, exercise.current_progression_id)}
<td><button class="mini danger" data-remove-exercise>×</button></td> </select></label>
</tr> <div class="tp-exercise-actions">
<button class="mini ghost" data-add-progression>Progression</button>
<button class="mini danger" data-remove-exercise>×</button>
</div>
</div>
<div class="tp-progressions">
${(exercise.progressions || []).map((progression, index) => `
<div class="tp-progression-row" data-progression-id="${esc(progression.id)}">
<div class="tp-progression-number">${index + 1}</div>
<label>Progression<input class="tp-table-input" data-progression-field="name" value="${esc(progression.name || "")}"></label>
<div class="tp-exercise-actions">
<button class="mini ghost" data-move-progression="-1" title="Nach oben">↑</button>
<button class="mini ghost" data-move-progression="1" title="Nach unten">↓</button>
<button class="mini danger" data-remove-progression title="Entfernen">×</button>
</div>
</div>
`).join("") || `<div class="bt-empty">Keine Progressionen hinterlegt.</div>`}
</div>
<div class="bt-mono bt-muted" style="margin-top:8px">${esc(exercise.id)}</div>
</div>
`).join(""); `).join("");
$("tab-uebungen").innerHTML = ` $("tab-uebungen").innerHTML = `
<div class="bt-card"> <div class="bt-card">
<h2>Übung anlegen</h2> <h2>Übung anlegen</h2>
<div class="bt-grid4"> <div class="tp-formline">
<label>Name<input id="newExerciseName" type="text"></label> <label>Name<input id="newExerciseName" type="text"></label>
<label>Cue<input id="newExerciseCue" type="text"></label> <button class="primary" data-add-exercise>Hinzufügen</button>
<label>Equipment<input id="newExerciseEquipment" type="text"></label>
<div style="display:flex;align-items:end"><button class="primary" data-add-exercise>Hinzufügen</button></div>
</div> </div>
</div> </div>
<div class="bt-card bt-card-flush"> <div class="bt-card bt-card-flush">
${rows ? ` ${rows ? `<div class="tp-library">${rows}</div>` : `<div class="bt-empty" style="margin:16px">Noch keine Übungen.</div>`}
<table class="bt-table">
<thead><tr><th>Name</th><th>Cue</th><th>Equipment</th><th>Beschreibung</th><th>ID</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
` : `<div class="bt-empty" style="margin:16px">Noch keine Übungen.</div>`}
</div> </div>
`; `;
} }
@@ -322,8 +350,8 @@ function addExerciseFromForm() {
state.cfg.exercises.push({ state.cfg.exercises.push({
id: uid("exercise"), id: uid("exercise"),
name, name,
cue: $("newExerciseCue")?.value.trim() || "", progressions: [],
equipment: $("newExerciseEquipment")?.value.trim() || "", current_progression_id: "",
}); });
markDirty(); markDirty();
renderAll(); renderAll();
@@ -345,8 +373,56 @@ function move(list, index, delta) {
renderAll(); 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() { async function savePlan() {
if (!state.activePlanId || !state.cfg) return; if (!state.activePlanId || !state.cfg) return;
syncFormStateFromDom();
const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}`, { const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}`, {
method: "POST", method: "POST",
body: JSON.stringify({expected_revision: state.wrapper.revision, config: state.cfg}), body: JSON.stringify({expected_revision: state.wrapper.revision, config: state.cfg}),
@@ -362,6 +438,7 @@ async function savePlan() {
async function publishPlan() { async function publishPlan() {
if (!state.activePlanId) return; if (!state.activePlanId) return;
syncFormStateFromDom();
if (state.dirty) await savePlan(); if (state.dirty) await savePlan();
const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}/publish`, { const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}/publish`, {
method: "POST", method: "POST",
@@ -382,7 +459,7 @@ $("tabs").addEventListener("click", (event) => {
if (state.tab === "json") renderJson(); if (state.tab === "json") renderJson();
}); });
document.addEventListener("input", (event) => { function handleFormEdit(event) {
const target = event.target; const target = event.target;
if (!state.cfg) return; if (!state.cfg) return;
if (target.id === "metaTitle") { if (target.id === "metaTitle") {
@@ -404,6 +481,14 @@ document.addEventListener("input", (event) => {
else delete exercise[key]; else delete exercise[key];
markDirty(); 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]"); const dayShell = target.closest("[data-day-id]");
if (dayShell) { if (dayShell) {
const day = state.cfg.days.find((item) => item.id === dayShell.dataset.dayId); 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); const item = (day.exercises || []).find((entry) => entry.id === itemRow.dataset.dayItem);
if (!item) return; if (!item) return;
const key = target.dataset.dayField; const key = target.dataset.dayField;
if (key === "sets" || key === "duration_seconds") { if (target.value.trim()) item[key] = target.value;
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;
else delete item[key]; else delete item[key];
markDirty(); markDirty();
} }
} }
}); }
document.addEventListener("input", handleFormEdit);
document.addEventListener("change", handleFormEdit);
document.addEventListener("click", async (event) => { document.addEventListener("click", async (event) => {
const button = event.target.closest("button"); const button = event.target.closest("button");
@@ -498,7 +582,16 @@ document.addEventListener("click", async (event) => {
} }
const exerciseRow = button.closest("[data-exercise-id]"); 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 id = exerciseRow.dataset.exerciseId;
const used = state.cfg.days.some((day) => (day.exercises || []).some((item) => item.exercise_id === id)); 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; if (used && !confirm("Übung ist in Tagen hinterlegt. Trotzdem entfernen?")) return;
@@ -509,6 +602,24 @@ document.addEventListener("click", async (event) => {
markDirty(); markDirty();
renderAll(); 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) { } catch (err) {
toast(err.message, "err"); toast(err.message, "err");
} }
+53 -6
View File
@@ -41,6 +41,11 @@ def legacy_config():
"name": "Squat", "name": "Squat",
"key": "squat", "key": "squat",
"cue": "tief", "cue": "tief",
"equipment": "Körpergewicht",
"description": "alter Beschreibungstext",
"sets": 3,
"reps": "8-12",
"duration_seconds": 30,
"result_schema": {"mode": "reps"}, "result_schema": {"mode": "reps"},
} }
], ],
@@ -54,8 +59,8 @@ def legacy_config():
def test_migration_flattens_legacy_plan_to_days_and_exercises(): def test_migration_flattens_legacy_plan_to_days_and_exercises():
migrated = migrate_training_config(legacy_config(), plan_id="test") migrated = migrate_training_config(legacy_config(), plan_id="test")
assert migrated["schema_version"] == 4 assert migrated["schema_version"] == 5
assert migrated["contract_version"] == 3 assert migrated["contract_version"] == 4
assert migrated["plan_id"] == "test" assert migrated["plan_id"] == "test"
assert "colors" not in migrated assert "colors" not in migrated
assert "training_format" 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 "exercise_catalog" not in migrated
assert migrated["meta"] == {"title": "Alter Plan", "subtitle": "wird reduziert"} 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] day = migrated["days"][0]
assert day["name"] == "Beine" 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 day["cooldown"]["items"] == ["Atmen", "Locker gehen"]
assert "stretch" not in day assert "stretch" not in day
assert "rotations" not in day assert "rotations" not in day
assert "focus" not in day
assert "notes" not in day
item = day["exercises"][0] item = day["exercises"][0]
assert item == { assert item == {
"id": "old-placement", "id": "old-placement",
"exercise_id": "exercise-squat", "exercise_id": "exercise-squat",
"notes": "Block A",
} }
@@ -87,10 +96,48 @@ def test_migration_is_stable_across_repeated_runs():
assert first == second 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(): def test_validation_accepts_complete_minimal_plan():
cfg = { cfg = {
"meta": {"title": "Minimal"}, "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": [ "days": [
{ {
"id": "day-1", "id": "day-1",
@@ -98,7 +145,7 @@ def test_validation_accepts_complete_minimal_plan():
"name": "Tag 1", "name": "Tag 1",
"warmup": {"items": ["Mobilisieren"]}, "warmup": {"items": ["Mobilisieren"]},
"cooldown": {"items": ["Atmen"]}, "cooldown": {"items": ["Atmen"]},
"exercises": [{"id": "item-1", "exercise_id": "exercise-pushup", "sets": 3, "reps": "8-12"}], "exercises": [{"id": "item-1", "exercise_id": "exercise-pushup"}],
} }
], ],
} }