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
- 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.
+9 -32
View File
@@ -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
+5 -5
View File
@@ -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"
+125 -57
View File
@@ -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}
+153 -42
View File
@@ -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; }
}
</style>
</head>
@@ -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) =>
`<option value="${esc(progression.id)}"${progression.id === selected ? " selected" : ""}>${esc(progression.name || progression.id)}</option>`
).join("");
}
function optionList(selected = "") {
return (state.cfg?.exercises || []).map((exercise) =>
`<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}`
: "Kein Trainingsplan gefunden.";
$("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.exercises || []).length} Übungen</span>`,
state.dirty || wrapper.has_unpublished_changes
@@ -231,10 +250,7 @@ function dayHtml(day, index) {
const items = (day.exercises || []).map((item, itemIndex) => `
<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>Sätze<input type="number" min="1" max="40" data-day-field="sets" value="${esc(item.sets || "")}"></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>
<label>Aktuell<input type="text" value="${esc(currentProgressionName(item.exercise_id) || "Keine Progression")}" readonly></label>
<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 unten">↓</button>
@@ -271,32 +287,44 @@ function dayHtml(day, index) {
function renderExercises() {
const rows = (state.cfg.exercises || []).map((exercise) => `
<tr data-exercise-id="${esc(exercise.id)}">
<td><input class="tp-table-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></td>
<td><input class="tp-table-input" data-exercise-field="cue" value="${esc(exercise.cue || "")}"></td>
<td><input class="tp-table-input" data-exercise-field="equipment" value="${esc(exercise.equipment || "")}"></td>
<td><input class="tp-table-input" data-exercise-field="description" value="${esc(exercise.description || "")}"></td>
<td class="bt-mono bt-muted">${esc(exercise.id)}</td>
<td><button class="mini danger" data-remove-exercise>×</button></td>
</tr>
<div class="tp-library-row" data-exercise-id="${esc(exercise.id)}">
<div class="tp-library-main">
<label>Name<input class="tp-table-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></label>
<label>Aktuelle Progression<select data-exercise-field="current_progression_id">
<option value="">Keine Progression</option>
${progressionOptions(exercise, exercise.current_progression_id)}
</select></label>
<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("");
$("tab-uebungen").innerHTML = `
<div class="bt-card">
<h2>Übung anlegen</h2>
<div class="bt-grid4">
<div class="tp-formline">
<label>Name<input id="newExerciseName" type="text"></label>
<label>Cue<input id="newExerciseCue" type="text"></label>
<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>
<button class="primary" data-add-exercise>Hinzufügen</button>
</div>
</div>
<div class="bt-card bt-card-flush">
${rows ? `
<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>`}
${rows ? `<div class="tp-library">${rows}</div>` : `<div class="bt-empty" style="margin:16px">Noch keine Übungen.</div>`}
</div>
`;
}
@@ -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");
}
+53 -6
View File
@@ -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"}],
}
],
}