UI
This commit is contained in:
@@ -21,13 +21,50 @@
|
|||||||
.tp-exercise-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); }
|
.tp-exercise-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); }
|
||||||
.tp-exercise-row:last-child { border-bottom: none; }
|
.tp-exercise-row:last-child { border-bottom: none; }
|
||||||
.tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; }
|
.tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; }
|
||||||
.tp-library { display: grid; gap: 12px; }
|
.tp-field { margin: 0; }
|
||||||
.tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 14px 16px; }
|
.tp-compact-input, .tp-library input, .tp-library select {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: var(--bt-r-sm);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.tp-library { display: grid; gap: 0; }
|
||||||
|
.tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 0; }
|
||||||
.tp-library-row:last-child { border-bottom: none; }
|
.tp-library-row:last-child { border-bottom: none; }
|
||||||
.tp-library-main { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; gap: 8px; align-items: end; }
|
.tp-library-main {
|
||||||
.tp-progressions { display: grid; gap: 8px; margin-top: 10px; }
|
display: grid;
|
||||||
.tp-progression-row { display: grid; grid-template-columns: 34px minmax(180px, 1fr) auto; gap: 8px; align-items: center; }
|
grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto;
|
||||||
.tp-progression-number { color: var(--bt-muted); font-size: 12px; text-align: center; }
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bt-surface-2);
|
||||||
|
border-bottom: 1px solid var(--bt-line-soft);
|
||||||
|
}
|
||||||
|
.tp-library-id { padding: 7px 14px 12px; font-size: 11px; }
|
||||||
|
.tp-progressions { display: grid; gap: 0; margin: 0; }
|
||||||
|
.tp-progressions-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(180px, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px 4px;
|
||||||
|
color: var(--bt-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.tp-progression-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(180px, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-top: 1px solid var(--bt-line-soft);
|
||||||
|
}
|
||||||
|
.tp-progression-number { color: var(--bt-muted); font-family: var(--bt-mono); font-size: 11px; text-align: center; }
|
||||||
|
.tp-progression-input { margin: 0; }
|
||||||
.tp-addline, .tp-formline { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; }
|
.tp-addline, .tp-formline { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; }
|
||||||
.tp-table-input { width: 100%; min-width: 0; }
|
.tp-table-input { width: 100%; min-width: 0; }
|
||||||
.tp-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; }
|
.tp-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; }
|
||||||
@@ -36,7 +73,7 @@
|
|||||||
.tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline, .tp-formline { grid-template-columns: 1fr; }
|
.tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline, .tp-formline { grid-template-columns: 1fr; }
|
||||||
.tp-side { position: static; }
|
.tp-side { position: static; }
|
||||||
.tp-actions, .tp-day-actions { justify-content: flex-start; }
|
.tp-actions, .tp-day-actions { justify-content: flex-start; }
|
||||||
.tp-exercise-row, .tp-library-main, .tp-progression-row { grid-template-columns: 1fr; }
|
.tp-exercise-row, .tp-library-main, .tp-progression-row, .tp-progressions-head { grid-template-columns: 1fr; }
|
||||||
.tp-exercise-actions { justify-content: flex-start; }
|
.tp-exercise-actions { justify-content: flex-start; }
|
||||||
.tp-progression-number { text-align: left; }
|
.tp-progression-number { text-align: left; }
|
||||||
}
|
}
|
||||||
@@ -291,8 +328,8 @@ function renderExercises() {
|
|||||||
const rows = (state.cfg.exercises || []).map((exercise) => `
|
const rows = (state.cfg.exercises || []).map((exercise) => `
|
||||||
<div class="tp-library-row" data-exercise-id="${esc(exercise.id)}">
|
<div class="tp-library-row" data-exercise-id="${esc(exercise.id)}">
|
||||||
<div class="tp-library-main">
|
<div class="tp-library-main">
|
||||||
<label>Name<input class="tp-table-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></label>
|
<label class="tp-field">Name<input class="tp-table-input tp-compact-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></label>
|
||||||
<label>Aktuelle Progression<select data-exercise-field="current_progression_id">
|
<label class="tp-field">Aktuelle Progression<select class="tp-compact-input" data-exercise-field="current_progression_id">
|
||||||
<option value="">Keine Progression</option>
|
<option value="">Keine Progression</option>
|
||||||
${progressionOptions(exercise, exercise.current_progression_id)}
|
${progressionOptions(exercise, exercise.current_progression_id)}
|
||||||
</select></label>
|
</select></label>
|
||||||
@@ -302,10 +339,17 @@ function renderExercises() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tp-progressions">
|
<div class="tp-progressions">
|
||||||
|
${(exercise.progressions || []).length ? `
|
||||||
|
<div class="tp-progressions-head">
|
||||||
|
<span></span>
|
||||||
|
<span>Progressionen</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
${(exercise.progressions || []).map((progression, index) => `
|
${(exercise.progressions || []).map((progression, index) => `
|
||||||
<div class="tp-progression-row" data-progression-id="${esc(progression.id)}">
|
<div class="tp-progression-row" data-progression-id="${esc(progression.id)}">
|
||||||
<div class="tp-progression-number">${index + 1}</div>
|
<div class="tp-progression-number">${index + 1}</div>
|
||||||
<label>Progression<input class="tp-table-input" data-progression-field="name" value="${esc(progression.name || "")}"></label>
|
<input class="tp-table-input tp-compact-input tp-progression-input" aria-label="Progression ${index + 1}" data-progression-field="name" value="${esc(progression.name || "")}">
|
||||||
<div class="tp-exercise-actions">
|
<div class="tp-exercise-actions">
|
||||||
<button class="mini ghost" data-move-progression="-1" title="Nach oben">↑</button>
|
<button class="mini ghost" data-move-progression="-1" title="Nach oben">↑</button>
|
||||||
<button class="mini ghost" data-move-progression="1" title="Nach unten">↓</button>
|
<button class="mini ghost" data-move-progression="1" title="Nach unten">↓</button>
|
||||||
@@ -314,14 +358,14 @@ function renderExercises() {
|
|||||||
</div>
|
</div>
|
||||||
`).join("") || `<div class="bt-empty">Keine Progressionen hinterlegt.</div>`}
|
`).join("") || `<div class="bt-empty">Keine Progressionen hinterlegt.</div>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="bt-mono bt-muted" style="margin-top:8px">${esc(exercise.id)}</div>
|
<div class="tp-library-id bt-mono bt-muted">${esc(exercise.id)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
`).join("");
|
||||||
$("tab-uebungen").innerHTML = `
|
$("tab-uebungen").innerHTML = `
|
||||||
<div class="bt-card">
|
<div class="bt-card">
|
||||||
<h2>Übung anlegen</h2>
|
<h2>Übung anlegen</h2>
|
||||||
<div class="tp-formline">
|
<div class="tp-formline">
|
||||||
<label>Name<input id="newExerciseName" type="text"></label>
|
<label>Name<input id="newExerciseName" class="tp-compact-input" type="text"></label>
|
||||||
<button class="primary" data-add-exercise>Hinzufügen</button>
|
<button class="primary" data-add-exercise>Hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,85 +1,48 @@
|
|||||||
# Trainings-Session-Tracker 2.0
|
# Training 3.0
|
||||||
|
|
||||||
Mobiler Session-Tracker für veröffentlichte Pläne des boehmitools-Plugins `trainingsplan`.
|
Schlanke mobile Ansicht fuer Plaene aus dem Plugin `trainingsplan`.
|
||||||
|
|
||||||
## Datenaufteilung
|
Das Plugin verwaltet keine Sessions mehr. Es gibt keine Wochen, kein Tracking,
|
||||||
|
keine Ergebnisfelder, keine KI-Analyse und keine Vorschlaege. Angezeigt werden
|
||||||
|
nur:
|
||||||
|
|
||||||
|
- Trainingstage
|
||||||
|
- Warm-up
|
||||||
|
- Cool-down
|
||||||
|
- Uebungen des Tages
|
||||||
|
- aktuelle Progression pro Uebung
|
||||||
|
|
||||||
|
## Datenzugriff
|
||||||
|
|
||||||
|
Gelesen und geschrieben werden die Plan-JSON-Dateien aus:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/trainingstracker/
|
data/trainingsplan/plans/
|
||||||
├── 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.
|
Die einzige fachliche Schreiboperation ist:
|
||||||
|
|
||||||
## 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
|
```text
|
||||||
planned → in_progress → stopped/completed
|
PATCH /api/plans/<plan>/exercises/<exercise>/current
|
||||||
stopped → in_progress
|
|
||||||
completed → in_progress
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Übungsstatus:
|
Body:
|
||||||
|
|
||||||
- offen,
|
```json
|
||||||
- erledigt,
|
{
|
||||||
- teilweise,
|
"current_progression_id": "progression-high-plank"
|
||||||
- übersprungen mit optionalem Grund.
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Nicht enthalten sind RIR/RPE, Technikbewertung oder unterschiedliche Gewichte pro Satz.
|
Die Aenderung wird direkt in `config` und `draft` des Plans geschrieben und ist
|
||||||
|
damit die neue Wahrheit fuer App und Editor.
|
||||||
|
|
||||||
## Plan- und progressionsabhängige Ergebnisfelder
|
## Vertrag
|
||||||
|
|
||||||
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.
|
```text
|
||||||
|
plan_schema: 5
|
||||||
|
training_contract: 4
|
||||||
|
```
|
||||||
|
|
||||||
Alte Freitextergebnisse bleiben lesbar. Eindeutige Werte werden in strukturierte Felder übernommen, unklare Angaben nicht erfunden.
|
Uebungen enthalten `progressions` und `current_progression_id`. Tagesuebungen
|
||||||
|
referenzieren nur noch `exercise_id`.
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
+181
-3191
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Plugin-Adapter für den Trainings-Session-Tracker."""
|
"""Plugin-Adapter fuer die schlanke mobile Trainingsansicht."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -10,13 +10,7 @@ from core.loader import load_module
|
|||||||
|
|
||||||
|
|
||||||
def create_app(ctx):
|
def create_app(ctx):
|
||||||
"""Hängt die Flask-App unter /plugins/trainingstracker ein.
|
"""Haengt 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)
|
tracker_data_dir = Path(ctx.data_dir)
|
||||||
plans_dir = tracker_data_dir.parent / "trainingsplan" / "plans"
|
plans_dir = tracker_data_dir.parent / "trainingsplan" / "plans"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Shared constants for the versioned plan/tracker contract."""
|
"""Shared constants for the slim training plan contract."""
|
||||||
PLAN_SCHEMA_VERSION = 3
|
PLAN_SCHEMA_VERSION = 5
|
||||||
CONTRACT_VERSION = 2
|
CONTRACT_VERSION = 4
|
||||||
TRACKER_SCHEMA_VERSION = 7
|
|
||||||
RESULT_DATA_VERSION = 2
|
|
||||||
PROMPT_VERSION = 5
|
|
||||||
ANALYSIS_SCHEMA_VERSION = 3
|
|
||||||
PROGRESSION_NORMALIZATION_VERSION = 5
|
|
||||||
|
|||||||
@@ -1,37 +1,27 @@
|
|||||||
{
|
{
|
||||||
"id": "trainingstracker",
|
"id": "trainingstracker",
|
||||||
"name": "Trainings-Session-Tracker",
|
"name": "Training",
|
||||||
"summary": "Sessions mobil erfassen und planbewusst über Wochen analysieren",
|
"summary": "Schlanke mobile Ansicht für Trainingstage und aktuelle Progressionen",
|
||||||
"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.",
|
"description": "Mobile Planansicht für den aktuellen Trainingsplan. Zeigt Tage, Warm-up, Cool-down und Übungen und schreibt ausschließlich die aktuelle Progression einer Übung zurück in den Plan.",
|
||||||
"icon": "📱",
|
"icon": "📱",
|
||||||
"category": "Planung",
|
"category": "Planung",
|
||||||
"version": "2.0.0",
|
"version": "3.0.0",
|
||||||
"entrypoint": "backend:create_app",
|
"entrypoint": "backend:create_app",
|
||||||
"order": 11,
|
"order": 11,
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"plugin_dependencies": {
|
"plugin_dependencies": {
|
||||||
"trainingsplan": ">=2.0.0"
|
"trainingsplan": ">=3.0.0"
|
||||||
},
|
},
|
||||||
"contract": {
|
"contract": {
|
||||||
"plan_schema": 3,
|
"plan_schema": 5,
|
||||||
"training_contract": 2,
|
"training_contract": 4
|
||||||
"tracker_schema": 7,
|
|
||||||
"result_data": 2
|
|
||||||
},
|
},
|
||||||
"features": [
|
"features": [
|
||||||
"Stabile Plan-, Übungs-, Progressions- und Stufen-IDs",
|
"Trainingstage mobil anzeigen",
|
||||||
"Alte Positionsschlüssel werden automatisch migriert",
|
"Warm-up und Cool-down anzeigen",
|
||||||
"Session-Patches mit Revisionskonfliktschutz",
|
"Übungen eines Tages anzeigen",
|
||||||
"Planrevision wird pro Session gespeichert",
|
"Aktuelle Progression je Übung ändern",
|
||||||
"Erledigt, teilweise und übersprungen mit Grund",
|
"Planwechsel merken"
|
||||||
"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"
|
"docs": "README.md"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# -*- 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
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -27,554 +26,128 @@ def load_module(module_path: Path, data_dir: Path, plans_dir: Path):
|
|||||||
def sample_plan() -> dict:
|
def sample_plan() -> dict:
|
||||||
return {
|
return {
|
||||||
"name": "Testplan",
|
"name": "Testplan",
|
||||||
|
"revision": 4,
|
||||||
|
"published_revision": 4,
|
||||||
"config": {
|
"config": {
|
||||||
"meta": {"title": "Test", "weeks": 2},
|
"schema_version": 5,
|
||||||
"front": {
|
"contract_version": 4,
|
||||||
"session_how_body": "Tabata: 8× (20 Sek Arbeit / 10 Sek Pause).",
|
"plan_id": "testplan",
|
||||||
"timer_note": "20/10 unverändert",
|
"meta": {"title": "Testplan"},
|
||||||
"goals_note": "Stufen sauber steigern",
|
"exercises": [
|
||||||
},
|
{
|
||||||
|
"id": "exercise-push",
|
||||||
|
"name": "Push-up-Progression",
|
||||||
|
"progressions": [
|
||||||
|
{"id": "progression-high-plank", "name": "High Plank"},
|
||||||
|
{"id": "progression-negative", "name": "Negative"},
|
||||||
|
],
|
||||||
|
"current_progression_id": "progression-high-plank",
|
||||||
|
}
|
||||||
|
],
|
||||||
"days": [
|
"days": [
|
||||||
{
|
{
|
||||||
|
"id": "day-1",
|
||||||
"num": 1,
|
"num": 1,
|
||||||
"focus": "Ganzkörper",
|
"name": "Tag 1",
|
||||||
"rotations": [{
|
"warmup": {"items": ["Mobilisieren"]},
|
||||||
"label": "Rotation A — 3 Sätze",
|
"cooldown": {"items": ["Atmen"]},
|
||||||
"exercises": [
|
"exercises": [{"id": "item-1", "exercise_id": "exercise-push"}],
|
||||||
{"name": "Squat", "key": "sq", "cue": "20 s sauber"},
|
}
|
||||||
{"name": "Push-up", "key": "push", "cue": "20 s stabil"},
|
|
||||||
],
|
],
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
|
"draft": {
|
||||||
|
"schema_version": 5,
|
||||||
|
"contract_version": 4,
|
||||||
|
"plan_id": "testplan",
|
||||||
|
"meta": {"title": "Testplan"},
|
||||||
|
"exercises": [
|
||||||
{
|
{
|
||||||
"num": 3,
|
"id": "exercise-push",
|
||||||
"focus": "Ganzkörper B",
|
"name": "Push-up-Progression",
|
||||||
"rotations": [{
|
"progressions": [
|
||||||
"label": "Rotation A — 3 Sätze",
|
{"id": "progression-high-plank", "name": "High Plank"},
|
||||||
"exercises": [
|
{"id": "progression-negative", "name": "Negative"},
|
||||||
{"name": "Squat", "key": "sq", "cue": "20 s sauber"},
|
|
||||||
{"name": "Push-up", "key": "push", "cue": "20 s stabil"},
|
|
||||||
],
|
],
|
||||||
}],
|
"current_progression_id": "progression-high-plank",
|
||||||
},
|
}
|
||||||
|
],
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"id": "day-1",
|
||||||
|
"num": 1,
|
||||||
|
"name": "Tag 1",
|
||||||
|
"warmup": {"items": ["Mobilisieren"]},
|
||||||
|
"cooldown": {"items": ["Atmen"]},
|
||||||
|
"exercises": [{"id": "item-1", "exercise_id": "exercise-push"}],
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"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):
|
def test_lists_only_training_plans_and_returns_slim_detail(tmp_path: Path):
|
||||||
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"
|
plans = tmp_path / "data" / "trainingsplan" / "plans"
|
||||||
tracker_data = tmp_path / "data" / "trainingstracker"
|
data_dir = tmp_path / "data" / "trainingstracker"
|
||||||
plans.mkdir(parents=True)
|
plans.mkdir(parents=True)
|
||||||
training = plans / "training.json"
|
(plans / "training.json").write_text(json.dumps(sample_plan()), encoding="utf-8")
|
||||||
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")
|
(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)
|
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, plans)
|
||||||
client = module.app.test_client()
|
client = module.app.test_client()
|
||||||
result = client.get("/api/plans").get_json()
|
|
||||||
assert [item["id"] for item in result["plans"]] == ["training.json"]
|
listed = client.get("/api/plans").get_json()
|
||||||
|
assert [item["id"] for item in listed["plans"]] == ["training.json"]
|
||||||
|
|
||||||
detail = client.get("/api/plans/training.json").get_json()
|
detail = client.get("/api/plans/training.json").get_json()
|
||||||
assert detail["plan"]["phases"][0]["name"] == "Phase & Technik"
|
cfg = detail["plan"]["config"]
|
||||||
assert detail["plan"]["training_format"]["is_tabata"] is True
|
assert cfg["schema_version"] == 5
|
||||||
tracker = detail["tracker"]
|
assert cfg["contract_version"] == 4
|
||||||
tracker["sessions"] = {"w01-d01": {"status": "completed", "note": "lief gut", "items": {}}}
|
assert cfg["days"][0]["exercises"][0]["exercise"]["current_progression"]["name"] == "High Plank"
|
||||||
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):
|
def test_current_progression_update_writes_config_and_draft(tmp_path: Path):
|
||||||
plans = tmp_path / "data" / "trainingsplan" / "plans"
|
plans = tmp_path / "data" / "trainingsplan" / "plans"
|
||||||
tracker_data = tmp_path / "data" / "trainingstracker"
|
data_dir = tmp_path / "data" / "trainingstracker"
|
||||||
plans.mkdir(parents=True)
|
plans.mkdir(parents=True)
|
||||||
training = plans / "training.json"
|
plan_file = plans / "training.json"
|
||||||
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
|
plan_file.write_text(json.dumps(sample_plan()), encoding="utf-8")
|
||||||
|
|
||||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, plans)
|
||||||
monkeypatch.setenv("OPENAI_MODEL", "test-model")
|
|
||||||
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
|
|
||||||
client = module.app.test_client()
|
client = module.app.test_client()
|
||||||
|
|
||||||
tracker = client.get("/api/plans/training.json").get_json()["tracker"]
|
response = client.patch(
|
||||||
tracker["profile"]["start_date"] = "2026-07-27"
|
"/api/plans/training.json/exercises/exercise-push/current",
|
||||||
tracker["sessions"] = {
|
json={"current_progression_id": "progression-negative"},
|
||||||
"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"))
|
|
||||||
plan = module._normalize_plan(training)
|
|
||||||
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
|
|
||||||
assert "d1-r0-e0" not in raw["sessions"]["w01-d01"]["items"]
|
|
||||||
item = raw["sessions"]["w01-d01"]["items"][stable_id]
|
|
||||||
assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps"
|
|
||||||
assert item["result_data"]["version"] == 2
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_results_are_migrated_server_side_without_hidden_values(tmp_path: Path):
|
|
||||||
plans = tmp_path / "data" / "trainingsplan" / "plans"
|
|
||||||
tracker_data = tmp_path / "data" / "trainingstracker"
|
|
||||||
plans.mkdir(parents=True)
|
|
||||||
training = plans / "training.json"
|
|
||||||
payload = sample_plan()
|
|
||||||
payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = {
|
|
||||||
"mode": "reps", "weight_mode": "required", "laterality": "unilateral",
|
|
||||||
"sides_mode": "separate", "sets": 3,
|
|
||||||
}
|
|
||||||
training.write_text(json.dumps(payload), encoding="utf-8")
|
|
||||||
sessions = tracker_data / "sessions"
|
|
||||||
sessions.mkdir(parents=True)
|
|
||||||
(sessions / "training.json").write_text(json.dumps({
|
|
||||||
"version": 5,
|
|
||||||
"revision": 4,
|
|
||||||
"sessions": {
|
|
||||||
"w01-d01": {
|
|
||||||
"status": "completed",
|
|
||||||
"items": {
|
|
||||||
"d1-r0-e0": {
|
|
||||||
"done": True,
|
|
||||||
"progression": "assistiert",
|
|
||||||
"result": "4kg, 6/6/5 je Seite",
|
|
||||||
"note": "Altwert",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}), encoding="utf-8")
|
|
||||||
|
|
||||||
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
|
|
||||||
client = module.app.test_client()
|
|
||||||
detail = client.get("/api/plans/training.json").get_json()
|
|
||||||
plan = detail["plan"]
|
|
||||||
tracker = detail["tracker"]
|
|
||||||
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
|
|
||||||
item = tracker["sessions"]["w01-d01"]["items"][stable_id]
|
|
||||||
|
|
||||||
assert "d1-r0-e0" not in tracker["sessions"]["w01-d01"]["items"]
|
|
||||||
assert item["result_data"] == {
|
|
||||||
"version": 2,
|
|
||||||
"mode": "reps",
|
|
||||||
"laterality": "unilateral",
|
|
||||||
"sides_mode": "same",
|
|
||||||
"sets": 3,
|
|
||||||
"weight_kg": 4.0,
|
|
||||||
"values": [6.0, 6.0, 5.0],
|
|
||||||
"left_values": [None, None, None],
|
|
||||||
"right_values": [None, None, None],
|
|
||||||
}
|
|
||||||
assert item["result_format"]["sides_mode"] == "same"
|
|
||||||
assert item["result"] == "4 kg · 6/6/5 Reps je Seite"
|
|
||||||
assert tracker["revision"] == 5
|
|
||||||
|
|
||||||
persisted = json.loads((sessions / "training.json").read_text(encoding="utf-8"))
|
|
||||||
assert persisted["sessions"]["w01-d01"]["items"][stable_id]["result_data"]["version"] == 2
|
|
||||||
backup = tracker_data / "sessions" / ".migration-backups" / "training.json.pre-result-data-v2.json"
|
|
||||||
assert json.loads(backup.read_text(encoding="utf-8"))["version"] == 5
|
|
||||||
assert client.get("/api/plans/training.json").get_json()["tracker"]["revision"] == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_mode_override_and_zero_weight_are_preserved(tmp_path: Path):
|
|
||||||
plans = tmp_path / "plans"
|
|
||||||
tracker_data = tmp_path / "tracker"
|
|
||||||
plans.mkdir()
|
|
||||||
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)
|
|
||||||
|
|
||||||
seconds, override = module._parse_legacy_result_data("6x20 s", {
|
|
||||||
"mode": "reps", "weight_mode": "none", "laterality": "bilateral",
|
|
||||||
"sides_mode": "same", "default_sets": 6,
|
|
||||||
})
|
|
||||||
assert seconds["mode"] == "seconds"
|
|
||||||
assert seconds["values"] == [20.0] * 6
|
|
||||||
assert override["mode"] == "seconds"
|
|
||||||
|
|
||||||
weighted, override = module._parse_legacy_result_data("0kg, 8/7", {
|
|
||||||
"mode": "reps", "weight_mode": "required", "laterality": "bilateral",
|
|
||||||
"sides_mode": "same", "default_sets": 2,
|
|
||||||
})
|
|
||||||
assert weighted["weight_kg"] == 0.0
|
|
||||||
assert weighted["values"] == [8.0, 7.0]
|
|
||||||
assert override is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_stable_placeholder_does_not_erase_completed_legacy_item(tmp_path: Path):
|
|
||||||
plans = tmp_path / "plans"
|
|
||||||
tracker_data = tmp_path / "tracker"
|
|
||||||
plans.mkdir()
|
|
||||||
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)
|
|
||||||
|
|
||||||
merged = module._merge_migrated_item(
|
|
||||||
{"done": True, "completion_status": "completed", "result": "8/7", "note": "wichtig"},
|
|
||||||
{"done": False, "completion_status": "planned", "result": "", "note": "", "exercise_name": "Squat"},
|
|
||||||
)
|
)
|
||||||
assert merged["done"] is True
|
|
||||||
assert merged["completion_status"] == "completed"
|
assert response.status_code == 200
|
||||||
assert merged["result"] == "8/7"
|
payload = response.get_json()
|
||||||
assert merged["note"] == "wichtig"
|
exercise = payload["plan"]["config"]["exercises"][0]
|
||||||
assert merged["exercise_name"] == "Squat"
|
assert exercise["current_progression_id"] == "progression-negative"
|
||||||
|
|
||||||
|
saved = json.loads(plan_file.read_text(encoding="utf-8"))
|
||||||
|
assert saved["config"]["exercises"][0]["current_progression_id"] == "progression-negative"
|
||||||
|
assert saved["draft"]["exercises"][0]["current_progression_id"] == "progression-negative"
|
||||||
|
assert saved["revision"] == 5
|
||||||
|
assert saved["published_revision"] == 5
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_hotfix_uses_result_data_v2_and_non_overlapping_status_layout():
|
def test_frontend_no_longer_contains_session_or_analysis_surfaces():
|
||||||
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
|
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
assert "const RESULT_DATA_VERSION = 2" in source
|
assert "data-current-progression" in source
|
||||||
assert "grid-template-columns: minmax(112px,132px) minmax(0,1fr)" in source
|
assert "analysis/status" not in source
|
||||||
assert "@media (max-width: 420px)" in source
|
assert "sessionView" not in source
|
||||||
assert ".exercise-title { grid-template-columns: minmax(0,1fr); }" in source
|
assert "week" not in source.lower()
|
||||||
assert ".app { width: min(1180px, 100%)" in source
|
|
||||||
assert "formatOverride" in source
|
|
||||||
|
def test_frontend_embeds_plugin_mount_path(tmp_path: Path):
|
||||||
|
plans = tmp_path / "data" / "trainingsplan" / "plans"
|
||||||
|
data_dir = tmp_path / "data" / "trainingstracker"
|
||||||
|
plans.mkdir(parents=True)
|
||||||
|
(plans / "training.json").write_text(json.dumps(sample_plan()), encoding="utf-8")
|
||||||
|
|
||||||
|
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, plans)
|
||||||
|
client = module.app.test_client()
|
||||||
|
|
||||||
|
html = client.get("/", environ_overrides={"SCRIPT_NAME": "/plugins/trainingstracker"}).get_data(as_text=True)
|
||||||
|
assert 'data-base="/plugins/trainingstracker"' in html
|
||||||
|
|||||||
@@ -6,49 +6,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
|
||||||
from analysis_store import cleanup_legacy_files, current_filename
|
from contract import CONTRACT_VERSION, PLAN_SCHEMA_VERSION
|
||||||
from session_store import merge_session_patch
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_patch_preserves_other_sessions_and_detects_conflicts():
|
def test_manifest_matches_slim_training_contract():
|
||||||
existing = {
|
manifest = json.loads((Path(__file__).parents[1] / "plugin.json").read_text(encoding="utf-8"))
|
||||||
"revision": 4,
|
|
||||||
"profile": {"start_date": "2026-01-01"},
|
assert manifest["contract"] == {
|
||||||
"sessions": {"w01-d01": {"status": "completed"}, "w01-d02": {"status": "planned"}},
|
"plan_schema": PLAN_SCHEMA_VERSION,
|
||||||
|
"training_contract": CONTRACT_VERSION,
|
||||||
}
|
}
|
||||||
merged = merge_session_patch(existing, {
|
assert "tracker_schema" not in manifest["contract"]
|
||||||
"expected_revision": 4,
|
assert "KI" not in manifest["description"]
|
||||||
"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