chore: initial import
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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 & Technik", "params": "Sauber & 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 15–20 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 20–30 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
|
||||
Reference in New Issue
Block a user