UI
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user