chore: initial import
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
backups/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle/
|
||||||
|
android/.idea/
|
||||||
|
android/local.properties
|
||||||
|
android/**/build/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# boehmitools-training
|
||||||
|
|
||||||
|
Gemeinsame Entwicklungseinheit für:
|
||||||
|
|
||||||
|
- plugins/trainingsplan
|
||||||
|
- plugins/trainingstracker
|
||||||
|
- android/
|
||||||
|
- Trainingsverträge und bereinigte Testdaten
|
||||||
|
|
||||||
|
Das gemeinsame Server-Grundgerüst kommt ausschließlich aus boehmitools-core.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
local.properties
|
||||||
|
**/build/
|
||||||
|
*.iml
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.properties.local
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Es wurde bewusst keine Datei app-debug.apk erzeugt.
|
||||||
|
|
||||||
|
Die drei vorgeschriebenen Gradle-Befehle wurden am 24.07.2026 ausgeführt,
|
||||||
|
scheiterten jedoch bereits beim Download von Gradle 8.11.1, weil die
|
||||||
|
Laufumgebung services.gradle.org nicht per DNS auflösen kann. Zusätzlich ist
|
||||||
|
kein Android SDK installiert. Eine leere, umbenannte oder anderweitig
|
||||||
|
vorgetäuschte APK wäre nicht installierbar und wurde deshalb nicht geliefert.
|
||||||
|
|
||||||
|
Siehe TEST-REPORT.md, BUILD.md und build-logs/gradle-assembleDebug.log.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Lokaler Build
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Android Studio mit JDK 17
|
||||||
|
- Android SDK Platform 35
|
||||||
|
- Android SDK Build-Tools für API 35
|
||||||
|
- Internetzugriff auf Google Maven, Maven Central und die Gradle-Distribution beim ersten Build
|
||||||
|
|
||||||
|
Minimum SDK der App ist 26. Zugangsdaten gehören nicht in `local.properties`, Gradle-Dateien oder Quellcode; sie werden erst in der App eingegeben.
|
||||||
|
|
||||||
|
## Projekt öffnen
|
||||||
|
|
||||||
|
1. Das Projektverzeichnis in Android Studio öffnen.
|
||||||
|
2. Falls nötig, in `local.properties` ausschließlich den lokalen SDK-Pfad setzen, zum Beispiel:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
sdk.dir=/home/user/Android/Sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Gradle synchronisieren.
|
||||||
|
4. Die Build-Variante `debug` wählen.
|
||||||
|
|
||||||
|
## Kommandozeile
|
||||||
|
|
||||||
|
Linux/macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
./gradlew lintDebug
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
gradlew.bat test
|
||||||
|
gradlew.bat lintDebug
|
||||||
|
gradlew.bat assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Debug-APK liegt anschließend unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Bei verbundenem Gerät oder Emulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ersteinrichtung
|
||||||
|
|
||||||
|
1. App öffnen und `Einstellungen` wählen.
|
||||||
|
2. Server-URL, Basic-Auth-Benutzername und Passwort eingeben.
|
||||||
|
3. `Speichern & Verbindung testen` ausführen.
|
||||||
|
4. Die App prüft zuerst den Host-Health-Endpunkt und danach den authentifizierten Tracker-Health-Endpunkt.
|
||||||
|
|
||||||
|
Debug-Builds erlauben für lokale Entwicklung auch HTTP. Release-Builds akzeptieren durch Manifest, Network Security Configuration und Laufzeitprüfung ausschließlich HTTPS.
|
||||||
|
|
||||||
|
## Gradle Wrapper
|
||||||
|
|
||||||
|
`gradle/wrapper/gradle-wrapper.jar` ist ein kleines, im Repository enthaltenes Bootstrap-Programm. Es lädt Gradle 8.11.1 in den üblichen Wrapper-Cache, prüft die Distribution anhand der mitgelieferten SHA-256-Datei und startet anschließend Gradle. Dadurch enthält das Projekt keine lokal installierte Gradle-Abhängigkeit.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Lieferumfang
|
||||||
|
|
||||||
|
Stand: 24. Juli 2026
|
||||||
|
|
||||||
|
## Enthalten
|
||||||
|
|
||||||
|
- vollständiges Android-Studio-Projekt für `de.drazz.boehmitools.training`
|
||||||
|
- native Kotlin-/Jetpack-Compose-/Material-3-App ohne WebView
|
||||||
|
- Tracker, Analyse, Planeditor, Offline-Queue und Konfliktbehandlung
|
||||||
|
- beide gelieferten Plan-JSONs und beide Session-JSONs als Test-Fixtures
|
||||||
|
- Analyse- und Vorschlags-Fixtures
|
||||||
|
- Vertrags- und Architekturdokumentation unter `docs/`
|
||||||
|
- Unit-, MockWebServer-, Repository- und Compose-UI-Tests
|
||||||
|
- reproduzierbare Prüfwerkzeuge und vollständige Buildprotokolle unter `build-logs/`
|
||||||
|
|
||||||
|
## Nicht enthalten
|
||||||
|
|
||||||
|
Eine `app-debug.apk` konnte in der bereitgestellten Laufumgebung nicht erzeugt werden. Gradle 8.11.1 konnte wegen fehlender DNS-Auflösung für `services.gradle.org` nicht geladen werden; zusätzlich ist kein Android SDK installiert. Es wurde bewusst keine Attrappe oder ungeprüfte Datei als APK ausgegeben.
|
||||||
|
|
||||||
|
Der lokale Build ist in `BUILD.md` beschrieben. Der vollständige Prüfstatus steht in `TEST-REPORT.md`, der Infrastrukturblocker zusätzlich in `APK_BUILD_BLOCKED.txt`.
|
||||||
|
|
||||||
|
## Erwartete APK nach lokalem Build
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Bekannte Einschränkungen
|
||||||
|
|
||||||
|
1. **Kein erzeugtes APK in dieser Ausführungsumgebung:** Der Container besitzt weder Android SDK noch eine vorinstallierte Gradle-Distribution und kann `services.gradle.org` per Shell-DNS nicht erreichen. Daher konnten die Android-Abhängigkeiten nicht aufgelöst und `app-debug.apk` hier nicht seriös erzeugt werden. Der genaue Befund und alle Befehlsprotokolle stehen in `TEST-REPORT.md`.
|
||||||
|
2. **Keine gelieferten Screenshots:** In den drei Archiven sind keine Bilddateien enthalten. Die visuelle Umsetzung basiert auf den mobilen HTML-UIs der Plugins; Pixelgleichheit zu nicht vorhandenen Screenshots kann nicht geprüft werden.
|
||||||
|
3. **Keine Geräte-/Emulatorausführung im Container:** Compose-Instrumentation, Keystore-Verhalten und reale 360-dp-Layouts konnten nur als Tests implementiert, nicht ausgeführt werden.
|
||||||
|
4. **Offline-Mutationen sind bewusst auf Session-Patches begrenzt:** Planentwürfe sind im Cache lesbar, Editoränderungen werden aber nicht offline veröffentlicht oder in eine Mutationsqueue aufgenommen. Dafür existiert im Serververtrag kein sicherer Patch-/Merge-Mechanismus.
|
||||||
|
5. **Konflikte werden nicht automatisch gemergt:** Bei `409` bleiben lokale Daten beziehungsweise Queue-Einträge sichtbar. Die App bietet bewusst nur Neuladen und explizites Verwerfen, kein riskantes Rebase.
|
||||||
|
6. **Optionale Verwaltungsendpunkte nicht als Hauptscreens:** Planimport/-export, PDF-Erzeugung, Reset und globale Kompatibilitätskonfiguration existieren serverseitig, gehören aber nicht zu den geforderten Kernflüssen der beiden nativen Plugins und wurden nicht als eigene Android-Oberflächen umgesetzt.
|
||||||
|
7. **Kein Timer:** Auch beim gelieferten Tabata-Plan wird nur das Trainingsformat angezeigt. Ein Tabata- oder Trainingstimer ist ausdrücklich nicht implementiert.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# boehmitools Training
|
||||||
|
|
||||||
|
Native Android-App für die boehmitools-Plugins `trainingsplan` und `trainingstracker` 2.0.
|
||||||
|
|
||||||
|
- Kotlin, Jetpack Compose und Material 3
|
||||||
|
- Application ID `de.drazz.boehmitools.training`
|
||||||
|
- Minimum SDK 26
|
||||||
|
- konfigurierbarer Server, Standard `https://tools.d-razz.de/`
|
||||||
|
- HTTP Basic Auth mit Android-Keystore-geschütztem Passwort
|
||||||
|
- Room-Cache und FIFO-Queue für Session-Patches
|
||||||
|
- WorkManager-Synchronisation mit sichtbarer `409`-Konfliktbehandlung
|
||||||
|
- nativer Tracker, Fortschritt/Analyse und Planeditor
|
||||||
|
|
||||||
|
Der technische Vertrag steht in `docs/API_CONTRACT.md`, die Quellenanalyse in `docs/SOURCE_ANALYSIS.md`. Lokale Buildschritte stehen in `BUILD.md`.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Testbericht
|
||||||
|
|
||||||
|
Stand: 24. Juli 2026
|
||||||
|
|
||||||
|
## Ergebnisübersicht
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis | Nachweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Vollständiger Quellen- und Fixture-Audit | bestanden | `docs/SOURCE_ANALYSIS.md`, `build-logs/source-audit.json` |
|
||||||
|
| Vergleich aktueller Plugins gegen v2.0-Bundle | bestanden | nur erzeugte `__pycache__`-Dateien unterscheiden sich; `build-logs/source-diff.log` |
|
||||||
|
| Vorhandene Backend-Plugin-Tests | **11 bestanden, 8 übersprungen** | `build-logs/backend-plugin-tests.log` |
|
||||||
|
| Statische Android-Akzeptanzprüfung | **728 bestanden** | `build-logs/static-acceptance.log` |
|
||||||
|
| Ressourcenprüfung | 8 JSON- und 7 XML-Dateien parsebar | `build-logs/resource-validation.log` |
|
||||||
|
| Kotlin-Parserprüfung ohne Android-Classpath | keine Syntax-/Parserdiagnosen | `build-logs/kotlinc-syntax-summary.txt` |
|
||||||
|
| `./gradlew test` | **nicht ausführbar in dieser Umgebung** | Wrapper-Download scheitert an nicht auflösbarem `services.gradle.org`; `build-logs/gradle-test.log` |
|
||||||
|
| `./gradlew lintDebug` | **nicht ausführbar in dieser Umgebung** | gleicher Infrastrukturfehler; `build-logs/gradle-lintDebug.log` |
|
||||||
|
| `./gradlew assembleDebug` | **nicht ausführbar in dieser Umgebung** | gleicher Infrastrukturfehler; `build-logs/gradle-assembleDebug.log` |
|
||||||
|
| Debug-APK | **nicht erzeugt** | ohne Gradle-Distribution, Android SDK und aufgelöste Abhängigkeiten wäre ein APK nicht verifizierbar |
|
||||||
|
|
||||||
|
## Ausgeführte Backendtests
|
||||||
|
|
||||||
|
Ausgeführt im vollständigen boehmitools-Quellstand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest -q \
|
||||||
|
plugins/trainingsplan/tests \
|
||||||
|
plugins/trainingstracker/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
11 passed, 8 skipped in 0.05s
|
||||||
|
```
|
||||||
|
|
||||||
|
Die übersprungenen Tests stammen aus den gelieferten Testsets und wurden nicht von der Android-Implementierung ausgeblendet.
|
||||||
|
|
||||||
|
## Android-Tests im Projekt
|
||||||
|
|
||||||
|
### Unit- und Fixture-Tests
|
||||||
|
|
||||||
|
`FixtureContractTest` prüft unter anderem:
|
||||||
|
|
||||||
|
- beide gelieferten Pläne mit `schema_version = 3` und `contract_version = 2`,
|
||||||
|
- vollständige und entitätsspezifisch eindeutige stabile IDs,
|
||||||
|
- explizite `result_schema`-Definitionen aller Übungen und Progressionsstufen,
|
||||||
|
- Laden beider Planvarianten,
|
||||||
|
- Migration beider alten Sessiondateien ohne Itemverlust,
|
||||||
|
- Priorität manuelle Anpassung → Progressionsstufe → Übung → Fallback,
|
||||||
|
- Vererbung nicht überschriebener result_schema-Felder,
|
||||||
|
- sichere Löschung inkompatibler Messwerte bei Einheit-/Seitenwechsel,
|
||||||
|
- Normalisierung alter Freitextergebnisse zu `result_data` Version 2,
|
||||||
|
- FIFO-Overlay ausstehender Session-Patches nach App-Neustart,
|
||||||
|
- Überschreibmodell für genau eine Wochen- beziehungsweise Gesamtanalyse.
|
||||||
|
|
||||||
|
`ApiContractTest` verwendet MockWebServer und prüft:
|
||||||
|
|
||||||
|
- exakten Session-Patch-Pfad, `PATCH`, Basic-Auth-Header und `expected_revision`,
|
||||||
|
- `202 Accepted` sowie `200` am selben Analyse-Endpunkt,
|
||||||
|
- unveränderte Weitergabe von `401` und `409 revision_conflict`,
|
||||||
|
- dokumentierte Editor-, Publish- und Vorschlagsstatus-Pfade.
|
||||||
|
|
||||||
|
### Repository- und Instrumentationstests
|
||||||
|
|
||||||
|
`RepositoryIntegrationTest` prüft mit Room, WorkManager und MockWebServer:
|
||||||
|
|
||||||
|
- Netzantwort und Room-Cache,
|
||||||
|
- sichtbare `409`-Weitergabe ohne Umwandlung in einen scheinbaren Offline-Erfolg,
|
||||||
|
- Erhalt des HTTP-Status `202` für die dauerhafte Analyse-Sperre.
|
||||||
|
|
||||||
|
`CompactEditorUiTest` setzt eine 360-dp-breite Compose-Oberfläche auf und prüft, dass lange Übungsnamen und strukturierte Ergebnisse innerhalb der verfügbaren Breite bleiben.
|
||||||
|
|
||||||
|
Diese Android-Tests sind vollständig im Projekt vorhanden, konnten in diesem Container aber nicht gestartet werden, weil bereits der Gradle-Wrapper-Download vor der Projektauswertung fehlschlägt.
|
||||||
|
|
||||||
|
## Statische Akzeptanzprüfung
|
||||||
|
|
||||||
|
Ausgeführt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/static_acceptance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS: 728 Prüfungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Prüfung deckt Buildparameter, Release-Netzwerksicherheit, Standardserver, verbotene WebView/OpenAI-Verbindungen, Basic Auth, zentrale API-Routen sowie alle gelieferten Plan- und Session-Fixtures ab.
|
||||||
|
|
||||||
|
## Kotlin-Parserprüfung
|
||||||
|
|
||||||
|
Alle Kotlin-Dateien wurden zusätzlich mit dem vorhandenen standalone `kotlinc` eingelesen. Da Android SDK und Maven-/Gradle-Abhängigkeiten fehlen, entstehen erwartungsgemäß nicht auflösbare Android-, Compose-, Hilt-, Retrofit- und Serialization-Symbole. Die separat gefilterte Prüfung meldet jedoch keine Parserfehler wie ungeschlossene Blöcke, illegale Escapes oder unerwartete Tokens.
|
||||||
|
|
||||||
|
Dies ersetzt keinen echten Gradle-Compile-Lauf. Das vollständige Rohprotokoll ist deshalb nur Diagnosematerial und kein Bestehensnachweis.
|
||||||
|
|
||||||
|
## Blocker der geforderten Gradle-Läufe
|
||||||
|
|
||||||
|
Alle drei vorgeschriebenen Befehle wurden separat ausgeführt und protokolliert. Die Laufumgebung enthält:
|
||||||
|
|
||||||
|
- keine installierte Gradle-Distribution,
|
||||||
|
- kein Android SDK, kein `sdkmanager`, kein `adb`,
|
||||||
|
- keinen vorgefüllten Gradle- oder Maven-Cache,
|
||||||
|
- keine DNS-Auflösung für `services.gradle.org` aus Shell-Prozessen.
|
||||||
|
|
||||||
|
Der mitgelieferte Wrapper versucht korrekt Gradle 8.11.1 zu laden und beendet sich mit `UnknownHostException`. Der Fehler tritt vor der Auswertung von `settings.gradle.kts` und damit vor Kompilierung, Tests, Lint oder APK-Erzeugung auf. Details stehen in `build-logs/build-environment.log` und den drei Gradle-Protokollen.
|
||||||
|
|
||||||
|
## Lokal nachzuholende Abnahme
|
||||||
|
|
||||||
|
In einer normalen Android-Entwicklungsumgebung mit JDK 17, Android SDK 35 und Internetzugang:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
./gradlew lintDebug
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Anschließend ist zusätzlich empfehlenswert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew connectedDebugAndroidTest
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Die erwartete APK-Ausgabe ist `app/build/outputs/apk/debug/app-debug.apk`. Bis diese Befehle erfolgreich gelaufen sind, darf das Projekt nicht als vollständig buildverifiziert bezeichnet werden.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.kapt)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.drazz.boehmitools.training"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "de.drazz.boehmitools.training"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "2.0.0"
|
||||||
|
testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner"
|
||||||
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions { jvmTarget = "17" }
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
testOptions.unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
arguments { arg("room.schemaLocation", "$projectDir/schemas") }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.icons)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
kapt(libs.hilt.compiler)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
implementation(libs.hilt.work)
|
||||||
|
kapt(libs.hilt.work.compiler)
|
||||||
|
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
kapt(libs.room.compiler)
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
|
implementation(libs.work.runtime.ktx)
|
||||||
|
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.retrofit.kotlinx)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.mockwebserver)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.core.ktx)
|
||||||
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
|
androidTestImplementation(libs.room.testing)
|
||||||
|
androidTestImplementation(libs.work.testing)
|
||||||
|
androidTestImplementation(libs.mockwebserver)
|
||||||
|
kaptAndroidTest(libs.hilt.compiler)
|
||||||
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-keepattributes Signature,*Annotation*
|
||||||
|
-keep class kotlinx.serialization.** { *; }
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.drazz.boehmitools.training
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.runner.AndroidJUnitRunner
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
|
||||||
|
class HiltTestRunner : AndroidJUnitRunner() {
|
||||||
|
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||||
|
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertWidthIsAtMost
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CompactEditorUiTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longExerciseNamesWrapWithin360Dp() {
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Column(Modifier.width(360.dp).testTag("root")) {
|
||||||
|
Text(
|
||||||
|
"Sehr langer Übungsname mit Progressionshinweis und mehreren Ergebniswerten, der sicher umbrechen muss",
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("long-name"),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = "8/8/7 Wiederholungen je Seite bei optionalem Gewicht",
|
||||||
|
onValueChange = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("field"),
|
||||||
|
label = { Text("Strukturiertes Ergebnis") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("long-name").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||||
|
compose.onNodeWithTag("field").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config_debug"
|
||||||
|
android:usesCleartextTraffic="true" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors><certificates src="system" /></trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".TrainingApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@drawable/ic_launcher_foreground"
|
||||||
|
android:label="boehmitools Training"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.BoehmitoolsTraining"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.drazz.boehmitools.training
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.drazz.boehmitools.training.ui.AppRoot
|
||||||
|
import de.drazz.boehmitools.training.ui.AppViewModel
|
||||||
|
import de.drazz.boehmitools.training.ui.theme.TrainingTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
val vm: AppViewModel = hiltViewModel()
|
||||||
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
|
TrainingTheme(settings.theme) {
|
||||||
|
AppRoot(settings = settings, onTheme = vm::setTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.drazz.boehmitools.training
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class TrainingApplication : Application(), Configuration.Provider {
|
||||||
|
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.drazz.boehmitools.training.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.drazz.boehmitools.training.data.local.TrainingDatabase
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun json(): Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun database(@ApplicationContext context: Context): TrainingDatabase =
|
||||||
|
Room.databaseBuilder(context, TrainingDatabase::class.java, "training.db")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun workManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package de.drazz.boehmitools.training.domain
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
fun JsonObject.string(key: String, fallback: String = ""): String = this[key]?.jsonPrimitive?.contentOrNull ?: fallback
|
||||||
|
fun JsonObject.int(key: String, fallback: Int = 0): Int = this[key]?.jsonPrimitive?.intOrNull ?: fallback
|
||||||
|
fun JsonObject.bool(key: String, fallback: Boolean = false): Boolean = this[key]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||||
|
fun JsonObject.obj(key: String): JsonObject = this[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||||
|
fun JsonObject.array(key: String): JsonArray = this[key] as? JsonArray ?: JsonArray(emptyList())
|
||||||
|
|
||||||
|
fun JsonObject.updated(key: String, value: JsonElement?): JsonObject = JsonObject(toMutableMap().apply {
|
||||||
|
if (value == null) remove(key) else put(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
fun JsonObject.updatedString(key: String, value: String): JsonObject = updated(key, JsonPrimitive(value))
|
||||||
|
fun JsonObject.updatedInt(key: String, value: Int): JsonObject = updated(key, JsonPrimitive(value))
|
||||||
|
fun JsonObject.updatedBool(key: String, value: Boolean): JsonObject = updated(key, JsonPrimitive(value))
|
||||||
|
|
||||||
|
data class ResultSchemaUi(
|
||||||
|
val mode: String = "reps",
|
||||||
|
val weightMode: String = "none",
|
||||||
|
val laterality: String = "bilateral",
|
||||||
|
val sidesMode: String = "same",
|
||||||
|
val defaultSets: Int = 3,
|
||||||
|
val lockedSets: Boolean = false,
|
||||||
|
val source: String = "fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProgressionStepUi(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val resultSchema: ResultSchemaUi?,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProgressionUi(
|
||||||
|
val id: String,
|
||||||
|
val key: String,
|
||||||
|
val name: String,
|
||||||
|
val steps: List<ProgressionStepUi>,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExerciseUi(
|
||||||
|
val id: String,
|
||||||
|
val legacyId: String,
|
||||||
|
val exerciseId: String,
|
||||||
|
val progressionId: String,
|
||||||
|
val name: String,
|
||||||
|
val cue: String,
|
||||||
|
val resultSchema: ResultSchemaUi?,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RotationUi(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val exercises: List<ExerciseUi>,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DayUi(
|
||||||
|
val id: String,
|
||||||
|
val num: Int,
|
||||||
|
val focus: String,
|
||||||
|
val light: Boolean,
|
||||||
|
val warmup: List<String>,
|
||||||
|
val cooldown: List<String>,
|
||||||
|
val stretch: List<String>,
|
||||||
|
val rotations: List<RotationUi>,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PlanUi(
|
||||||
|
val sourceFile: String,
|
||||||
|
val planId: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val weeks: Int,
|
||||||
|
val publishedRevision: Int,
|
||||||
|
val days: List<DayUi>,
|
||||||
|
val progressions: Map<String, ProgressionUi>,
|
||||||
|
val trainingFormat: JsonObject,
|
||||||
|
val exerciseCatalog: JsonObject,
|
||||||
|
val front: JsonObject,
|
||||||
|
val raw: JsonObject,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parsePlan(plan: JsonObject): PlanUi {
|
||||||
|
val progressions = parseProgressions(plan["stages"])
|
||||||
|
val prepost = plan.obj("prepost")
|
||||||
|
val meta = plan.obj("meta")
|
||||||
|
val days = plan.array("days").mapNotNull { it as? JsonObject }.map { day ->
|
||||||
|
val dayNum = day.int("num", 1)
|
||||||
|
val rotations = day.array("rotations").mapNotNull { it as? JsonObject }.mapIndexed { ri, rotation ->
|
||||||
|
val exercises = rotation.array("exercises").mapNotNull { it as? JsonObject }.mapIndexed { ei, exercise ->
|
||||||
|
ExerciseUi(
|
||||||
|
id = exercise.string("id", "exercise-$dayNum-$ri-$ei"),
|
||||||
|
legacyId = exercise.string("legacy_id", "d$dayNum-r$ri-e$ei"),
|
||||||
|
exerciseId = exercise.string("exercise_id"),
|
||||||
|
progressionId = exercise.string("progression_id", exercise.string("key")),
|
||||||
|
name = exercise.string("name", "Übung ${ei + 1}"),
|
||||||
|
cue = exercise.string("cue"),
|
||||||
|
resultSchema = parseResultSchema(exercise["result_schema"] as? JsonObject, "exercise"),
|
||||||
|
raw = exercise,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RotationUi(
|
||||||
|
id = rotation.string("id", "rotation-$dayNum-$ri"),
|
||||||
|
label = rotation.string("label", "Block ${ri + 1}"),
|
||||||
|
exercises = exercises,
|
||||||
|
raw = rotation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val legacyPrePost = prepost.obj(dayNum.toString())
|
||||||
|
fun sequence(name: String): List<String> {
|
||||||
|
val normalized = day.obj(name).array("items").mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||||
|
if (normalized.isNotEmpty()) return normalized
|
||||||
|
return legacyPrePost.string(name)
|
||||||
|
.split(Regex("""<br\s*/?>|\n""", RegexOption.IGNORE_CASE))
|
||||||
|
.map { it.replace(Regex("<[^>]+>"), "").trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
DayUi(
|
||||||
|
id = day.string("id", "day-$dayNum"),
|
||||||
|
num = dayNum,
|
||||||
|
focus = day.string("focus", "Tag $dayNum"),
|
||||||
|
light = day.bool("light"),
|
||||||
|
warmup = sequence("warmup"),
|
||||||
|
cooldown = sequence("cooldown"),
|
||||||
|
stretch = sequence("stretch"),
|
||||||
|
rotations = rotations,
|
||||||
|
raw = day,
|
||||||
|
)
|
||||||
|
}.sortedBy { it.num }
|
||||||
|
return PlanUi(
|
||||||
|
sourceFile = plan.string("source_file"),
|
||||||
|
planId = plan.string("plan_id"),
|
||||||
|
title = plan.string("title", meta.string("title", plan.string("name"))),
|
||||||
|
subtitle = plan.string("subtitle", meta.string("subtitle")),
|
||||||
|
weeks = plan.int("weeks", meta.int("weeks", 1)).coerceAtLeast(1),
|
||||||
|
publishedRevision = plan.int("published_revision", 1),
|
||||||
|
days = days,
|
||||||
|
progressions = progressions,
|
||||||
|
trainingFormat = plan.obj("training_format"),
|
||||||
|
exerciseCatalog = plan.obj("exercise_catalog"),
|
||||||
|
front = plan.obj("front"),
|
||||||
|
raw = plan,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProgressions(element: JsonElement?): Map<String, ProgressionUi> {
|
||||||
|
val entries: List<Pair<String, JsonObject>> = when (element) {
|
||||||
|
is JsonArray -> element.mapNotNull { value ->
|
||||||
|
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||||
|
val key = stage.string("key", stage.string("id"))
|
||||||
|
key to stage
|
||||||
|
}
|
||||||
|
is JsonObject -> element.mapNotNull { (mapKey, value) ->
|
||||||
|
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||||
|
mapKey to stage
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
return entries.associate { (mapKey, stage) ->
|
||||||
|
val key = stage.string("key", mapKey.ifBlank { stage.string("id") })
|
||||||
|
val steps = stage.array("steps").mapNotNull { it as? JsonObject }.map { step ->
|
||||||
|
ProgressionStepUi(
|
||||||
|
id = step.string("id"),
|
||||||
|
name = step.string("name"),
|
||||||
|
resultSchema = parseResultSchema(step["result_schema"] as? JsonObject, "progression"),
|
||||||
|
raw = step,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
key to ProgressionUi(
|
||||||
|
id = stage.string("id", key),
|
||||||
|
key = key,
|
||||||
|
name = stage.string("name", key),
|
||||||
|
steps = steps,
|
||||||
|
raw = stage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseResultSchema(raw: JsonObject?, source: String): ResultSchemaUi? {
|
||||||
|
if (raw == null || raw.isEmpty()) return null
|
||||||
|
val mode = raw.string("mode", "auto")
|
||||||
|
val normalizedMode = if (mode in setOf("reps", "seconds", "minutes", "none", "auto")) mode else "auto"
|
||||||
|
return ResultSchemaUi(
|
||||||
|
mode = normalizedMode,
|
||||||
|
weightMode = raw.string("weight_mode", "none").takeIf { it in setOf("none", "optional", "required") } ?: "none",
|
||||||
|
laterality = raw.string("laterality", "bilateral").takeIf { it in setOf("bilateral", "unilateral") } ?: "bilateral",
|
||||||
|
sidesMode = raw.string("sides_mode", "same").takeIf { it in setOf("same", "separate") } ?: "same",
|
||||||
|
defaultSets = (raw.int("default_sets", raw.int("sets", 3))).coerceIn(1, 20),
|
||||||
|
lockedSets = raw.bool("locked_sets"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mergeResultSchemaLayers(
|
||||||
|
exerciseSchema: JsonObject?,
|
||||||
|
progressionSchema: JsonObject?,
|
||||||
|
manualSchema: JsonObject?,
|
||||||
|
): ResultSchemaUi {
|
||||||
|
fun apply(base: ResultSchemaUi, raw: JsonObject?, source: String): ResultSchemaUi {
|
||||||
|
if (raw == null || raw.isEmpty()) return base
|
||||||
|
val mode = raw["mode"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?.takeIf { it in setOf("reps", "seconds", "minutes", "none", "auto") }
|
||||||
|
?: base.mode
|
||||||
|
val weightMode = raw["weight_mode"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?.takeIf { it in setOf("none", "optional", "required") }
|
||||||
|
?: base.weightMode
|
||||||
|
val laterality = raw["laterality"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?.takeIf { it in setOf("bilateral", "unilateral") }
|
||||||
|
?: base.laterality
|
||||||
|
val sidesMode = raw["sides_mode"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?.takeIf { it in setOf("same", "separate") }
|
||||||
|
?: base.sidesMode
|
||||||
|
val sets = when {
|
||||||
|
raw["sets"]?.jsonPrimitive?.intOrNull != null -> raw["sets"]!!.jsonPrimitive.intOrNull
|
||||||
|
raw["default_sets"]?.jsonPrimitive?.intOrNull != null -> raw["default_sets"]!!.jsonPrimitive.intOrNull
|
||||||
|
else -> null
|
||||||
|
}?.coerceIn(1, 20) ?: base.defaultSets
|
||||||
|
val locked = raw["locked_sets"]?.jsonPrimitive?.booleanOrNull ?: base.lockedSets
|
||||||
|
return base.copy(
|
||||||
|
mode = mode,
|
||||||
|
weightMode = weightMode,
|
||||||
|
laterality = laterality,
|
||||||
|
sidesMode = if (laterality == "bilateral") "same" else sidesMode,
|
||||||
|
defaultSets = sets,
|
||||||
|
lockedSets = locked,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = ResultSchemaUi()
|
||||||
|
merged = apply(merged, exerciseSchema, "exercise")
|
||||||
|
merged = apply(merged, progressionSchema, "progression")
|
||||||
|
merged = apply(merged, manualSchema, "session")
|
||||||
|
if ((exerciseSchema == null || exerciseSchema.isEmpty()) &&
|
||||||
|
(progressionSchema == null || progressionSchema.isEmpty()) &&
|
||||||
|
(manualSchema == null || manualSchema.isEmpty())
|
||||||
|
) {
|
||||||
|
merged = merged.copy(source = "fallback")
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveResultSchema(
|
||||||
|
exercise: ExerciseUi,
|
||||||
|
item: JsonObject,
|
||||||
|
progressionText: String,
|
||||||
|
plan: PlanUi,
|
||||||
|
): ResultSchemaUi {
|
||||||
|
val manualRaw = item["result_format"] as? JsonObject
|
||||||
|
val progression = plan.progressions[exercise.progressionId]
|
||||||
|
val stepId = item.string("progression_step_id")
|
||||||
|
val step = progression?.steps?.firstOrNull { it.id == stepId }
|
||||||
|
?: progression?.steps?.firstOrNull { it.name.equals(progressionText, ignoreCase = true) }
|
||||||
|
val stepRaw = step?.raw?.get("result_schema") as? JsonObject
|
||||||
|
val exerciseRaw = exercise.raw["result_schema"] as? JsonObject
|
||||||
|
val base = mergeResultSchemaLayers(exerciseRaw, stepRaw, manualRaw)
|
||||||
|
|
||||||
|
val text = "${exercise.name} $progressionText ${exercise.cue}".lowercase()
|
||||||
|
val static = Regex("hold|plank|hang|wall sit|wandsitz|isometr|stützposition").containsMatchIn(text)
|
||||||
|
val dynamic = Regex("push.?up|pull.?up|klimmzug|liegestütz|senkung|negativ|reps|rudern|row|squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt").containsMatchIn(progressionText.lowercase())
|
||||||
|
val mode = when {
|
||||||
|
base.mode == "auto" -> if (static && !dynamic) "seconds" else "reps"
|
||||||
|
base.source == "exercise" && base.mode !in setOf("none", "minutes") && static && !dynamic -> "seconds"
|
||||||
|
else -> base.mode
|
||||||
|
}
|
||||||
|
val inferredLaterality = when {
|
||||||
|
base.source !in setOf("progression", "session") && Regex("einarm|einbein|je seite|pro seite").containsMatchIn(progressionText.lowercase()) -> "unilateral"
|
||||||
|
base.source !in setOf("progression", "session") && Regex("beidbeinig|zweiarmig|beide seiten gemeinsam").containsMatchIn(progressionText.lowercase()) -> "bilateral"
|
||||||
|
else -> base.laterality
|
||||||
|
}
|
||||||
|
val inferredWeight = if (base.source in setOf("progression", "session")) base.weightMode else {
|
||||||
|
val low = progressionText.lowercase()
|
||||||
|
val unweighted = Regex("ohne gewicht|ungewichtet|körpergewicht|koerpergewicht|\\bbw\\b").containsMatchIn(low)
|
||||||
|
val loaded = Regex("gewicht|gewichtet|zusatzlast|\\blast\\b|\\bkg\\b|kettlebell|\\bkb\\b|rucksack").containsMatchIn(low)
|
||||||
|
when {
|
||||||
|
unweighted && loaded -> "optional"
|
||||||
|
unweighted -> "none"
|
||||||
|
loaded -> if (Regex("möglich|optional|bei 8").containsMatchIn(low)) "optional" else "required"
|
||||||
|
else -> base.weightMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.copy(
|
||||||
|
mode = mode,
|
||||||
|
laterality = inferredLaterality,
|
||||||
|
sidesMode = if (inferredLaterality == "unilateral") base.sidesMode else "same",
|
||||||
|
weightMode = inferredWeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sessionKey(week: Int, day: Int): String = "w%02d-d%02d".format(week, day)
|
||||||
|
|
||||||
|
fun blankSession(plan: PlanUi): JsonObject = buildJsonObject {
|
||||||
|
put("status", "planned")
|
||||||
|
put("plan_id", plan.planId)
|
||||||
|
put("plan_revision", plan.publishedRevision)
|
||||||
|
put("items", JsonObject(emptyMap()))
|
||||||
|
put("note", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackerSession(tracker: JsonObject, key: String, plan: PlanUi): JsonObject {
|
||||||
|
return (tracker.obj("sessions")[key] as? JsonObject) ?: blankSession(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun migrateLegacyItems(session: JsonObject, day: DayUi): Pair<JsonObject, Boolean> {
|
||||||
|
val items = session.obj("items").toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||||
|
if (items[exercise.id] == null && exercise.legacyId.isNotBlank() && items[exercise.legacyId] != null) {
|
||||||
|
items[exercise.id] = items.getValue(exercise.legacyId)
|
||||||
|
items.remove(exercise.legacyId)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session.updated("items", JsonObject(items)) to changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun itemFor(session: JsonObject, exerciseId: String): JsonObject = session.obj("items")[exerciseId] as? JsonObject
|
||||||
|
?: buildJsonObject {
|
||||||
|
put("completion_status", "planned")
|
||||||
|
put("done", false)
|
||||||
|
put("progression", "")
|
||||||
|
put("result", "")
|
||||||
|
put("note", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateItem(session: JsonObject, exerciseId: String, item: JsonObject): JsonObject {
|
||||||
|
val items = session.obj("items").toMutableMap().apply { put(exerciseId, item) }
|
||||||
|
return session.updated("items", JsonObject(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTrackerSession(tracker: JsonObject, key: String, session: JsonObject): JsonObject {
|
||||||
|
val sessions = tracker.obj("sessions").toMutableMap().apply { put(key, session) }
|
||||||
|
return tracker.updated("sessions", JsonObject(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sessionPatch(tracker: JsonObject, key: String, session: JsonObject): JsonObject = buildJsonObject {
|
||||||
|
put("expected_revision", tracker.int("revision", 1))
|
||||||
|
put("profile", tracker.obj("profile"))
|
||||||
|
put("week_statuses", tracker.obj("week_statuses"))
|
||||||
|
put("session_key", key)
|
||||||
|
put("session", session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun overlaySessionPatches(base: JsonObject, patches: List<JsonObject>): JsonObject {
|
||||||
|
var tracker = base
|
||||||
|
patches.forEach { patch ->
|
||||||
|
patch["profile"]?.let { tracker = tracker.updated("profile", it) }
|
||||||
|
patch["week_statuses"]?.let { tracker = tracker.updated("week_statuses", it) }
|
||||||
|
val key = patch.string("session_key")
|
||||||
|
val session = patch["session"] as? JsonObject
|
||||||
|
if (key.isNotBlank() && session != null) tracker = updateTrackerSession(tracker, key, session)
|
||||||
|
val localRevision = patch.int("expected_revision", tracker.int("revision", 1)) + 1
|
||||||
|
if (localRevision > tracker.int("revision", 1)) tracker = tracker.updatedInt("revision", localRevision)
|
||||||
|
}
|
||||||
|
return tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
fun adaptResultDataToSchema(current: JsonObject, schema: ResultSchemaUi): JsonObject? {
|
||||||
|
if (schema.mode == "none") return null
|
||||||
|
val oldMode = current.string("mode", schema.mode)
|
||||||
|
val oldLaterality = current.string("laterality", schema.laterality)
|
||||||
|
val oldSides = current.string("sides_mode", schema.sidesMode)
|
||||||
|
val incompatible = oldMode != schema.mode || oldLaterality != schema.laterality ||
|
||||||
|
(schema.laterality == "unilateral" && oldSides != schema.sidesMode)
|
||||||
|
var data = current
|
||||||
|
.updatedString("mode", schema.mode)
|
||||||
|
.updatedString("laterality", schema.laterality)
|
||||||
|
.updatedString("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||||
|
if (incompatible) {
|
||||||
|
data = data
|
||||||
|
.updated("values", JsonArray(emptyList()))
|
||||||
|
.updated("left_values", JsonArray(emptyList()))
|
||||||
|
.updated("right_values", JsonArray(emptyList()))
|
||||||
|
}
|
||||||
|
if (schema.lockedSets) data = resultDataWithSets(data, schema.defaultSets)
|
||||||
|
if (schema.weightMode == "none") data = data.updated("weight_kg", null)
|
||||||
|
return normalizeResultData(data, schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emptyResultData(schema: ResultSchemaUi): JsonObject {
|
||||||
|
val sets = schema.defaultSets.coerceIn(1, 20)
|
||||||
|
return buildJsonObject {
|
||||||
|
put("version", 2)
|
||||||
|
put("mode", schema.mode)
|
||||||
|
put("laterality", schema.laterality)
|
||||||
|
put("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||||
|
put("sets", sets)
|
||||||
|
put("weight_kg", JsonNull)
|
||||||
|
put("values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||||
|
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||||
|
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizedResultData(item: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||||
|
val structured = item["result_data"] as? JsonObject
|
||||||
|
if (structured != null) return normalizeResultData(structured, schema)
|
||||||
|
return parseLegacyResult(item.string("result"), schema) ?: emptyResultData(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeResultData(raw: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||||
|
val sets = raw.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||||
|
fun normalizedArray(key: String): JsonArray {
|
||||||
|
val values = raw.array(key).take(sets).toMutableList()
|
||||||
|
while (values.size < sets) values.add(JsonNull)
|
||||||
|
return JsonArray(values)
|
||||||
|
}
|
||||||
|
return buildJsonObject {
|
||||||
|
put("version", 2)
|
||||||
|
put("mode", raw.string("mode", schema.mode))
|
||||||
|
put("laterality", raw.string("laterality", schema.laterality))
|
||||||
|
put("sides_mode", raw.string("sides_mode", schema.sidesMode))
|
||||||
|
put("sets", sets)
|
||||||
|
put("weight_kg", raw["weight_kg"] ?: JsonNull)
|
||||||
|
put("values", normalizedArray("values"))
|
||||||
|
put("left_values", normalizedArray("left_values"))
|
||||||
|
put("right_values", normalizedArray("right_values"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseLegacyResult(text: String, schema: ResultSchemaUi): JsonObject? {
|
||||||
|
val raw = text.trim()
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val weight = Regex("(\\d+(?:[.,]\\d+)?)\\s*kg", RegexOption.IGNORE_CASE)
|
||||||
|
.find(raw)?.groupValues?.getOrNull(1)?.replace(',', '.')?.toDoubleOrNull()
|
||||||
|
val repeatedSeconds = Regex("(\\d+)\\s*[x×]\\s*(\\d+(?:[.,]\\d+)?)\\s*s", RegexOption.IGNORE_CASE).find(raw)
|
||||||
|
val values: List<Double> = when {
|
||||||
|
repeatedSeconds != null -> {
|
||||||
|
val count = repeatedSeconds.groupValues[1].toIntOrNull()?.coerceIn(1, 20) ?: 1
|
||||||
|
val value = repeatedSeconds.groupValues[2].replace(',', '.').toDoubleOrNull() ?: return null
|
||||||
|
List(count) { value }
|
||||||
|
}
|
||||||
|
else -> raw.replace(Regex("\\d+(?:[.,]\\d+)?\\s*kg", RegexOption.IGNORE_CASE), "")
|
||||||
|
.split('/', ';')
|
||||||
|
.mapNotNull { Regex("-?\\d+(?:[.,]\\d+)?").find(it)?.value?.replace(',', '.')?.toDoubleOrNull() }
|
||||||
|
.take(20)
|
||||||
|
}
|
||||||
|
if (values.isEmpty() && weight == null) return null
|
||||||
|
val mode = when {
|
||||||
|
repeatedSeconds != null || Regex("\\bsek|\\bs\\b|second", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "seconds"
|
||||||
|
Regex("min", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "minutes"
|
||||||
|
else -> schema.mode.takeIf { it in setOf("reps", "seconds", "minutes") } ?: "reps"
|
||||||
|
}
|
||||||
|
val sets = values.size.coerceAtLeast(1)
|
||||||
|
return buildJsonObject {
|
||||||
|
put("version", 2)
|
||||||
|
put("mode", mode)
|
||||||
|
put("laterality", schema.laterality)
|
||||||
|
put("sides_mode", schema.sidesMode)
|
||||||
|
put("sets", sets)
|
||||||
|
put("weight_kg", weight?.let(::JsonPrimitive) ?: JsonNull)
|
||||||
|
put("values", JsonArray(values.map(::JsonPrimitive)))
|
||||||
|
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||||
|
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatResultData(data: JsonObject): String {
|
||||||
|
fun num(element: JsonElement?): String = element?.jsonPrimitive?.doubleOrNull?.let {
|
||||||
|
if (it % 1.0 == 0.0) it.toInt().toString() else it.toString().trimEnd('0').trimEnd('.')
|
||||||
|
} ?: "–"
|
||||||
|
val mode = data.string("mode", "reps")
|
||||||
|
val unit = when (mode) { "seconds" -> " s"; "minutes" -> " min"; else -> " Reps" }
|
||||||
|
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.let { "${num(JsonPrimitive(it))} kg · " }.orEmpty()
|
||||||
|
val laterality = data.string("laterality", "bilateral")
|
||||||
|
val separate = laterality == "unilateral" && data.string("sides_mode") == "separate"
|
||||||
|
return if (separate) {
|
||||||
|
val left = data.array("left_values").joinToString("/") { num(it) }
|
||||||
|
val right = data.array("right_values").joinToString("/") { num(it) }
|
||||||
|
"$weight L $left · R $right$unit"
|
||||||
|
} else {
|
||||||
|
val values = data.array("values").joinToString("/") { num(it) }
|
||||||
|
"$weight$values$unit${if (laterality == "unilateral") " je Seite" else ""}"
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resultDataWithValue(data: JsonObject, field: String, index: Int, value: Double?): JsonObject {
|
||||||
|
val list = data.array(field).toMutableList()
|
||||||
|
while (list.size <= index) list.add(JsonNull)
|
||||||
|
list[index] = value?.let(::JsonPrimitive) ?: JsonNull
|
||||||
|
return data.updated(field, JsonArray(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resultDataWithSets(data: JsonObject, sets: Int): JsonObject {
|
||||||
|
val count = sets.coerceIn(1, 20)
|
||||||
|
var result = data.updatedInt("sets", count)
|
||||||
|
for (field in listOf("values", "left_values", "right_values")) {
|
||||||
|
val list = result.array(field).take(count).toMutableList()
|
||||||
|
while (list.size < count) list.add(JsonNull)
|
||||||
|
result = result.updated(field, JsonArray(list))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FitnessCenter
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
import de.drazz.boehmitools.training.ui.editor.EditorScreen
|
||||||
|
import de.drazz.boehmitools.training.ui.setup.SettingsScreen
|
||||||
|
import de.drazz.boehmitools.training.ui.tracker.TrackerScreen
|
||||||
|
|
||||||
|
private data class AppDestination(val route: String, val label: String, val icon: @Composable () -> Unit)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppRoot(
|
||||||
|
settings: SettingsRepository.PublicSettings,
|
||||||
|
onTheme: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val destinations = listOf(
|
||||||
|
AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) },
|
||||||
|
AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) },
|
||||||
|
AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) },
|
||||||
|
)
|
||||||
|
val backStack by navController.currentBackStackEntryAsState()
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
destinations.forEach { destination ->
|
||||||
|
val selected = backStack?.destination?.hierarchy?.any { it.route == destination.route } == true
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(destination.route) {
|
||||||
|
popUpTo("tracker") { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = destination.icon,
|
||||||
|
label = { Text(destination.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) {
|
||||||
|
composable("tracker") { TrackerScreen(contentPadding = padding) }
|
||||||
|
composable("editor") { EditorScreen(contentPadding = padding) }
|
||||||
|
composable("settings") {
|
||||||
|
SettingsScreen(
|
||||||
|
contentPadding = padding,
|
||||||
|
current = settings,
|
||||||
|
onTheme = onTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AppViewModel @Inject constructor(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val settings = settingsRepository.publicSettings.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
|
SettingsRepository.PublicSettings(
|
||||||
|
SettingsRepository.DEFAULT_SERVER,
|
||||||
|
"admin",
|
||||||
|
false,
|
||||||
|
"system",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setTheme(theme: String) {
|
||||||
|
viewModelScope.launch { settingsRepository.setTheme(theme) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.drazz.boehmitools.training.domain.array
|
||||||
|
import de.drazz.boehmitools.training.domain.bool
|
||||||
|
import de.drazz.boehmitools.training.domain.int
|
||||||
|
import de.drazz.boehmitools.training.domain.obj
|
||||||
|
import de.drazz.boehmitools.training.domain.string
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedBool
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedInt
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedString
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
private val editorTabs = listOf("Grunddaten", "Tage", "Progressionen", "Bibliothek", "Prüfen", "Vorschläge")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EditorScreen(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
viewModel: EditorViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
var planMenu by remember { mutableStateOf(false) }
|
||||||
|
var createDialog by remember { mutableStateOf(false) }
|
||||||
|
var renameDialog by remember { mutableStateOf(false) }
|
||||||
|
var deleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
state.conflict?.let { conflict ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissConflict,
|
||||||
|
title = { Text("Revisionskonflikt") },
|
||||||
|
text = { Text("Der Serverplan liegt inzwischen bei Revision ${conflict.currentRevision}. Deine Änderungen wurden nicht überschrieben.") },
|
||||||
|
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||||
|
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Bearbeitung behalten") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createDialog) NameDialog(
|
||||||
|
title = "Neuen Plan anlegen",
|
||||||
|
initial = "",
|
||||||
|
allowClone = state.selectedPlanId.isNotBlank(),
|
||||||
|
onDismiss = { createDialog = false },
|
||||||
|
onConfirm = { name, clone -> createDialog = false; viewModel.createPlan(name, clone) },
|
||||||
|
)
|
||||||
|
if (renameDialog) NameDialog(
|
||||||
|
title = "Plan umbenennen",
|
||||||
|
initial = state.wrapper.string("name"),
|
||||||
|
allowClone = false,
|
||||||
|
onDismiss = { renameDialog = false },
|
||||||
|
onConfirm = { name, _ -> renameDialog = false; viewModel.renamePlan(name) },
|
||||||
|
)
|
||||||
|
if (deleteDialog) AlertDialog(
|
||||||
|
onDismissRequest = { deleteDialog = false },
|
||||||
|
title = { Text("Plan löschen?") },
|
||||||
|
text = { Text("Der Server legt vor dem Löschen ein Backup an. Der letzte verbleibende Plan kann nicht gelöscht werden.") },
|
||||||
|
confirmButton = { TextButton(onClick = { deleteDialog = false; viewModel.deletePlan() }) { Text("Löschen") } },
|
||||||
|
dismissButton = { TextButton(onClick = { deleteDialog = false }) { Text("Abbrechen") } },
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.padding(contentPadding),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { planMenu = true }) {
|
||||||
|
Text(state.wrapper.string("name", "Planeditor"), maxLines = 2)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||||
|
state.plans.forEach { plan ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("${plan.name}${if (plan.hasUnpublishedChanges) " •" else ""}") },
|
||||||
|
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { createDialog = true }) { Icon(Icons.Default.Add, "Plan anlegen") }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { planMenu = true }) { Icon(Icons.Default.MoreVert, "Planaktionen") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
ScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 8.dp) {
|
||||||
|
editorTabs.forEachIndexed { index, title ->
|
||||||
|
Tab(selected = index == selectedTab, onClick = { selectedTab = index }, text = { Text(title) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.loading) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||||
|
} else if (state.selectedPlanId.isBlank()) {
|
||||||
|
EmptyEditor(onCreate = { createDialog = true })
|
||||||
|
} else {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
StatusStrip(state)
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> BasicsPane(state, viewModel, onRename = { renameDialog = true }, onDelete = { deleteDialog = true })
|
||||||
|
1 -> DaysPane(state, viewModel)
|
||||||
|
2 -> ProgressionsPane(state, viewModel)
|
||||||
|
3 -> LibraryPane(state)
|
||||||
|
4 -> ValidationPane(state, viewModel)
|
||||||
|
else -> ProposalsPane(state, viewModel) { selectedTab = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusStrip(state: EditorUiState) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(
|
||||||
|
"Entwurf R${state.wrapper.int("revision", 1)} · veröffentlicht R${state.wrapper.int("published_revision", 1)}${if (state.dirty) " · ungespeichert" else ""}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
state.error.isNotBlank() -> Text(state.error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
state.message.isNotBlank() -> Text(state.message, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyEditor(onCreate: () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Noch kein Trainingsplan verfügbar.", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Button(onClick = onCreate) { Text("Plan anlegen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BasicsPane(state: EditorUiState, vm: EditorViewModel, onRename: () -> Unit, onDelete: () -> Unit) {
|
||||||
|
val meta = state.config.obj("meta")
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text("Plan und Entwurf", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
OutlinedTextField(meta.string("title"), { vm.updateMeta("title", it) }, Modifier.fillMaxWidth(), label = { Text("Titel") })
|
||||||
|
OutlinedTextField(meta.string("subtitle"), { vm.updateMeta("subtitle", it) }, Modifier.fillMaxWidth(), label = { Text("Untertitel") }, minLines = 2)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = meta.int("weeks", 1).toString(),
|
||||||
|
onValueChange = { it.toIntOrNull()?.let(vm::updateWeeks) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Wochen") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Stabile Identität", fontWeight = FontWeight.Bold)
|
||||||
|
Text("Plan-ID: ${state.wrapper.string("plan_id", state.selectedPlanId)}")
|
||||||
|
Text("Datei-ID: ${state.selectedPlanId}")
|
||||||
|
Text("Vorhandene Sessions: ${state.wrapper.int("tracked_sessions")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Entwurf speichern") }
|
||||||
|
OutlinedButton(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Prüfen") }
|
||||||
|
}
|
||||||
|
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty, modifier = Modifier.fillMaxWidth()) { Text("Entwurf veröffentlichen") }
|
||||||
|
OutlinedButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Plan umbenennen") }
|
||||||
|
TextButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("Plan löschen", color = MaterialTheme.colorScheme.error) }
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DaysPane(state: EditorUiState, vm: EditorViewModel) {
|
||||||
|
val target = state.proposalTargetId
|
||||||
|
val prepost = state.config.obj("prepost")
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp).testTag("editor-days"),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text("Trainingstage", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
state.config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||||
|
DayEditor(day, prepost.obj(day.int("num").toString()), vm, highlightExerciseId = target)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = vm::addDay, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null); Text(" Tag hinzufügen") }
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayEditor(day: JsonObject, prepost: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||||
|
val dayId = day.string("id")
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text("Tag ${day.int("num", 1)}", style = MaterialTheme.typography.titleLarge)
|
||||||
|
IconButton(onClick = { vm.removeDay(dayId) }) { Icon(Icons.Default.Delete, "Tag löschen") }
|
||||||
|
}
|
||||||
|
OutlinedTextField(day.string("focus"), { value -> vm.updateDay(dayId) { it.updatedString("focus", value) } }, Modifier.fillMaxWidth(), label = { Text("Fokus") })
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(day.bool("light"), { value -> vm.updateDay(dayId) { it.updatedBool("light", value) } })
|
||||||
|
Text("Leichter Trainingstag")
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
prepost.string("warmup"),
|
||||||
|
{ vm.updatePrePost(day.int("num"), "warmup", it) },
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Warm-up") },
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
prepost.string("cooldown"),
|
||||||
|
{ vm.updatePrePost(day.int("num"), "cooldown", it) },
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Cool-down") },
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
prepost.string("stretch"),
|
||||||
|
{ vm.updatePrePost(day.int("num"), "stretch", it) },
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Stretching") },
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||||
|
RotationEditor(dayId, rotation, vm, highlightExerciseId)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { vm.addRotation(dayId) }, modifier = Modifier.fillMaxWidth()) { Text("Block hinzufügen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RotationEditor(dayId: String, rotation: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||||
|
val rotationId = rotation.string("id")
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
rotation.string("label"),
|
||||||
|
{ value -> vm.updateRotation(dayId, rotationId) { it.updatedString("label", value) } },
|
||||||
|
Modifier.weight(1f),
|
||||||
|
label = { Text("Block") },
|
||||||
|
)
|
||||||
|
IconButton(onClick = { vm.removeRotation(dayId, rotationId) }) { Icon(Icons.Default.Delete, "Block löschen") }
|
||||||
|
}
|
||||||
|
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||||
|
ExerciseEditor(dayId, rotationId, exercise, vm, highlightExerciseId == exercise.string("exercise_id"))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { vm.addExercise(dayId, rotationId) }, modifier = Modifier.fillMaxWidth()) { Text("Übung hinzufügen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExerciseEditor(dayId: String, rotationId: String, exercise: JsonObject, vm: EditorViewModel, highlighted: Boolean) {
|
||||||
|
val placementId = exercise.string("id")
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth().testTag("exercise-$placementId")) {
|
||||||
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (highlighted) AssistChip(onClick = {}, label = { Text("Ziel des ausgewählten Vorschlags") })
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(exercise.string("name", "Übung"), style = MaterialTheme.typography.titleMedium)
|
||||||
|
IconButton(onClick = { vm.removeExercise(dayId, rotationId, placementId) }) { Icon(Icons.Default.Delete, "Übung löschen") }
|
||||||
|
}
|
||||||
|
OutlinedTextField(exercise.string("name"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
OutlinedTextField(exercise.string("cue"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("cue", value) } }, Modifier.fillMaxWidth(), label = { Text("Hinweis") }, minLines = 2)
|
||||||
|
OutlinedTextField(exercise.string("exercise_id"), {}, Modifier.fillMaxWidth(), readOnly = true, label = { Text("Stabile Bewegungs-ID") })
|
||||||
|
OutlinedTextField(exercise.string("progression_id"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("progression_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Progressions-ID") })
|
||||||
|
ResultSchemaEditor(
|
||||||
|
schema = exercise.obj("result_schema"),
|
||||||
|
onField = { field, value -> vm.updateExerciseSchema(dayId, rotationId, placementId, field, value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressionsPane(state: EditorUiState, vm: EditorViewModel) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Progressionsstufen", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
state.config.obj("stages").forEach { (key, element) ->
|
||||||
|
val stage = element as? JsonObject ?: return@forEach
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(stage.string("name", key), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text("ID: ${stage.string("id", key)} · Schlüssel: $key", style = MaterialTheme.typography.bodySmall)
|
||||||
|
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||||
|
val stepId = step.string("id")
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(step.string("name"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Stufe") })
|
||||||
|
OutlinedTextField(step.string("phase_id"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("phase_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Phasen-ID") })
|
||||||
|
ResultSchemaEditor(step.obj("result_schema")) { field, value -> vm.updateStepSchema(key, stepId, field, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { vm.addProgressionStep(key) }, modifier = Modifier.fillMaxWidth()) { Text("Stufe hinzufügen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultSchemaEditor(schema: JsonObject, onField: (String, JsonPrimitive) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
Text("Ergebnisschema", fontWeight = FontWeight.Bold)
|
||||||
|
ChoiceRow("Modus", listOf("reps", "seconds", "minutes", "none"), schema.string("mode", "reps")) { onField("mode", JsonPrimitive(it)) }
|
||||||
|
ChoiceRow("Gewicht", listOf("none", "optional", "required"), schema.string("weight_mode", "none")) { onField("weight_mode", JsonPrimitive(it)) }
|
||||||
|
ChoiceRow("Seiten", listOf("bilateral", "unilateral"), schema.string("laterality", "bilateral")) { onField("laterality", JsonPrimitive(it)) }
|
||||||
|
if (schema.string("laterality") == "unilateral") {
|
||||||
|
ChoiceRow("Seitenwerte", listOf("same", "separate"), schema.string("sides_mode", "same")) { onField("sides_mode", JsonPrimitive(it)) }
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
schema.int("sets", 3).toString(),
|
||||||
|
{ it.toIntOrNull()?.coerceIn(1, 20)?.let { count -> onField("sets", JsonPrimitive(count)) } },
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Standardsätze") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(schema.bool("locked_sets"), { onField("locked_sets", JsonPrimitive(it)) })
|
||||||
|
Text("Satzanzahl sperren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceRow(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
choices.forEach { value ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == value,
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
label = { Text(value) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryPane(state: EditorUiState) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("Übungsbibliothek", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text("Die Bibliothek wird beim Speichern serverseitig aus stabilen Bewegungs- und Progressions-IDs neu aufgebaut.")
|
||||||
|
val catalog = state.config.obj("exercise_catalog")
|
||||||
|
if (catalog.isEmpty()) Text("Noch kein Katalog im Entwurf. Speichern oder validieren erzeugt ihn.")
|
||||||
|
catalog.forEach { (id, element) ->
|
||||||
|
val entry = element as? JsonObject ?: return@forEach
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(entry.string("movement_label", entry.string("name", id)), fontWeight = FontWeight.Bold)
|
||||||
|
Text(id, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text("Cluster: ${entry.string("movement_cluster", "general")}")
|
||||||
|
Text("Varianten: ${entry.array("variants").size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ValidationPane(state: EditorUiState, vm: EditorViewModel) {
|
||||||
|
val validation = state.validation
|
||||||
|
val errors = validation.array("errors")
|
||||||
|
val warnings = validation.array("warnings")
|
||||||
|
val diff = validation.obj("diff")
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Validierung und Vorschau", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Validieren") }
|
||||||
|
OutlinedButton(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Speichern") }
|
||||||
|
}
|
||||||
|
if (validation.isEmpty()) Text("Noch keine Prüfung für den aktuellen Entwurf ausgeführt.")
|
||||||
|
errors.forEach { Text("Fehler: ${it.toString().trim('"')}", color = MaterialTheme.colorScheme.error) }
|
||||||
|
warnings.forEach { Text("Warnung: ${it.toString().trim('"')}") }
|
||||||
|
if (validation.isNotEmpty() && errors.isEmpty()) AssistChip(onClick = {}, label = { Text("Keine Validierungsfehler") })
|
||||||
|
if (diff.isNotEmpty()) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Änderungsübersicht", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Bestehende Sessions: ${diff.int("tracked_sessions")}")
|
||||||
|
listOf("days" to "Tage", "rotations" to "Blöcke", "exercises" to "Übungen", "progression_steps" to "Progressionsstufen").forEach { (key, label) ->
|
||||||
|
val section = diff.obj(key)
|
||||||
|
Text("$label: +${section.array("added").size} / −${section.array("removed").size} / ~${section.array("changed").size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrackerPreview(state.config)
|
||||||
|
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty && errors.isEmpty(), modifier = Modifier.fillMaxWidth()) { Text("Geprüften Entwurf veröffentlichen") }
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackerPreview(config: JsonObject) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Tracker-Vorschau", style = MaterialTheme.typography.titleMedium)
|
||||||
|
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||||
|
Text("Tag ${day.int("num")}: ${day.string("focus")}", fontWeight = FontWeight.Bold)
|
||||||
|
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||||
|
Text(rotation.string("label"))
|
||||||
|
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise -> Text("• ${exercise.string("name")}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProposalsPane(state: EditorUiState, vm: EditorViewModel, showDays: () -> Unit) {
|
||||||
|
val proposals = state.proposals.array("proposals").mapNotNull { it as? JsonObject }
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("KI-Vorschläge", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text("Vorschläge verändern den Plan niemals automatisch. Annahme und Ablehnung markieren ausschließlich den Prüfstatus.")
|
||||||
|
if (proposals.isEmpty()) Text("Keine Vorschläge vorhanden.")
|
||||||
|
proposals.forEach { proposal ->
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(proposal.string("target_label", proposal.string("target")), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||||
|
AssistChip(onClick = {}, label = { Text(proposal.string("status", "open")) })
|
||||||
|
}
|
||||||
|
Text(proposal.string("suggested_change"), fontWeight = FontWeight.Bold)
|
||||||
|
Text(proposal.string("reason"))
|
||||||
|
if (proposal.string("condition").isNotBlank()) Text("Bedingung: ${proposal.string("condition")}")
|
||||||
|
if (proposal.string("manual_step").isNotBlank()) Text("Manueller Schritt: ${proposal.string("manual_step")}")
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
vm.navigateToProposal(proposal.string("target_exercise_id"))
|
||||||
|
showDays()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Zum Ziel navigieren") }
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { vm.setProposalStatus(proposal.string("id"), "accepted") }, modifier = Modifier.weight(1f)) { Text("Annehmen") }
|
||||||
|
OutlinedButton(onClick = { vm.setProposalStatus(proposal.string("id"), "rejected") }, modifier = Modifier.weight(1f)) { Text("Ablehnen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameDialog(
|
||||||
|
title: String,
|
||||||
|
initial: String,
|
||||||
|
allowClone: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember(initial) { mutableStateOf(initial) }
|
||||||
|
var clone by remember { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||||
|
if (allowClone) Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Checkbox(clone, { clone = it })
|
||||||
|
Text("Aktuellen Plan als Vorlage verwenden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(name, clone) }, enabled = name.isNotBlank()) { Text("Übernehmen") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.editor
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||||
|
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
import de.drazz.boehmitools.training.domain.array
|
||||||
|
import de.drazz.boehmitools.training.domain.int
|
||||||
|
import de.drazz.boehmitools.training.domain.obj
|
||||||
|
import de.drazz.boehmitools.training.domain.string
|
||||||
|
import de.drazz.boehmitools.training.domain.updated
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedBool
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedInt
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedString
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
data class EditorPlanSummary(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val revision: Int,
|
||||||
|
val publishedRevision: Int,
|
||||||
|
val active: Boolean,
|
||||||
|
val hasUnpublishedChanges: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EditorUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val plans: List<EditorPlanSummary> = emptyList(),
|
||||||
|
val selectedPlanId: String = "",
|
||||||
|
val wrapper: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val config: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val validation: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val proposals: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val dirty: Boolean = false,
|
||||||
|
val message: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
val conflict: RevisionConflictException? = null,
|
||||||
|
val proposalTargetId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EditorViewModel @Inject constructor(
|
||||||
|
private val repository: TrainingRepository,
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(EditorUiState())
|
||||||
|
val state: StateFlow<EditorUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init { loadPlans() }
|
||||||
|
|
||||||
|
fun loadPlans(force: Boolean = false) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = "", message = "")
|
||||||
|
runCatching { repository.editorPlans(force) }
|
||||||
|
.onSuccess { body ->
|
||||||
|
val plans = body.array("plans").mapNotNull { it as? JsonObject }.map {
|
||||||
|
EditorPlanSummary(
|
||||||
|
id = it.string("id"),
|
||||||
|
name = it.string("name", it.string("id")),
|
||||||
|
revision = it.int("revision", 1),
|
||||||
|
publishedRevision = it.int("published_revision", 1),
|
||||||
|
active = it["active"]?.toString() == "true",
|
||||||
|
hasUnpublishedChanges = it["has_unpublished_changes"]?.toString() == "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stored = settings.selectedEditorPlan()
|
||||||
|
val selected = _state.value.selectedPlanId.takeIf { id -> plans.any { it.id == id } }
|
||||||
|
?: stored?.takeIf { id -> plans.any { it.id == id } }
|
||||||
|
?: body.string("active").takeIf { id -> plans.any { it.id == id } }
|
||||||
|
?: plans.firstOrNull()?.id.orEmpty()
|
||||||
|
_state.value = _state.value.copy(loading = false, plans = plans)
|
||||||
|
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, selectedPlanId = planId, error = "", conflict = null)
|
||||||
|
runCatching {
|
||||||
|
if (persistServer) repository.selectEditorPlan(planId)
|
||||||
|
settings.setSelectedEditorPlan(planId)
|
||||||
|
val wrapper = repository.editorPlan(planId, force = true)
|
||||||
|
val proposals = repository.proposals(planId)
|
||||||
|
wrapper to proposals
|
||||||
|
}.onSuccess { (wrapper, proposals) ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
wrapper = wrapper,
|
||||||
|
config = wrapper.obj("config"),
|
||||||
|
proposals = proposals,
|
||||||
|
validation = JsonObject(emptyMap()),
|
||||||
|
dirty = false,
|
||||||
|
message = "",
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
_state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlan(name: String, cloneCurrent: Boolean) {
|
||||||
|
if (name.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = "")
|
||||||
|
runCatching { repository.createPlan(name.trim(), _state.value.selectedPlanId.takeIf { cloneCurrent }) }
|
||||||
|
.onSuccess { created ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
selectedPlanId = created.string("id"),
|
||||||
|
wrapper = created,
|
||||||
|
config = created.obj("config"),
|
||||||
|
message = "Plan angelegt.",
|
||||||
|
)
|
||||||
|
loadPlans(force = true)
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(saving = false, error = error.message ?: "Plan konnte nicht angelegt werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renamePlan(name: String) {
|
||||||
|
val id = _state.value.selectedPlanId
|
||||||
|
if (id.isBlank() || name.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.renamePlan(id, name.trim()) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.value = _state.value.copy(wrapper = _state.value.wrapper.updatedString("name", name.trim()), message = "Name geändert.")
|
||||||
|
loadPlans(force = true)
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Umbenennen fehlgeschlagen.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePlan() {
|
||||||
|
val id = _state.value.selectedPlanId
|
||||||
|
if (id.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.deletePlan(id) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.value = EditorUiState(message = "Plan gelöscht.")
|
||||||
|
loadPlans(force = true)
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Löschen fehlgeschlagen.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMeta(field: String, value: String) {
|
||||||
|
val meta = _state.value.config.obj("meta").updatedString(field, value)
|
||||||
|
mutateConfig(_state.value.config.updated("meta", meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWeeks(value: Int) {
|
||||||
|
val meta = _state.value.config.obj("meta").updatedInt("weeks", value.coerceAtLeast(1))
|
||||||
|
mutateConfig(_state.value.config.updated("meta", meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePrePost(dayNumber: Int, field: String, value: String) {
|
||||||
|
val config = _state.value.config
|
||||||
|
val prepost = config.obj("prepost").toMutableMap()
|
||||||
|
val dayKey = dayNumber.toString()
|
||||||
|
val entry = (prepost[dayKey] as? JsonObject ?: JsonObject(emptyMap())).updatedString(field, value)
|
||||||
|
prepost[dayKey] = entry
|
||||||
|
mutateConfig(config.updated("prepost", JsonObject(prepost)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDay(dayId: String, transform: (JsonObject) -> JsonObject) {
|
||||||
|
val days = _state.value.config.array("days").map { element ->
|
||||||
|
val day = element as? JsonObject ?: return@map element
|
||||||
|
if (day.string("id") == dayId) transform(day) else day
|
||||||
|
}
|
||||||
|
mutateConfig(_state.value.config.updated("days", JsonArray(days)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDay() {
|
||||||
|
val config = _state.value.config
|
||||||
|
val days = config.array("days").toMutableList()
|
||||||
|
val number = (days.mapNotNull { (it as? JsonObject)?.int("num") }.maxOrNull() ?: 0) + 1
|
||||||
|
days += buildJsonObject {
|
||||||
|
put("id", stableId("day"))
|
||||||
|
put("num", number)
|
||||||
|
put("focus", "Neuer Trainingstag")
|
||||||
|
put("light", false)
|
||||||
|
put("rotations", JsonArray(emptyList()))
|
||||||
|
}
|
||||||
|
val prepost = config.obj("prepost").toMutableMap()
|
||||||
|
prepost[number.toString()] = buildJsonObject {
|
||||||
|
put("warmup", "")
|
||||||
|
put("cooldown", "")
|
||||||
|
put("stretch", "")
|
||||||
|
}
|
||||||
|
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDay(dayId: String) {
|
||||||
|
val config = _state.value.config
|
||||||
|
val removed = config.array("days").mapNotNull { it as? JsonObject }.firstOrNull { it.string("id") == dayId }
|
||||||
|
val days = config.array("days").filterNot { (it as? JsonObject)?.string("id") == dayId }
|
||||||
|
val prepost = config.obj("prepost").toMutableMap()
|
||||||
|
removed?.let { prepost.remove(it.int("num").toString()) }
|
||||||
|
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRotation(dayId: String) {
|
||||||
|
updateDay(dayId) { day ->
|
||||||
|
val rotations = day.array("rotations").toMutableList()
|
||||||
|
rotations += buildJsonObject {
|
||||||
|
put("id", stableId("rotation"))
|
||||||
|
put("label", "Neuer Block")
|
||||||
|
put("exercises", JsonArray(emptyList()))
|
||||||
|
}
|
||||||
|
day.updated("rotations", JsonArray(rotations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRotation(dayId: String, rotationId: String, transform: (JsonObject) -> JsonObject) {
|
||||||
|
updateDay(dayId) { day ->
|
||||||
|
val rotations = day.array("rotations").map { element ->
|
||||||
|
val rotation = element as? JsonObject ?: return@map element
|
||||||
|
if (rotation.string("id") == rotationId) transform(rotation) else rotation
|
||||||
|
}
|
||||||
|
day.updated("rotations", JsonArray(rotations))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRotation(dayId: String, rotationId: String) {
|
||||||
|
updateDay(dayId) { day ->
|
||||||
|
day.updated("rotations", JsonArray(day.array("rotations").filterNot { (it as? JsonObject)?.string("id") == rotationId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addExercise(dayId: String, rotationId: String) {
|
||||||
|
updateRotation(dayId, rotationId) { rotation ->
|
||||||
|
val exercises = rotation.array("exercises").toMutableList()
|
||||||
|
val movementId = stableId("movement")
|
||||||
|
exercises += buildJsonObject {
|
||||||
|
put("id", stableId("exercise-placement"))
|
||||||
|
put("legacy_id", "")
|
||||||
|
put("exercise_id", movementId)
|
||||||
|
put("progression_id", "")
|
||||||
|
put("name", "Neue Übung")
|
||||||
|
put("cue", "")
|
||||||
|
put("movement_cluster", "general")
|
||||||
|
put("result_schema", defaultSchema())
|
||||||
|
}
|
||||||
|
rotation.updated("exercises", JsonArray(exercises))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateExercise(dayId: String, rotationId: String, exerciseId: String, transform: (JsonObject) -> JsonObject) {
|
||||||
|
updateRotation(dayId, rotationId) { rotation ->
|
||||||
|
val exercises = rotation.array("exercises").map { element ->
|
||||||
|
val exercise = element as? JsonObject ?: return@map element
|
||||||
|
if (exercise.string("id") == exerciseId) transform(exercise) else exercise
|
||||||
|
}
|
||||||
|
rotation.updated("exercises", JsonArray(exercises))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeExercise(dayId: String, rotationId: String, exerciseId: String) {
|
||||||
|
updateRotation(dayId, rotationId) { rotation ->
|
||||||
|
rotation.updated("exercises", JsonArray(rotation.array("exercises").filterNot { (it as? JsonObject)?.string("id") == exerciseId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateExerciseSchema(dayId: String, rotationId: String, exerciseId: String, field: String, value: JsonElement) {
|
||||||
|
updateExercise(dayId, rotationId, exerciseId) { exercise ->
|
||||||
|
val schema = (exercise["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||||
|
exercise.updated("result_schema", schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgressionStep(progressionKey: String, stepId: String, transform: (JsonObject) -> JsonObject) {
|
||||||
|
val stages = _state.value.config.obj("stages")
|
||||||
|
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||||
|
val steps = stage.array("steps").map { element ->
|
||||||
|
val step = element as? JsonObject ?: return@map element
|
||||||
|
if (step.string("id") == stepId) transform(step) else step
|
||||||
|
}
|
||||||
|
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||||
|
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addProgressionStep(progressionKey: String) {
|
||||||
|
val stages = _state.value.config.obj("stages")
|
||||||
|
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||||
|
val steps = stage.array("steps").toMutableList()
|
||||||
|
steps += buildJsonObject {
|
||||||
|
put("id", stableId("step"))
|
||||||
|
put("name", "Neue Stufe")
|
||||||
|
put("phase_id", "")
|
||||||
|
put("factor", 1.0)
|
||||||
|
put("movement_cluster", "general")
|
||||||
|
put("result_schema", defaultSchema())
|
||||||
|
}
|
||||||
|
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||||
|
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStepSchema(progressionKey: String, stepId: String, field: String, value: JsonElement) {
|
||||||
|
updateProgressionStep(progressionKey, stepId) { step ->
|
||||||
|
val schema = (step["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||||
|
step.updated("result_schema", schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.selectedPlanId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||||
|
runCatching { repository.savePlan(s.selectedPlanId, s.wrapper.int("revision", 1), s.config) }
|
||||||
|
.onSuccess { body ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
wrapper = body,
|
||||||
|
config = body.obj("config"),
|
||||||
|
validation = body.obj("validation"),
|
||||||
|
dirty = false,
|
||||||
|
message = "Entwurf gespeichert.",
|
||||||
|
)
|
||||||
|
loadPlans(force = true)
|
||||||
|
}
|
||||||
|
.onFailure(::handleFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.selectedPlanId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = s.copy(saving = true, error = "")
|
||||||
|
runCatching { repository.validatePlan(s.selectedPlanId, s.config) }
|
||||||
|
.onSuccess { validation ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
validation = validation,
|
||||||
|
config = validation.obj("config").takeIf { it.isNotEmpty() } ?: s.config,
|
||||||
|
message = if (validation.array("errors").isEmpty()) "Validierung erfolgreich." else "Validierung enthält Fehler.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure(::handleFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publish() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.selectedPlanId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||||
|
runCatching { repository.publishPlan(s.selectedPlanId, s.wrapper.int("revision", 1)) }
|
||||||
|
.onSuccess { body ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
wrapper = body,
|
||||||
|
config = body.obj("config"),
|
||||||
|
validation = body.obj("validation"),
|
||||||
|
dirty = false,
|
||||||
|
message = "Entwurf veröffentlicht.",
|
||||||
|
)
|
||||||
|
loadPlans(force = true)
|
||||||
|
}
|
||||||
|
.onFailure(::handleFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setProposalStatus(proposalId: String, status: String) {
|
||||||
|
val planId = _state.value.selectedPlanId
|
||||||
|
if (planId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.setProposalStatus(planId, proposalId, status) }
|
||||||
|
.onSuccess { body ->
|
||||||
|
val proposals = _state.value.proposals.updated("proposals", body["proposals"] ?: JsonArray(emptyList()))
|
||||||
|
_state.value = _state.value.copy(proposals = proposals, message = "Vorschlag als $status markiert.")
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Vorschlag konnte nicht aktualisiert werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateToProposal(targetExerciseId: String) {
|
||||||
|
_state.value = _state.value.copy(proposalTargetId = targetExerciseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearProposalTarget() { _state.value = _state.value.copy(proposalTargetId = "") }
|
||||||
|
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||||
|
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId) }
|
||||||
|
|
||||||
|
private fun mutateConfig(config: JsonObject) {
|
||||||
|
_state.value = _state.value.copy(config = config, dirty = true, message = "Ungespeicherte Änderungen", error = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFailure(error: Throwable) {
|
||||||
|
_state.value = if (error is RevisionConflictException) {
|
||||||
|
_state.value.copy(saving = false, conflict = error, error = "")
|
||||||
|
} else {
|
||||||
|
_state.value.copy(saving = false, error = error.message ?: "Aktion fehlgeschlagen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stableId(prefix: String): String = "$prefix-${UUID.randomUUID().toString().replace("-", "").take(12)}"
|
||||||
|
|
||||||
|
private fun defaultSchema(): JsonObject = buildJsonObject {
|
||||||
|
put("mode", "reps")
|
||||||
|
put("weight_mode", "none")
|
||||||
|
put("laterality", "bilateral")
|
||||||
|
put("sides_mode", "same")
|
||||||
|
put("sets", 3)
|
||||||
|
put("locked_sets", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.setup
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudDone
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
current: SettingsRepository.PublicSettings,
|
||||||
|
onTheme: (String) -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(contentPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Einstellungen", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
"Basic-Auth-Zugangsdaten werden mit einem Android-Keystore-Schlüssel verschlüsselt. Release-Builds akzeptieren ausschließlich HTTPS.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.serverUrl,
|
||||||
|
onValueChange = viewModel::updateServer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Server-URL") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = viewModel::updateUsername,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Benutzername") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Passwort") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Button(onClick = viewModel::saveAndTest, enabled = !state.loading) {
|
||||||
|
if (state.loading) CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||||
|
else Icon(Icons.Default.CloudDone, null)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
Text("Speichern & Verbindung testen")
|
||||||
|
}
|
||||||
|
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
|
||||||
|
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Darstellung", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = current.theme == value,
|
||||||
|
onClick = { onTheme(value) },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.setup
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val serverUrl: String = SettingsRepository.DEFAULT_SERVER,
|
||||||
|
val username: String = "admin",
|
||||||
|
val password: String = "",
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val message: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
private val repository: TrainingRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(SettingsUiState())
|
||||||
|
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val snapshot = settings.snapshot()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
serverUrl = snapshot.serverUrl,
|
||||||
|
username = snapshot.username,
|
||||||
|
password = snapshot.password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateServer(value: String) { _state.value = _state.value.copy(serverUrl = value, message = "", error = "") }
|
||||||
|
fun updateUsername(value: String) { _state.value = _state.value.copy(username = value, message = "", error = "") }
|
||||||
|
fun updatePassword(value: String) { _state.value = _state.value.copy(password = value, message = "", error = "") }
|
||||||
|
|
||||||
|
fun saveAndTest() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, message = "", error = "")
|
||||||
|
runCatching {
|
||||||
|
settings.saveConnection(_state.value.serverUrl, _state.value.username, _state.value.password)
|
||||||
|
repository.testConnection()
|
||||||
|
}.onSuccess { health ->
|
||||||
|
val versions = health["versions"]?.toString().orEmpty()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
message = if (versions.isBlank()) "Verbindung erfolgreich." else "Verbindung erfolgreich · Vertrag erkannt.",
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
_state.value = _state.value.copy(loading = false, error = error.message ?: "Verbindung fehlgeschlagen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val Light = lightColorScheme(
|
||||||
|
primary = Color(0xFF566600),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Color(0xFFD9F26A),
|
||||||
|
onPrimaryContainer = Color(0xFF172000),
|
||||||
|
secondary = Color(0xFF53643C),
|
||||||
|
tertiary = Color(0xFF00696C),
|
||||||
|
background = Color(0xFFF9FAF2),
|
||||||
|
surface = Color(0xFFF9FAF2),
|
||||||
|
surfaceVariant = Color(0xFFE5E8D8),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Dark = darkColorScheme(
|
||||||
|
primary = Color(0xFFC0D852),
|
||||||
|
onPrimary = Color(0xFF2B3400),
|
||||||
|
primaryContainer = Color(0xFF414D00),
|
||||||
|
onPrimaryContainer = Color(0xFFD9F26A),
|
||||||
|
secondary = Color(0xFFBACCA2),
|
||||||
|
tertiary = Color(0xFF80D4D6),
|
||||||
|
background = Color(0xFF11140E),
|
||||||
|
surface = Color(0xFF11140E),
|
||||||
|
surfaceVariant = Color(0xFF41443A),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TrainingTheme(theme: String, content: @Composable () -> Unit) {
|
||||||
|
val dark = when (theme) {
|
||||||
|
"dark" -> true
|
||||||
|
"light" -> false
|
||||||
|
else -> isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.tracker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.weight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||||
|
import de.drazz.boehmitools.training.domain.DayUi
|
||||||
|
import de.drazz.boehmitools.training.domain.ExerciseUi
|
||||||
|
import de.drazz.boehmitools.training.domain.PlanUi
|
||||||
|
import de.drazz.boehmitools.training.domain.ResultSchemaUi
|
||||||
|
import de.drazz.boehmitools.training.domain.array
|
||||||
|
import de.drazz.boehmitools.training.domain.bool
|
||||||
|
import de.drazz.boehmitools.training.domain.emptyResultData
|
||||||
|
import de.drazz.boehmitools.training.domain.formatResultData
|
||||||
|
import de.drazz.boehmitools.training.domain.int
|
||||||
|
import de.drazz.boehmitools.training.domain.itemFor
|
||||||
|
import de.drazz.boehmitools.training.domain.normalizedResultData
|
||||||
|
import de.drazz.boehmitools.training.domain.obj
|
||||||
|
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||||
|
import de.drazz.boehmitools.training.domain.resultDataWithSets
|
||||||
|
import de.drazz.boehmitools.training.domain.resultDataWithValue
|
||||||
|
import de.drazz.boehmitools.training.domain.string
|
||||||
|
import de.drazz.boehmitools.training.domain.trackerSession
|
||||||
|
import de.drazz.boehmitools.training.domain.updated
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedString
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TrackerScreen(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
viewModel: TrackerViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val pending by viewModel.pending.collectAsStateWithLifecycle()
|
||||||
|
val currentPending = pending.filter { it.planId == state.selectedPlanId }
|
||||||
|
var planMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.padding(contentPadding),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(state.plan?.title ?: "boehmitools Training", maxLines = 2)
|
||||||
|
if (state.saveState.isNotBlank()) Text(state.saveState, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { planMenu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Plan wechseln")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||||
|
state.plans.forEach { plan ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(plan.title) },
|
||||||
|
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
val tabs = listOf("session" to "Session", "progress" to "Fortschritt", "plan" to "Plan", "faq" to "FAQ")
|
||||||
|
ScrollableTabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) {
|
||||||
|
tabs.forEach { (id, label) ->
|
||||||
|
Tab(selected = state.tab == id, onClick = { viewModel.setTab(id) }, text = { Text(label) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
state.loading && state.plan == null -> Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||||
|
state.error.isNotBlank() && state.plan == null -> ErrorPane(state.error) { viewModel.loadPlans(force = true) }
|
||||||
|
state.plan == null -> ErrorPane("Kein Trainingsplan verfügbar.") { viewModel.loadPlans(force = true) }
|
||||||
|
else -> when (state.tab) {
|
||||||
|
"progress" -> ProgressPane(state, currentPending, viewModel)
|
||||||
|
"plan" -> PlanPane(state.plan!!)
|
||||||
|
"faq" -> FaqPane(state)
|
||||||
|
else -> SessionPane(state, currentPending, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.conflict?.let { conflict ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissConflict,
|
||||||
|
title = { Text("Revisionskonflikt") },
|
||||||
|
text = { Text("Der Server steht bereits auf Revision ${conflict.currentRevision}. Deine lokale Änderung wird nicht automatisch überschrieben. Lade den Serverstand neu und wende die Änderung bewusst erneut an.") },
|
||||||
|
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||||
|
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Lokale Ansicht behalten") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorPane(message: String, retry: () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.error)
|
||||||
|
Button(onClick = retry) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.width(8.dp)); Text("Neu laden") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||||
|
val plan = state.plan ?: return
|
||||||
|
val day = plan.days.firstOrNull { it.num == state.day } ?: return
|
||||||
|
val session = trackerSession(state.tracker, de.drazz.boehmitools.training.domain.sessionKey(state.week, state.day), plan)
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||||
|
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||||
|
WeekDayPicker(plan, state.week, state.day, vm)
|
||||||
|
SessionHeader(plan, day, session, state.week, vm)
|
||||||
|
|
||||||
|
SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm)
|
||||||
|
day.rotations.forEach { rotation ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
rotation.exercises.forEach { exercise ->
|
||||||
|
ExerciseCard(exercise, session, plan, vm)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SequenceCard("Cooldown", "cooldown", day.cooldown, session, vm)
|
||||||
|
SequenceCard("Stretch", "stretch", day.stretch, session, vm)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PendingQueue(pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(Icons.Default.CloudOff, null)
|
||||||
|
Text("${pending.size} ausstehende Änderung(en)", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
pending.take(3).forEach { item ->
|
||||||
|
Text("${item.sessionKey} · Revision ${item.expectedRevision} · ${item.state}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (item.state == PendingMutation.STATE_CONFLICT) {
|
||||||
|
Text(item.lastError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
TextButton(onClick = { vm.discardQueuedConflict(item.id) }) { Text("Konflikt-Patch verwerfen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekDayPicker(plan: PlanUi, week: Int, day: Int, vm: TrackerViewModel) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
(1..plan.weeks).forEach { value -> FilterChip(selected = value == week, onClick = { vm.setWeek(value) }, label = { Text("W$value") }) }
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
plan.days.forEach { value -> FilterChip(selected = value.num == day, onClick = { vm.setDay(value.num) }, label = { Text("Tag ${value.num}") }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) {
|
||||||
|
val status = session.string("status", "planned")
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Text(day.focus, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $status", style = MaterialTheme.typography.bodySmall)
|
||||||
|
when (status) {
|
||||||
|
"in_progress" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { vm.setSessionStatus("completed") }) { Icon(Icons.Default.CheckCircle, null); Spacer(Modifier.width(6.dp)); Text("Abschließen") }
|
||||||
|
OutlinedButton(onClick = { vm.setSessionStatus("stopped") }) { Icon(Icons.Default.Stop, null); Spacer(Modifier.width(6.dp)); Text("Stoppen") }
|
||||||
|
}
|
||||||
|
"stopped" -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Fortsetzen") }
|
||||||
|
"completed" -> OutlinedButton(onClick = { vm.setSessionStatus("in_progress") }) { Text("Wieder öffnen") }
|
||||||
|
else -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Session starten") }
|
||||||
|
}
|
||||||
|
TextButton(onClick = vm::resetSession) { Text("Session zurücksetzen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SequenceCard(title: String, prefix: String, items: List<String>, session: JsonObject, vm: TrackerViewModel) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
items.forEachIndexed { index, label ->
|
||||||
|
val id = "$prefix-$index"
|
||||||
|
val item = itemFor(session, id)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Checkbox(checked = item.string("completion_status") == "completed" || item.bool("done"), onCheckedChange = { vm.setSequenceStatus(id, it) })
|
||||||
|
Text(label, modifier = Modifier.padding(top = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExerciseCard(exercise: ExerciseUi, session: JsonObject, plan: PlanUi, vm: TrackerViewModel) {
|
||||||
|
val item = itemFor(session, exercise.id)
|
||||||
|
val status = item.string("completion_status", if (item.bool("done")) "completed" else "planned")
|
||||||
|
val progression = item.string("progression")
|
||||||
|
val progressionModel = plan.progressions[exercise.progressionId]
|
||||||
|
val schema = resolveResultSchema(exercise, item, progression, plan)
|
||||||
|
val data = normalizedResultData(item, schema)
|
||||||
|
var progressionMenu by remember(exercise.id, progression) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(exercise.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
if (exercise.cue.isNotBlank()) Text(exercise.cue, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text("ID ${exercise.id}", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Status", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
listOf("planned" to "Offen", "completed" to "Erledigt", "partial" to "Teilweise", "skipped" to "Übersprungen").forEach { (value, label) ->
|
||||||
|
FilterChip(selected = status == value, onClick = { vm.setItemStatus(exercise.id, value) }, label = { Text(label) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status == "skipped") {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = item.string("skip_reason"),
|
||||||
|
onValueChange = { vm.setItemText(exercise.id, "skip_reason", it) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Grund") },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = progression,
|
||||||
|
onValueChange = { vm.setItemProgression(exercise.id, it) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Progressionsstufe") },
|
||||||
|
supportingText = { Text("Stufe überschreibt bei Bedarf das Ergebnisschema") },
|
||||||
|
)
|
||||||
|
if (progressionModel?.steps?.isNotEmpty() == true) {
|
||||||
|
Box {
|
||||||
|
OutlinedButton(onClick = { progressionMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Stufe aus Plan auswählen")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = progressionMenu, onDismissRequest = { progressionMenu = false }) {
|
||||||
|
progressionModel.steps.forEach { step ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(step.name) },
|
||||||
|
onClick = { progressionMenu = false; vm.setItemProgression(exercise.id, step.name, step.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ManualResultFormatEditor(
|
||||||
|
inherited = schema,
|
||||||
|
manualOverride = item["result_format"] as? JsonObject,
|
||||||
|
onChange = { vm.setItemResultFormat(exercise.id, it) },
|
||||||
|
)
|
||||||
|
ResultEditor(schema, data) { vm.setItemResultData(exercise.id, it) }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = item.string("note"),
|
||||||
|
onValueChange = { vm.setItemText(exercise.id, "note", it) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Sessionnotiz") },
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ManualResultFormatEditor(
|
||||||
|
inherited: ResultSchemaUi,
|
||||||
|
manualOverride: JsonObject?,
|
||||||
|
onChange: (JsonObject?) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember(manualOverride) { mutableStateOf(false) }
|
||||||
|
val mode = manualOverride?.string("mode", inherited.mode) ?: inherited.mode
|
||||||
|
val weightMode = manualOverride?.string("weight_mode", inherited.weightMode) ?: inherited.weightMode
|
||||||
|
val laterality = manualOverride?.string("laterality", inherited.laterality) ?: inherited.laterality
|
||||||
|
val sidesMode = if (laterality == "unilateral") {
|
||||||
|
manualOverride?.string("sides_mode", inherited.sidesMode) ?: inherited.sidesMode
|
||||||
|
} else {
|
||||||
|
"same"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changed(field: String, value: String): JsonObject {
|
||||||
|
val values = linkedMapOf<String, kotlinx.serialization.json.JsonElement>(
|
||||||
|
"mode" to JsonPrimitive(mode),
|
||||||
|
"weight_mode" to JsonPrimitive(weightMode),
|
||||||
|
"laterality" to JsonPrimitive(laterality),
|
||||||
|
"sides_mode" to JsonPrimitive(sidesMode),
|
||||||
|
)
|
||||||
|
values[field] = JsonPrimitive(value)
|
||||||
|
if ((values["laterality"] as JsonPrimitive).content == "bilateral") {
|
||||||
|
values["sides_mode"] = JsonPrimitive("same")
|
||||||
|
}
|
||||||
|
return JsonObject(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Manuelle Session-Anpassung", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Text(
|
||||||
|
if (manualOverride == null) "Vererbt: ${inherited.source}" else "Überschreibt Progressionsstufe und Übung",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) "Schließen" else "Anpassen") }
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
ChoiceChips("Messwert", listOf("reps", "seconds", "minutes", "none"), mode) { onChange(changed("mode", it)) }
|
||||||
|
ChoiceChips("Gewicht", listOf("none", "optional", "required"), weightMode) { onChange(changed("weight_mode", it)) }
|
||||||
|
ChoiceChips("Ausführung", listOf("bilateral", "unilateral"), laterality) { onChange(changed("laterality", it)) }
|
||||||
|
if (laterality == "unilateral") {
|
||||||
|
ChoiceChips("Seitenwerte", listOf("same", "separate"), sidesMode) { onChange(changed("sides_mode", it)) }
|
||||||
|
}
|
||||||
|
if (manualOverride != null) {
|
||||||
|
OutlinedButton(onClick = { onChange(null) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Manuelle Anpassung zurücksetzen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceChips(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||||
|
choices.forEach { value ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == value,
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
label = { Text(value) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultEditor(schema: ResultSchemaUi, data: JsonObject, onChange: (JsonObject) -> Unit) {
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Ergebnis · ${schema.source}", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Text("${schema.mode}, ${schema.laterality}, Gewicht ${schema.weightMode}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (schema.mode == "none") {
|
||||||
|
Text("Für diese Stufe ist kein numerischer Messwert vorgesehen.")
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
if (schema.weightMode != "none") {
|
||||||
|
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.toString().orEmpty()
|
||||||
|
OutlinedTextField(
|
||||||
|
value = weight,
|
||||||
|
onValueChange = { raw -> onChange(data.updated("weight_kg", raw.replace(',', '.').toDoubleOrNull()?.let(::JsonPrimitive) ?: JsonNull)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(if (schema.weightMode == "required") "Gewicht kg (Pflicht)" else "Gewicht kg (optional)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!schema.lockedSets) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = data.int("sets", schema.defaultSets).toString(),
|
||||||
|
onValueChange = { raw -> raw.toIntOrNull()?.let { onChange(resultDataWithSets(data, it)) } },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Sätze") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val count = data.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||||
|
repeat(count) { index ->
|
||||||
|
if (data.string("laterality", schema.laterality) == "unilateral" && data.string("sides_mode", schema.sidesMode) == "separate") {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = data.array("left_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||||
|
onValueChange = { raw -> onChange(resultDataWithValue(data, "left_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Satz ${index + 1} links") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = data.array("right_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||||
|
onValueChange = { raw -> onChange(resultDataWithValue(data, "right_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Satz ${index + 1} rechts") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = data.array("values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||||
|
onValueChange = { raw -> onChange(resultDataWithValue(data, "values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Satz ${index + 1}${if (schema.laterality == "unilateral") " je Seite" else ""}") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Gespeichert als: ${formatResultData(data)}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||||
|
val plan = state.plan ?: return
|
||||||
|
val sessions = state.tracker.obj("sessions").values.mapNotNull { it as? JsonObject }
|
||||||
|
val completed = sessions.count { it.string("status") == "completed" }
|
||||||
|
val stopped = sessions.count { it.string("status") == "stopped" }
|
||||||
|
val analysisState = state.response.obj("analysis_state").takeIf { it.isNotEmpty() }
|
||||||
|
?: state.tracker.obj("analysis_state")
|
||||||
|
val running = analysisState.string("status") == "running"
|
||||||
|
val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() }
|
||||||
|
?: state.tracker.obj("analysis_cache")
|
||||||
|
val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject
|
||||||
|
val overall = cache["overall"] as? JsonObject
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Fortschritt", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("$completed abgeschlossene Sessions · $stopped gestoppte Sessions · ${sessions.size} angelegte Sessions")
|
||||||
|
Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("Wochenstatus", style = MaterialTheme.typography.titleMedium)
|
||||||
|
val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed"
|
||||||
|
FilterChip(selected = closed, onClick = { vm.setWeekClosed(!closed) }, label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") })
|
||||||
|
Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Button(onClick = { vm.startAnalysis("week", state.week) }, enabled = !running) { Text("Woche ${state.week} analysieren") }
|
||||||
|
OutlinedButton(onClick = { vm.startAnalysis("overall") }, enabled = !running) { Text("Gesamtanalyse") }
|
||||||
|
if (running) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(analysisState.string("message", "Analysejob läuft …"))
|
||||||
|
} else if (state.analysisMessage.isNotBlank()) Text(state.analysisMessage)
|
||||||
|
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnalysisCard("Analyse Woche ${state.week}", weekRecord)
|
||||||
|
AnalysisCard("Gesamtanalyse", overall)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnalysisCard(title: String, record: JsonObject?) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (record == null) {
|
||||||
|
Text("Noch keine aktuelle Analyse vorhanden.")
|
||||||
|
} else {
|
||||||
|
val response = record.obj("response")
|
||||||
|
Text(response.string("headline", "Analyse"), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(response.string("summary"))
|
||||||
|
response.array("metrics").mapNotNull { it as? JsonObject }.forEach { metric ->
|
||||||
|
AssistChip(onClick = {}, label = { Text("${metric.string("label")}: ${metric.string("value")}") })
|
||||||
|
}
|
||||||
|
response.array("exercise_updates").mapNotNull { it as? JsonObject }.take(6).forEach { update ->
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text(update.string("name"), fontWeight = FontWeight.Bold)
|
||||||
|
Text(update.string("next_action"))
|
||||||
|
if (update.string("criterion").isNotBlank()) Text("Kriterium: ${update.string("criterion")}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlanPane(plan: PlanUi) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(plan.title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(plan.subtitle)
|
||||||
|
Text("${plan.weeks} Wochen · veröffentlichte Revision ${plan.publishedRevision}")
|
||||||
|
plan.days.forEach { day ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Tag ${day.num}: ${day.focus}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
day.rotations.forEach { rotation ->
|
||||||
|
Text(rotation.label, fontWeight = FontWeight.Bold)
|
||||||
|
rotation.exercises.forEach { exercise -> Text("• ${exercise.name}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FaqPane(state: TrackerUiState) {
|
||||||
|
val plan = state.plan ?: return
|
||||||
|
val guide = state.response.obj("equivalence_guide")
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("FAQ & Variantencluster", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Trainingsformat", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Modus: ${plan.trainingFormat.string("mode", "sets_reps")}")
|
||||||
|
if (plan.trainingFormat.bool("fixed_interval")) {
|
||||||
|
Text("Festes Intervall: ${plan.trainingFormat.int("work_seconds")} s Arbeit / ${plan.trainingFormat.int("rest_seconds")} s Pause · ${plan.trainingFormat.int("rounds")} Intervalle pro Block")
|
||||||
|
Text("Die App implementiert bewusst keinen Timer und verändert dieses Format nicht.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Planbezogene Übungsbibliothek", style = MaterialTheme.typography.titleMedium)
|
||||||
|
plan.exerciseCatalog.values.mapNotNull { it as? JsonObject }.take(100).forEach { entry ->
|
||||||
|
Text("${entry.string("movement_label", entry.string("name"))} · ${entry.array("variants").size} Variante(n)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guide.isNotEmpty()) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Fallbackregeln des Servers", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(guide.toString(), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
package de.drazz.boehmitools.training.ui.tracker
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||||
|
import de.drazz.boehmitools.training.data.repository.ApiException
|
||||||
|
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||||
|
import de.drazz.boehmitools.training.data.repository.SaveOutcome
|
||||||
|
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||||
|
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||||
|
import de.drazz.boehmitools.training.domain.DayUi
|
||||||
|
import de.drazz.boehmitools.training.domain.adaptResultDataToSchema
|
||||||
|
import de.drazz.boehmitools.training.domain.PlanUi
|
||||||
|
import de.drazz.boehmitools.training.domain.blankSession
|
||||||
|
import de.drazz.boehmitools.training.domain.formatResultData
|
||||||
|
import de.drazz.boehmitools.training.domain.int
|
||||||
|
import de.drazz.boehmitools.training.domain.migrateLegacyItems
|
||||||
|
import de.drazz.boehmitools.training.domain.mergeResultSchemaLayers
|
||||||
|
import de.drazz.boehmitools.training.domain.obj
|
||||||
|
import de.drazz.boehmitools.training.domain.overlaySessionPatches
|
||||||
|
import de.drazz.boehmitools.training.domain.parsePlan
|
||||||
|
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||||
|
import de.drazz.boehmitools.training.domain.sessionKey
|
||||||
|
import de.drazz.boehmitools.training.domain.sessionPatch
|
||||||
|
import de.drazz.boehmitools.training.domain.string
|
||||||
|
import de.drazz.boehmitools.training.domain.trackerSession
|
||||||
|
import de.drazz.boehmitools.training.domain.updateItem
|
||||||
|
import de.drazz.boehmitools.training.domain.updateTrackerSession
|
||||||
|
import de.drazz.boehmitools.training.domain.updated
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedBool
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedInt
|
||||||
|
import de.drazz.boehmitools.training.domain.updatedString
|
||||||
|
import java.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
data class TrackerPlanSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val weeks: Int,
|
||||||
|
val completedSessions: Int,
|
||||||
|
val sourceChanged: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TrackerUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val error: String = "",
|
||||||
|
val plans: List<TrackerPlanSummary> = emptyList(),
|
||||||
|
val selectedPlanId: String = "",
|
||||||
|
val response: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val plan: PlanUi? = null,
|
||||||
|
val tracker: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val tab: String = "session",
|
||||||
|
val week: Int = 1,
|
||||||
|
val day: Int = 1,
|
||||||
|
val saveState: String = "",
|
||||||
|
val conflict: RevisionConflictException? = null,
|
||||||
|
val analysisMessage: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TrackerViewModel @Inject constructor(
|
||||||
|
private val repository: TrainingRepository,
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(TrackerUiState())
|
||||||
|
val state: StateFlow<TrackerUiState> = _state.asStateFlow()
|
||||||
|
val pending = repository.pendingMutations().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
private var saveJob: Job? = null
|
||||||
|
private var pollJob: Job? = null
|
||||||
|
private val saveMutex = Mutex()
|
||||||
|
private val wireJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||||
|
|
||||||
|
init { loadPlans() }
|
||||||
|
|
||||||
|
fun loadPlans(force: Boolean = false) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = "")
|
||||||
|
runCatching { repository.trackerPlans(force) }
|
||||||
|
.onSuccess { payload ->
|
||||||
|
val plans = payload["plans"]?.jsonArray?.mapNotNull { it as? JsonObject }?.map { item ->
|
||||||
|
TrackerPlanSummary(
|
||||||
|
id = item.string("id"),
|
||||||
|
title = item.string("title", item.string("name")),
|
||||||
|
subtitle = item.string("subtitle"),
|
||||||
|
weeks = item.int("weeks", 1),
|
||||||
|
completedSessions = item.int("completed_sessions"),
|
||||||
|
sourceChanged = item["source_changed"]?.jsonPrimitive?.content == "true",
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
val preferred = settings.selectedTrackerPlan()
|
||||||
|
val selected = preferred?.takeIf { id -> plans.any { it.id == id } }
|
||||||
|
?: payload.string("selected").takeIf { id -> plans.any { it.id == id } }
|
||||||
|
?: plans.firstOrNull()?.id.orEmpty()
|
||||||
|
_state.value = _state.value.copy(plans = plans, loading = false)
|
||||||
|
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = "", conflict = null)
|
||||||
|
runCatching {
|
||||||
|
if (persistServer) repository.selectTrackerPlan(planId)
|
||||||
|
settings.setSelectedTrackerPlan(planId)
|
||||||
|
val ui = settings.trackerUi(planId)
|
||||||
|
Triple(repository.trackerPlan(planId, force = true), ui, repository.pendingForPlan(planId))
|
||||||
|
}.onSuccess { (response, ui, queued) ->
|
||||||
|
val plan = parsePlan(response.obj("plan"))
|
||||||
|
var tracker = overlayPendingPatches(response.obj("tracker"), queued)
|
||||||
|
val week = ui.week.coerceIn(1, plan.weeks)
|
||||||
|
val day = ui.day.takeIf { candidate -> plan.days.any { it.num == candidate } } ?: plan.days.firstOrNull()?.num ?: 1
|
||||||
|
val currentDay = plan.days.firstOrNull { it.num == day }
|
||||||
|
if (currentDay != null) {
|
||||||
|
val key = sessionKey(week, day)
|
||||||
|
val (migrated, changed) = migrateLegacyItems(trackerSession(tracker, key, plan), currentDay)
|
||||||
|
if (changed) tracker = updateTrackerSession(tracker, key, migrated)
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
loading = false,
|
||||||
|
selectedPlanId = planId,
|
||||||
|
response = response,
|
||||||
|
plan = plan,
|
||||||
|
tracker = tracker,
|
||||||
|
tab = ui.tab,
|
||||||
|
week = week,
|
||||||
|
day = day,
|
||||||
|
saveState = "",
|
||||||
|
)
|
||||||
|
if (response.obj("tracker").obj("analysis_state").string("status") == "running") startPolling()
|
||||||
|
}.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTab(tab: String) {
|
||||||
|
_state.value = _state.value.copy(tab = tab)
|
||||||
|
persistUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWeek(week: Int) {
|
||||||
|
val plan = _state.value.plan ?: return
|
||||||
|
_state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks))
|
||||||
|
migrateCurrentLegacy()
|
||||||
|
persistUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDay(day: Int) {
|
||||||
|
val plan = _state.value.plan ?: return
|
||||||
|
if (plan.days.none { it.num == day }) return
|
||||||
|
_state.value = _state.value.copy(day = day)
|
||||||
|
migrateCurrentLegacy()
|
||||||
|
persistUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistUi() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.selectedPlanId.isBlank()) return
|
||||||
|
viewModelScope.launch { settings.saveTrackerUi(s.selectedPlanId, s.tab, s.week, s.day) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentDay(): DayUi? = _state.value.plan?.days?.firstOrNull { it.num == _state.value.day }
|
||||||
|
fun currentSession(): JsonObject? {
|
||||||
|
val s = _state.value
|
||||||
|
val plan = s.plan ?: return null
|
||||||
|
return trackerSession(s.tracker, sessionKey(s.week, s.day), plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSessionStatus(status: String) {
|
||||||
|
mutateSession { session ->
|
||||||
|
var next = session.updatedString("status", status)
|
||||||
|
val now = Instant.now().toString()
|
||||||
|
when (status) {
|
||||||
|
"in_progress" -> if (session.string("started_at").isBlank()) next = next.updatedString("started_at", now)
|
||||||
|
"stopped" -> next = next.updatedString("stopped_at", now)
|
||||||
|
"completed" -> next = next.updatedString("completed_at", now)
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSession() {
|
||||||
|
val plan = _state.value.plan ?: return
|
||||||
|
val key = sessionKey(_state.value.week, _state.value.day)
|
||||||
|
val tracker = updateTrackerSession(_state.value.tracker, key, blankSession(plan))
|
||||||
|
_state.value = _state.value.copy(tracker = tracker)
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSequenceStatus(id: String, done: Boolean) {
|
||||||
|
mutateSession { session ->
|
||||||
|
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||||
|
.updatedString("completion_status", if (done) "completed" else "planned")
|
||||||
|
.updatedBool("done", done)
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemStatus(id: String, status: String) {
|
||||||
|
mutateSession { session ->
|
||||||
|
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||||
|
.updatedString("completion_status", status)
|
||||||
|
.updatedBool("done", status == "completed" || status == "partial")
|
||||||
|
if (status != "skipped") item = item.updated("skip_reason", null)
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemProgression(id: String, value: String, stepId: String = "") {
|
||||||
|
val plan = _state.value.plan ?: return
|
||||||
|
val exercise = currentDay()?.rotations?.flatMap { it.exercises }?.firstOrNull { it.id == id }
|
||||||
|
mutateSession { session ->
|
||||||
|
var item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString("progression", value)
|
||||||
|
item = if (stepId.isBlank()) item.updated("progression_step_id", null) else item.updatedString("progression_step_id", stepId)
|
||||||
|
val current = item["result_data"] as? JsonObject
|
||||||
|
if (exercise != null && current != null) {
|
||||||
|
val schema = resolveResultSchema(exercise, item, value, plan)
|
||||||
|
val adapted = adaptResultDataToSchema(current, schema)
|
||||||
|
item = if (adapted == null) {
|
||||||
|
item.updated("result_data", null).updatedString("result", "")
|
||||||
|
} else {
|
||||||
|
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemText(id: String, field: String, value: String) {
|
||||||
|
mutateSession { session ->
|
||||||
|
val item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString(field, value)
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemResultData(id: String, data: JsonObject) {
|
||||||
|
mutateSession { session ->
|
||||||
|
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||||
|
.updated("result_data", data)
|
||||||
|
.updatedString("result", de.drazz.boehmitools.training.domain.formatResultData(data))
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setItemResultFormat(id: String, format: JsonObject?) {
|
||||||
|
mutateSession { session ->
|
||||||
|
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||||
|
.updated("result_format", format)
|
||||||
|
val current = item["result_data"] as? JsonObject
|
||||||
|
if (format == null) {
|
||||||
|
item = item.updated("result_data", null).updatedString("result", "")
|
||||||
|
return@mutateSession updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextMode = format.string("mode", "reps")
|
||||||
|
if (nextMode == "none") {
|
||||||
|
item = item.updated("result_data", null).updatedString("result", "")
|
||||||
|
return@mutateSession updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current != null) {
|
||||||
|
val schema = mergeResultSchemaLayers(null, null, format)
|
||||||
|
val adapted = adaptResultDataToSchema(current, schema)
|
||||||
|
item = if (adapted == null) {
|
||||||
|
item.updated("result_data", null).updatedString("result", "")
|
||||||
|
} else {
|
||||||
|
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateItem(session, id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWeekClosed(closed: Boolean) {
|
||||||
|
val s = _state.value
|
||||||
|
val statuses = s.tracker.obj("week_statuses").toMutableMap()
|
||||||
|
statuses[s.week.toString()] = buildJsonObject {
|
||||||
|
put("status", if (closed) "closed" else "open")
|
||||||
|
put("updated_at", Instant.now().toString())
|
||||||
|
}
|
||||||
|
val tracker = s.tracker.updated("week_statuses", JsonObject(statuses))
|
||||||
|
_state.value = s.copy(tracker = tracker)
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mutateSession(block: (JsonObject) -> JsonObject) {
|
||||||
|
val s = _state.value
|
||||||
|
val plan = s.plan ?: return
|
||||||
|
val key = sessionKey(s.week, s.day)
|
||||||
|
val nextSession = block(trackerSession(s.tracker, key, plan))
|
||||||
|
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, nextSession), saveState = "Ungespeichert")
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrateCurrentLegacy() {
|
||||||
|
val s = _state.value
|
||||||
|
val plan = s.plan ?: return
|
||||||
|
val day = plan.days.firstOrNull { it.num == s.day } ?: return
|
||||||
|
val key = sessionKey(s.week, s.day)
|
||||||
|
val (session, changed) = migrateLegacyItems(trackerSession(s.tracker, key, plan), day)
|
||||||
|
if (changed) {
|
||||||
|
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, session))
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSave() {
|
||||||
|
saveJob?.cancel()
|
||||||
|
saveJob = viewModelScope.launch {
|
||||||
|
delay(650)
|
||||||
|
saveCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCurrent() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
saveMutex.withLock {
|
||||||
|
val snapshot = _state.value
|
||||||
|
val plan = snapshot.plan ?: return@withLock
|
||||||
|
val key = sessionKey(snapshot.week, snapshot.day)
|
||||||
|
val session = trackerSession(snapshot.tracker, key, plan)
|
||||||
|
val patch = sessionPatch(snapshot.tracker, key, session)
|
||||||
|
_state.value = _state.value.copy(saveState = "Speichert …")
|
||||||
|
runCatching { repository.patchSession(snapshot.selectedPlanId, key, patch) }
|
||||||
|
.onSuccess { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
is SaveOutcome.Saved -> {
|
||||||
|
val revision = outcome.body.int("revision", snapshot.tracker.int("revision", 1) + 1)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
tracker = _state.value.tracker.updatedInt("revision", revision),
|
||||||
|
saveState = "Gespeichert",
|
||||||
|
response = _state.value.response
|
||||||
|
.updated("analysis_catalog", outcome.body["analysis_catalog"])
|
||||||
|
.updated("analysis_state", outcome.body["analysis_state"]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SaveOutcome.Queued -> {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
tracker = _state.value.tracker.updatedInt("revision", snapshot.tracker.int("revision", 1) + 1),
|
||||||
|
saveState = "Offline vorgemerkt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
if (error is RevisionConflictException) {
|
||||||
|
_state.value = _state.value.copy(conflict = error, saveState = "Konflikt")
|
||||||
|
} else {
|
||||||
|
_state.value = _state.value.copy(saveState = "Fehler", error = error.message ?: "Speichern fehlgeschlagen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overlayPendingPatches(base: JsonObject, queued: List<PendingMutation>): JsonObject {
|
||||||
|
val patches = queued.mapNotNull { mutation ->
|
||||||
|
runCatching { wireJson.parseToJsonElement(mutation.payloadJson).jsonObject }.getOrNull()
|
||||||
|
}
|
||||||
|
return overlaySessionPatches(base, patches)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||||
|
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId, persistServer = false) }
|
||||||
|
fun discardQueuedConflict(id: Long) { viewModelScope.launch { repository.discardConflict(id) } }
|
||||||
|
|
||||||
|
fun startAnalysis(scope: String, week: Int? = null) {
|
||||||
|
val planId = _state.value.selectedPlanId
|
||||||
|
if (planId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(analysisMessage = "Analyse wird gestartet …", error = "")
|
||||||
|
runCatching { repository.analysisStart(planId, scope, week) }
|
||||||
|
.onSuccess { outcome ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
response = _state.value.response
|
||||||
|
.updated("analysis_state", outcome.body["analysis_state"])
|
||||||
|
.updated("analysis_cache", outcome.body["analysis_cache"])
|
||||||
|
.updated("analysis_catalog", outcome.body["analysis_catalog"]),
|
||||||
|
analysisMessage = if (outcome.statusCode == 202) "Serverjob läuft …" else "Aktuelle Analyse aus Cache geladen.",
|
||||||
|
)
|
||||||
|
if (outcome.statusCode == 202) startPolling()
|
||||||
|
}
|
||||||
|
.onFailure { error -> _state.value = _state.value.copy(analysisMessage = "", error = error.message ?: "Analyse fehlgeschlagen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
if (pollJob?.isActive == true) return
|
||||||
|
val planId = _state.value.selectedPlanId
|
||||||
|
pollJob = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(2_000)
|
||||||
|
val body = runCatching { repository.analysisStatus(planId) }.getOrElse {
|
||||||
|
_state.value = _state.value.copy(error = it.message ?: "Analysestatus konnte nicht gelesen werden")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
response = _state.value.response
|
||||||
|
.updated("analysis_state", body["analysis_state"])
|
||||||
|
.updated("analysis_cache", body["analysis_cache"])
|
||||||
|
.updated("analysis_catalog", body["analysis_catalog"]),
|
||||||
|
)
|
||||||
|
val status = body.obj("analysis_state").string("status", "idle")
|
||||||
|
_state.value = _state.value.copy(analysisMessage = body.obj("analysis_state").string("message"))
|
||||||
|
if (status != "running") {
|
||||||
|
selectPlan(planId, persistServer = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#17212B" android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
<path android:fillColor="#D9F26A" android:pathData="M20,46h12v16h-12zM76,46h12v16h-12zM32,50h44v8h-44zM14,42h6v24h-6zM88,42h6v24h-6z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">boehmitools Training</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.BoehmitoolsTraining" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
package de.drazz.boehmitools.training.domain
|
||||||
|
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FixtureContractTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bothPlansRespectVersionAndStableIdContracts() {
|
||||||
|
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||||
|
val wrapper = fixture("samples/plans/$name.json")
|
||||||
|
val config = wrapper.obj("config")
|
||||||
|
assertEquals(3, config.int("schema_version"))
|
||||||
|
assertEquals(2, config.int("contract_version"))
|
||||||
|
assertEquals(name, config.string("plan_id"))
|
||||||
|
|
||||||
|
val dayIds = mutableListOf<String>()
|
||||||
|
val rotationIds = mutableListOf<String>()
|
||||||
|
val placementIds = mutableListOf<String>()
|
||||||
|
val stepIds = mutableListOf<String>()
|
||||||
|
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||||
|
dayIds += day.string("id")
|
||||||
|
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||||
|
rotationIds += rotation.string("id")
|
||||||
|
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||||
|
placementIds += exercise.string("id")
|
||||||
|
assertTrue(exercise.string("legacy_id").matches(Regex("d\\d+-r\\d+-e\\d+")))
|
||||||
|
assertTrue(exercise.string("exercise_id").startsWith("movement-"))
|
||||||
|
assertTrue(exercise.string("progression_id").isNotBlank())
|
||||||
|
assertFalse(exercise.obj("result_schema").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.obj("stages").values.mapNotNull { it as? JsonObject }.forEach { stage ->
|
||||||
|
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||||
|
stepIds += step.string("id")
|
||||||
|
assertFalse(step.obj("result_schema").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listOf(dayIds, rotationIds, placementIds, stepIds).forEach { ids ->
|
||||||
|
assertTrue(ids.all { it.isNotBlank() })
|
||||||
|
assertEquals(ids.size, ids.toSet().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsePlanSupportsBothProvidedEditorConfigs() {
|
||||||
|
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||||
|
val config = fixture("samples/plans/$name.json").obj("config")
|
||||||
|
val plan = parsePlan(config)
|
||||||
|
assertEquals(8, plan.weeks)
|
||||||
|
assertEquals(6, plan.days.size)
|
||||||
|
assertEquals(31, plan.progressions.size)
|
||||||
|
assertTrue(plan.days.flatMap { it.rotations }.flatMap { it.exercises }.isNotEmpty())
|
||||||
|
assertTrue(plan.title.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bothLegacySessionsLoadAndMigrateWithoutDroppingItems() {
|
||||||
|
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||||
|
val plan = parsePlan(fixture("samples/plans/$name.json").obj("config"))
|
||||||
|
val tracker = fixture("samples/sessions/$name.json")
|
||||||
|
assertTrue(tracker.int("version") in 1..7)
|
||||||
|
val firstSessionEntry = tracker.obj("sessions").entries.first()
|
||||||
|
val day = plan.days.first { it.num == firstSessionEntry.key.substringAfter("-d").toInt() }
|
||||||
|
val original = firstSessionEntry.value.jsonObject
|
||||||
|
val originalCount = original.obj("items").size
|
||||||
|
val (migrated, changed) = migrateLegacyItems(original, day)
|
||||||
|
assertTrue(changed)
|
||||||
|
assertEquals(originalCount, migrated.obj("items").size)
|
||||||
|
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||||
|
if (original.obj("items").containsKey(exercise.legacyId)) {
|
||||||
|
assertTrue(migrated.obj("items").containsKey(exercise.id))
|
||||||
|
assertFalse(migrated.obj("items").containsKey(exercise.legacyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resultSchemaPriorityIsManualThenStepThenExerciseThenFallback() {
|
||||||
|
val config = fixture("samples/plans/phase-1.json").obj("config")
|
||||||
|
val plan = parsePlan(config)
|
||||||
|
val exercise = plan.days.flatMap { it.rotations }.flatMap { it.exercises }
|
||||||
|
.first { it.progressionId == "pike" }
|
||||||
|
val progression = plan.progressions.getValue("pike")
|
||||||
|
val step = progression.steps.first { it.resultSchema != null }
|
||||||
|
|
||||||
|
val fromStep = resolveResultSchema(
|
||||||
|
exercise = exercise,
|
||||||
|
item = buildJsonObject {
|
||||||
|
put("progression_step_id", step.id)
|
||||||
|
},
|
||||||
|
progressionText = step.name,
|
||||||
|
plan = plan,
|
||||||
|
)
|
||||||
|
assertEquals("progression", fromStep.source)
|
||||||
|
assertEquals(step.resultSchema?.mode, fromStep.mode)
|
||||||
|
|
||||||
|
val manual = resolveResultSchema(
|
||||||
|
exercise = exercise,
|
||||||
|
item = buildJsonObject {
|
||||||
|
put("result_format", buildJsonObject {
|
||||||
|
put("mode", "minutes")
|
||||||
|
put("weight_mode", "none")
|
||||||
|
put("laterality", "bilateral")
|
||||||
|
put("sides_mode", "same")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
progressionText = step.name,
|
||||||
|
plan = plan,
|
||||||
|
)
|
||||||
|
assertEquals("session", manual.source)
|
||||||
|
assertEquals("minutes", manual.mode)
|
||||||
|
assertNotEquals(fromStep.mode, manual.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun partialProgressionSchemaInheritsUnspecifiedExerciseFields() {
|
||||||
|
val exercise = buildJsonObject {
|
||||||
|
put("mode", "reps")
|
||||||
|
put("weight_mode", "required")
|
||||||
|
put("laterality", "unilateral")
|
||||||
|
put("sides_mode", "separate")
|
||||||
|
put("sets", 4)
|
||||||
|
put("locked_sets", true)
|
||||||
|
}
|
||||||
|
val progression = buildJsonObject { put("mode", "seconds") }
|
||||||
|
val merged = mergeResultSchemaLayers(exercise, progression, null)
|
||||||
|
assertEquals("seconds", merged.mode)
|
||||||
|
assertEquals("required", merged.weightMode)
|
||||||
|
assertEquals("unilateral", merged.laterality)
|
||||||
|
assertEquals("separate", merged.sidesMode)
|
||||||
|
assertEquals(4, merged.defaultSets)
|
||||||
|
assertTrue(merged.lockedSets)
|
||||||
|
assertEquals("progression", merged.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressionUnitChangeNeverReinterpretsOldNumbers() {
|
||||||
|
val current = buildJsonObject {
|
||||||
|
put("version", 2)
|
||||||
|
put("mode", "reps")
|
||||||
|
put("laterality", "bilateral")
|
||||||
|
put("sides_mode", "same")
|
||||||
|
put("sets", 3)
|
||||||
|
put("values", kotlinx.serialization.json.buildJsonArray { add(8); add(7); add(6) })
|
||||||
|
put("left_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||||
|
put("right_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||||
|
put("weight_kg", 8.0)
|
||||||
|
}
|
||||||
|
val adapted = adaptResultDataToSchema(
|
||||||
|
current,
|
||||||
|
ResultSchemaUi(mode = "seconds", weightMode = "none", defaultSets = 2, lockedSets = true),
|
||||||
|
)!!
|
||||||
|
assertEquals("seconds", adapted.string("mode"))
|
||||||
|
assertEquals(2, adapted.int("sets"))
|
||||||
|
assertTrue(adapted.array("values").all { it == kotlinx.serialization.json.JsonNull })
|
||||||
|
assertTrue(adapted["weight_kg"] == kotlinx.serialization.json.JsonNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resultDataV2NormalizesLegacyTextAndSetCount() {
|
||||||
|
val schema = ResultSchemaUi(mode = "seconds", defaultSets = 3)
|
||||||
|
val parsed = parseLegacyResult("3 x 20 s, 8 kg", schema)!!
|
||||||
|
assertEquals(2, parsed.int("version"))
|
||||||
|
assertEquals("seconds", parsed.string("mode"))
|
||||||
|
assertEquals(3, parsed.int("sets"))
|
||||||
|
assertEquals(3, parsed.array("values").size)
|
||||||
|
assertTrue(formatResultData(parsed).contains("20"))
|
||||||
|
assertTrue(formatResultData(parsed).contains("8 kg"))
|
||||||
|
|
||||||
|
val resized = resultDataWithSets(parsed, 20)
|
||||||
|
assertEquals(20, resized.int("sets"))
|
||||||
|
assertEquals(20, resized.array("values").size)
|
||||||
|
assertEquals(20, resultDataWithSets(resized, 99).int("sets"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queuedPatchesOverlayInFifoOrderAfterRestart() {
|
||||||
|
val base = buildJsonObject {
|
||||||
|
put("revision", 5)
|
||||||
|
put("profile", buildJsonObject { put("display_name", "Server") })
|
||||||
|
put("week_statuses", JsonObject(emptyMap()))
|
||||||
|
put("sessions", JsonObject(emptyMap()))
|
||||||
|
}
|
||||||
|
val first = buildJsonObject {
|
||||||
|
put("expected_revision", 5)
|
||||||
|
put("profile", buildJsonObject { put("display_name", "Lokal") })
|
||||||
|
put("session_key", "w01-d01")
|
||||||
|
put("session", buildJsonObject { put("status", "in_progress") })
|
||||||
|
}
|
||||||
|
val second = buildJsonObject {
|
||||||
|
put("expected_revision", 6)
|
||||||
|
put("week_statuses", buildJsonObject { put("1", buildJsonObject { put("status", "closed") }) })
|
||||||
|
put("session_key", "w01-d02")
|
||||||
|
put("session", buildJsonObject { put("status", "completed") })
|
||||||
|
}
|
||||||
|
val overlaid = overlaySessionPatches(base, listOf(first, second))
|
||||||
|
assertEquals(7, overlaid.int("revision"))
|
||||||
|
assertEquals("Lokal", overlaid.obj("profile").string("display_name"))
|
||||||
|
assertEquals("closed", overlaid.obj("week_statuses").obj("1").string("status"))
|
||||||
|
assertEquals("in_progress", overlaid.obj("sessions").obj("w01-d01").string("status"))
|
||||||
|
assertEquals("completed", overlaid.obj("sessions").obj("w01-d02").string("status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun analysesAndProposalsMatchOverwriteOnlyModel() {
|
||||||
|
val week = fixture("samples/analyses/phase-1/week-01.json")
|
||||||
|
val overall = fixture("samples/analyses/phase-1/overall.json")
|
||||||
|
val proposals = fixture("samples/proposals/phase-1.json")
|
||||||
|
assertEquals("week", week.string("type"))
|
||||||
|
assertEquals("overall", overall.string("type"))
|
||||||
|
assertTrue(proposals.array("proposals").isNotEmpty())
|
||||||
|
proposals.array("proposals").mapNotNull { it as? JsonObject }.forEach {
|
||||||
|
assertTrue(it.string("id").isNotBlank())
|
||||||
|
assertTrue(it.string("target_exercise_id").startsWith("movement-"))
|
||||||
|
assertEquals("open", it.string("status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fixture(path: String): JsonObject {
|
||||||
|
val stream = checkNotNull(javaClass.classLoader?.getResourceAsStream(path)) { "Fixture fehlt: $path" }
|
||||||
|
return InputStreamReader(stream).use { json.parseToJsonElement(it.readText()).jsonObject }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"source_file": "phase-1.json",
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"published_revision": 1,
|
||||||
|
"updated_at": "2026-07-23T12:01:33+00:00",
|
||||||
|
"proposals": [
|
||||||
|
{
|
||||||
|
"id": "overall:overall-1",
|
||||||
|
"status": "open",
|
||||||
|
"scope_key": "overall:overall",
|
||||||
|
"analysis_type": "overall",
|
||||||
|
"week": null,
|
||||||
|
"target": "Woche 2 / Pike",
|
||||||
|
"target_label": "Pike: nächste Stufe prüfen",
|
||||||
|
"target_exercise_id": "movement-pike-17f7716e00",
|
||||||
|
"target_progression_id": "pike",
|
||||||
|
"target_step_id": "step-hold-kleine-senkung-4f3bfac4b2",
|
||||||
|
"suggested_change": "Progressionsstufe von „Pike Hold“ auf „Hold + kleine Senkung“ setzen.",
|
||||||
|
"reason": "Zwei Einheiten mit 6x20 s Pike Hold sprechen für einen vorsichtigen Variantenwechsel.",
|
||||||
|
"condition": "Nur wenn beide Pike-Einheiten technisch sauber waren; sonst Pike Hold beibehalten.",
|
||||||
|
"manual_step": "Im Trainingsplan-Modul bei Pike die nächste Progressionsstufe auswählen.",
|
||||||
|
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "overall:overall-2",
|
||||||
|
"status": "open",
|
||||||
|
"scope_key": "overall:overall",
|
||||||
|
"analysis_type": "overall",
|
||||||
|
"week": null,
|
||||||
|
"target": "Woche 2 / Einarm-Rudern ENG",
|
||||||
|
"target_label": "Einarm-Rudern ENG: Last oder Stufe prüfen",
|
||||||
|
"target_exercise_id": "movement-einarm-rudern-eng-kb-4907fbb5c7",
|
||||||
|
"target_progression_id": "rowe",
|
||||||
|
"target_step_id": "step-etwas-mehr-4f2775725c",
|
||||||
|
"suggested_change": "Von „leicht“ auf „etwas mehr“ wechseln.",
|
||||||
|
"reason": "Die Wiederholungen lagen in beiden Einheiten stabil bei etwa 8–9 pro Intervall.",
|
||||||
|
"condition": "Nur wenn Rückenposition und Zugweg sauber bleiben; sonst aktuelle Stufe beibehalten.",
|
||||||
|
"manual_step": "Im Trainingsplan-Modul die nächste Rudern-ENG-Progression oder passende Last eintragen.",
|
||||||
|
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
{
|
||||||
|
"created_at": "2026-07-23T09:14:02+00:00",
|
||||||
|
"profile": {
|
||||||
|
"display_name": "",
|
||||||
|
"plan_notes": "",
|
||||||
|
"start_date": ""
|
||||||
|
},
|
||||||
|
"progressions": {
|
||||||
|
"ls": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:1-2-reps-reserve-pruefen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:beine-protokollieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:brustwirbelsaeule-schultern": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:bulletproof-shoulders": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:core-protokollieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:finger-plank": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:full-body-mobility": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:hueft-mobilitaet": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:lockerer-spaziergang": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:negative-pruefen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:prone-y-t-w-technik": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:pull-protokollieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:push-protokollieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:regeneration-pruefen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:schlaf-priorisieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:schmerzcheck": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:schwierigkeit-erhoehen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:sonst-vollstaendig-pausieren": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:spaziergang": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:sprunggelenk-mobilitaet": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:startposition-pruefen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:volle-reps-pruefen": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"name:wrist-conditioning": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"plank": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"rowe": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"squat": {
|
||||||
|
"note": "",
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"w01-d01": {
|
||||||
|
"completed_at": "",
|
||||||
|
"items": {
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-4": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"status": "planned"
|
||||||
|
},
|
||||||
|
"w02-d01": {
|
||||||
|
"completed_at": "",
|
||||||
|
"items": {
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-4": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"status": "planned"
|
||||||
|
},
|
||||||
|
"w03-d01": {
|
||||||
|
"completed_at": "",
|
||||||
|
"items": {
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-4": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"status": "planned"
|
||||||
|
},
|
||||||
|
"w08-d01": {
|
||||||
|
"completed_at": "",
|
||||||
|
"items": {
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"d1-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-2": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-3": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
},
|
||||||
|
"warmup-4": {
|
||||||
|
"done": false,
|
||||||
|
"note": "",
|
||||||
|
"result": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"status": "planned"
|
||||||
|
},
|
||||||
|
"w01-d02": {
|
||||||
|
"status": "planned",
|
||||||
|
"items": {
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d2-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"cooldown-1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
"w01-d03": {
|
||||||
|
"status": "planned",
|
||||||
|
"items": {
|
||||||
|
"warmup-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"warmup-1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"warmup-2": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"warmup-3": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"warmup-4": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r0-e0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r0-e1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r0-e2": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r0-e3": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r1-e0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r1-e1": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r1-e2": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"d3-r1-e3": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"cooldown-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
"stretch-0": {
|
||||||
|
"done": false,
|
||||||
|
"result": "",
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source_file": "calihoss-newbie-8-wochen.json",
|
||||||
|
"source_hash": "6d9ab7972384bb711693ced004c6a7053fbe858323e98dfea7e7a6b0c56c83b1",
|
||||||
|
"updated_at": "2026-07-23T09:42:21+00:00",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
....ss.s.ssss.s.... [100%]
|
||||||
|
11 passed, 8 skipped in 0.05s
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Date: 2026-07-24T11:29:54+00:00
|
||||||
|
OS: Linux 922d8f789c54 6.12.13 #1 SMP Wed Jul 22 00:33:13 UTC 2026 x86_64 GNU/Linux
|
||||||
|
Java:
|
||||||
|
openjdk version "21.0.10" 2026-01-20
|
||||||
|
OpenJDK Runtime Environment (build 21.0.10+7-Debian-1deb13u1)
|
||||||
|
OpenJDK 64-Bit Server VM (build 21.0.10+7-Debian-1deb13u1, mixed mode, sharing)
|
||||||
|
Kotlinc:
|
||||||
|
info: kotlinc-jvm 1.9.0 (JRE 21.0.10+7-Debian-1deb13u1)
|
||||||
|
ANDROID_HOME=<unset>
|
||||||
|
ANDROID_SDK_ROOT=<unset>
|
||||||
|
gradle=<not installed>
|
||||||
|
sdkmanager=<not installed>
|
||||||
|
adb=<not installed>
|
||||||
|
DNS services.gradle.org:
|
||||||
|
<resolution failed>
|
||||||
|
Gradle cache:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
===== ./gradlew assembleDebug =====
|
||||||
|
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||||
|
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||||
|
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||||
|
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||||
|
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||||
|
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||||
|
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||||
|
EXIT_CODE=1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
===== ./gradlew lintDebug =====
|
||||||
|
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||||
|
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||||
|
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||||
|
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||||
|
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||||
|
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||||
|
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||||
|
EXIT_CODE=1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
===== ./gradlew test =====
|
||||||
|
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||||
|
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||||
|
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||||
|
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||||
|
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||||
|
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||||
|
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||||
|
EXIT_CODE=1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||||
|
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||||
|
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||||
|
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||||
|
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||||
|
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||||
|
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||||
|
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||||
|
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||||
|
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||||
|
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:77)
|
||||||
|
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:32)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
Kotlinc was run without the Android/Gradle dependency classpath. Unresolved Android and library references are expected.
|
||||||
|
Potential parser/syntax diagnostics:
|
||||||
|
none
|
||||||
|
|
||||||
|
Total diagnostics:
|
||||||
|
8017 build-logs/kotlinc-static.log
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PASS: 8 JSON-Dateien und 7 XML-Dateien parsebar
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Production credential/key scan:
|
||||||
|
PASS: no production credential/key pattern found
|
||||||
|
|
||||||
|
Forbidden technology scan:
|
||||||
|
PASS: no WebView or direct OpenAI client endpoint found
|
||||||
|
|
||||||
|
Release cleartext controls:
|
||||||
|
app/src/main/res/xml/network_security_config.xml:3: <base-config cleartextTrafficPermitted="false">
|
||||||
|
app/src/main/AndroidManifest.xml:15: android:usesCleartextTraffic="false">
|
||||||
|
app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt:77: require(BuildConfig.DEBUG || scheme == "https") { "Release-Builds erlauben ausschließlich HTTPS." }
|
||||||
|
app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt:77: require(BuildConfig.DEBUG || scheme == "https") { "Release-Builds erlauben ausschließlich HTTPS." }
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"files": 103,
|
||||||
|
"extensions": {
|
||||||
|
".py": 27,
|
||||||
|
".json": 20,
|
||||||
|
".example": 1,
|
||||||
|
"<none>": 5,
|
||||||
|
".md": 7,
|
||||||
|
".txt": 1,
|
||||||
|
".yml": 1,
|
||||||
|
".css": 1,
|
||||||
|
".js": 3,
|
||||||
|
".tag": 1,
|
||||||
|
".html": 6,
|
||||||
|
".pyc": 29,
|
||||||
|
".lock": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"files": 29,
|
||||||
|
"extensions": {
|
||||||
|
".json": 9,
|
||||||
|
".md": 4,
|
||||||
|
".py": 14,
|
||||||
|
".html": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"files": 8,
|
||||||
|
"extensions": {
|
||||||
|
".md": 1,
|
||||||
|
".json": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plans": [
|
||||||
|
{
|
||||||
|
"file": "calihoss-newbie-8-wochen.json",
|
||||||
|
"plan_id": "calihoss-newbie-8-wochen",
|
||||||
|
"revision": 1,
|
||||||
|
"published_revision": 1,
|
||||||
|
"schema_version": 3,
|
||||||
|
"contract_version": 2,
|
||||||
|
"weeks": 8,
|
||||||
|
"days": 6,
|
||||||
|
"rotations": 12,
|
||||||
|
"exercise_placements": 48,
|
||||||
|
"stages": 31,
|
||||||
|
"steps": 248,
|
||||||
|
"exercise_catalog": 27,
|
||||||
|
"exercise_result_schemas": 48,
|
||||||
|
"step_result_schemas": 248,
|
||||||
|
"stable_ids_nonblank": true,
|
||||||
|
"stable_ids_unique": false,
|
||||||
|
"legacy_ids": 48,
|
||||||
|
"training_mode": "sets_reps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "phase-1.json",
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"revision": 1,
|
||||||
|
"published_revision": 1,
|
||||||
|
"schema_version": 3,
|
||||||
|
"contract_version": 2,
|
||||||
|
"weeks": 8,
|
||||||
|
"days": 6,
|
||||||
|
"rotations": 12,
|
||||||
|
"exercise_placements": 48,
|
||||||
|
"stages": 31,
|
||||||
|
"steps": 248,
|
||||||
|
"exercise_catalog": 40,
|
||||||
|
"exercise_result_schemas": 48,
|
||||||
|
"step_result_schemas": 248,
|
||||||
|
"stable_ids_nonblank": true,
|
||||||
|
"stable_ids_unique": false,
|
||||||
|
"legacy_ids": 48,
|
||||||
|
"training_mode": "tabata"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"file": "calihoss-newbie-8-wochen.json",
|
||||||
|
"version": 1,
|
||||||
|
"sessions": 6,
|
||||||
|
"progressions": 27,
|
||||||
|
"items": 88,
|
||||||
|
"legacy_position_items": 48,
|
||||||
|
"sequence_items": 40,
|
||||||
|
"structured_result_data": 0,
|
||||||
|
"free_text_results": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "phase-1.json",
|
||||||
|
"version": 5,
|
||||||
|
"sessions": 10,
|
||||||
|
"progressions": 36,
|
||||||
|
"items": 197,
|
||||||
|
"legacy_position_items": 80,
|
||||||
|
"sequence_items": 117,
|
||||||
|
"structured_result_data": 0,
|
||||||
|
"free_text_results": 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"analyses": [
|
||||||
|
{
|
||||||
|
"file": "samples/analyses/phase-1/overall.json",
|
||||||
|
"type": "overall",
|
||||||
|
"week": null,
|
||||||
|
"status": "success",
|
||||||
|
"response_keys": [
|
||||||
|
"headline",
|
||||||
|
"summary",
|
||||||
|
"data_quality",
|
||||||
|
"metrics",
|
||||||
|
"exercise_updates",
|
||||||
|
"plan_adjustments",
|
||||||
|
"warnings",
|
||||||
|
"conclusion"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "samples/analyses/phase-1/week-01.json",
|
||||||
|
"type": "week",
|
||||||
|
"week": 1,
|
||||||
|
"status": "success",
|
||||||
|
"response_keys": [
|
||||||
|
"headline",
|
||||||
|
"summary",
|
||||||
|
"data_quality",
|
||||||
|
"metrics",
|
||||||
|
"exercise_updates",
|
||||||
|
"plan_adjustments",
|
||||||
|
"warnings",
|
||||||
|
"conclusion"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"proposals": {
|
||||||
|
"version": 2,
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"published_revision": 1,
|
||||||
|
"count": 2,
|
||||||
|
"statuses": {
|
||||||
|
"open": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"screenshots": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
trainingsplan diff:
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan: __pycache__
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/tests: __pycache__
|
||||||
|
trainingstracker diff:
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker: __pycache__
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests: __pycache__
|
||||||
|
plans diff:
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: couchsnacks.json
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: fruehstueck.json
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: granola.json
|
||||||
|
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: nachmittags.json
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/.dockerignore
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/.env.example
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/.gitignore
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/Dockerfile
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/MANIFEST.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/MIGRATION.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/README.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/RELEASE-NOTES.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__init__.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/__init__.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/ai.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/app.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/backups.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/foodmatch.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/importer.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/jobs.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/loader.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/registry.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/security.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/settings.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/tandoor.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/ai.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/app.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/backups.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/foodmatch.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/importer.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/jobs.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/loader.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/registry.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/security.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/settings.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/static/404.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/static/dashboard.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/static/jobs.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/static/settings.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/core/tandoor.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/settings.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/couchsnacks.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/fruehstueck.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/granola.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/nachmittags.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/phase-1.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/.analysis.lock
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/index.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/state.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/week-01.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/sessions/calihoss-newbie-8-wochen.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/sessions/phase-1.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/settings.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/docker-compose.yml
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/README.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/TOOL-README.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/app.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/backend.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/plan_builder.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/recipe_builder.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/schema_contract.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/storage.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/app.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/backend.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/plan_builder.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/plugin.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/couchsnacks.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/fruehstueck.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/granola.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/nachmittags.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/recipe_builder.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/schema_contract.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/static/index.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/storage.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/tests/test_contract.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/README.md
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/analysis_store.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/app.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/backend.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/contract.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/session_store.cpython-312.pyc
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/analysis_store.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/app.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/backend.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/contract.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/plugin.json
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/session_store.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/static/index.html
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests/test_app.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests/test_stability.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/requirements.txt
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/run.py
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi-backups.js
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi-runner.js
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi.css
|
||||||
|
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi.js
|
||||||
|
samples/training-app-samples/README.md
|
||||||
|
samples/training-app-samples/samples/analyses/phase-1/overall.json
|
||||||
|
samples/training-app-samples/samples/analyses/phase-1/week-01.json
|
||||||
|
samples/training-app-samples/samples/plans/calihoss-newbie-8-wochen.json
|
||||||
|
samples/training-app-samples/samples/plans/phase-1.json
|
||||||
|
samples/training-app-samples/samples/proposals/phase-1.json
|
||||||
|
samples/training-app-samples/samples/sessions/calihoss-newbie-8-wochen.json
|
||||||
|
samples/training-app-samples/samples/sessions/phase-1.json
|
||||||
|
training-tools-v2/MANIFEST.json
|
||||||
|
training-tools-v2/MIGRATION.md
|
||||||
|
training-tools-v2/RELEASE-NOTES.md
|
||||||
|
training-tools-v2/data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||||
|
training-tools-v2/data/trainingsplan/plans/phase-1.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/TOOL-README.md
|
||||||
|
training-tools-v2/plugins/trainingsplan/app.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/backend.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/plan_builder.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/plugin.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/presets/couchsnacks.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/presets/fruehstueck.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/presets/granola.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/presets/nachmittags.json
|
||||||
|
training-tools-v2/plugins/trainingsplan/recipe_builder.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/schema_contract.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/static/index.html
|
||||||
|
training-tools-v2/plugins/trainingsplan/storage.py
|
||||||
|
training-tools-v2/plugins/trainingsplan/tests/test_contract.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/README.md
|
||||||
|
training-tools-v2/plugins/trainingstracker/analysis_store.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/app.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/backend.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/contract.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/plugin.json
|
||||||
|
training-tools-v2/plugins/trainingstracker/session_store.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/static/index.html
|
||||||
|
training-tools-v2/plugins/trainingstracker/tests/test_app.py
|
||||||
|
training-tools-v2/plugins/trainingstracker/tests/test_stability.py
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PASS: 728 Prüfungen
|
||||||
|
EXIT_CODE=0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
471264b9b8fdf46888558ce7f23bacf2e4c958edfac3106b80c065af6e727cf1 gradle/wrapper/gradle-wrapper.jar
|
||||||
|
d16696db59d33ccbed443f46c95e0f44b4636605db3fe515163e99a6d8c006f1 gradle/wrapper/bootstrap-src/org/gradle/wrapper/GradleWrapperMain.java
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.kotlin.kapt) apply false
|
||||||
|
alias(libs.plugins.hilt) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# API-Vertrag boehmitools Training
|
||||||
|
|
||||||
|
Stand: aus dem vollständigen boehmitools-Quellstand und den Plugins `trainingsplan`/`trainingstracker` 2.0 abgeleitet. Die Plugins werden durch die Host-App unter `/plugins/<plugin-id>` gemountet. Alle Pfade unten sind relativ zur konfigurierbaren Serverbasis, standardmäßig `https://tools.d-razz.de/`.
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
Die Host-App schützt alle Pfade außer exakt `/api/health` optional mit HTTP Basic Auth. Ist `APP_PASSWORD` gesetzt, muss der Client `Authorization: Basic <base64(username:password)>` senden. Der Standard-Benutzername des Servers ist `admin`, sofern `APP_USERNAME` nicht gesetzt wurde.
|
||||||
|
|
||||||
|
Fehlerantwort bei fehlender oder falscher Anmeldung:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"detail":"Anmeldung erforderlich."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: `401 Unauthorized`, Header `WWW-Authenticate: Basic realm="boehmitools"`.
|
||||||
|
|
||||||
|
## Host-Verbindungstest
|
||||||
|
|
||||||
|
### `GET /api/health`
|
||||||
|
|
||||||
|
Ohne Authentifizierung erreichbar. Antwortfelder laut Backend:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"plugins": {"trainingsplan":"ok","trainingstracker":"ok"},
|
||||||
|
"tandoor_configured": false,
|
||||||
|
"openai_configured": true,
|
||||||
|
"authentication_enabled": true,
|
||||||
|
"jobs_running": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: `200`. Die App prüft danach zusätzlich den authentifizierten Tracker-Health-Endpunkt.
|
||||||
|
|
||||||
|
## Trainingstracker
|
||||||
|
|
||||||
|
Basis: `/plugins/trainingstracker`
|
||||||
|
|
||||||
|
### `GET /api/health`
|
||||||
|
|
||||||
|
Liefert `ok`, Server-Datenpfade, Verfügbarkeit von OpenAI und die unterstützten Vertragsversionen:
|
||||||
|
|
||||||
|
- `plan_schema`
|
||||||
|
- `training_contract`
|
||||||
|
- `tracker_schema`
|
||||||
|
- `result_data`
|
||||||
|
- `prompt`
|
||||||
|
- `analysis_schema`
|
||||||
|
- `normalization`
|
||||||
|
|
||||||
|
Status: `200`, oder hostweit `401`.
|
||||||
|
|
||||||
|
### `GET /api/plans`
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plans": [{
|
||||||
|
"id": "phase-1.json",
|
||||||
|
"filename": "phase-1.json",
|
||||||
|
"name": "Phase 1",
|
||||||
|
"title": "…",
|
||||||
|
"subtitle": "…",
|
||||||
|
"weeks": 8,
|
||||||
|
"days": 6,
|
||||||
|
"tracked": true,
|
||||||
|
"start_date": "",
|
||||||
|
"completed_sessions": 5,
|
||||||
|
"source_changed": false,
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"published_revision": 1,
|
||||||
|
"tracker_revision": 7
|
||||||
|
}],
|
||||||
|
"selected": "phase-1.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: `200`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{plan_id}/select`
|
||||||
|
|
||||||
|
Kein Request-Body erforderlich. Antwort: `{"ok":true,"selected":"phase-1.json"}`.
|
||||||
|
|
||||||
|
Status: `200`; `404` mit `{"error":"Trainingsplan nicht gefunden"}`.
|
||||||
|
|
||||||
|
### `GET /api/plans/{plan_id}`
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan": { /* normalisierte veröffentlichte Planfassung */ },
|
||||||
|
"tracker": { /* Tracker-Schema 7 */ },
|
||||||
|
"analysis_catalog": { /* Wochen- und Gesamtstatus */ },
|
||||||
|
"equivalence_guide": { /* Variantencluster und Fallback-FAQ */ },
|
||||||
|
"capabilities": {"openai": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plan` enthält ausschließlich vom Backend erzeugte Felder wie `source_file`, `source_hash`, `plan_id`, `schema_version`, `contract_version`, `compatibility`, `published_revision`, `published_at`, `name`, `title`, `subtitle`, `weeks`, `days`, `phases`, `phase_by_week`, `stages`, `exercise_catalog`, `front`, `training_format`.
|
||||||
|
|
||||||
|
`tracker` enthält mindestens `version`, `revision`, `source_file`, `source_hash`, `profile`, `progressions`, `sessions`, `week_statuses`, `created_at`, `updated_at`; für die GET-Antwort werden zusätzlich `source_changed`, `analysis_cache` und `analysis_state` zusammengesetzt.
|
||||||
|
|
||||||
|
Status: `200`; `404`; `400` bei nicht lesbarem Plan.
|
||||||
|
|
||||||
|
### `PUT /api/plans/{plan_id}/tracker`
|
||||||
|
|
||||||
|
Vollständiges Speichern, primär für Kompatibilität. Request entweder direkt Trackerobjekt oder:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"expected_revision":7,"tracker":{}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"updated_at": "…",
|
||||||
|
"revision": 8,
|
||||||
|
"analysis_catalog": {},
|
||||||
|
"analysis_state": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: `200`; `400` für ungültige Nutzdaten; `409` bei Revisionskonflikt; `413` über 5 MiB.
|
||||||
|
|
||||||
|
### `PATCH /api/plans/{plan_id}/tracker/session`
|
||||||
|
|
||||||
|
Der von der App verwendete Autosave-Vertrag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expected_revision": 7,
|
||||||
|
"profile": {"start_date":"","display_name":"","plan_notes":""},
|
||||||
|
"week_statuses": {"1":{"status":"open"}},
|
||||||
|
"session_key": "w01-d01",
|
||||||
|
"session": {
|
||||||
|
"status": "in_progress",
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"plan_revision": 1,
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`profile`, `week_statuses` und `session` sind jeweils optional. Ein Patch ersetzt die betreffende Session vollständig, nicht einzelne Felder innerhalb der Session.
|
||||||
|
|
||||||
|
Erlaubte Sessionstatus: `planned`, `in_progress`, `stopped`, `completed`.
|
||||||
|
|
||||||
|
Erlaubte Übungsstatus: `planned`, `completed`, `partial`, `skipped`. Bei `skipped` kann `skip_reason` gespeichert werden. Neue strukturierte Ergebnisse nutzen `result_data` Version 2:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"mode": "reps|seconds|minutes",
|
||||||
|
"laterality": "bilateral|unilateral",
|
||||||
|
"sides_mode": "same|separate",
|
||||||
|
"sets": 3,
|
||||||
|
"weight_kg": 8.0,
|
||||||
|
"values": [8,7,6],
|
||||||
|
"left_values": [8,7,6],
|
||||||
|
"right_values": [8,7,6]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Backend akzeptiert maximal 20 Sätze, Werte von 0 bis 100000 und genau ein gemeinsames Gewicht. Alte Freitextfelder `result`, `note`, `progression` bleiben erhalten.
|
||||||
|
|
||||||
|
Erfolg wie beim PUT. Konfliktantwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error":"Die Session wurde in einem anderen Tab oder Gerät geändert.",
|
||||||
|
"code":"revision_conflict",
|
||||||
|
"current_revision":8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: `409`. Die App darf dann nicht automatisch gegen Revision 8 überschreiben.
|
||||||
|
|
||||||
|
### `POST /api/plans/{plan_id}/analysis`
|
||||||
|
|
||||||
|
Request Woche:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"scope":"week","week":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
Request Gesamtanalyse:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"scope":"overall"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unveränderte, bereits aktuelle Analyse: `200` mit `cached:true`, `selection`, `analysis_state`, `analysis_cache`, `analysis_catalog`.
|
||||||
|
|
||||||
|
Neuer Hintergrundjob: `202 Accepted` mit `cached:false`, `job`, `analysis_state`, `analysis_catalog`.
|
||||||
|
|
||||||
|
Fehler: `400` bei fehlender OpenAI-Konfiguration, ungültiger Woche oder fehlenden Daten. `409` wenn bereits eine Analyse läuft. `404` bei unbekanntem Plan.
|
||||||
|
|
||||||
|
### `GET /api/plans/{plan_id}/analysis/status`
|
||||||
|
|
||||||
|
Antwort enthält `analysis_state`, `analysis_cache`, `analysis_catalog`, `updated_at`. Die App pollt nur bei `analysis_state.status == "running"`.
|
||||||
|
|
||||||
|
Status: `200`; `400`; `404`.
|
||||||
|
|
||||||
|
### `GET /api/plans/{plan_id}/tracker/export`
|
||||||
|
|
||||||
|
Liefert die Sessiondatei als `application/json`; erzeugt bei Bedarf einen leeren Tracker. Status `200` oder `404`.
|
||||||
|
|
||||||
|
## Trainingsplan-Editor
|
||||||
|
|
||||||
|
Basis: `/plugins/trainingsplan`
|
||||||
|
|
||||||
|
### `GET /api/plans`
|
||||||
|
|
||||||
|
Antwort: `plans` mit `id`, `name`, `active`, `revision`, `published_revision`, `has_unpublished_changes`, plus `active`.
|
||||||
|
|
||||||
|
### `POST /api/plans`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"Neue Phase","from":"optionale-plan-id","kind":"training"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` kann technisch auch `recipe` sein; die Android-App erstellt ausschließlich Trainingspläne. Antwort ist der öffentliche Wrapper mit `id`, `plan_id`, `name`, `revision`, `published_revision`, `updated_at`, `published_at`, `has_unpublished_changes`, `tracked_sessions`, `config`.
|
||||||
|
|
||||||
|
### `GET /api/plans/{pid}`
|
||||||
|
|
||||||
|
Öffentlicher Wrapper des Entwurfs. Status `200` oder `404`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}/select`
|
||||||
|
|
||||||
|
Setzt den aktiven Plan. Status `200` oder `404`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}`
|
||||||
|
|
||||||
|
Speichert den Entwurf:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"config":{},"expected_revision":4}
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `ok`, öffentlicher Wrapper, `validation` mit `errors` und `warnings`.
|
||||||
|
|
||||||
|
Konflikt: `409` mit `code:"revision_conflict"` und `current_revision`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}/validate`
|
||||||
|
|
||||||
|
Request `{"config":{}}`. Antwort: `errors`, `warnings`, normalisierte `config`, `diff`. `diff` enthält Zähler und stabile-ID-basierte Änderungen für `days`, `rotations`, `exercises`, `progression_steps`, außerdem `changes`, `total_changes`, `training_format_changed`, `plan_meta_changed`, `tracked_sessions`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}/publish`
|
||||||
|
|
||||||
|
Request `{"expected_revision":4}`. Erfolgsantwort: `ok`, Wrapper, `diff`, `validation`.
|
||||||
|
|
||||||
|
Status `400` bei Validierungsfehlern; `409` bei Revisionskonflikt.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}/rename`
|
||||||
|
|
||||||
|
Request `{"name":"…"}`. Antwort `id`, `name`, `revision`. Status `400` bei leerem Namen, `404` bei unbekanntem Plan.
|
||||||
|
|
||||||
|
### `DELETE /api/plans/{pid}`
|
||||||
|
|
||||||
|
Antwort `{"ok":true,"active":"…"}`. Status `400`, wenn der letzte Plan gelöscht werden soll; `404` bei unbekanntem Plan.
|
||||||
|
|
||||||
|
### `GET /api/plans/{pid}/proposals`
|
||||||
|
|
||||||
|
Liefert die Vorschlagsdatei unverändert oder `{}`. Vorschläge enthalten unter anderem `id`, `status`, `scope_key`, `analysis_type`, `week`, `target`, `target_label`, `target_exercise_id`, `target_progression_id`, `target_step_id`, `suggested_change`, `reason`, `condition`, `manual_step`, `analysis_created_at`.
|
||||||
|
|
||||||
|
### `POST /api/plans/{pid}/proposals/{proposal_id}`
|
||||||
|
|
||||||
|
Request `{"status":"open|accepted|rejected|reviewed"}`. Antwort `ok`, `proposals`. Ungültiger Status: `400`.
|
||||||
|
|
||||||
|
### Weitere vorhandene Endpunkte
|
||||||
|
|
||||||
|
`GET /api/config`, `POST /api/config`, `GET /api/default`, `POST /api/reset`, `GET /api/plans/{pid}/export`, `POST /api/plans/import`, `POST /api/pdf` existieren. Die native App verwendet für den Kernbetrieb die planbezogenen Endpunkte; sie bearbeitet niemals Serverdateien direkt.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Domänenmodell
|
||||||
|
|
||||||
|
## Vertragsversionen
|
||||||
|
|
||||||
|
- Plan: `schema_version = 3`
|
||||||
|
- Trainingsvertrag: `contract_version = 2`
|
||||||
|
- Ergebnisschema: Version 2
|
||||||
|
- Tracker: `version = 7`
|
||||||
|
- Ergebnisdaten: `version = 2`
|
||||||
|
|
||||||
|
## Identitäten
|
||||||
|
|
||||||
|
Dauerhaft stabil sind `plan_id`, Phasen-ID, Tages-ID, Rotations-ID, Übungsplatzierungs-ID, Bewegungs-`exercise_id`, `progression_id`, Progressions-ID und Stufen-ID. `legacy_id` wie `d1-r0-e0` dient ausschließlich der Migration alter Sessions. Neue Sessionitems werden mit der stabilen Übungsplatzierungs-ID gespeichert.
|
||||||
|
|
||||||
|
## Planlebenszyklus
|
||||||
|
|
||||||
|
Eine Plandatei besitzt `config` als veröffentlichte Fassung und `draft` als bearbeitbaren Entwurf. `revision` schützt Bearbeitungen, `published_revision` kennzeichnet die vom Tracker gelesene Fassung. Entwurf speichern und veröffentlichen sind getrennte Operationen.
|
||||||
|
|
||||||
|
## Planstruktur
|
||||||
|
|
||||||
|
Plan → Phasen, Tage, Progressionen, Übungsbibliothek und Trainingsformat.
|
||||||
|
|
||||||
|
Tag → Warm-up, Rotationen, Cooldown, Stretch.
|
||||||
|
|
||||||
|
Rotation → stabile ID, Bezeichnung, Übungen.
|
||||||
|
|
||||||
|
Übungsplatzierung → `id`, `legacy_id`, `exercise_id`, `progression_id`, Name, Hinweise, Standard-`result_schema`.
|
||||||
|
|
||||||
|
Progression → stabile ID/Key, Name, geordnete Stufen.
|
||||||
|
|
||||||
|
Progressionsstufe → ID, Name, optionale Phase, Bewegungscluster, Analysefaktor und optional überschreibendes `result_schema`.
|
||||||
|
|
||||||
|
## Ergebnisschema
|
||||||
|
|
||||||
|
Auflösungsreihenfolge:
|
||||||
|
|
||||||
|
1. manuelle Sessionanpassung,
|
||||||
|
2. Schema der gewählten Progressionsstufe,
|
||||||
|
3. Standardschema der Übung,
|
||||||
|
4. automatische Fallback-Erkennung.
|
||||||
|
|
||||||
|
Felder: `mode` (`reps`, `seconds`, `minutes`, `none`, im Plan zusätzlich `auto`), `weight_mode` (`none`, `optional`, `required`), `laterality` (`bilateral`, `unilateral`), `sides_mode` (`same`, `separate`) sowie optional eine feste Satzanzahl.
|
||||||
|
|
||||||
|
## Tracker
|
||||||
|
|
||||||
|
Tracker → Profil, Sessions, Wochenstatus, Revision und Planbezug.
|
||||||
|
|
||||||
|
Session-Key: `wNN-dNN`. Sessionstatus: `planned`, `in_progress`, `stopped`, `completed`.
|
||||||
|
|
||||||
|
Sessionitemstatus: `planned`, `completed`, `partial`, `skipped`. `done` bleibt als Kompatibilitätsfeld erhalten und ist bei `completed` oder `partial` wahr.
|
||||||
|
|
||||||
|
`result_data` speichert eine Messart, gemeinsame oder getrennte Satzwerte und höchstens ein gemeinsames Gewicht. Unterschiedliche Gewichte pro Satz, RIR, RPE und Technikrating gehören bewusst nicht zum Vertrag.
|
||||||
|
|
||||||
|
## Analyse
|
||||||
|
|
||||||
|
`analysis_state` ist persistent und kann `idle`, `running`, `done` oder `error` sein. Der Cache enthält höchstens eine Analyse je Woche und genau eine Gesamtanalyse. Neuberechnung überschreibt dieselbe Datei. Es gibt keine Historie.
|
||||||
|
|
||||||
|
`analysis_catalog` kennzeichnet Wochen als `missing`, `stale` oder `current`; die Gesamtanalyse zusätzlich als `needs_weeks`.
|
||||||
|
|
||||||
|
## Vorschläge
|
||||||
|
|
||||||
|
Analysen erzeugen höchstens drei strukturierte Vorschläge. Ziel-IDs verbinden Vorschläge mit Übungen, Progressionen und Stufen. Die App darf Status ändern und zum Ziel navigieren, aber niemals automatisch den Plan mutieren.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Implementierungsplan
|
||||||
|
|
||||||
|
## Meilenstein 0
|
||||||
|
|
||||||
|
Backendcode, Mounts, Authentifizierung, Datenmodelle, Migrationen, Beispielpläne, alte Sessions, Analysen, Vorschläge und Web-UIs analysieren. Vertrag und Konfliktmodell dokumentieren.
|
||||||
|
|
||||||
|
## Meilenstein 1
|
||||||
|
|
||||||
|
Single-Activity-App, Compose/Material 3, Navigation, Hilt, Retrofit/OkHttp, Kotlin Serialization, Room, DataStore, Android-Keystore-Verschlüsselung, WorkManager und Verbindungstest.
|
||||||
|
|
||||||
|
## Meilenstein 2
|
||||||
|
|
||||||
|
Trackerplan laden, UI-Zustand pro Plan persistieren, Legacy-IDs migrieren, Sessionstatus und Übungsstatus abbilden, Ergebnisfelder aus Stufe/Übung/Fallback bestimmen, debounced Autosave und Offline-Queue implementieren.
|
||||||
|
|
||||||
|
## Meilenstein 3
|
||||||
|
|
||||||
|
Lokale Fortschrittskarten, Variantencluster/FAQ, Wochenabschluss, Analyseauswahl, `200`/`202`, persistente Jobanzeige und Polling.
|
||||||
|
|
||||||
|
## Meilenstein 4
|
||||||
|
|
||||||
|
Entwurfs-CRUD, strukturierte Planansicht, Result-Schema-Editor, Bibliothek, Validierung, Diff, Publish und manuell bestätigte KI-Vorschläge.
|
||||||
|
|
||||||
|
## Meilenstein 5
|
||||||
|
|
||||||
|
Unit-, Repository-, MockWebServer- und Compose-Tests mit allen gelieferten Fixtures; 360-dp-Prüfung; Light/Dark; Gradle-Test-, Lint- und Assemble-Läufe; ZIP/APK/Build- und Testbericht.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Offline- und Konfliktkonzept
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
|
||||||
|
Room speichert die zuletzt erfolgreichen JSON-Antworten für Planlisten, normalisierte Trackerpläne, Editorentwürfe und Analysen. Der Cache ist lesbar, wenn das Netzwerk fehlt; er ist kein eigenständiger Serverdatenbestand.
|
||||||
|
|
||||||
|
## Ausstehende Mutationen
|
||||||
|
|
||||||
|
Sessionänderungen werden als vollständiger Session-Patch mit `expected_revision` gespeichert. Bei Transportfehlern wird exakt der gesendete JSON-Patch in einer FIFO-Queue abgelegt. WorkManager synchronisiert nur bei Netzwerkverbindung und in Erstellungsreihenfolge.
|
||||||
|
|
||||||
|
## Revisionskonflikt
|
||||||
|
|
||||||
|
HTTP `409` mit `code = revision_conflict` stoppt die Queue am konfliktbehafteten Eintrag. Der Eintrag bleibt erhalten und wird als Konflikt markiert. Die UI zeigt lokalen Patch und aktuelle Serverrevision. Es gibt kein automatisches Rebase und kein blindes Überschreiben.
|
||||||
|
|
||||||
|
Sichere Aktionen:
|
||||||
|
|
||||||
|
- Serverstand neu laden und lokale Änderung manuell erneut anwenden.
|
||||||
|
- Konflikt-Patch nach bewusster Benutzerentscheidung verwerfen.
|
||||||
|
- Keine automatische Änderung der `expected_revision`.
|
||||||
|
|
||||||
|
## Analysejobs
|
||||||
|
|
||||||
|
Ein laufender Job wird vom Server in `state.json` gespeichert. Die App verlässt sich nicht auf flüchtigen UI-Zustand, sondern liest nach Neustart `analysis_state` erneut. Solange `status == running`, bleibt der Startbutton deaktiviert und der Statusendpunkt wird gepollt.
|
||||||
|
|
||||||
|
## Serveränderungen
|
||||||
|
|
||||||
|
Die App schreibt nie direkt in Plan-, Session-, Analyse- oder Vorschlagsdateien. Sämtliche Mutationen laufen über die vorhandenen HTTP-Endpunkte.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Screen Map
|
||||||
|
|
||||||
|
## App-Shell
|
||||||
|
|
||||||
|
- Start/Verbindung: Server-URL, Benutzername, Passwort, Verbindungstest.
|
||||||
|
- Hauptnavigation: Tracker, Planeditor, Einstellungen.
|
||||||
|
- Material-3-Theme mit Light/Dark/System.
|
||||||
|
|
||||||
|
## Tracker
|
||||||
|
|
||||||
|
- Planauswahl.
|
||||||
|
- Session-Ansicht mit Wochen- und Tageschips.
|
||||||
|
- Sessionstatus starten, stoppen, fortsetzen, wieder öffnen, abschließen, zurücksetzen.
|
||||||
|
- Warm-up, Rotationen, Übungen, Cooldown und Stretch.
|
||||||
|
- Übungsstatus offen, erledigt, teilweise, übersprungen.
|
||||||
|
- Progressionsauswahl und progressionsabhängiger Ergebnis-Editor.
|
||||||
|
- Fortschritt: lokale Sessionkennzahlen, Wochenstatus, Analyseauswahl, Cache/Jobstatus.
|
||||||
|
- Plan: veröffentlichte Struktur und Planrevision.
|
||||||
|
- FAQ: Trainingsformat, Variantencluster und Backend-Fallbackregeln.
|
||||||
|
|
||||||
|
## Planeditor
|
||||||
|
|
||||||
|
- Planliste, Auswahl, neuer Plan, Umbenennen, Löschen.
|
||||||
|
- Entwurfsübersicht und Metadaten.
|
||||||
|
- Tage, Rotationen und Übungen mit stabilen IDs.
|
||||||
|
- Ergebnisschema pro Übung.
|
||||||
|
- Progressionsstufen und überschreibende Ergebnisschemata.
|
||||||
|
- Übungsbibliothek.
|
||||||
|
- Prüfung mit Fehlern/Warnungen.
|
||||||
|
- Tracker-Vorschau und Änderungsübersicht.
|
||||||
|
- Veröffentlichen.
|
||||||
|
- Vorschlags-Postfach: prüfen, Ziel öffnen, angenommen/abgelehnt/geprüft markieren.
|
||||||
|
|
||||||
|
## Visuelle Referenz
|
||||||
|
|
||||||
|
In den gelieferten Archiven sind keine Bilddateien oder Screenshots enthalten. Als vorhandene visuelle Referenz wurden daher die mobile-first HTML/CSS-Oberflächen der beiden Plugins ausgewertet. Die native Umsetzung übernimmt Informationshierarchie und Funktionsgruppen, nicht DOM-Struktur oder Web-Komponenten.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Vollständige Quellenanalyse
|
||||||
|
|
||||||
|
Stand: 24. Juli 2026
|
||||||
|
|
||||||
|
## Untersuchte Lieferobjekte
|
||||||
|
|
||||||
|
| Archiv | Originaldateien | SHA-256 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `01-boehmitools-current(1).zip` | 93 | `167d0d1b4670cbe6bb7cab10d2b84c315fde6dadc824cf3f7b9d6ccce3d61d1e` |
|
||||||
|
| `02-training-tools-v2.0-bundle.zip` | 29 | `b2859ccb86a3915a86e3c64e157c9021f3b713b63b600360419e686f41c8dbee` |
|
||||||
|
| `03-training-app-samples.zip` | 8 | `1634f3b83e98d5e5df7d1c56719dede13ed4cf1ce1517a3745cfee0384f93991` |
|
||||||
|
|
||||||
|
Alle enthaltenen Dateien wurden inventarisiert. Die technische Analyse umfasste insbesondere Host-Mounts und Basic Auth, beide Plugin-Backends, Storage- und Vertragsmodule, Plugin-Metadaten, Tests, HTML-Oberflächen, Migrationsdokumentation, beide Planwrapper, beide Legacy-Tracker, Wochen- und Gesamtanalyse sowie die Vorschlagsdatei. Die vollständige Dateiliste liegt unter `build-logs/source-files.txt`; die maschinenlesbare Datenauswertung unter `build-logs/source-audit.json`.
|
||||||
|
|
||||||
|
## Abgleich aktueller Serverstand gegen v2.0-Bundle
|
||||||
|
|
||||||
|
Die Quelltexte von `plugins/trainingsplan` und `plugins/trainingstracker` sind in beiden Lieferungen inhaltlich identisch. Im entpackten aktuellen Stand kamen durch lokale Python-Testläufe lediglich `__pycache__`-Dateien hinzu. Die beiden Trainingsplan-Dateien `phase-1.json` und `calihoss-newbie-8-wochen.json` sind ebenfalls identisch. Der vollständige Serverstand enthält zusätzlich vier Rezeptplan-Dateien, die nicht zum Umfang dieser Android-App gehören.
|
||||||
|
|
||||||
|
Die Host-App mountet die Plugins unter:
|
||||||
|
|
||||||
|
- `/plugins/trainingsplan`
|
||||||
|
- `/plugins/trainingstracker`
|
||||||
|
|
||||||
|
Die App nutzt ausschließlich diese HTTP-Mounts. Es gibt keinen direkten Dateizugriff.
|
||||||
|
|
||||||
|
## Pläne und Vertragsversionen
|
||||||
|
|
||||||
|
Beide Planwrapper haben `revision = 1` und `published_revision = 1`. Die veröffentlichte Fassung liegt in `config`, der Entwurf in `draft`.
|
||||||
|
|
||||||
|
| Plan | Modus | Wochen | Tage | Blöcke | Platzierungen | Progressionen | Stufen | Katalog |
|
||||||
|
|---|---|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| `phase-1` | `tabata` | 8 | 6 | 12 | 48 | 31 | 248 | 40 |
|
||||||
|
| `calihoss-newbie-8-wochen` | `sets_reps` | 8 | 6 | 12 | 48 | 31 | 248 | 27 |
|
||||||
|
|
||||||
|
Jede der 96 Übungsplatzierungen besitzt ein explizites `result_schema`; alle 496 Progressionsstufen ebenfalls. Tages-, Rotations-, Platzierungs-, Progressions- und Stufen-IDs sind innerhalb ihres jeweiligen Entitätstyps vollständig und eindeutig. Bewegungs-IDs werden absichtlich über mehrere Platzierungen hinweg wiederverwendet und dürfen daher nicht global eindeutig behandelt werden.
|
||||||
|
|
||||||
|
Die Beschreibung verwendet die Begriffe `plan_schema` und `training_contract`. In den Plan-JSONs heißen die tatsächlichen Felder `schema_version = 3` und `contract_version = 2`. Der Tracker-Health-Endpunkt meldet dieselben Verträge als `versions.plan_schema = 3` und `versions.training_contract = 2`. Die Android-Domäne bildet diese Namensdifferenz explizit ab, statt ein zusätzliches JSON-Feld zu erfinden.
|
||||||
|
|
||||||
|
## Legacy-Sessions und Migration
|
||||||
|
|
||||||
|
| Sessiondatei | gelieferte Version | Sessions | Items | positionsbasierte Übungskeys | Freitextergebnisse | `result_data` v2 |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| `phase-1.json` | 5 | 10 | 197 | 80 | 40 | 0 |
|
||||||
|
| `calihoss-newbie-8-wochen.json` | 1 | 6 | 88 | 48 | 0 | 0 |
|
||||||
|
|
||||||
|
Die Testdaten erzwingen damit beide Migrationspfade:
|
||||||
|
|
||||||
|
1. `dN-rN-eN` wird ausschließlich anhand des im Plan mitgelieferten `legacy_id` in die stabile Platzierungs-ID überführt.
|
||||||
|
2. Vorhandene Freitextergebnisse werden lokal in `result_data` Version 2 geparst, ohne den Originaltext vor einer Benutzeränderung zu verwerfen.
|
||||||
|
|
||||||
|
Warm-up-, Cooldown- und Stretch-Items besitzen eigene Sequenzschlüssel und werden nicht als Übungsplatzierungen migriert.
|
||||||
|
|
||||||
|
## Analyse und Vorschläge
|
||||||
|
|
||||||
|
Geliefert sind genau eine Wochenanalyse für Woche 1 und genau eine Gesamtanalyse. Beide besitzen `status = success` und die Antwortbereiche `headline`, `summary`, `data_quality`, `metrics`, `exercise_updates`, `plan_adjustments`, `warnings` und `conclusion`.
|
||||||
|
|
||||||
|
Die Vorschlagsdatei hat Version 2, gehört zu `phase-1`, basiert auf `published_revision = 1` und enthält zwei offene Vorschläge. Zielreferenzen verwenden stabile Bewegungs-, Progressions- und Stufen-IDs. Der Editor aktualisiert nur den Vorschlagsstatus und navigiert zum Ziel. Er überträgt keinen Vorschlag automatisch in den Entwurf.
|
||||||
|
|
||||||
|
## API- und Konfliktbefunde
|
||||||
|
|
||||||
|
- Hostweite Basic Auth wird vor den Plugin-Routen ausgewertet; nur der exakte Hostpfad `/api/health` ist ausgenommen.
|
||||||
|
- Der Session-Patch ersetzt eine komplette Session und prüft die globale Tracker-`revision` über `expected_revision`.
|
||||||
|
- Ein `409 revision_conflict` liefert `current_revision`; es existiert kein serverseitiger Merge-Endpunkt.
|
||||||
|
- Analyse-POST liefert `200` für einen aktuellen Cachetreffer und `202` für einen neu gestarteten Hintergrundjob.
|
||||||
|
- Der laufende Analysezustand ist serverseitig persistent und wird über den Statusendpunkt gelesen.
|
||||||
|
- Planentwurf und Veröffentlichung haben getrennte Revisionen und getrennte Endpunkte.
|
||||||
|
- Der Server erstellt beim Planlöschen ein Backup. Die Android-App verlässt sich dennoch ausschließlich auf die API und kennt keinen Backup-Dateipfad.
|
||||||
|
|
||||||
|
## Visuelle Referenz
|
||||||
|
|
||||||
|
Keines der drei Archive enthält PNG-, JPEG-, WebP- oder andere Screenshotdateien. Das ist ein Widerspruch zur Aufgabenbeschreibung, die UI-Screenshots als mitgelieferte Referenz nennt. Für die Umsetzung wurden deshalb die beiden mobilen HTML/CSS-Oberflächen vollständig als visuelle und funktionale Referenz ausgewertet. Die native App übernimmt Informationshierarchie, Begriffe und Arbeitsabläufe, nicht DOM-Struktur oder Web-Komponenten.
|
||||||
|
|
||||||
|
## Bewusst nicht übernommene Web-Funktionen
|
||||||
|
|
||||||
|
Der `phase-1`-Plan deklariert ein Tabata-Format. Entsprechend der App-Vorgabe wird dieser Vertrag angezeigt und für Ergebnisschemata berücksichtigt, aber es wird kein Tabata- oder Trainingstimer implementiert. Ebenso fehlen bewusst RIR, RPE, Technikrating, unterschiedliche Gewichte pro Satz, WebView und direkte OpenAI-Kommunikation.
|
||||||
|
|
||||||
|
## Verifikation des gelieferten Backends
|
||||||
|
|
||||||
|
Die vorhandenen Plugin-Testsets wurden lokal ausgeführt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
11 passed, 8 skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
Das vollständige Protokoll liegt unter `build-logs/backend-plugin-tests.log`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.9.1"
|
||||||
|
kotlin = "2.1.20"
|
||||||
|
composeBom = "2025.05.01"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
lifecycle = "2.9.0"
|
||||||
|
navigation = "2.9.0"
|
||||||
|
hilt = "2.56.2"
|
||||||
|
hiltNavigation = "1.2.0"
|
||||||
|
room = "2.7.1"
|
||||||
|
datastore = "1.1.7"
|
||||||
|
work = "2.10.1"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
serialization = "1.8.1"
|
||||||
|
coreKtx = "1.16.0"
|
||||||
|
securityCrypto = "1.1.0-alpha06"
|
||||||
|
junit = "4.13.2"
|
||||||
|
androidxJunit = "1.2.1"
|
||||||
|
espresso = "3.6.1"
|
||||||
|
testRunner = "1.6.2"
|
||||||
|
testCore = "1.6.1"
|
||||||
|
mockwebserver = "4.12.0"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
compose-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
|
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
|
||||||
|
hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigation" }
|
||||||
|
hilt-work-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigation" }
|
||||||
|
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
|
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||||
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
|
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||||
|
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-kotlinx = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||||
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||||
|
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||||
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "testRunner" }
|
||||||
|
androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "testCore" }
|
||||||
|
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||||
|
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
package org.gradle.wrapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLConnection;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small auditable Gradle wrapper bootstrap used because the official wrapper JAR
|
||||||
|
* could not be generated in the build container. It downloads the configured
|
||||||
|
* distribution, validates SHA-256, extracts it with zip-slip protection and
|
||||||
|
* starts Gradle in the project directory.
|
||||||
|
*/
|
||||||
|
public final class GradleWrapperMain {
|
||||||
|
private GradleWrapperMain() {}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
Path jar = Paths.get(GradleWrapperMain.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||||
|
Path projectRoot = jar.getParent().getParent().getParent();
|
||||||
|
Path propertiesPath = jar.resolveSibling("gradle-wrapper.properties");
|
||||||
|
Properties properties = new Properties();
|
||||||
|
try (InputStream input = Files.newInputStream(propertiesPath)) {
|
||||||
|
properties.load(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
URI distributionUri = URI.create(required(properties, "distributionUrl"));
|
||||||
|
String archiveName = Paths.get(distributionUri.getPath()).getFileName().toString();
|
||||||
|
String distributionName = archiveName.replaceFirst("-(bin|all)\\.zip$", "");
|
||||||
|
Path distributionDirectory = gradleUserHome(args)
|
||||||
|
.resolve(properties.getProperty("distributionPath", "wrapper/dists"))
|
||||||
|
.resolve(distributionName)
|
||||||
|
.resolve(archiveName);
|
||||||
|
Path archive = distributionDirectory.resolve(archiveName);
|
||||||
|
Path marker = distributionDirectory.resolve(".installed");
|
||||||
|
Files.createDirectories(distributionDirectory);
|
||||||
|
|
||||||
|
if (!Files.exists(marker)) {
|
||||||
|
if (!Files.exists(archive)) {
|
||||||
|
download(distributionUri.toURL(), archive, intProperty(properties, "networkTimeout", 10_000));
|
||||||
|
}
|
||||||
|
String expectedSha = properties.getProperty("distributionSha256Sum", "").trim();
|
||||||
|
if (!expectedSha.isEmpty()) {
|
||||||
|
verifySha256(archive, expectedSha);
|
||||||
|
}
|
||||||
|
unzip(archive, distributionDirectory);
|
||||||
|
Files.writeString(marker, "ok\n", StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path gradleHome;
|
||||||
|
try (Stream<Path> children = Files.list(distributionDirectory)) {
|
||||||
|
gradleHome = children
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(path -> path.getFileName().toString().startsWith("gradle-"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IOException("Extracted Gradle directory not found in " + distributionDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win");
|
||||||
|
Path executable = gradleHome.resolve("bin").resolve(windows ? "gradle.bat" : "gradle");
|
||||||
|
if (!windows) executable.toFile().setExecutable(true);
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(executable.toString());
|
||||||
|
for (String argument : args) command.add(argument);
|
||||||
|
Process process = new ProcessBuilder(command)
|
||||||
|
.directory(projectRoot.toFile())
|
||||||
|
.inheritIO()
|
||||||
|
.start();
|
||||||
|
System.exit(process.waitFor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path gradleUserHome(String[] args) {
|
||||||
|
for (int index = 0; index < args.length - 1; index++) {
|
||||||
|
if ("-g".equals(args[index]) || "--gradle-user-home".equals(args[index])) {
|
||||||
|
return Paths.get(args[index + 1]).toAbsolutePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String configured = System.getenv("GRADLE_USER_HOME");
|
||||||
|
return configured == null || configured.isBlank()
|
||||||
|
? Paths.get(System.getProperty("user.home"), ".gradle")
|
||||||
|
: Paths.get(configured).toAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void download(URL initialUrl, Path destination, int timeoutMillis) throws Exception {
|
||||||
|
URL url = initialUrl;
|
||||||
|
for (int redirect = 0; redirect < 8; redirect++) {
|
||||||
|
URLConnection connection = url.openConnection();
|
||||||
|
connection.setConnectTimeout(timeoutMillis);
|
||||||
|
connection.setReadTimeout(Math.max(timeoutMillis, 30_000));
|
||||||
|
connection.setRequestProperty("User-Agent", "boehmitools-training-gradle-wrapper");
|
||||||
|
if (connection instanceof HttpURLConnection http) {
|
||||||
|
http.setInstanceFollowRedirects(false);
|
||||||
|
int status = http.getResponseCode();
|
||||||
|
if (status >= 300 && status < 400) {
|
||||||
|
String location = http.getHeaderField("Location");
|
||||||
|
if (location == null) throw new IOException("Redirect without Location from " + url);
|
||||||
|
url = url.toURI().resolve(location).toURL();
|
||||||
|
http.disconnect();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status >= 400) throw new IOException("HTTP " + status + " while downloading " + url);
|
||||||
|
}
|
||||||
|
Path partial = destination.resolveSibling(destination.getFileName() + ".part");
|
||||||
|
try (InputStream input = connection.getInputStream(); OutputStream output = Files.newOutputStream(partial)) {
|
||||||
|
input.transferTo(output);
|
||||||
|
}
|
||||||
|
Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IOException("Too many redirects while downloading " + initialUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifySha256(Path file, String expected) throws Exception {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
try (InputStream input = Files.newInputStream(file)) {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
for (int read; (read = input.read(buffer)) >= 0;) {
|
||||||
|
if (read > 0) digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String actual = HexFormat.of().formatHex(digest.digest());
|
||||||
|
if (!actual.equalsIgnoreCase(expected)) {
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
throw new IOException("Gradle distribution SHA-256 mismatch: expected " + expected + ", got " + actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void unzip(Path archive, Path destination) throws IOException {
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) {
|
||||||
|
for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) {
|
||||||
|
Path target = destination.resolve(entry.getName()).normalize();
|
||||||
|
if (!target.startsWith(destination.normalize())) {
|
||||||
|
throw new IOException("Unsafe ZIP entry: " + entry.getName());
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
Files.createDirectories(target);
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
Files.copy(zip, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(Properties properties, String key) {
|
||||||
|
String value = properties.getProperty(key);
|
||||||
|
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intProperty(Properties properties, String key, int fallback) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(properties.getProperty(key, Integer.toString(fallback)));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|
||||||
|
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
APP_HOME=$(cd "${0%/*}" && pwd -P)
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
@rem Gradle wrapper
|
||||||
|
@java -classpath "%~dp0\gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "boehmitools Training"
|
||||||
|
include(":app")
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
errors: list[str] = []
|
||||||
|
checks: list[str] = []
|
||||||
|
|
||||||
|
def require(condition: bool, message: str) -> None:
|
||||||
|
(checks if condition else errors).append(message)
|
||||||
|
|
||||||
|
app_gradle = (ROOT / "app/build.gradle.kts").read_text()
|
||||||
|
manifest = (ROOT / "app/src/main/AndroidManifest.xml").read_text()
|
||||||
|
network = (ROOT / "app/src/main/res/xml/network_security_config.xml").read_text()
|
||||||
|
source = "\n".join(p.read_text(errors="replace") for p in (ROOT / "app/src/main").rglob("*.*") if p.is_file())
|
||||||
|
api = (ROOT / "app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt").read_text()
|
||||||
|
|
||||||
|
require('applicationId = "de.drazz.boehmitools.training"' in app_gradle, "Application ID stimmt")
|
||||||
|
require('minSdk = 26' in app_gradle, "Minimum SDK 26")
|
||||||
|
require('compileSdk = 35' in app_gradle, "Compile SDK 35")
|
||||||
|
require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext")
|
||||||
|
require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext")
|
||||||
|
require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt")
|
||||||
|
require('WebView' not in source, "Keine WebView")
|
||||||
|
require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL")
|
||||||
|
require('sk-' not in source, "Kein erkennbarer OpenAI-Key")
|
||||||
|
require('Credentials.basic' in api, "Basic Auth über OkHttp")
|
||||||
|
for route in [
|
||||||
|
'tracker/session', '/analysis', '/analysis/status',
|
||||||
|
'trainingsplan/api/plans/{planId}/validate',
|
||||||
|
'trainingsplan/api/plans/{planId}/publish',
|
||||||
|
'proposals/{proposalId}',
|
||||||
|
]:
|
||||||
|
require(route in api, f"API-Route vorhanden: {route}")
|
||||||
|
|
||||||
|
fixtures = ROOT / "app/src/test/resources/samples"
|
||||||
|
for name in ["phase-1", "calihoss-newbie-8-wochen"]:
|
||||||
|
wrapper = json.loads((fixtures / f"plans/{name}.json").read_text())
|
||||||
|
config = wrapper["config"]
|
||||||
|
require(config.get("schema_version") == 3, f"{name}: schema_version 3")
|
||||||
|
require(config.get("contract_version") == 2, f"{name}: contract_version 2")
|
||||||
|
groups = {"days": [], "rotations": [], "placements": [], "steps": []}
|
||||||
|
for day in config.get("days", []):
|
||||||
|
groups["days"].append(day.get("id"))
|
||||||
|
for rotation in day.get("rotations", []):
|
||||||
|
groups["rotations"].append(rotation.get("id"))
|
||||||
|
for exercise in rotation.get("exercises", []):
|
||||||
|
groups["placements"].append(exercise.get("id"))
|
||||||
|
require(bool(re.fullmatch(r"d\d+-r\d+-e\d+", exercise.get("legacy_id", ""))), f"{name}: Legacy-ID gültig")
|
||||||
|
require(bool(exercise.get("result_schema")), f"{name}: Übungsschema vorhanden")
|
||||||
|
for stage in config.get("stages", {}).values():
|
||||||
|
for step in stage.get("steps", []):
|
||||||
|
groups["steps"].append(step.get("id"))
|
||||||
|
require(bool(step.get("result_schema")), f"{name}: Stufenschema vorhanden")
|
||||||
|
for label, ids in groups.items():
|
||||||
|
require(all(ids), f"{name}: {label} vollständig")
|
||||||
|
require(len(ids) == len(set(ids)), f"{name}: {label} eindeutig")
|
||||||
|
|
||||||
|
for name in ["phase-1", "calihoss-newbie-8-wochen"]:
|
||||||
|
tracker = json.loads((fixtures / f"sessions/{name}.json").read_text())
|
||||||
|
require(1 <= int(tracker.get("version", 0)) <= 7, f"{name}: Legacy-Tracker unterstützt")
|
||||||
|
require(bool(tracker.get("sessions")), f"{name}: Sessions vorhanden")
|
||||||
|
|
||||||
|
print(f"PASS: {len(checks)} Prüfungen")
|
||||||
|
if errors:
|
||||||
|
print(f"FAIL: {len(errors)} Prüfungen")
|
||||||
|
for error in errors:
|
||||||
|
print(f"- {error}")
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=boehmitools Training
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=michael
|
||||||
|
Group=michael
|
||||||
|
WorkingDirectory=/home/michael/src/boehmitools-core
|
||||||
|
EnvironmentFile=/home/michael/runtime/training/.env
|
||||||
|
Environment=BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-training/plugins
|
||||||
|
Environment=BOEHMITOOLS_DATA=/home/michael/runtime/training/data
|
||||||
|
Environment=HOST=0.0.0.0
|
||||||
|
Environment=PORT=8101
|
||||||
|
ExecStart=/home/michael/venvs/boehmitools-training/bin/python /home/michael/src/boehmitools-core/run.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Keine echten Secrets committen.
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8101
|
||||||
|
BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-training/plugins
|
||||||
|
BOEHMITOOLS_DATA=/home/michael/runtime/training/data
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
set -a
|
||||||
|
source /home/michael/runtime/training/.env
|
||||||
|
set +a
|
||||||
|
export BOEHMITOOLS_PLUGINS=/home/michael/src/boehmitools-training/plugins
|
||||||
|
export BOEHMITOOLS_DATA=/home/michael/runtime/training/data
|
||||||
|
export HOST="0.0.0.0"
|
||||||
|
export PORT="8101"
|
||||||
|
cd /home/michael/src/boehmitools-core
|
||||||
|
exec /home/michael/venvs/boehmitools-training/bin/python run.py
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"release": "training-tools-2.0.0",
|
||||||
|
"components": {
|
||||||
|
"trainingsplan": "2.0.0",
|
||||||
|
"trainingstracker": "2.0.0"
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"plan_schema": 3,
|
||||||
|
"training_contract": 2,
|
||||||
|
"tracker_schema": 7,
|
||||||
|
"result_data": 2
|
||||||
|
},
|
||||||
|
"analysis_storage": "one-current-file-per-week-and-overall; overwrite on regeneration",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "MIGRATION.md",
|
||||||
|
"size": 2397,
|
||||||
|
"sha256": "691c9ce3c9bbc2c535896b9b1cacc51e54b691099d538fe5372c02ff696b1107"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "RELEASE-NOTES.md",
|
||||||
|
"size": 1926,
|
||||||
|
"sha256": "e9cee71dfb96d619e8f013f0b174681c4304f07aaa6ae5c7f61dd0f3304ca3db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/trainingsplan/plans/calihoss-newbie-8-wochen.json",
|
||||||
|
"size": 419707,
|
||||||
|
"sha256": "8365b612ed2b0d6d49db5844c1db8b9d1e001ce1b5d09fdda25c0612374cffbd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/trainingsplan/plans/phase-1.json",
|
||||||
|
"size": 575838,
|
||||||
|
"sha256": "f61d90bedb1bff15bdb667ef7c49428ddd1d8e36ad5f39acf52085fbf641c618"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/TOOL-README.md",
|
||||||
|
"size": 4055,
|
||||||
|
"sha256": "1c26eb25d57eca965cb71d7ac0baf0a93a53ba94c89521b76330d4011a6b5ba3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/app.py",
|
||||||
|
"size": 21571,
|
||||||
|
"sha256": "367b30e3cf62cb816c34099c67417495fe58fd243975beb46a22e6e000031e87"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/backend.py",
|
||||||
|
"size": 1180,
|
||||||
|
"sha256": "9efa3cac72842c5c8548e3dcc4cd337579890eafd11d64be462c3581fad9f689"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/plan_builder.py",
|
||||||
|
"size": 46127,
|
||||||
|
"sha256": "f8a9ade995272402bb32350e2686c0374945581a6bfbfbad9adb4a553159f49a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/plugin.json",
|
||||||
|
"size": 1292,
|
||||||
|
"sha256": "cac5da0eb04e320b07904ada1786f7b9c5cd43fd7aaac8bd584918e0cbc7db9e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/presets/couchsnacks.json",
|
||||||
|
"size": 74860,
|
||||||
|
"sha256": "73e70d563f32a540b3e140888adf30e56c39babb7aff6118ab8e0690a389f93b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/presets/fruehstueck.json",
|
||||||
|
"size": 60871,
|
||||||
|
"sha256": "1bace5aa1d97346600408020cefcc23481e40adb577c939bda0da781432935b0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/presets/granola.json",
|
||||||
|
"size": 39330,
|
||||||
|
"sha256": "73983b3ffd72a998f0136fea2df9e6278b1076366adf89bd60f2d5f5c7327d17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/presets/nachmittags.json",
|
||||||
|
"size": 66551,
|
||||||
|
"sha256": "65c7e11a9728597c725ae8af128f62ee25a70095b8f78f99972a879688c528ec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/recipe_builder.py",
|
||||||
|
"size": 18387,
|
||||||
|
"sha256": "c9738a7c5e0e178cd8f91be5cbdacd13ee100eea4413d4ed4e101c33f6881e48"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/schema_contract.py",
|
||||||
|
"size": 20726,
|
||||||
|
"sha256": "4b02ed3b22f014c17b4f960521ee89bd0081309587c065619f67950923814a75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/static/index.html",
|
||||||
|
"size": 69985,
|
||||||
|
"sha256": "f86b38f18eaba2bcf10683d0cf4e8c32061c5a35f8dd53ab56b46d16df7d06e8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/storage.py",
|
||||||
|
"size": 1091,
|
||||||
|
"sha256": "a5ce0c9159d4a102b465e436e715e6c820cfbfeae0489773ea2d007f553e510c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingsplan/tests/test_contract.py",
|
||||||
|
"size": 3301,
|
||||||
|
"sha256": "7d6c4f807c3ce28dcb84d0ab918aae77c8ac9419a84ea72c132f755fa1cb1061"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/README.md",
|
||||||
|
"size": 4240,
|
||||||
|
"sha256": "a237661fb657cabdc32592bcc65d948b3cacd837bd8dc9aef74af1e8619733b1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/analysis_store.py",
|
||||||
|
"size": 832,
|
||||||
|
"sha256": "b13402ddf8fd4b8d049ffa0200c02b7f32ef329aa9c93c9fd2156ef469e84293"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/app.py",
|
||||||
|
"size": 143226,
|
||||||
|
"sha256": "f7f87f43ca7d13c09b98b7e5364d350ad6e11d6a9da3ecca4b78dbfcae0de761"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/backend.py",
|
||||||
|
"size": 1453,
|
||||||
|
"sha256": "57490ca4d1283ad6084817e50e1331e2a2be80138f172b050905d1f2b8996c63"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/contract.py",
|
||||||
|
"size": 269,
|
||||||
|
"sha256": "5b73a9698f41300b5a21390bab1393bdca431676aead6d986520da492c97bac3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/plugin.json",
|
||||||
|
"size": 1531,
|
||||||
|
"sha256": "8e3f10ac7a8325e925ad222561cd6d1d537c21059bebd517aa3afaea4937a604"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/session_store.py",
|
||||||
|
"size": 1186,
|
||||||
|
"sha256": "4af7c49cdaaf3acea311e1f54a3f922b8503111550a2dc0c1c7a3089b6463b1f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/static/index.html",
|
||||||
|
"size": 116266,
|
||||||
|
"sha256": "3a37bf462667350f2104b362c90d4dde3eca92e2ecfe4a71f58f0fb39f213e40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/tests/test_app.py",
|
||||||
|
"size": 21391,
|
||||||
|
"sha256": "d101b1a64ba7f954148d2da82a15a85843bd77f9b2144381c1b74c2920683e4c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins/trainingstracker/tests/test_stability.py",
|
||||||
|
"size": 2012,
|
||||||
|
"sha256": "30f8af88f8f43b0294b860b8e35c4325aadfb477934a115b12eba1c84d0c58e4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Migration auf Trainingsplan / Trainingstracker 2.0
|
||||||
|
|
||||||
|
## Enthaltene Komponenten
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/trainingsplan/
|
||||||
|
plugins/trainingstracker/
|
||||||
|
data/trainingsplan/plans/phase-1.json
|
||||||
|
data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Es werden keine anderen boehmitools-Komponenten verändert.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. boehmitools beziehungsweise den betroffenen Container stoppen.
|
||||||
|
2. Die beiden bestehenden Plugin-Ordner sichern.
|
||||||
|
3. `plugins/trainingsplan/` und `plugins/trainingstracker/` durch die Ordner aus dem Paket ersetzen.
|
||||||
|
4. Die beiden mitgelieferten Plan-JSONs nach `data/trainingsplan/plans/` kopieren.
|
||||||
|
5. boehmitools wieder starten.
|
||||||
|
|
||||||
|
## Bestehende Sessiondateien
|
||||||
|
|
||||||
|
Die Dateien unter `data/trainingstracker/sessions/` müssen nicht manuell geändert werden.
|
||||||
|
|
||||||
|
- Alte Positionsschlüssel wie `d1-r0-e0` bleiben lesbar.
|
||||||
|
- Die neuen Pläne speichern den ursprünglichen Positionsschlüssel dauerhaft als `legacy_id`.
|
||||||
|
- Beim Öffnen und nächsten Speichern einer Session wird der Eintrag auf die stabile Übungs-ID migriert.
|
||||||
|
- Bereits dokumentierte Sessiondaten erhalten beim nächsten Speichern die aktuelle `plan_id` und `plan_revision`, ohne Ergebnisse oder Notizen zu verwerfen.
|
||||||
|
|
||||||
|
Vorhandene Sessiondateien sollten trotzdem einmal separat gesichert werden.
|
||||||
|
|
||||||
|
## Bestehende Analysen
|
||||||
|
|
||||||
|
Der Tracker führt künftig genau eine aktuelle Analyse je Woche und eine Gesamtanalyse:
|
||||||
|
|
||||||
|
```text
|
||||||
|
analyses/<Plan-Dateiname>/week-01.json
|
||||||
|
analyses/<Plan-Dateiname>/week-02.json
|
||||||
|
analyses/<Plan-Dateiname>/overall.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Lesen eines alten Analyseordners wird die im bisherigen `index.json` als aktuell markierte Analyse übernommen. Ältere Zeitstempeldateien und das alte History-Feld werden entfernt. Eine neue Analyse überschreibt anschließend immer dieselbe Datei.
|
||||||
|
|
||||||
|
## Planrevisionen
|
||||||
|
|
||||||
|
Jede Plandatei enthält jetzt:
|
||||||
|
|
||||||
|
- `config`: die veröffentlichte, vom Tracker gelesene Fassung
|
||||||
|
- `draft`: den bearbeitbaren Entwurf
|
||||||
|
- `revision`: Bearbeitungsrevision für Konfliktschutz
|
||||||
|
- `published_revision`: veröffentlichte Planrevision
|
||||||
|
|
||||||
|
Das Speichern eines Entwurfs verändert laufende Sessions nicht. Erst **Entwurf veröffentlichen** stellt die neue Fassung dem Tracker bereit.
|
||||||
|
|
||||||
|
## Bewusste Nicht-Funktionen
|
||||||
|
|
||||||
|
Nicht eingebaut wurden:
|
||||||
|
|
||||||
|
- strukturierte Technik-, RIR- oder RPE-Werte
|
||||||
|
- unterschiedliches Gewicht pro Satz
|
||||||
|
- Trainings- oder Tabata-Timer
|
||||||
|
- mehrere Versionen oder Historien von KI-Analysen
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Trainingstools 2.0
|
||||||
|
|
||||||
|
## Datenmodell und Stabilität
|
||||||
|
|
||||||
|
- stabile IDs für Plan, Phasen, Tage, Rotationen, Übungsinstanzen, Bewegungen, Progressionen und Progressionsstufen
|
||||||
|
- dauerhaft gespeicherte `legacy_id` für die sichere Migration alter Positionsschlüssel
|
||||||
|
- Progressionsstufen als Objekte mit Name, Schema, Cluster, Faktor und Phasenbezug
|
||||||
|
- gemeinsamer, versionierter Datenvertrag zwischen Planeditor und Tracker
|
||||||
|
- veröffentlichte Planfassung und bearbeitbarer Entwurf getrennt
|
||||||
|
- Planrevision pro Session gespeichert
|
||||||
|
- atomische Plan- und Tracker-Schreibvorgänge
|
||||||
|
- Konfliktschutz über Revisionsnummern und Session-Patches
|
||||||
|
|
||||||
|
## Planeditor
|
||||||
|
|
||||||
|
- Validierung vor Veröffentlichung
|
||||||
|
- stabil-ID-basierter Änderungsbericht
|
||||||
|
- Tracker-Vorschau für Ergebnisfelder und feste Intervalle
|
||||||
|
- automatische, anschließend editierbare Migration fehlender Ergebnisschemata
|
||||||
|
- planbezogene Übungsbibliothek mit Bewegungsclustern und Progressionsfaktoren
|
||||||
|
- KI-Vorschläge mit Ziel-IDs, direkter Navigation zum Ziel und manueller Bestätigung
|
||||||
|
|
||||||
|
## Tracker
|
||||||
|
|
||||||
|
- Übungen können erledigt, teilweise erledigt oder mit Grund übersprungen werden
|
||||||
|
- Woche kann explizit geöffnet oder abgeschlossen werden
|
||||||
|
- Sessiondaten werden nur als Patch gespeichert
|
||||||
|
- historische Sessions bleiben ihrer Planrevision zugeordnet
|
||||||
|
- robuste Analysejobs mit Dateisperre, Heartbeat und Ablauf-Lease
|
||||||
|
- Analysehash berücksichtigt Planrevision, Schema-, Prompt-, Modell- und Normalisierungsversion
|
||||||
|
- genau eine aktuelle Analyse je Woche und eine Gesamtanalyse
|
||||||
|
- Neuberechnung überschreibt die vorhandene Analyse
|
||||||
|
- alte Analysehistorien werden automatisch auf das Ein-Datei-Modell reduziert
|
||||||
|
- strukturierte Planvorschläge werden an den Planeditor übergeben
|
||||||
|
|
||||||
|
## Nicht enthalten
|
||||||
|
|
||||||
|
- keine strukturierten Technik-, RIR- oder RPE-Werte
|
||||||
|
- kein unterschiedliches Gewicht pro Satz
|
||||||
|
- kein Trainings- oder Intervalltimer
|
||||||
|
- keine Analysehistorie und keine Analyseversionierung
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Trainingsplan- & Rezept-Editor 2.0
|
||||||
|
|
||||||
|
Der Editor verwaltet Trainingspläne und Rezeptsammlungen innerhalb von boehmitools. Trainingspläne sind zugleich die verbindliche Datenquelle für den `trainingstracker`.
|
||||||
|
|
||||||
|
## Planlebenszyklus
|
||||||
|
|
||||||
|
Jede Plandatei enthält eine veröffentlichte Fassung und einen bearbeitbaren Entwurf:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan_id": "phase-1",
|
||||||
|
"revision": 4,
|
||||||
|
"published_revision": 2,
|
||||||
|
"config": {},
|
||||||
|
"draft": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Entwurf speichern** erhöht die Bearbeitungsrevision, verändert aber nicht den vom Tracker gelesenen Plan.
|
||||||
|
- **Plan prüfen** validiert IDs, Progressionsbezüge, Ergebnisschemata und feste Intervalle.
|
||||||
|
- **Entwurf veröffentlichen** erzeugt eine neue veröffentlichte Planrevision.
|
||||||
|
- Bereits dokumentierte Sessions speichern die Planrevision, nach der sie ausgeführt wurden.
|
||||||
|
- Vor dem Ersetzen einer Plandatei wird atomisch geschrieben und ein Backup unter `data/trainingsplan/backups/` angelegt.
|
||||||
|
- Gleichzeitige Bearbeitungen werden über eine Revisionsprüfung erkannt und nicht still überschrieben.
|
||||||
|
|
||||||
|
## Stabiler Datenvertrag
|
||||||
|
|
||||||
|
Trainingspläne verwenden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
schema_version: 3
|
||||||
|
contract_version: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Phasen, Tage, Rotationen, Übungsplatzierungen, Bewegungen, Progressionen und Progressionsstufen besitzen dauerhafte IDs. Beim Verschieben im Editor bleiben diese IDs erhalten.
|
||||||
|
|
||||||
|
Progressionsstufen sind Objekte und keine parallelen Text-/Schema-Arrays mehr:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "step-squat-hold-...",
|
||||||
|
"name": "Deep Squat Hold",
|
||||||
|
"phase_id": "phase-start-...",
|
||||||
|
"movement_cluster": "knee_dominant",
|
||||||
|
"factor": 1.0,
|
||||||
|
"result_schema": {
|
||||||
|
"mode": "seconds",
|
||||||
|
"weight_mode": "optional",
|
||||||
|
"laterality": "bilateral",
|
||||||
|
"sides_mode": "same"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dadurch reisen Stufenname, Eingabeformat und Analysefaktor immer gemeinsam.
|
||||||
|
|
||||||
|
## Ergebniserfassung
|
||||||
|
|
||||||
|
Eine Übung besitzt ein Standardformat. Jede Progressionsstufe kann es überschreiben.
|
||||||
|
|
||||||
|
Priorität im Tracker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
manuelle Anpassung der aktuellen Session
|
||||||
|
→ Schema der gewählten Progressionsstufe
|
||||||
|
→ Standard der Übung
|
||||||
|
→ automatische Erkennung als Fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
Unterstützt werden Wiederholungen, Sekunden, Minuten, keine Zahl, optionales oder verpflichtendes Gewicht sowie gemeinsame oder getrennte Seitenwerte.
|
||||||
|
|
||||||
|
## Tabata und feste Intervalle
|
||||||
|
|
||||||
|
Das Trainingsformat wird ausdrücklich gespeichert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "tabata",
|
||||||
|
"fixed_interval": true,
|
||||||
|
"work_seconds": 20,
|
||||||
|
"rest_seconds": 10,
|
||||||
|
"rounds": 8,
|
||||||
|
"rounds_scope": "block"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rounds` bezeichnet Intervalle pro Block. Bei acht Intervallen und vier Übungen entstehen zwei Durchgänge pro Übung. Eine zusätzlich im Rotationstitel angegebene Satzanzahl wird damit multipliziert. Ist die Intervallzahl nicht gleichmäßig durch die Übungszahl teilbar, meldet die Validierung einen Hinweis und verlangt eine feste Wertezahl im Ergebnisschema.
|
||||||
|
|
||||||
|
## Übungsbibliothek und Analysefaktoren
|
||||||
|
|
||||||
|
Aus den Übungen und Progressionen wird eine planbezogene `exercise_catalog` erzeugt. Sie enthält stabile Bewegungs-IDs, Cluster, Varianten, Ergebnisschemata und Faktoren. Diese Angaben haben im Tracker Vorrang vor Namens- und Regex-Erkennung.
|
||||||
|
|
||||||
|
## Prüfung und Tracker-Vorschau
|
||||||
|
|
||||||
|
Der Tab **Prüfung & Vorschläge** zeigt:
|
||||||
|
|
||||||
|
- Validierungsfehler und Hinweise,
|
||||||
|
- das vom Tracker erwartete Ergebnisformat pro Übung,
|
||||||
|
- stufenabhängige Überschreibungen,
|
||||||
|
- die berechneten Durchgänge fester Intervallblöcke,
|
||||||
|
- strukturierte KI-Vorschläge aus dem Tracker mit stabilen Ziel-IDs.
|
||||||
|
|
||||||
|
Vorschläge werden niemals automatisch in den Plan geschrieben. Über **Ziel im Entwurf öffnen** springt der Editor anhand der stabilen IDs direkt zur betroffenen Übung oder Progression. Nach der manuellen Änderung kann der Vorschlag als übernommen oder abgelehnt markiert werden.
|
||||||
|
|
||||||
|
## Kompatibilität
|
||||||
|
|
||||||
|
Ältere Pläne werden beim Laden in das neue Schema überführt. Fehlende Ergebnisschemata erhalten dabei einmalig explizite, im Editor korrigierbare Migrationswerte. Die veröffentlichten Beispielpläne liegen bereits in Version 3 vor. Rezeptsammlungen bleiben vom Trainingsvertrag unberührt.
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Trainingsplan- und Rezepteditor mit Entwurf/Veröffentlichung und Konfliktschutz."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
import plan_builder
|
||||||
|
import recipe_builder
|
||||||
|
from schema_contract import migrate_training_config, validate_training_config, stable_id
|
||||||
|
from storage import atomic_json_write, utc_now
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")).resolve()
|
||||||
|
PLANS_DIR = DATA_DIR / "plans"
|
||||||
|
PRESETS_DIR = BASE_DIR / "presets"
|
||||||
|
ACTIVE_FILE = DATA_DIR / "active.txt"
|
||||||
|
LEGACY_CONFIG = DATA_DIR / "config.json"
|
||||||
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
BACKUPS_DIR = DATA_DIR / "backups"
|
||||||
|
TRACKER_PROPOSALS_DIR = DATA_DIR.parent / "trainingstracker" / "proposals"
|
||||||
|
TRACKER_SESSIONS_DIR = DATA_DIR.parent / "trainingstracker" / "sessions"
|
||||||
|
PLANS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
app = Flask(__name__, static_folder=None)
|
||||||
|
|
||||||
|
|
||||||
|
def builder_for(cfg: dict[str, Any]):
|
||||||
|
return recipe_builder if (cfg or {}).get("type") == "recipe" else plan_builder
|
||||||
|
|
||||||
|
|
||||||
|
def default_for(kind: str):
|
||||||
|
cfg = recipe_builder.default_config() if kind == "recipe" else plan_builder.default_config()
|
||||||
|
return cfg if kind == "recipe" else migrate_training_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: Any) -> str:
|
||||||
|
s = str(name or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", s).strip("-") or "phase"
|
||||||
|
|
||||||
|
|
||||||
|
def plan_path(pid: str) -> Path:
|
||||||
|
return PLANS_DIR / f"{pid}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def unique_id(base: str) -> str:
|
||||||
|
stem, index = base, 2
|
||||||
|
while plan_path(stem).exists():
|
||||||
|
stem = f"{base}-{index}"; index += 1
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_wrapper(pid: str, raw: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raw = {}
|
||||||
|
name = str(raw.get("name") or pid)
|
||||||
|
published = raw.get("config") if isinstance(raw.get("config"), dict) else {}
|
||||||
|
is_recipe = published.get("type") == "recipe"
|
||||||
|
if not is_recipe:
|
||||||
|
published = migrate_training_config(published, plan_id=str(raw.get("plan_id") or pid))
|
||||||
|
draft = raw.get("draft") if isinstance(raw.get("draft"), dict) else deepcopy(published)
|
||||||
|
if not is_recipe:
|
||||||
|
draft = migrate_training_config(draft, plan_id=published.get("plan_id") or pid)
|
||||||
|
revision = max(1, int(raw.get("revision") or 1))
|
||||||
|
published_revision = max(1, int(raw.get("published_revision") or 1))
|
||||||
|
return {
|
||||||
|
"id": pid,
|
||||||
|
"plan_id": str(raw.get("plan_id") or published.get("plan_id") or stable_id("plan", pid, name)),
|
||||||
|
"name": name,
|
||||||
|
"revision": revision,
|
||||||
|
"published_revision": published_revision,
|
||||||
|
"created_at": str(raw.get("created_at") or utc_now()),
|
||||||
|
"updated_at": str(raw.get("updated_at") or utc_now()),
|
||||||
|
"published_at": str(raw.get("published_at") or raw.get("updated_at") or utc_now()),
|
||||||
|
"config": published,
|
||||||
|
"draft": draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_plan(pid: str) -> dict[str, Any]:
|
||||||
|
with plan_path(pid).open("r", encoding="utf-8") as handle:
|
||||||
|
return _normalize_wrapper(pid, json.load(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def write_plan(pid: str, wrapper: dict[str, Any], *, backup: bool = True) -> dict[str, Any]:
|
||||||
|
normalized = _normalize_wrapper(pid, wrapper)
|
||||||
|
normalized["updated_at"] = utc_now()
|
||||||
|
atomic_json_write(plan_path(pid), {k: v for k, v in normalized.items() if k != "id"}, BACKUPS_DIR if backup else None)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def create_wrapper(pid: str, name: str, cfg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if cfg.get("type") != "recipe":
|
||||||
|
cfg = migrate_training_config(cfg, plan_id=pid)
|
||||||
|
now = utc_now()
|
||||||
|
return {
|
||||||
|
"id": pid, "plan_id": cfg.get("plan_id") or pid, "name": name,
|
||||||
|
"revision": 1, "published_revision": 1,
|
||||||
|
"created_at": now, "updated_at": now, "published_at": now,
|
||||||
|
"config": deepcopy(cfg), "draft": deepcopy(cfg),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_plan_ids() -> list[str]:
|
||||||
|
return sorted(path.stem for path in PLANS_DIR.glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def plan_name(pid: str) -> str:
|
||||||
|
try: return read_plan(pid)["name"]
|
||||||
|
except Exception: return pid
|
||||||
|
|
||||||
|
|
||||||
|
def get_active() -> str | None:
|
||||||
|
pid = ACTIVE_FILE.read_text(encoding="utf-8").strip() if ACTIVE_FILE.exists() else None
|
||||||
|
ids = list_plan_ids()
|
||||||
|
return pid if pid in ids else (ids[0] if ids else None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_active(pid: str) -> None:
|
||||||
|
ACTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ACTIVE_FILE.write_text(pid, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def install_presets() -> None:
|
||||||
|
if not PRESETS_DIR.is_dir(): return
|
||||||
|
for source in sorted(PRESETS_DIR.glob("*.json")):
|
||||||
|
if plan_path(source.stem).exists(): continue
|
||||||
|
try:
|
||||||
|
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
cfg = raw.get("config", {}) if isinstance(raw, dict) else {}
|
||||||
|
wrapper = create_wrapper(source.stem, str(raw.get("name") or source.stem), cfg)
|
||||||
|
write_plan(source.stem, wrapper, backup=False)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_seed() -> None:
|
||||||
|
if not list_plan_ids():
|
||||||
|
try: cfg = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_for("training")
|
||||||
|
except Exception: cfg = default_for("training")
|
||||||
|
write_plan("phase-1", create_wrapper("phase-1", "Phase 1", cfg), backup=False)
|
||||||
|
install_presets()
|
||||||
|
if get_active() is None and list_plan_ids(): set_active(list_plan_ids()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _tracked_session_count(pid: str) -> int:
|
||||||
|
path = TRACKER_SESSIONS_DIR / f"{pid}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
sessions = raw.get("sessions") if isinstance(raw, dict) and isinstance(raw.get("sessions"), dict) else {}
|
||||||
|
count = 0
|
||||||
|
for session in sessions.values():
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
continue
|
||||||
|
if str(session.get("status") or "planned") != "planned" or any(
|
||||||
|
isinstance(item, dict) and (
|
||||||
|
item.get("done") or item.get("completion_status") in {"completed", "partial", "skipped"}
|
||||||
|
or any(str(item.get(field) or "").strip() for field in ("result", "note", "progression", "skip_reason"))
|
||||||
|
) for item in (session.get("items") or {}).values()
|
||||||
|
):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def public_wrapper(wrapper: dict[str, Any], *, draft: bool = True) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": wrapper["id"], "plan_id": wrapper["plan_id"], "name": wrapper["name"],
|
||||||
|
"revision": wrapper["revision"], "published_revision": wrapper["published_revision"],
|
||||||
|
"updated_at": wrapper["updated_at"], "published_at": wrapper["published_at"],
|
||||||
|
"has_unpublished_changes": wrapper["draft"] != wrapper["config"],
|
||||||
|
"tracked_sessions": _tracked_session_count(wrapper["id"]),
|
||||||
|
"config": deepcopy(wrapper["draft"] if draft else wrapper["config"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validation(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if config.get("type") == "recipe": return {"errors": [], "warnings": [], "config": config}
|
||||||
|
return validate_training_config(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_summary(old: Any, new: Any) -> dict[str, Any]:
|
||||||
|
"""Erzeugt einen stabil-ID-basierten Änderungsbericht für die Veröffentlichung."""
|
||||||
|
old_cfg = old if isinstance(old, dict) else {}
|
||||||
|
new_cfg = new if isinstance(new, dict) else {}
|
||||||
|
|
||||||
|
def collect(cfg: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]:
|
||||||
|
result: dict[str, dict[str, dict[str, Any]]] = {
|
||||||
|
"days": {}, "rotations": {}, "exercises": {}, "progression_steps": {},
|
||||||
|
}
|
||||||
|
for di, day in enumerate(cfg.get("days", []) if isinstance(cfg.get("days"), list) else []):
|
||||||
|
if not isinstance(day, dict):
|
||||||
|
continue
|
||||||
|
day_id = str(day.get("id") or f"day-index-{di}")
|
||||||
|
result["days"][day_id] = {
|
||||||
|
"label": str(day.get("focus") or f"Tag {di + 1}"),
|
||||||
|
"value": {"num": day.get("num"), "focus": day.get("focus"), "light": bool(day.get("light"))},
|
||||||
|
}
|
||||||
|
for ri, rotation in enumerate(day.get("rotations", []) if isinstance(day.get("rotations"), list) else []):
|
||||||
|
if not isinstance(rotation, dict):
|
||||||
|
continue
|
||||||
|
rotation_id = str(rotation.get("id") or f"{day_id}:rotation:{ri}")
|
||||||
|
result["rotations"][rotation_id] = {
|
||||||
|
"label": f"{day.get('focus') or 'Tag'} · {rotation.get('label') or f'Block {ri + 1}'}",
|
||||||
|
"value": {"day_id": day_id, "label": rotation.get("label")},
|
||||||
|
}
|
||||||
|
for ei, exercise in enumerate(rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []):
|
||||||
|
if not isinstance(exercise, dict):
|
||||||
|
continue
|
||||||
|
exercise_id = str(exercise.get("id") or f"{rotation_id}:exercise:{ei}")
|
||||||
|
result["exercises"][exercise_id] = {
|
||||||
|
"label": str(exercise.get("name") or f"Übung {ei + 1}"),
|
||||||
|
"value": {
|
||||||
|
"rotation_id": rotation_id,
|
||||||
|
"name": exercise.get("name"), "cue": exercise.get("cue"),
|
||||||
|
"progression_id": exercise.get("progression_id") or exercise.get("key"),
|
||||||
|
"exercise_id": exercise.get("exercise_id"),
|
||||||
|
"movement_cluster": exercise.get("movement_cluster"),
|
||||||
|
"result_schema": exercise.get("result_schema"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
|
||||||
|
for key, stage in stages.items():
|
||||||
|
if not isinstance(stage, dict):
|
||||||
|
continue
|
||||||
|
for si, step in enumerate(stage.get("steps", []) if isinstance(stage.get("steps"), list) else []):
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
step_id = str(step.get("id") or f"{key}:step:{si}")
|
||||||
|
result["progression_steps"][step_id] = {
|
||||||
|
"label": f"{stage.get('name') or key} · {step.get('name') or f'Stufe {si + 1}'}",
|
||||||
|
"value": {
|
||||||
|
"progression_id": key, "name": step.get("name"),
|
||||||
|
"phase_id": step.get("phase_id"), "factor": step.get("factor"),
|
||||||
|
"movement_cluster": step.get("movement_cluster"),
|
||||||
|
"result_schema": step.get("result_schema"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
before, after = collect(old_cfg), collect(new_cfg)
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
changes: list[dict[str, Any]] = []
|
||||||
|
for section in ("days", "rotations", "exercises", "progression_steps"):
|
||||||
|
left, right = before[section], after[section]
|
||||||
|
added = sorted(set(right) - set(left))
|
||||||
|
removed = sorted(set(left) - set(right))
|
||||||
|
changed = sorted(key for key in set(left) & set(right) if left[key]["value"] != right[key]["value"])
|
||||||
|
summary[section] = {
|
||||||
|
"before": len(left), "after": len(right), "delta": len(right) - len(left),
|
||||||
|
"added": len(added), "removed": len(removed), "changed": len(changed),
|
||||||
|
}
|
||||||
|
for key in added:
|
||||||
|
changes.append({"section": section, "kind": "added", "id": key, "label": right[key]["label"]})
|
||||||
|
for key in removed:
|
||||||
|
changes.append({"section": section, "kind": "removed", "id": key, "label": left[key]["label"]})
|
||||||
|
for key in changed:
|
||||||
|
changes.append({"section": section, "kind": "changed", "id": key, "label": right[key]["label"]})
|
||||||
|
summary["changes"] = changes[:50]
|
||||||
|
summary["total_changes"] = len(changes)
|
||||||
|
summary["training_format_changed"] = old_cfg.get("training_format") != new_cfg.get("training_format")
|
||||||
|
summary["plan_meta_changed"] = old_cfg.get("meta") != new_cfg.get("meta")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_file(pid: str) -> Path:
|
||||||
|
return TRACKER_PROPOSALS_DIR / f"{pid}.json"
|
||||||
|
|
||||||
|
|
||||||
|
ensure_seed()
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html")
|
||||||
|
|
||||||
|
@app.route("/api/plans", methods=["GET"])
|
||||||
|
def api_plans():
|
||||||
|
active = get_active(); plans = []
|
||||||
|
for pid in list_plan_ids():
|
||||||
|
try:
|
||||||
|
p = read_plan(pid)
|
||||||
|
plans.append({"id": pid, "name": p["name"], "active": pid == active, "revision": p["revision"], "published_revision": p["published_revision"], "has_unpublished_changes": p["draft"] != p["config"]})
|
||||||
|
except Exception: continue
|
||||||
|
return jsonify({"plans": plans, "active": active})
|
||||||
|
|
||||||
|
@app.route("/api/plans", methods=["POST"])
|
||||||
|
def api_plan_create():
|
||||||
|
body = request.get_json(force=True) or {}; name = str(body.get("name") or "Neue Phase").strip()
|
||||||
|
src = body.get("from"); kind = body.get("kind") or body.get("type") or "training"
|
||||||
|
cfg = deepcopy(read_plan(src)["draft"]) if src and plan_path(str(src)).exists() else default_for(str(kind))
|
||||||
|
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
|
||||||
|
return jsonify(public_wrapper(wrapper))
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>", methods=["GET"])
|
||||||
|
def api_plan_get(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(public_wrapper(read_plan(pid)))
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/select", methods=["POST"])
|
||||||
|
def api_plan_select(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
set_active(pid); return jsonify(public_wrapper(read_plan(pid)))
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>", methods=["POST"])
|
||||||
|
def api_plan_save(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
body = request.get_json(force=True) or {}
|
||||||
|
config = body.get("config") if isinstance(body.get("config"), dict) else body
|
||||||
|
expected = body.get("expected_revision") if isinstance(body, dict) else None
|
||||||
|
wrapper = read_plan(pid)
|
||||||
|
if expected is not None and int(expected) != wrapper["revision"]:
|
||||||
|
return jsonify({"error": "Der Plan wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
|
||||||
|
validation = _validation(config)
|
||||||
|
wrapper["draft"] = validation["config"]
|
||||||
|
wrapper["revision"] += 1
|
||||||
|
saved = write_plan(pid, wrapper)
|
||||||
|
return jsonify({"ok": True, **public_wrapper(saved), "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/validate", methods=["POST"])
|
||||||
|
def api_plan_validate(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
body = request.get_json(force=True) or {}
|
||||||
|
cfg = body.get("config") if isinstance(body.get("config"), dict) else body
|
||||||
|
result = _validation(cfg)
|
||||||
|
wrapper = read_plan(pid)
|
||||||
|
diff = _diff_summary(wrapper["config"], result["config"])
|
||||||
|
diff["tracked_sessions"] = _tracked_session_count(pid)
|
||||||
|
return jsonify({"errors": result["errors"], "warnings": result["warnings"], "config": result["config"], "diff": diff})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/publish", methods=["POST"])
|
||||||
|
def api_plan_publish(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
body = request.get_json(silent=True) or {}; wrapper = read_plan(pid)
|
||||||
|
expected = body.get("expected_revision")
|
||||||
|
if expected is not None and int(expected) != wrapper["revision"]:
|
||||||
|
return jsonify({"error": "Der Entwurf wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
|
||||||
|
validation = _validation(wrapper["draft"])
|
||||||
|
if validation["errors"]:
|
||||||
|
return jsonify({"error": "Der Plan enthält Validierungsfehler.", "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}), 400
|
||||||
|
diff = _diff_summary(wrapper["config"], validation["config"])
|
||||||
|
diff["tracked_sessions"] = _tracked_session_count(pid)
|
||||||
|
wrapper["config"] = deepcopy(validation["config"])
|
||||||
|
wrapper["draft"] = deepcopy(validation["config"])
|
||||||
|
wrapper["published_revision"] += 1
|
||||||
|
wrapper["revision"] += 1
|
||||||
|
wrapper["published_at"] = utc_now()
|
||||||
|
saved = write_plan(pid, wrapper)
|
||||||
|
return jsonify({"ok": True, **public_wrapper(saved), "diff": diff, "validation": {"errors": [], "warnings": validation["warnings"]}})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/rename", methods=["POST"])
|
||||||
|
def api_plan_rename(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
name = str((request.get_json(force=True) or {}).get("name") or "").strip()
|
||||||
|
if not name: return jsonify({"error": "name required"}), 400
|
||||||
|
wrapper = read_plan(pid); wrapper["name"] = name; wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
|
||||||
|
return jsonify({"id": pid, "name": name, "revision": saved["revision"]})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>", methods=["DELETE"])
|
||||||
|
def api_plan_delete(pid: str):
|
||||||
|
ids = list_plan_ids()
|
||||||
|
if pid not in ids: return jsonify({"error": "not found"}), 404
|
||||||
|
if len(ids) <= 1: return jsonify({"error": "letzte Phase kann nicht gelöscht werden"}), 400
|
||||||
|
atomic_json_write(BACKUPS_DIR / f"deleted-{pid}-{utc_now().replace(':','')}.json", {k:v for k,v in read_plan(pid).items() if k != 'id'})
|
||||||
|
plan_path(pid).unlink()
|
||||||
|
if get_active() is None: set_active(list_plan_ids()[0])
|
||||||
|
return jsonify({"ok": True, "active": get_active()})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/export")
|
||||||
|
def api_plan_export(pid: str):
|
||||||
|
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404
|
||||||
|
return send_file(io.BytesIO(plan_path(pid).read_bytes()), mimetype="application/json", as_attachment=True, download_name=f"{pid}.json")
|
||||||
|
|
||||||
|
@app.route("/api/plans/import", methods=["POST"])
|
||||||
|
def api_plan_import():
|
||||||
|
raw = request.files["file"].read() if "file" in request.files else request.data
|
||||||
|
if not raw: return jsonify({"error": "keine Datei"}), 400
|
||||||
|
try: obj = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc: return jsonify({"error": f"ungültiges JSON: {exc}"}), 400
|
||||||
|
if isinstance(obj, dict) and "config" in obj:
|
||||||
|
cfg = obj.get("config", {}); name = str(obj.get("name") or "Importierte Phase")
|
||||||
|
else:
|
||||||
|
cfg = obj if isinstance(obj, dict) else {}; name = str((cfg.get("meta") or {}).get("title") or "Importierte Phase")
|
||||||
|
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid)
|
||||||
|
return jsonify(public_wrapper(wrapper))
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/proposals")
|
||||||
|
def api_plan_proposals(pid: str):
|
||||||
|
raw = {}
|
||||||
|
path = _proposal_file(pid)
|
||||||
|
if path.exists():
|
||||||
|
try: raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception: raw = {}
|
||||||
|
return jsonify(raw if isinstance(raw, dict) else {})
|
||||||
|
|
||||||
|
@app.route("/api/plans/<pid>/proposals/<proposal_id>", methods=["POST"])
|
||||||
|
def api_plan_proposal_status(pid: str, proposal_id: str):
|
||||||
|
path = _proposal_file(pid); body = request.get_json(silent=True) or {}; status = str(body.get("status") or "reviewed")
|
||||||
|
if status not in {"open", "accepted", "rejected", "reviewed"}:
|
||||||
|
return jsonify({"error": "ungültiger Vorschlagsstatus"}), 400
|
||||||
|
raw = {}
|
||||||
|
if path.exists():
|
||||||
|
try: raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception: raw = {}
|
||||||
|
proposals = raw.get("proposals") if isinstance(raw.get("proposals"), list) else []
|
||||||
|
for proposal in proposals:
|
||||||
|
if str(proposal.get("id")) == proposal_id:
|
||||||
|
proposal["status"] = status; proposal["reviewed_at"] = utc_now()
|
||||||
|
raw["proposals"] = proposals; raw["updated_at"] = utc_now(); atomic_json_write(path, raw)
|
||||||
|
return jsonify({"ok": True, "proposals": proposals})
|
||||||
|
|
||||||
|
# Backward-compatible active plan endpoints
|
||||||
|
@app.route("/api/config", methods=["GET"])
|
||||||
|
def api_get_config():
|
||||||
|
pid = get_active(); return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_for("training")})
|
||||||
|
|
||||||
|
@app.route("/api/config", methods=["POST"])
|
||||||
|
def api_save_config():
|
||||||
|
pid = get_active()
|
||||||
|
if not pid: return jsonify({"error": "kein aktiver Plan"}), 400
|
||||||
|
return api_plan_save(pid)
|
||||||
|
|
||||||
|
@app.route("/api/default")
|
||||||
|
def api_default(): return jsonify(default_for(request.args.get("kind", "training")))
|
||||||
|
|
||||||
|
@app.route("/api/reset", methods=["POST"])
|
||||||
|
def api_reset():
|
||||||
|
pid = get_active(); wrapper = read_plan(pid); kind = "recipe" if wrapper["draft"].get("type") == "recipe" else "training"
|
||||||
|
wrapper["draft"] = default_for(kind); wrapper["revision"] += 1; saved = write_plan(pid, wrapper)
|
||||||
|
return jsonify(public_wrapper(saved))
|
||||||
|
|
||||||
|
@app.route("/api/pdf", methods=["POST"])
|
||||||
|
def api_pdf():
|
||||||
|
body = request.get_json(force=True) or {}; cfg = body.get("config") if isinstance(body.get("config"), dict) else body
|
||||||
|
if cfg.get("type") != "recipe": cfg = migrate_training_config(cfg)
|
||||||
|
try: pdf_bytes = builder_for(cfg).build_pdf(cfg)
|
||||||
|
except Exception as exc: return jsonify({"error": str(exc)}), 400
|
||||||
|
name = cfg.get("meta", {}).get("output_name", "Trainingsplan.pdf")
|
||||||
|
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=name)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Plugin-Adapter für den Trainingsplan- und Rezept-Editor.
|
||||||
|
|
||||||
|
Das Original ist eine Flask-Anwendung (app.py) und bleibt vollständig
|
||||||
|
unverändert. Hier wird sie lediglich
|
||||||
|
|
||||||
|
* auf ihren eigenen Datenordner data/trainingsplan/ gezeigt und
|
||||||
|
* als WSGI-App in die ASGI-Suite eingehängt.
|
||||||
|
|
||||||
|
Das Tool läuft dadurch weiterhin auch allein:
|
||||||
|
cd plugins/trainingsplan && python app.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from a2wsgi import WSGIMiddleware
|
||||||
|
|
||||||
|
from core.loader import load_module
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(ctx):
|
||||||
|
# app.py liest DATA_DIR beim Import und legt dort die Phasen an.
|
||||||
|
previous = os.environ.get("DATA_DIR")
|
||||||
|
os.environ["DATA_DIR"] = str(ctx.data_dir)
|
||||||
|
try:
|
||||||
|
module = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app")
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop("DATA_DIR", None)
|
||||||
|
else:
|
||||||
|
os.environ["DATA_DIR"] = previous
|
||||||
|
|
||||||
|
# Flask erzeugt seine URLs anhand von SCRIPT_NAME; a2wsgi übernimmt dafür
|
||||||
|
# den root_path des Mounts. Damit funktionieren alle Routen unter
|
||||||
|
# /plugins/trainingsplan genauso wie vorher unter /.
|
||||||
|
return WSGIMiddleware(module.app)
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
plan_builder.py
|
||||||
|
Rendert den 8-Wochen-Calisthenics-Plan als A4-PDF.
|
||||||
|
Layout 1:1 wie im Original-Skript, aber alle Inhalte kommen aus einem Config-Dict.
|
||||||
|
build_pdf(config) -> bytes
|
||||||
|
default_config() -> dict (Standard-Inhalte)
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
from schema_contract import migrate_training_config
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||||
|
PageBreak, HRFlowable, KeepTogether)
|
||||||
|
from reportlab.lib.enums import TA_RIGHT
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# STANDARD-KONFIGURATION (entspricht exakt dem Original-Skript)
|
||||||
|
# =========================================================================
|
||||||
|
DEFAULTS = {
|
||||||
|
"meta": {
|
||||||
|
"title": "Calisthenics-Basis – 8-Wochen Komplettplan",
|
||||||
|
"subtitle": "Blutiger Anfänger · 1,90 m · 95 kg · Rucksack + Couch + Kettlebell · tägliche ~30-Min Tabata-Session",
|
||||||
|
"output_name": "Calisthenics-8-Wochen-Komplett.pdf",
|
||||||
|
"footer_left": "Calisthenics 8-Wochen Komplettplan \u00b7 mit Kettlebell",
|
||||||
|
"doc_title": "Calisthenics 8-Wochen Komplettplan (mit Kettlebell)",
|
||||||
|
"weeks": 8,
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"INK": "#1A1A1A", "ACCENT": "#C2410C", "ACC2": "#0F766E", "KBCOL": "#6D28D9",
|
||||||
|
"LINK": "#1565C0", "LIGHT": "#F4F1EC", "GRID": "#CFC9BF", "MUTE": "#6B6256",
|
||||||
|
"DARK": "#23201C", "WARMBG": "#EAF1F0", "GOALBG": "#FBF1E9", "KBBG": "#F1ECFB",
|
||||||
|
},
|
||||||
|
"front": {
|
||||||
|
"session_how_head": "So funktioniert eine Session",
|
||||||
|
"session_how_body": (
|
||||||
|
"<b>Tabata-Block = 4 Min:</b> 10 Sek einzählen, dann 8× (20 Sek Arbeit / 10 Sek Pause). "
|
||||||
|
"Pro Block rotieren <b>4 Übungen</b>, je <b>2 Durchgänge</b> = 8 Intervalle = 1 Block = <b>1 Satz</b>. "
|
||||||
|
"Eine Rotation läuft <b>3 Sätze</b> (in Woche 1 ruhig mit 2 starten). Warm-up steht über, Cool-down unter den Übungen."
|
||||||
|
),
|
||||||
|
"flow_rows": [
|
||||||
|
["Teil", "Inhalt", "Zeit"],
|
||||||
|
["Warm-up", "Gelenk-Prep (dynamisch)", "~3 Min"],
|
||||||
|
["Rotation A", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
|
||||||
|
["Rotation B", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
|
||||||
|
["Cool-down", "Ausklingen + Dehnen", "~6 Min"],
|
||||||
|
["", "Gesamt", "~35 Min"],
|
||||||
|
],
|
||||||
|
"timer_note": "<b>Timer:</b> „Tabata / Interval Timer“ auf 10s Vorlauf, 8 Runden, 20/10.",
|
||||||
|
"kb_head": "Warum Kettlebell – und warum es zu deinem Ziel passt",
|
||||||
|
"kb_box": (
|
||||||
|
"Du trainierst für <b>Calisthenics</b>, nicht für Maximalkraft. Genau da liegen die <b>belegten</b> Stärken der Kettlebell – "
|
||||||
|
"ohne dass dich ihr dokumentierter Schwachpunkt (Maximalkraft/Hypertrophie wegen fixer Gewichtssprünge) trifft:<br/>"
|
||||||
|
"• <b>Griffkraft</b> (Studie BELL: +6–7 kg) – dein größter Mehrwert auf dem Weg zu Klimmzug & Dip.<br/>"
|
||||||
|
"• <b>Power / ballistische Hüftstreckung</b> (Swing) – explosive Basis für spätere Skills.<br/>"
|
||||||
|
"• <b>Kondition & Körperfett</b> – wirksam belegt; du startest mit wenig Ausdauer.<br/>"
|
||||||
|
"• <b>Ganzkörper, ein Gerät, zeiteffizient</b> – passt ideal ins 30-Min-Tabata-Format; niedriges Einstiegs-Verletzungsrisiko bei sauberer Technik.<br/>"
|
||||||
|
"<b>Ehrlich:</b> Reines KB ersetzt <u>kein</u> Maximalkraft-/Muskelaufbau-Programm. Für dein Ziel (Basis-Fundament) liegt das ohnehin unter der relevanten Schwelle."
|
||||||
|
),
|
||||||
|
"goals_head": "Realistische Ziele nach 8 Wochen",
|
||||||
|
"goals_box": (
|
||||||
|
"Du startest bei 2–3 Liegestützen und 0 Dips. Realistisch und ehrlich nach 8 Wochen sauberer Arbeit:<br/>"
|
||||||
|
"• <b>Liegestütze:</b> von 2–3 auf erste saubere <b>volle</b> Reps (Rest in Knie-/Negativ-Form).<br/>"
|
||||||
|
"• <b>Bank-Dips:</b> saubere Reps mit Füßen etwas weiter weg. <b>Pike Push-up:</b> am Boden mit kleinem ROM.<br/>"
|
||||||
|
"• <b>Plank ~45–60 s</b>, <b>Wall Sit ~60 s</b>, viele saubere <b>Goblet-Kniebeugen</b>, sauberer <b>Swing</b> + spürbar bessere <b>Griffkraft</b>.<br/>"
|
||||||
|
"• <b>Noch NICHT dran:</b> Diamond Push-ups, L-Sit, Handstand, schwerer Turkish Get-Up, Snatch – Stoff für Woche 12+."
|
||||||
|
),
|
||||||
|
"goals_note": (
|
||||||
|
"<b>Wichtig:</b> Die Wochen-Stufen sind ein <b>Rahmen, kein Muss</b>. Bleib auf einer Stufe, solange sie fordert. "
|
||||||
|
"<b>Aufstiegs-Regel:</b> erst hoch (oder schwerere Glocke), wenn du eine Übung 2 Sessions in Folge in allen Sätzen sauber über die vollen 20s schaffst. "
|
||||||
|
"<font color='#6D28D9'><b>KB = Kettlebell bevorzugt; Rucksack bleibt als Alternative.</b></font>"
|
||||||
|
),
|
||||||
|
"lib_head": "Übungs-Bibliothek (mit Anleitungen)",
|
||||||
|
"lib_intro": (
|
||||||
|
"Für Übungen, die du noch nicht kennst, ist ein Link zu einer bebilderten Anleitung dabei. "
|
||||||
|
"<font color='#6D28D9'>Lila Gruppe = Kettlebell-Übungen.</font>"
|
||||||
|
),
|
||||||
|
"baseline_head": "Standortbestimmung & Tracking",
|
||||||
|
"baseline_intro": "Mach die Tests <b>vor Tag 1</b> und in <b>Woche 8 identisch</b> nochmal – dein Vorher/Nachher.",
|
||||||
|
"baseline_rows": [
|
||||||
|
["Test", "Tag 0", "Woche 8"],
|
||||||
|
["Liegestütze (max, saubere Reps)", "", ""],
|
||||||
|
["Bank-Dips an der Couch (max)", "", ""],
|
||||||
|
["Kniebeugen in 60 Sekunden", "", ""],
|
||||||
|
["Ausfallschritte (max gesamt)", "", ""],
|
||||||
|
["Plank (max Sekunden)", "", ""],
|
||||||
|
["Wall Sit (max Sekunden)", "", ""],
|
||||||
|
["KB Farmer-Hold (max Sekunden, Griff)", "", ""],
|
||||||
|
["Körpergewicht (kg)", "95", ""],
|
||||||
|
],
|
||||||
|
"reminder_head": "Kurz-Reminder",
|
||||||
|
"reminders": [
|
||||||
|
"<b>Form vor Reps.</b> Wähle die Stufe / Glocke, bei der du die 20s kontrolliert durcharbeitest – mit 1–2 Reps Reserve.",
|
||||||
|
"<b>Swing-Technik first.</b> Der Swing ist ein Hüft-Schnapp, KEIN Kniebeuge und KEIN Armheben. Erst Dead-Stop-Swings, bis die Hüftstreckung sitzt.",
|
||||||
|
"<b>Einseitige Übungen (Tag 2):</b> Durchgang 1 = links, Durchgang 2 = rechts – so ist jede Seite automatisch ausbalanciert.",
|
||||||
|
"<b>Täglich ist viel.</b> Tag 5 leicht, Tag 7 frei. Bei starkem Muskelkater einen Extra-Ruhetag – gehört zum Aufbau.",
|
||||||
|
"<b>Schlaf & Eiweiß.</b> 7–9 h Schlaf, ~1,6 g Eiweiß pro kg. Stechender Schmerz = stoppen.",
|
||||||
|
],
|
||||||
|
"sheets_note": (
|
||||||
|
"Ab der nächsten Seite: 1 Druckblatt pro Session (Woche 1–8). <font color='#6D28D9'>Tag 6 = reine Kettlebell-Session.</font> "
|
||||||
|
"Warm-up steht über, Cool-down + Dehnen unter den Übungen. Bei Vorerkrankungen/Verletzungen vorher ärztlich abklären."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"library": [
|
||||||
|
{"group": "DRÜCKEN (Brust / Schultern / Trizeps)", "color": "ACC2", "items": [
|
||||||
|
{"name": "Liegestütze", "desc": "Stütz, Körper gerade, Brust kontrolliert senken & drücken. Stufen: Inkline (Couch), Knie, Negativ, Voll.", "url": "https://blog.calimove.com/2023/01/25/push-up-variations-beginner-expert/"},
|
||||||
|
{"name": "Bank-Dips (Couch)", "desc": "Rücken zur Couch, Hände auf der Kante, Po davor absenken (Ellbogen nach hinten), hochdrücken. Knie beugen = leichter.", "url": "https://www.healthline.com/health/fitness-exercise/bench-dips"},
|
||||||
|
{"name": "Pike Push-up / Hold", "desc": "Umgekehrtes V (Hintern hoch), Kopf Richtung Boden senken, hochdrücken. Schulter-Fokus. Hände erhöht = leichter.", "url": "https://www.hingehealth.com/resources/articles/pike-push-up/"},
|
||||||
|
{"name": "Seitheben (KB/Rucksack)", "desc": "Arme seitlich bis Schulterhöhe heben, Ellbogen leicht gebeugt, kontrolliert senken. Seitlicher Deltamuskel.", "url": ""},
|
||||||
|
]},
|
||||||
|
{"group": "ZIEHEN (Rücken / Bizeps – KB oder Rucksack + Boden)", "color": "ACC2", "items": [
|
||||||
|
{"name": "Einarm-Rudern ENG", "desc": "Hüfte beugen, Rücken GERADE, abgestützt. Ellbogen an den Rippen, Zug zur Hüfte – Lat.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
|
||||||
|
{"name": "Einarm-Rudern BREIT", "desc": "Gleiche Position, aber Ellbogen ~45° geöffnet, Zug zum Brustkorb – oberer Rücken / hinterer Delta.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
|
||||||
|
{"name": "Bizeps-Curls (KB/Rucksack)", "desc": "Last unten greifen, Oberarme fix am Körper, kontrolliert zu den Schultern curlen.", "url": ""},
|
||||||
|
{"name": "Superman / Reverse Snow Angels / Y-T-W", "desc": "Bäuchlings Arme+Beine heben (Superman). Snow Angels = Arme am Boden auf/ab. Rücken ohne Stange.", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
|
||||||
|
]},
|
||||||
|
{"group": "BEINE", "color": "ACC2", "items": [
|
||||||
|
{"name": "Kniebeuge / Goblet Squat (KB/Rucksack)", "desc": "Hüfte zurück, Oberschenkel parallel, Fersen am Boden. Goblet = Glocke/Rucksack vor der Brust umarmen.", "url": ""},
|
||||||
|
{"name": "Ausfallschritte", "desc": "Großer Schritt (rückwärts = knieschonend), hinteres Knie Richtung Boden. Anfangs ggf. festhalten.", "url": ""},
|
||||||
|
{"name": "RDL / Hip Hinge (KB/Rucksack)", "desc": "Knie leicht gebeugt, Hüfte nach hinten, Rücken gerade, Last entlang der Beine senken bis Dehnung hinten (Hamstrings).", "url": ""},
|
||||||
|
{"name": "Glute Bridge / Wadenheben / Wall Sit", "desc": "Bridge: Hüfte hochdrücken (1-beinig steigern). Waden: Zehenspitzen hoch/runter. Wall Sit: Rücken an die Wand, halten.", "url": ""},
|
||||||
|
]},
|
||||||
|
{"group": "KETTLEBELL (Power / Griff / Ganzkörper)", "color": "KBCOL", "items": [
|
||||||
|
{"name": "Swing (zweiarmig, Hardstyle)", "desc": "<b>Wichtigste KB-Übung.</b> Hüfthinge, Glocke aus der Hüfte explosiv auf Brusthöhe schnappen (kein Armheben), oben Po+Bauch fest. Dead-Stop zum Lernen.", "url": "https://www.onepeloton.com/blog/kettlebell-swing/"},
|
||||||
|
{"name": "Goblet Squat (KB)", "desc": "Glocke an den Hörnern vor der Brust umarmen, tief und aufrecht hocken. Beste Einsteiger-KB-Kniebeuge.", "url": "https://www.onepeloton.com/blog/goblet-squat/"},
|
||||||
|
{"name": "KB-Deadlift (beidbeinig)", "desc": "Glocke zwischen den Füßen, Hüfte hinge, Rücken gerade, vom Boden heben und Hüfte durchstrecken. Stabile Swing-Vorbereitung.", "url": "https://www.onepeloton.com/blog/kettlebell-deadlift/"},
|
||||||
|
{"name": "KB Floor Press", "desc": "Rücklings am Boden, Glocke vom Boden weg nach oben drücken. Schulterschonend, gut für Anfänger.", "url": "https://www.masterclass.com/articles/kettlebell-floor-press-guide"},
|
||||||
|
{"name": "Halo", "desc": "Glocke (oder Rucksack) vor der Brust, langsam eng um den Kopf kreisen. Schulter-Mobility + Core.", "url": "https://www.tomsguide.com/how-to/kettlebell-halo-exercise-how-to-do-it-and-the-benefits-for-your-core-and-shoulder-muscles"},
|
||||||
|
{"name": "Bottoms-up KB-Hold / Carry", "desc": "Glocke am Griff, Boden nach oben, Unterarm senkrecht, still halten. Schwierigkeit kommt aus der Balance – fordert <b>Griff + Handgelenk</b> auch mit leichter Glocke.", "url": "https://www.onepeloton.com/blog/farmers-carry/"},
|
||||||
|
{"name": "Around-the-World + Uppercut", "desc": "Glocke kreist um den Körper (Around-the-World); danach diagonal nach oben zur gegenüberliegenden Schulter/Brust führen, die andere Hand fängt ab (wie ein Uppercut von unten). Rotatorischer Core + Koordination. Erst nur das Umkreisen lernen, Uppercut später.", "url": ""},
|
||||||
|
]},
|
||||||
|
{"group": "CORE", "color": "ACC2", "items": [
|
||||||
|
{"name": "Plank / Side Plank", "desc": "Unterarmstütz, Körper gerade. Anfangs auf den Knien. Side Plank seitlich auf einem Unterarm.", "url": ""},
|
||||||
|
{"name": "Hollow Hold", "desc": "Auf dem Rücken, unterer Rücken am Boden, Arme+Beine leicht angehoben. Anfänger: angewinkelt (Tuck).", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
|
||||||
|
{"name": "Dead Bug", "desc": "Auf dem Rücken, Arme+Knie 90°, gegengleich Arm+Bein langsam ausstrecken, unterer Rücken bleibt am Boden.", "url": "https://dieringe.com/exercises/dead-bug"},
|
||||||
|
{"name": "Bird Dog", "desc": "Vierfüßler, gegengleich Arm+Bein ausstrecken, Rumpf stabil, kurz halten.", "url": "https://www.hingehealth.com/resources/articles/bird-dog/"},
|
||||||
|
{"name": "Mountain Climbers / Bicycle / Russian Twist / Beinheben", "desc": "Climbers: Knie zügig zur Brust. Bicycle: Ellbogen zum Gegenknie. Twist: sitzend drehen (KB). Beinheben: Knie gebeugt = leichter.", "url": ""},
|
||||||
|
]},
|
||||||
|
{"group": "WARM-UP & MOBILITY", "color": "ACC2", "items": [
|
||||||
|
{"name": "Inchworm", "desc": "Aus dem Stand vorbeugen, Hände nach vorne in den Stütz wandern, dann zurück.", "url": "https://www.onepeloton.com/blog/inchworm-exercise"},
|
||||||
|
{"name": "Jacks / High Knees / Kreisen / Cat-Cow", "desc": "Klassische dynamische Aufwärm-Bewegungen – locker, zum Warmwerden.", "url": ""},
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
"phases": {
|
||||||
|
"names": ["Phase 1 · Fundament", "Phase 2 · Aufbau", "Phase 3 · Steigerung", "Phase 4 · Festigen"],
|
||||||
|
"params": [
|
||||||
|
"Fokus: Technik (v.a. Swing als Dead-Stop). 2–3 Reps Reserve. Pausen bis ~60s. Leichte Glocke. Woche 1 mit 2 Sätzen pro Rotation.",
|
||||||
|
"Sauber bleiben, letzter Satz fordernd. Pausen ~45–60s. Etwas schwerere Glocke / mehr Reps. 2–3 Sätze.",
|
||||||
|
"Fordernd, aber 1 Rep Reserve. Pausen ~45s. Schwerere Glocke / einarmig wo angegeben. 3 Sätze.",
|
||||||
|
"Fordernd & sauber (NICHT bis zum Muskelversagen). Pausen ~45s. Ende Woche 8 leichter (Deload). 3 Sätze.",
|
||||||
|
],
|
||||||
|
"param_light": "Bewusst locker – aktive Erholung. Saubere, leichte Wiederholungen, keine Belastung bis zum Limit.",
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
'ls': ['Inkline (hoch)', 'Inkline tiefer / Knie', 'Knie / Negativ', 'Negativ + erste Volle'],
|
||||||
|
'dips': ['Knie stark gebeugt, klein', 'Knie gebeugt, voll', 'Füße etwas weiter', 'Füße weiter weg'],
|
||||||
|
'pike': ['Pike Hold', 'Hold + kleine Senkung', 'Push-up Hände erhöht', 'Push-up Boden, klein'],
|
||||||
|
'lseng': ['Inkline (hoch)', 'Inkline mittel', 'Inkline tief', 'Knie-Liegestütze'],
|
||||||
|
'load': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
|
||||||
|
'squat': ['Körpergewicht', 'Körpergewicht, mehr Reps', 'Goblet leicht', 'Goblet mittel'],
|
||||||
|
'tsquat': ['3s runter', '3s + Pause unten', 'Goblet 3s', 'Goblet 3s + Pause'],
|
||||||
|
'lunge': ['Körpergewicht (ggf. festhalten)', 'Körpergewicht', 'mehr Reps', '+ Last leicht'],
|
||||||
|
'bridge': ['beidbeinig', 'beidbeinig, 2s halten', 'einbeinig', 'einbeinig, 2s halten'],
|
||||||
|
'wall': ['15–25 s', '25–40 s', '40–55 s', '55–70 s'],
|
||||||
|
'calf': ['Körpergewicht', 'langsam', 'langsam, mehr Reps', '1-beinig'],
|
||||||
|
'sup': ['Superman Hold', 'Superman Hold länger', 'Reverse Snow Angels', 'Y-T-W Raises'],
|
||||||
|
'plank': ['auf Knien 15–20 s', 'voll 20–30 s', 'voll + Schulter-Taps', 'voll, länger'],
|
||||||
|
'mtn': ['moderat', 'moderat', 'zügig', 'zügig'],
|
||||||
|
'db': ['Arme ODER Beine einzeln', 'gegengleich', 'gegengleich + Pause', 'Beine weiter gestreckt'],
|
||||||
|
'lr': ['Knie gebeugt', 'Knie gebeugt, langsam', 'halb gestreckt', 'gestreckt'],
|
||||||
|
'holl': ['Tuck (Knie an)', 'Tuck, länger', 'halb gestreckt', 'halb/voll'],
|
||||||
|
'sp': ['auf Knien', 'auf Knien, länger', 'voll', 'voll, länger'],
|
||||||
|
'bic': ['langsam', 'moderat', 'moderat', 'zügig'],
|
||||||
|
'tw': ['ohne Gewicht', 'ohne Gewicht', 'KB leicht', 'KB'],
|
||||||
|
'swing': ['Dead-Stop, leicht (Technik)', 'Dead-Stop, mehr Reps', 'durchgehend, mittel', 'durchgehend, schwerer'],
|
||||||
|
'gobletkb': ['leicht, Tiefe lernen', 'leicht, mehr Reps', 'mittel', 'mittel, 3s runter'],
|
||||||
|
'press': ['leicht (Floor Press)', 'Floor Press mehr', 'Overhead leicht', 'Overhead mittel'],
|
||||||
|
'halo': ['leicht, langsam', 'leicht', 'mittel', 'mittel, langsam'],
|
||||||
|
'carry': ['im Stand 10–15s', '15–20s, ruhig', 'bis Schulterhöhe 15s', 'länger / ein paar Schritte'],
|
||||||
|
'kbdl': ['leicht, Technik', 'leicht, mehr Reps', 'mittel', 'schwerer, kontrolliert'],
|
||||||
|
'rowe': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
|
||||||
|
'row1': ['leicht', 'etwas mehr', 'einarmig leicht', 'einarmig mittel'],
|
||||||
|
'row2': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, Pause oben'],
|
||||||
|
'lraise': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, langsam'],
|
||||||
|
'atw': ['nur Around-the-World, leicht', 'Around-the-World, mehr Reps', '+ Uppercut, leicht', '+ Uppercut, flüssig'],
|
||||||
|
},
|
||||||
|
"prepost": {
|
||||||
|
"1": {"warmup": "Handflächen-Wippen · Armkreisen/Wand-Slides · Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "Türrahmen (Brust/Schulter, je 30–40s) · Trizeps (je 30s) · Seitheben/Lat-Seitdehnung (je 30s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 30–40s) · Wade an der Wand (je 30s)"},
|
||||||
|
"2": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (8–10×) · Cat-Cow · Hüftkreisen + Beinschwünge",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "Lat-/Seitdehnung (je 30s) · Türrahmen (je 30–40s) · BWS über die Couch (30–40s) · <b>Cobra/Sphinx</b> (Bauch, 30s) · Unterarm (je 20s) · Knieender Hüftbeuger (je 30s)"},
|
||||||
|
"3": {"warmup": "Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand · Cat-Cow · Handflächen-Wippen",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "<b>Hamstring-Dehnung</b> (Couch, je 30–40s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 30–40s) · Figure-4/Taube (je 30–40s) · <b>Cobra + liegende Wirbelsäulen-Rotation</b> (je 30s) · Wade an der Wand (je 30s)"},
|
||||||
|
"4": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (8–10×) · Armkreisen/Wand-Slides · Cat-Cow",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "Türrahmen (je 30–40s) · Trizeps (je 30s) · Lat-/Seitdehnung (je 30s) · BWS über die Couch (30–40s) · Unterarm (je 20s)"},
|
||||||
|
"5": {"warmup": "Cat-Cow · Hüftkreisen + Beinschwünge · Armkreisen/Wand-Slides · Knie-über-Zeh an der Wand",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "Türrahmen (je 30s) · Knieender Hüftbeuger (je 30s) · BWS über die Couch (30–40s) · Figure-4/Taube (je 30s) · Wade an der Wand (je 30s)"},
|
||||||
|
"6": {"warmup": "Halo (leicht) · Hüftkreisen + Beinschwünge · Cat-Cow · Handflächen-Wippen",
|
||||||
|
"cooldown": "Ausschütteln + Gehen (60–90s) · Box-Atmung (4–5 Runden)",
|
||||||
|
"stretch": "Hamstring-Dehnung (Couch, je 30–40s) · Knieender Hüftbeuger (je 30–40s) · Figure-4/Taube (je 30–40s) · Unterarm (je 20s) · BWS über die Couch (30–40s) · Lat-/Seitdehnung (je 30s)"},
|
||||||
|
},
|
||||||
|
"days": [
|
||||||
|
{"num": 1, "focus": "Drücken + Beine",
|
||||||
|
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken & drücken"},
|
||||||
|
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken, hochdrücken"},
|
||||||
|
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken (Schultern)"},
|
||||||
|
{"name": "Seitheben (KB/Rucksack)", "key": "lraise", "cue": "Arme seitlich bis Schulterhöhe, kontrolliert"}],
|
||||||
|
"rotB": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
|
||||||
|
{"name": "Ausfallschritt rückw.", "key": "lunge", "cue": "großer Schritt, Knie Richtung Boden"},
|
||||||
|
{"name": "Glute Bridge", "key": "bridge", "cue": "Hüfte hochdrücken, Po fest"},
|
||||||
|
{"name": "Wadenheben", "key": "calf", "cue": "auf Zehenspitzen hoch/runter"}]},
|
||||||
|
{"num": 2, "focus": "Ziehen + Core",
|
||||||
|
"rotA": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
|
||||||
|
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
|
||||||
|
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
|
||||||
|
{"name": "Superman / Y-T-W", "key": "sup", "cue": "bäuchlings Arme+Beine heben"}],
|
||||||
|
"rotB": [{"name": "Plank", "key": "plank", "cue": "gerade Linie halten"},
|
||||||
|
{"name": "Mountain Climbers", "key": "mtn", "cue": "Knie zügig zur Brust"},
|
||||||
|
{"name": "Dead Bug", "key": "db", "cue": "gegengleich, Rücken am Boden"},
|
||||||
|
{"name": "Beinheben", "key": "lr", "cue": "Beine heben/senken"}]},
|
||||||
|
{"num": 3, "focus": "Beine + Core",
|
||||||
|
"rotA": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
|
||||||
|
{"name": "RDL / Hip Hinge (KB/Rucksack)", "key": "load", "cue": "Hüfte zurück, Rücken gerade, Hamstrings"},
|
||||||
|
{"name": "Glute Bridge 1-beinig", "key": "bridge", "cue": "ein Bein gestreckt"},
|
||||||
|
{"name": "Wall Sit", "key": "wall", "cue": "Rücken an die Wand, halten"}],
|
||||||
|
"rotB": [{"name": "Hollow Hold", "key": "holl", "cue": "unterer Rücken am Boden"},
|
||||||
|
{"name": "Side Plank (Wechsel)", "key": "sp", "cue": "seitlich auf einem Unterarm"},
|
||||||
|
{"name": "Bicycle Crunch", "key": "bic", "cue": "Ellbogen zum Gegenknie"},
|
||||||
|
{"name": "Russian Twist (KB)", "key": "tw", "cue": "sitzend Rumpf drehen"}]},
|
||||||
|
{"num": 4, "focus": "Oberkörper: Drücken + Ziehen",
|
||||||
|
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken & drücken"},
|
||||||
|
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken"},
|
||||||
|
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken"},
|
||||||
|
{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"}],
|
||||||
|
"rotB": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
|
||||||
|
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
|
||||||
|
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
|
||||||
|
{"name": "Y-T-W / Superman", "key": "sup", "cue": "bäuchlings, Rücken"}]},
|
||||||
|
{"num": 5, "focus": "Ganzkörper LEICHT + Mobility",
|
||||||
|
"rotA": [{"name": "Kniebeuge (leicht)", "key": "", "cue": "locker, sauber"},
|
||||||
|
{"name": "Liegestütze (1 Stufe leichter)", "key": "", "cue": "leichtere Variante"},
|
||||||
|
{"name": "Rudern (leicht)", "key": "", "cue": "wenig Gewicht"},
|
||||||
|
{"name": "Mountain Climbers (locker)", "key": "", "cue": "ruhiges Tempo"}],
|
||||||
|
"rotB": [{"name": "Inchworm", "key": "", "cue": "in den Stütz wandern, zurück"},
|
||||||
|
{"name": "Cat-Cow", "key": "", "cue": "Rücken ründen/strecken"},
|
||||||
|
{"name": "Bird Dog", "key": "", "cue": "gegengleich Arm+Bein, kurz halten"},
|
||||||
|
{"name": "KB-Halo (leicht)", "key": "", "cue": "langsam um den Kopf kreisen"}]},
|
||||||
|
{"num": 6, "focus": "KETTLEBELL Ganzkörper",
|
||||||
|
"rotA": [{"name": "KB Swing (zweiarmig)", "key": "swing", "cue": "Hüft-Schnapp, kein Armheben"},
|
||||||
|
{"name": "Goblet Squat (KB)", "key": "gobletkb", "cue": "Glocke vor der Brust, tief"},
|
||||||
|
{"name": "KB-Deadlift (beidbeinig)", "key": "kbdl", "cue": "Glocke zwischen den Füßen, Hüfte hinge"},
|
||||||
|
{"name": "Halo", "key": "halo", "cue": "eng um den Kopf kreisen"}],
|
||||||
|
"rotB": [{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"},
|
||||||
|
{"name": "Einarmiges Rudern (KB)", "key": "row1", "cue": "Rücken gerade, zum Bauch"},
|
||||||
|
{"name": "Bottoms-up KB-Hold (Griff)", "key": "carry", "cue": "Glocke Boden nach oben, Unterarm senkrecht, still halten"},
|
||||||
|
{"name": "Around-the-World + Uppercut (KB)", "key": "atw", "cue": "Glocke um den Körper, dann diagonal zur Gegen-Schulter, andere Hand fängt"}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
KB_NOTE = ("<b>Reine Kettlebell-Session.</b> Swing = Hüft-Schnapp, kein Armheben. "
|
||||||
|
"Glocke so wählen, dass alle Reps sauber sind.")
|
||||||
|
|
||||||
|
|
||||||
|
def _lighten(hexstr, w=0.90):
|
||||||
|
try:
|
||||||
|
c = colors.HexColor(hexstr)
|
||||||
|
except Exception:
|
||||||
|
c = colors.HexColor("#0F766E")
|
||||||
|
return colors.Color(c.red * (1 - w) + w, c.green * (1 - w) + w, c.blue * (1 - w) + w)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_config(cfg):
|
||||||
|
"""Führt alte Trainings-Configs in das neue, flexible Format über (idempotent).
|
||||||
|
Neu: phases.items[{name,params,weeks}], stages{key:{name,steps[]}},
|
||||||
|
days[{...,rotations:[{label,exercises[]}]}] statt rotA/rotB,
|
||||||
|
Tag-Farbe/Notiz/Badge statt fest verdrahtetem KB-Tag."""
|
||||||
|
cfg = copy.deepcopy(cfg or {})
|
||||||
|
meta = cfg.setdefault("meta", {})
|
||||||
|
days = cfg.get("days", []) or []
|
||||||
|
|
||||||
|
# phases: {names,params} -> {items:[{name,params,weeks}], param_light}
|
||||||
|
ph = cfg.get("phases", {}) or {}
|
||||||
|
if "items" not in ph:
|
||||||
|
names = ph.get("names", []) or []
|
||||||
|
params = ph.get("params", []) or []
|
||||||
|
n = max(len(names), len(params))
|
||||||
|
items = [{"name": names[i] if i < len(names) else ("Phase %d" % (i + 1)),
|
||||||
|
"params": params[i] if i < len(params) else "",
|
||||||
|
"weeks": 2} for i in range(n)]
|
||||||
|
cfg["phases"] = {"items": items, "param_light": ph.get("param_light", "")}
|
||||||
|
|
||||||
|
# Übungsnamen je Key sammeln (für Stufen-Namen)
|
||||||
|
name_by_key = {}
|
||||||
|
for d in days:
|
||||||
|
groups = [d.get("rotA", []) or [], d.get("rotB", []) or []]
|
||||||
|
for rot in d.get("rotations", []) or []:
|
||||||
|
groups.append(rot.get("exercises", []) or [])
|
||||||
|
for grp in groups:
|
||||||
|
for it in grp:
|
||||||
|
k = it.get("key")
|
||||||
|
if k and it.get("name") and k not in name_by_key:
|
||||||
|
name_by_key[k] = it["name"]
|
||||||
|
|
||||||
|
# stages: [..] -> {name, steps:[..], result_schemas:[..]}
|
||||||
|
# ``result_schemas`` liegt parallel zu ``steps``. Ein Eintrag kann ``None``
|
||||||
|
# sein und bedeutet dann: Übungsstandard bzw. Tracker-Fallback verwenden.
|
||||||
|
newstages = {}
|
||||||
|
for key, val in (cfg.get("stages", {}) or {}).items():
|
||||||
|
if isinstance(val, dict) and "steps" in val:
|
||||||
|
stage = copy.deepcopy(val)
|
||||||
|
steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
|
||||||
|
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
|
||||||
|
schemas = list(schemas[:len(steps)])
|
||||||
|
while len(schemas) < len(steps):
|
||||||
|
schemas.append(None)
|
||||||
|
stage["steps"] = steps
|
||||||
|
stage["result_schemas"] = schemas
|
||||||
|
newstages[key] = stage
|
||||||
|
else:
|
||||||
|
steps = val if isinstance(val, list) else []
|
||||||
|
nm = name_by_key.get(key) or next((s for s in steps if s), "") or key
|
||||||
|
newstages[key] = {"name": nm, "steps": steps, "result_schemas": [None] * len(steps)}
|
||||||
|
cfg["stages"] = newstages
|
||||||
|
|
||||||
|
# Explizites Trainingsformat. Alte Pläne bleiben auf ``auto`` und werden
|
||||||
|
# vom Tracker weiterhin aus den Plantexten erkannt. Neue Pläne können
|
||||||
|
# Tabata/Intervalle damit ohne Text-Heuristik eindeutig beschreiben.
|
||||||
|
fmt = cfg.get("training_format")
|
||||||
|
if not isinstance(fmt, dict):
|
||||||
|
fmt = {}
|
||||||
|
mode = str(fmt.get("mode") or "auto")
|
||||||
|
if mode not in {"auto", "sets_reps", "tabata", "fixed_interval"}:
|
||||||
|
mode = "auto"
|
||||||
|
normalized_fmt = {"mode": mode}
|
||||||
|
for field in ("work_seconds", "rest_seconds", "rounds"):
|
||||||
|
try:
|
||||||
|
value = int(fmt.get(field))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 0
|
||||||
|
normalized_fmt[field] = value if value > 0 else None
|
||||||
|
normalized_fmt["fixed_interval"] = bool(
|
||||||
|
fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"}
|
||||||
|
)
|
||||||
|
normalized_fmt["rounds_scope"] = "block"
|
||||||
|
cfg["training_format"] = normalized_fmt
|
||||||
|
|
||||||
|
# days: rotA/rotB -> rotations (+ KB-Styling & light-Tag nur bei Alt-Konvertierung)
|
||||||
|
for d in days:
|
||||||
|
if "rotations" not in d:
|
||||||
|
rots = []
|
||||||
|
if d.get("rotA"):
|
||||||
|
rots.append({"label": "ROTATION A — 3 Sätze", "exercises": d.get("rotA")})
|
||||||
|
if d.get("rotB"):
|
||||||
|
rots.append({"label": "ROTATION B — 3 Sätze", "exercises": d.get("rotB")})
|
||||||
|
d["rotations"] = rots
|
||||||
|
d.pop("rotA", None)
|
||||||
|
d.pop("rotB", None)
|
||||||
|
if d.get("num") == 6:
|
||||||
|
d.setdefault("color", "#6D28D9")
|
||||||
|
d.setdefault("note", KB_NOTE)
|
||||||
|
d.setdefault("note_bg", "#F1ECFB")
|
||||||
|
if d.get("num") == 5:
|
||||||
|
d.setdefault("light", True)
|
||||||
|
cfg["days"] = days
|
||||||
|
meta.setdefault("days", len(days))
|
||||||
|
return migrate_training_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config():
|
||||||
|
return migrate_config(copy.deepcopy(DEFAULTS))
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# RENDERER
|
||||||
|
# =========================================================================
|
||||||
|
def build_pdf(cfg):
|
||||||
|
"""cfg: Trainings-Config (altes oder neues Format). Gibt PDF-Bytes zurück."""
|
||||||
|
_D = DEFAULTS
|
||||||
|
cfg = dict(cfg or {})
|
||||||
|
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
|
||||||
|
cols = dict(_D["colors"]); cols.update(cfg.get("colors") or {})
|
||||||
|
cfg["meta"], cfg["colors"] = meta, cols
|
||||||
|
for k in ("front", "library", "phases", "stages", "prepost", "days"):
|
||||||
|
if not cfg.get(k):
|
||||||
|
cfg[k] = copy.deepcopy(_D[k])
|
||||||
|
cfg = migrate_config(cfg) # auf neues Modell normalisieren
|
||||||
|
|
||||||
|
C = {k: colors.HexColor(v) for k, v in cfg["colors"].items()}
|
||||||
|
INK, ACCENT, ACC2, KBCOL = C["INK"], C["ACCENT"], C["ACC2"], C["KBCOL"]
|
||||||
|
LINK, LIGHT, GRID, MUTE = C["LINK"], C["LIGHT"], C["GRID"], C["MUTE"]
|
||||||
|
DARK, WARMBG, GOALBG, KBBG = C["DARK"], C["WARMBG"], C["GOALBG"], C["KBBG"]
|
||||||
|
link_hex = cfg["colors"]["LINK"]
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
def Sty(name, **kw):
|
||||||
|
base = kw.pop("parent", styles["Normal"])
|
||||||
|
return ParagraphStyle(name, parent=base, **kw)
|
||||||
|
|
||||||
|
title_s = Sty("title", fontName="Helvetica-Bold", fontSize=23, textColor=INK, leading=26, spaceAfter=2)
|
||||||
|
sub_s = Sty("sub", fontName="Helvetica", fontSize=10.5, textColor=MUTE, leading=14, spaceAfter=2)
|
||||||
|
h2_s = Sty("h2", fontName="Helvetica-Bold", fontSize=14, textColor=ACCENT, leading=17, spaceBefore=13, spaceAfter=5)
|
||||||
|
body_s = Sty("body", fontName="Helvetica", fontSize=9.6, textColor=INK, leading=13.5, spaceAfter=4)
|
||||||
|
smallm_s = Sty("smallm", fontName="Helvetica", fontSize=8.2, textColor=MUTE, leading=10.5)
|
||||||
|
cell_s = Sty("cell", fontName="Helvetica", fontSize=8.3, textColor=INK, leading=10.6)
|
||||||
|
cellb_s = Sty("cellb", fontName="Helvetica-Bold", fontSize=8.3, textColor=INK, leading=10.6)
|
||||||
|
link_s = Sty("link", fontName="Helvetica", fontSize=8.2, textColor=LINK, leading=10.6)
|
||||||
|
th_s = Sty("th", fontName="Helvetica-Bold", fontSize=8.6, textColor=colors.white, leading=11)
|
||||||
|
tag_s = Sty("tag", fontName="Helvetica-Bold", fontSize=8.4, textColor=colors.white, leading=11)
|
||||||
|
note_s = Sty("note", fontName="Helvetica", fontSize=9, textColor=INK, leading=13)
|
||||||
|
goal_s = Sty("goal", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
|
||||||
|
kb_s = Sty("kb", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
|
||||||
|
bandL = Sty("bandL", fontName="Helvetica-Bold", fontSize=17, textColor=colors.white, leading=19)
|
||||||
|
bandR = Sty("bandR", fontName="Helvetica-Bold", fontSize=11.5, textColor=colors.white, leading=14, alignment=TA_RIGHT)
|
||||||
|
howto = Sty("howto", fontName="Helvetica", fontSize=8.6, textColor=MUTE, leading=11.5, spaceBefore=4, spaceAfter=2)
|
||||||
|
warm = Sty("warm", fontName="Helvetica", fontSize=9, textColor=INK, leading=12.5)
|
||||||
|
roth = Sty("roth", fontName="Helvetica-Bold", fontSize=11, textColor=colors.white, leading=13)
|
||||||
|
exn = Sty("exn", fontName="Helvetica-Bold", fontSize=9.6, textColor=INK, leading=11.5)
|
||||||
|
stage_s = Sty("stage", fontName="Helvetica-Bold", fontSize=9, textColor=ACCENT, leading=11)
|
||||||
|
param_s = Sty("param", fontName="Helvetica", fontSize=8.8, textColor=INK, leading=12)
|
||||||
|
logst = Sty("logst", fontName="Helvetica", fontSize=9.5, textColor=INK, leading=18)
|
||||||
|
|
||||||
|
USABLE = A4[0] - 2 * 1.3 * cm
|
||||||
|
|
||||||
|
def link(url, label="Anleitung (Bilder/Video)"):
|
||||||
|
return Paragraph('<a href="%s" color="%s"><u>%s</u></a>' % (url, link_hex, label), link_s)
|
||||||
|
|
||||||
|
front = cfg["front"]
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# ===================== FRONT MATTER =====================
|
||||||
|
story.append(Paragraph(cfg["meta"]["title"], title_s))
|
||||||
|
story.append(Paragraph(cfg["meta"]["subtitle"], sub_s))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=6, spaceAfter=6))
|
||||||
|
|
||||||
|
story.append(Paragraph(front["session_how_head"], h2_s))
|
||||||
|
story.append(Paragraph(front["session_how_body"], body_s))
|
||||||
|
|
||||||
|
flow = front["flow_rows"]
|
||||||
|
fr = [[Paragraph(c, th_s if i == 0 else (cellb_s if (j == 1 and r[0] == '') else cell_s)) for j, c in enumerate(r)] for i, r in enumerate(flow)]
|
||||||
|
t = Table(fr, colWidths=[3.2 * cm, 9.6 * cm, 2.7 * cm])
|
||||||
|
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
|
||||||
|
story.append(t)
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(front["timer_note"], smallm_s))
|
||||||
|
|
||||||
|
# KB evidence box
|
||||||
|
story.append(Paragraph(front["kb_head"], h2_s))
|
||||||
|
kb_rows = [[Paragraph(front["kb_box"], kb_s)]]
|
||||||
|
gt = Table(kb_rows, colWidths=[USABLE])
|
||||||
|
gt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), KBBG), ("BOX", (0, 0), (-1, -1), 0.8, KBCOL),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
|
||||||
|
story.append(gt)
|
||||||
|
|
||||||
|
story.append(Paragraph(front["goals_head"], h2_s))
|
||||||
|
goal_rows = [[Paragraph(front["goals_box"], goal_s)]]
|
||||||
|
gt2 = Table(goal_rows, colWidths=[USABLE])
|
||||||
|
gt2.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), GOALBG), ("BOX", (0, 0), (-1, -1), 0.8, ACCENT),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
|
||||||
|
story.append(gt2)
|
||||||
|
story.append(Spacer(1, 5))
|
||||||
|
story.append(Paragraph(front["goals_note"], note_s))
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# ---- exercise library ----
|
||||||
|
story.append(Paragraph(front["lib_head"], h2_s))
|
||||||
|
story.append(Paragraph(front["lib_intro"], smallm_s))
|
||||||
|
|
||||||
|
for grp in cfg["library"]:
|
||||||
|
gcol = C.get(grp["color"], ACC2)
|
||||||
|
rows = [[Paragraph(grp["group"], tag_s), "", ""],
|
||||||
|
[Paragraph("Übung", th_s), Paragraph("Ausführung (Kurz)", th_s), Paragraph("Anleitung", th_s)]]
|
||||||
|
for it in grp["items"]:
|
||||||
|
rows.append([Paragraph(it["name"], cellb_s), Paragraph(it["desc"], cell_s),
|
||||||
|
link(it["url"]) if it.get("url") else Paragraph("Standard-Bewegung", smallm_s)])
|
||||||
|
tstyle = [("SPAN", (0, 0), (-1, 0)), ("BACKGROUND", (0, 0), (-1, 0), gcol), ("BACKGROUND", (0, 1), (-1, 1), DARK),
|
||||||
|
("ROWBACKGROUNDS", (0, 2), (-1, -1), [colors.white, LIGHT]), ("GRID", (0, 1), (-1, -1), 0.5, GRID),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6)]
|
||||||
|
story.append(KeepTogether(Table(rows, colWidths=[4.0 * cm, 8.9 * cm, 3.5 * cm], style=TableStyle(tstyle))))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# ---- baseline + reminders ----
|
||||||
|
story.append(Paragraph(front["baseline_head"], h2_s))
|
||||||
|
story.append(Paragraph(front["baseline_intro"], body_s))
|
||||||
|
track = front["baseline_rows"]
|
||||||
|
tr = [[Paragraph(c, th_s if i == 0 else cell_s) for c in r] for i, r in enumerate(track)]
|
||||||
|
tt = Table(tr, colWidths=[9.5 * cm, 3.0 * cm, 3.0 * cm])
|
||||||
|
tt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
|
||||||
|
story.append(tt)
|
||||||
|
story.append(Spacer(1, 8))
|
||||||
|
story.append(Paragraph(front["reminder_head"], h2_s))
|
||||||
|
for r in front["reminders"]:
|
||||||
|
story.append(Paragraph("• " + r, body_s))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=1, color=GRID))
|
||||||
|
story.append(Paragraph(front["sheets_note"], smallm_s))
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# ===================== SESSION SHEETS =====================
|
||||||
|
stages = cfg["stages"]
|
||||||
|
phase_items = cfg["phases"]["items"]
|
||||||
|
PARAM_LIGHT = cfg["phases"].get("param_light", "")
|
||||||
|
prepost = cfg["prepost"]
|
||||||
|
|
||||||
|
def phase_index(week):
|
||||||
|
acc = 0
|
||||||
|
for i, ph in enumerate(phase_items):
|
||||||
|
try:
|
||||||
|
acc += int(ph.get("weeks", 2) or 0)
|
||||||
|
except Exception:
|
||||||
|
acc += 2
|
||||||
|
if week <= acc:
|
||||||
|
return i
|
||||||
|
return max(0, len(phase_items) - 1)
|
||||||
|
|
||||||
|
def phase_name(pi):
|
||||||
|
return phase_items[pi]["name"] if 0 <= pi < len(phase_items) else ""
|
||||||
|
|
||||||
|
def phase_params(pi):
|
||||||
|
return phase_items[pi].get("params", "") if 0 <= pi < len(phase_items) else ""
|
||||||
|
|
||||||
|
def stage_text(key, pi):
|
||||||
|
st = stages.get(key) if key else None
|
||||||
|
if st:
|
||||||
|
steps = st.get("steps", []) if isinstance(st, dict) else st
|
||||||
|
if 0 <= pi < len(steps) and steps[pi]:
|
||||||
|
step = steps[pi]
|
||||||
|
return step.get("name", "") if isinstance(step, dict) else step
|
||||||
|
return "leicht"
|
||||||
|
|
||||||
|
def rotation_table(rot, pi):
|
||||||
|
rows = [[Paragraph("Übung", th_s), Paragraph("Stufe heute", th_s),
|
||||||
|
Paragraph("Satz 1", th_s), Paragraph("Satz 2", th_s), Paragraph("Satz 3", th_s)]]
|
||||||
|
for item in rot:
|
||||||
|
name, key, cue = item.get("name", ""), item.get("key") or None, item.get("cue", "")
|
||||||
|
ex = Paragraph("%s<br/><font size=7.6 color='#6B6256'>%s</font>" % (name, cue), exn)
|
||||||
|
sc = Paragraph(stage_text(key, pi), stage_s)
|
||||||
|
rows.append([ex, sc, "", "", ""])
|
||||||
|
tb = Table(rows, colWidths=[7.2 * cm, 3.5 * cm, 1.95 * cm, 1.95 * cm, 1.95 * cm])
|
||||||
|
tb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.6, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("TOPPADDING", (0, 1), (-1, -1), 8), ("BOTTOMPADDING", (0, 1), (-1, -1), 8),
|
||||||
|
("TOPPADDING", (0, 0), (-1, 0), 4), ("BOTTOMPADDING", (0, 0), (-1, 0), 4),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("ALIGN", (2, 0), (-1, -1), "CENTER")]))
|
||||||
|
return tb
|
||||||
|
|
||||||
|
def day_has_exercises(day):
|
||||||
|
return any((rot.get("exercises") or []) for rot in (day.get("rotations") or []))
|
||||||
|
|
||||||
|
def session_page(week, day):
|
||||||
|
num, focus = day.get("num"), day.get("focus", "")
|
||||||
|
pi = phase_index(week)
|
||||||
|
color = day.get("color")
|
||||||
|
band_col = colors.HexColor(color) if color else ACCENT
|
||||||
|
rot_col = colors.HexColor(color) if color else ACC2
|
||||||
|
pp = prepost.get(str(num), {"warmup": "", "cooldown": "", "stretch": ""})
|
||||||
|
pre_txt = pp.get("warmup", ""); cd_txt = pp.get("cooldown", ""); stretch_txt = pp.get("stretch", "")
|
||||||
|
flow = []
|
||||||
|
badge = day.get("badge") or {}
|
||||||
|
btext = badge.get("text", "").strip()
|
||||||
|
left_html = "WOCHE %d · TAG %d" % (week, num)
|
||||||
|
if btext:
|
||||||
|
left_html += " <font size=8 color='white' backColor='%s'> %s </font>" % (badge.get("color", "#6D28D9"), btext)
|
||||||
|
band = Table([[Paragraph(left_html, bandL),
|
||||||
|
Paragraph("%s<br/><font size=8 color='#EAD9F7'>%s</font>" % (focus, phase_name(pi)), bandR)]],
|
||||||
|
colWidths=[USABLE * 0.5, USABLE * 0.5])
|
||||||
|
band.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), band_col), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("LEFTPADDING", (0, 0), (0, 0), 12), ("RIGHTPADDING", (-1, 0), (-1, 0), 12)]))
|
||||||
|
flow.append(band)
|
||||||
|
flow.append(Paragraph("Tabata: 20s an / 10s aus · pro Block 4 Übungen × 2 Durchgänge = 1 Satz · 10s Vorlauf", howto))
|
||||||
|
wb = Table([[Paragraph("<b>WARM-UP · Gelenk-Prep</b> (dynamisch, ~3 Min, kein Halten): " + pre_txt, warm)]], colWidths=[USABLE])
|
||||||
|
wb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
|
||||||
|
flow.append(Spacer(1, 5))
|
||||||
|
flow.append(wb)
|
||||||
|
note = (day.get("note") or "").strip()
|
||||||
|
if note:
|
||||||
|
nb = colors.HexColor(day["note_bg"]) if day.get("note_bg") else _lighten(color or "#0F766E")
|
||||||
|
nborder = colors.HexColor(color) if color else ACC2
|
||||||
|
flow.append(Spacer(1, 4))
|
||||||
|
nt = Table([[Paragraph(note, warm)]], colWidths=[USABLE])
|
||||||
|
nt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), nb), ("BOX", (0, 0), (-1, -1), 0.6, nborder),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
|
||||||
|
flow.append(nt)
|
||||||
|
flow.append(Spacer(1, 8))
|
||||||
|
for rot in (day.get("rotations") or []):
|
||||||
|
exs = rot.get("exercises") or []
|
||||||
|
if not exs:
|
||||||
|
continue
|
||||||
|
rh = Table([[Paragraph(rot.get("label", ""), roth)]], colWidths=[USABLE])
|
||||||
|
rh.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), rot_col), ("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 8)]))
|
||||||
|
flow.append(rh)
|
||||||
|
flow.append(Spacer(1, 2))
|
||||||
|
flow.append(rotation_table(exs, pi))
|
||||||
|
flow.append(Spacer(1, 8))
|
||||||
|
pbp = day.get("params_by_phase") or []
|
||||||
|
if 0 <= pi < len(pbp) and (pbp[pi] or "").strip():
|
||||||
|
ptxt = pbp[pi]
|
||||||
|
else:
|
||||||
|
ptxt = PARAM_LIGHT if day.get("light") else phase_params(pi)
|
||||||
|
pb = Table([[Paragraph(ptxt, param_s)]], colWidths=[USABLE])
|
||||||
|
pb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), LIGHT), ("BOX", (0, 0), (-1, -1), 0.6, GRID),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 9)]))
|
||||||
|
flow.append(pb)
|
||||||
|
flow.append(Spacer(1, 7))
|
||||||
|
cd = Table([[Paragraph("<b>COOL-DOWN</b> (~1–2 Min): " + cd_txt +
|
||||||
|
"<br/><b>DEHNEN</b> (warm & statisch, bis mildes Ziehen, ~4–6 Min): " + stretch_txt, warm)]], colWidths=[USABLE])
|
||||||
|
cd.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
|
||||||
|
flow.append(cd)
|
||||||
|
flow.append(Spacer(1, 7))
|
||||||
|
flow.append(Paragraph("Gefühl heute (1–10): ______ Glocke (kg): ______ Notizen: ________________________________", logst))
|
||||||
|
return flow
|
||||||
|
|
||||||
|
weeks = int(cfg["meta"].get("weeks", 8) or 0)
|
||||||
|
for week in range(1, weeks + 1):
|
||||||
|
for day in cfg["days"]:
|
||||||
|
if not day_has_exercises(day):
|
||||||
|
continue
|
||||||
|
story += session_page(week, day)
|
||||||
|
story.append(PageBreak())
|
||||||
|
if story and isinstance(story[-1], PageBreak):
|
||||||
|
story.pop()
|
||||||
|
|
||||||
|
footer_left = cfg["meta"]["footer_left"]
|
||||||
|
|
||||||
|
def footer(canvas, doc):
|
||||||
|
canvas.saveState()
|
||||||
|
canvas.setFont("Helvetica", 7.5)
|
||||||
|
canvas.setFillColor(MUTE)
|
||||||
|
canvas.drawString(1.3 * cm, 1.0 * cm, footer_left)
|
||||||
|
canvas.drawRightString(A4[0] - 1.3 * cm, 1.0 * cm, "Seite %d" % doc.page)
|
||||||
|
canvas.restoreState()
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=1.3 * cm, rightMargin=1.3 * cm,
|
||||||
|
topMargin=1.2 * cm, bottomMargin=1.4 * cm, title=cfg["meta"]["doc_title"])
|
||||||
|
doc.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = build_pdf(default_config())
|
||||||
|
with open("/tmp/test_plan.pdf", "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
print("OK", len(data), "bytes")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"id": "trainingsplan",
|
||||||
|
"name": "Trainingsplan- & Rezept-Editor",
|
||||||
|
"summary": "Versionierte Trainingspläne mit stabilen IDs, Validierung und Tracker-Vorschau bearbeiten",
|
||||||
|
"description": "Editor mit Entwurf und Veröffentlichung, stabilem Planvertrag, progressionsabhängigen Ergebnisschemata, planbezogener Übungsbibliothek, Konfliktschutz und strukturierten Vorschlägen aus dem Session-Tracker.",
|
||||||
|
"icon": "🏋",
|
||||||
|
"category": "Planung",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"entrypoint": "backend:create_app",
|
||||||
|
"order": 10,
|
||||||
|
"requires": [],
|
||||||
|
"contract": {
|
||||||
|
"plan_schema": 3,
|
||||||
|
"training_contract": 2,
|
||||||
|
"result_schema": 2
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
"Entwurf und veröffentlichte Planrevision sind getrennt",
|
||||||
|
"Stabile IDs für Phasen, Tage, Rotationen, Übungen und Progressionsstufen",
|
||||||
|
"Progressionsstufen als Objekte mit Ergebnisschema, Cluster und Faktor",
|
||||||
|
"Planbezogene Übungsbibliothek für den Tracker",
|
||||||
|
"Validierung von IDs, Schemata und festen Intervallen",
|
||||||
|
"Tracker-Vorschau inklusive Tabata-Durchgängen",
|
||||||
|
"Atomisches Speichern mit Backups und Revisionskonfliktschutz",
|
||||||
|
"Strukturiertes Prüfpostfach für KI-Planvorschläge",
|
||||||
|
"Import, Export und PDF-Erzeugung",
|
||||||
|
"Rezeptsammlungen bleiben unterstützt"
|
||||||
|
],
|
||||||
|
"docs": "TOOL-README.md"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
|||||||
|
{
|
||||||
|
"name": "Granola (Vorrat)",
|
||||||
|
"config": {
|
||||||
|
"type": "recipe",
|
||||||
|
"meta": {
|
||||||
|
"title": "Die Granola-Bibel",
|
||||||
|
"header_left": "GRANOLA · Vorrats-Edition · Begleitend zum 8-Wochen-Plan",
|
||||||
|
"footer_left": "Topping statt Schüssel · Portion abwiegen · Zartbitter statt Zucker",
|
||||||
|
"output_name": "Granola_Vorrat.pdf",
|
||||||
|
"doc_title": "Die Granola-Bibel",
|
||||||
|
"brand_color": "#6D28D9",
|
||||||
|
"steps_label": "SO GEHT'S"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"headline": "Die Granola-Bibel",
|
||||||
|
"subtitle": "28 Rezepte mit exakten Arbeitsschritten – deutlich weniger Zucker als jedes Fertig-Granola, Nährwerte",
|
||||||
|
"paragraphs": [
|
||||||
|
"pro Portion als Richtwert.",
|
||||||
|
"Gekauftes Granola ist meist eine als Müsli getarnte Süßigkeit – 20–30 % Zucker sind normal. Diese Rezepte",
|
||||||
|
"drehen das um: Hafer, Nüsse, Kerne und Buchweizen tragen, gesüßt wird sparsam und bewusst. Das ist genau",
|
||||||
|
"dein Substitutions-Hebel: Granola ist hier ein Topping für Skyr oder Quark, kein Schüssel-Füller. Eine",
|
||||||
|
"Eiweiß-Basis plus Crunch schlägt abends auf der Couch die Schoko-Chips-Cola-Kombi – nicht durch Verzicht,",
|
||||||
|
"sondern durch einen besseren Reiz. Ein Wort zur Ehrlichkeit: Granola bleibt kaloriendicht. Der Hebel ist die",
|
||||||
|
"Portion, nicht die Zutat. Wieg dir 40–50 g ab und stell das Glas weg – aus dem Glas gelöffelt wird jedes Granola",
|
||||||
|
"zur Falle. Die Nährwerte unter NÄHRWERTE ca. sind Richtwerte pro Portion (≈50 g), keine Zielvorgabe.",
|
||||||
|
"Die Grund-Technik – gilt für fast jedes Rezept",
|
||||||
|
"1 Trocken mischen: Hafer, grob gehackte Nüsse, Kerne, Gewürze, ggf. Kakao/Proteinpulver in einer großen",
|
||||||
|
"Schüssel verrühren.",
|
||||||
|
"2 Bindung erwärmen: Öl bzw. Nussmus mit Süße und Aroma kurz schmelzen, bis es dünnflüssig ist – so",
|
||||||
|
"verteilt es sich gleichmäßig.",
|
||||||
|
"3 Benetzen: über das Trockene gießen und gründlich mischen, bis alles glänzt. Trockene Nester = weiche",
|
||||||
|
"Stellen später.",
|
||||||
|
"4 Cluster-Trick: für feste Brocken 1 Eiweiß schaumig schlagen und unterheben, Masse fest aufs Blech",
|
||||||
|
"drücken – und beim Backen nicht rühren.",
|
||||||
|
"5 Backen: meist 150–160 °C Umluft. Niedrig und länger röstet gleichmäßig; ohne Cluster einmal wenden, mit",
|
||||||
|
"Cluster in Ruhe lassen.",
|
||||||
|
"6 Auskühlen = knusprig: Granola ist frisch aus dem Ofen noch weich. Erst vollständig auf dem Blech",
|
||||||
|
"auskühlen lassen, dann brechen.",
|
||||||
|
"7 Empfindliches später: Schokolade, Trockenfrüchte und Kokoschips erst nach dem Abkühlen zugeben, sonst",
|
||||||
|
"verbrennen sie.",
|
||||||
|
"8 Lagern: luftdicht, kühl und dunkel 4–6 Wochen. Nicht warm einfüllen, sonst schwitzt es und wird zäh."
|
||||||
|
],
|
||||||
|
"rule_head": "",
|
||||||
|
"rule_text": "",
|
||||||
|
"categories_head": "7 Kategorien"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Schoko & Kakao",
|
||||||
|
"color": "#7C3AED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nuss, Kern & Klassik",
|
||||||
|
"color": "#0EA5E9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frucht & Beere",
|
||||||
|
"color": "#EA580C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gewürz & Saison",
|
||||||
|
"color": "#16A34A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Protein-Boost & Post-Workout",
|
||||||
|
"color": "#DC2626"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grain-free & Low-Carb",
|
||||||
|
"color": "#DB2777"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ohne Ofen & Overnight",
|
||||||
|
"color": "#B45309"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recipes": [
|
||||||
|
{
|
||||||
|
"num": "01",
|
||||||
|
"category": "Schoko & Kakao",
|
||||||
|
"title": "Malz-Schoko-Buchweizen-Granola",
|
||||||
|
"tagline": "Die Referenz nach Anna Jones – zuckerreduziert adaptiert.",
|
||||||
|
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 24 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken (kernig) · 100 g roher Buchweizen · 80 g Haselnüsse grob · 40 g Kakaonibs · 30 g Backkakao · 1 EL Gerstenmalz-Extrakt · 2 EL Ahornsirup · 3 EL Kokosöl · 1 Prise Meersalz · 60 g Zartbitter 85 % gehackt (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft vorheizen, Blech mit Backpapier.\n2 Hafer, Buchweizen, Haselnüsse, Kakaonibs, Kakao und Salz in einer großen Schüssel gut vermischen.\n3 Kokosöl, Malz-Extrakt und Ahornsirup in einem Töpfchen bei kleiner Hitze schmelzen und glatt rühren.\n4 Flüssige Mischung über das Trockene gießen und gründlich wenden, bis alles dunkel glänzt.\n5 Gleichmäßig aufs Blech verteilen und leicht andrücken.\n6 25–30 Min backen, nach der Hälfte einmal wenden – Kakao verbrennt schnell, lieber kürzer.\n7 Vollständig auf dem Blech auskühlen lassen, dann die gehackte Zartbitter untermischen.",
|
||||||
|
"variations": [
|
||||||
|
"Nuss-Tausch Mandeln oder Pekannüsse statt Haselnüssen",
|
||||||
|
"Ohne Malz Malz weglassen, dafür 1 TL Instant-Kaffee für Tiefe"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kakaonibs bringen Schoko-Bitterkeit fast ohne Zucker – der Trick, um Vollmilch-Schoko-Gelüste auf der Couch abzufangen.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 800 g · ~16 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "02",
|
||||||
|
"category": "Schoko & Kakao",
|
||||||
|
"title": "Tiramisu-Granola",
|
||||||
|
"tagline": "Nach The Doctor's Kitchen – Kaffee, Kakao, feste Cluster.",
|
||||||
|
"nutrition": "ca. ≈ 245 kcal · Eiweiß 8 g · Fett 15 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 20 g Backkakao · 1 TL Zimt · 2 EL Mandelmus · 2 EL Honig · 3 EL Kokosöl · 60 ml starker Espresso (abgekühlt) · 1 TL Vanille · 1 Eiweiß · Kakao zum Bestäuben",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbis- und Sonnenblumenkerne, Leinsamen, Kakao und Zimt vermischen.\n3 Mandelmus, Honig, Kokosöl und Espresso leicht erwärmen und glatt rühren, Vanille einrühren.\n4 Über das Trockene gießen und gründlich mischen.\n5 Eiweiß schaumig schlagen und unterheben – das gibt die Tiramisu-typischen Cluster.\n6 Masse fest aufs Blech drücken (ca. 1,5 cm dick), nicht verteilen.\n7 30–35 Min backen, dabei NICHT rühren, damit die Brocken halten.\n8 Auskühlen lassen, in Brocken brechen und mit etwas Kakao bestäuben.",
|
||||||
|
"variations": [
|
||||||
|
"Entkoffeiniert Espresso durch Lupinen- oder Malzkaffee ersetzen",
|
||||||
|
"Nüsse pur Kakao weglassen für reine Cappuccino-Note"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kaffee + Kakao schmecken „Dessert“ ohne Süße-Bombe. Über Skyr wird das dein Abend-Dessert-Ersatz.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "03",
|
||||||
|
"category": "Schoko & Kakao",
|
||||||
|
"title": "Doppel-Kakao-Haselnuss",
|
||||||
|
"tagline": "Die „Nutella“-Idee als knuspriges Topping.",
|
||||||
|
"nutrition": "ca. ≈ 252 kcal · Eiweiß 8 g · Fett 16 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 120 g Haselnüsse grob · 30 g Backkakao · 2 EL Haselnussmus · 2 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz · 40 g Kakaonibs (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Kakao und Salz mischen.\n3 Haselnussmus, Kokosöl und Ahornsirup schmelzen und glatt rühren.\n4 Über das Trockene gießen und mischen, bis alles dunkel und gleichmäßig ist.\n5 Aufs Blech, leicht andrücken.\n6 25 Min backen, nach der Hälfte wenden – wegen Kakao auf die Farbe achten.\n7 Auskühlen lassen, Kakaonibs untermischen.",
|
||||||
|
"variations": [
|
||||||
|
"Extra Crunch 1 EL Buchweizen mitrösten",
|
||||||
|
"Milder Kakao halbieren, 1 TL Vanille zugeben"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Haselnuss + Kakao trifft exakt den Nutella-Reiz – aber mit Ballaststoffen statt Palmöl und Zucker.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "04",
|
||||||
|
"category": "Schoko & Kakao",
|
||||||
|
"title": "Schoko-Meersalz-Cluster",
|
||||||
|
"tagline": "Feste Brocken, salzig-bitter, sehr gering gesüßt.",
|
||||||
|
"nutrition": "ca. ≈ 248 kcal · Eiweiß 8 g · Fett 15 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 25 g Backkakao · 2 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · grobes Meersalz · 60 g Zartbitter 85 % (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, mit unterheben – gibt die Cluster-Bindung.\n5 Fest aufs Blech drücken, mit etwas grobem Meersalz bestreuen.\n6 30 Min backen, NICHT rühren.\n7 Vollständig auskühlen lassen, in Brocken brechen, gehackte Zartbitter untermischen.",
|
||||||
|
"variations": [
|
||||||
|
"Orange Abrieb 1/2 Bio-Orange in die Bindung",
|
||||||
|
"Chili 1 Prise Chili für Schoko-Chili-Kick"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Salz + 85 %-Schoko: der intensivste Schoko-Hit pro Gramm. Kleine Menge sättigt den Gusto komplett.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "05",
|
||||||
|
"category": "Schoko & Kakao",
|
||||||
|
"title": "Mokka-Walnuss",
|
||||||
|
"tagline": "Espresso trifft Röstaroma, kaum Süße.",
|
||||||
|
"nutrition": "ca. ≈ 250 kcal · Eiweiß 8 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 100 g Walnüsse grob · 40 g Leinsamen · 15 g Backkakao · 1 TL Instant-Espresso · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Leinsamen, Kakao, Espressopulver und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, leicht andrücken.\n6 25–30 Min backen, einmal wenden.\n7 Auskühlen lassen – wird erst kalt knusprig.",
|
||||||
|
"variations": [
|
||||||
|
"Pekan Pekannüsse statt Walnüssen",
|
||||||
|
"Stärker 2 TL Espressopulver für echten Mokka-Punch"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Walnüsse liefern Omega-3 – passt gut in die Regenerationswochen mit viel Volumen.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "06",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Klassik pur · Nuss-Kern-Granola",
|
||||||
|
"tagline": "Die Basis. Minimaler Zucker, maximal variierbar.",
|
||||||
|
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 22 g · Zucker 4 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "300 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Alle trockenen Zutaten mit Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren, bis alles glänzt.\n4 Aufs Blech, gleichmäßig dünn verteilen.\n5 25–30 Min backen, nach der Hälfte wenden, bis goldbraun.\n6 Auf dem Blech auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Frucht nach dem Backen 40 g Trockenfrüchte untermischen",
|
||||||
|
"Nüsse pur Kerne durch mehr Nüsse ersetzen für weniger KH",
|
||||||
|
"Vanille 1 TL Vanille in die Bindung"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Lern dieses Rezept auswendig – alle anderen sind nur Varianten davon. Sparsame Süße ist Absicht.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "07",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Erdnuss-Protein-Cluster",
|
||||||
|
"tagline": "Post-Workout-tauglich, feste Brocken, extra Eiweiß.",
|
||||||
|
"nutrition": "ca. ≈ 258 kcal · Eiweiß 12 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 60 g Erdnüsse grob · 40 g Sonnenblumenkerne · 30 g Whey oder Erbsenprotein (neutral/vanille) · 3 EL Erdnussmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Erdnüsse, Kerne und Proteinpulver mischen (Pulver gut unterheben, keine Klumpen).\n3 Erdnussmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen und unterheben.\n5 Fest aufs Blech drücken.\n6 28–32 Min bei niedriger Hitze backen, NICHT rühren – Protein bräunt schneller, Farbe im Auge behalten.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko 20 g Kakao ins Trockene",
|
||||||
|
"Casein-Abend Casein statt Whey für langsame Abend-Variante"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Proteinpulver bräunt früher – lieber 150 °C und Sichtkontrolle. Über Skyr kommst du locker auf 25–30 g Eiweiß.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "08",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Mandel-Vanille",
|
||||||
|
"tagline": "Schlicht, hell, alltagstauglich.",
|
||||||
|
"nutrition": "ca. ≈ 240 kcal · Eiweiß 7 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "300 g Haferflocken · 120 g Mandeln grob · 40 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 2 TL Vanille · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Leinsamen und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, Vanille einrühren, über das Trockene gießen.\n4 Gut mischen, aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Mandel-Zimt 1 TL Zimt zugeben",
|
||||||
|
"Kirsch nach dem Backen getrocknete Sauerkirschen unter)"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Die neutralste Basis – perfekt, wenn der Rest der Schüssel (Beeren, Quark) den Geschmack trägt.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "09",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Kokos-Cashew",
|
||||||
|
"tagline": "Cremig-tropisch, leicht süß von allein.",
|
||||||
|
"nutrition": "ca. ≈ 255 kcal · Eiweiß 7 g · Fett 16 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "260 g Haferflocken · 100 g Cashews grob · 50 g Kokoschips · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne und Salz mischen – Kokoschips noch NICHT zugeben.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 20 Min backen, dann wenden und die Kokoschips untermischen.\n6 Weitere 5–8 Min backen, bis die Kokoschips goldgelb sind – sie verbrennen schnell.\n7 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko-Kokos 40 g Zartbitter nach dem Backen",
|
||||||
|
"Limette Abrieb 1 Limette in die Bindung"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kokoschips zum Schluss zugeben ist Pflicht – sonst sind sie schwarz, bevor der Rest fertig ist.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "10",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Tahin-Sesam-Halva",
|
||||||
|
"tagline": "Nährig, orientalisch, unerwartet gut.",
|
||||||
|
"nutrition": "ca. ≈ 252 kcal · Eiweiß 9 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sesam · 40 g Sonnenblumenkerne · 3 EL Tahin · 2 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Sesam, Kerne und Salz mischen.\n3 Tahin, Kokosöl und Honig leicht erwärmen und glatt rühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, andrücken.\n6 22–26 Min backen, einmal wenden – Sesam bräunt schnell.\n7 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko 30 g Kakaonibs nach dem Backen",
|
||||||
|
"Kardamom 1/2 TL gemahlener Kardamom in die Bindung"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Tahin bringt Kalzium und einen leicht bitteren Halva-Ton – sättigt richtig, kleine Portion reicht.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "11",
|
||||||
|
"category": "Nuss, Kern & Klassik",
|
||||||
|
"title": "Ahorn-Pekan",
|
||||||
|
"tagline": "Der Klassiker – hier bewusst sparsam gesüßt.",
|
||||||
|
"nutrition": "ca. ≈ 246 kcal · Eiweiß 6 g · Fett 15 g · KH 22 g · Zucker 7 g · Ballaststoffe 4 g",
|
||||||
|
"ingredients": "290 g Haferflocken · 110 g Pekannüsse grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 3 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Auskühlen lassen – die Karamell-Note kommt erst kalt.",
|
||||||
|
"variations": [
|
||||||
|
"Ahorn-Apfel nach dem Backen getrocknete Apfelstücke unter",
|
||||||
|
"Weniger süß nur 2 EL Ahornsirup"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Das klassische Frühstücks-Granola – aber mit 7 g Zucker statt der üblichen 15–20 g im Handel.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "12",
|
||||||
|
"category": "Frucht & Beere",
|
||||||
|
"title": "Beeren-Chia",
|
||||||
|
"tagline": "Säuerlich-fruchtig, Beeren als frisches Topping.",
|
||||||
|
"nutrition": "ca. ≈ 232 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 7 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "280 g Haferflocken · 80 g Mandeln grob · 30 g Chiasamen · 30 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 40 g gefriergetrocknete Beeren (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Chia, Kerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann die gefriergetrockneten Beeren untermischen.\n7 Frische oder TK-Beeren erst in der Schüssel dazugeben, nicht ins Vorratsglas.",
|
||||||
|
"variations": [
|
||||||
|
"Himbeer-Kakao 20 g Kakao + gefriergetrocknete Himbeeren",
|
||||||
|
"Frisch in der Schüssel mit TK-Heidelbeeren toppen"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Gefrorene Beeren statt Gummibärchen – hier direkt eingebaut. Säure bremst die Süß-Gier.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "13",
|
||||||
|
"category": "Frucht & Beere",
|
||||||
|
"title": "Apfel-Zimt-Bratapfel",
|
||||||
|
"tagline": "Apfelkuchen-Gefühl ohne Teig.",
|
||||||
|
"nutrition": "ca. ≈ 234 kcal · Eiweiß 6 g · Fett 12 g · KH 25 g · Zucker 8 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "290 g Haferflocken · 90 g Walnüsse grob · 40 g Sonnenblumenkerne · 2 TL Zimt · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 60 g getrocknete Apfelstücke (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Auskühlen lassen, dann getrocknete Apfelstücke untermischen (sonst werden sie zäh/hart).",
|
||||||
|
"variations": [
|
||||||
|
"Rosine zusätzlich eine kleine Handvoll Rosinen",
|
||||||
|
"Warm in der Schüssel mit warmem, gewürfeltem Apfel toppen"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Der Kuchen-Ersatz. Über Skyr mit warmem Apfel schmeckt es wie Apfelstrudel – ohne Zuckerschock.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 760 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "14",
|
||||||
|
"category": "Frucht & Beere",
|
||||||
|
"title": "Bananenbrot-Granola",
|
||||||
|
"tagline": "Reife Banane bringt Süße fast ohne Sirup.",
|
||||||
|
"nutrition": "ca. ≈ 228 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 8 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "270 g Haferflocken · 90 g Walnüsse grob · 40 g Leinsamen · 1 sehr reife Banane · 2 EL Kokosöl · 1 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Banane mit einer Gabel fein zerdrücken.\n3 Hafer, Walnüsse, Leinsamen, Zimt und Salz mischen.\n4 Bananenpüree, geschmolzenes Kokosöl und Ahornsirup verrühren, über das Trockene geben.\n5 Gut mischen, aufs Blech, andrücken.\n6 30–35 Min backen – Banane braucht länger zum Trocknen; wenden nach 20 Min.\n7 Vollständig auskühlen lassen (bleibt sonst weich).",
|
||||||
|
"variations": [
|
||||||
|
"Schoko-Chunk 40 g Zartbitter nach dem Backen",
|
||||||
|
"Erdnuss 2 EL Erdnussmus in die Bindung"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Braune Bananen sinnvoll verwerten statt wegwerfen – sie ersetzen einen Gutteil des Sirups.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "15",
|
||||||
|
"category": "Frucht & Beere",
|
||||||
|
"title": "Orange-Cranberry",
|
||||||
|
"tagline": "Weihnachtlich-fruchtig, säuerlicher Kontrast.",
|
||||||
|
"nutrition": "ca. ≈ 238 kcal · Eiweiß 6 g · Fett 12 g · KH 26 g · Zucker 9 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · Abrieb 1 Bio-Orange · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g getrocknete Cranberries (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne, Orangenabrieb und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann Cranberries untermischen.",
|
||||||
|
"variations": [
|
||||||
|
"Pistazie statt Mandeln für Farbe und Weihnachtslook",
|
||||||
|
"Zitrone Zitronenabrieb statt Orange"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Cranberries sind von Natur aus sauer und oft gezuckert – lieber sparsam, der Orangenabrieb trägt den Geschmack.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "16",
|
||||||
|
"category": "Frucht & Beere",
|
||||||
|
"title": "Trail-Mix-Granola",
|
||||||
|
"tagline": "Studentenfutter-Idee, ideal als Wander-/Büro-Snack.",
|
||||||
|
"nutrition": "ca. ≈ 256 kcal · Eiweiß 8 g · Fett 15 g · KH 23 g · Zucker 9 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "250 g Haferflocken · 120 g gemischte Nüsse grob · 40 g Kürbiskerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g Rosinen + 30 g Zartbitter-Tropfen (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbiskerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Vollständig auskühlen lassen, dann Rosinen und Zartbitter untermischen (Schoko schmilzt sonst).",
|
||||||
|
"variations": [
|
||||||
|
"Salzig 1 Prise mehr Salz + geröstete Erdnüsse",
|
||||||
|
"Ohne Schoko stattdessen mehr Nüsse"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Portionsbeutel abwiegen und in die Tasche – das ist dein Büro-Swap gegen Chips und Gummibärchen.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 780 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "17",
|
||||||
|
"category": "Gewürz & Saison",
|
||||||
|
"title": "Kürbisgewürz (Pumpkin Spice)",
|
||||||
|
"tagline": "Herbst im Glas, wärmende Gewürze.",
|
||||||
|
"nutrition": "ca. ≈ 240 kcal · Eiweiß 8 g · Fett 14 g · KH 21 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "270 g Haferflocken · 90 g Pekannüsse grob · 60 g Kürbiskerne · 2 TL Zimt · 1/2 TL Ingwer · 1/2 TL Muskat · 1 Prise Nelke · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kürbiskerne, alle Gewürze und Salz gründlich mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Mit Kürbis 2 EL Kürbispüree in die Bindung, 5 Min länger backen",
|
||||||
|
"Ahorn-Pekan-Plus zusätzlich Vanille"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Das Gewürz macht die Süße subjektiv größer – typischer Trick, um mit weniger Zucker auszukommen.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "18",
|
||||||
|
"category": "Gewürz & Saison",
|
||||||
|
"title": "Ingwer-Kurkuma „Golden“",
|
||||||
|
"tagline": "Wärmend, leicht scharf, entzündungsfreundlich.",
|
||||||
|
"nutrition": "ca. ≈ 242 kcal · Eiweiß 7 g · Fett 14 g · KH 22 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "270 g Haferflocken · 90 g Cashews grob · 40 g Sonnenblumenkerne · 1 TL Kurkuma · 1 TL Ingwer · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne, Kurkuma, Ingwer, Pfeffer und Salz mischen (Pfeffer macht Kurkuma verfügbar).\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Kokos 40 g Kokoschips in den letzten 6 Min",
|
||||||
|
"Schärfer frischen geriebenen Ingwer in die Bindung"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kurkuma färbt alles gelb (auch das Blech-Papier) – harmlos. Eine Prise Pfeffer gehört dazu.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "19",
|
||||||
|
"category": "Gewürz & Saison",
|
||||||
|
"title": "Spekulatius-Lebkuchen",
|
||||||
|
"tagline": "Winter-Edition, ohne die übliche Zuckerlast.",
|
||||||
|
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 7 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Haselnüsse grob · 2 TL Lebkuchengewürz · 1 TL Zimt · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Haselnüsse, Lebkuchengewürz, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25–30 Min backen, einmal wenden.\n6 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Orange Orangenabrieb dazu",
|
||||||
|
"Schoko 40 g Zartbitter nach dem Backen"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Weihnachtsgebäck-Ersatz für den Advent – stillt den Spekulatius-Reflex, ohne die Kekstüte.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "20",
|
||||||
|
"category": "Gewürz & Saison",
|
||||||
|
"title": "Chai-Gewürz",
|
||||||
|
"tagline": "Schwarzer Tee trifft wärmende Gewürze.",
|
||||||
|
"nutrition": "ca. ≈ 239 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 6 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · 1 TL Zimt · 1/2 TL Kardamom · 1/2 TL Ingwer · 1 Prise Nelke · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und alle Gewürze mischen.\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
|
||||||
|
"variations": [
|
||||||
|
"Vanille-Chai 1 TL Vanille zugeben",
|
||||||
|
"Milchkaffee über warme Milch als Chai-Porridge"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kardamom und Nelke sind intensiv – sparsam dosieren, sonst überdeckt es alles.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "21",
|
||||||
|
"category": "Gewürz & Saison",
|
||||||
|
"title": "Karottenkuchen-Granola",
|
||||||
|
"tagline": "Carrot Cake als Frühstücks-Topping.",
|
||||||
|
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 8 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "260 g Haferflocken · 80 g Walnüsse grob · 40 g Sonnenblumenkerne · 80 g fein geraspelte Möhre · 2 TL Zimt · 1/2 TL Muskat · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 30 g Rosinen (nach dem Backen)",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Möhre fein raspeln und mit Küchenpapier gut ausdrücken – sonst wird das Granola nicht knusprig.\n3 Hafer, Walnüsse, Kerne, Gewürze, Salz und die geraspelte Möhre mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Aufs Blech, dünn verteilen, andrücken.\n6 30–35 Min backen – Möhre bringt Feuchtigkeit, länger und niedriger trocknen; nach 20 Min wenden.\n7 Vollständig auskühlen lassen, dann Rosinen untermischen.",
|
||||||
|
"variations": [
|
||||||
|
"Frischkäse-Note über Skyr = Carrot-Cake-Frosting-Effekt",
|
||||||
|
"Kokos 2 EL Kokosraspel dazu"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Möhre gut ausdrücken ist der ganze Trick – sonst bleibt es zäh statt knusprig.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "22",
|
||||||
|
"category": "Protein-Boost & Post-Workout",
|
||||||
|
"title": "Vanille-Whey-Cluster",
|
||||||
|
"tagline": "Post-Workout: viel Eiweiß, feste Brocken.",
|
||||||
|
"nutrition": "ca. ≈ 260 kcal · Eiweiß 13 g · Fett 13 g · KH 22 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "240 g Haferflocken · 70 g Mandeln grob · 40 g Sonnenblumenkerne · 40 g Whey Vanille · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Whey mischen – Pulver klumpenfrei unterheben.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 25–30 Min bei 150 °C backen, NICHT rühren – Whey bräunt früh, Farbe kontrollieren.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko-Whey Schoko-Whey + 15 g Kakao",
|
||||||
|
"Erdnuss Erdnussmus statt Mandelmus"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Über 150 g Skyr sind das ~30 g Eiweiß – idealer Post-Session-Snack rund ums Training.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "23",
|
||||||
|
"category": "Protein-Boost & Post-Workout",
|
||||||
|
"title": "Schoko-Casein-Abend",
|
||||||
|
"tagline": "Langsames Eiweiß für den Couch-Abend.",
|
||||||
|
"nutrition": "ca. ≈ 256 kcal · Eiweiß 13 g · Fett 13 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "240 g Haferflocken · 70 g Haselnüsse grob · 40 g Leinsamen · 40 g Casein Schoko · 20 g Kakao · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Leinsamen, Casein und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 25–28 Min backen, NICHT rühren – Kakao + Casein bräunen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
|
||||||
|
"variations": [
|
||||||
|
"Meersalz grobes Salz vor dem Backen aufstreuen",
|
||||||
|
"Nibs 30 g Kakaonibs nach dem Backen"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Casein macht langsam satt – als Abend-Dessert über Magerquark dein bester Schoko-Couch-Ersatz.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "24",
|
||||||
|
"category": "Protein-Boost & Post-Workout",
|
||||||
|
"title": "Skyr-Topper · grob & pur",
|
||||||
|
"tagline": "Extra grobe Brocken, kaum Süße – nur zum Toppen.",
|
||||||
|
"nutrition": "ca. ≈ 244 kcal · Eiweiß 9 g · Fett 15 g · KH 19 g · Zucker 3 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "240 g Haferflocken kernig · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 30 g Leinsamen · 3 EL Mandelmus · 2 EL Kokosöl · 1 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kerne und Leinsamen mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben – für maximal grobe Cluster sehr fest andrücken.\n5 28–32 Min backen, NICHT rühren.\n6 Vollständig auskühlen lassen, in große Brocken brechen.",
|
||||||
|
"variations": [
|
||||||
|
"Herzhaft Salz erhöhen, Süße weglassen",
|
||||||
|
"Zimt 1 TL Zimt für milde Süße ohne Zucke"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Bewusst wenig Zucker (3 g) – die Süße kommt vom Obst in der Schüssel. Das gröbste Rezept, perfekt als Crunch auf Quark.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "25",
|
||||||
|
"category": "Grain-free & Low-Carb",
|
||||||
|
"title": "Grain-free Nuss-Granola",
|
||||||
|
"tagline": "Ohne Getreide, sehr wenig KH, viel Crunch.",
|
||||||
|
"nutrition": "ca. ≈ 272 kcal · Eiweiß 9 g · Fett 23 g · KH 8 g · Zucker 4 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "120 g Mandeln · 120 g Pekannüsse · 80 g Kürbiskerne · 60 g Sonnenblumenkerne · 80 g Kokoschips · 30 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
|
||||||
|
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Nüsse grob hacken; mit Kernen und Leinsamen mischen (Kokoschips zurückhalten).\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben – bindet die Cluster ohne Hafer.\n5 Fest aufs Blech drücken.\n6 18–22 Min backen; in den letzten 6 Min Kokoschips untermischen – Nüsse und Kokos verbrennen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko 20 g Kakao ins Trockene",
|
||||||
|
"Zimt-Kokos 1 TL Zimt zugeben"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Für Tage mit wenig Bewegung: kaum KH, sättigt lange über Fett und Ballaststoffe. Portion trotzdem abwiegen – sehr kaloriendicht.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 650 g · ~13 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "26",
|
||||||
|
"category": "Grain-free & Low-Carb",
|
||||||
|
"title": "Buchweizen-Buckini-Crunch",
|
||||||
|
"tagline": "Aktivierter Buchweizen – extrem knusprig, glutenfrei.",
|
||||||
|
"nutrition": "ca. ≈ 234 kcal · Eiweiß 8 g · Fett 11 g · KH 27 g · Zucker 5 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "200 g roher Buchweizen · 100 g Haferflocken (oder mehr Buchweizen für glutenfrei) · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
|
||||||
|
"steps": "1 Buchweizen 4–6 h in Wasser einweichen, dann gründlich abspülen und gut abtropfen lassen (macht ihn\nbekömmlicher und knuspriger).\n2 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n3 Buchweizen, Hafer, Mandeln, Kerne, Zimt und Salz mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Dünn aufs Blech, andrücken.\n6 30–35 Min backen, alle 10 Min wenden – eingeweichter Buchweizen braucht länger zum Trocknen.\n7 Vollständig auskühlen lassen – wird dann glasig-knusprig.",
|
||||||
|
"variations": [
|
||||||
|
"Kakao 20 g Kakao für Schoko-Buckini",
|
||||||
|
"Schnell Buchweizen ohne Einweichen – etwas härter, geht abe"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Der knusprigste Crunch überhaupt. Wenn Hafer dir schwer im Magen liegt, ist das die leichtere Basis.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "27",
|
||||||
|
"category": "Ohne Ofen & Overnight",
|
||||||
|
"title": "No-Bake Erdnuss-Cluster",
|
||||||
|
"tagline": "Ohne Ofen, aus dem Kühlschrank fest.",
|
||||||
|
"nutrition": "ca. ≈ 248 kcal · Eiweiß 10 g · Fett 14 g · KH 22 g · Zucker 9 g · Ballaststoffe 5 g",
|
||||||
|
"ingredients": "200 g Haferflocken · 60 g Erdnüsse grob · 30 g Leinsamen · 6 entsteinte Datteln · 4 EL Erdnussmus · 2 EL Kokosöl · 1 Prise Salz · optional 20 g Zartbitter",
|
||||||
|
"steps": "1 Datteln 10 Min in warmem Wasser einweichen, abgießen und zu einer Paste zerdrücken.\n2 Erdnussmus und Kokosöl leicht erwärmen, mit der Dattelpaste glatt rühren.\n3 Hafer, Erdnüsse, Leinsamen und Salz untermischen, bis alles klebt.\n4 Masse fest in eine mit Papier ausgelegte Form drücken (ca. 1,5 cm).\n5 Mindestens 1 h kalt stellen, bis sie fest ist.\n6 In Riegel oder Brocken schneiden; optional mit geschmolzener Zartbitter bestreichen.\n7 Kühl gelagert 1 Woche haltbar (kein Backen = kürzer haltbar als gebäcken).",
|
||||||
|
"variations": [
|
||||||
|
"Schoko 20 g Kakao in die Paste",
|
||||||
|
"Riegel als Snack-Riegel schneiden für unterwegs"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Kein Ofen nötig – aber Datteln bringen Zucker (9 g). Portion klein halten, dann ein solider Riegel-Ersatz.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": "ergibt ca. 600 g · ~12 Portionen à 50 g"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"num": "28",
|
||||||
|
"category": "Ohne Ofen & Overnight",
|
||||||
|
"title": "Overnight-Bircher-Mix",
|
||||||
|
"tagline": "Trockenmischung zum Einweichen – quasi Granola rückwärts.",
|
||||||
|
"nutrition": "ca. ≈ 230 kcal · Eiweiß 8 g · Fett 10 g · KH 27 g · Zucker 6 g · Ballaststoffe 6 g",
|
||||||
|
"ingredients": "350 g Haferflocken · 60 g Mandeln grob · 40 g Leinsamen · 30 g Chiasamen · 1 TL Zimt · 40 g Rosinen – pro Portion: 150 g Skyr/Milch + geraspelter Apfel Trockenmix für ~14 Portionen à 50 g",
|
||||||
|
"steps": "1 Alle trockenen Zutaten mischen und in ein großes Vorratsglas füllen (kein Backen).\n2 Am Vorabend 50 g Mix mit 150 g Skyr oder Milch übergießen und verrühren.\n3 Optional geraspelten Apfel oder Beeren unterheben.\n4 Abgedeckt über Nacht in den Kühlschrank.\n5 Am Morgen kurz umrühren; bei Bedarf einen Schuss Milch für die Konsistenz.\n6 Nach Wunsch mit frischem Obst oder einem Teelöffel gebackenem Granola toppen.",
|
||||||
|
"variations": [
|
||||||
|
"Schoko 1 TL Kakao einrühren",
|
||||||
|
"Protein 1/2 Portion Whey unterrühren",
|
||||||
|
"Beere TK-Beeren mit einweichen"
|
||||||
|
],
|
||||||
|
"tip": "Coach-Tipp Das Löst die Ausrede „keine Zeit“ – abends 30 Sekunden Aufwand, morgens Eiweiß-Basis ohne Nachdenken.",
|
||||||
|
"badge": "",
|
||||||
|
"yield": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
recipe_builder.py
|
||||||
|
Rendert eine Rezeptsammlung als A4-PDF im Design der Original-Editionen:
|
||||||
|
farbiges Kategorie-Band, farbiges Rezept-Titelband (weiße Schrift), getönter
|
||||||
|
Untertitel, NÄHRWERTE-Strip, zweispaltig ZUTATEN | SO GEHT'S, getönte
|
||||||
|
VARIATIONEN- und Tipp-Box, dünn umrandete Karte.
|
||||||
|
|
||||||
|
Regeln (wie im Original-Generator):
|
||||||
|
* jedes Rezept ist KeepTogether -> wird nie durch einen Seitenumbruch geteilt
|
||||||
|
* vor jeder Kategorie ein PageBreak -> jede Kategorie beginnt auf neuer Seite
|
||||||
|
|
||||||
|
build_pdf(cfg) -> bytes ; default_config() -> dict
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import copy
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||||
|
KeepTogether, PageBreak)
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"type": "recipe",
|
||||||
|
"meta": {
|
||||||
|
"title": "Neue Rezeptsammlung",
|
||||||
|
"header_left": "REZEPTE · Edition",
|
||||||
|
"header_right": "Begleitend zum 8-Wochen-Plan",
|
||||||
|
"footer_left": "Teller = Portionsgrenze · Trend über die Woche zählt",
|
||||||
|
"output_name": "Rezepte.pdf",
|
||||||
|
"doc_title": "Rezeptsammlung",
|
||||||
|
"brand_color": "#6D28D9",
|
||||||
|
"tip_label": "Tipp",
|
||||||
|
"steps_label": "SO GEHT'S",
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"headline": "Neue Rezeptsammlung",
|
||||||
|
"subtitle": "Kurzbeschreibung der Edition.",
|
||||||
|
"paragraphs": ["Einleitender Absatz."],
|
||||||
|
"rule_head": "Die eine Grundregel",
|
||||||
|
"rule_text": "Teller fertig machen, Rest aus dem Sichtfeld.",
|
||||||
|
"categories_head": "1 Kategorie",
|
||||||
|
},
|
||||||
|
"categories": [{"name": "Cremig & süß", "color": "#7C3AED"}],
|
||||||
|
"recipes": [
|
||||||
|
{"num": "01", "category": "Cremig & süß", "title": "Beispiel-Rezept",
|
||||||
|
"tagline": "Kurzer Untertitel.", "nutrition": "", "badge": "", "yield": "",
|
||||||
|
"ingredients": "200 g Magerquark · etwas Süße",
|
||||||
|
"steps": "Alles verrühren, fertig.",
|
||||||
|
"variations": ["Schoko: + 1 TL Kakao"], "tip": "Tipp Eiweiß-reich."},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_config():
|
||||||
|
return copy.deepcopy(DEFAULTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
_ENT = re.compile(r"&(?!(?:amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);)")
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s):
|
||||||
|
if s is None:
|
||||||
|
return ""
|
||||||
|
s = str(s).replace("<", "<").replace(">", ">")
|
||||||
|
return _ENT.sub("&", s)
|
||||||
|
|
||||||
|
|
||||||
|
def _hx(c):
|
||||||
|
return "#" + c.hexval()[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def lighten(hexstr, w=0.92):
|
||||||
|
"""Mischt eine Farbe mit Weiß (w = Weiß-Anteil) -> heller Ton."""
|
||||||
|
try:
|
||||||
|
c = colors.HexColor(hexstr)
|
||||||
|
except Exception:
|
||||||
|
c = colors.HexColor("#334155")
|
||||||
|
r = c.red * 255 * (1 - w) + 255 * w
|
||||||
|
g = c.green * 255 * (1 - w) + 255 * w
|
||||||
|
b = c.blue * 255 * (1 - w) + 255 * w
|
||||||
|
return colors.Color(r / 255, g / 255, b / 255)
|
||||||
|
|
||||||
|
|
||||||
|
TIP_LABEL_RE = re.compile(r"^\s*([A-Za-zÄÖÜäöü]+-Tipp|Tipp)\b[:\s]*(.*)$", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def split_tip(text, default_label):
|
||||||
|
"""Zerlegt 'Morgen-Tipp Text' -> ('Morgen-Tipp', 'Text')."""
|
||||||
|
if not text:
|
||||||
|
return default_label, ""
|
||||||
|
m = TIP_LABEL_RE.match(text)
|
||||||
|
if m:
|
||||||
|
return m.group(1), m.group(2).strip()
|
||||||
|
return default_label, text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def bold_lead(v):
|
||||||
|
"""Erste Bezeichnung einer Variation fett (bis Doppelpunkt bzw. erstes Wort)."""
|
||||||
|
v = v.strip()
|
||||||
|
m = re.match(r"^([^:]{2,40}):\s*(.*)$", v, re.S)
|
||||||
|
if m:
|
||||||
|
return "<b>%s:</b> %s" % (esc(m.group(1)), esc(m.group(2)))
|
||||||
|
m = re.match(r"^(\S+)\s+(.*)$", v, re.S)
|
||||||
|
if m:
|
||||||
|
return "<b>%s</b> %s" % (esc(m.group(1)), esc(m.group(2)))
|
||||||
|
return esc(v)
|
||||||
|
|
||||||
|
|
||||||
|
NOSPLIT = {"bis", "damit", "sodass", "bevor", "nachdem", "während", "wenn", "falls", "sobald",
|
||||||
|
"weil", "sowie", "oder", "aber", "bei", "je", "ohne", "mit", "in", "im", "am", "zum",
|
||||||
|
"zur", "auf", "für", "über", "unter", "dabei", "dazu", "nach", "vor", "als"}
|
||||||
|
|
||||||
|
|
||||||
|
def split_steps(text):
|
||||||
|
t = (text or "").strip().replace("z. B.", "z.B.")
|
||||||
|
raw = re.split(r"(?<=[.!?])\s+|,\s+", t)
|
||||||
|
steps = []
|
||||||
|
for frag in raw:
|
||||||
|
frag = frag.strip()
|
||||||
|
if not frag:
|
||||||
|
continue
|
||||||
|
first = frag.split()[0].lower().strip("(„\"'.:;»«")
|
||||||
|
if steps and first in NOSPLIT:
|
||||||
|
steps[-1] = steps[-1].rstrip(".") + ", " + frag
|
||||||
|
else:
|
||||||
|
steps.append(frag)
|
||||||
|
steps = [s if s.endswith((".", "!", "?")) else s + "." for s in steps]
|
||||||
|
return [s[:1].upper() + s[1:] for s in steps]
|
||||||
|
|
||||||
|
|
||||||
|
def numbered_steps(steps_txt):
|
||||||
|
"""Bereits nummerierte (mehrzeilige) Schritte -> Zeilen; sonst Prosa splitten."""
|
||||||
|
if steps_txt and "\n" in steps_txt:
|
||||||
|
out = []
|
||||||
|
for ln in steps_txt.split("\n"):
|
||||||
|
ln = re.sub(r"^\s*\d+[\.\)]?\s+", "", ln.strip())
|
||||||
|
if ln:
|
||||||
|
out.append(ln)
|
||||||
|
return out
|
||||||
|
return split_steps(steps_txt)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- renderer
|
||||||
|
def build_pdf(cfg):
|
||||||
|
_D = DEFAULTS
|
||||||
|
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
|
||||||
|
intro = dict(_D["intro"]); intro.update(cfg.get("intro") or {})
|
||||||
|
cats = cfg.get("categories") or _D["categories"]
|
||||||
|
recipes = cfg.get("recipes") or []
|
||||||
|
|
||||||
|
INK = colors.HexColor("#23272F")
|
||||||
|
SUB = colors.HexColor("#5B6270")
|
||||||
|
HAIR = colors.HexColor("#E3E1EA")
|
||||||
|
NUTBG = colors.HexColor("#F7F7F9")
|
||||||
|
BRAND = colors.HexColor(meta.get("brand_color", "#6D28D9"))
|
||||||
|
TIPBG = lighten(_hx(BRAND), 0.93)
|
||||||
|
|
||||||
|
def catcolor(name):
|
||||||
|
for c in cats:
|
||||||
|
if c.get("name") == name:
|
||||||
|
try:
|
||||||
|
return colors.HexColor(c.get("color", "#334155"))
|
||||||
|
except Exception:
|
||||||
|
return colors.HexColor("#334155")
|
||||||
|
return colors.HexColor("#334155")
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
def S(name, **kw):
|
||||||
|
base = kw.pop("parent", styles["Normal"])
|
||||||
|
return ParagraphStyle(name, parent=base, **kw)
|
||||||
|
|
||||||
|
st_title = S("t", fontName="Helvetica-Bold", fontSize=22, leading=25, textColor=INK)
|
||||||
|
st_sub = S("s", fontName="Helvetica", fontSize=10.5, leading=14, textColor=SUB)
|
||||||
|
st_intro = S("i", fontName="Helvetica", fontSize=10, leading=15, textColor=INK)
|
||||||
|
st_callh = S("ch", fontName="Helvetica-Bold", fontSize=10.5, leading=13, textColor=INK)
|
||||||
|
st_legend= S("lg", fontName="Helvetica", fontSize=8.6, leading=12, textColor=INK)
|
||||||
|
st_cat = S("c", fontName="Helvetica-Bold", fontSize=12.5, leading=15, textColor=colors.white)
|
||||||
|
st_rname = S("rn", fontName="Helvetica-Bold", fontSize=11.5, leading=14, textColor=colors.white)
|
||||||
|
st_badge = S("bd", fontName="Helvetica-Bold", fontSize=7.5, leading=9)
|
||||||
|
st_tag = S("tg", fontName="Helvetica-Oblique", fontSize=9, leading=12, textColor=SUB)
|
||||||
|
st_lbl = S("lb", fontName="Helvetica-Bold", fontSize=7.5, leading=10, spaceAfter=1)
|
||||||
|
st_body = S("bd2", fontName="Helvetica", fontSize=8.8, leading=12, textColor=INK)
|
||||||
|
st_var = S("vr", fontName="Helvetica", fontSize=8.4, leading=11.6, textColor=INK)
|
||||||
|
st_tip = S("tp", fontName="Helvetica", fontSize=8.5, leading=12, textColor=INK)
|
||||||
|
st_yield = S("yl", fontName="Helvetica-Oblique", fontSize=8, leading=11, textColor=SUB)
|
||||||
|
|
||||||
|
PAGE_W, PAGE_H = A4
|
||||||
|
LM = RM = 1.5 * cm
|
||||||
|
CW = PAGE_W - LM - RM
|
||||||
|
|
||||||
|
def lbl(text, acc):
|
||||||
|
return Paragraph(text, S("lx", parent=st_lbl, textColor=acc))
|
||||||
|
|
||||||
|
# ---- category band ----
|
||||||
|
def category_band(name):
|
||||||
|
acc = catcolor(name)
|
||||||
|
t = Table([[Paragraph(esc(name), st_cat)]], colWidths=[CW])
|
||||||
|
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6)]))
|
||||||
|
return t
|
||||||
|
|
||||||
|
# ---- one recipe card ----
|
||||||
|
def recipe_card(r):
|
||||||
|
acc = catcolor(r.get("category", ""))
|
||||||
|
accx = _hx(acc)
|
||||||
|
tint = lighten(accx, 0.92)
|
||||||
|
|
||||||
|
# Titelband (+ optional AIRFRYER-Badge)
|
||||||
|
name_p = Paragraph("%s %s" % (esc(r.get("num", "")), esc(r.get("title", ""))), st_rname)
|
||||||
|
if r.get("badge"):
|
||||||
|
badge = Table([[Paragraph(esc(r["badge"]), S("bx", parent=st_badge, textColor=acc))]])
|
||||||
|
badge.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.white),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
|
||||||
|
badge.hAlign = "RIGHT"
|
||||||
|
head = Table([[name_p, badge]], colWidths=[CW - 82, 82])
|
||||||
|
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("LEFTPADDING", (0, 0), (0, 0), 9), ("RIGHTPADDING", (0, 0), (0, 0), 4),
|
||||||
|
("LEFTPADDING", (1, 0), (1, 0), 0), ("RIGHTPADDING", (1, 0), (1, 0), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
|
||||||
|
else:
|
||||||
|
head = Table([[name_p]], colWidths=[CW])
|
||||||
|
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
|
||||||
|
|
||||||
|
rows = [[head]]
|
||||||
|
|
||||||
|
# Untertitel (getönt)
|
||||||
|
if r.get("tagline"):
|
||||||
|
tg = Table([[Paragraph(esc(r["tagline"]), st_tag)]], colWidths=[CW])
|
||||||
|
tg.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
|
||||||
|
rows.append([tg])
|
||||||
|
|
||||||
|
# NÄHRWERTE-Strip
|
||||||
|
if r.get("nutrition"):
|
||||||
|
ntext = '<b><font color="%s">NÄHRWERTE</font></b> %s' % (accx, esc(r["nutrition"]))
|
||||||
|
n = Table([[Paragraph(ntext, st_var)]], colWidths=[CW])
|
||||||
|
n.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), NUTBG),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("LINEBELOW", (0, 0), (-1, 0), 0.6, HAIR)]))
|
||||||
|
rows.append([n])
|
||||||
|
|
||||||
|
# ZUTATEN – volle Breite
|
||||||
|
zut_cell = [lbl("ZUTATEN", acc), Paragraph(esc(r.get("ingredients", "")), st_body)]
|
||||||
|
if r.get("yield"):
|
||||||
|
zut_cell.append(Spacer(1, 2))
|
||||||
|
zut_cell.append(Paragraph("<i>%s</i>" % esc(r["yield"]), st_yield))
|
||||||
|
zut = Table([[zut_cell]], colWidths=[CW])
|
||||||
|
zut.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
|
||||||
|
rows.append([zut])
|
||||||
|
|
||||||
|
# Schritte – nummeriert, volle Breite
|
||||||
|
steps = numbered_steps(r.get("steps", ""))
|
||||||
|
srows = [[Paragraph('<b><font color="%s">%d</font></b>' % (accx, i), st_body),
|
||||||
|
Paragraph(esc(s), st_body)] for i, s in enumerate(steps, 1)]
|
||||||
|
if srows:
|
||||||
|
steps_inner = Table(srows, colWidths=[15, CW - 18 - 15])
|
||||||
|
steps_inner.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 4), ("RIGHTPADDING", (1, 0), (1, 0), 0),
|
||||||
|
("TOPPADDING", (0, 0), (-1, 0), 0), ("TOPPADDING", (0, 1), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
|
||||||
|
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc), Spacer(1, 2), steps_inner]
|
||||||
|
else:
|
||||||
|
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc)]
|
||||||
|
arb = Table([[arb_cell]], colWidths=[CW])
|
||||||
|
arb.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
|
||||||
|
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR), ("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
|
||||||
|
rows.append([arb])
|
||||||
|
|
||||||
|
# ZUBEREITUNG (Airfryer/Ofen), optional
|
||||||
|
if r.get("methods"):
|
||||||
|
m_cell = [lbl("ZUBEREITUNG · AIRFRYER / OFEN", acc), Paragraph(esc(r["methods"]), st_var)]
|
||||||
|
mt = Table([[m_cell]], colWidths=[CW])
|
||||||
|
mt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F3F4F6")),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
|
||||||
|
rows.append([mt])
|
||||||
|
|
||||||
|
# VARIATIONEN (getönt)
|
||||||
|
if r.get("variations"):
|
||||||
|
vhtml = "<br/>".join(bold_lead(v) for v in r["variations"] if v)
|
||||||
|
v = Table([[[lbl("VARIATIONEN", acc), Paragraph(vhtml, st_var)]]], colWidths=[CW])
|
||||||
|
v.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
|
||||||
|
rows.append([v])
|
||||||
|
|
||||||
|
# Tipp (Marken-Tint)
|
||||||
|
if r.get("tip"):
|
||||||
|
lab, txt = split_tip(r["tip"], meta.get("tip_label", "Tipp"))
|
||||||
|
tip_html = '<b><font color="%s">%s</font></b> %s' % (accx, esc(lab), esc(txt))
|
||||||
|
tp = Table([[Paragraph(tip_html, st_tip)]], colWidths=[CW])
|
||||||
|
tp.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
|
||||||
|
rows.append([tp])
|
||||||
|
|
||||||
|
card = Table(rows, colWidths=[CW])
|
||||||
|
card.setStyle(TableStyle([("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("BOX", (0, 0), (-1, -1), 0.8, HAIR)]))
|
||||||
|
return KeepTogether([card, Spacer(1, 7)])
|
||||||
|
|
||||||
|
# ---- story ----
|
||||||
|
story = []
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
story.append(Paragraph(esc(intro.get("headline", meta["title"])), st_title))
|
||||||
|
story.append(Spacer(1, 3))
|
||||||
|
if intro.get("subtitle"):
|
||||||
|
story.append(Paragraph(esc(intro["subtitle"]), st_sub))
|
||||||
|
story.append(Spacer(1, 10))
|
||||||
|
for p in intro.get("paragraphs", []):
|
||||||
|
story.append(Paragraph(esc(p), st_intro))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
if intro.get("rule_head") or intro.get("rule_text"):
|
||||||
|
gr = Table([[[Paragraph(esc(intro.get("rule_head", "")), st_callh), Spacer(1, 3),
|
||||||
|
Paragraph(esc(intro.get("rule_text", "")), st_intro)]]], colWidths=[CW])
|
||||||
|
gr.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 12),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
|
||||||
|
("LINEBEFORE", (0, 0), (0, 0), 3, BRAND)]))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(gr)
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
story.append(Paragraph(esc(intro.get("categories_head", "%d Kategorien" % len(cats))),
|
||||||
|
S("lh", parent=st_callh, fontSize=10)))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
leg_rows = []
|
||||||
|
for c in cats:
|
||||||
|
sw = Table([[""]], colWidths=[10], rowHeights=[10])
|
||||||
|
sw.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), catcolor(c["name"])),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
|
||||||
|
leg_rows.append([sw, Paragraph(esc(c["name"]), st_legend)])
|
||||||
|
if leg_rows:
|
||||||
|
leg = Table(leg_rows, colWidths=[16, CW - 16])
|
||||||
|
leg.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 6),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
|
||||||
|
story.append(leg)
|
||||||
|
|
||||||
|
# Rezepte, gruppiert je Kategorie -> neue Seite pro Kategorie, Rezept nie geteilt
|
||||||
|
cur = None
|
||||||
|
for r in recipes:
|
||||||
|
c = r.get("category", "")
|
||||||
|
if c != cur:
|
||||||
|
story.append(PageBreak())
|
||||||
|
story.append(category_band(c))
|
||||||
|
story.append(Spacer(1, 7))
|
||||||
|
cur = c
|
||||||
|
story.append(recipe_card(r))
|
||||||
|
|
||||||
|
# ---- Kopf-/Fußzeile ----
|
||||||
|
header_left = meta.get("header_left", "")
|
||||||
|
header_right = meta.get("header_right", "Begleitend zum 8-Wochen-Plan")
|
||||||
|
footer_left = meta.get("footer_left", "")
|
||||||
|
|
||||||
|
def deco(canvas, doc):
|
||||||
|
canvas.saveState()
|
||||||
|
canvas.setFillColor(BRAND); canvas.setFont("Helvetica-Bold", 8)
|
||||||
|
canvas.drawString(LM, PAGE_H - 1.05 * cm, header_left)
|
||||||
|
canvas.setFont("Helvetica", 8); canvas.setFillColor(SUB)
|
||||||
|
canvas.drawRightString(PAGE_W - RM, PAGE_H - 1.05 * cm, header_right)
|
||||||
|
canvas.setStrokeColor(HAIR); canvas.setLineWidth(0.7)
|
||||||
|
canvas.line(LM, PAGE_H - 1.2 * cm, PAGE_W - RM, PAGE_H - 1.2 * cm)
|
||||||
|
canvas.line(LM, 1.15 * cm, PAGE_W - RM, 1.15 * cm)
|
||||||
|
canvas.setFont("Helvetica", 7.5); canvas.setFillColor(SUB)
|
||||||
|
canvas.drawString(LM, 0.8 * cm, footer_left)
|
||||||
|
canvas.drawRightString(PAGE_W - RM, 0.8 * cm, "Seite %d" % doc.page)
|
||||||
|
canvas.restoreState()
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=LM, rightMargin=RM,
|
||||||
|
topMargin=1.6 * cm, bottomMargin=1.5 * cm,
|
||||||
|
title=meta.get("doc_title", meta.get("title", "Rezepte")))
|
||||||
|
doc.build(story, onFirstPage=deco, onLaterPages=deco)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
open("/tmp/recipe_test.pdf", "wb").write(build_pdf(default_config()))
|
||||||
|
print("OK")
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Versionierter Datenvertrag zwischen Trainingsplan-Editor und Session-Tracker."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PLAN_SCHEMA_VERSION = 3
|
||||||
|
CONTRACT_VERSION = 2
|
||||||
|
RESULT_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
VALID_RESULT_MODES = {"auto", "reps", "seconds", "minutes", "none"}
|
||||||
|
VALID_WEIGHT_MODES = {"none", "optional", "required"}
|
||||||
|
VALID_LATERALITY = {"bilateral", "unilateral"}
|
||||||
|
VALID_SIDES_MODES = {"same", "separate"}
|
||||||
|
VALID_TRAINING_MODES = {"auto", "sets_reps", "tabata", "fixed_interval"}
|
||||||
|
|
||||||
|
|
||||||
|
def slug(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", text).strip("-") or "item"
|
||||||
|
|
||||||
|
|
||||||
|
def stable_id(prefix: str, *parts: Any) -> str:
|
||||||
|
seed = "|".join(str(part or "") for part in parts)
|
||||||
|
digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:10]
|
||||||
|
return f"{prefix}-{slug(parts[-1] if parts else prefix)[:30]}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_result_schema(value: Any, partial: bool = False) -> dict[str, Any] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
value = {}
|
||||||
|
mode = str(value.get("mode") or ("auto" if not partial else ""))
|
||||||
|
weight_mode = str(value.get("weight_mode") or ("none" if not partial else ""))
|
||||||
|
laterality = str(value.get("laterality") or ("bilateral" if not partial else ""))
|
||||||
|
sides_mode = str(value.get("sides_mode") or ("same" if not partial else ""))
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
if mode in VALID_RESULT_MODES:
|
||||||
|
result["mode"] = mode
|
||||||
|
elif not partial:
|
||||||
|
result["mode"] = "auto"
|
||||||
|
if weight_mode in VALID_WEIGHT_MODES:
|
||||||
|
result["weight_mode"] = weight_mode
|
||||||
|
elif not partial:
|
||||||
|
result["weight_mode"] = "none"
|
||||||
|
if laterality in VALID_LATERALITY:
|
||||||
|
result["laterality"] = laterality
|
||||||
|
elif not partial:
|
||||||
|
result["laterality"] = "bilateral"
|
||||||
|
if sides_mode in VALID_SIDES_MODES:
|
||||||
|
result["sides_mode"] = sides_mode
|
||||||
|
elif not partial:
|
||||||
|
result["sides_mode"] = "same"
|
||||||
|
try:
|
||||||
|
sets = int(value.get("sets"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sets = 0
|
||||||
|
if 1 <= sets <= 40:
|
||||||
|
result["sets"] = sets
|
||||||
|
if "locked_sets" in value:
|
||||||
|
result["locked_sets"] = bool(value.get("locked_sets"))
|
||||||
|
if result.get("laterality") == "bilateral":
|
||||||
|
result["sides_mode"] = "same"
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def infer_result_schema(name: Any, cue: Any = "", *, stage: bool = False) -> dict[str, Any]:
|
||||||
|
"""Leitet für Legacy- und neue Standardpläne ein explizites Basisschema ab.
|
||||||
|
|
||||||
|
Die Ableitung ist nur eine Migrationshilfe. Nach dem Speichern liegt das
|
||||||
|
Ergebnis fest im Plan und kann im Editor jederzeit korrigiert werden.
|
||||||
|
"""
|
||||||
|
text = f"{name or ''} {cue or ''}".casefold()
|
||||||
|
no_measure = re.search(
|
||||||
|
r"(?:regeneration|schlaf|schmerzcheck|pausieren|protokollieren|prüfen|check|"
|
||||||
|
r"mobility|mobilität|dehnen|stretch|cool.?down|warm.?up|spaziergang locker)$",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if no_measure:
|
||||||
|
mode = "none"
|
||||||
|
elif re.search(r"(?:spaziergang|gehen|walk).*(?:min|minute)|(?:min|minute).*(?:spaziergang|gehen|walk)", text):
|
||||||
|
mode = "minutes"
|
||||||
|
elif re.search(r"(?:hold|plank|hang|wall\s*sit|wandsitz|isometr|halte|stützposition|deep\s+squat)", text):
|
||||||
|
mode = "seconds"
|
||||||
|
elif re.search(r"(?:sek(?:unde)?|\d+\s*s)", text) and not re.search(r"(?:reps?|wiederhol|negativ|push|pull|squat|row|rudern|dip)", text):
|
||||||
|
mode = "seconds"
|
||||||
|
else:
|
||||||
|
mode = "reps"
|
||||||
|
|
||||||
|
has_load = bool(re.search(r"(?:kb|kettlebell|kurzhantel|dumbbell|rucksack|gewicht|last)", text))
|
||||||
|
bodyweight_mix = bool(re.search(r"(?:optional|wahlweise|ggf\.?|bodyweight|bw|ungewichtet|körpergewicht)", text))
|
||||||
|
weight_mode = "optional" if has_load and bodyweight_mix else ("required" if has_load else "none")
|
||||||
|
# Bei generischen Stufen mit „gewichtet“ bleibt Gewicht optional, wenn eine
|
||||||
|
# ungewichtete Variante im selben Text genannt wird.
|
||||||
|
if "gewichtet" in text and ("ungewichtet" in text or "bw" in text):
|
||||||
|
weight_mode = "optional"
|
||||||
|
|
||||||
|
unilateral = bool(re.search(r"(?:einarm|einbein|je\s+seite|pro\s+seite|links.?/?rechts|seitenwechsel|side\s+plank|wechsel)", text))
|
||||||
|
schema = {
|
||||||
|
"mode": mode,
|
||||||
|
"weight_mode": weight_mode,
|
||||||
|
"laterality": "unilateral" if unilateral else "bilateral",
|
||||||
|
"sides_mode": "separate" if unilateral else "same",
|
||||||
|
}
|
||||||
|
# Nicht messbare Checklistenpunkte benötigen nie Gewicht oder Seitenfelder.
|
||||||
|
if mode == "none":
|
||||||
|
schema.update({"weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"})
|
||||||
|
return normalize_result_schema(schema, partial=False) or {
|
||||||
|
"mode": "auto", "weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"
|
||||||
|
}
|
||||||
|
|
||||||
|
def infer_cluster(name: str, key: str = "") -> tuple[str, str]:
|
||||||
|
text = f"{key} {name}".lower()
|
||||||
|
rules = [
|
||||||
|
(r"liegest|push.?up|\bls\b", "horizontal_push", "Push-up"),
|
||||||
|
(r"dip", "vertical_push", "Dip"),
|
||||||
|
(r"pike|overhead|floor press", "vertical_push", "Schulterdrücken"),
|
||||||
|
(r"pull.?up|klimm|hang", "vertical_pull", "Pull-up / Hang"),
|
||||||
|
(r"rudern|row", "horizontal_pull", "Rudern"),
|
||||||
|
(r"squat|kniebeuge|goblet|lunge|ausfallschritt|wall sit", "knee_dominant", "Kniebeuge / Ausfallschritt"),
|
||||||
|
(r"deadlift|rdl|hinge|swing|glute bridge", "hip_hinge", "Hip Hinge / Hüftstreckung"),
|
||||||
|
(r"plank|hollow|dead bug|side plank|core", "core", "Core"),
|
||||||
|
(r"curl", "elbow_flexion", "Bizeps"),
|
||||||
|
(r"waden|calf", "calf", "Waden"),
|
||||||
|
(r"halo|around|carry|bottoms", "loaded_mobility", "Kettlebell-Kontrolle"),
|
||||||
|
]
|
||||||
|
for pattern, cid, label in rules:
|
||||||
|
if re.search(pattern, text):
|
||||||
|
return cid, label
|
||||||
|
return "general", "Allgemein"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_factor(name: str, cluster_id: str) -> float:
|
||||||
|
text = str(name or "").lower()
|
||||||
|
if cluster_id == "horizontal_push":
|
||||||
|
if "wand" in text: return 0.05
|
||||||
|
if "hoch" in text and ("incline" in text or "inkline" in text): return 0.10
|
||||||
|
if "mittel" in text and ("incline" in text or "inkline" in text): return 0.20
|
||||||
|
if "tief" in text and ("incline" in text or "inkline" in text): return 0.35
|
||||||
|
if "negativ" in text and ("voll" in text or "erste" in text): return 0.85
|
||||||
|
if "negativ" in text: return 0.70
|
||||||
|
if "assist" in text: return 0.55
|
||||||
|
if "knie" in text: return 0.45
|
||||||
|
if "teil" in text or "partial" in text: return 0.35
|
||||||
|
if "voll" in text or "standard" in text or "normal" in text: return 1.00
|
||||||
|
if cluster_id == "vertical_pull":
|
||||||
|
if "active hang" in text or "hang" in text: return 0.20
|
||||||
|
if "scapular" in text: return 0.25
|
||||||
|
if "negativ" in text: return 0.70
|
||||||
|
if "leicht assist" in text: return 0.80
|
||||||
|
if "assist" in text: return 0.55
|
||||||
|
if "voll" in text or "klimm" in text or "pull-up" in text: return 1.00
|
||||||
|
if cluster_id == "horizontal_pull":
|
||||||
|
if "aufrecht" in text or "stark assist" in text: return 0.25
|
||||||
|
if "bent" in text or "gebeugt" in text: return 0.50
|
||||||
|
if "straight" in text or "gestreckt" in text: return 1.00
|
||||||
|
if "füße erhöht" in text: return 1.15
|
||||||
|
if cluster_id == "knee_dominant":
|
||||||
|
if "hold" in text: return 1.00
|
||||||
|
if "chair" in text or "box" in text or "stuhl" in text: return 0.35
|
||||||
|
if "negativ" in text: return 0.75
|
||||||
|
if "assist" in text or "festhalten" in text: return 0.55
|
||||||
|
if "voll" in text or "goblet" in text or "körpergewicht" in text: return 1.00
|
||||||
|
if cluster_id == "vertical_push":
|
||||||
|
if "hold" in text: return 0.30
|
||||||
|
if "assist" in text: return 0.60
|
||||||
|
if "negativ" in text: return 0.80
|
||||||
|
if "voll" in text: return 1.00
|
||||||
|
if cluster_id == "core":
|
||||||
|
if "knie" in text or "tuck" in text: return 0.65
|
||||||
|
if "halb" in text: return 0.82
|
||||||
|
if "voll" in text: return 1.00
|
||||||
|
return 1.00
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
|
||||||
|
cfg = copy.deepcopy(config or {})
|
||||||
|
if cfg.get("type") == "recipe":
|
||||||
|
return cfg
|
||||||
|
cfg["schema_version"] = PLAN_SCHEMA_VERSION
|
||||||
|
cfg["contract_version"] = CONTRACT_VERSION
|
||||||
|
meta = cfg.setdefault("meta", {})
|
||||||
|
resolved_plan_id = str(cfg.get("plan_id") or plan_id or stable_id("plan", meta.get("title") or "training"))
|
||||||
|
cfg["plan_id"] = resolved_plan_id
|
||||||
|
|
||||||
|
phases = cfg.setdefault("phases", {}).setdefault("items", [])
|
||||||
|
for index, phase in enumerate(phases):
|
||||||
|
if not isinstance(phase, dict):
|
||||||
|
phases[index] = phase = {"name": str(phase or "")}
|
||||||
|
phase.setdefault("id", stable_id("phase", resolved_plan_id, index, phase.get("name")))
|
||||||
|
phase_ids = [phase.get("id") for phase in phases]
|
||||||
|
|
||||||
|
fmt = cfg.get("training_format") if isinstance(cfg.get("training_format"), dict) else {}
|
||||||
|
mode = str(fmt.get("mode") or "auto")
|
||||||
|
if mode not in VALID_TRAINING_MODES:
|
||||||
|
mode = "auto"
|
||||||
|
fmt["mode"] = mode
|
||||||
|
fmt["fixed_interval"] = bool(fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"})
|
||||||
|
fmt["rounds_scope"] = str(fmt.get("rounds_scope") or "block")
|
||||||
|
fmt["schema_version"] = 1
|
||||||
|
cfg["training_format"] = fmt
|
||||||
|
|
||||||
|
days = cfg.get("days") if isinstance(cfg.get("days"), list) else []
|
||||||
|
for di, day in enumerate(days):
|
||||||
|
if not isinstance(day, dict):
|
||||||
|
continue
|
||||||
|
day.setdefault("id", stable_id("day", resolved_plan_id, day.get("num", di + 1), day.get("focus")))
|
||||||
|
rotations = day.get("rotations") if isinstance(day.get("rotations"), list) else []
|
||||||
|
for ri, rotation in enumerate(rotations):
|
||||||
|
if not isinstance(rotation, dict):
|
||||||
|
continue
|
||||||
|
rotation.setdefault("id", stable_id("rotation", day["id"], ri, rotation.get("label")))
|
||||||
|
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
|
||||||
|
day_num = day.get("num", di + 1)
|
||||||
|
for ei, exercise in enumerate(exercises):
|
||||||
|
if not isinstance(exercise, dict):
|
||||||
|
continue
|
||||||
|
# Persist the original positional key once so legacy session data can
|
||||||
|
# still be migrated safely even after the published plan is reordered.
|
||||||
|
exercise.setdefault("legacy_id", f"d{day_num}-r{ri}-e{ei}")
|
||||||
|
explicit_progression = str(exercise.get("progression_id") or exercise.get("key") or "").strip()
|
||||||
|
progression_id = explicit_progression or f"name:{slug(exercise.get('name'))}"
|
||||||
|
exercise["progression_id"] = progression_id
|
||||||
|
exercise["key"] = str(exercise.get("key") or progression_id)
|
||||||
|
exercise.setdefault("id", stable_id("exercise", rotation["id"], progression_id, exercise.get("name"), ei))
|
||||||
|
exercise.setdefault("exercise_id", stable_id("movement", progression_id, exercise.get("name")))
|
||||||
|
cid, label = infer_cluster(str(exercise.get("name") or ""), progression_id)
|
||||||
|
exercise.setdefault("movement_cluster", cid)
|
||||||
|
exercise.setdefault("movement_label", label)
|
||||||
|
if exercise.get("result_schema") is not None:
|
||||||
|
exercise["result_schema"] = normalize_result_schema(exercise.get("result_schema"), partial=False)
|
||||||
|
else:
|
||||||
|
exercise["result_schema"] = infer_result_schema(exercise.get("name"), exercise.get("cue"))
|
||||||
|
cfg["days"] = days
|
||||||
|
|
||||||
|
old_stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
|
||||||
|
new_stages: dict[str, Any] = {}
|
||||||
|
for key, raw_stage in old_stages.items():
|
||||||
|
stage = copy.deepcopy(raw_stage) if isinstance(raw_stage, dict) else {"steps": raw_stage if isinstance(raw_stage, list) else []}
|
||||||
|
stage_id = str(stage.get("id") or stable_id("progression", resolved_plan_id, key, stage.get("name")))
|
||||||
|
stage["id"] = stage_id
|
||||||
|
stage["key"] = str(stage.get("key") or key)
|
||||||
|
raw_steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
|
||||||
|
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
for index, raw_step in enumerate(raw_steps):
|
||||||
|
if isinstance(raw_step, dict):
|
||||||
|
step = copy.deepcopy(raw_step)
|
||||||
|
step_name = str(step.get("name") or step.get("label") or "")
|
||||||
|
else:
|
||||||
|
step_name = str(raw_step or "")
|
||||||
|
step = {"name": step_name}
|
||||||
|
step.setdefault("id", stable_id("step", stage_id, index, step_name))
|
||||||
|
step.setdefault("phase_id", phase_ids[index] if index < len(phase_ids) else "")
|
||||||
|
schema = step.get("result_schema")
|
||||||
|
if schema is None and index < len(schemas):
|
||||||
|
schema = schemas[index]
|
||||||
|
if schema is not None:
|
||||||
|
step["result_schema"] = normalize_result_schema(schema, partial=False)
|
||||||
|
else:
|
||||||
|
step["result_schema"] = infer_result_schema(step_name, stage.get("name"), stage=True)
|
||||||
|
cid, _ = infer_cluster(str(stage.get("name") or step_name), key)
|
||||||
|
step.setdefault("movement_cluster", cid)
|
||||||
|
step.setdefault("factor", infer_factor(step_name, cid))
|
||||||
|
steps.append(step)
|
||||||
|
stage["steps"] = steps
|
||||||
|
stage.pop("result_schemas", None)
|
||||||
|
new_stages[str(key)] = stage
|
||||||
|
cfg["stages"] = new_stages
|
||||||
|
|
||||||
|
# Zentrale, planbezogene Übungsbibliothek. Der Tracker nutzt diese vor Regex-Fallbacks.
|
||||||
|
catalog: dict[str, Any] = {}
|
||||||
|
for day in days:
|
||||||
|
for rotation in day.get("rotations", []):
|
||||||
|
for exercise in rotation.get("exercises", []):
|
||||||
|
eid = str(exercise.get("exercise_id"))
|
||||||
|
pid = str(exercise.get("progression_id") or "")
|
||||||
|
entry = catalog.setdefault(eid, {
|
||||||
|
"id": eid,
|
||||||
|
"name": str(exercise.get("name") or ""),
|
||||||
|
"progression_id": pid,
|
||||||
|
"movement_cluster": exercise.get("movement_cluster") or "general",
|
||||||
|
"movement_label": exercise.get("movement_label") or "Allgemein",
|
||||||
|
"result_schema": copy.deepcopy(exercise.get("result_schema")),
|
||||||
|
"variants": [],
|
||||||
|
})
|
||||||
|
stage = new_stages.get(pid)
|
||||||
|
if stage:
|
||||||
|
entry["variants"] = [
|
||||||
|
{
|
||||||
|
"id": step.get("id"), "name": step.get("name", ""),
|
||||||
|
"factor": step.get("factor", 1.0),
|
||||||
|
"movement_cluster": step.get("movement_cluster") or entry["movement_cluster"],
|
||||||
|
"result_schema": copy.deepcopy(step.get("result_schema")),
|
||||||
|
}
|
||||||
|
for step in stage.get("steps", [])
|
||||||
|
]
|
||||||
|
cfg["exercise_catalog"] = catalog
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def validate_training_config(config: Any) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
cfg = migrate_training_config(config)
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
warnings: list[dict[str, Any]] = []
|
||||||
|
ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
def register(value: Any, location: str) -> None:
|
||||||
|
sid = str(value or "")
|
||||||
|
if not sid:
|
||||||
|
errors.append({"code": "missing_id", "location": location, "message": "Stabile ID fehlt."})
|
||||||
|
elif sid in ids:
|
||||||
|
errors.append({"code": "duplicate_id", "location": location, "message": f"ID wird bereits bei {ids[sid]} verwendet."})
|
||||||
|
else:
|
||||||
|
ids[sid] = location
|
||||||
|
|
||||||
|
def validate_result_schema(schema: Any, location: str, *, required: bool) -> None:
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
target = errors if required else warnings
|
||||||
|
target.append({"code": "missing_result_schema", "location": location, "message": "Ergebniserfassung ist nicht eindeutig festgelegt."})
|
||||||
|
return
|
||||||
|
mode = str(schema.get("mode") or "auto")
|
||||||
|
weight = str(schema.get("weight_mode") or "none")
|
||||||
|
laterality = str(schema.get("laterality") or "bilateral")
|
||||||
|
sides = str(schema.get("sides_mode") or "same")
|
||||||
|
if mode == "none" and weight != "none":
|
||||||
|
errors.append({"code": "invalid_result_schema", "location": location, "message": "Ohne Messwert darf kein Gewicht verlangt werden."})
|
||||||
|
if laterality == "bilateral" and sides == "separate":
|
||||||
|
errors.append({"code": "invalid_sides", "location": location, "message": "Getrennte Seitenwerte sind nur bei einseitigen Übungen möglich."})
|
||||||
|
if laterality == "unilateral" and sides not in {"same", "separate"}:
|
||||||
|
errors.append({"code": "invalid_sides", "location": location, "message": "Für einseitige Übungen muss die Seitenlogik festgelegt sein."})
|
||||||
|
try:
|
||||||
|
sets = int(schema.get("sets") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sets = 0
|
||||||
|
if "sets" in schema and not 1 <= sets <= 40:
|
||||||
|
errors.append({"code": "invalid_sets", "location": location, "message": "Die feste Anzahl muss zwischen 1 und 40 liegen."})
|
||||||
|
|
||||||
|
for pi, phase in enumerate(cfg.get("phases", {}).get("items", [])):
|
||||||
|
register(phase.get("id"), f"Phase {pi + 1}")
|
||||||
|
if not str(phase.get("name") or "").strip():
|
||||||
|
errors.append({"code": "empty_phase", "location": f"Phase {pi + 1}", "message": "Phasenname fehlt."})
|
||||||
|
for di, day in enumerate(cfg.get("days", [])):
|
||||||
|
register(day.get("id"), f"Tag {di + 1}")
|
||||||
|
for ri, rotation in enumerate(day.get("rotations", [])):
|
||||||
|
register(rotation.get("id"), f"Tag {di + 1}, Rotation {ri + 1}")
|
||||||
|
exercises = rotation.get("exercises", [])
|
||||||
|
if not exercises:
|
||||||
|
warnings.append({"code": "empty_rotation", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": "Rotation enthält keine Übungen."})
|
||||||
|
for ei, exercise in enumerate(exercises):
|
||||||
|
location = f"Tag {di + 1}, Rotation {ri + 1}, Übung {ei + 1}"
|
||||||
|
register(exercise.get("id"), location)
|
||||||
|
if not str(exercise.get("name") or "").strip():
|
||||||
|
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
|
||||||
|
pid = str(exercise.get("progression_id") or "")
|
||||||
|
if pid and not pid.startswith("name:") and pid not in cfg.get("stages", {}):
|
||||||
|
warnings.append({"code": "unknown_progression", "location": location, "message": f"Progression „{pid}“ ist nicht definiert."})
|
||||||
|
validate_result_schema(exercise.get("result_schema"), f"{location}, Ergebniserfassung", required=True)
|
||||||
|
for key, stage in cfg.get("stages", {}).items():
|
||||||
|
register(stage.get("id"), f"Progression {key}")
|
||||||
|
for si, step in enumerate(stage.get("steps", [])):
|
||||||
|
register(step.get("id"), f"Progression {key}, Stufe {si + 1}")
|
||||||
|
step_location = f"Progression {key}, Stufe {si + 1}"
|
||||||
|
if not str(step.get("name") or "").strip():
|
||||||
|
warnings.append({"code": "empty_step", "location": step_location, "message": "Stufenname ist leer."})
|
||||||
|
validate_result_schema(step.get("result_schema"), f"{step_location}, Ergebniserfassung", required=False)
|
||||||
|
try:
|
||||||
|
factor = float(step.get("factor") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
factor = 0
|
||||||
|
if factor <= 0:
|
||||||
|
errors.append({"code": "invalid_factor", "location": step_location, "message": "Der Analysefaktor muss größer als 0 sein."})
|
||||||
|
fmt = cfg.get("training_format", {})
|
||||||
|
if fmt.get("mode") in {"tabata", "fixed_interval"}:
|
||||||
|
if not fmt.get("work_seconds"):
|
||||||
|
errors.append({"code": "missing_work_seconds", "location": "Trainingsformat", "message": "Arbeitszeit fehlt."})
|
||||||
|
if not fmt.get("rounds"):
|
||||||
|
errors.append({"code": "missing_rounds", "location": "Trainingsformat", "message": "Intervallzahl fehlt."})
|
||||||
|
rounds = int(fmt.get("rounds") or 0)
|
||||||
|
for di, day in enumerate(cfg.get("days", [])):
|
||||||
|
for ri, rotation in enumerate(day.get("rotations", [])):
|
||||||
|
count = len(rotation.get("exercises", []))
|
||||||
|
if rounds and count and rounds % count:
|
||||||
|
warnings.append({"code": "uneven_intervals", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": f"{rounds} Intervalle sind nicht durch {count} Übungen teilbar. Feste Satzanzahl im Ergebnisschema setzen."})
|
||||||
|
return {"errors": errors, "warnings": warnings, "config": cfg}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, os, shutil, tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json_write(path: str | Path, payload: Any, backup_dir: str | Path | None = None) -> None:
|
||||||
|
target = Path(path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if backup_dir and target.exists():
|
||||||
|
bdir = Path(backup_dir); bdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
||||||
|
shutil.copy2(target, bdir / f"{target.stem}_{stamp}{target.suffix}")
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||||
|
handle.flush(); os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp, target)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp): os.unlink(tmp)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||||
|
from schema_contract import migrate_training_config, validate_training_config
|
||||||
|
|
||||||
|
|
||||||
|
def sample_config():
|
||||||
|
return {
|
||||||
|
"meta": {"title": "Test", "weeks": 2},
|
||||||
|
"training_format": {"mode": "tabata", "fixed_interval": True, "work_seconds": 20, "rest_seconds": 10, "rounds": 8},
|
||||||
|
"phases": {"items": [{"name": "Start", "weeks": 1}, {"name": "Aufbau", "weeks": 1}]},
|
||||||
|
"days": [{"num": 1, "focus": "A", "rotations": [{"label": "Block", "exercises": [{
|
||||||
|
"name": "Squat-Progression", "key": "squat", "result_schema": {"mode": "reps", "weight_mode": "none", "laterality": "bilateral"}
|
||||||
|
}]}]}],
|
||||||
|
"stages": {"squat": {"name": "Squat-Progression", "steps": [
|
||||||
|
"Deep Squat Hold", "Volle Squats"
|
||||||
|
], "result_schemas": [
|
||||||
|
{"mode": "seconds", "weight_mode": "optional", "laterality": "bilateral"},
|
||||||
|
{"mode": "reps", "weight_mode": "optional", "laterality": "bilateral"},
|
||||||
|
]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_creates_stable_ids_object_steps_and_catalog():
|
||||||
|
first = migrate_training_config(sample_config(), plan_id="test")
|
||||||
|
second = migrate_training_config(first, plan_id="test")
|
||||||
|
assert first["schema_version"] == 3
|
||||||
|
assert first["contract_version"] == 2
|
||||||
|
assert first["days"][0]["id"] == second["days"][0]["id"]
|
||||||
|
exercise = first["days"][0]["rotations"][0]["exercises"][0]
|
||||||
|
assert exercise["id"] == second["days"][0]["rotations"][0]["exercises"][0]["id"]
|
||||||
|
steps = first["stages"]["squat"]["steps"]
|
||||||
|
assert all(isinstance(step, dict) and step.get("id") for step in steps)
|
||||||
|
assert steps[0]["result_schema"]["mode"] == "seconds"
|
||||||
|
assert steps[1]["result_schema"]["mode"] == "reps"
|
||||||
|
assert first["exercise_catalog"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_accepts_complete_plan():
|
||||||
|
result = validate_training_config(sample_config())
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_result_schemas_are_migrated_to_explicit_defaults():
|
||||||
|
cfg = sample_config()
|
||||||
|
exercise = cfg["days"][0]["rotations"][0]["exercises"][0]
|
||||||
|
exercise.pop("result_schema", None)
|
||||||
|
cfg["stages"]["squat"].pop("result_schemas", None)
|
||||||
|
migrated = migrate_training_config(cfg, plan_id="test")
|
||||||
|
ex_schema = migrated["days"][0]["rotations"][0]["exercises"][0]["result_schema"]
|
||||||
|
assert ex_schema["mode"] == "reps"
|
||||||
|
steps = migrated["stages"]["squat"]["steps"]
|
||||||
|
assert steps[0]["result_schema"]["mode"] == "seconds"
|
||||||
|
assert steps[1]["result_schema"]["mode"] == "reps"
|
||||||
|
assert validate_training_config(migrated)["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_position_id_is_persisted_across_reordering():
|
||||||
|
migrated = migrate_training_config(sample_config(), plan_id="test")
|
||||||
|
exercise = migrated["days"][0]["rotations"][0]["exercises"][0]
|
||||||
|
legacy_id = exercise["legacy_id"]
|
||||||
|
exercise_id = exercise["id"]
|
||||||
|
migrated["days"][0]["rotations"][0]["exercises"].insert(0, {
|
||||||
|
"name": "Neue Übung", "key": "new", "result_schema": {"mode": "reps"}
|
||||||
|
})
|
||||||
|
remigrated = migrate_training_config(migrated, plan_id="test")
|
||||||
|
original = next(item for item in remigrated["days"][0]["rotations"][0]["exercises"] if item["id"] == exercise_id)
|
||||||
|
assert original["legacy_id"] == legacy_id
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user