chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 6b4ed9bc36
111 changed files with 95131 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# Trainingsplan- & Rezept-Editor 2.0
Der Editor verwaltet Trainingspläne und Rezeptsammlungen innerhalb von boehmitools. Trainingspläne sind zugleich die verbindliche Datenquelle für den `trainingstracker`.
## Planlebenszyklus
Jede Plandatei enthält eine veröffentlichte Fassung und einen bearbeitbaren Entwurf:
```json
{
"plan_id": "phase-1",
"revision": 4,
"published_revision": 2,
"config": {},
"draft": {}
}
```
- **Entwurf speichern** erhöht die Bearbeitungsrevision, verändert aber nicht den vom Tracker gelesenen Plan.
- **Plan prüfen** validiert IDs, Progressionsbezüge, Ergebnisschemata und feste Intervalle.
- **Entwurf veröffentlichen** erzeugt eine neue veröffentlichte Planrevision.
- Bereits dokumentierte Sessions speichern die Planrevision, nach der sie ausgeführt wurden.
- Vor dem Ersetzen einer Plandatei wird atomisch geschrieben und ein Backup unter `data/trainingsplan/backups/` angelegt.
- Gleichzeitige Bearbeitungen werden über eine Revisionsprüfung erkannt und nicht still überschrieben.
## Stabiler Datenvertrag
Trainingspläne verwenden:
```text
schema_version: 3
contract_version: 2
```
Phasen, Tage, Rotationen, Übungsplatzierungen, Bewegungen, Progressionen und Progressionsstufen besitzen dauerhafte IDs. Beim Verschieben im Editor bleiben diese IDs erhalten.
Progressionsstufen sind Objekte und keine parallelen Text-/Schema-Arrays mehr:
```json
{
"id": "step-squat-hold-...",
"name": "Deep Squat Hold",
"phase_id": "phase-start-...",
"movement_cluster": "knee_dominant",
"factor": 1.0,
"result_schema": {
"mode": "seconds",
"weight_mode": "optional",
"laterality": "bilateral",
"sides_mode": "same"
}
}
```
Dadurch reisen Stufenname, Eingabeformat und Analysefaktor immer gemeinsam.
## Ergebniserfassung
Eine Übung besitzt ein Standardformat. Jede Progressionsstufe kann es überschreiben.
Priorität im Tracker:
```text
manuelle Anpassung der aktuellen Session
→ Schema der gewählten Progressionsstufe
→ Standard der Übung
→ automatische Erkennung als Fallback
```
Unterstützt werden Wiederholungen, Sekunden, Minuten, keine Zahl, optionales oder verpflichtendes Gewicht sowie gemeinsame oder getrennte Seitenwerte.
## Tabata und feste Intervalle
Das Trainingsformat wird ausdrücklich gespeichert:
```json
{
"mode": "tabata",
"fixed_interval": true,
"work_seconds": 20,
"rest_seconds": 10,
"rounds": 8,
"rounds_scope": "block"
}
```
`rounds` bezeichnet Intervalle pro Block. Bei acht Intervallen und vier Übungen entstehen zwei Durchgänge pro Übung. Eine zusätzlich im Rotationstitel angegebene Satzanzahl wird damit multipliziert. Ist die Intervallzahl nicht gleichmäßig durch die Übungszahl teilbar, meldet die Validierung einen Hinweis und verlangt eine feste Wertezahl im Ergebnisschema.
## Übungsbibliothek und Analysefaktoren
Aus den Übungen und Progressionen wird eine planbezogene `exercise_catalog` erzeugt. Sie enthält stabile Bewegungs-IDs, Cluster, Varianten, Ergebnisschemata und Faktoren. Diese Angaben haben im Tracker Vorrang vor Namens- und Regex-Erkennung.
## Prüfung und Tracker-Vorschau
Der Tab **Prüfung & Vorschläge** zeigt:
- Validierungsfehler und Hinweise,
- das vom Tracker erwartete Ergebnisformat pro Übung,
- stufenabhängige Überschreibungen,
- die berechneten Durchgänge fester Intervallblöcke,
- strukturierte KI-Vorschläge aus dem Tracker mit stabilen Ziel-IDs.
Vorschläge werden niemals automatisch in den Plan geschrieben. Über **Ziel im Entwurf öffnen** springt der Editor anhand der stabilen IDs direkt zur betroffenen Übung oder Progression. Nach der manuellen Änderung kann der Vorschlag als übernommen oder abgelehnt markiert werden.
## Kompatibilität
Ältere Pläne werden beim Laden in das neue Schema überführt. Fehlende Ergebnisschemata erhalten dabei einmalig explizite, im Editor korrigierbare Migrationswerte. Die veröffentlichten Beispielpläne liegen bereits in Version 3 vor. Rezeptsammlungen bleiben vom Trainingsvertrag unberührt.
+450
View File
@@ -0,0 +1,450 @@
# -*- coding: utf-8 -*-
"""Trainingsplan- und Rezepteditor mit Entwurf/Veröffentlichung und Konfliktschutz."""
from __future__ import annotations
import io
import json
import os
import re
from copy import deepcopy
from pathlib import Path
from typing import Any
from flask import Flask, Response, jsonify, request, send_file
import plan_builder
import recipe_builder
from schema_contract import migrate_training_config, validate_training_config, stable_id
from storage import atomic_json_write, utc_now
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")).resolve()
PLANS_DIR = DATA_DIR / "plans"
PRESETS_DIR = BASE_DIR / "presets"
ACTIVE_FILE = DATA_DIR / "active.txt"
LEGACY_CONFIG = DATA_DIR / "config.json"
STATIC_DIR = BASE_DIR / "static"
BACKUPS_DIR = DATA_DIR / "backups"
TRACKER_PROPOSALS_DIR = DATA_DIR.parent / "trainingstracker" / "proposals"
TRACKER_SESSIONS_DIR = DATA_DIR.parent / "trainingstracker" / "sessions"
PLANS_DIR.mkdir(parents=True, exist_ok=True)
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(__name__, static_folder=None)
def builder_for(cfg: dict[str, Any]):
return recipe_builder if (cfg or {}).get("type") == "recipe" else plan_builder
def default_for(kind: str):
cfg = recipe_builder.default_config() if kind == "recipe" else plan_builder.default_config()
return cfg if kind == "recipe" else migrate_training_config(cfg)
def slugify(name: Any) -> str:
s = str(name or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
return re.sub(r"[^a-z0-9]+", "-", s).strip("-") or "phase"
def plan_path(pid: str) -> Path:
return PLANS_DIR / f"{pid}.json"
def unique_id(base: str) -> str:
stem, index = base, 2
while plan_path(stem).exists():
stem = f"{base}-{index}"; index += 1
return stem
def _normalize_wrapper(pid: str, raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raw = {}
name = str(raw.get("name") or pid)
published = raw.get("config") if isinstance(raw.get("config"), dict) else {}
is_recipe = published.get("type") == "recipe"
if not is_recipe:
published = migrate_training_config(published, plan_id=str(raw.get("plan_id") or pid))
draft = raw.get("draft") if isinstance(raw.get("draft"), dict) else deepcopy(published)
if not is_recipe:
draft = migrate_training_config(draft, plan_id=published.get("plan_id") or pid)
revision = max(1, int(raw.get("revision") or 1))
published_revision = max(1, int(raw.get("published_revision") or 1))
return {
"id": pid,
"plan_id": str(raw.get("plan_id") or published.get("plan_id") or stable_id("plan", pid, name)),
"name": name,
"revision": revision,
"published_revision": published_revision,
"created_at": str(raw.get("created_at") or utc_now()),
"updated_at": str(raw.get("updated_at") or utc_now()),
"published_at": str(raw.get("published_at") or raw.get("updated_at") or utc_now()),
"config": published,
"draft": draft,
}
def read_plan(pid: str) -> dict[str, Any]:
with plan_path(pid).open("r", encoding="utf-8") as handle:
return _normalize_wrapper(pid, json.load(handle))
def write_plan(pid: str, wrapper: dict[str, Any], *, backup: bool = True) -> dict[str, Any]:
normalized = _normalize_wrapper(pid, wrapper)
normalized["updated_at"] = utc_now()
atomic_json_write(plan_path(pid), {k: v for k, v in normalized.items() if k != "id"}, BACKUPS_DIR if backup else None)
return normalized
def create_wrapper(pid: str, name: str, cfg: dict[str, Any]) -> dict[str, Any]:
if cfg.get("type") != "recipe":
cfg = migrate_training_config(cfg, plan_id=pid)
now = utc_now()
return {
"id": pid, "plan_id": cfg.get("plan_id") or pid, "name": name,
"revision": 1, "published_revision": 1,
"created_at": now, "updated_at": now, "published_at": now,
"config": deepcopy(cfg), "draft": deepcopy(cfg),
}
def list_plan_ids() -> list[str]:
return sorted(path.stem for path in PLANS_DIR.glob("*.json"))
def plan_name(pid: str) -> str:
try: return read_plan(pid)["name"]
except Exception: return pid
def get_active() -> str | None:
pid = ACTIVE_FILE.read_text(encoding="utf-8").strip() if ACTIVE_FILE.exists() else None
ids = list_plan_ids()
return pid if pid in ids else (ids[0] if ids else None)
def set_active(pid: str) -> None:
ACTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
ACTIVE_FILE.write_text(pid, encoding="utf-8")
def install_presets() -> None:
if not PRESETS_DIR.is_dir(): return
for source in sorted(PRESETS_DIR.glob("*.json")):
if plan_path(source.stem).exists(): continue
try:
raw = json.loads(source.read_text(encoding="utf-8"))
cfg = raw.get("config", {}) if isinstance(raw, dict) else {}
wrapper = create_wrapper(source.stem, str(raw.get("name") or source.stem), cfg)
write_plan(source.stem, wrapper, backup=False)
except Exception: pass
def ensure_seed() -> None:
if not list_plan_ids():
try: cfg = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_for("training")
except Exception: cfg = default_for("training")
write_plan("phase-1", create_wrapper("phase-1", "Phase 1", cfg), backup=False)
install_presets()
if get_active() is None and list_plan_ids(): set_active(list_plan_ids()[0])
def _tracked_session_count(pid: str) -> int:
path = TRACKER_SESSIONS_DIR / f"{pid}.json"
if not path.exists():
return 0
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return 0
sessions = raw.get("sessions") if isinstance(raw, dict) and isinstance(raw.get("sessions"), dict) else {}
count = 0
for session in sessions.values():
if not isinstance(session, dict):
continue
if str(session.get("status") or "planned") != "planned" or any(
isinstance(item, dict) and (
item.get("done") or item.get("completion_status") in {"completed", "partial", "skipped"}
or any(str(item.get(field) or "").strip() for field in ("result", "note", "progression", "skip_reason"))
) for item in (session.get("items") or {}).values()
):
count += 1
return count
def public_wrapper(wrapper: dict[str, Any], *, draft: bool = True) -> dict[str, Any]:
return {
"id": wrapper["id"], "plan_id": wrapper["plan_id"], "name": wrapper["name"],
"revision": wrapper["revision"], "published_revision": wrapper["published_revision"],
"updated_at": wrapper["updated_at"], "published_at": wrapper["published_at"],
"has_unpublished_changes": wrapper["draft"] != wrapper["config"],
"tracked_sessions": _tracked_session_count(wrapper["id"]),
"config": deepcopy(wrapper["draft"] if draft else wrapper["config"]),
}
def _validation(config: dict[str, Any]) -> dict[str, Any]:
if config.get("type") == "recipe": return {"errors": [], "warnings": [], "config": config}
return validate_training_config(config)
def _diff_summary(old: Any, new: Any) -> dict[str, Any]:
"""Erzeugt einen stabil-ID-basierten Änderungsbericht für die Veröffentlichung."""
old_cfg = old if isinstance(old, dict) else {}
new_cfg = new if isinstance(new, dict) else {}
def collect(cfg: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]:
result: dict[str, dict[str, dict[str, Any]]] = {
"days": {}, "rotations": {}, "exercises": {}, "progression_steps": {},
}
for di, day in enumerate(cfg.get("days", []) if isinstance(cfg.get("days"), list) else []):
if not isinstance(day, dict):
continue
day_id = str(day.get("id") or f"day-index-{di}")
result["days"][day_id] = {
"label": str(day.get("focus") or f"Tag {di + 1}"),
"value": {"num": day.get("num"), "focus": day.get("focus"), "light": bool(day.get("light"))},
}
for ri, rotation in enumerate(day.get("rotations", []) if isinstance(day.get("rotations"), list) else []):
if not isinstance(rotation, dict):
continue
rotation_id = str(rotation.get("id") or f"{day_id}:rotation:{ri}")
result["rotations"][rotation_id] = {
"label": f"{day.get('focus') or 'Tag'} · {rotation.get('label') or f'Block {ri + 1}'}",
"value": {"day_id": day_id, "label": rotation.get("label")},
}
for ei, exercise in enumerate(rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []):
if not isinstance(exercise, dict):
continue
exercise_id = str(exercise.get("id") or f"{rotation_id}:exercise:{ei}")
result["exercises"][exercise_id] = {
"label": str(exercise.get("name") or f"Übung {ei + 1}"),
"value": {
"rotation_id": rotation_id,
"name": exercise.get("name"), "cue": exercise.get("cue"),
"progression_id": exercise.get("progression_id") or exercise.get("key"),
"exercise_id": exercise.get("exercise_id"),
"movement_cluster": exercise.get("movement_cluster"),
"result_schema": exercise.get("result_schema"),
},
}
stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
for key, stage in stages.items():
if not isinstance(stage, dict):
continue
for si, step in enumerate(stage.get("steps", []) if isinstance(stage.get("steps"), list) else []):
if not isinstance(step, dict):
continue
step_id = str(step.get("id") or f"{key}:step:{si}")
result["progression_steps"][step_id] = {
"label": f"{stage.get('name') or key} · {step.get('name') or f'Stufe {si + 1}'}",
"value": {
"progression_id": key, "name": step.get("name"),
"phase_id": step.get("phase_id"), "factor": step.get("factor"),
"movement_cluster": step.get("movement_cluster"),
"result_schema": step.get("result_schema"),
},
}
return result
before, after = collect(old_cfg), collect(new_cfg)
summary: dict[str, Any] = {}
changes: list[dict[str, Any]] = []
for section in ("days", "rotations", "exercises", "progression_steps"):
left, right = before[section], after[section]
added = sorted(set(right) - set(left))
removed = sorted(set(left) - set(right))
changed = sorted(key for key in set(left) & set(right) if left[key]["value"] != right[key]["value"])
summary[section] = {
"before": len(left), "after": len(right), "delta": len(right) - len(left),
"added": len(added), "removed": len(removed), "changed": len(changed),
}
for key in added:
changes.append({"section": section, "kind": "added", "id": key, "label": right[key]["label"]})
for key in removed:
changes.append({"section": section, "kind": "removed", "id": key, "label": left[key]["label"]})
for key in changed:
changes.append({"section": section, "kind": "changed", "id": key, "label": right[key]["label"]})
summary["changes"] = changes[:50]
summary["total_changes"] = len(changes)
summary["training_format_changed"] = old_cfg.get("training_format") != new_cfg.get("training_format")
summary["plan_meta_changed"] = old_cfg.get("meta") != new_cfg.get("meta")
return summary
def _proposal_file(pid: str) -> Path:
return TRACKER_PROPOSALS_DIR / f"{pid}.json"
ensure_seed()
@app.route("/")
def index():
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html")
@app.route("/api/plans", methods=["GET"])
def api_plans():
active = get_active(); plans = []
for pid in list_plan_ids():
try:
p = read_plan(pid)
plans.append({"id": pid, "name": p["name"], "active": pid == active, "revision": p["revision"], "published_revision": p["published_revision"], "has_unpublished_changes": p["draft"] != p["config"]})
except Exception: continue
return jsonify({"plans": plans, "active": active})
@app.route("/api/plans", methods=["POST"])
def api_plan_create():
body = request.get_json(force=True) or {}; name = str(body.get("name") or "Neue Phase").strip()
src = body.get("from"); kind = body.get("kind") or body.get("type") or "training"
cfg = deepcopy(read_plan(src)["draft"]) if src and plan_path(str(src)).exists() else default_for(str(kind))
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
return jsonify(public_wrapper(wrapper))
@app.route("/api/plans/<pid>", methods=["GET"])
def api_plan_get(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
return jsonify(public_wrapper(read_plan(pid)))
@app.route("/api/plans/<pid>/select", methods=["POST"])
def api_plan_select(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
set_active(pid); return jsonify(public_wrapper(read_plan(pid)))
@app.route("/api/plans/<pid>", methods=["POST"])
def api_plan_save(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
body = request.get_json(force=True) or {}
config = body.get("config") if isinstance(body.get("config"), dict) else body
expected = body.get("expected_revision") if isinstance(body, dict) else None
wrapper = read_plan(pid)
if expected is not None and int(expected) != wrapper["revision"]:
return jsonify({"error": "Der Plan wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
validation = _validation(config)
wrapper["draft"] = validation["config"]
wrapper["revision"] += 1
saved = write_plan(pid, wrapper)
return jsonify({"ok": True, **public_wrapper(saved), "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}})
@app.route("/api/plans/<pid>/validate", methods=["POST"])
def api_plan_validate(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
body = request.get_json(force=True) or {}
cfg = body.get("config") if isinstance(body.get("config"), dict) else body
result = _validation(cfg)
wrapper = read_plan(pid)
diff = _diff_summary(wrapper["config"], result["config"])
diff["tracked_sessions"] = _tracked_session_count(pid)
return jsonify({"errors": result["errors"], "warnings": result["warnings"], "config": result["config"], "diff": diff})
@app.route("/api/plans/<pid>/publish", methods=["POST"])
def api_plan_publish(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
body = request.get_json(silent=True) or {}; wrapper = read_plan(pid)
expected = body.get("expected_revision")
if expected is not None and int(expected) != wrapper["revision"]:
return jsonify({"error": "Der Entwurf wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
validation = _validation(wrapper["draft"])
if validation["errors"]:
return jsonify({"error": "Der Plan enthält Validierungsfehler.", "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}), 400
diff = _diff_summary(wrapper["config"], validation["config"])
diff["tracked_sessions"] = _tracked_session_count(pid)
wrapper["config"] = deepcopy(validation["config"])
wrapper["draft"] = deepcopy(validation["config"])
wrapper["published_revision"] += 1
wrapper["revision"] += 1
wrapper["published_at"] = utc_now()
saved = write_plan(pid, wrapper)
return jsonify({"ok": True, **public_wrapper(saved), "diff": diff, "validation": {"errors": [], "warnings": validation["warnings"]}})
@app.route("/api/plans/<pid>/rename", methods=["POST"])
def api_plan_rename(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
name = str((request.get_json(force=True) or {}).get("name") or "").strip()
if not name: return jsonify({"error": "name required"}), 400
wrapper = read_plan(pid); wrapper["name"] = name; wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
return jsonify({"id": pid, "name": name, "revision": saved["revision"]})
@app.route("/api/plans/<pid>", methods=["DELETE"])
def api_plan_delete(pid: str):
ids = list_plan_ids()
if pid not in ids: return jsonify({"error": "not found"}), 404
if len(ids) <= 1: return jsonify({"error": "letzte Phase kann nicht gelöscht werden"}), 400
atomic_json_write(BACKUPS_DIR / f"deleted-{pid}-{utc_now().replace(':','')}.json", {k:v for k,v in read_plan(pid).items() if k != 'id'})
plan_path(pid).unlink()
if get_active() is None: set_active(list_plan_ids()[0])
return jsonify({"ok": True, "active": get_active()})
@app.route("/api/plans/<pid>/export")
def api_plan_export(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
return send_file(io.BytesIO(plan_path(pid).read_bytes()), mimetype="application/json", as_attachment=True, download_name=f"{pid}.json")
@app.route("/api/plans/import", methods=["POST"])
def api_plan_import():
raw = request.files["file"].read() if "file" in request.files else request.data
if not raw: return jsonify({"error": "keine Datei"}), 400
try: obj = json.loads(raw.decode("utf-8"))
except Exception as exc: return jsonify({"error": f"ungültiges JSON: {exc}"}), 400
if isinstance(obj, dict) and "config" in obj:
cfg = obj.get("config", {}); name = str(obj.get("name") or "Importierte Phase")
else:
cfg = obj if isinstance(obj, dict) else {}; name = str((cfg.get("meta") or {}).get("title") or "Importierte Phase")
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
return jsonify(public_wrapper(wrapper))
@app.route("/api/plans/<pid>/proposals")
def api_plan_proposals(pid: str):
raw = {}
path = _proposal_file(pid)
if path.exists():
try: raw = json.loads(path.read_text(encoding="utf-8"))
except Exception: raw = {}
return jsonify(raw if isinstance(raw, dict) else {})
@app.route("/api/plans/<pid>/proposals/<proposal_id>", methods=["POST"])
def api_plan_proposal_status(pid: str, proposal_id: str):
path = _proposal_file(pid); body = request.get_json(silent=True) or {}; status = str(body.get("status") or "reviewed")
if status not in {"open", "accepted", "rejected", "reviewed"}:
return jsonify({"error": "ungültiger Vorschlagsstatus"}), 400
raw = {}
if path.exists():
try: raw = json.loads(path.read_text(encoding="utf-8"))
except Exception: raw = {}
proposals = raw.get("proposals") if isinstance(raw.get("proposals"), list) else []
for proposal in proposals:
if str(proposal.get("id")) == proposal_id:
proposal["status"] = status; proposal["reviewed_at"] = utc_now()
raw["proposals"] = proposals; raw["updated_at"] = utc_now(); atomic_json_write(path, raw)
return jsonify({"ok": True, "proposals": proposals})
# Backward-compatible active plan endpoints
@app.route("/api/config", methods=["GET"])
def api_get_config():
pid = get_active(); return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_for("training")})
@app.route("/api/config", methods=["POST"])
def api_save_config():
pid = get_active()
if not pid: return jsonify({"error": "kein aktiver Plan"}), 400
return api_plan_save(pid)
@app.route("/api/default")
def api_default(): return jsonify(default_for(request.args.get("kind", "training")))
@app.route("/api/reset", methods=["POST"])
def api_reset():
pid = get_active(); wrapper = read_plan(pid); kind = "recipe" if wrapper["draft"].get("type") == "recipe" else "training"
wrapper["draft"] = default_for(kind); wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
return jsonify(public_wrapper(saved))
@app.route("/api/pdf", methods=["POST"])
def api_pdf():
body = request.get_json(force=True) or {}; cfg = body.get("config") if isinstance(body.get("config"), dict) else body
if cfg.get("type") != "recipe": cfg = migrate_training_config(cfg)
try: pdf_bytes = builder_for(cfg).build_pdf(cfg)
except Exception as exc: return jsonify({"error": str(exc)}), 400
name = cfg.get("meta", {}).get("output_name", "Trainingsplan.pdf")
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=name)
if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False)
+38
View File
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für den Trainingsplan- und Rezept-Editor.
Das Original ist eine Flask-Anwendung (app.py) und bleibt vollständig
unverändert. Hier wird sie lediglich
* auf ihren eigenen Datenordner data/trainingsplan/ gezeigt und
* als WSGI-App in die ASGI-Suite eingehängt.
Das Tool läuft dadurch weiterhin auch allein:
cd plugins/trainingsplan && python app.py
"""
from __future__ import annotations
import os
from a2wsgi import WSGIMiddleware
from core.loader import load_module
def create_app(ctx):
# app.py liest DATA_DIR beim Import und legt dort die Phasen an.
previous = os.environ.get("DATA_DIR")
os.environ["DATA_DIR"] = str(ctx.data_dir)
try:
module = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app")
finally:
if previous is None:
os.environ.pop("DATA_DIR", None)
else:
os.environ["DATA_DIR"] = previous
# Flask erzeugt seine URLs anhand von SCRIPT_NAME; a2wsgi übernimmt dafür
# den root_path des Mounts. Damit funktionieren alle Routen unter
# /plugins/trainingsplan genauso wie vorher unter /.
return WSGIMiddleware(module.app)
+669
View File
@@ -0,0 +1,669 @@
# -*- coding: utf-8 -*-
"""
plan_builder.py
Rendert den 8-Wochen-Calisthenics-Plan als A4-PDF.
Layout 1:1 wie im Original-Skript, aber alle Inhalte kommen aus einem Config-Dict.
build_pdf(config) -> bytes
default_config() -> dict (Standard-Inhalte)
"""
import copy
from schema_contract import migrate_training_config
from io import BytesIO
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, KeepTogether)
from reportlab.lib.enums import TA_RIGHT
# =========================================================================
# STANDARD-KONFIGURATION (entspricht exakt dem Original-Skript)
# =========================================================================
DEFAULTS = {
"meta": {
"title": "Calisthenics-Basis 8-Wochen Komplettplan",
"subtitle": "Blutiger Anfänger · 1,90 m · 95 kg · Rucksack + Couch + Kettlebell · tägliche ~30-Min Tabata-Session",
"output_name": "Calisthenics-8-Wochen-Komplett.pdf",
"footer_left": "Calisthenics 8-Wochen Komplettplan \u00b7 mit Kettlebell",
"doc_title": "Calisthenics 8-Wochen Komplettplan (mit Kettlebell)",
"weeks": 8,
},
"colors": {
"INK": "#1A1A1A", "ACCENT": "#C2410C", "ACC2": "#0F766E", "KBCOL": "#6D28D9",
"LINK": "#1565C0", "LIGHT": "#F4F1EC", "GRID": "#CFC9BF", "MUTE": "#6B6256",
"DARK": "#23201C", "WARMBG": "#EAF1F0", "GOALBG": "#FBF1E9", "KBBG": "#F1ECFB",
},
"front": {
"session_how_head": "So funktioniert eine Session",
"session_how_body": (
"<b>Tabata-Block = 4 Min:</b> 10 Sek einzählen, dann 8× (20 Sek Arbeit / 10 Sek Pause). "
"Pro Block rotieren <b>4 Übungen</b>, je <b>2 Durchgänge</b> = 8 Intervalle = 1 Block = <b>1 Satz</b>. "
"Eine Rotation läuft <b>3 Sätze</b> (in Woche 1 ruhig mit 2 starten). Warm-up steht über, Cool-down unter den Übungen."
),
"flow_rows": [
["Teil", "Inhalt", "Zeit"],
["Warm-up", "Gelenk-Prep (dynamisch)", "~3 Min"],
["Rotation A", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Rotation B", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Cool-down", "Ausklingen + Dehnen", "~6 Min"],
["", "Gesamt", "~35 Min"],
],
"timer_note": "<b>Timer:</b> „Tabata / Interval Timer“ auf 10s Vorlauf, 8 Runden, 20/10.",
"kb_head": "Warum Kettlebell und warum es zu deinem Ziel passt",
"kb_box": (
"Du trainierst für <b>Calisthenics</b>, nicht für Maximalkraft. Genau da liegen die <b>belegten</b> Stärken der Kettlebell "
"ohne dass dich ihr dokumentierter Schwachpunkt (Maximalkraft/Hypertrophie wegen fixer Gewichtssprünge) trifft:<br/>"
"•  <b>Griffkraft</b> (Studie BELL: +67 kg) dein größter Mehrwert auf dem Weg zu Klimmzug &amp; Dip.<br/>"
"•  <b>Power / ballistische Hüftstreckung</b> (Swing) explosive Basis für spätere Skills.<br/>"
"•  <b>Kondition &amp; Körperfett</b> wirksam belegt; du startest mit wenig Ausdauer.<br/>"
"•  <b>Ganzkörper, ein Gerät, zeiteffizient</b> passt ideal ins 30-Min-Tabata-Format; niedriges Einstiegs-Verletzungsrisiko bei sauberer Technik.<br/>"
"<b>Ehrlich:</b> Reines KB ersetzt <u>kein</u> Maximalkraft-/Muskelaufbau-Programm. Für dein Ziel (Basis-Fundament) liegt das ohnehin unter der relevanten Schwelle."
),
"goals_head": "Realistische Ziele nach 8 Wochen",
"goals_box": (
"Du startest bei 23 Liegestützen und 0 Dips. Realistisch und ehrlich nach 8 Wochen sauberer Arbeit:<br/>"
"•  <b>Liegestütze:</b> von 23 auf erste saubere <b>volle</b> Reps (Rest in Knie-/Negativ-Form).<br/>"
"•  <b>Bank-Dips:</b> saubere Reps mit Füßen etwas weiter weg.   <b>Pike Push-up:</b> am Boden mit kleinem ROM.<br/>"
"•  <b>Plank ~4560 s</b>, <b>Wall Sit ~60 s</b>, viele saubere <b>Goblet-Kniebeugen</b>, sauberer <b>Swing</b> + spürbar bessere <b>Griffkraft</b>.<br/>"
"•  <b>Noch NICHT dran:</b> Diamond Push-ups, L-Sit, Handstand, schwerer Turkish Get-Up, Snatch Stoff für Woche 12+."
),
"goals_note": (
"<b>Wichtig:</b> Die Wochen-Stufen sind ein <b>Rahmen, kein Muss</b>. Bleib auf einer Stufe, solange sie fordert. "
"<b>Aufstiegs-Regel:</b> erst hoch (oder schwerere Glocke), wenn du eine Übung 2 Sessions in Folge in allen Sätzen sauber über die vollen 20s schaffst. "
"<font color='#6D28D9'><b>KB = Kettlebell bevorzugt; Rucksack bleibt als Alternative.</b></font>"
),
"lib_head": "Übungs-Bibliothek (mit Anleitungen)",
"lib_intro": (
"Für Übungen, die du noch nicht kennst, ist ein Link zu einer bebilderten Anleitung dabei. "
"<font color='#6D28D9'>Lila Gruppe = Kettlebell-Übungen.</font>"
),
"baseline_head": "Standortbestimmung &amp; Tracking",
"baseline_intro": "Mach die Tests <b>vor Tag 1</b> und in <b>Woche 8 identisch</b> nochmal dein Vorher/Nachher.",
"baseline_rows": [
["Test", "Tag 0", "Woche 8"],
["Liegestütze (max, saubere Reps)", "", ""],
["Bank-Dips an der Couch (max)", "", ""],
["Kniebeugen in 60 Sekunden", "", ""],
["Ausfallschritte (max gesamt)", "", ""],
["Plank (max Sekunden)", "", ""],
["Wall Sit (max Sekunden)", "", ""],
["KB Farmer-Hold (max Sekunden, Griff)", "", ""],
["Körpergewicht (kg)", "95", ""],
],
"reminder_head": "Kurz-Reminder",
"reminders": [
"<b>Form vor Reps.</b> Wähle die Stufe / Glocke, bei der du die 20s kontrolliert durcharbeitest mit 12 Reps Reserve.",
"<b>Swing-Technik first.</b> Der Swing ist ein Hüft-Schnapp, KEIN Kniebeuge und KEIN Armheben. Erst Dead-Stop-Swings, bis die Hüftstreckung sitzt.",
"<b>Einseitige Übungen (Tag 2):</b> Durchgang 1 = links, Durchgang 2 = rechts so ist jede Seite automatisch ausbalanciert.",
"<b>Täglich ist viel.</b> Tag 5 leicht, Tag 7 frei. Bei starkem Muskelkater einen Extra-Ruhetag gehört zum Aufbau.",
"<b>Schlaf &amp; Eiweiß.</b> 79 h Schlaf, ~1,6 g Eiweiß pro kg. Stechender Schmerz = stoppen.",
],
"sheets_note": (
"Ab der nächsten Seite: 1 Druckblatt pro Session (Woche 18). <font color='#6D28D9'>Tag 6 = reine Kettlebell-Session.</font> "
"Warm-up steht über, Cool-down + Dehnen unter den Übungen. Bei Vorerkrankungen/Verletzungen vorher ärztlich abklären."
),
},
"library": [
{"group": "DRÜCKEN (Brust / Schultern / Trizeps)", "color": "ACC2", "items": [
{"name": "Liegestütze", "desc": "Stütz, Körper gerade, Brust kontrolliert senken &amp; drücken. Stufen: Inkline (Couch), Knie, Negativ, Voll.", "url": "https://blog.calimove.com/2023/01/25/push-up-variations-beginner-expert/"},
{"name": "Bank-Dips (Couch)", "desc": "Rücken zur Couch, Hände auf der Kante, Po davor absenken (Ellbogen nach hinten), hochdrücken. Knie beugen = leichter.", "url": "https://www.healthline.com/health/fitness-exercise/bench-dips"},
{"name": "Pike Push-up / Hold", "desc": "Umgekehrtes V (Hintern hoch), Kopf Richtung Boden senken, hochdrücken. Schulter-Fokus. Hände erhöht = leichter.", "url": "https://www.hingehealth.com/resources/articles/pike-push-up/"},
{"name": "Seitheben (KB/Rucksack)", "desc": "Arme seitlich bis Schulterhöhe heben, Ellbogen leicht gebeugt, kontrolliert senken. Seitlicher Deltamuskel.", "url": ""},
]},
{"group": "ZIEHEN (Rücken / Bizeps KB oder Rucksack + Boden)", "color": "ACC2", "items": [
{"name": "Einarm-Rudern ENG", "desc": "Hüfte beugen, Rücken GERADE, abgestützt. Ellbogen an den Rippen, Zug zur Hüfte Lat.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Einarm-Rudern BREIT", "desc": "Gleiche Position, aber Ellbogen ~45° geöffnet, Zug zum Brustkorb oberer Rücken / hinterer Delta.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Bizeps-Curls (KB/Rucksack)", "desc": "Last unten greifen, Oberarme fix am Körper, kontrolliert zu den Schultern curlen.", "url": ""},
{"name": "Superman / Reverse Snow Angels / Y-T-W", "desc": "Bäuchlings Arme+Beine heben (Superman). Snow Angels = Arme am Boden auf/ab. Rücken ohne Stange.", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
]},
{"group": "BEINE", "color": "ACC2", "items": [
{"name": "Kniebeuge / Goblet Squat (KB/Rucksack)", "desc": "Hüfte zurück, Oberschenkel parallel, Fersen am Boden. Goblet = Glocke/Rucksack vor der Brust umarmen.", "url": ""},
{"name": "Ausfallschritte", "desc": "Großer Schritt (rückwärts = knieschonend), hinteres Knie Richtung Boden. Anfangs ggf. festhalten.", "url": ""},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "desc": "Knie leicht gebeugt, Hüfte nach hinten, Rücken gerade, Last entlang der Beine senken bis Dehnung hinten (Hamstrings).", "url": ""},
{"name": "Glute Bridge / Wadenheben / Wall Sit", "desc": "Bridge: Hüfte hochdrücken (1-beinig steigern). Waden: Zehenspitzen hoch/runter. Wall Sit: Rücken an die Wand, halten.", "url": ""},
]},
{"group": "KETTLEBELL (Power / Griff / Ganzkörper)", "color": "KBCOL", "items": [
{"name": "Swing (zweiarmig, Hardstyle)", "desc": "<b>Wichtigste KB-Übung.</b> Hüfthinge, Glocke aus der Hüfte explosiv auf Brusthöhe schnappen (kein Armheben), oben Po+Bauch fest. Dead-Stop zum Lernen.", "url": "https://www.onepeloton.com/blog/kettlebell-swing/"},
{"name": "Goblet Squat (KB)", "desc": "Glocke an den Hörnern vor der Brust umarmen, tief und aufrecht hocken. Beste Einsteiger-KB-Kniebeuge.", "url": "https://www.onepeloton.com/blog/goblet-squat/"},
{"name": "KB-Deadlift (beidbeinig)", "desc": "Glocke zwischen den Füßen, Hüfte hinge, Rücken gerade, vom Boden heben und Hüfte durchstrecken. Stabile Swing-Vorbereitung.", "url": "https://www.onepeloton.com/blog/kettlebell-deadlift/"},
{"name": "KB Floor Press", "desc": "Rücklings am Boden, Glocke vom Boden weg nach oben drücken. Schulterschonend, gut für Anfänger.", "url": "https://www.masterclass.com/articles/kettlebell-floor-press-guide"},
{"name": "Halo", "desc": "Glocke (oder Rucksack) vor der Brust, langsam eng um den Kopf kreisen. Schulter-Mobility + Core.", "url": "https://www.tomsguide.com/how-to/kettlebell-halo-exercise-how-to-do-it-and-the-benefits-for-your-core-and-shoulder-muscles"},
{"name": "Bottoms-up KB-Hold / Carry", "desc": "Glocke am Griff, Boden nach oben, Unterarm senkrecht, still halten. Schwierigkeit kommt aus der Balance fordert <b>Griff + Handgelenk</b> auch mit leichter Glocke.", "url": "https://www.onepeloton.com/blog/farmers-carry/"},
{"name": "Around-the-World + Uppercut", "desc": "Glocke kreist um den Körper (Around-the-World); danach diagonal nach oben zur gegenüberliegenden Schulter/Brust führen, die andere Hand fängt ab (wie ein Uppercut von unten). Rotatorischer Core + Koordination. Erst nur das Umkreisen lernen, Uppercut später.", "url": ""},
]},
{"group": "CORE", "color": "ACC2", "items": [
{"name": "Plank / Side Plank", "desc": "Unterarmstütz, Körper gerade. Anfangs auf den Knien. Side Plank seitlich auf einem Unterarm.", "url": ""},
{"name": "Hollow Hold", "desc": "Auf dem Rücken, unterer Rücken am Boden, Arme+Beine leicht angehoben. Anfänger: angewinkelt (Tuck).", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
{"name": "Dead Bug", "desc": "Auf dem Rücken, Arme+Knie 90°, gegengleich Arm+Bein langsam ausstrecken, unterer Rücken bleibt am Boden.", "url": "https://dieringe.com/exercises/dead-bug"},
{"name": "Bird Dog", "desc": "Vierfüßler, gegengleich Arm+Bein ausstrecken, Rumpf stabil, kurz halten.", "url": "https://www.hingehealth.com/resources/articles/bird-dog/"},
{"name": "Mountain Climbers / Bicycle / Russian Twist / Beinheben", "desc": "Climbers: Knie zügig zur Brust. Bicycle: Ellbogen zum Gegenknie. Twist: sitzend drehen (KB). Beinheben: Knie gebeugt = leichter.", "url": ""},
]},
{"group": "WARM-UP &amp; MOBILITY", "color": "ACC2", "items": [
{"name": "Inchworm", "desc": "Aus dem Stand vorbeugen, Hände nach vorne in den Stütz wandern, dann zurück.", "url": "https://www.onepeloton.com/blog/inchworm-exercise"},
{"name": "Jacks / High Knees / Kreisen / Cat-Cow", "desc": "Klassische dynamische Aufwärm-Bewegungen locker, zum Warmwerden.", "url": ""},
]},
],
"phases": {
"names": ["Phase 1 · Fundament", "Phase 2 · Aufbau", "Phase 3 · Steigerung", "Phase 4 · Festigen"],
"params": [
"Fokus: Technik (v.a. Swing als Dead-Stop). 23 Reps Reserve. Pausen bis ~60s. Leichte Glocke. Woche 1 mit 2 Sätzen pro Rotation.",
"Sauber bleiben, letzter Satz fordernd. Pausen ~4560s. Etwas schwerere Glocke / mehr Reps. 23 Sätze.",
"Fordernd, aber 1 Rep Reserve. Pausen ~45s. Schwerere Glocke / einarmig wo angegeben. 3 Sätze.",
"Fordernd &amp; sauber (NICHT bis zum Muskelversagen). Pausen ~45s. Ende Woche 8 leichter (Deload). 3 Sätze.",
],
"param_light": "Bewusst locker aktive Erholung. Saubere, leichte Wiederholungen, keine Belastung bis zum Limit.",
},
"stages": {
'ls': ['Inkline (hoch)', 'Inkline tiefer / Knie', 'Knie / Negativ', 'Negativ + erste Volle'],
'dips': ['Knie stark gebeugt, klein', 'Knie gebeugt, voll', 'Füße etwas weiter', 'Füße weiter weg'],
'pike': ['Pike Hold', 'Hold + kleine Senkung', 'Push-up Hände erhöht', 'Push-up Boden, klein'],
'lseng': ['Inkline (hoch)', 'Inkline mittel', 'Inkline tief', 'Knie-Liegestütze'],
'load': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'squat': ['Körpergewicht', 'Körpergewicht, mehr Reps', 'Goblet leicht', 'Goblet mittel'],
'tsquat': ['3s runter', '3s + Pause unten', 'Goblet 3s', 'Goblet 3s + Pause'],
'lunge': ['Körpergewicht (ggf. festhalten)', 'Körpergewicht', 'mehr Reps', '+ Last leicht'],
'bridge': ['beidbeinig', 'beidbeinig, 2s halten', 'einbeinig', 'einbeinig, 2s halten'],
'wall': ['1525 s', '2540 s', '4055 s', '5570 s'],
'calf': ['Körpergewicht', 'langsam', 'langsam, mehr Reps', '1-beinig'],
'sup': ['Superman Hold', 'Superman Hold länger', 'Reverse Snow Angels', 'Y-T-W Raises'],
'plank': ['auf Knien 1520 s', 'voll 2030 s', 'voll + Schulter-Taps', 'voll, länger'],
'mtn': ['moderat', 'moderat', 'zügig', 'zügig'],
'db': ['Arme ODER Beine einzeln', 'gegengleich', 'gegengleich + Pause', 'Beine weiter gestreckt'],
'lr': ['Knie gebeugt', 'Knie gebeugt, langsam', 'halb gestreckt', 'gestreckt'],
'holl': ['Tuck (Knie an)', 'Tuck, länger', 'halb gestreckt', 'halb/voll'],
'sp': ['auf Knien', 'auf Knien, länger', 'voll', 'voll, länger'],
'bic': ['langsam', 'moderat', 'moderat', 'zügig'],
'tw': ['ohne Gewicht', 'ohne Gewicht', 'KB leicht', 'KB'],
'swing': ['Dead-Stop, leicht (Technik)', 'Dead-Stop, mehr Reps', 'durchgehend, mittel', 'durchgehend, schwerer'],
'gobletkb': ['leicht, Tiefe lernen', 'leicht, mehr Reps', 'mittel', 'mittel, 3s runter'],
'press': ['leicht (Floor Press)', 'Floor Press mehr', 'Overhead leicht', 'Overhead mittel'],
'halo': ['leicht, langsam', 'leicht', 'mittel', 'mittel, langsam'],
'carry': ['im Stand 1015s', '1520s, ruhig', 'bis Schulterhöhe 15s', 'länger / ein paar Schritte'],
'kbdl': ['leicht, Technik', 'leicht, mehr Reps', 'mittel', 'schwerer, kontrolliert'],
'rowe': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'row1': ['leicht', 'etwas mehr', 'einarmig leicht', 'einarmig mittel'],
'row2': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, Pause oben'],
'lraise': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, langsam'],
'atw': ['nur Around-the-World, leicht', 'Around-the-World, mehr Reps', '+ Uppercut, leicht', '+ Uppercut, flüssig'],
},
"prepost": {
"1": {"warmup": "Handflächen-Wippen · Armkreisen/Wand-Slides · Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (Brust/Schulter, je 3040s) · Trizeps (je 30s) · Seitheben/Lat-Seitdehnung (je 30s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Wade an der Wand (je 30s)"},
"2": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Cat-Cow · Hüftkreisen + Beinschwünge",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Lat-/Seitdehnung (je 30s) · Türrahmen (je 3040s) · BWS über die Couch (3040s) · <b>Cobra/Sphinx</b> (Bauch, 30s) · Unterarm (je 20s) · Knieender Hüftbeuger (je 30s)"},
"3": {"warmup": "Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "<b>Hamstring-Dehnung</b> (Couch, je 3040s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · <b>Cobra + liegende Wirbelsäulen-Rotation</b> (je 30s) · Wade an der Wand (je 30s)"},
"4": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Armkreisen/Wand-Slides · Cat-Cow",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 3040s) · Trizeps (je 30s) · Lat-/Seitdehnung (je 30s) · BWS über die Couch (3040s) · Unterarm (je 20s)"},
"5": {"warmup": "Cat-Cow · Hüftkreisen + Beinschwünge · Armkreisen/Wand-Slides · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 30s) · Knieender Hüftbeuger (je 30s) · BWS über die Couch (3040s) · Figure-4/Taube (je 30s) · Wade an der Wand (je 30s)"},
"6": {"warmup": "Halo (leicht) · Hüftkreisen + Beinschwünge · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Hamstring-Dehnung (Couch, je 3040s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · Unterarm (je 20s) · BWS über die Couch (3040s) · Lat-/Seitdehnung (je 30s)"},
},
"days": [
{"num": 1, "focus": "Drücken + Beine",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken, hochdrücken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken (Schultern)"},
{"name": "Seitheben (KB/Rucksack)", "key": "lraise", "cue": "Arme seitlich bis Schulterhöhe, kontrolliert"}],
"rotB": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "Ausfallschritt rückw.", "key": "lunge", "cue": "großer Schritt, Knie Richtung Boden"},
{"name": "Glute Bridge", "key": "bridge", "cue": "Hüfte hochdrücken, Po fest"},
{"name": "Wadenheben", "key": "calf", "cue": "auf Zehenspitzen hoch/runter"}]},
{"num": 2, "focus": "Ziehen + Core",
"rotA": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Superman / Y-T-W", "key": "sup", "cue": "bäuchlings Arme+Beine heben"}],
"rotB": [{"name": "Plank", "key": "plank", "cue": "gerade Linie halten"},
{"name": "Mountain Climbers", "key": "mtn", "cue": "Knie zügig zur Brust"},
{"name": "Dead Bug", "key": "db", "cue": "gegengleich, Rücken am Boden"},
{"name": "Beinheben", "key": "lr", "cue": "Beine heben/senken"}]},
{"num": 3, "focus": "Beine + Core",
"rotA": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "key": "load", "cue": "Hüfte zurück, Rücken gerade, Hamstrings"},
{"name": "Glute Bridge 1-beinig", "key": "bridge", "cue": "ein Bein gestreckt"},
{"name": "Wall Sit", "key": "wall", "cue": "Rücken an die Wand, halten"}],
"rotB": [{"name": "Hollow Hold", "key": "holl", "cue": "unterer Rücken am Boden"},
{"name": "Side Plank (Wechsel)", "key": "sp", "cue": "seitlich auf einem Unterarm"},
{"name": "Bicycle Crunch", "key": "bic", "cue": "Ellbogen zum Gegenknie"},
{"name": "Russian Twist (KB)", "key": "tw", "cue": "sitzend Rumpf drehen"}]},
{"num": 4, "focus": "Oberkörper: Drücken + Ziehen",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken"},
{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"}],
"rotB": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Y-T-W / Superman", "key": "sup", "cue": "bäuchlings, Rücken"}]},
{"num": 5, "focus": "Ganzkörper LEICHT + Mobility",
"rotA": [{"name": "Kniebeuge (leicht)", "key": "", "cue": "locker, sauber"},
{"name": "Liegestütze (1 Stufe leichter)", "key": "", "cue": "leichtere Variante"},
{"name": "Rudern (leicht)", "key": "", "cue": "wenig Gewicht"},
{"name": "Mountain Climbers (locker)", "key": "", "cue": "ruhiges Tempo"}],
"rotB": [{"name": "Inchworm", "key": "", "cue": "in den Stütz wandern, zurück"},
{"name": "Cat-Cow", "key": "", "cue": "Rücken ründen/strecken"},
{"name": "Bird Dog", "key": "", "cue": "gegengleich Arm+Bein, kurz halten"},
{"name": "KB-Halo (leicht)", "key": "", "cue": "langsam um den Kopf kreisen"}]},
{"num": 6, "focus": "KETTLEBELL Ganzkörper",
"rotA": [{"name": "KB Swing (zweiarmig)", "key": "swing", "cue": "Hüft-Schnapp, kein Armheben"},
{"name": "Goblet Squat (KB)", "key": "gobletkb", "cue": "Glocke vor der Brust, tief"},
{"name": "KB-Deadlift (beidbeinig)", "key": "kbdl", "cue": "Glocke zwischen den Füßen, Hüfte hinge"},
{"name": "Halo", "key": "halo", "cue": "eng um den Kopf kreisen"}],
"rotB": [{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"},
{"name": "Einarmiges Rudern (KB)", "key": "row1", "cue": "Rücken gerade, zum Bauch"},
{"name": "Bottoms-up KB-Hold (Griff)", "key": "carry", "cue": "Glocke Boden nach oben, Unterarm senkrecht, still halten"},
{"name": "Around-the-World + Uppercut (KB)", "key": "atw", "cue": "Glocke um den Körper, dann diagonal zur Gegen-Schulter, andere Hand fängt"}]},
],
}
KB_NOTE = ("<b>Reine Kettlebell-Session.</b> Swing = Hüft-Schnapp, kein Armheben. "
"Glocke so wählen, dass alle Reps sauber sind.")
def _lighten(hexstr, w=0.90):
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#0F766E")
return colors.Color(c.red * (1 - w) + w, c.green * (1 - w) + w, c.blue * (1 - w) + w)
def migrate_config(cfg):
"""Führt alte Trainings-Configs in das neue, flexible Format über (idempotent).
Neu: phases.items[{name,params,weeks}], stages{key:{name,steps[]}},
days[{...,rotations:[{label,exercises[]}]}] statt rotA/rotB,
Tag-Farbe/Notiz/Badge statt fest verdrahtetem KB-Tag."""
cfg = copy.deepcopy(cfg or {})
meta = cfg.setdefault("meta", {})
days = cfg.get("days", []) or []
# phases: {names,params} -> {items:[{name,params,weeks}], param_light}
ph = cfg.get("phases", {}) or {}
if "items" not in ph:
names = ph.get("names", []) or []
params = ph.get("params", []) or []
n = max(len(names), len(params))
items = [{"name": names[i] if i < len(names) else ("Phase %d" % (i + 1)),
"params": params[i] if i < len(params) else "",
"weeks": 2} for i in range(n)]
cfg["phases"] = {"items": items, "param_light": ph.get("param_light", "")}
# Übungsnamen je Key sammeln (für Stufen-Namen)
name_by_key = {}
for d in days:
groups = [d.get("rotA", []) or [], d.get("rotB", []) or []]
for rot in d.get("rotations", []) or []:
groups.append(rot.get("exercises", []) or [])
for grp in groups:
for it in grp:
k = it.get("key")
if k and it.get("name") and k not in name_by_key:
name_by_key[k] = it["name"]
# stages: [..] -> {name, steps:[..], result_schemas:[..]}
# ``result_schemas`` liegt parallel zu ``steps``. Ein Eintrag kann ``None``
# sein und bedeutet dann: Übungsstandard bzw. Tracker-Fallback verwenden.
newstages = {}
for key, val in (cfg.get("stages", {}) or {}).items():
if isinstance(val, dict) and "steps" in val:
stage = copy.deepcopy(val)
steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
schemas = list(schemas[:len(steps)])
while len(schemas) < len(steps):
schemas.append(None)
stage["steps"] = steps
stage["result_schemas"] = schemas
newstages[key] = stage
else:
steps = val if isinstance(val, list) else []
nm = name_by_key.get(key) or next((s for s in steps if s), "") or key
newstages[key] = {"name": nm, "steps": steps, "result_schemas": [None] * len(steps)}
cfg["stages"] = newstages
# Explizites Trainingsformat. Alte Pläne bleiben auf ``auto`` und werden
# vom Tracker weiterhin aus den Plantexten erkannt. Neue Pläne können
# Tabata/Intervalle damit ohne Text-Heuristik eindeutig beschreiben.
fmt = cfg.get("training_format")
if not isinstance(fmt, dict):
fmt = {}
mode = str(fmt.get("mode") or "auto")
if mode not in {"auto", "sets_reps", "tabata", "fixed_interval"}:
mode = "auto"
normalized_fmt = {"mode": mode}
for field in ("work_seconds", "rest_seconds", "rounds"):
try:
value = int(fmt.get(field))
except (TypeError, ValueError):
value = 0
normalized_fmt[field] = value if value > 0 else None
normalized_fmt["fixed_interval"] = bool(
fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"}
)
normalized_fmt["rounds_scope"] = "block"
cfg["training_format"] = normalized_fmt
# days: rotA/rotB -> rotations (+ KB-Styling & light-Tag nur bei Alt-Konvertierung)
for d in days:
if "rotations" not in d:
rots = []
if d.get("rotA"):
rots.append({"label": "ROTATION A — 3 Sätze", "exercises": d.get("rotA")})
if d.get("rotB"):
rots.append({"label": "ROTATION B — 3 Sätze", "exercises": d.get("rotB")})
d["rotations"] = rots
d.pop("rotA", None)
d.pop("rotB", None)
if d.get("num") == 6:
d.setdefault("color", "#6D28D9")
d.setdefault("note", KB_NOTE)
d.setdefault("note_bg", "#F1ECFB")
if d.get("num") == 5:
d.setdefault("light", True)
cfg["days"] = days
meta.setdefault("days", len(days))
return migrate_training_config(cfg)
def default_config():
return migrate_config(copy.deepcopy(DEFAULTS))
# =========================================================================
# RENDERER
# =========================================================================
def build_pdf(cfg):
"""cfg: Trainings-Config (altes oder neues Format). Gibt PDF-Bytes zurück."""
_D = DEFAULTS
cfg = dict(cfg or {})
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
cols = dict(_D["colors"]); cols.update(cfg.get("colors") or {})
cfg["meta"], cfg["colors"] = meta, cols
for k in ("front", "library", "phases", "stages", "prepost", "days"):
if not cfg.get(k):
cfg[k] = copy.deepcopy(_D[k])
cfg = migrate_config(cfg) # auf neues Modell normalisieren
C = {k: colors.HexColor(v) for k, v in cfg["colors"].items()}
INK, ACCENT, ACC2, KBCOL = C["INK"], C["ACCENT"], C["ACC2"], C["KBCOL"]
LINK, LIGHT, GRID, MUTE = C["LINK"], C["LIGHT"], C["GRID"], C["MUTE"]
DARK, WARMBG, GOALBG, KBBG = C["DARK"], C["WARMBG"], C["GOALBG"], C["KBBG"]
link_hex = cfg["colors"]["LINK"]
styles = getSampleStyleSheet()
def Sty(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
title_s = Sty("title", fontName="Helvetica-Bold", fontSize=23, textColor=INK, leading=26, spaceAfter=2)
sub_s = Sty("sub", fontName="Helvetica", fontSize=10.5, textColor=MUTE, leading=14, spaceAfter=2)
h2_s = Sty("h2", fontName="Helvetica-Bold", fontSize=14, textColor=ACCENT, leading=17, spaceBefore=13, spaceAfter=5)
body_s = Sty("body", fontName="Helvetica", fontSize=9.6, textColor=INK, leading=13.5, spaceAfter=4)
smallm_s = Sty("smallm", fontName="Helvetica", fontSize=8.2, textColor=MUTE, leading=10.5)
cell_s = Sty("cell", fontName="Helvetica", fontSize=8.3, textColor=INK, leading=10.6)
cellb_s = Sty("cellb", fontName="Helvetica-Bold", fontSize=8.3, textColor=INK, leading=10.6)
link_s = Sty("link", fontName="Helvetica", fontSize=8.2, textColor=LINK, leading=10.6)
th_s = Sty("th", fontName="Helvetica-Bold", fontSize=8.6, textColor=colors.white, leading=11)
tag_s = Sty("tag", fontName="Helvetica-Bold", fontSize=8.4, textColor=colors.white, leading=11)
note_s = Sty("note", fontName="Helvetica", fontSize=9, textColor=INK, leading=13)
goal_s = Sty("goal", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
kb_s = Sty("kb", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
bandL = Sty("bandL", fontName="Helvetica-Bold", fontSize=17, textColor=colors.white, leading=19)
bandR = Sty("bandR", fontName="Helvetica-Bold", fontSize=11.5, textColor=colors.white, leading=14, alignment=TA_RIGHT)
howto = Sty("howto", fontName="Helvetica", fontSize=8.6, textColor=MUTE, leading=11.5, spaceBefore=4, spaceAfter=2)
warm = Sty("warm", fontName="Helvetica", fontSize=9, textColor=INK, leading=12.5)
roth = Sty("roth", fontName="Helvetica-Bold", fontSize=11, textColor=colors.white, leading=13)
exn = Sty("exn", fontName="Helvetica-Bold", fontSize=9.6, textColor=INK, leading=11.5)
stage_s = Sty("stage", fontName="Helvetica-Bold", fontSize=9, textColor=ACCENT, leading=11)
param_s = Sty("param", fontName="Helvetica", fontSize=8.8, textColor=INK, leading=12)
logst = Sty("logst", fontName="Helvetica", fontSize=9.5, textColor=INK, leading=18)
USABLE = A4[0] - 2 * 1.3 * cm
def link(url, label="Anleitung (Bilder/Video)"):
return Paragraph('<a href="%s" color="%s"><u>%s</u></a>' % (url, link_hex, label), link_s)
front = cfg["front"]
story = []
# ===================== FRONT MATTER =====================
story.append(Paragraph(cfg["meta"]["title"], title_s))
story.append(Paragraph(cfg["meta"]["subtitle"], sub_s))
story.append(HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=6, spaceAfter=6))
story.append(Paragraph(front["session_how_head"], h2_s))
story.append(Paragraph(front["session_how_body"], body_s))
flow = front["flow_rows"]
fr = [[Paragraph(c, th_s if i == 0 else (cellb_s if (j == 1 and r[0] == '') else cell_s)) for j, c in enumerate(r)] for i, r in enumerate(flow)]
t = Table(fr, colWidths=[3.2 * cm, 9.6 * cm, 2.7 * cm])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(t)
story.append(Spacer(1, 4))
story.append(Paragraph(front["timer_note"], smallm_s))
# KB evidence box
story.append(Paragraph(front["kb_head"], h2_s))
kb_rows = [[Paragraph(front["kb_box"], kb_s)]]
gt = Table(kb_rows, colWidths=[USABLE])
gt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), KBBG), ("BOX", (0, 0), (-1, -1), 0.8, KBCOL),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt)
story.append(Paragraph(front["goals_head"], h2_s))
goal_rows = [[Paragraph(front["goals_box"], goal_s)]]
gt2 = Table(goal_rows, colWidths=[USABLE])
gt2.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), GOALBG), ("BOX", (0, 0), (-1, -1), 0.8, ACCENT),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt2)
story.append(Spacer(1, 5))
story.append(Paragraph(front["goals_note"], note_s))
story.append(PageBreak())
# ---- exercise library ----
story.append(Paragraph(front["lib_head"], h2_s))
story.append(Paragraph(front["lib_intro"], smallm_s))
for grp in cfg["library"]:
gcol = C.get(grp["color"], ACC2)
rows = [[Paragraph(grp["group"], tag_s), "", ""],
[Paragraph("Übung", th_s), Paragraph("Ausführung (Kurz)", th_s), Paragraph("Anleitung", th_s)]]
for it in grp["items"]:
rows.append([Paragraph(it["name"], cellb_s), Paragraph(it["desc"], cell_s),
link(it["url"]) if it.get("url") else Paragraph("Standard-Bewegung", smallm_s)])
tstyle = [("SPAN", (0, 0), (-1, 0)), ("BACKGROUND", (0, 0), (-1, 0), gcol), ("BACKGROUND", (0, 1), (-1, 1), DARK),
("ROWBACKGROUNDS", (0, 2), (-1, -1), [colors.white, LIGHT]), ("GRID", (0, 1), (-1, -1), 0.5, GRID),
("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6)]
story.append(KeepTogether(Table(rows, colWidths=[4.0 * cm, 8.9 * cm, 3.5 * cm], style=TableStyle(tstyle))))
story.append(Spacer(1, 6))
story.append(PageBreak())
# ---- baseline + reminders ----
story.append(Paragraph(front["baseline_head"], h2_s))
story.append(Paragraph(front["baseline_intro"], body_s))
track = front["baseline_rows"]
tr = [[Paragraph(c, th_s if i == 0 else cell_s) for c in r] for i, r in enumerate(track)]
tt = Table(tr, colWidths=[9.5 * cm, 3.0 * cm, 3.0 * cm])
tt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(tt)
story.append(Spacer(1, 8))
story.append(Paragraph(front["reminder_head"], h2_s))
for r in front["reminders"]:
story.append(Paragraph("•  " + r, body_s))
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", thickness=1, color=GRID))
story.append(Paragraph(front["sheets_note"], smallm_s))
story.append(PageBreak())
# ===================== SESSION SHEETS =====================
stages = cfg["stages"]
phase_items = cfg["phases"]["items"]
PARAM_LIGHT = cfg["phases"].get("param_light", "")
prepost = cfg["prepost"]
def phase_index(week):
acc = 0
for i, ph in enumerate(phase_items):
try:
acc += int(ph.get("weeks", 2) or 0)
except Exception:
acc += 2
if week <= acc:
return i
return max(0, len(phase_items) - 1)
def phase_name(pi):
return phase_items[pi]["name"] if 0 <= pi < len(phase_items) else ""
def phase_params(pi):
return phase_items[pi].get("params", "") if 0 <= pi < len(phase_items) else ""
def stage_text(key, pi):
st = stages.get(key) if key else None
if st:
steps = st.get("steps", []) if isinstance(st, dict) else st
if 0 <= pi < len(steps) and steps[pi]:
step = steps[pi]
return step.get("name", "") if isinstance(step, dict) else step
return "leicht"
def rotation_table(rot, pi):
rows = [[Paragraph("Übung", th_s), Paragraph("Stufe heute", th_s),
Paragraph("Satz 1", th_s), Paragraph("Satz 2", th_s), Paragraph("Satz 3", th_s)]]
for item in rot:
name, key, cue = item.get("name", ""), item.get("key") or None, item.get("cue", "")
ex = Paragraph("%s<br/><font size=7.6 color='#6B6256'>%s</font>" % (name, cue), exn)
sc = Paragraph(stage_text(key, pi), stage_s)
rows.append([ex, sc, "", "", ""])
tb = Table(rows, colWidths=[7.2 * cm, 3.5 * cm, 1.95 * cm, 1.95 * cm, 1.95 * cm])
tb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.6, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 1), (-1, -1), 8), ("BOTTOMPADDING", (0, 1), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, 0), 4), ("BOTTOMPADDING", (0, 0), (-1, 0), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("ALIGN", (2, 0), (-1, -1), "CENTER")]))
return tb
def day_has_exercises(day):
return any((rot.get("exercises") or []) for rot in (day.get("rotations") or []))
def session_page(week, day):
num, focus = day.get("num"), day.get("focus", "")
pi = phase_index(week)
color = day.get("color")
band_col = colors.HexColor(color) if color else ACCENT
rot_col = colors.HexColor(color) if color else ACC2
pp = prepost.get(str(num), {"warmup": "", "cooldown": "", "stretch": ""})
pre_txt = pp.get("warmup", ""); cd_txt = pp.get("cooldown", ""); stretch_txt = pp.get("stretch", "")
flow = []
badge = day.get("badge") or {}
btext = badge.get("text", "").strip()
left_html = "WOCHE %d · TAG %d" % (week, num)
if btext:
left_html += " <font size=8 color='white' backColor='%s'> %s </font>" % (badge.get("color", "#6D28D9"), btext)
band = Table([[Paragraph(left_html, bandL),
Paragraph("%s<br/><font size=8 color='#EAD9F7'>%s</font>" % (focus, phase_name(pi)), bandR)]],
colWidths=[USABLE * 0.5, USABLE * 0.5])
band.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), band_col), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LEFTPADDING", (0, 0), (0, 0), 12), ("RIGHTPADDING", (-1, 0), (-1, 0), 12)]))
flow.append(band)
flow.append(Paragraph("Tabata: 20s an / 10s aus · pro Block 4 Übungen × 2 Durchgänge = 1 Satz · 10s Vorlauf", howto))
wb = Table([[Paragraph("<b>WARM-UP · Gelenk-Prep</b> (dynamisch, ~3 Min, kein Halten): " + pre_txt, warm)]], colWidths=[USABLE])
wb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(Spacer(1, 5))
flow.append(wb)
note = (day.get("note") or "").strip()
if note:
nb = colors.HexColor(day["note_bg"]) if day.get("note_bg") else _lighten(color or "#0F766E")
nborder = colors.HexColor(color) if color else ACC2
flow.append(Spacer(1, 4))
nt = Table([[Paragraph(note, warm)]], colWidths=[USABLE])
nt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), nb), ("BOX", (0, 0), (-1, -1), 0.6, nborder),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(nt)
flow.append(Spacer(1, 8))
for rot in (day.get("rotations") or []):
exs = rot.get("exercises") or []
if not exs:
continue
rh = Table([[Paragraph(rot.get("label", ""), roth)]], colWidths=[USABLE])
rh.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), rot_col), ("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 8)]))
flow.append(rh)
flow.append(Spacer(1, 2))
flow.append(rotation_table(exs, pi))
flow.append(Spacer(1, 8))
pbp = day.get("params_by_phase") or []
if 0 <= pi < len(pbp) and (pbp[pi] or "").strip():
ptxt = pbp[pi]
else:
ptxt = PARAM_LIGHT if day.get("light") else phase_params(pi)
pb = Table([[Paragraph(ptxt, param_s)]], colWidths=[USABLE])
pb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), LIGHT), ("BOX", (0, 0), (-1, -1), 0.6, GRID),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(pb)
flow.append(Spacer(1, 7))
cd = Table([[Paragraph("<b>COOL-DOWN</b> (~12 Min): " + cd_txt +
"<br/><b>DEHNEN</b> (warm &amp; statisch, bis mildes Ziehen, ~46 Min): " + stretch_txt, warm)]], colWidths=[USABLE])
cd.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(cd)
flow.append(Spacer(1, 7))
flow.append(Paragraph("Gefühl heute (110): ______ Glocke (kg): ______ Notizen: ________________________________", logst))
return flow
weeks = int(cfg["meta"].get("weeks", 8) or 0)
for week in range(1, weeks + 1):
for day in cfg["days"]:
if not day_has_exercises(day):
continue
story += session_page(week, day)
story.append(PageBreak())
if story and isinstance(story[-1], PageBreak):
story.pop()
footer_left = cfg["meta"]["footer_left"]
def footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(MUTE)
canvas.drawString(1.3 * cm, 1.0 * cm, footer_left)
canvas.drawRightString(A4[0] - 1.3 * cm, 1.0 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=1.3 * cm, rightMargin=1.3 * cm,
topMargin=1.2 * cm, bottomMargin=1.4 * cm, title=cfg["meta"]["doc_title"])
doc.build(story, onFirstPage=footer, onLaterPages=footer)
return buf.getvalue()
if __name__ == "__main__":
data = build_pdf(default_config())
with open("/tmp/test_plan.pdf", "wb") as f:
f.write(data)
print("OK", len(data), "bytes")
+30
View File
@@ -0,0 +1,30 @@
{
"id": "trainingsplan",
"name": "Trainingsplan- & Rezept-Editor",
"summary": "Versionierte Trainingspläne mit stabilen IDs, Validierung und Tracker-Vorschau bearbeiten",
"description": "Editor mit Entwurf und Veröffentlichung, stabilem Planvertrag, progressionsabhängigen Ergebnisschemata, planbezogener Übungsbibliothek, Konfliktschutz und strukturierten Vorschlägen aus dem Session-Tracker.",
"icon": "🏋",
"category": "Planung",
"version": "2.0.0",
"entrypoint": "backend:create_app",
"order": 10,
"requires": [],
"contract": {
"plan_schema": 3,
"training_contract": 2,
"result_schema": 2
},
"features": [
"Entwurf und veröffentlichte Planrevision sind getrennt",
"Stabile IDs für Phasen, Tage, Rotationen, Übungen und Progressionsstufen",
"Progressionsstufen als Objekte mit Ergebnisschema, Cluster und Faktor",
"Planbezogene Übungsbibliothek für den Tracker",
"Validierung von IDs, Schemata und festen Intervallen",
"Tracker-Vorschau inklusive Tabata-Durchgängen",
"Atomisches Speichern mit Backups und Revisionskonfliktschutz",
"Strukturiertes Prüfpostfach für KI-Planvorschläge",
"Import, Export und PDF-Erzeugung",
"Rezeptsammlungen bleiben unterstützt"
],
"docs": "TOOL-README.md"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
{
"name": "Granola (Vorrat)",
"config": {
"type": "recipe",
"meta": {
"title": "Die Granola-Bibel",
"header_left": "GRANOLA · Vorrats-Edition · Begleitend zum 8-Wochen-Plan",
"footer_left": "Topping statt Schüssel · Portion abwiegen · Zartbitter statt Zucker",
"output_name": "Granola_Vorrat.pdf",
"doc_title": "Die Granola-Bibel",
"brand_color": "#6D28D9",
"steps_label": "SO GEHT'S"
},
"intro": {
"headline": "Die Granola-Bibel",
"subtitle": "28 Rezepte mit exakten Arbeitsschritten deutlich weniger Zucker als jedes Fertig-Granola, Nährwerte",
"paragraphs": [
"pro Portion als Richtwert.",
"Gekauftes Granola ist meist eine als Müsli getarnte Süßigkeit 2030 % Zucker sind normal. Diese Rezepte",
"drehen das um: Hafer, Nüsse, Kerne und Buchweizen tragen, gesüßt wird sparsam und bewusst. Das ist genau",
"dein Substitutions-Hebel: Granola ist hier ein Topping für Skyr oder Quark, kein Schüssel-Füller. Eine",
"Eiweiß-Basis plus Crunch schlägt abends auf der Couch die Schoko-Chips-Cola-Kombi nicht durch Verzicht,",
"sondern durch einen besseren Reiz. Ein Wort zur Ehrlichkeit: Granola bleibt kaloriendicht. Der Hebel ist die",
"Portion, nicht die Zutat. Wieg dir 4050 g ab und stell das Glas weg aus dem Glas gelöffelt wird jedes Granola",
"zur Falle. Die Nährwerte unter NÄHRWERTE ca. sind Richtwerte pro Portion (≈50 g), keine Zielvorgabe.",
"Die Grund-Technik gilt für fast jedes Rezept",
"1 Trocken mischen: Hafer, grob gehackte Nüsse, Kerne, Gewürze, ggf. Kakao/Proteinpulver in einer großen",
"Schüssel verrühren.",
"2 Bindung erwärmen: Öl bzw. Nussmus mit Süße und Aroma kurz schmelzen, bis es dünnflüssig ist so",
"verteilt es sich gleichmäßig.",
"3 Benetzen: über das Trockene gießen und gründlich mischen, bis alles glänzt. Trockene Nester = weiche",
"Stellen später.",
"4 Cluster-Trick: für feste Brocken 1 Eiweiß schaumig schlagen und unterheben, Masse fest aufs Blech",
"drücken und beim Backen nicht rühren.",
"5 Backen: meist 150160 °C Umluft. Niedrig und länger röstet gleichmäßig; ohne Cluster einmal wenden, mit",
"Cluster in Ruhe lassen.",
"6 Auskühlen = knusprig: Granola ist frisch aus dem Ofen noch weich. Erst vollständig auf dem Blech",
"auskühlen lassen, dann brechen.",
"7 Empfindliches später: Schokolade, Trockenfrüchte und Kokoschips erst nach dem Abkühlen zugeben, sonst",
"verbrennen sie.",
"8 Lagern: luftdicht, kühl und dunkel 46 Wochen. Nicht warm einfüllen, sonst schwitzt es und wird zäh."
],
"rule_head": "",
"rule_text": "",
"categories_head": "7 Kategorien"
},
"categories": [
{
"name": "Schoko & Kakao",
"color": "#7C3AED"
},
{
"name": "Nuss, Kern & Klassik",
"color": "#0EA5E9"
},
{
"name": "Frucht & Beere",
"color": "#EA580C"
},
{
"name": "Gewürz & Saison",
"color": "#16A34A"
},
{
"name": "Protein-Boost & Post-Workout",
"color": "#DC2626"
},
{
"name": "Grain-free & Low-Carb",
"color": "#DB2777"
},
{
"name": "Ohne Ofen & Overnight",
"color": "#B45309"
}
],
"recipes": [
{
"num": "01",
"category": "Schoko & Kakao",
"title": "Malz-Schoko-Buchweizen-Granola",
"tagline": "Die Referenz nach Anna Jones zuckerreduziert adaptiert.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 24 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken (kernig) · 100 g roher Buchweizen · 80 g Haselnüsse grob · 40 g Kakaonibs · 30 g Backkakao · 1 EL Gerstenmalz-Extrakt · 2 EL Ahornsirup · 3 EL Kokosöl · 1 Prise Meersalz · 60 g Zartbitter 85 % gehackt (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft vorheizen, Blech mit Backpapier.\n2 Hafer, Buchweizen, Haselnüsse, Kakaonibs, Kakao und Salz in einer großen Schüssel gut vermischen.\n3 Kokosöl, Malz-Extrakt und Ahornsirup in einem Töpfchen bei kleiner Hitze schmelzen und glatt rühren.\n4 Flüssige Mischung über das Trockene gießen und gründlich wenden, bis alles dunkel glänzt.\n5 Gleichmäßig aufs Blech verteilen und leicht andrücken.\n6 2530 Min backen, nach der Hälfte einmal wenden Kakao verbrennt schnell, lieber kürzer.\n7 Vollständig auf dem Blech auskühlen lassen, dann die gehackte Zartbitter untermischen.",
"variations": [
"Nuss-Tausch Mandeln oder Pekannüsse statt Haselnüssen",
"Ohne Malz Malz weglassen, dafür 1 TL Instant-Kaffee für Tiefe"
],
"tip": "Coach-Tipp Kakaonibs bringen Schoko-Bitterkeit fast ohne Zucker der Trick, um Vollmilch-Schoko-Gelüste auf der Couch abzufangen.",
"badge": "",
"yield": "ergibt ca. 800 g · ~16 Portionen à 50 g"
},
{
"num": "02",
"category": "Schoko & Kakao",
"title": "Tiramisu-Granola",
"tagline": "Nach The Doctor's Kitchen Kaffee, Kakao, feste Cluster.",
"nutrition": "ca. ≈ 245 kcal · Eiweiß 8 g · Fett 15 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 20 g Backkakao · 1 TL Zimt · 2 EL Mandelmus · 2 EL Honig · 3 EL Kokosöl · 60 ml starker Espresso (abgekühlt) · 1 TL Vanille · 1 Eiweiß · Kakao zum Bestäuben",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbis- und Sonnenblumenkerne, Leinsamen, Kakao und Zimt vermischen.\n3 Mandelmus, Honig, Kokosöl und Espresso leicht erwärmen und glatt rühren, Vanille einrühren.\n4 Über das Trockene gießen und gründlich mischen.\n5 Eiweiß schaumig schlagen und unterheben das gibt die Tiramisu-typischen Cluster.\n6 Masse fest aufs Blech drücken (ca. 1,5 cm dick), nicht verteilen.\n7 3035 Min backen, dabei NICHT rühren, damit die Brocken halten.\n8 Auskühlen lassen, in Brocken brechen und mit etwas Kakao bestäuben.",
"variations": [
"Entkoffeiniert Espresso durch Lupinen- oder Malzkaffee ersetzen",
"Nüsse pur Kakao weglassen für reine Cappuccino-Note"
],
"tip": "Coach-Tipp Kaffee + Kakao schmecken „Dessert“ ohne Süße-Bombe. Über Skyr wird das dein Abend-Dessert-Ersatz.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "03",
"category": "Schoko & Kakao",
"title": "Doppel-Kakao-Haselnuss",
"tagline": "Die „Nutella“-Idee als knuspriges Topping.",
"nutrition": "ca. ≈ 252 kcal · Eiweiß 8 g · Fett 16 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 120 g Haselnüsse grob · 30 g Backkakao · 2 EL Haselnussmus · 2 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz · 40 g Kakaonibs (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Kakao und Salz mischen.\n3 Haselnussmus, Kokosöl und Ahornsirup schmelzen und glatt rühren.\n4 Über das Trockene gießen und mischen, bis alles dunkel und gleichmäßig ist.\n5 Aufs Blech, leicht andrücken.\n6 25 Min backen, nach der Hälfte wenden wegen Kakao auf die Farbe achten.\n7 Auskühlen lassen, Kakaonibs untermischen.",
"variations": [
"Extra Crunch 1 EL Buchweizen mitrösten",
"Milder Kakao halbieren, 1 TL Vanille zugeben"
],
"tip": "Coach-Tipp Haselnuss + Kakao trifft exakt den Nutella-Reiz aber mit Ballaststoffen statt Palmöl und Zucker.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "04",
"category": "Schoko & Kakao",
"title": "Schoko-Meersalz-Cluster",
"tagline": "Feste Brocken, salzig-bitter, sehr gering gesüßt.",
"nutrition": "ca. ≈ 248 kcal · Eiweiß 8 g · Fett 15 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 25 g Backkakao · 2 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · grobes Meersalz · 60 g Zartbitter 85 % (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, mit unterheben gibt die Cluster-Bindung.\n5 Fest aufs Blech drücken, mit etwas grobem Meersalz bestreuen.\n6 30 Min backen, NICHT rühren.\n7 Vollständig auskühlen lassen, in Brocken brechen, gehackte Zartbitter untermischen.",
"variations": [
"Orange Abrieb 1/2 Bio-Orange in die Bindung",
"Chili 1 Prise Chili für Schoko-Chili-Kick"
],
"tip": "Coach-Tipp Salz + 85 %-Schoko: der intensivste Schoko-Hit pro Gramm. Kleine Menge sättigt den Gusto komplett.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "05",
"category": "Schoko & Kakao",
"title": "Mokka-Walnuss",
"tagline": "Espresso trifft Röstaroma, kaum Süße.",
"nutrition": "ca. ≈ 250 kcal · Eiweiß 8 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 100 g Walnüsse grob · 40 g Leinsamen · 15 g Backkakao · 1 TL Instant-Espresso · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Leinsamen, Kakao, Espressopulver und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, leicht andrücken.\n6 2530 Min backen, einmal wenden.\n7 Auskühlen lassen wird erst kalt knusprig.",
"variations": [
"Pekan Pekannüsse statt Walnüssen",
"Stärker 2 TL Espressopulver für echten Mokka-Punch"
],
"tip": "Coach-Tipp Walnüsse liefern Omega-3 passt gut in die Regenerationswochen mit viel Volumen.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "06",
"category": "Nuss, Kern & Klassik",
"title": "Klassik pur · Nuss-Kern-Granola",
"tagline": "Die Basis. Minimaler Zucker, maximal variierbar.",
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 22 g · Zucker 4 g · Ballaststoffe 5 g",
"ingredients": "300 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Alle trockenen Zutaten mit Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren, bis alles glänzt.\n4 Aufs Blech, gleichmäßig dünn verteilen.\n5 2530 Min backen, nach der Hälfte wenden, bis goldbraun.\n6 Auf dem Blech auskühlen lassen.",
"variations": [
"Frucht nach dem Backen 40 g Trockenfrüchte untermischen",
"Nüsse pur Kerne durch mehr Nüsse ersetzen für weniger KH",
"Vanille 1 TL Vanille in die Bindung"
],
"tip": "Coach-Tipp Lern dieses Rezept auswendig alle anderen sind nur Varianten davon. Sparsame Süße ist Absicht.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "07",
"category": "Nuss, Kern & Klassik",
"title": "Erdnuss-Protein-Cluster",
"tagline": "Post-Workout-tauglich, feste Brocken, extra Eiweiß.",
"nutrition": "ca. ≈ 258 kcal · Eiweiß 12 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 60 g Erdnüsse grob · 40 g Sonnenblumenkerne · 30 g Whey oder Erbsenprotein (neutral/vanille) · 3 EL Erdnussmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Erdnüsse, Kerne und Proteinpulver mischen (Pulver gut unterheben, keine Klumpen).\n3 Erdnussmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen und unterheben.\n5 Fest aufs Blech drücken.\n6 2832 Min bei niedriger Hitze backen, NICHT rühren Protein bräunt schneller, Farbe im Auge behalten.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko 20 g Kakao ins Trockene",
"Casein-Abend Casein statt Whey für langsame Abend-Variante"
],
"tip": "Coach-Tipp Proteinpulver bräunt früher lieber 150 °C und Sichtkontrolle. Über Skyr kommst du locker auf 2530 g Eiweiß.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "08",
"category": "Nuss, Kern & Klassik",
"title": "Mandel-Vanille",
"tagline": "Schlicht, hell, alltagstauglich.",
"nutrition": "ca. ≈ 240 kcal · Eiweiß 7 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "300 g Haferflocken · 120 g Mandeln grob · 40 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 2 TL Vanille · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Leinsamen und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, Vanille einrühren, über das Trockene gießen.\n4 Gut mischen, aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Mandel-Zimt 1 TL Zimt zugeben",
"Kirsch nach dem Backen getrocknete Sauerkirschen unter)"
],
"tip": "Coach-Tipp Die neutralste Basis perfekt, wenn der Rest der Schüssel (Beeren, Quark) den Geschmack trägt.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "09",
"category": "Nuss, Kern & Klassik",
"title": "Kokos-Cashew",
"tagline": "Cremig-tropisch, leicht süß von allein.",
"nutrition": "ca. ≈ 255 kcal · Eiweiß 7 g · Fett 16 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "260 g Haferflocken · 100 g Cashews grob · 50 g Kokoschips · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne und Salz mischen Kokoschips noch NICHT zugeben.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 20 Min backen, dann wenden und die Kokoschips untermischen.\n6 Weitere 58 Min backen, bis die Kokoschips goldgelb sind sie verbrennen schnell.\n7 Auskühlen lassen.",
"variations": [
"Schoko-Kokos 40 g Zartbitter nach dem Backen",
"Limette Abrieb 1 Limette in die Bindung"
],
"tip": "Coach-Tipp Kokoschips zum Schluss zugeben ist Pflicht sonst sind sie schwarz, bevor der Rest fertig ist.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "10",
"category": "Nuss, Kern & Klassik",
"title": "Tahin-Sesam-Halva",
"tagline": "Nährig, orientalisch, unerwartet gut.",
"nutrition": "ca. ≈ 252 kcal · Eiweiß 9 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sesam · 40 g Sonnenblumenkerne · 3 EL Tahin · 2 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Sesam, Kerne und Salz mischen.\n3 Tahin, Kokosöl und Honig leicht erwärmen und glatt rühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, andrücken.\n6 2226 Min backen, einmal wenden Sesam bräunt schnell.\n7 Auskühlen lassen.",
"variations": [
"Schoko 30 g Kakaonibs nach dem Backen",
"Kardamom 1/2 TL gemahlener Kardamom in die Bindung"
],
"tip": "Coach-Tipp Tahin bringt Kalzium und einen leicht bitteren Halva-Ton sättigt richtig, kleine Portion reicht.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "11",
"category": "Nuss, Kern & Klassik",
"title": "Ahorn-Pekan",
"tagline": "Der Klassiker hier bewusst sparsam gesüßt.",
"nutrition": "ca. ≈ 246 kcal · Eiweiß 6 g · Fett 15 g · KH 22 g · Zucker 7 g · Ballaststoffe 4 g",
"ingredients": "290 g Haferflocken · 110 g Pekannüsse grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 3 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen die Karamell-Note kommt erst kalt.",
"variations": [
"Ahorn-Apfel nach dem Backen getrocknete Apfelstücke unter",
"Weniger süß nur 2 EL Ahornsirup"
],
"tip": "Coach-Tipp Das klassische Frühstücks-Granola aber mit 7 g Zucker statt der üblichen 1520 g im Handel.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "12",
"category": "Frucht & Beere",
"title": "Beeren-Chia",
"tagline": "Säuerlich-fruchtig, Beeren als frisches Topping.",
"nutrition": "ca. ≈ 232 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 7 g · Ballaststoffe 6 g",
"ingredients": "280 g Haferflocken · 80 g Mandeln grob · 30 g Chiasamen · 30 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 40 g gefriergetrocknete Beeren (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Chia, Kerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann die gefriergetrockneten Beeren untermischen.\n7 Frische oder TK-Beeren erst in der Schüssel dazugeben, nicht ins Vorratsglas.",
"variations": [
"Himbeer-Kakao 20 g Kakao + gefriergetrocknete Himbeeren",
"Frisch in der Schüssel mit TK-Heidelbeeren toppen"
],
"tip": "Coach-Tipp Gefrorene Beeren statt Gummibärchen hier direkt eingebaut. Säure bremst die Süß-Gier.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "13",
"category": "Frucht & Beere",
"title": "Apfel-Zimt-Bratapfel",
"tagline": "Apfelkuchen-Gefühl ohne Teig.",
"nutrition": "ca. ≈ 234 kcal · Eiweiß 6 g · Fett 12 g · KH 25 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "290 g Haferflocken · 90 g Walnüsse grob · 40 g Sonnenblumenkerne · 2 TL Zimt · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 60 g getrocknete Apfelstücke (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen, dann getrocknete Apfelstücke untermischen (sonst werden sie zäh/hart).",
"variations": [
"Rosine zusätzlich eine kleine Handvoll Rosinen",
"Warm in der Schüssel mit warmem, gewürfeltem Apfel toppen"
],
"tip": "Coach-Tipp Der Kuchen-Ersatz. Über Skyr mit warmem Apfel schmeckt es wie Apfelstrudel ohne Zuckerschock.",
"badge": "",
"yield": "ergibt ca. 760 g · ~15 Portionen à 50 g"
},
{
"num": "14",
"category": "Frucht & Beere",
"title": "Bananenbrot-Granola",
"tagline": "Reife Banane bringt Süße fast ohne Sirup.",
"nutrition": "ca. ≈ 228 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Walnüsse grob · 40 g Leinsamen · 1 sehr reife Banane · 2 EL Kokosöl · 1 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Banane mit einer Gabel fein zerdrücken.\n3 Hafer, Walnüsse, Leinsamen, Zimt und Salz mischen.\n4 Bananenpüree, geschmolzenes Kokosöl und Ahornsirup verrühren, über das Trockene geben.\n5 Gut mischen, aufs Blech, andrücken.\n6 3035 Min backen Banane braucht länger zum Trocknen; wenden nach 20 Min.\n7 Vollständig auskühlen lassen (bleibt sonst weich).",
"variations": [
"Schoko-Chunk 40 g Zartbitter nach dem Backen",
"Erdnuss 2 EL Erdnussmus in die Bindung"
],
"tip": "Coach-Tipp Braune Bananen sinnvoll verwerten statt wegwerfen sie ersetzen einen Gutteil des Sirups.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "15",
"category": "Frucht & Beere",
"title": "Orange-Cranberry",
"tagline": "Weihnachtlich-fruchtig, säuerlicher Kontrast.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 6 g · Fett 12 g · KH 26 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · Abrieb 1 Bio-Orange · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g getrocknete Cranberries (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne, Orangenabrieb und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann Cranberries untermischen.",
"variations": [
"Pistazie statt Mandeln für Farbe und Weihnachtslook",
"Zitrone Zitronenabrieb statt Orange"
],
"tip": "Coach-Tipp Cranberries sind von Natur aus sauer und oft gezuckert lieber sparsam, der Orangenabrieb trägt den Geschmack.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "16",
"category": "Frucht & Beere",
"title": "Trail-Mix-Granola",
"tagline": "Studentenfutter-Idee, ideal als Wander-/Büro-Snack.",
"nutrition": "ca. ≈ 256 kcal · Eiweiß 8 g · Fett 15 g · KH 23 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 120 g gemischte Nüsse grob · 40 g Kürbiskerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g Rosinen + 30 g Zartbitter-Tropfen (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbiskerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Vollständig auskühlen lassen, dann Rosinen und Zartbitter untermischen (Schoko schmilzt sonst).",
"variations": [
"Salzig 1 Prise mehr Salz + geröstete Erdnüsse",
"Ohne Schoko stattdessen mehr Nüsse"
],
"tip": "Coach-Tipp Portionsbeutel abwiegen und in die Tasche das ist dein Büro-Swap gegen Chips und Gummibärchen.",
"badge": "",
"yield": "ergibt ca. 780 g · ~15 Portionen à 50 g"
},
{
"num": "17",
"category": "Gewürz & Saison",
"title": "Kürbisgewürz (Pumpkin Spice)",
"tagline": "Herbst im Glas, wärmende Gewürze.",
"nutrition": "ca. ≈ 240 kcal · Eiweiß 8 g · Fett 14 g · KH 21 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Pekannüsse grob · 60 g Kürbiskerne · 2 TL Zimt · 1/2 TL Ingwer · 1/2 TL Muskat · 1 Prise Nelke · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kürbiskerne, alle Gewürze und Salz gründlich mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Mit Kürbis 2 EL Kürbispüree in die Bindung, 5 Min länger backen",
"Ahorn-Pekan-Plus zusätzlich Vanille"
],
"tip": "Coach-Tipp Das Gewürz macht die Süße subjektiv größer typischer Trick, um mit weniger Zucker auszukommen.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "18",
"category": "Gewürz & Saison",
"title": "Ingwer-Kurkuma „Golden“",
"tagline": "Wärmend, leicht scharf, entzündungsfreundlich.",
"nutrition": "ca. ≈ 242 kcal · Eiweiß 7 g · Fett 14 g · KH 22 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Cashews grob · 40 g Sonnenblumenkerne · 1 TL Kurkuma · 1 TL Ingwer · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne, Kurkuma, Ingwer, Pfeffer und Salz mischen (Pfeffer macht Kurkuma verfügbar).\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Kokos 40 g Kokoschips in den letzten 6 Min",
"Schärfer frischen geriebenen Ingwer in die Bindung"
],
"tip": "Coach-Tipp Kurkuma färbt alles gelb (auch das Blech-Papier) harmlos. Eine Prise Pfeffer gehört dazu.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "19",
"category": "Gewürz & Saison",
"title": "Spekulatius-Lebkuchen",
"tagline": "Winter-Edition, ohne die übliche Zuckerlast.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 7 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Haselnüsse grob · 2 TL Lebkuchengewürz · 1 TL Zimt · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Haselnüsse, Lebkuchengewürz, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Orange Orangenabrieb dazu",
"Schoko 40 g Zartbitter nach dem Backen"
],
"tip": "Coach-Tipp Weihnachtsgebäck-Ersatz für den Advent stillt den Spekulatius-Reflex, ohne die Kekstüte.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "20",
"category": "Gewürz & Saison",
"title": "Chai-Gewürz",
"tagline": "Schwarzer Tee trifft wärmende Gewürze.",
"nutrition": "ca. ≈ 239 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · 1 TL Zimt · 1/2 TL Kardamom · 1/2 TL Ingwer · 1 Prise Nelke · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und alle Gewürze mischen.\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Vanille-Chai 1 TL Vanille zugeben",
"Milchkaffee über warme Milch als Chai-Porridge"
],
"tip": "Coach-Tipp Kardamom und Nelke sind intensiv sparsam dosieren, sonst überdeckt es alles.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "21",
"category": "Gewürz & Saison",
"title": "Karottenkuchen-Granola",
"tagline": "Carrot Cake als Frühstücks-Topping.",
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "260 g Haferflocken · 80 g Walnüsse grob · 40 g Sonnenblumenkerne · 80 g fein geraspelte Möhre · 2 TL Zimt · 1/2 TL Muskat · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 30 g Rosinen (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Möhre fein raspeln und mit Küchenpapier gut ausdrücken sonst wird das Granola nicht knusprig.\n3 Hafer, Walnüsse, Kerne, Gewürze, Salz und die geraspelte Möhre mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Aufs Blech, dünn verteilen, andrücken.\n6 3035 Min backen Möhre bringt Feuchtigkeit, länger und niedriger trocknen; nach 20 Min wenden.\n7 Vollständig auskühlen lassen, dann Rosinen untermischen.",
"variations": [
"Frischkäse-Note über Skyr = Carrot-Cake-Frosting-Effekt",
"Kokos 2 EL Kokosraspel dazu"
],
"tip": "Coach-Tipp Möhre gut ausdrücken ist der ganze Trick sonst bleibt es zäh statt knusprig.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "22",
"category": "Protein-Boost & Post-Workout",
"title": "Vanille-Whey-Cluster",
"tagline": "Post-Workout: viel Eiweiß, feste Brocken.",
"nutrition": "ca. ≈ 260 kcal · Eiweiß 13 g · Fett 13 g · KH 22 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "240 g Haferflocken · 70 g Mandeln grob · 40 g Sonnenblumenkerne · 40 g Whey Vanille · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Whey mischen Pulver klumpenfrei unterheben.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 2530 Min bei 150 °C backen, NICHT rühren Whey bräunt früh, Farbe kontrollieren.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko-Whey Schoko-Whey + 15 g Kakao",
"Erdnuss Erdnussmus statt Mandelmus"
],
"tip": "Coach-Tipp Über 150 g Skyr sind das ~30 g Eiweiß idealer Post-Session-Snack rund ums Training.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "23",
"category": "Protein-Boost & Post-Workout",
"title": "Schoko-Casein-Abend",
"tagline": "Langsames Eiweiß für den Couch-Abend.",
"nutrition": "ca. ≈ 256 kcal · Eiweiß 13 g · Fett 13 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
"ingredients": "240 g Haferflocken · 70 g Haselnüsse grob · 40 g Leinsamen · 40 g Casein Schoko · 20 g Kakao · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Leinsamen, Casein und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 2528 Min backen, NICHT rühren Kakao + Casein bräunen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Meersalz grobes Salz vor dem Backen aufstreuen",
"Nibs 30 g Kakaonibs nach dem Backen"
],
"tip": "Coach-Tipp Casein macht langsam satt als Abend-Dessert über Magerquark dein bester Schoko-Couch-Ersatz.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "24",
"category": "Protein-Boost & Post-Workout",
"title": "Skyr-Topper · grob & pur",
"tagline": "Extra grobe Brocken, kaum Süße nur zum Toppen.",
"nutrition": "ca. ≈ 244 kcal · Eiweiß 9 g · Fett 15 g · KH 19 g · Zucker 3 g · Ballaststoffe 6 g",
"ingredients": "240 g Haferflocken kernig · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 30 g Leinsamen · 3 EL Mandelmus · 2 EL Kokosöl · 1 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kerne und Leinsamen mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben für maximal grobe Cluster sehr fest andrücken.\n5 2832 Min backen, NICHT rühren.\n6 Vollständig auskühlen lassen, in große Brocken brechen.",
"variations": [
"Herzhaft Salz erhöhen, Süße weglassen",
"Zimt 1 TL Zimt für milde Süße ohne Zucke"
],
"tip": "Coach-Tipp Bewusst wenig Zucker (3 g) die Süße kommt vom Obst in der Schüssel. Das gröbste Rezept, perfekt als Crunch auf Quark.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "25",
"category": "Grain-free & Low-Carb",
"title": "Grain-free Nuss-Granola",
"tagline": "Ohne Getreide, sehr wenig KH, viel Crunch.",
"nutrition": "ca. ≈ 272 kcal · Eiweiß 9 g · Fett 23 g · KH 8 g · Zucker 4 g · Ballaststoffe 6 g",
"ingredients": "120 g Mandeln · 120 g Pekannüsse · 80 g Kürbiskerne · 60 g Sonnenblumenkerne · 80 g Kokoschips · 30 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Nüsse grob hacken; mit Kernen und Leinsamen mischen (Kokoschips zurückhalten).\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben bindet die Cluster ohne Hafer.\n5 Fest aufs Blech drücken.\n6 1822 Min backen; in den letzten 6 Min Kokoschips untermischen Nüsse und Kokos verbrennen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko 20 g Kakao ins Trockene",
"Zimt-Kokos 1 TL Zimt zugeben"
],
"tip": "Coach-Tipp Für Tage mit wenig Bewegung: kaum KH, sättigt lange über Fett und Ballaststoffe. Portion trotzdem abwiegen sehr kaloriendicht.",
"badge": "",
"yield": "ergibt ca. 650 g · ~13 Portionen à 50 g"
},
{
"num": "26",
"category": "Grain-free & Low-Carb",
"title": "Buchweizen-Buckini-Crunch",
"tagline": "Aktivierter Buchweizen extrem knusprig, glutenfrei.",
"nutrition": "ca. ≈ 234 kcal · Eiweiß 8 g · Fett 11 g · KH 27 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "200 g roher Buchweizen · 100 g Haferflocken (oder mehr Buchweizen für glutenfrei) · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Buchweizen 46 h in Wasser einweichen, dann gründlich abspülen und gut abtropfen lassen (macht ihn\nbekömmlicher und knuspriger).\n2 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n3 Buchweizen, Hafer, Mandeln, Kerne, Zimt und Salz mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Dünn aufs Blech, andrücken.\n6 3035 Min backen, alle 10 Min wenden eingeweichter Buchweizen braucht länger zum Trocknen.\n7 Vollständig auskühlen lassen wird dann glasig-knusprig.",
"variations": [
"Kakao 20 g Kakao für Schoko-Buckini",
"Schnell Buchweizen ohne Einweichen etwas härter, geht abe"
],
"tip": "Coach-Tipp Der knusprigste Crunch überhaupt. Wenn Hafer dir schwer im Magen liegt, ist das die leichtere Basis.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "27",
"category": "Ohne Ofen & Overnight",
"title": "No-Bake Erdnuss-Cluster",
"tagline": "Ohne Ofen, aus dem Kühlschrank fest.",
"nutrition": "ca. ≈ 248 kcal · Eiweiß 10 g · Fett 14 g · KH 22 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "200 g Haferflocken · 60 g Erdnüsse grob · 30 g Leinsamen · 6 entsteinte Datteln · 4 EL Erdnussmus · 2 EL Kokosöl · 1 Prise Salz · optional 20 g Zartbitter",
"steps": "1 Datteln 10 Min in warmem Wasser einweichen, abgießen und zu einer Paste zerdrücken.\n2 Erdnussmus und Kokosöl leicht erwärmen, mit der Dattelpaste glatt rühren.\n3 Hafer, Erdnüsse, Leinsamen und Salz untermischen, bis alles klebt.\n4 Masse fest in eine mit Papier ausgelegte Form drücken (ca. 1,5 cm).\n5 Mindestens 1 h kalt stellen, bis sie fest ist.\n6 In Riegel oder Brocken schneiden; optional mit geschmolzener Zartbitter bestreichen.\n7 Kühl gelagert 1 Woche haltbar (kein Backen = kürzer haltbar als gebäcken).",
"variations": [
"Schoko 20 g Kakao in die Paste",
"Riegel als Snack-Riegel schneiden für unterwegs"
],
"tip": "Coach-Tipp Kein Ofen nötig aber Datteln bringen Zucker (9 g). Portion klein halten, dann ein solider Riegel-Ersatz.",
"badge": "",
"yield": "ergibt ca. 600 g · ~12 Portionen à 50 g"
},
{
"num": "28",
"category": "Ohne Ofen & Overnight",
"title": "Overnight-Bircher-Mix",
"tagline": "Trockenmischung zum Einweichen quasi Granola rückwärts.",
"nutrition": "ca. ≈ 230 kcal · Eiweiß 8 g · Fett 10 g · KH 27 g · Zucker 6 g · Ballaststoffe 6 g",
"ingredients": "350 g Haferflocken · 60 g Mandeln grob · 40 g Leinsamen · 30 g Chiasamen · 1 TL Zimt · 40 g Rosinen pro Portion: 150 g Skyr/Milch + geraspelter Apfel Trockenmix für ~14 Portionen à 50 g",
"steps": "1 Alle trockenen Zutaten mischen und in ein großes Vorratsglas füllen (kein Backen).\n2 Am Vorabend 50 g Mix mit 150 g Skyr oder Milch übergießen und verrühren.\n3 Optional geraspelten Apfel oder Beeren unterheben.\n4 Abgedeckt über Nacht in den Kühlschrank.\n5 Am Morgen kurz umrühren; bei Bedarf einen Schuss Milch für die Konsistenz.\n6 Nach Wunsch mit frischem Obst oder einem Teelöffel gebackenem Granola toppen.",
"variations": [
"Schoko 1 TL Kakao einrühren",
"Protein 1/2 Portion Whey unterrühren",
"Beere TK-Beeren mit einweichen"
],
"tip": "Coach-Tipp Das Löst die Ausrede „keine Zeit“ abends 30 Sekunden Aufwand, morgens Eiweiß-Basis ohne Nachdenken.",
"badge": "",
"yield": ""
}
]
}
}
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
"""
recipe_builder.py
Rendert eine Rezeptsammlung als A4-PDF im Design der Original-Editionen:
farbiges Kategorie-Band, farbiges Rezept-Titelband (weiße Schrift), getönter
Untertitel, NÄHRWERTE-Strip, zweispaltig ZUTATEN | SO GEHT'S, getönte
VARIATIONEN- und Tipp-Box, dünn umrandete Karte.
Regeln (wie im Original-Generator):
* jedes Rezept ist KeepTogether -> wird nie durch einen Seitenumbruch geteilt
* vor jeder Kategorie ein PageBreak -> jede Kategorie beginnt auf neuer Seite
build_pdf(cfg) -> bytes ; default_config() -> dict
"""
import re
import copy
from io import BytesIO
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
KeepTogether, PageBreak)
DEFAULTS = {
"type": "recipe",
"meta": {
"title": "Neue Rezeptsammlung",
"header_left": "REZEPTE · Edition",
"header_right": "Begleitend zum 8-Wochen-Plan",
"footer_left": "Teller = Portionsgrenze · Trend über die Woche zählt",
"output_name": "Rezepte.pdf",
"doc_title": "Rezeptsammlung",
"brand_color": "#6D28D9",
"tip_label": "Tipp",
"steps_label": "SO GEHT'S",
},
"intro": {
"headline": "Neue Rezeptsammlung",
"subtitle": "Kurzbeschreibung der Edition.",
"paragraphs": ["Einleitender Absatz."],
"rule_head": "Die eine Grundregel",
"rule_text": "Teller fertig machen, Rest aus dem Sichtfeld.",
"categories_head": "1 Kategorie",
},
"categories": [{"name": "Cremig & süß", "color": "#7C3AED"}],
"recipes": [
{"num": "01", "category": "Cremig & süß", "title": "Beispiel-Rezept",
"tagline": "Kurzer Untertitel.", "nutrition": "", "badge": "", "yield": "",
"ingredients": "200 g Magerquark · etwas Süße",
"steps": "Alles verrühren, fertig.",
"variations": ["Schoko: + 1 TL Kakao"], "tip": "Tipp Eiweiß-reich."},
],
}
def default_config():
return copy.deepcopy(DEFAULTS)
# ---------------------------------------------------------------- helpers
_ENT = re.compile(r"&(?!(?:amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);)")
def esc(s):
if s is None:
return ""
s = str(s).replace("<", "&lt;").replace(">", "&gt;")
return _ENT.sub("&amp;", s)
def _hx(c):
return "#" + c.hexval()[2:]
def lighten(hexstr, w=0.92):
"""Mischt eine Farbe mit Weiß (w = Weiß-Anteil) -> heller Ton."""
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#334155")
r = c.red * 255 * (1 - w) + 255 * w
g = c.green * 255 * (1 - w) + 255 * w
b = c.blue * 255 * (1 - w) + 255 * w
return colors.Color(r / 255, g / 255, b / 255)
TIP_LABEL_RE = re.compile(r"^\s*([A-Za-zÄÖÜäöü]+-Tipp|Tipp)\b[:\s]*(.*)$", re.S)
def split_tip(text, default_label):
"""Zerlegt 'Morgen-Tipp Text' -> ('Morgen-Tipp', 'Text')."""
if not text:
return default_label, ""
m = TIP_LABEL_RE.match(text)
if m:
return m.group(1), m.group(2).strip()
return default_label, text.strip()
def bold_lead(v):
"""Erste Bezeichnung einer Variation fett (bis Doppelpunkt bzw. erstes Wort)."""
v = v.strip()
m = re.match(r"^([^:]{2,40}):\s*(.*)$", v, re.S)
if m:
return "<b>%s:</b> %s" % (esc(m.group(1)), esc(m.group(2)))
m = re.match(r"^(\S+)\s+(.*)$", v, re.S)
if m:
return "<b>%s</b> %s" % (esc(m.group(1)), esc(m.group(2)))
return esc(v)
NOSPLIT = {"bis", "damit", "sodass", "bevor", "nachdem", "während", "wenn", "falls", "sobald",
"weil", "sowie", "oder", "aber", "bei", "je", "ohne", "mit", "in", "im", "am", "zum",
"zur", "auf", "für", "über", "unter", "dabei", "dazu", "nach", "vor", "als"}
def split_steps(text):
t = (text or "").strip().replace("z. B.", "z.B.")
raw = re.split(r"(?<=[.!?])\s+|,\s+", t)
steps = []
for frag in raw:
frag = frag.strip()
if not frag:
continue
first = frag.split()[0].lower().strip("(„\"'.:;»«")
if steps and first in NOSPLIT:
steps[-1] = steps[-1].rstrip(".") + ", " + frag
else:
steps.append(frag)
steps = [s if s.endswith((".", "!", "?")) else s + "." for s in steps]
return [s[:1].upper() + s[1:] for s in steps]
def numbered_steps(steps_txt):
"""Bereits nummerierte (mehrzeilige) Schritte -> Zeilen; sonst Prosa splitten."""
if steps_txt and "\n" in steps_txt:
out = []
for ln in steps_txt.split("\n"):
ln = re.sub(r"^\s*\d+[\.\)]?\s+", "", ln.strip())
if ln:
out.append(ln)
return out
return split_steps(steps_txt)
# ---------------------------------------------------------------- renderer
def build_pdf(cfg):
_D = DEFAULTS
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
intro = dict(_D["intro"]); intro.update(cfg.get("intro") or {})
cats = cfg.get("categories") or _D["categories"]
recipes = cfg.get("recipes") or []
INK = colors.HexColor("#23272F")
SUB = colors.HexColor("#5B6270")
HAIR = colors.HexColor("#E3E1EA")
NUTBG = colors.HexColor("#F7F7F9")
BRAND = colors.HexColor(meta.get("brand_color", "#6D28D9"))
TIPBG = lighten(_hx(BRAND), 0.93)
def catcolor(name):
for c in cats:
if c.get("name") == name:
try:
return colors.HexColor(c.get("color", "#334155"))
except Exception:
return colors.HexColor("#334155")
return colors.HexColor("#334155")
styles = getSampleStyleSheet()
def S(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
st_title = S("t", fontName="Helvetica-Bold", fontSize=22, leading=25, textColor=INK)
st_sub = S("s", fontName="Helvetica", fontSize=10.5, leading=14, textColor=SUB)
st_intro = S("i", fontName="Helvetica", fontSize=10, leading=15, textColor=INK)
st_callh = S("ch", fontName="Helvetica-Bold", fontSize=10.5, leading=13, textColor=INK)
st_legend= S("lg", fontName="Helvetica", fontSize=8.6, leading=12, textColor=INK)
st_cat = S("c", fontName="Helvetica-Bold", fontSize=12.5, leading=15, textColor=colors.white)
st_rname = S("rn", fontName="Helvetica-Bold", fontSize=11.5, leading=14, textColor=colors.white)
st_badge = S("bd", fontName="Helvetica-Bold", fontSize=7.5, leading=9)
st_tag = S("tg", fontName="Helvetica-Oblique", fontSize=9, leading=12, textColor=SUB)
st_lbl = S("lb", fontName="Helvetica-Bold", fontSize=7.5, leading=10, spaceAfter=1)
st_body = S("bd2", fontName="Helvetica", fontSize=8.8, leading=12, textColor=INK)
st_var = S("vr", fontName="Helvetica", fontSize=8.4, leading=11.6, textColor=INK)
st_tip = S("tp", fontName="Helvetica", fontSize=8.5, leading=12, textColor=INK)
st_yield = S("yl", fontName="Helvetica-Oblique", fontSize=8, leading=11, textColor=SUB)
PAGE_W, PAGE_H = A4
LM = RM = 1.5 * cm
CW = PAGE_W - LM - RM
def lbl(text, acc):
return Paragraph(text, S("lx", parent=st_lbl, textColor=acc))
# ---- category band ----
def category_band(name):
acc = catcolor(name)
t = Table([[Paragraph(esc(name), st_cat)]], colWidths=[CW])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6)]))
return t
# ---- one recipe card ----
def recipe_card(r):
acc = catcolor(r.get("category", ""))
accx = _hx(acc)
tint = lighten(accx, 0.92)
# Titelband (+ optional AIRFRYER-Badge)
name_p = Paragraph("%s&nbsp;&nbsp;%s" % (esc(r.get("num", "")), esc(r.get("title", ""))), st_rname)
if r.get("badge"):
badge = Table([[Paragraph(esc(r["badge"]), S("bx", parent=st_badge, textColor=acc))]])
badge.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
badge.hAlign = "RIGHT"
head = Table([[name_p, badge]], colWidths=[CW - 82, 82])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (0, 0), 9), ("RIGHTPADDING", (0, 0), (0, 0), 4),
("LEFTPADDING", (1, 0), (1, 0), 0), ("RIGHTPADDING", (1, 0), (1, 0), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
else:
head = Table([[name_p]], colWidths=[CW])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows = [[head]]
# Untertitel (getönt)
if r.get("tagline"):
tg = Table([[Paragraph(esc(r["tagline"]), st_tag)]], colWidths=[CW])
tg.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
rows.append([tg])
# NÄHRWERTE-Strip
if r.get("nutrition"):
ntext = '<b><font color="%s">NÄHRWERTE</font></b>&nbsp;&nbsp;%s' % (accx, esc(r["nutrition"]))
n = Table([[Paragraph(ntext, st_var)]], colWidths=[CW])
n.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), NUTBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LINEBELOW", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([n])
# ZUTATEN volle Breite
zut_cell = [lbl("ZUTATEN", acc), Paragraph(esc(r.get("ingredients", "")), st_body)]
if r.get("yield"):
zut_cell.append(Spacer(1, 2))
zut_cell.append(Paragraph("<i>%s</i>" % esc(r["yield"]), st_yield))
zut = Table([[zut_cell]], colWidths=[CW])
zut.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([zut])
# Schritte nummeriert, volle Breite
steps = numbered_steps(r.get("steps", ""))
srows = [[Paragraph('<b><font color="%s">%d</font></b>' % (accx, i), st_body),
Paragraph(esc(s), st_body)] for i, s in enumerate(steps, 1)]
if srows:
steps_inner = Table(srows, colWidths=[15, CW - 18 - 15])
steps_inner.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 4), ("RIGHTPADDING", (1, 0), (1, 0), 0),
("TOPPADDING", (0, 0), (-1, 0), 0), ("TOPPADDING", (0, 1), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc), Spacer(1, 2), steps_inner]
else:
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc)]
arb = Table([[arb_cell]], colWidths=[CW])
arb.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR), ("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([arb])
# ZUBEREITUNG (Airfryer/Ofen), optional
if r.get("methods"):
m_cell = [lbl("ZUBEREITUNG · AIRFRYER / OFEN", acc), Paragraph(esc(r["methods"]), st_var)]
mt = Table([[m_cell]], colWidths=[CW])
mt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F3F4F6")),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([mt])
# VARIATIONEN (getönt)
if r.get("variations"):
vhtml = "<br/>".join(bold_lead(v) for v in r["variations"] if v)
v = Table([[[lbl("VARIATIONEN", acc), Paragraph(vhtml, st_var)]]], colWidths=[CW])
v.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([v])
# Tipp (Marken-Tint)
if r.get("tip"):
lab, txt = split_tip(r["tip"], meta.get("tip_label", "Tipp"))
tip_html = '<b><font color="%s">%s</font></b>&nbsp;&nbsp;%s' % (accx, esc(lab), esc(txt))
tp = Table([[Paragraph(tip_html, st_tip)]], colWidths=[CW])
tp.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows.append([tp])
card = Table(rows, colWidths=[CW])
card.setStyle(TableStyle([("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("BOX", (0, 0), (-1, -1), 0.8, HAIR)]))
return KeepTogether([card, Spacer(1, 7)])
# ---- story ----
story = []
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(esc(intro.get("headline", meta["title"])), st_title))
story.append(Spacer(1, 3))
if intro.get("subtitle"):
story.append(Paragraph(esc(intro["subtitle"]), st_sub))
story.append(Spacer(1, 10))
for p in intro.get("paragraphs", []):
story.append(Paragraph(esc(p), st_intro))
story.append(Spacer(1, 6))
if intro.get("rule_head") or intro.get("rule_text"):
gr = Table([[[Paragraph(esc(intro.get("rule_head", "")), st_callh), Spacer(1, 3),
Paragraph(esc(intro.get("rule_text", "")), st_intro)]]], colWidths=[CW])
gr.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 12),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LINEBEFORE", (0, 0), (0, 0), 3, BRAND)]))
story.append(Spacer(1, 4))
story.append(gr)
story.append(Spacer(1, 12))
story.append(Paragraph(esc(intro.get("categories_head", "%d Kategorien" % len(cats))),
S("lh", parent=st_callh, fontSize=10)))
story.append(Spacer(1, 4))
leg_rows = []
for c in cats:
sw = Table([[""]], colWidths=[10], rowHeights=[10])
sw.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), catcolor(c["name"])),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
leg_rows.append([sw, Paragraph(esc(c["name"]), st_legend)])
if leg_rows:
leg = Table(leg_rows, colWidths=[16, CW - 16])
leg.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 6),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
story.append(leg)
# Rezepte, gruppiert je Kategorie -> neue Seite pro Kategorie, Rezept nie geteilt
cur = None
for r in recipes:
c = r.get("category", "")
if c != cur:
story.append(PageBreak())
story.append(category_band(c))
story.append(Spacer(1, 7))
cur = c
story.append(recipe_card(r))
# ---- Kopf-/Fußzeile ----
header_left = meta.get("header_left", "")
header_right = meta.get("header_right", "Begleitend zum 8-Wochen-Plan")
footer_left = meta.get("footer_left", "")
def deco(canvas, doc):
canvas.saveState()
canvas.setFillColor(BRAND); canvas.setFont("Helvetica-Bold", 8)
canvas.drawString(LM, PAGE_H - 1.05 * cm, header_left)
canvas.setFont("Helvetica", 8); canvas.setFillColor(SUB)
canvas.drawRightString(PAGE_W - RM, PAGE_H - 1.05 * cm, header_right)
canvas.setStrokeColor(HAIR); canvas.setLineWidth(0.7)
canvas.line(LM, PAGE_H - 1.2 * cm, PAGE_W - RM, PAGE_H - 1.2 * cm)
canvas.line(LM, 1.15 * cm, PAGE_W - RM, 1.15 * cm)
canvas.setFont("Helvetica", 7.5); canvas.setFillColor(SUB)
canvas.drawString(LM, 0.8 * cm, footer_left)
canvas.drawRightString(PAGE_W - RM, 0.8 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=LM, rightMargin=RM,
topMargin=1.6 * cm, bottomMargin=1.5 * cm,
title=meta.get("doc_title", meta.get("title", "Rezepte")))
doc.build(story, onFirstPage=deco, onLaterPages=deco)
return buf.getvalue()
if __name__ == "__main__":
open("/tmp/recipe_test.pdf", "wb").write(build_pdf(default_config()))
print("OK")
+391
View File
@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
"""Versionierter Datenvertrag zwischen Trainingsplan-Editor und Session-Tracker."""
from __future__ import annotations
import copy
import hashlib
import re
from typing import Any
PLAN_SCHEMA_VERSION = 3
CONTRACT_VERSION = 2
RESULT_SCHEMA_VERSION = 2
VALID_RESULT_MODES = {"auto", "reps", "seconds", "minutes", "none"}
VALID_WEIGHT_MODES = {"none", "optional", "required"}
VALID_LATERALITY = {"bilateral", "unilateral"}
VALID_SIDES_MODES = {"same", "separate"}
VALID_TRAINING_MODES = {"auto", "sets_reps", "tabata", "fixed_interval"}
def slug(value: Any) -> str:
text = str(value or "").strip().lower()
text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
return re.sub(r"[^a-z0-9]+", "-", text).strip("-") or "item"
def stable_id(prefix: str, *parts: Any) -> str:
seed = "|".join(str(part or "") for part in parts)
digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:10]
return f"{prefix}-{slug(parts[-1] if parts else prefix)[:30]}-{digest}"
def normalize_result_schema(value: Any, partial: bool = False) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict):
value = {}
mode = str(value.get("mode") or ("auto" if not partial else ""))
weight_mode = str(value.get("weight_mode") or ("none" if not partial else ""))
laterality = str(value.get("laterality") or ("bilateral" if not partial else ""))
sides_mode = str(value.get("sides_mode") or ("same" if not partial else ""))
result: dict[str, Any] = {}
if mode in VALID_RESULT_MODES:
result["mode"] = mode
elif not partial:
result["mode"] = "auto"
if weight_mode in VALID_WEIGHT_MODES:
result["weight_mode"] = weight_mode
elif not partial:
result["weight_mode"] = "none"
if laterality in VALID_LATERALITY:
result["laterality"] = laterality
elif not partial:
result["laterality"] = "bilateral"
if sides_mode in VALID_SIDES_MODES:
result["sides_mode"] = sides_mode
elif not partial:
result["sides_mode"] = "same"
try:
sets = int(value.get("sets"))
except (TypeError, ValueError):
sets = 0
if 1 <= sets <= 40:
result["sets"] = sets
if "locked_sets" in value:
result["locked_sets"] = bool(value.get("locked_sets"))
if result.get("laterality") == "bilateral":
result["sides_mode"] = "same"
return result or None
def infer_result_schema(name: Any, cue: Any = "", *, stage: bool = False) -> dict[str, Any]:
"""Leitet für Legacy- und neue Standardpläne ein explizites Basisschema ab.
Die Ableitung ist nur eine Migrationshilfe. Nach dem Speichern liegt das
Ergebnis fest im Plan und kann im Editor jederzeit korrigiert werden.
"""
text = f"{name or ''} {cue or ''}".casefold()
no_measure = re.search(
r"(?:regeneration|schlaf|schmerzcheck|pausieren|protokollieren|prüfen|check|"
r"mobility|mobilität|dehnen|stretch|cool.?down|warm.?up|spaziergang locker)$",
text,
)
if no_measure:
mode = "none"
elif re.search(r"(?:spaziergang|gehen|walk).*(?:min|minute)|(?:min|minute).*(?:spaziergang|gehen|walk)", text):
mode = "minutes"
elif re.search(r"(?:hold|plank|hang|wall\s*sit|wandsitz|isometr|halte|stützposition|deep\s+squat)", text):
mode = "seconds"
elif re.search(r"(?:sek(?:unde)?|\d+\s*s)", text) and not re.search(r"(?:reps?|wiederhol|negativ|push|pull|squat|row|rudern|dip)", text):
mode = "seconds"
else:
mode = "reps"
has_load = bool(re.search(r"(?:kb|kettlebell|kurzhantel|dumbbell|rucksack|gewicht|last)", text))
bodyweight_mix = bool(re.search(r"(?:optional|wahlweise|ggf\.?|bodyweight|bw|ungewichtet|körpergewicht)", text))
weight_mode = "optional" if has_load and bodyweight_mix else ("required" if has_load else "none")
# Bei generischen Stufen mit „gewichtet“ bleibt Gewicht optional, wenn eine
# ungewichtete Variante im selben Text genannt wird.
if "gewichtet" in text and ("ungewichtet" in text or "bw" in text):
weight_mode = "optional"
unilateral = bool(re.search(r"(?:einarm|einbein|je\s+seite|pro\s+seite|links.?/?rechts|seitenwechsel|side\s+plank|wechsel)", text))
schema = {
"mode": mode,
"weight_mode": weight_mode,
"laterality": "unilateral" if unilateral else "bilateral",
"sides_mode": "separate" if unilateral else "same",
}
# Nicht messbare Checklistenpunkte benötigen nie Gewicht oder Seitenfelder.
if mode == "none":
schema.update({"weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"})
return normalize_result_schema(schema, partial=False) or {
"mode": "auto", "weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"
}
def infer_cluster(name: str, key: str = "") -> tuple[str, str]:
text = f"{key} {name}".lower()
rules = [
(r"liegest|push.?up|\bls\b", "horizontal_push", "Push-up"),
(r"dip", "vertical_push", "Dip"),
(r"pike|overhead|floor press", "vertical_push", "Schulterdrücken"),
(r"pull.?up|klimm|hang", "vertical_pull", "Pull-up / Hang"),
(r"rudern|row", "horizontal_pull", "Rudern"),
(r"squat|kniebeuge|goblet|lunge|ausfallschritt|wall sit", "knee_dominant", "Kniebeuge / Ausfallschritt"),
(r"deadlift|rdl|hinge|swing|glute bridge", "hip_hinge", "Hip Hinge / Hüftstreckung"),
(r"plank|hollow|dead bug|side plank|core", "core", "Core"),
(r"curl", "elbow_flexion", "Bizeps"),
(r"waden|calf", "calf", "Waden"),
(r"halo|around|carry|bottoms", "loaded_mobility", "Kettlebell-Kontrolle"),
]
for pattern, cid, label in rules:
if re.search(pattern, text):
return cid, label
return "general", "Allgemein"
def infer_factor(name: str, cluster_id: str) -> float:
text = str(name or "").lower()
if cluster_id == "horizontal_push":
if "wand" in text: return 0.05
if "hoch" in text and ("incline" in text or "inkline" in text): return 0.10
if "mittel" in text and ("incline" in text or "inkline" in text): return 0.20
if "tief" in text and ("incline" in text or "inkline" in text): return 0.35
if "negativ" in text and ("voll" in text or "erste" in text): return 0.85
if "negativ" in text: return 0.70
if "assist" in text: return 0.55
if "knie" in text: return 0.45
if "teil" in text or "partial" in text: return 0.35
if "voll" in text or "standard" in text or "normal" in text: return 1.00
if cluster_id == "vertical_pull":
if "active hang" in text or "hang" in text: return 0.20
if "scapular" in text: return 0.25
if "negativ" in text: return 0.70
if "leicht assist" in text: return 0.80
if "assist" in text: return 0.55
if "voll" in text or "klimm" in text or "pull-up" in text: return 1.00
if cluster_id == "horizontal_pull":
if "aufrecht" in text or "stark assist" in text: return 0.25
if "bent" in text or "gebeugt" in text: return 0.50
if "straight" in text or "gestreckt" in text: return 1.00
if "füße erhöht" in text: return 1.15
if cluster_id == "knee_dominant":
if "hold" in text: return 1.00
if "chair" in text or "box" in text or "stuhl" in text: return 0.35
if "negativ" in text: return 0.75
if "assist" in text or "festhalten" in text: return 0.55
if "voll" in text or "goblet" in text or "körpergewicht" in text: return 1.00
if cluster_id == "vertical_push":
if "hold" in text: return 0.30
if "assist" in text: return 0.60
if "negativ" in text: return 0.80
if "voll" in text: return 1.00
if cluster_id == "core":
if "knie" in text or "tuck" in text: return 0.65
if "halb" in text: return 0.82
if "voll" in text: return 1.00
return 1.00
def migrate_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
cfg = copy.deepcopy(config or {})
if cfg.get("type") == "recipe":
return cfg
cfg["schema_version"] = PLAN_SCHEMA_VERSION
cfg["contract_version"] = CONTRACT_VERSION
meta = cfg.setdefault("meta", {})
resolved_plan_id = str(cfg.get("plan_id") or plan_id or stable_id("plan", meta.get("title") or "training"))
cfg["plan_id"] = resolved_plan_id
phases = cfg.setdefault("phases", {}).setdefault("items", [])
for index, phase in enumerate(phases):
if not isinstance(phase, dict):
phases[index] = phase = {"name": str(phase or "")}
phase.setdefault("id", stable_id("phase", resolved_plan_id, index, phase.get("name")))
phase_ids = [phase.get("id") for phase in phases]
fmt = cfg.get("training_format") if isinstance(cfg.get("training_format"), dict) else {}
mode = str(fmt.get("mode") or "auto")
if mode not in VALID_TRAINING_MODES:
mode = "auto"
fmt["mode"] = mode
fmt["fixed_interval"] = bool(fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"})
fmt["rounds_scope"] = str(fmt.get("rounds_scope") or "block")
fmt["schema_version"] = 1
cfg["training_format"] = fmt
days = cfg.get("days") if isinstance(cfg.get("days"), list) else []
for di, day in enumerate(days):
if not isinstance(day, dict):
continue
day.setdefault("id", stable_id("day", resolved_plan_id, day.get("num", di + 1), day.get("focus")))
rotations = day.get("rotations") if isinstance(day.get("rotations"), list) else []
for ri, rotation in enumerate(rotations):
if not isinstance(rotation, dict):
continue
rotation.setdefault("id", stable_id("rotation", day["id"], ri, rotation.get("label")))
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
day_num = day.get("num", di + 1)
for ei, exercise in enumerate(exercises):
if not isinstance(exercise, dict):
continue
# Persist the original positional key once so legacy session data can
# still be migrated safely even after the published plan is reordered.
exercise.setdefault("legacy_id", f"d{day_num}-r{ri}-e{ei}")
explicit_progression = str(exercise.get("progression_id") or exercise.get("key") or "").strip()
progression_id = explicit_progression or f"name:{slug(exercise.get('name'))}"
exercise["progression_id"] = progression_id
exercise["key"] = str(exercise.get("key") or progression_id)
exercise.setdefault("id", stable_id("exercise", rotation["id"], progression_id, exercise.get("name"), ei))
exercise.setdefault("exercise_id", stable_id("movement", progression_id, exercise.get("name")))
cid, label = infer_cluster(str(exercise.get("name") or ""), progression_id)
exercise.setdefault("movement_cluster", cid)
exercise.setdefault("movement_label", label)
if exercise.get("result_schema") is not None:
exercise["result_schema"] = normalize_result_schema(exercise.get("result_schema"), partial=False)
else:
exercise["result_schema"] = infer_result_schema(exercise.get("name"), exercise.get("cue"))
cfg["days"] = days
old_stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
new_stages: dict[str, Any] = {}
for key, raw_stage in old_stages.items():
stage = copy.deepcopy(raw_stage) if isinstance(raw_stage, dict) else {"steps": raw_stage if isinstance(raw_stage, list) else []}
stage_id = str(stage.get("id") or stable_id("progression", resolved_plan_id, key, stage.get("name")))
stage["id"] = stage_id
stage["key"] = str(stage.get("key") or key)
raw_steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
steps: list[dict[str, Any]] = []
for index, raw_step in enumerate(raw_steps):
if isinstance(raw_step, dict):
step = copy.deepcopy(raw_step)
step_name = str(step.get("name") or step.get("label") or "")
else:
step_name = str(raw_step or "")
step = {"name": step_name}
step.setdefault("id", stable_id("step", stage_id, index, step_name))
step.setdefault("phase_id", phase_ids[index] if index < len(phase_ids) else "")
schema = step.get("result_schema")
if schema is None and index < len(schemas):
schema = schemas[index]
if schema is not None:
step["result_schema"] = normalize_result_schema(schema, partial=False)
else:
step["result_schema"] = infer_result_schema(step_name, stage.get("name"), stage=True)
cid, _ = infer_cluster(str(stage.get("name") or step_name), key)
step.setdefault("movement_cluster", cid)
step.setdefault("factor", infer_factor(step_name, cid))
steps.append(step)
stage["steps"] = steps
stage.pop("result_schemas", None)
new_stages[str(key)] = stage
cfg["stages"] = new_stages
# Zentrale, planbezogene Übungsbibliothek. Der Tracker nutzt diese vor Regex-Fallbacks.
catalog: dict[str, Any] = {}
for day in days:
for rotation in day.get("rotations", []):
for exercise in rotation.get("exercises", []):
eid = str(exercise.get("exercise_id"))
pid = str(exercise.get("progression_id") or "")
entry = catalog.setdefault(eid, {
"id": eid,
"name": str(exercise.get("name") or ""),
"progression_id": pid,
"movement_cluster": exercise.get("movement_cluster") or "general",
"movement_label": exercise.get("movement_label") or "Allgemein",
"result_schema": copy.deepcopy(exercise.get("result_schema")),
"variants": [],
})
stage = new_stages.get(pid)
if stage:
entry["variants"] = [
{
"id": step.get("id"), "name": step.get("name", ""),
"factor": step.get("factor", 1.0),
"movement_cluster": step.get("movement_cluster") or entry["movement_cluster"],
"result_schema": copy.deepcopy(step.get("result_schema")),
}
for step in stage.get("steps", [])
]
cfg["exercise_catalog"] = catalog
return cfg
def validate_training_config(config: Any) -> dict[str, list[dict[str, Any]]]:
cfg = migrate_training_config(config)
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
ids: dict[str, str] = {}
def register(value: Any, location: str) -> None:
sid = str(value or "")
if not sid:
errors.append({"code": "missing_id", "location": location, "message": "Stabile ID fehlt."})
elif sid in ids:
errors.append({"code": "duplicate_id", "location": location, "message": f"ID wird bereits bei {ids[sid]} verwendet."})
else:
ids[sid] = location
def validate_result_schema(schema: Any, location: str, *, required: bool) -> None:
if not isinstance(schema, dict):
target = errors if required else warnings
target.append({"code": "missing_result_schema", "location": location, "message": "Ergebniserfassung ist nicht eindeutig festgelegt."})
return
mode = str(schema.get("mode") or "auto")
weight = str(schema.get("weight_mode") or "none")
laterality = str(schema.get("laterality") or "bilateral")
sides = str(schema.get("sides_mode") or "same")
if mode == "none" and weight != "none":
errors.append({"code": "invalid_result_schema", "location": location, "message": "Ohne Messwert darf kein Gewicht verlangt werden."})
if laterality == "bilateral" and sides == "separate":
errors.append({"code": "invalid_sides", "location": location, "message": "Getrennte Seitenwerte sind nur bei einseitigen Übungen möglich."})
if laterality == "unilateral" and sides not in {"same", "separate"}:
errors.append({"code": "invalid_sides", "location": location, "message": "Für einseitige Übungen muss die Seitenlogik festgelegt sein."})
try:
sets = int(schema.get("sets") or 0)
except (TypeError, ValueError):
sets = 0
if "sets" in schema and not 1 <= sets <= 40:
errors.append({"code": "invalid_sets", "location": location, "message": "Die feste Anzahl muss zwischen 1 und 40 liegen."})
for pi, phase in enumerate(cfg.get("phases", {}).get("items", [])):
register(phase.get("id"), f"Phase {pi + 1}")
if not str(phase.get("name") or "").strip():
errors.append({"code": "empty_phase", "location": f"Phase {pi + 1}", "message": "Phasenname fehlt."})
for di, day in enumerate(cfg.get("days", [])):
register(day.get("id"), f"Tag {di + 1}")
for ri, rotation in enumerate(day.get("rotations", [])):
register(rotation.get("id"), f"Tag {di + 1}, Rotation {ri + 1}")
exercises = rotation.get("exercises", [])
if not exercises:
warnings.append({"code": "empty_rotation", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": "Rotation enthält keine Übungen."})
for ei, exercise in enumerate(exercises):
location = f"Tag {di + 1}, Rotation {ri + 1}, Übung {ei + 1}"
register(exercise.get("id"), location)
if not str(exercise.get("name") or "").strip():
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
pid = str(exercise.get("progression_id") or "")
if pid and not pid.startswith("name:") and pid not in cfg.get("stages", {}):
warnings.append({"code": "unknown_progression", "location": location, "message": f"Progression „{pid}“ ist nicht definiert."})
validate_result_schema(exercise.get("result_schema"), f"{location}, Ergebniserfassung", required=True)
for key, stage in cfg.get("stages", {}).items():
register(stage.get("id"), f"Progression {key}")
for si, step in enumerate(stage.get("steps", [])):
register(step.get("id"), f"Progression {key}, Stufe {si + 1}")
step_location = f"Progression {key}, Stufe {si + 1}"
if not str(step.get("name") or "").strip():
warnings.append({"code": "empty_step", "location": step_location, "message": "Stufenname ist leer."})
validate_result_schema(step.get("result_schema"), f"{step_location}, Ergebniserfassung", required=False)
try:
factor = float(step.get("factor") or 0)
except (TypeError, ValueError):
factor = 0
if factor <= 0:
errors.append({"code": "invalid_factor", "location": step_location, "message": "Der Analysefaktor muss größer als 0 sein."})
fmt = cfg.get("training_format", {})
if fmt.get("mode") in {"tabata", "fixed_interval"}:
if not fmt.get("work_seconds"):
errors.append({"code": "missing_work_seconds", "location": "Trainingsformat", "message": "Arbeitszeit fehlt."})
if not fmt.get("rounds"):
errors.append({"code": "missing_rounds", "location": "Trainingsformat", "message": "Intervallzahl fehlt."})
rounds = int(fmt.get("rounds") or 0)
for di, day in enumerate(cfg.get("days", [])):
for ri, rotation in enumerate(day.get("rotations", [])):
count = len(rotation.get("exercises", []))
if rounds and count and rounds % count:
warnings.append({"code": "uneven_intervals", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": f"{rounds} Intervalle sind nicht durch {count} Übungen teilbar. Feste Satzanzahl im Ergebnisschema setzen."})
return {"errors": errors, "warnings": warnings, "config": cfg}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json, os, shutil, tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def atomic_json_write(path: str | Path, payload: Any, backup_dir: str | Path | None = None) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
if backup_dir and target.exists():
bdir = Path(backup_dir); bdir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
shutil.copy2(target, bdir / f"{target.stem}_{stamp}{target.suffix}")
fd, tmp = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.flush(); os.fsync(handle.fileno())
os.replace(tmp, target)
finally:
if os.path.exists(tmp): os.unlink(tmp)
@@ -0,0 +1,70 @@
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
from schema_contract import migrate_training_config, validate_training_config
def sample_config():
return {
"meta": {"title": "Test", "weeks": 2},
"training_format": {"mode": "tabata", "fixed_interval": True, "work_seconds": 20, "rest_seconds": 10, "rounds": 8},
"phases": {"items": [{"name": "Start", "weeks": 1}, {"name": "Aufbau", "weeks": 1}]},
"days": [{"num": 1, "focus": "A", "rotations": [{"label": "Block", "exercises": [{
"name": "Squat-Progression", "key": "squat", "result_schema": {"mode": "reps", "weight_mode": "none", "laterality": "bilateral"}
}]}]}],
"stages": {"squat": {"name": "Squat-Progression", "steps": [
"Deep Squat Hold", "Volle Squats"
], "result_schemas": [
{"mode": "seconds", "weight_mode": "optional", "laterality": "bilateral"},
{"mode": "reps", "weight_mode": "optional", "laterality": "bilateral"},
]}},
}
def test_migration_creates_stable_ids_object_steps_and_catalog():
first = migrate_training_config(sample_config(), plan_id="test")
second = migrate_training_config(first, plan_id="test")
assert first["schema_version"] == 3
assert first["contract_version"] == 2
assert first["days"][0]["id"] == second["days"][0]["id"]
exercise = first["days"][0]["rotations"][0]["exercises"][0]
assert exercise["id"] == second["days"][0]["rotations"][0]["exercises"][0]["id"]
steps = first["stages"]["squat"]["steps"]
assert all(isinstance(step, dict) and step.get("id") for step in steps)
assert steps[0]["result_schema"]["mode"] == "seconds"
assert steps[1]["result_schema"]["mode"] == "reps"
assert first["exercise_catalog"]
def test_validation_accepts_complete_plan():
result = validate_training_config(sample_config())
assert result["errors"] == []
def test_missing_result_schemas_are_migrated_to_explicit_defaults():
cfg = sample_config()
exercise = cfg["days"][0]["rotations"][0]["exercises"][0]
exercise.pop("result_schema", None)
cfg["stages"]["squat"].pop("result_schemas", None)
migrated = migrate_training_config(cfg, plan_id="test")
ex_schema = migrated["days"][0]["rotations"][0]["exercises"][0]["result_schema"]
assert ex_schema["mode"] == "reps"
steps = migrated["stages"]["squat"]["steps"]
assert steps[0]["result_schema"]["mode"] == "seconds"
assert steps[1]["result_schema"]["mode"] == "reps"
assert validate_training_config(migrated)["errors"] == []
def test_legacy_position_id_is_persisted_across_reordering():
migrated = migrate_training_config(sample_config(), plan_id="test")
exercise = migrated["days"][0]["rotations"][0]["exercises"][0]
legacy_id = exercise["legacy_id"]
exercise_id = exercise["id"]
migrated["days"][0]["rotations"][0]["exercises"].insert(0, {
"name": "Neue Übung", "key": "new", "result_schema": {"mode": "reps"}
})
remigrated = migrate_training_config(migrated, plan_id="test")
original = next(item for item in remigrated["days"][0]["rotations"][0]["exercises"] if item["id"] == exercise_id)
assert original["legacy_id"] == legacy_id