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
+85
View File
@@ -0,0 +1,85 @@
# Trainings-Session-Tracker 2.0
Mobiler Session-Tracker für veröffentlichte Pläne des boehmitools-Plugins `trainingsplan`.
## Datenaufteilung
```text
data/trainingstracker/
├── sessions/<Plan-Dateiname>.json
├── analyses/<Plan-Dateiname>/
│ ├── index.json
│ ├── state.json
│ ├── week-01.json
│ ├── week-02.json
│ └── overall.json
└── proposals/<Plan-Dateiname>.json
```
Die Sessiondatei enthält ausschließlich Profil-, Wochenstatus- und Sessiondaten. Prompts, KI-Antworten und Jobstatus liegen vollständig getrennt.
## Genau eine Analyse pro Bereich
Für jede Woche gibt es höchstens eine Datei `week-NN.json`, für den Gesamtplan genau eine `overall.json`. Eine neue Auswertung überschreibt die vorherige Datei. Es gibt keine Analysehistorie und keine Versionierung von KI-Auswertungen. Ältere Zeitstempeldateien werden beim ersten Lesen auf dieses Modell reduziert; erhalten bleibt nur die zuvor als aktuell markierte Auswertung.
Unveränderte Daten werden über einen Hash erkannt und lösen keinen neuen OpenAI-Aufruf aus. Der Hash berücksichtigt unter anderem:
- veröffentlichte Planrevision,
- Plan- und Vertragsversion,
- Prompt- und Antwortschemaversion,
- Modell,
- Normalisierungsversion,
- Sessiondaten und Wochenstatus.
## Sessiondaten und Konfliktschutz
Der Browser speichert nur die aktuell bearbeitete Session als Patch. Eine Revisionsnummer verhindert, dass ein älterer Browser-Tab neuere Daten überschreibt.
Jede Session merkt `plan_id` und `plan_revision`. Alte positionsbasierte Einträge wie `d1-r0-e0` werden beim Öffnen automatisch den neuen stabilen Übungs-IDs zugeordnet und beim nächsten Speichern migriert.
Sessionstatus:
```text
planned → in_progress → stopped/completed
stopped → in_progress
completed → in_progress
```
Übungsstatus:
- offen,
- erledigt,
- teilweise,
- übersprungen mit optionalem Grund.
Nicht enthalten sind RIR/RPE, Technikbewertung oder unterschiedliche Gewichte pro Satz.
## Plan- und progressionsabhängige Ergebnisfelder
Der Tracker liest das Ergebnisformat zunächst aus der gewählten Progressionsstufe, danach aus der Übung. So kann eine Squat-Progression zunächst Sekunden mit optionalem Gewicht und später Wiederholungen oder Gewicht plus Wiederholungen verlangen.
Alte Freitextergebnisse bleiben lesbar. Eindeutige Werte werden in strukturierte Felder übernommen, unklare Angaben nicht erfunden.
## Wochenabschluss
Eine Woche kann ausdrücklich als laufend oder abgeschlossen markiert werden. Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse. Wird der Wochenstatus oder eine Session geändert, gilt die bestehende Analyse als veraltet. Die nächste manuelle Auswertung überschreibt sie.
## Progressionsanalyse
Wochen werden einzeln analysiert. Die Gesamtanalyse verwendet nur aktuelle Wochenzusammenfassungen und lokale Aggregate, nicht erneut sämtliche Rohsessions.
Der veröffentlichte Plan ist bindend. Bei Tabata bleiben Arbeitszeit, Pause, Rundenzahl und Satzlogik unverändert. Die KI erhält lokal berechnete Variantencluster und soll deren Faktoren nicht selbst neu erfinden.
Planvorschläge werden strukturiert mit `exercise_id`, `progression_id` und `step_id` nach `data/trainingstracker/proposals/` geschrieben. Der Planeditor zeigt sie als Prüfpostfach an und verändert den Plan niemals automatisch.
## Robuste Analysejobs
Der Jobstatus wird vor dem API-Aufruf gespeichert. Eine Prozess- und Dateisperre verhindert parallele Jobs für denselben Plan. Ein Heartbeat verlängert die Job-Lease. Nach einem Prozessabbruch läuft die Sperre zeitnah ab, auch nach einem Browser-Reload oder Containerneustart.
## Übungsbibliothek und FAQ
Die planbezogene `exercise_catalog` ist die primäre Quelle für Bewegungscluster, Varianten und Faktoren. Die FAQ zeigt diese Planbibliothek sowie die transparenten Fallback-Tabellen. Dynamische Übungen werden als Referenz-Reps, Holds als Referenzsekunden und externe Lasten als kg·Reps beziehungsweise kg·s ausgewertet.
## Oberflächenzustand
Der letzte Tab, die Woche, der Tag und die ausgewählte Analyse bleiben pro Trainingsplan im Browser erhalten.
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
def current_filename(record: dict[str, Any]) -> str:
if record.get("type") == "week":
return f"week-{int(record.get('week') or 0):02d}.json"
return "overall.json"
def cleanup_legacy_files(directory: Path, record: dict[str, Any], keep: str) -> None:
if record.get("type") == "week":
week = int(record.get("week") or 0)
pattern = re.compile(rf"^week[-_]?0?{week}(?:[-_].*)?\.json$", re.I)
else:
pattern = re.compile(r"^overall(?:[-_].*)?\.json$", re.I)
for path in directory.glob("*.json"):
if path.name in {"index.json", "state.json", keep}: continue
if pattern.match(path.name):
try: path.unlink()
except OSError: pass
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""Plugin-Adapter für den Trainings-Session-Tracker."""
from __future__ import annotations
import os
from pathlib import Path
from a2wsgi import WSGIMiddleware
from core.loader import load_module
def create_app(ctx):
"""Hängt die Flask-App unter /plugins/trainingstracker ein.
Es werden ausschließlich Pfade relativ zum boehmitools-Datenordner benutzt:
data/trainingsplan/plans (nur lesen)
data/trainingstracker/sessions (Sessiondaten schreiben)
data/trainingstracker/analyses (Prompts, Antworten, Cache und Jobstatus)
"""
tracker_data_dir = Path(ctx.data_dir)
plans_dir = tracker_data_dir.parent / "trainingsplan" / "plans"
previous_data = os.environ.get("TRAININGSTRACKER_DATA_DIR")
previous_plans = os.environ.get("TRAININGSTRACKER_PLANS_DIR")
os.environ["TRAININGSTRACKER_DATA_DIR"] = str(tracker_data_dir)
os.environ["TRAININGSTRACKER_PLANS_DIR"] = str(plans_dir)
try:
module = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app")
finally:
if previous_data is None:
os.environ.pop("TRAININGSTRACKER_DATA_DIR", None)
else:
os.environ["TRAININGSTRACKER_DATA_DIR"] = previous_data
if previous_plans is None:
os.environ.pop("TRAININGSTRACKER_PLANS_DIR", None)
else:
os.environ["TRAININGSTRACKER_PLANS_DIR"] = previous_plans
return WSGIMiddleware(module.app)
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""Shared constants for the versioned plan/tracker contract."""
PLAN_SCHEMA_VERSION = 3
CONTRACT_VERSION = 2
TRACKER_SCHEMA_VERSION = 7
RESULT_DATA_VERSION = 2
PROMPT_VERSION = 5
ANALYSIS_SCHEMA_VERSION = 3
PROGRESSION_NORMALIZATION_VERSION = 5
+37
View File
@@ -0,0 +1,37 @@
{
"id": "trainingstracker",
"name": "Trainings-Session-Tracker",
"summary": "Sessions mobil erfassen und planbewusst über Wochen analysieren",
"description": "Mobiler Tracker für veröffentlichte Trainingspläne mit stabilen Übungs-IDs, progressionsabhängigen Ergebnisfeldern, Session-Patches, Wochenabschluss, Variantenclustern und genau einer überschreibbaren KI-Analyse pro Woche beziehungsweise Gesamtplan.",
"icon": "📱",
"category": "Planung",
"version": "2.0.0",
"entrypoint": "backend:create_app",
"order": 11,
"requires": [],
"plugin_dependencies": {
"trainingsplan": ">=2.0.0"
},
"contract": {
"plan_schema": 3,
"training_contract": 2,
"tracker_schema": 7,
"result_data": 2
},
"features": [
"Stabile Plan-, Übungs-, Progressions- und Stufen-IDs",
"Alte Positionsschlüssel werden automatisch migriert",
"Session-Patches mit Revisionskonfliktschutz",
"Planrevision wird pro Session gespeichert",
"Erledigt, teilweise und übersprungen mit Grund",
"Expliziter Wochenabschluss mit Zwischen- oder Abschlussanalyse",
"Genau eine Wochenanalyse je Woche und eine Gesamtanalyse, jeweils überschreibbar",
"Keine Analysehistorie",
"Persistente Analysejobs mit Dateisperre, Heartbeat und Lease",
"Planbezogene Übungsbibliothek und Variantencluster",
"Strukturierte KI-Vorschläge für den Planeditor",
"Letzter Tab, Woche, Tag und Auswahl bleiben erhalten",
"Trainingsplan bleibt read-only"
],
"docs": "README.md"
}
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from copy import deepcopy
from typing import Any
VALID_SESSION_STATUSES = {"planned", "in_progress", "stopped", "completed"}
VALID_ITEM_STATUSES = {"planned", "completed", "partial", "skipped"}
def merge_session_patch(existing: dict[str, Any], payload: dict[str, Any], now: str) -> dict[str, Any]:
current_revision = int(existing.get("revision") or 1)
expected = payload.get("expected_revision")
if expected is not None and int(expected) != current_revision:
raise RuntimeError(f"revision_conflict:{current_revision}")
result = deepcopy(existing)
profile = payload.get("profile")
if isinstance(profile, dict):
result["profile"] = deepcopy(profile)
week_statuses = payload.get("week_statuses")
if isinstance(week_statuses, dict):
result["week_statuses"] = deepcopy(week_statuses)
key = str(payload.get("session_key") or "")
session = payload.get("session")
if key and isinstance(session, dict):
result.setdefault("sessions", {})[key] = deepcopy(session)
result["revision"] = current_revision + 1
result["updated_at"] = now
return result
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
from __future__ import annotations
import importlib.util
import json
import os
import time
import uuid
from pathlib import Path
def load_module(module_path: Path, data_dir: Path, plans_dir: Path):
try:
import flask # noqa: F401
except ModuleNotFoundError:
import pytest
pytest.skip("Flask ist in dieser isolierten Testumgebung nicht installiert")
os.environ["TRAININGSTRACKER_DATA_DIR"] = str(data_dir)
os.environ["TRAININGSTRACKER_PLANS_DIR"] = str(plans_dir)
name = f"tracker_test_app_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(name, module_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module
def sample_plan() -> dict:
return {
"name": "Testplan",
"config": {
"meta": {"title": "Test", "weeks": 2},
"front": {
"session_how_body": "Tabata: 8× (20 Sek Arbeit / 10 Sek Pause).",
"timer_note": "20/10 unverändert",
"goals_note": "Stufen sauber steigern",
},
"days": [
{
"num": 1,
"focus": "Ganzkörper",
"rotations": [{
"label": "Rotation A — 3 Sätze",
"exercises": [
{"name": "Squat", "key": "sq", "cue": "20 s sauber"},
{"name": "Push-up", "key": "push", "cue": "20 s stabil"},
],
}],
},
{
"num": 3,
"focus": "Ganzkörper B",
"rotations": [{
"label": "Rotation A — 3 Sätze",
"exercises": [
{"name": "Squat", "key": "sq", "cue": "20 s sauber"},
{"name": "Push-up", "key": "push", "cue": "20 s stabil"},
],
}],
},
],
"prepost": {
"1": {"warmup": "Kreisen · Squat", "cooldown": "Gehen", "stretch": "Wade"},
"3": {"warmup": "Kreisen", "cooldown": "Gehen", "stretch": "Brust"},
},
"phases": {"items": [{"name": "Phase &amp; Technik", "params": "Sauber &amp; kontrolliert", "weeks": 2}]},
"stages": {
"sq": {"name": "Squat", "steps": ["assistiert", "voll", "Goblet schwerer"]},
"push": {"name": "Push-up", "steps": ["erhöht", "voll"]},
},
},
}
def wait_for_analysis(client, plan_id: str, timeout: float = 3.0):
deadline = time.time() + timeout
while time.time() < deadline:
payload = client.get(f"/api/plans/{plan_id}/analysis/status").get_json()
if payload["analysis_state"]["status"] != "running":
return payload
time.sleep(0.02)
raise AssertionError("Analyse blieb im Test zu lange aktiv")
def test_source_is_read_only_and_session_file_strips_all_analysis_fields(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
original = training.read_bytes()
(plans / "food.json").write_text(json.dumps({"name": "Essen", "config": {"type": "recipe", "days": []}}), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
result = client.get("/api/plans").get_json()
assert [item["id"] for item in result["plans"]] == ["training.json"]
detail = client.get("/api/plans/training.json").get_json()
assert detail["plan"]["phases"][0]["name"] == "Phase & Technik"
assert detail["plan"]["training_format"]["is_tabata"] is True
tracker = detail["tracker"]
tracker["sessions"] = {"w01-d01": {"status": "completed", "note": "lief gut", "items": {}}}
tracker["analysis_cache"] = {"weeks": {"1": {"bad": True}}}
tracker["analysis_state"] = {"status": "running"}
tracker["analyses"] = [{"legacy": True}]
assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200
raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8"))
assert raw["version"] == 7
assert "analysis_cache" not in raw
assert "analysis_state" not in raw
assert "analyses" not in raw
assert raw["sessions"]["w01-d01"]["note"] == "lief gut"
assert training.read_bytes() == original
def test_week_archive_contains_prompt_dataset_response_and_cache_uses_index(tmp_path: Path, monkeypatch):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
monkeypatch.setenv("OPENAI_MODEL", "test-model")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
tracker = client.get("/api/plans/training.json").get_json()["tracker"]
tracker["profile"]["start_date"] = "2026-07-27"
tracker["sessions"] = {
"w01-d01": {"status": "completed", "note": "Woche eins", "items": {
"d1-r0-e0": {"done": True, "progression_id": "sq", "exercise_name": "Squat", "progression": "assistiert", "result": "8 / 7", "note": "sauber"}
}},
"w02-d01": {"status": "completed", "note": "Woche zwei", "items": {
"d1-r0-e0": {"done": True, "progression_id": "sq", "exercise_name": "Squat", "progression": "voll", "result": "8 / 8", "note": "gut"}
}},
}
assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200
datasets = []
def fake_call(messages, model):
dataset = json.loads(messages[1]["content"].split("Analyse-Datensatz:\n", 1)[1])
datasets.append(dataset)
return {
"headline": "Kompakt", "summary": "Kurze Analyse.", "data_quality": "Ausreichend.",
"metrics": [{"label": "Sessions", "value": "1", "detail": "Woche"}],
"exercise_updates": [{
"name": "Squat", "trend": "up", "current_level": "voll", "evidence": "8 / 8",
"next_action": "Haltezeit auf 30 Sekunden verlängern", "criterion": "sauber",
}],
"plan_adjustments": [{
"target": "Squat", "suggested_change": "Arbeitsintervall auf 30 Sekunden erhöhen",
"reason": "mehr Belastung", "manual_step": "Timer ändern", "condition": "wenn sauber",
}],
"warnings": [], "conclusion": "Weiter.",
}
module._call_ai = fake_call
started = client.post("/api/plans/training.json/analysis", json={"scope": "week", "week": 1})
assert started.status_code == 202
status = wait_for_analysis(client, "training.json")
assert status["analysis_state"]["status"] == "done"
assert len(datasets) == 1
week_record = status["analysis_cache"]["weeks"]["1"]
assert "unverändert" in week_record["result"]["exercise_updates"][0]["next_action"]
plan_dir = tracker_data / "analyses" / "training.json"
archive_files = list(plan_dir.glob("week-01.json"))
assert len(archive_files) == 1
archive = json.loads(archive_files[0].read_text(encoding="utf-8"))
assert archive["request"]["scope"] == "week"
assert archive["request"]["dataset"]["analysis_type"] == "week"
assert archive["request"]["messages"]
assert archive["response"]["headline"] == "Kompakt"
index = json.loads((plan_dir / "index.json").read_text(encoding="utf-8"))
assert index["latest"]["weeks"]["1"] == archive_files[0].name
assert "history" not in index
assert (plan_dir / "state.json").exists()
session_raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8"))
assert "analysis_cache" not in session_raw and "analysis_state" not in session_raw
cached = client.post("/api/plans/training.json/analysis", json={"scope": "week", "week": 1})
assert cached.status_code == 200
assert cached.get_json()["cached"] is True
assert len(datasets) == 1
overall_start = client.post("/api/plans/training.json/analysis", json={"scope": "overall"})
assert overall_start.status_code == 202
final = wait_for_analysis(client, "training.json")
assert final["analysis_state"]["status"] == "done"
assert len(datasets) == 3
overall_dataset = datasets[-1]
assert overall_dataset["analysis_type"] == "overall"
assert "weekly_analyses" in overall_dataset
assert "sessions" not in overall_dataset
assert len(list(plan_dir.glob("overall.json"))) == 1
def test_frontend_persists_navigation_and_polls_running_job():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "localStorage.setItem" in source
assert "analysisSelection" in source
assert "analysis/status" in source
assert "state.tracker.analysis_state.status === 'running'" in source
def test_stopped_session_is_valid_and_included_in_analysis_records(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
tracker = client.get("/api/plans/training.json").get_json()["tracker"]
tracker["sessions"] = {
"w01-d01": {
"status": "stopped",
"started_at": "2026-07-27T08:00:00+00:00",
"stopped_at": "2026-07-27T08:20:00+00:00",
"completed_at": "",
"items": {},
"note": "Vorzeitig beendet",
}
}
assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200
plan = module._normalize_plan(training)
saved = module._load_tracker(plan)
records = module._session_records(plan, saved)
assert records[0]["status"] == "stopped"
assert records[0]["stopped_at"] == "2026-07-27T08:20:00+00:00"
assert module._local_metrics(plan, records, week=1)["completed_sessions"] == 0
def test_frontend_supports_stop_continue_and_confirmed_reset():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert 'id="stopSession"' in source
assert 'id="resetSession"' in source
assert "session.status = 'stopped'" in source
assert "Session fortsetzen" in source
assert "window.confirm" in source
assert "state.tracker.sessions[sessionKey(state.week, state.dayNum)] = blankSession()" in source
def test_variant_clusters_create_reference_reps_and_reference_seconds(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
plan = module._normalize_plan(training)
records = [
{"week": 1, "day": 1, "session_key": "w01-d01", "items": [
{"exercise": "Push-up", "progression_id": "push", "progression": "Inkline (hoch)", "result": "10"},
{"exercise": "Plank", "progression_id": "plank", "progression": "auf Knien 1520 s", "result": "6x20 s"},
]},
{"week": 1, "day": 3, "session_key": "w01-d03", "items": [
{"exercise": "Push-up", "progression_id": "push", "progression": "Inkline mittel", "result": "5"},
{"exercise": "Plank", "progression_id": "plank", "progression": "voll 2030 s", "result": "3x20 s"},
]},
{"week": 2, "day": 1, "session_key": "w02-d01", "items": [
{"exercise": "Push-up", "progression_id": "push", "progression": "volle Push-ups", "result": "1"},
]},
]
series = module._cluster_progression_series(plan, records, limit=20)
push = next(row for row in series if row["id"] == "push_up")
assert [point["value"] for point in push["points"]] == [1.0, 1.0, 1.0]
plank = next(row for row in series if row["id"] == "plank")
assert plank["unit"] == "Referenzsekunden"
assert plank["points"][0]["value"] == 78.0 # 6×20 s × 0,65
assert plank["points"][1]["value"] == 60.0 # 3×20 s × 1,00
def test_equivalence_guide_and_mobile_faq_are_exposed(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
plan = module._normalize_plan(training)
guide = module._public_equivalence_guide(plan)
push = next(table for table in guide["tables"] if table["id"] == "push_up")
examples = {row["label"]: row["example"] for row in push["variants"]}
assert examples["Incline hoch, ca. 60 cm"].startswith("10 Reps")
assert examples["Incline mittel"].startswith("5 Reps")
assert examples["Voller Push-up"].startswith("1 Rep")
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert 'data-view="faq"' in source
assert 'id="faqView"' in source
assert "renderFAQ()" in source
assert "grid-template-columns: minmax(0,1fr) auto" in source
assert ".history-item button { width: 100%; min-width: 0;" in source
assert "overflow-x: hidden" in source
def test_plan_infers_structured_result_schemas(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
plan_payload = sample_plan()
plan_payload["config"]["days"][0]["rotations"][0]["exercises"] = [
{"name": "KB Floor Press", "key": "press", "cue": "sauber drücken"},
{"name": "Side Plank (Wechsel)", "key": "side", "cue": "links / rechts halten"},
{"name": "Einarm-Rudern (KB)", "key": "row", "cue": "DG1 links / DG2 rechts"},
{"name": "Regeneration prüfen", "key": "", "cue": "Schlaf und Gelenke prüfen"},
]
plan_payload["config"]["stages"].update({
"press": {"name": "Press", "steps": ["leicht", "schwerer"]},
"side": {"name": "Side Plank", "steps": ["auf Knien", "voll, länger"]},
"row": {"name": "Row", "steps": ["leicht", "mehr"]},
})
training = plans / "training.json"
training.write_text(json.dumps(plan_payload), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
plan = module._normalize_plan(training)
exercises = {exercise["name"]: exercise for exercise in plan["days"][0]["rotations"][0]["exercises"]}
press = exercises["KB Floor Press"]["result_schema"]
assert press["mode"] == "reps" and press["weight_mode"] == "required"
assert press["laterality"] == "bilateral" and press["locked_sets"] is True
side = exercises["Side Plank (Wechsel)"]["result_schema"]
assert side["mode"] == "seconds" and side["laterality"] == "unilateral"
assert side["sides_mode"] == "separate"
row = exercises["Einarm-Rudern (KB)"]["result_schema"]
assert row["mode"] == "reps" and row["weight_mode"] == "required"
assert row["laterality"] == "unilateral" and row["sides_mode"] == "separate"
recovery = exercises["Regeneration prüfen"]["result_schema"]
assert recovery["mode"] == "none"
def test_structured_result_is_saved_canonically_and_used_by_analysis(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
tracker = client.get("/api/plans/training.json").get_json()["tracker"]
tracker["sessions"] = {
"w01-d01": {
"status": "completed",
"items": {
"d1-r0-e0": {
"done": True,
"exercise_name": "Einarm-Rudern (KB)",
"progression_id": "row",
"progression": "leicht",
"result": "dieser Alttext wird ersetzt",
"result_data": {
"version": 1,
"mode": "reps",
"laterality": "unilateral",
"sides_mode": "separate",
"sets": 3,
"weight_kg": "4,0",
"values": [],
"left_values": [9, 8, 8],
"right_values": [9, 8, 9],
},
}
},
}
}
assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200
raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8"))
item = raw["sessions"]["w01-d01"]["items"]["d1-r0-e0"]
assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps"
assert item["result_data"]["weight_kg"] == 4.0
assert item["result_data"]["left_values"] == [9.0, 8.0, 8.0]
measure = module._extract_training_measure(item)
assert measure["mode"] == "reps"
assert measure["total"] == 51.0
assert measure["weight_kg"] == 4.0
assert measure["structured"] is True
def test_frontend_uses_exercise_specific_result_editor_and_legacy_fallback():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "Ergebnis heute" in source
assert "data-result-value" in source
assert "data-result-weight" in source
assert "data-result-sides" in source
assert "data-result-weight-mode" in source
assert "parseLegacyResultData" in source
assert "result_data" in source
assert "Bitte mindestens einen Satz bzw. ein Intervall eintragen" in source
assert "data-item-result" not in source
def test_explicit_training_format_and_stage_result_schemas(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
payload = sample_plan()
payload["config"]["training_format"] = {
"mode": "tabata", "fixed_interval": True,
"work_seconds": 20, "rest_seconds": 10, "rounds": 8,
}
payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = {
"mode": "auto", "weight_mode": "optional", "laterality": "bilateral"
}
payload["config"]["stages"]["sq"] = {
"name": "Squat-Progression",
"steps": ["Deep Squat Hold", "Negative Squats", "Goblet Squat"],
"result_schemas": [
{"mode": "seconds", "weight_mode": "optional", "laterality": "bilateral"},
{"mode": "reps", "weight_mode": "none", "laterality": "bilateral"},
{"mode": "reps", "weight_mode": "required", "laterality": "bilateral"},
],
}
training = plans / "training.json"
training.write_text(json.dumps(payload), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
plan = module._normalize_plan(training)
assert plan["training_format"]["source"] == "plan"
assert plan["training_format"]["mode"] == "tabata"
assert plan["training_format"]["rounds"] == 8
assert plan["training_format"]["work_seconds"] == 20
assert plan["stages"]["sq"]["result_schemas"][0]["mode"] == "seconds"
assert plan["stages"]["sq"]["result_schemas"][1]["mode"] == "reps"
assert plan["stages"]["sq"]["result_schemas"][2]["weight_mode"] == "required"
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "progressionResultSchema" in source
assert "durch Progressionsstufe vorgegeben" in source
def test_analysis_store_uses_one_deterministic_file_per_scope(tmp_path: Path):
import sys
sys.path.insert(0, str(Path(__file__).parents[1]))
from analysis_store import current_filename
assert current_filename({"type": "week", "week": 3}) == "week-03.json"
assert current_filename({"type": "overall"}) == "overall.json"
def test_frontend_contains_explicit_week_status_and_item_skip_states():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "Woche abschließen" in source
assert "Woche wieder öffnen" in source
assert "Übersprungen" in source
assert "Teilweise" in source
assert "eine neue analyse überschreibt die vorherige" in source.lower()
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
from analysis_store import cleanup_legacy_files, current_filename
from session_store import merge_session_patch
def test_session_patch_preserves_other_sessions_and_detects_conflicts():
existing = {
"revision": 4,
"profile": {"start_date": "2026-01-01"},
"sessions": {"w01-d01": {"status": "completed"}, "w01-d02": {"status": "planned"}},
}
merged = merge_session_patch(existing, {
"expected_revision": 4,
"session_key": "w01-d02",
"session": {"status": "stopped"},
}, "2026-01-02T00:00:00Z")
assert merged["revision"] == 5
assert merged["sessions"]["w01-d01"]["status"] == "completed"
assert merged["sessions"]["w01-d02"]["status"] == "stopped"
try:
merge_session_patch(merged, {"expected_revision": 4}, "2026-01-02T00:00:01Z")
except RuntimeError as exc:
assert str(exc) == "revision_conflict:5"
else:
raise AssertionError("Revisionskonflikt wurde nicht erkannt")
def test_analysis_cleanup_keeps_exactly_current_scope(tmp_path: Path):
records = [
"week-01_20260101T100000Z.json",
"week-01_20260102T100000Z.json",
"week-02_20260101T100000Z.json",
"overall_20260101T100000Z.json",
"index.json",
"state.json",
]
for name in records:
(tmp_path / name).write_text(json.dumps({"name": name}), encoding="utf-8")
keep = current_filename({"type": "week", "week": 1})
(tmp_path / keep).write_text("{}", encoding="utf-8")
cleanup_legacy_files(tmp_path, {"type": "week", "week": 1}, keep)
names = {path.name for path in tmp_path.glob("*.json")}
assert keep in names
assert "week-02_20260101T100000Z.json" in names
assert "overall_20260101T100000Z.json" in names
assert "week-01_20260101T100000Z.json" not in names
assert "week-01_20260102T100000Z.json" not in names