10 Commits
Author SHA1 Message Date
michael e97ffe1701 Update 2026-09-01 14:57:28 +02:00
michael 56b1f4263b UI 2026-07-27 12:25:15 +02:00
michael e905ec5bb8 UI 2026-07-27 12:16:02 +02:00
michael 59f97682c1 Fix settings version state and Compose imports 2026-07-27 12:07:13 +02:00
michael c83314a6d5 UI 2026-07-27 11:48:01 +02:00
michael 2acee580f7 UI 2026-07-27 11:35:11 +02:00
michael 4490dd3126 Android UI 2026-07-27 08:03:29 +02:00
Mboehmlaender 1744e444b7 Android patch 2026-07-25 21:13:37 +02:00
michael 6dd827b0b4 Hotfix 2026-07-25 21:10:53 +02:00
Mboehmlaender e5bbc25c89 first build 2026-07-25 20:51:55 +02:00
44 changed files with 2902 additions and 26349 deletions
View File
View File
+9 -10
View File
@@ -1,14 +1,13 @@
# boehmitools Training # RipMe
Native Android-App für die boehmitools-Plugins `trainingsplan` und `trainingstracker` 2.0. Native Android-App für den boehmitools-Trainingstracker 2.0.
- Application ID: `de.drazz.boehmitools.training`
- Minimum SDK: 26
- Kotlin, Jetpack Compose und Material 3 - Kotlin, Jetpack Compose und Material 3
- Application ID `de.drazz.boehmitools.training` - Session-Tracker, Fortschritt, Analyse, Plan-Leseansicht und FAQ
- Minimum SDK 26 - Room-Cache, Offline-Queue, DataStore und WorkManager
- konfigurierbarer Server, Standard `https://tools.d-razz.de/` - sichtbarer Revisionskonflikt statt stiller Überschreibung
- HTTP Basic Auth mit Android-Keystore-geschütztem Passwort - App-Version 2.1.0
- 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`. Der separate Planeditor ist nicht Bestandteil der Android-App. Pläne werden ausschließlich gelesen; Sessions werden über die vorhandenen HTTP-APIs bearbeitet.
View File
View File
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId = "de.drazz.boehmitools.training" applicationId = "de.drazz.boehmitools.training"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 2
versionName = "2.0.0" versionName = "2.1.0"
testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner" testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
@@ -0,0 +1,109 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "1f4ec396d94258c311db7b15c2879062",
"entities": [
{
"tableName": "cache_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "pending_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `planId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `expectedRevision` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `state` TEXT NOT NULL, `lastError` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "planId",
"columnName": "planId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "expectedRevision",
"columnName": "expectedRevision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "payloadJson",
"columnName": "payloadJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f4ec396d94258c311db7b15c2879062')"
]
}
}
@@ -1,44 +0,0 @@
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,50 @@
package de.drazz.boehmitools.training.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
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 CompactTrackerUiTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun fourStatusButtonsStayInside360Dp() {
compose.setContent {
MaterialTheme {
Row(
modifier = Modifier.width(360.dp).testTag("status-row"),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
listOf("Offen", "Erledigt", "Teilweise", "Übersprungen").forEachIndexed { index, label ->
FilterChip(
selected = index == 0,
onClick = {},
label = { Text(label, maxLines = 2) },
modifier = Modifier.weight(1f).testTag("status-$index"),
)
}
}
}
}
compose.onNodeWithTag("status-row").assertIsDisplayed().assertWidthIsAtMost(360.dp)
repeat(4) { index ->
compose.onNodeWithTag("status-$index").assertIsDisplayed().assertWidthIsAtMost(90.dp)
}
}
}
+7 -3
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application <application
android:networkSecurityConfig="@xml/network_security_config_debug" android:networkSecurityConfig="@xml/network_security_config_debug"
android:usesCleartextTraffic="true" /> android:usesCleartextTraffic="true"
</manifest> tools:replace="android:networkSecurityConfig,android:usesCleartextTraffic" />
</manifest>
+1 -1
View File
@@ -8,7 +8,7 @@
android:name=".TrainingApplication" android:name=".TrainingApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_launcher_foreground" android:icon="@drawable/ic_launcher_foreground"
android:label="boehmitools Training" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.BoehmitoolsTraining" android:theme="@style/Theme.BoehmitoolsTraining"
@@ -20,8 +20,15 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val vm: AppViewModel = hiltViewModel() val vm: AppViewModel = hiltViewModel()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val uiState by vm.uiState.collectAsStateWithLifecycle()
TrainingTheme(settings.theme) { TrainingTheme(settings.theme) {
AppRoot(settings = settings, onTheme = vm::setTheme) AppRoot(
settings = settings,
showWhatsNew = uiState.showWhatsNew,
onDismissWhatsNew = vm::dismissWhatsNew,
onShowWhatsNew = vm::showWhatsNew,
onTheme = vm::setTheme,
)
} }
} }
} }
@@ -511,3 +511,19 @@ fun resultDataWithSets(data: JsonObject, sets: Int): JsonObject {
} }
return result return result
} }
fun resultDataWithoutSet(data: JsonObject, index: Int): JsonObject {
val currentCount = data.int("sets", 1).coerceIn(1, 20)
if (currentCount <= 1 || index !in 0 until currentCount) return data
val values = data.toMutableMap()
val newCount = currentCount - 1
values["sets"] = JsonPrimitive(newCount)
for (field in listOf("values", "left_values", "right_values")) {
val list = data.array(field).toMutableList()
if (index in list.indices) list.removeAt(index)
while (list.size < newCount) list.add(JsonNull)
values[field] = JsonArray(list.take(newCount))
}
return JsonObject(values)
}
@@ -1,24 +1,41 @@
package de.drazz.boehmitools.training.ui package de.drazz.boehmitools.training.ui
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository 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.setup.SettingsScreen
import de.drazz.boehmitools.training.ui.tracker.TrackerScreen import de.drazz.boehmitools.training.ui.tracker.TrackerScreen
@@ -27,46 +44,128 @@ private data class AppDestination(val route: String, val label: String, val icon
@Composable @Composable
fun AppRoot( fun AppRoot(
settings: SettingsRepository.PublicSettings, settings: SettingsRepository.PublicSettings,
showWhatsNew: Boolean,
onDismissWhatsNew: () -> Unit,
onShowWhatsNew: () -> Unit,
onTheme: (String) -> Unit, onTheme: (String) -> Unit,
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val destinations = listOf( val destinations = listOf(
AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) }, AppDestination("plan", "Plan") { Icon(Icons.Default.Home, null) },
AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) }, AppDestination("tracking", "Tracking") { Icon(Icons.Default.FitnessCenter, null) },
AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) }, AppDestination("analysis", "Analyse") { Icon(Icons.Default.Analytics, null) },
AppDestination("more", "Mehr") { Icon(Icons.Default.Settings, null) },
) )
val backStack by navController.currentBackStackEntryAsState() val backStack by navController.currentBackStackEntryAsState()
Scaffold( val currentRoute = backStack?.destination?.route ?: "tracking"
bottomBar = {
NavigationBar { Box(
destinations.forEach { destination -> modifier = Modifier
val selected = backStack?.destination?.hierarchy?.any { it.route == destination.route } == true .fillMaxSize()
NavigationBarItem( .background(MaterialTheme.colorScheme.background),
selected = selected, ) {
onClick = { Scaffold(
navController.navigate(destination.route) { containerColor = Color.Transparent,
popUpTo("tracker") { saveState = true } bottomBar = {
launchSingleTop = true CompactBottomBar(destinations, currentRoute) { route ->
restoreState = true navController.navigate(route) {
} popUpTo(navController.graph.startDestinationId) { saveState = true }
}, launchSingleTop = true
icon = destination.icon, restoreState = true
label = { Text(destination.label) }, }
}
},
) { padding ->
NavHost(navController = navController, startDestination = "tracking") {
composable("plan") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "plan") }
composable("tracking") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "session") }
composable("analysis") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "progress") }
composable("more") {
SettingsScreen(
contentPadding = paddedContent(padding),
current = settings,
onTheme = onTheme,
onShowWhatsNew = onShowWhatsNew,
) )
} }
} }
}, }
) { padding ->
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) { if (showWhatsNew) {
composable("tracker") { TrackerScreen(contentPadding = padding) } WhatsNewDialog(onDismiss = onDismissWhatsNew)
composable("editor") { EditorScreen(contentPadding = padding) } }
composable("settings") { }
SettingsScreen( }
contentPadding = padding,
current = settings, private fun paddedContent(original: PaddingValues): PaddingValues = PaddingValues(
onTheme = onTheme, top = original.calculateTopPadding(),
) bottom = original.calculateBottomPadding(),
)
@Composable
private fun CompactBottomBar(
destinations: List<AppDestination>,
currentRoute: String,
onNavigate: (String) -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
shadowElevation = 6.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { destination ->
val selected = currentRoute == destination.route
Surface(
onClick = { onNavigate(destination.route) },
modifier = Modifier.weight(1f),
color = Color.Transparent,
) {
Column(
modifier = Modifier.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else Color.Transparent),
contentAlignment = Alignment.Center,
) {
destination.icon()
}
Text(
destination.label,
style = MaterialTheme.typography.labelMedium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
} }
} }
} }
} }
@Composable
private fun WhatsNewDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Neu in RipMe ${BuildConfig.VERSION_NAME}") },
text = {
Text(
"• RipMe konzentriert sich vollständig auf den Trainingstracker.\n" +
"• Analyseergebnisse werden jetzt vollständig in der App angezeigt.\n" +
"• Wochen und Trainingstage zeigen ihren aktuellen Abschlussstatus.\n" +
"• Offline erfasste Sessionänderungen werden weiterhin sicher synchronisiert.",
)
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Verstanden") } },
)
}
@@ -1,17 +1,28 @@
package de.drazz.boehmitools.training.ui package de.drazz.boehmitools.training.ui
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository import de.drazz.boehmitools.training.data.settings.SettingsRepository
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class AppUiState(
val showWhatsNew: Boolean = false,
)
@HiltViewModel @HiltViewModel
class AppViewModel @Inject constructor( class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val settings = settingsRepository.publicSettings.stateIn( val settings = settingsRepository.publicSettings.stateIn(
viewModelScope, viewModelScope,
@@ -24,7 +35,42 @@ class AppViewModel @Inject constructor(
), ),
) )
private val _uiState = MutableStateFlow(AppUiState())
val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val lastSeen = settingsRepository.lastSeenVersionCode()
val updatedExistingInstall = packageWasUpdated()
val shouldShow = lastSeen in 1 until BuildConfig.VERSION_CODE ||
(lastSeen == 0 && updatedExistingInstall)
if (shouldShow) {
_uiState.value = AppUiState(showWhatsNew = true)
} else {
settingsRepository.markVersionSeen(BuildConfig.VERSION_CODE)
}
}
}
fun setTheme(theme: String) { fun setTheme(theme: String) {
viewModelScope.launch { settingsRepository.setTheme(theme) } viewModelScope.launch { settingsRepository.setTheme(theme) }
} }
fun dismissWhatsNew() {
_uiState.value = AppUiState(showWhatsNew = false)
viewModelScope.launch { settingsRepository.markVersionSeen(BuildConfig.VERSION_CODE) }
}
fun showWhatsNew() {
_uiState.value = AppUiState(showWhatsNew = true)
}
@Suppress("DEPRECATION")
private fun packageWasUpdated(): Boolean {
return runCatching {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
info.lastUpdateTime > info.firstInstallTime + 1_000L
}.getOrDefault(false)
}
} }
@@ -1,530 +0,0 @@
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") } },
)
}
@@ -1,429 +0,0 @@
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)
}
}
@@ -2,6 +2,7 @@ package de.drazz.boehmitools.training.ui.setup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -9,25 +10,32 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository import de.drazz.boehmitools.training.data.settings.SettingsRepository
@Composable @Composable
@@ -35,6 +43,7 @@ fun SettingsScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
current: SettingsRepository.PublicSettings, current: SettingsRepository.PublicSettings,
onTheme: (String) -> Unit, onTheme: (String) -> Unit,
onShowWhatsNew: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -44,62 +53,60 @@ fun SettingsScreen(
.padding(contentPadding) .padding(contentPadding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
Text("Einstellungen", style = MaterialTheme.typography.headlineMedium) Text("Mehr", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Card(Modifier.fillMaxWidth()) { Text("Einstellungen & App", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge) SettingsCard(title = "Server & Anmeldung", subtitle = "Zugangsdaten werden lokal verschlüsselt gespeichert.") {
Text( OutlinedTextField(value = state.serverUrl, onValueChange = viewModel::updateServer, modifier = Modifier.fillMaxWidth(), label = { Text("Server-URL") }, singleLine = true)
"Basic-Auth-Zugangsdaten werden mit einem Android-Keystore-Schlüssel verschlüsselt. Release-Builds akzeptieren ausschließlich HTTPS.", OutlinedTextField(value = state.username, onValueChange = viewModel::updateUsername, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true)
style = MaterialTheme.typography.bodyMedium, OutlinedTextField(value = state.password, onValueChange = viewModel::updatePassword, modifier = Modifier.fillMaxWidth(), label = { Text("Passwort") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
) Button(onClick = viewModel::saveAndTest, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( if (state.loading) {
value = state.serverUrl, CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
onValueChange = viewModel::updateServer, } else {
modifier = Modifier.fillMaxWidth(), Icon(Icons.Default.CloudDone, null)
label = { Text("Server-URL") }, }
singleLine = true, Spacer(Modifier.size(8.dp))
) Text("Speichern & Verbindung testen")
OutlinedTextField( }
value = state.username, if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
onValueChange = viewModel::updateUsername, if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
modifier = Modifier.fillMaxWidth(), }
label = { Text("Benutzername") },
singleLine = true, SettingsCard(title = "Darstellung", subtitle = "Modern Card Style in Hell, Dunkel oder System.") {
) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField( listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
value = state.password, FilterChip(selected = current.theme == value, onClick = { onTheme(value) }, label = { Text(label) }, modifier = Modifier.weight(1f))
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()) { SettingsCard(title = "Über RipMe", subtitle = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("Native Android-App für deinen Trainingstracker im Modern-Card-Stil.")
Text("Darstellung", style = MaterialTheme.typography.titleLarge) OutlinedButton(onClick = onShowWhatsNew, modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.Info, null)
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) -> Spacer(Modifier.size(8.dp))
FilterChip( Text("Was ist neu?")
selected = current.theme == value,
onClick = { onTheme(value) },
label = { Text(label) },
)
}
}
} }
} }
} }
} }
@Composable
private fun SettingsCard(title: String, subtitle: String, content: @Composable ColumnScope.() -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
shadowElevation = 8.dp,
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), content = {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
content()
})
}
}
@@ -1,39 +1,66 @@
package de.drazz.boehmitools.training.ui.theme package de.drazz.boehmitools.training.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp
private val Light = lightColorScheme( private val Light = lightColorScheme(
primary = Color(0xFF566600), primary = Color(0xFF8E56FF),
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Color(0xFFD9F26A), primaryContainer = Color(0xFFEDE3FF),
onPrimaryContainer = Color(0xFF172000), onPrimaryContainer = Color(0xFF2B1459),
secondary = Color(0xFF53643C), secondary = Color(0xFF6A7086),
tertiary = Color(0xFF00696C), onSecondary = Color.White,
background = Color(0xFFF9FAF2), secondaryContainer = Color(0xFFF0F2F8),
surface = Color(0xFFF9FAF2), onSecondaryContainer = Color(0xFF1C2132),
surfaceVariant = Color(0xFFE5E8D8), tertiary = Color(0xFFAC8BFF),
background = Color(0xFFF5F6FB),
onBackground = Color(0xFF171923),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF171923),
surfaceVariant = Color(0xFFF1F2F8),
onSurfaceVariant = Color(0xFF666B7B),
outline = Color(0xFFDEE2EE),
outlineVariant = Color(0xFFE9ECF6),
error = Color(0xFFC54646),
onError = Color.White,
) )
private val Dark = darkColorScheme( private val Dark = darkColorScheme(
primary = Color(0xFFC0D852), primary = Color(0xFFA66BFF),
onPrimary = Color(0xFF2B3400), onPrimary = Color(0xFF1D0C45),
primaryContainer = Color(0xFF414D00), primaryContainer = Color(0xFF2A175B),
onPrimaryContainer = Color(0xFFD9F26A), onPrimaryContainer = Color(0xFFEFE7FF),
secondary = Color(0xFFBACCA2), secondary = Color(0xFFAAB1C8),
tertiary = Color(0xFF80D4D6), onSecondary = Color(0xFF1B2236),
background = Color(0xFF11140E), secondaryContainer = Color(0xFF1C2235),
surface = Color(0xFF11140E), onSecondaryContainer = Color(0xFFDEE3F7),
surfaceVariant = Color(0xFF41443A), tertiary = Color(0xFFCC9DFF),
background = Color(0xFF0B1020),
onBackground = Color(0xFFF3F5FB),
surface = Color(0xFF161C2D),
onSurface = Color(0xFFF3F5FB),
surfaceVariant = Color(0xFF21283C),
onSurfaceVariant = Color(0xFFB1B8CD),
outline = Color(0xFF363D55),
outlineVariant = Color(0xFF272E45),
error = Color(0xFFFF8A8A),
onError = Color(0xFF320F16),
)
private val AppShapes = Shapes(
extraSmall = RoundedCornerShape(12.dp),
small = RoundedCornerShape(18.dp),
medium = RoundedCornerShape(24.dp),
large = RoundedCornerShape(28.dp),
extraLarge = RoundedCornerShape(32.dp),
) )
@Composable @Composable
@@ -43,5 +70,10 @@ fun TrainingTheme(theme: String, content: @Composable () -> Unit) {
"light" -> false "light" -> false
else -> isSystemInDarkTheme() else -> isSystemInDarkTheme()
} }
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content) MaterialTheme(
colorScheme = if (dark) Dark else Light,
typography = Typography(),
shapes = AppShapes,
content = content,
)
} }
File diff suppressed because it is too large Load Diff
@@ -75,6 +75,7 @@ data class TrackerUiState(
val saveState: String = "", val saveState: String = "",
val conflict: RevisionConflictException? = null, val conflict: RevisionConflictException? = null,
val analysisMessage: String = "", val analysisMessage: String = "",
val analysisScope: String = "week",
) )
@HiltViewModel @HiltViewModel
@@ -159,6 +160,11 @@ class TrackerViewModel @Inject constructor(
persistUi() persistUi()
} }
fun setAnalysisScope(scope: String) {
if (scope !in setOf("week", "overall")) return
_state.value = _state.value.copy(analysisScope = scope)
}
fun setWeek(week: Int) { fun setWeek(week: Int) {
val plan = _state.value.plan ?: return val plan = _state.value.plan ?: return
_state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks)) _state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks))
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">boehmitools Training</string> <string name="app_name">RipMe</string>
</resources> </resources>
@@ -156,7 +156,14 @@ class FixtureContractTest {
put("laterality", "bilateral") put("laterality", "bilateral")
put("sides_mode", "same") put("sides_mode", "same")
put("sets", 3) put("sets", 3)
put("values", kotlinx.serialization.json.buildJsonArray { add(8); add(7); add(6) }) put(
"values",
kotlinx.serialization.json.buildJsonArray {
add(kotlinx.serialization.json.JsonPrimitive(8))
add(kotlinx.serialization.json.JsonPrimitive(7))
add(kotlinx.serialization.json.JsonPrimitive(6))
},
)
put("left_values", kotlinx.serialization.json.JsonArray(emptyList())) put("left_values", kotlinx.serialization.json.JsonArray(emptyList()))
put("right_values", kotlinx.serialization.json.JsonArray(emptyList())) put("right_values", kotlinx.serialization.json.JsonArray(emptyList()))
put("weight_kg", 8.0) put("weight_kg", 8.0)
@@ -0,0 +1,91 @@
package de.drazz.boehmitools.training.domain
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Test
class ResultDataEditingTest {
@Test
fun addingSetPreservesAllExistingValuesAndWeight() {
val original = buildJsonObject {
put("version", 2)
put("mode", "reps")
put("laterality", "bilateral")
put("sides_mode", "same")
put("sets", 3)
put("weight_kg", 80.0)
put("values", buildJsonArray {
add(8.0)
add(7.0)
add(6.0)
})
put("left_values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("right_values", buildJsonArray { repeat(3) { add(JsonNull) } })
}
val expanded = resultDataWithSets(original, 4)
assertEquals(4, expanded.int("sets"))
assertEquals(80.0, expanded["weight_kg"]!!.jsonPrimitive.doubleOrNull!!, 0.0)
assertEquals(listOf(8.0, 7.0, 6.0), expanded.array("values").take(3).map { it.jsonPrimitive.doubleOrNull })
assertEquals(JsonNull, expanded.array("values")[3])
}
@Test
fun removingMiddleSetKeepsRemainingValuesInOrder() {
val original = buildJsonObject {
put("version", 2)
put("mode", "reps")
put("laterality", "bilateral")
put("sides_mode", "same")
put("sets", 3)
put("weight_kg", JsonPrimitive(85.0))
put("values", buildJsonArray {
add(10.0)
add(8.0)
add(6.0)
})
put("left_values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("right_values", buildJsonArray { repeat(3) { add(JsonNull) } })
}
val reduced = resultDataWithoutSet(original, 1)
assertEquals(2, reduced.int("sets"))
assertEquals(listOf(10.0, 6.0), reduced.array("values").map { it.jsonPrimitive.doubleOrNull })
assertEquals(85.0, reduced["weight_kg"]!!.jsonPrimitive.doubleOrNull!!, 0.0)
}
@Test
fun removingUnilateralSetPreservesBothSides() {
val original = buildJsonObject {
put("version", 2)
put("mode", "seconds")
put("laterality", "unilateral")
put("sides_mode", "separate")
put("sets", 3)
put("weight_kg", JsonNull)
put("values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("left_values", buildJsonArray {
add(20.0)
add(18.0)
add(16.0)
})
put("right_values", buildJsonArray {
add(19.0)
add(17.0)
add(15.0)
})
}
val reduced = resultDataWithoutSet(original, 0)
assertEquals(listOf(18.0, 16.0), reduced.array("left_values").map { it.jsonPrimitive.doubleOrNull })
assertEquals(listOf(17.0, 15.0), reduced.array("right_values").map { it.jsonPrimitive.doubleOrNull })
}
}
+18 -21
View File
@@ -1,25 +1,22 @@
# Implementierungsplan # Implementierungsstand RipMe 2.1
## Meilenstein 0 ## Enthalten
Backendcode, Mounts, Authentifizierung, Datenmodelle, Migrationen, Beispielpläne, alte Sessions, Analysen, Vorschläge und Web-UIs analysieren. Vertrag und Konfliktmodell dokumentieren. - native Single-Activity-App mit Compose und Material 3
- Tracker-Navigation mit Session, Fortschritt, Plan-Leseansicht und FAQ
- Retrofit, OkHttp, Basic Auth und HTTPS-Sperre im Release
- Room-Cache und FIFO-Queue für Offline-Session-Patches
- WorkManager-Synchronisation mit `expected_revision`
- sichtbare Behandlung von HTTP 409
- strukturierte Ergebnisdaten und Legacy-Migration
- manuelle Wochen- und Gesamtanalysen mit 200/202 und Status-Polling
- vollständige Darstellung des Serverfelds `analysis_cache.*.result`
- WhatsNew-Anzeige nach App-Updates
- Versionsanzeige in den Einstellungen
## Meilenstein 1 ## Bewusst nicht enthalten
Single-Activity-App, Compose/Material 3, Navigation, Hilt, Retrofit/OkHttp, Kotlin Serialization, Room, DataStore, Android-Keystore-Verschlüsselung, WorkManager und Verbindungstest. - Planeditor und Trainingsplan-Schreibzugriffe
- direkte Dateisystemzugriffe
## Meilenstein 2 - direkte OpenAI-Verbindung
- Timer, RIR, RPE oder Technikrating
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.
+29 -27
View File
@@ -1,36 +1,38 @@
# Screen Map # Screen Map RipMe
## App-Shell ## App-Navigation
- Start/Verbindung: Server-URL, Benutzername, Passwort, Verbindungstest. - **Tracker**: gesamte Trainingsoberfläche.
- Hauptnavigation: Tracker, Planeditor, Einstellungen. - **Einstellungen**: Server, Anmeldung, Theme, Version und WhatsNew.
- Material-3-Theme mit Light/Dark/System.
## Tracker ## Tracker
- Planauswahl. Die obere Navigation ist fest und besteht aus vier gleich breiten Bereichen:
- 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 1. **Session**
- Planauswahl
- horizontal scrollbare Wochen- und Tagesauswahl
- Statusmarkierungen für laufende, gestoppte und abgeschlossene Sessions
- Start, Stop, Fortsetzen, Abschluss und Reset
- strukturierte Ergebnisse, Progressionsstufen und Notizen
- Offline-Queue und Konflikthinweise
2. **Fortschritt**
- lokale Sessionübersicht
- Wochen- oder Gesamtanalyse auswählen
- Woche öffnen oder abschließen
- Analyse manuell starten und Status pollen
- vollständige Analyseausgabe mit Kennzahlen, Übungsständen, Vorschlägen, Warnungen und Fazit
3. **Plan**
- ausschließlich lesende Übersicht des veröffentlichten Plans
4. **FAQ**
- Trainingsformat, Übungsvarianten und serverseitige Fallbackregeln
- Planliste, Auswahl, neuer Plan, Umbenennen, Löschen. Nur die Wochen- und Tagesleisten dürfen horizontal scrollen. Alle anderen Bedienelemente müssen innerhalb der Bildschirmbreite umbrechen oder die verfügbare Breite gleichmäßig aufteilen.
- 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 ## Einstellungen
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. - Server-URL und Basic-Auth-Zugangsdaten
- Verbindungstest
- System-, Hell- oder Dunkelmodus
- sichtbare Versionsnummer
- WhatsNew erneut öffnen
+10 -6
View File
@@ -24,17 +24,21 @@ require('compileSdk = 35' in app_gradle, "Compile SDK 35")
require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext") require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext")
require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext") require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext")
require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt") require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt")
require('<string name="app_name">RipMe</string>' in (ROOT / 'app/src/main/res/values/strings.xml').read_text(), "Sichtbarer App-Name RipMe")
require('Planeditor' not in source and 'EditorScreen' not in source, "Planeditor aus App entfernt")
require('WebView' not in source, "Keine WebView") require('WebView' not in source, "Keine WebView")
require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL") require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL")
require('sk-' not in source, "Kein erkennbarer OpenAI-Key") require('sk-' not in source, "Kein erkennbarer OpenAI-Key")
require('Credentials.basic' in api, "Basic Auth über OkHttp") require('Credentials.basic' in api, "Basic Auth über OkHttp")
for route in [ for route in ['tracker/session', '/analysis', '/analysis/status']:
'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}") require(route in api, f"API-Route vorhanden: {route}")
require('trainingsplan/api' not in api, "Keine Planeditor-API im Android-Client")
tracker_screen = (ROOT / 'app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt').read_text()
require('ScrollableTabRow' not in tracker_screen, "Hauptnavigation ist nicht horizontal scrollbar")
require(tracker_screen.count('horizontalScroll(') == 2, "Nur Wochen und Tage scrollen horizontal")
require('record.obj("result")' in tracker_screen, "Analyseergebnis liest Serverfeld result")
require('last_seen_version_code' in source, "WhatsNew-Version wird gespeichert")
require('BuildConfig.VERSION_NAME' in source, "Versionsanzeige ist vorhanden")
fixtures = ROOT / "app/src/test/resources/samples" fixtures = ROOT / "app/src/test/resources/samples"
for name in ["phase-1", "calihoss-newbie-8-wochen"]: for name in ["phase-1", "calihoss-newbie-8-wochen"]:
+75 -86
View File
@@ -1,107 +1,96 @@
# Trainingsplan- & Rezept-Editor 2.0 # Trainingsplan-Backend
Der Editor verwaltet Trainingspläne und Rezeptsammlungen innerhalb von boehmitools. Trainingspläne sind zugleich die verbindliche Datenquelle für den `trainingstracker`. Dieses Plugin ist nur noch das Eintragebackend fuer Trainingsplaene.
## Planlebenszyklus Es verwaltet:
Jede Plandatei enthält eine veröffentlichte Fassung und einen bearbeitbaren Entwurf: - Plaene
- Uebungen in einer zentralen Uebungsbibliothek
- Trainingstage
- Warm-up und Cool-down pro Trainingstag
- Uebungen, die an Trainingstagen hinterlegt sind
```json Nicht mehr enthalten sind PDF-Erzeugung, Rezeptsammlungen, Progressionen,
{ Planvorschlaege, Analyse-/ChatGPT-Anbindungen und Farbdefinitionen fuer eine
"plan_id": "phase-1", Ausgabeoberflaeche.
"revision": 4,
"published_revision": 2,
"config": {},
"draft": {}
}
```
- **Entwurf speichern** erhöht die Bearbeitungsrevision, verändert aber nicht den vom Tracker gelesenen Plan. ## Datenvertrag
- **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 Trainingsplaene verwenden:
Trainingspläne verwenden:
```text ```text
schema_version: 3 schema_version: 4
contract_version: 2 contract_version: 3
``` ```
Phasen, Tage, Rotationen, Übungsplatzierungen, Bewegungen, Progressionen und Progressionsstufen besitzen dauerhafte IDs. Beim Verschieben im Editor bleiben diese IDs erhalten. Eine Plan-Config ist bewusst flach:
Progressionsstufen sind Objekte und keine parallelen Text-/Schema-Arrays mehr:
```json ```json
{ {
"id": "step-squat-hold-...", "schema_version": 4,
"name": "Deep Squat Hold", "contract_version": 3,
"phase_id": "phase-start-...", "plan_id": "basis-plan",
"movement_cluster": "knee_dominant", "meta": {
"factor": 1.0, "title": "Basis-Plan"
"result_schema": { },
"mode": "seconds", "exercises": [
"weight_mode": "optional", {
"laterality": "bilateral", "id": "exercise-squat",
"sides_mode": "same" "name": "Squat",
} "cue": "Sauber und kontrolliert"
}
],
"days": [
{
"id": "day-1",
"num": 1,
"name": "Tag 1",
"warmup": { "items": ["Gelenke mobilisieren"] },
"cooldown": { "items": ["Locker auslaufen"] },
"exercises": [
{
"id": "day-exercise-1",
"exercise_id": "exercise-squat",
"sets": 3,
"reps": "8-12"
}
]
}
]
} }
``` ```
Dadurch reisen Stufenname, Eingabeformat und Analysefaktor immer gemeinsam. ## API
## Ergebniserfassung - `GET /api/plans`
- `POST /api/plans`
- `GET /api/plans/<plan>`
- `POST /api/plans/<plan>`
- `POST /api/plans/<plan>/publish`
- `POST /api/plans/<plan>/rename`
- `DELETE /api/plans/<plan>`
- `GET /api/plans/<plan>/exercises`
- `POST /api/plans/<plan>/exercises`
- `PATCH /api/plans/<plan>/exercises/<exercise>`
- `DELETE /api/plans/<plan>/exercises/<exercise>`
- `GET /api/plans/<plan>/days`
- `POST /api/plans/<plan>/days`
- `PATCH /api/plans/<plan>/days/<day>`
- `DELETE /api/plans/<plan>/days/<day>`
- `POST /api/plans/<plan>/days/<day>/exercises`
- `PATCH /api/plans/<plan>/days/<day>/exercises/<item>`
- `DELETE /api/plans/<plan>/days/<day>/exercises/<item>`
Eine Übung besitzt ein Standardformat. Jede Progressionsstufe kann es überschreiben. Der alte aktive Config-Zugriff bleibt fuer einfache Clients erhalten:
Priorität im Tracker: - `GET /api/config`
- `POST /api/config`
- `GET /api/default`
- `POST /api/reset`
```text ## Migration
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. Aeltere Trainingsplaene werden beim Laden auf den flachen Vertrag reduziert.
Uebungen aus alten Rotationen werden in die Uebungsbibliothek uebernommen und
## Tabata und feste Intervalle als Tagesuebungen referenziert. Warm-up und Cool-down werden aus den bisherigen
Tages- oder `prepost`-Feldern uebernommen.
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.
+476 -262
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Trainingsplan- und Rezepteditor mit Entwurf/Veröffentlichung und Konfliktschutz.""" """Backend fuer einen schlanken Trainingsplan-Editor."""
from __future__ import annotations from __future__ import annotations
import io import io
@@ -8,42 +8,29 @@ import os
import re import re
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from flask import Flask, Response, jsonify, request, send_file from flask import Flask, jsonify, request, send_file
import plan_builder from schema_contract import default_config, normalize_training_config, stable_id, validate_training_config
import recipe_builder
from schema_contract import migrate_training_config, validate_training_config, stable_id
from storage import atomic_json_write, utc_now from storage import atomic_json_write, utc_now
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")).resolve() DATA_DIR = Path(os.environ.get("DATA_DIR", BASE_DIR / "data")).resolve()
PLANS_DIR = DATA_DIR / "plans" PLANS_DIR = DATA_DIR / "plans"
PRESETS_DIR = BASE_DIR / "presets"
ACTIVE_FILE = DATA_DIR / "active.txt" ACTIVE_FILE = DATA_DIR / "active.txt"
LEGACY_CONFIG = DATA_DIR / "config.json" LEGACY_CONFIG = DATA_DIR / "config.json"
STATIC_DIR = BASE_DIR / "static"
BACKUPS_DIR = DATA_DIR / "backups" 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) PLANS_DIR.mkdir(parents=True, exist_ok=True)
BACKUPS_DIR.mkdir(parents=True, exist_ok=True) BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(__name__, static_folder=None) 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: def slugify(name: Any) -> str:
s = str(name or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") text = str(name or "").strip().lower()
return re.sub(r"[^a-z0-9]+", "-", s).strip("-") or "phase" text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
return re.sub(r"[^a-z0-9]+", "-", text).strip("-") or "trainingsplan"
def plan_path(pid: str) -> Path: def plan_path(pid: str) -> Path:
@@ -53,26 +40,29 @@ def plan_path(pid: str) -> Path:
def unique_id(base: str) -> str: def unique_id(base: str) -> str:
stem, index = base, 2 stem, index = base, 2
while plan_path(stem).exists(): while plan_path(stem).exists():
stem = f"{base}-{index}"; index += 1 stem = f"{base}-{index}"
index += 1
return stem return stem
def _payload() -> dict[str, Any]:
body = request.get_json(silent=True)
return body if isinstance(body, dict) else {}
def _normalize_wrapper(pid: str, raw: Any) -> dict[str, Any]: def _normalize_wrapper(pid: str, raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict): if not isinstance(raw, dict):
raw = {} raw = {}
name = str(raw.get("name") or pid) name = str(raw.get("name") or pid)
published = raw.get("config") if isinstance(raw.get("config"), dict) else {} published_source = raw.get("config") if isinstance(raw.get("config"), dict) else raw
is_recipe = published.get("type") == "recipe" published = normalize_training_config(published_source, plan_id=str(raw.get("plan_id") or pid))
if not is_recipe: draft_source = raw.get("draft") if isinstance(raw.get("draft"), dict) else published
published = migrate_training_config(published, plan_id=str(raw.get("plan_id") or pid)) draft = normalize_training_config(draft_source, plan_id=published.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)) revision = max(1, int(raw.get("revision") or 1))
published_revision = max(1, int(raw.get("published_revision") or 1)) published_revision = max(1, int(raw.get("published_revision") or 1))
return { return {
"id": pid, "id": pid,
"plan_id": str(raw.get("plan_id") or published.get("plan_id") or stable_id("plan", pid, name)), "plan_id": str(raw.get("plan_id") or published.get("plan_id") or pid),
"name": name, "name": name,
"revision": revision, "revision": revision,
"published_revision": published_revision, "published_revision": published_revision,
@@ -96,25 +86,41 @@ def write_plan(pid: str, wrapper: dict[str, Any], *, backup: bool = True) -> dic
return normalized return normalized
def create_wrapper(pid: str, name: str, cfg: dict[str, Any]) -> dict[str, Any]: def create_wrapper(pid: str, name: str, cfg: dict[str, Any] | None = None) -> dict[str, Any]:
if cfg.get("type") != "recipe": plan = normalize_training_config(cfg or default_config(), plan_id=pid)
cfg = migrate_training_config(cfg, plan_id=pid) plan["plan_id"] = pid
plan["meta"]["title"] = str(name or plan["meta"].get("title") or "Neuer Trainingsplan")
now = utc_now() now = utc_now()
return { return {
"id": pid, "plan_id": cfg.get("plan_id") or pid, "name": name, "id": pid,
"revision": 1, "published_revision": 1, "plan_id": pid,
"created_at": now, "updated_at": now, "published_at": now, "name": plan["meta"]["title"],
"config": deepcopy(cfg), "draft": deepcopy(cfg), "revision": 1,
"published_revision": 1,
"created_at": now,
"updated_at": now,
"published_at": now,
"config": deepcopy(plan),
"draft": deepcopy(plan),
} }
def list_plan_ids() -> list[str]: def list_plan_ids() -> list[str]:
return sorted(path.stem for path in PLANS_DIR.glob("*.json")) ids = []
for path in PLANS_DIR.glob("*.json"):
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
raw = {}
source = raw.get("config") if isinstance(raw, dict) and isinstance(raw.get("config"), dict) else raw
if isinstance(source, dict) and source.get("type") == "recipe":
continue
ids.append(path.stem)
return sorted(ids)
def plan_name(pid: str) -> str: def editable_plan_exists(pid: str) -> bool:
try: return read_plan(pid)["name"] return pid in list_plan_ids()
except Exception: return pid
def get_active() -> str | None: def get_active() -> str | None:
@@ -128,323 +134,531 @@ def set_active(pid: str) -> None:
ACTIVE_FILE.write_text(pid, encoding="utf-8") 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: def ensure_seed() -> None:
if not list_plan_ids(): if not list_plan_ids():
try: cfg = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_for("training") try:
except Exception: cfg = default_for("training") raw = json.loads(LEGACY_CONFIG.read_text(encoding="utf-8")) if LEGACY_CONFIG.exists() else default_config()
write_plan("phase-1", create_wrapper("phase-1", "Phase 1", cfg), backup=False) except Exception:
install_presets() raw = default_config()
if get_active() is None and list_plan_ids(): set_active(list_plan_ids()[0]) write_plan("trainingsplan", create_wrapper("trainingsplan", "Trainingsplan", raw), backup=False)
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]: def public_wrapper(wrapper: dict[str, Any], *, draft: bool = True) -> dict[str, Any]:
return { return {
"id": wrapper["id"], "plan_id": wrapper["plan_id"], "name": wrapper["name"], "id": wrapper["id"],
"revision": wrapper["revision"], "published_revision": wrapper["published_revision"], "plan_id": wrapper["plan_id"],
"updated_at": wrapper["updated_at"], "published_at": wrapper["published_at"], "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"], "has_unpublished_changes": wrapper["draft"] != wrapper["config"],
"tracked_sessions": _tracked_session_count(wrapper["id"]),
"config": deepcopy(wrapper["draft"] if draft else wrapper["config"]), "config": deepcopy(wrapper["draft"] if draft else wrapper["config"]),
} }
def _validation(config: dict[str, Any]) -> dict[str, Any]: def _save_draft(pid: str, wrapper: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
if config.get("type") == "recipe": return {"errors": [], "warnings": [], "config": config} wrapper["draft"] = normalize_training_config(config, plan_id=wrapper["plan_id"])
return validate_training_config(config) wrapper["draft"]["meta"]["title"] = str(wrapper["name"])
wrapper["revision"] += 1
return write_plan(pid, wrapper)
def _diff_summary(old: Any, new: Any) -> dict[str, Any]: def _mutate_draft(pid: str, mutator: Callable[[dict[str, Any]], Any]) -> tuple[dict[str, Any], Any]:
"""Erzeugt einen stabil-ID-basierten Änderungsbericht für die Veröffentlichung.""" if not editable_plan_exists(pid):
old_cfg = old if isinstance(old, dict) else {} raise FileNotFoundError(pid)
new_cfg = new if isinstance(new, dict) else {} wrapper = read_plan(pid)
draft = deepcopy(wrapper["draft"])
def collect(cfg: dict[str, Any]) -> dict[str, dict[str, dict[str, Any]]]: result = mutator(draft)
result: dict[str, dict[str, dict[str, Any]]] = { if isinstance(result, str) and result in {"day", "exercise", "item", "used"}:
"days": {}, "rotations": {}, "exercises": {}, "progression_steps": {}, return wrapper, result
} saved = _save_draft(pid, wrapper, draft)
for di, day in enumerate(cfg.get("days", []) if isinstance(cfg.get("days"), list) else []): return saved, result
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: def _find_by_id(items: list[dict[str, Any]], item_id: str) -> dict[str, Any] | None:
return TRACKER_PROPOSALS_DIR / f"{pid}.json" return next((item for item in items if str(item.get("id")) == item_id), None)
def _replace_list_item(items: list[dict[str, Any]], item_id: str, replacement: dict[str, Any]) -> bool:
for index, item in enumerate(items):
if str(item.get("id")) == item_id:
items[index] = replacement
return True
return False
def _delete_list_item(items: list[dict[str, Any]], item_id: str) -> bool:
before = len(items)
items[:] = [item for item in items if str(item.get("id")) != item_id]
return len(items) != before
ensure_seed() ensure_seed()
@app.route("/") @app.route("/")
def index(): def index():
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html") return jsonify({"name": "Trainingsplan-Backend", "status": "ok"})
@app.route("/api/plans", methods=["GET"]) @app.route("/api/plans", methods=["GET"])
def api_plans(): def api_plans():
active = get_active(); plans = [] active = get_active()
plans = []
for pid in list_plan_ids(): for pid in list_plan_ids():
try: try:
p = read_plan(pid) plan = 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"]}) plans.append({
except Exception: continue "id": pid,
"name": plan["name"],
"active": pid == active,
"revision": plan["revision"],
"published_revision": plan["published_revision"],
"has_unpublished_changes": plan["draft"] != plan["config"],
})
except Exception:
continue
return jsonify({"plans": plans, "active": active}) return jsonify({"plans": plans, "active": active})
@app.route("/api/plans", methods=["POST"]) @app.route("/api/plans", methods=["POST"])
def api_plan_create(): def api_plan_create():
body = request.get_json(force=True) or {}; name = str(body.get("name") or "Neue Phase").strip() body = _payload()
src = body.get("from"); kind = body.get("kind") or body.get("type") or "training" name = str(body.get("name") or "Neuer Trainingsplan").strip()
cfg = deepcopy(read_plan(src)["draft"]) if src and plan_path(str(src)).exists() else default_for(str(kind)) source = body.get("from")
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid) cfg = deepcopy(read_plan(str(source))["draft"]) if source and editable_plan_exists(str(source)) else default_config()
return jsonify(public_wrapper(wrapper)) 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)), 201
@app.route("/api/plans/<pid>", methods=["GET"]) @app.route("/api/plans/<pid>", methods=["GET"])
def api_plan_get(pid: str): def api_plan_get(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
return jsonify(public_wrapper(read_plan(pid))) return jsonify(public_wrapper(read_plan(pid)))
@app.route("/api/plans/<pid>/select", methods=["POST"]) @app.route("/api/plans/<pid>/select", methods=["POST"])
def api_plan_select(pid: str): def api_plan_select(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
set_active(pid); return jsonify(public_wrapper(read_plan(pid))) return jsonify({"error": "not found"}), 404
set_active(pid)
return jsonify(public_wrapper(read_plan(pid)))
@app.route("/api/plans/<pid>", methods=["POST"]) @app.route("/api/plans/<pid>", methods=["POST"])
def api_plan_save(pid: str): def api_plan_save(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
body = request.get_json(force=True) or {} return jsonify({"error": "not found"}), 404
body = _payload()
config = body.get("config") if isinstance(body.get("config"), dict) else body config = body.get("config") if isinstance(body.get("config"), dict) else body
expected = body.get("expected_revision") if isinstance(body, dict) else None expected = body.get("expected_revision") if isinstance(body, dict) else None
wrapper = read_plan(pid) wrapper = read_plan(pid)
if expected is not None and int(expected) != wrapper["revision"]: 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 return jsonify({"error": "Der Plan wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
validation = _validation(config) saved = _save_draft(pid, wrapper, config)
wrapper["draft"] = validation["config"] validation = validate_training_config(saved["draft"])
wrapper["revision"] += 1
saved = write_plan(pid, wrapper)
return jsonify({"ok": True, **public_wrapper(saved), "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}) 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"]) @app.route("/api/plans/<pid>/publish", methods=["POST"])
def api_plan_publish(pid: str): def api_plan_publish(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
body = request.get_json(silent=True) or {}; wrapper = read_plan(pid) return jsonify({"error": "not found"}), 404
body = _payload()
wrapper = read_plan(pid)
expected = body.get("expected_revision") expected = body.get("expected_revision")
if expected is not None and int(expected) != wrapper["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 return jsonify({"error": "Der Entwurf wurde zwischenzeitlich geändert.", "code": "revision_conflict", "current_revision": wrapper["revision"]}), 409
validation = _validation(wrapper["draft"]) validation = validate_training_config(wrapper["draft"])
if validation["errors"]: if validation["errors"]:
return jsonify({"error": "Der Plan enthält Validierungsfehler.", "validation": {"errors": validation["errors"], "warnings": validation["warnings"]}}), 400 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["config"] = deepcopy(validation["config"])
wrapper["draft"] = deepcopy(validation["config"]) wrapper["draft"] = deepcopy(validation["config"])
wrapper["published_revision"] += 1 wrapper["published_revision"] += 1
wrapper["revision"] += 1 wrapper["revision"] += 1
wrapper["published_at"] = utc_now() wrapper["published_at"] = utc_now()
saved = write_plan(pid, wrapper) saved = write_plan(pid, wrapper)
return jsonify({"ok": True, **public_wrapper(saved), "diff": diff, "validation": {"errors": [], "warnings": validation["warnings"]}}) return jsonify({"ok": True, **public_wrapper(saved, draft=False), "validation": {"errors": [], "warnings": validation["warnings"]}})
@app.route("/api/plans/<pid>/rename", methods=["POST"]) @app.route("/api/plans/<pid>/rename", methods=["POST"])
def api_plan_rename(pid: str): def api_plan_rename(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
name = str((request.get_json(force=True) or {}).get("name") or "").strip() return jsonify({"error": "not found"}), 404
if not name: return jsonify({"error": "name required"}), 400 name = str(_payload().get("name") or "").strip()
wrapper = read_plan(pid); wrapper["name"] = name; wrapper["revision"] += 1; saved = write_plan(pid, wrapper) if not name:
return jsonify({"error": "name required"}), 400
wrapper = read_plan(pid)
wrapper["name"] = name
wrapper["draft"]["meta"]["title"] = name
wrapper["config"]["meta"]["title"] = name
wrapper["revision"] += 1
saved = write_plan(pid, wrapper)
return jsonify({"id": pid, "name": name, "revision": saved["revision"]}) return jsonify({"id": pid, "name": name, "revision": saved["revision"]})
@app.route("/api/plans/<pid>", methods=["DELETE"]) @app.route("/api/plans/<pid>", methods=["DELETE"])
def api_plan_delete(pid: str): def api_plan_delete(pid: str):
ids = list_plan_ids() ids = list_plan_ids()
if pid not in ids: return jsonify({"error": "not found"}), 404 if pid not in ids:
if len(ids) <= 1: return jsonify({"error": "letzte Phase kann nicht gelöscht werden"}), 400 return jsonify({"error": "not found"}), 404
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'}) if len(ids) <= 1:
return jsonify({"error": "letzter Plan 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() plan_path(pid).unlink()
if get_active() is None: set_active(list_plan_ids()[0]) if get_active() is None:
set_active(list_plan_ids()[0])
return jsonify({"ok": True, "active": get_active()}) return jsonify({"ok": True, "active": get_active()})
@app.route("/api/plans/<pid>/export") @app.route("/api/plans/<pid>/export")
def api_plan_export(pid: str): def api_plan_export(pid: str):
if not plan_path(pid).exists(): return jsonify({"error": "not found"}), 404 if not editable_plan_exists(pid):
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") 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"]) @app.route("/api/plans/import", methods=["POST"])
def api_plan_import(): def api_plan_import():
raw = request.files["file"].read() if "file" in request.files else request.data raw = request.files["file"].read() if "file" in request.files else request.data
if not raw: return jsonify({"error": "keine Datei"}), 400 if not raw:
try: obj = json.loads(raw.decode("utf-8")) return jsonify({"error": "keine Datei"}), 400
except Exception as exc: return jsonify({"error": f"ungültiges JSON: {exc}"}), 400 try:
if isinstance(obj, dict) and "config" in obj: obj = json.loads(raw.decode("utf-8"))
cfg = obj.get("config", {}); name = str(obj.get("name") or "Importierte Phase") except Exception as exc:
else: return jsonify({"error": f"ungültiges JSON: {exc}"}), 400
cfg = obj if isinstance(obj, dict) else {}; name = str((cfg.get("meta") or {}).get("title") or "Importierte Phase") cfg = obj.get("config", {}) if isinstance(obj, dict) and isinstance(obj.get("config"), dict) else (obj if isinstance(obj, dict) else {})
pid = unique_id(slugify(name)); wrapper = create_wrapper(pid, name, cfg); write_plan(pid, wrapper, backup=False); set_active(pid) name = str(obj.get("name") or (cfg.get("meta") or {}).get("title") or "Importierter Trainingsplan")
return jsonify(public_wrapper(wrapper)) 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)), 201
@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"]) @app.route("/api/plans/<pid>/exercises", methods=["GET"])
def api_plan_proposal_status(pid: str, proposal_id: str): def api_exercises_list(pid: str):
path = _proposal_file(pid); body = request.get_json(silent=True) or {}; status = str(body.get("status") or "reviewed") if not editable_plan_exists(pid):
if status not in {"open", "accepted", "rejected", "reviewed"}: return jsonify({"error": "not found"}), 404
return jsonify({"error": "ungültiger Vorschlagsstatus"}), 400 return jsonify({"exercises": read_plan(pid)["draft"].get("exercises", [])})
raw = {}
if path.exists():
try: raw = json.loads(path.read_text(encoding="utf-8")) @app.route("/api/plans/<pid>/exercises", methods=["POST"])
except Exception: raw = {} def api_exercise_create(pid: str):
proposals = raw.get("proposals") if isinstance(raw.get("proposals"), list) else [] body = _payload()
for proposal in proposals: name = str(body.get("name") or "").strip()
if str(proposal.get("id")) == proposal_id: if not name:
proposal["status"] = status; proposal["reviewed_at"] = utc_now() return jsonify({"error": "name required"}), 400
raw["proposals"] = proposals; raw["updated_at"] = utc_now(); atomic_json_write(path, raw)
return jsonify({"ok": True, "proposals": proposals}) def mutate(cfg: dict[str, Any]) -> dict[str, Any]:
exercise = {
"id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), name),
"name": name,
}
for key in ("cue", "description", "equipment", "notes"):
value = str(body.get(key) or "").strip()
if value:
exercise[key] = value
tags = body.get("tags")
if isinstance(tags, list):
exercise["tags"] = [str(tag).strip() for tag in tags if str(tag).strip()]
cfg.setdefault("exercises", []).append(exercise)
return exercise
try:
saved, exercise = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True, "exercise": exercise, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/exercises/<exercise_id>", methods=["PATCH"])
def api_exercise_update(pid: str, exercise_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | None:
exercises = cfg.setdefault("exercises", [])
exercise = _find_by_id(exercises, exercise_id)
if not exercise:
return None
updated = deepcopy(exercise)
for key in ("name", "cue", "description", "equipment", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
if isinstance(body.get("tags"), list):
updated["tags"] = [str(tag).strip() for tag in body["tags"] if str(tag).strip()]
_replace_list_item(exercises, exercise_id, updated)
return updated
try:
saved, exercise = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if exercise is None:
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "exercise": exercise, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/exercises/<exercise_id>", methods=["DELETE"])
def api_exercise_delete(pid: str, exercise_id: str):
force = request.args.get("force") in {"1", "true", "yes"}
def mutate(cfg: dict[str, Any]) -> bool | str:
used = any(item.get("exercise_id") == exercise_id for day in cfg.get("days", []) for item in day.get("exercises", []))
if used and not force:
return "used"
removed = _delete_list_item(cfg.setdefault("exercises", []), exercise_id)
if force:
for day in cfg.get("days", []):
day["exercises"] = [item for item in day.get("exercises", []) if item.get("exercise_id") != exercise_id]
return removed
try:
saved, result = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if result == "used":
return jsonify({"error": "exercise is used by training days", "code": "exercise_in_use"}), 409
if not result:
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days", methods=["GET"])
def api_days_list(pid: str):
if not editable_plan_exists(pid):
return jsonify({"error": "not found"}), 404
return jsonify({"days": read_plan(pid)["draft"].get("days", [])})
@app.route("/api/plans/<pid>/days", methods=["POST"])
def api_day_create(pid: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any]:
days = cfg.setdefault("days", [])
next_num = max([int(day.get("num") or 0) for day in days], default=0) + 1
num = int(body.get("num") or next_num)
name = str(body.get("name") or body.get("focus") or f"Tag {num}").strip()
day = {
"id": stable_id("day", cfg.get("plan_id"), num, name),
"num": num,
"name": name,
"warmup": {"items": [str(item).strip() for item in body.get("warmup", []) if str(item).strip()]} if isinstance(body.get("warmup"), list) else {"items": []},
"cooldown": {"items": [str(item).strip() for item in body.get("cooldown", []) if str(item).strip()]} if isinstance(body.get("cooldown"), list) else {"items": []},
"exercises": [],
}
days.append(day)
return day
try:
saved, day = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True, "day": day, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/days/<day_id>", methods=["PATCH"])
def api_day_update(pid: str, day_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | None:
days = cfg.setdefault("days", [])
day = _find_by_id(days, day_id)
if not day:
return None
updated = deepcopy(day)
for key in ("name", "focus", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
if "num" in body:
updated["num"] = int(body.get("num") or updated.get("num") or 1)
for key in ("warmup", "cooldown"):
if isinstance(body.get(key), list):
updated[key] = {"items": [str(item).strip() for item in body[key] if str(item).strip()]}
elif isinstance(body.get(key), dict):
updated[key] = body[key]
_replace_list_item(days, day_id, updated)
return updated
try:
saved, day = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if day is None:
return jsonify({"error": "day not found"}), 404
return jsonify({"ok": True, "day": day, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>", methods=["DELETE"])
def api_day_delete(pid: str, day_id: str):
def mutate(cfg: dict[str, Any]) -> bool:
return _delete_list_item(cfg.setdefault("days", []), day_id)
try:
saved, removed = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if not removed:
return jsonify({"error": "day not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>/exercises", methods=["POST"])
def api_day_exercise_add(pid: str, day_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
exercise_id = str(body.get("exercise_id") or "").strip()
if not exercise_id and str(body.get("name") or "").strip():
exercise = {
"id": stable_id("exercise", cfg.get("plan_id"), len(cfg.get("exercises", [])), body.get("name")),
"name": str(body.get("name")).strip(),
}
cfg.setdefault("exercises", []).append(exercise)
exercise_id = exercise["id"]
if not _find_by_id(cfg.get("exercises", []), exercise_id):
return "exercise"
item = {
"id": stable_id("day-exercise", day_id, exercise_id, len(day.get("exercises", []))),
"exercise_id": exercise_id,
}
for key in ("sets", "duration_seconds"):
if body.get(key):
item[key] = int(body[key])
for key in ("reps", "tempo", "rest", "notes"):
value = str(body.get(key) or "").strip()
if value:
item[key] = value
day.setdefault("exercises", []).append(item)
return item
try:
saved, item = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if item == "day":
return jsonify({"error": "day not found"}), 404
if item == "exercise":
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "day_exercise": item, "revision": saved["revision"]}), 201
@app.route("/api/plans/<pid>/days/<day_id>/exercises/<item_id>", methods=["PATCH"])
def api_day_exercise_update(pid: str, day_id: str, item_id: str):
body = _payload()
def mutate(cfg: dict[str, Any]) -> dict[str, Any] | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
item = _find_by_id(day.setdefault("exercises", []), item_id)
if not item:
return "item"
updated = deepcopy(item)
if "exercise_id" in body:
exercise_id = str(body.get("exercise_id") or "").strip()
if not _find_by_id(cfg.get("exercises", []), exercise_id):
return "exercise"
updated["exercise_id"] = exercise_id
for key in ("sets", "duration_seconds"):
if key in body:
value = int(body.get(key) or 0)
if value > 0:
updated[key] = value
else:
updated.pop(key, None)
for key in ("reps", "tempo", "rest", "notes"):
if key in body:
value = str(body.get(key) or "").strip()
if value:
updated[key] = value
else:
updated.pop(key, None)
_replace_list_item(day["exercises"], item_id, updated)
return updated
try:
saved, item = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if item == "day":
return jsonify({"error": "day not found"}), 404
if item == "item":
return jsonify({"error": "day exercise not found"}), 404
if item == "exercise":
return jsonify({"error": "exercise not found"}), 404
return jsonify({"ok": True, "day_exercise": item, "revision": saved["revision"]})
@app.route("/api/plans/<pid>/days/<day_id>/exercises/<item_id>", methods=["DELETE"])
def api_day_exercise_delete(pid: str, day_id: str, item_id: str):
def mutate(cfg: dict[str, Any]) -> bool | str:
day = _find_by_id(cfg.setdefault("days", []), day_id)
if not day:
return "day"
return _delete_list_item(day.setdefault("exercises", []), item_id)
try:
saved, result = _mutate_draft(pid, mutate)
except FileNotFoundError:
return jsonify({"error": "not found"}), 404
if result == "day":
return jsonify({"error": "day not found"}), 404
if not result:
return jsonify({"error": "day exercise not found"}), 404
return jsonify({"ok": True, "revision": saved["revision"]})
# Backward-compatible active plan endpoints
@app.route("/api/config", methods=["GET"]) @app.route("/api/config", methods=["GET"])
def api_get_config(): def api_get_config():
pid = get_active(); return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_for("training")}) pid = get_active()
return jsonify(public_wrapper(read_plan(pid)) if pid else {"config": default_config()})
@app.route("/api/config", methods=["POST"]) @app.route("/api/config", methods=["POST"])
def api_save_config(): def api_save_config():
pid = get_active() pid = get_active()
if not pid: return jsonify({"error": "kein aktiver Plan"}), 400 if not pid:
return jsonify({"error": "kein aktiver Plan"}), 400
return api_plan_save(pid) return api_plan_save(pid)
@app.route("/api/default") @app.route("/api/default")
def api_default(): return jsonify(default_for(request.args.get("kind", "training"))) def api_default():
return jsonify(default_config())
@app.route("/api/reset", methods=["POST"]) @app.route("/api/reset", methods=["POST"])
def api_reset(): def api_reset():
pid = get_active(); wrapper = read_plan(pid); kind = "recipe" if wrapper["draft"].get("type") == "recipe" else "training" pid = get_active()
wrapper["draft"] = default_for(kind); wrapper["revision"] += 1; saved = write_plan(pid, wrapper) if not pid:
return jsonify({"error": "kein aktiver Plan"}), 400
wrapper = read_plan(pid)
wrapper["draft"] = normalize_training_config(default_config(), plan_id=wrapper["plan_id"])
wrapper["draft"]["meta"]["title"] = wrapper["name"]
wrapper["revision"] += 1
saved = write_plan(pid, wrapper)
return jsonify(public_wrapper(saved)) 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__": if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False) app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8080")), debug=False)
+4 -16
View File
@@ -1,16 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """Plugin-Adapter fuer das Trainingsplan-Backend."""
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 from __future__ import annotations
import os import os
@@ -21,7 +10,7 @@ from core.loader import load_module
def create_app(ctx): def create_app(ctx):
# app.py liest DATA_DIR beim Import und legt dort die Phasen an. # app.py liest DATA_DIR beim Import und legt dort den ersten Plan an.
previous = os.environ.get("DATA_DIR") previous = os.environ.get("DATA_DIR")
os.environ["DATA_DIR"] = str(ctx.data_dir) os.environ["DATA_DIR"] = str(ctx.data_dir)
try: try:
@@ -32,7 +21,6 @@ def create_app(ctx):
else: else:
os.environ["DATA_DIR"] = previous os.environ["DATA_DIR"] = previous
# Flask erzeugt seine URLs anhand von SCRIPT_NAME; a2wsgi übernimmt dafür # Flask erzeugt seine URLs anhand von SCRIPT_NAME; a2wsgi uebernimmt dafuer
# den root_path des Mounts. Damit funktionieren alle Routen unter # den root_path des Mounts.
# /plugins/trainingsplan genauso wie vorher unter /.
return WSGIMiddleware(module.app) return WSGIMiddleware(module.app)
-669
View File
@@ -1,669 +0,0 @@
# -*- coding: utf-8 -*-
"""
plan_builder.py
Rendert den 8-Wochen-Calisthenics-Plan als A4-PDF.
Layout 1:1 wie im Original-Skript, aber alle Inhalte kommen aus einem Config-Dict.
build_pdf(config) -> bytes
default_config() -> dict (Standard-Inhalte)
"""
import copy
from schema_contract import migrate_training_config
from io import BytesIO
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, KeepTogether)
from reportlab.lib.enums import TA_RIGHT
# =========================================================================
# STANDARD-KONFIGURATION (entspricht exakt dem Original-Skript)
# =========================================================================
DEFAULTS = {
"meta": {
"title": "Calisthenics-Basis 8-Wochen Komplettplan",
"subtitle": "Blutiger Anfänger · 1,90 m · 95 kg · Rucksack + Couch + Kettlebell · tägliche ~30-Min Tabata-Session",
"output_name": "Calisthenics-8-Wochen-Komplett.pdf",
"footer_left": "Calisthenics 8-Wochen Komplettplan \u00b7 mit Kettlebell",
"doc_title": "Calisthenics 8-Wochen Komplettplan (mit Kettlebell)",
"weeks": 8,
},
"colors": {
"INK": "#1A1A1A", "ACCENT": "#C2410C", "ACC2": "#0F766E", "KBCOL": "#6D28D9",
"LINK": "#1565C0", "LIGHT": "#F4F1EC", "GRID": "#CFC9BF", "MUTE": "#6B6256",
"DARK": "#23201C", "WARMBG": "#EAF1F0", "GOALBG": "#FBF1E9", "KBBG": "#F1ECFB",
},
"front": {
"session_how_head": "So funktioniert eine Session",
"session_how_body": (
"<b>Tabata-Block = 4 Min:</b> 10 Sek einzählen, dann 8× (20 Sek Arbeit / 10 Sek Pause). "
"Pro Block rotieren <b>4 Übungen</b>, je <b>2 Durchgänge</b> = 8 Intervalle = 1 Block = <b>1 Satz</b>. "
"Eine Rotation läuft <b>3 Sätze</b> (in Woche 1 ruhig mit 2 starten). Warm-up steht über, Cool-down unter den Übungen."
),
"flow_rows": [
["Teil", "Inhalt", "Zeit"],
["Warm-up", "Gelenk-Prep (dynamisch)", "~3 Min"],
["Rotation A", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Rotation B", "4 Übungen, 3 Sätze (kurze Pausen)", "~14 Min"],
["Cool-down", "Ausklingen + Dehnen", "~6 Min"],
["", "Gesamt", "~35 Min"],
],
"timer_note": "<b>Timer:</b> „Tabata / Interval Timer“ auf 10s Vorlauf, 8 Runden, 20/10.",
"kb_head": "Warum Kettlebell und warum es zu deinem Ziel passt",
"kb_box": (
"Du trainierst für <b>Calisthenics</b>, nicht für Maximalkraft. Genau da liegen die <b>belegten</b> Stärken der Kettlebell "
"ohne dass dich ihr dokumentierter Schwachpunkt (Maximalkraft/Hypertrophie wegen fixer Gewichtssprünge) trifft:<br/>"
"•  <b>Griffkraft</b> (Studie BELL: +67 kg) dein größter Mehrwert auf dem Weg zu Klimmzug &amp; Dip.<br/>"
"•  <b>Power / ballistische Hüftstreckung</b> (Swing) explosive Basis für spätere Skills.<br/>"
"•  <b>Kondition &amp; Körperfett</b> wirksam belegt; du startest mit wenig Ausdauer.<br/>"
"•  <b>Ganzkörper, ein Gerät, zeiteffizient</b> passt ideal ins 30-Min-Tabata-Format; niedriges Einstiegs-Verletzungsrisiko bei sauberer Technik.<br/>"
"<b>Ehrlich:</b> Reines KB ersetzt <u>kein</u> Maximalkraft-/Muskelaufbau-Programm. Für dein Ziel (Basis-Fundament) liegt das ohnehin unter der relevanten Schwelle."
),
"goals_head": "Realistische Ziele nach 8 Wochen",
"goals_box": (
"Du startest bei 23 Liegestützen und 0 Dips. Realistisch und ehrlich nach 8 Wochen sauberer Arbeit:<br/>"
"•  <b>Liegestütze:</b> von 23 auf erste saubere <b>volle</b> Reps (Rest in Knie-/Negativ-Form).<br/>"
"•  <b>Bank-Dips:</b> saubere Reps mit Füßen etwas weiter weg.   <b>Pike Push-up:</b> am Boden mit kleinem ROM.<br/>"
"•  <b>Plank ~4560 s</b>, <b>Wall Sit ~60 s</b>, viele saubere <b>Goblet-Kniebeugen</b>, sauberer <b>Swing</b> + spürbar bessere <b>Griffkraft</b>.<br/>"
"•  <b>Noch NICHT dran:</b> Diamond Push-ups, L-Sit, Handstand, schwerer Turkish Get-Up, Snatch Stoff für Woche 12+."
),
"goals_note": (
"<b>Wichtig:</b> Die Wochen-Stufen sind ein <b>Rahmen, kein Muss</b>. Bleib auf einer Stufe, solange sie fordert. "
"<b>Aufstiegs-Regel:</b> erst hoch (oder schwerere Glocke), wenn du eine Übung 2 Sessions in Folge in allen Sätzen sauber über die vollen 20s schaffst. "
"<font color='#6D28D9'><b>KB = Kettlebell bevorzugt; Rucksack bleibt als Alternative.</b></font>"
),
"lib_head": "Übungs-Bibliothek (mit Anleitungen)",
"lib_intro": (
"Für Übungen, die du noch nicht kennst, ist ein Link zu einer bebilderten Anleitung dabei. "
"<font color='#6D28D9'>Lila Gruppe = Kettlebell-Übungen.</font>"
),
"baseline_head": "Standortbestimmung &amp; Tracking",
"baseline_intro": "Mach die Tests <b>vor Tag 1</b> und in <b>Woche 8 identisch</b> nochmal dein Vorher/Nachher.",
"baseline_rows": [
["Test", "Tag 0", "Woche 8"],
["Liegestütze (max, saubere Reps)", "", ""],
["Bank-Dips an der Couch (max)", "", ""],
["Kniebeugen in 60 Sekunden", "", ""],
["Ausfallschritte (max gesamt)", "", ""],
["Plank (max Sekunden)", "", ""],
["Wall Sit (max Sekunden)", "", ""],
["KB Farmer-Hold (max Sekunden, Griff)", "", ""],
["Körpergewicht (kg)", "95", ""],
],
"reminder_head": "Kurz-Reminder",
"reminders": [
"<b>Form vor Reps.</b> Wähle die Stufe / Glocke, bei der du die 20s kontrolliert durcharbeitest mit 12 Reps Reserve.",
"<b>Swing-Technik first.</b> Der Swing ist ein Hüft-Schnapp, KEIN Kniebeuge und KEIN Armheben. Erst Dead-Stop-Swings, bis die Hüftstreckung sitzt.",
"<b>Einseitige Übungen (Tag 2):</b> Durchgang 1 = links, Durchgang 2 = rechts so ist jede Seite automatisch ausbalanciert.",
"<b>Täglich ist viel.</b> Tag 5 leicht, Tag 7 frei. Bei starkem Muskelkater einen Extra-Ruhetag gehört zum Aufbau.",
"<b>Schlaf &amp; Eiweiß.</b> 79 h Schlaf, ~1,6 g Eiweiß pro kg. Stechender Schmerz = stoppen.",
],
"sheets_note": (
"Ab der nächsten Seite: 1 Druckblatt pro Session (Woche 18). <font color='#6D28D9'>Tag 6 = reine Kettlebell-Session.</font> "
"Warm-up steht über, Cool-down + Dehnen unter den Übungen. Bei Vorerkrankungen/Verletzungen vorher ärztlich abklären."
),
},
"library": [
{"group": "DRÜCKEN (Brust / Schultern / Trizeps)", "color": "ACC2", "items": [
{"name": "Liegestütze", "desc": "Stütz, Körper gerade, Brust kontrolliert senken &amp; drücken. Stufen: Inkline (Couch), Knie, Negativ, Voll.", "url": "https://blog.calimove.com/2023/01/25/push-up-variations-beginner-expert/"},
{"name": "Bank-Dips (Couch)", "desc": "Rücken zur Couch, Hände auf der Kante, Po davor absenken (Ellbogen nach hinten), hochdrücken. Knie beugen = leichter.", "url": "https://www.healthline.com/health/fitness-exercise/bench-dips"},
{"name": "Pike Push-up / Hold", "desc": "Umgekehrtes V (Hintern hoch), Kopf Richtung Boden senken, hochdrücken. Schulter-Fokus. Hände erhöht = leichter.", "url": "https://www.hingehealth.com/resources/articles/pike-push-up/"},
{"name": "Seitheben (KB/Rucksack)", "desc": "Arme seitlich bis Schulterhöhe heben, Ellbogen leicht gebeugt, kontrolliert senken. Seitlicher Deltamuskel.", "url": ""},
]},
{"group": "ZIEHEN (Rücken / Bizeps KB oder Rucksack + Boden)", "color": "ACC2", "items": [
{"name": "Einarm-Rudern ENG", "desc": "Hüfte beugen, Rücken GERADE, abgestützt. Ellbogen an den Rippen, Zug zur Hüfte Lat.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Einarm-Rudern BREIT", "desc": "Gleiche Position, aber Ellbogen ~45° geöffnet, Zug zum Brustkorb oberer Rücken / hinterer Delta.", "url": "https://www.puregym.com/exercises/back/rows/dumbbell-bent-over-row/"},
{"name": "Bizeps-Curls (KB/Rucksack)", "desc": "Last unten greifen, Oberarme fix am Körper, kontrolliert zu den Schultern curlen.", "url": ""},
{"name": "Superman / Reverse Snow Angels / Y-T-W", "desc": "Bäuchlings Arme+Beine heben (Superman). Snow Angels = Arme am Boden auf/ab. Rücken ohne Stange.", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
]},
{"group": "BEINE", "color": "ACC2", "items": [
{"name": "Kniebeuge / Goblet Squat (KB/Rucksack)", "desc": "Hüfte zurück, Oberschenkel parallel, Fersen am Boden. Goblet = Glocke/Rucksack vor der Brust umarmen.", "url": ""},
{"name": "Ausfallschritte", "desc": "Großer Schritt (rückwärts = knieschonend), hinteres Knie Richtung Boden. Anfangs ggf. festhalten.", "url": ""},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "desc": "Knie leicht gebeugt, Hüfte nach hinten, Rücken gerade, Last entlang der Beine senken bis Dehnung hinten (Hamstrings).", "url": ""},
{"name": "Glute Bridge / Wadenheben / Wall Sit", "desc": "Bridge: Hüfte hochdrücken (1-beinig steigern). Waden: Zehenspitzen hoch/runter. Wall Sit: Rücken an die Wand, halten.", "url": ""},
]},
{"group": "KETTLEBELL (Power / Griff / Ganzkörper)", "color": "KBCOL", "items": [
{"name": "Swing (zweiarmig, Hardstyle)", "desc": "<b>Wichtigste KB-Übung.</b> Hüfthinge, Glocke aus der Hüfte explosiv auf Brusthöhe schnappen (kein Armheben), oben Po+Bauch fest. Dead-Stop zum Lernen.", "url": "https://www.onepeloton.com/blog/kettlebell-swing/"},
{"name": "Goblet Squat (KB)", "desc": "Glocke an den Hörnern vor der Brust umarmen, tief und aufrecht hocken. Beste Einsteiger-KB-Kniebeuge.", "url": "https://www.onepeloton.com/blog/goblet-squat/"},
{"name": "KB-Deadlift (beidbeinig)", "desc": "Glocke zwischen den Füßen, Hüfte hinge, Rücken gerade, vom Boden heben und Hüfte durchstrecken. Stabile Swing-Vorbereitung.", "url": "https://www.onepeloton.com/blog/kettlebell-deadlift/"},
{"name": "KB Floor Press", "desc": "Rücklings am Boden, Glocke vom Boden weg nach oben drücken. Schulterschonend, gut für Anfänger.", "url": "https://www.masterclass.com/articles/kettlebell-floor-press-guide"},
{"name": "Halo", "desc": "Glocke (oder Rucksack) vor der Brust, langsam eng um den Kopf kreisen. Schulter-Mobility + Core.", "url": "https://www.tomsguide.com/how-to/kettlebell-halo-exercise-how-to-do-it-and-the-benefits-for-your-core-and-shoulder-muscles"},
{"name": "Bottoms-up KB-Hold / Carry", "desc": "Glocke am Griff, Boden nach oben, Unterarm senkrecht, still halten. Schwierigkeit kommt aus der Balance fordert <b>Griff + Handgelenk</b> auch mit leichter Glocke.", "url": "https://www.onepeloton.com/blog/farmers-carry/"},
{"name": "Around-the-World + Uppercut", "desc": "Glocke kreist um den Körper (Around-the-World); danach diagonal nach oben zur gegenüberliegenden Schulter/Brust führen, die andere Hand fängt ab (wie ein Uppercut von unten). Rotatorischer Core + Koordination. Erst nur das Umkreisen lernen, Uppercut später.", "url": ""},
]},
{"group": "CORE", "color": "ACC2", "items": [
{"name": "Plank / Side Plank", "desc": "Unterarmstütz, Körper gerade. Anfangs auf den Knien. Side Plank seitlich auf einem Unterarm.", "url": ""},
{"name": "Hollow Hold", "desc": "Auf dem Rücken, unterer Rücken am Boden, Arme+Beine leicht angehoben. Anfänger: angewinkelt (Tuck).", "url": "https://www.calixpert.com/exercises/hollow-body-to-superman-hold"},
{"name": "Dead Bug", "desc": "Auf dem Rücken, Arme+Knie 90°, gegengleich Arm+Bein langsam ausstrecken, unterer Rücken bleibt am Boden.", "url": "https://dieringe.com/exercises/dead-bug"},
{"name": "Bird Dog", "desc": "Vierfüßler, gegengleich Arm+Bein ausstrecken, Rumpf stabil, kurz halten.", "url": "https://www.hingehealth.com/resources/articles/bird-dog/"},
{"name": "Mountain Climbers / Bicycle / Russian Twist / Beinheben", "desc": "Climbers: Knie zügig zur Brust. Bicycle: Ellbogen zum Gegenknie. Twist: sitzend drehen (KB). Beinheben: Knie gebeugt = leichter.", "url": ""},
]},
{"group": "WARM-UP &amp; MOBILITY", "color": "ACC2", "items": [
{"name": "Inchworm", "desc": "Aus dem Stand vorbeugen, Hände nach vorne in den Stütz wandern, dann zurück.", "url": "https://www.onepeloton.com/blog/inchworm-exercise"},
{"name": "Jacks / High Knees / Kreisen / Cat-Cow", "desc": "Klassische dynamische Aufwärm-Bewegungen locker, zum Warmwerden.", "url": ""},
]},
],
"phases": {
"names": ["Phase 1 · Fundament", "Phase 2 · Aufbau", "Phase 3 · Steigerung", "Phase 4 · Festigen"],
"params": [
"Fokus: Technik (v.a. Swing als Dead-Stop). 23 Reps Reserve. Pausen bis ~60s. Leichte Glocke. Woche 1 mit 2 Sätzen pro Rotation.",
"Sauber bleiben, letzter Satz fordernd. Pausen ~4560s. Etwas schwerere Glocke / mehr Reps. 23 Sätze.",
"Fordernd, aber 1 Rep Reserve. Pausen ~45s. Schwerere Glocke / einarmig wo angegeben. 3 Sätze.",
"Fordernd &amp; sauber (NICHT bis zum Muskelversagen). Pausen ~45s. Ende Woche 8 leichter (Deload). 3 Sätze.",
],
"param_light": "Bewusst locker aktive Erholung. Saubere, leichte Wiederholungen, keine Belastung bis zum Limit.",
},
"stages": {
'ls': ['Inkline (hoch)', 'Inkline tiefer / Knie', 'Knie / Negativ', 'Negativ + erste Volle'],
'dips': ['Knie stark gebeugt, klein', 'Knie gebeugt, voll', 'Füße etwas weiter', 'Füße weiter weg'],
'pike': ['Pike Hold', 'Hold + kleine Senkung', 'Push-up Hände erhöht', 'Push-up Boden, klein'],
'lseng': ['Inkline (hoch)', 'Inkline mittel', 'Inkline tief', 'Knie-Liegestütze'],
'load': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'squat': ['Körpergewicht', 'Körpergewicht, mehr Reps', 'Goblet leicht', 'Goblet mittel'],
'tsquat': ['3s runter', '3s + Pause unten', 'Goblet 3s', 'Goblet 3s + Pause'],
'lunge': ['Körpergewicht (ggf. festhalten)', 'Körpergewicht', 'mehr Reps', '+ Last leicht'],
'bridge': ['beidbeinig', 'beidbeinig, 2s halten', 'einbeinig', 'einbeinig, 2s halten'],
'wall': ['1525 s', '2540 s', '4055 s', '5570 s'],
'calf': ['Körpergewicht', 'langsam', 'langsam, mehr Reps', '1-beinig'],
'sup': ['Superman Hold', 'Superman Hold länger', 'Reverse Snow Angels', 'Y-T-W Raises'],
'plank': ['auf Knien 1520 s', 'voll 2030 s', 'voll + Schulter-Taps', 'voll, länger'],
'mtn': ['moderat', 'moderat', 'zügig', 'zügig'],
'db': ['Arme ODER Beine einzeln', 'gegengleich', 'gegengleich + Pause', 'Beine weiter gestreckt'],
'lr': ['Knie gebeugt', 'Knie gebeugt, langsam', 'halb gestreckt', 'gestreckt'],
'holl': ['Tuck (Knie an)', 'Tuck, länger', 'halb gestreckt', 'halb/voll'],
'sp': ['auf Knien', 'auf Knien, länger', 'voll', 'voll, länger'],
'bic': ['langsam', 'moderat', 'moderat', 'zügig'],
'tw': ['ohne Gewicht', 'ohne Gewicht', 'KB leicht', 'KB'],
'swing': ['Dead-Stop, leicht (Technik)', 'Dead-Stop, mehr Reps', 'durchgehend, mittel', 'durchgehend, schwerer'],
'gobletkb': ['leicht, Tiefe lernen', 'leicht, mehr Reps', 'mittel', 'mittel, 3s runter'],
'press': ['leicht (Floor Press)', 'Floor Press mehr', 'Overhead leicht', 'Overhead mittel'],
'halo': ['leicht, langsam', 'leicht', 'mittel', 'mittel, langsam'],
'carry': ['im Stand 1015s', '1520s, ruhig', 'bis Schulterhöhe 15s', 'länger / ein paar Schritte'],
'kbdl': ['leicht, Technik', 'leicht, mehr Reps', 'mittel', 'schwerer, kontrolliert'],
'rowe': ['leicht', 'etwas mehr', 'mehr', 'fordernd'],
'row1': ['leicht', 'etwas mehr', 'einarmig leicht', 'einarmig mittel'],
'row2': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, Pause oben'],
'lraise': ['leicht (Technik)', 'leicht, mehr Reps', 'mittel', 'mittel, langsam'],
'atw': ['nur Around-the-World, leicht', 'Around-the-World, mehr Reps', '+ Uppercut, leicht', '+ Uppercut, flüssig'],
},
"prepost": {
"1": {"warmup": "Handflächen-Wippen · Armkreisen/Wand-Slides · Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (Brust/Schulter, je 3040s) · Trizeps (je 30s) · Seitheben/Lat-Seitdehnung (je 30s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Wade an der Wand (je 30s)"},
"2": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Cat-Cow · Hüftkreisen + Beinschwünge",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Lat-/Seitdehnung (je 30s) · Türrahmen (je 3040s) · BWS über die Couch (3040s) · <b>Cobra/Sphinx</b> (Bauch, 30s) · Unterarm (je 20s) · Knieender Hüftbeuger (je 30s)"},
"3": {"warmup": "Hüftkreisen + Beinschwünge · Knie-über-Zeh an der Wand · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "<b>Hamstring-Dehnung</b> (Couch, je 3040s) · Quad im Stand (je 30s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · <b>Cobra + liegende Wirbelsäulen-Rotation</b> (je 30s) · Wade an der Wand (je 30s)"},
"4": {"warmup": "Handflächen-Wippen · Handtuch-Dislocates (810×) · Armkreisen/Wand-Slides · Cat-Cow",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 3040s) · Trizeps (je 30s) · Lat-/Seitdehnung (je 30s) · BWS über die Couch (3040s) · Unterarm (je 20s)"},
"5": {"warmup": "Cat-Cow · Hüftkreisen + Beinschwünge · Armkreisen/Wand-Slides · Knie-über-Zeh an der Wand",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Türrahmen (je 30s) · Knieender Hüftbeuger (je 30s) · BWS über die Couch (3040s) · Figure-4/Taube (je 30s) · Wade an der Wand (je 30s)"},
"6": {"warmup": "Halo (leicht) · Hüftkreisen + Beinschwünge · Cat-Cow · Handflächen-Wippen",
"cooldown": "Ausschütteln + Gehen (6090s) · Box-Atmung (45 Runden)",
"stretch": "Hamstring-Dehnung (Couch, je 3040s) · Knieender Hüftbeuger (je 3040s) · Figure-4/Taube (je 3040s) · Unterarm (je 20s) · BWS über die Couch (3040s) · Lat-/Seitdehnung (je 30s)"},
},
"days": [
{"num": 1, "focus": "Drücken + Beine",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken, hochdrücken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken (Schultern)"},
{"name": "Seitheben (KB/Rucksack)", "key": "lraise", "cue": "Arme seitlich bis Schulterhöhe, kontrolliert"}],
"rotB": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "Ausfallschritt rückw.", "key": "lunge", "cue": "großer Schritt, Knie Richtung Boden"},
{"name": "Glute Bridge", "key": "bridge", "cue": "Hüfte hochdrücken, Po fest"},
{"name": "Wadenheben", "key": "calf", "cue": "auf Zehenspitzen hoch/runter"}]},
{"num": 2, "focus": "Ziehen + Core",
"rotA": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Superman / Y-T-W", "key": "sup", "cue": "bäuchlings Arme+Beine heben"}],
"rotB": [{"name": "Plank", "key": "plank", "cue": "gerade Linie halten"},
{"name": "Mountain Climbers", "key": "mtn", "cue": "Knie zügig zur Brust"},
{"name": "Dead Bug", "key": "db", "cue": "gegengleich, Rücken am Boden"},
{"name": "Beinheben", "key": "lr", "cue": "Beine heben/senken"}]},
{"num": 3, "focus": "Beine + Core",
"rotA": [{"name": "Goblet Squat (KB/Rucksack)", "key": "gobletkb", "cue": "Last umarmen, tief"},
{"name": "RDL / Hip Hinge (KB/Rucksack)", "key": "load", "cue": "Hüfte zurück, Rücken gerade, Hamstrings"},
{"name": "Glute Bridge 1-beinig", "key": "bridge", "cue": "ein Bein gestreckt"},
{"name": "Wall Sit", "key": "wall", "cue": "Rücken an die Wand, halten"}],
"rotB": [{"name": "Hollow Hold", "key": "holl", "cue": "unterer Rücken am Boden"},
{"name": "Side Plank (Wechsel)", "key": "sp", "cue": "seitlich auf einem Unterarm"},
{"name": "Bicycle Crunch", "key": "bic", "cue": "Ellbogen zum Gegenknie"},
{"name": "Russian Twist (KB)", "key": "tw", "cue": "sitzend Rumpf drehen"}]},
{"num": 4, "focus": "Oberkörper: Drücken + Ziehen",
"rotA": [{"name": "Liegestütze", "key": "ls", "cue": "Brust kontrolliert senken &amp; drücken"},
{"name": "Bank-Dips (Couch)", "key": "dips", "cue": "Po vor der Couch senken"},
{"name": "Pike", "key": "pike", "cue": "umgek. V, Kopf senken"},
{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"}],
"rotB": [{"name": "Einarm-Rudern ENG (KB)", "key": "rowe", "cue": "1 Arm, Ellbogen an den Rippen, Zug zur Hüfte · Lat · DG1 links / DG2 rechts"},
{"name": "Bizeps-Curls (KB/Rucksack)", "key": "load", "cue": "Oberarme fix, zu den Schultern"},
{"name": "Einarm-Rudern BREIT (KB)", "key": "row2", "cue": "1 Arm, Ellbogen ~45° auf, Zug zum Brustkorb · oberer Rücken · DG1 links / DG2 rechts"},
{"name": "Y-T-W / Superman", "key": "sup", "cue": "bäuchlings, Rücken"}]},
{"num": 5, "focus": "Ganzkörper LEICHT + Mobility",
"rotA": [{"name": "Kniebeuge (leicht)", "key": "", "cue": "locker, sauber"},
{"name": "Liegestütze (1 Stufe leichter)", "key": "", "cue": "leichtere Variante"},
{"name": "Rudern (leicht)", "key": "", "cue": "wenig Gewicht"},
{"name": "Mountain Climbers (locker)", "key": "", "cue": "ruhiges Tempo"}],
"rotB": [{"name": "Inchworm", "key": "", "cue": "in den Stütz wandern, zurück"},
{"name": "Cat-Cow", "key": "", "cue": "Rücken ründen/strecken"},
{"name": "Bird Dog", "key": "", "cue": "gegengleich Arm+Bein, kurz halten"},
{"name": "KB-Halo (leicht)", "key": "", "cue": "langsam um den Kopf kreisen"}]},
{"num": 6, "focus": "KETTLEBELL Ganzkörper",
"rotA": [{"name": "KB Swing (zweiarmig)", "key": "swing", "cue": "Hüft-Schnapp, kein Armheben"},
{"name": "Goblet Squat (KB)", "key": "gobletkb", "cue": "Glocke vor der Brust, tief"},
{"name": "KB-Deadlift (beidbeinig)", "key": "kbdl", "cue": "Glocke zwischen den Füßen, Hüfte hinge"},
{"name": "Halo", "key": "halo", "cue": "eng um den Kopf kreisen"}],
"rotB": [{"name": "KB Floor Press", "key": "press", "cue": "Glocke vom Boden weg drücken"},
{"name": "Einarmiges Rudern (KB)", "key": "row1", "cue": "Rücken gerade, zum Bauch"},
{"name": "Bottoms-up KB-Hold (Griff)", "key": "carry", "cue": "Glocke Boden nach oben, Unterarm senkrecht, still halten"},
{"name": "Around-the-World + Uppercut (KB)", "key": "atw", "cue": "Glocke um den Körper, dann diagonal zur Gegen-Schulter, andere Hand fängt"}]},
],
}
KB_NOTE = ("<b>Reine Kettlebell-Session.</b> Swing = Hüft-Schnapp, kein Armheben. "
"Glocke so wählen, dass alle Reps sauber sind.")
def _lighten(hexstr, w=0.90):
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#0F766E")
return colors.Color(c.red * (1 - w) + w, c.green * (1 - w) + w, c.blue * (1 - w) + w)
def migrate_config(cfg):
"""Führt alte Trainings-Configs in das neue, flexible Format über (idempotent).
Neu: phases.items[{name,params,weeks}], stages{key:{name,steps[]}},
days[{...,rotations:[{label,exercises[]}]}] statt rotA/rotB,
Tag-Farbe/Notiz/Badge statt fest verdrahtetem KB-Tag."""
cfg = copy.deepcopy(cfg or {})
meta = cfg.setdefault("meta", {})
days = cfg.get("days", []) or []
# phases: {names,params} -> {items:[{name,params,weeks}], param_light}
ph = cfg.get("phases", {}) or {}
if "items" not in ph:
names = ph.get("names", []) or []
params = ph.get("params", []) or []
n = max(len(names), len(params))
items = [{"name": names[i] if i < len(names) else ("Phase %d" % (i + 1)),
"params": params[i] if i < len(params) else "",
"weeks": 2} for i in range(n)]
cfg["phases"] = {"items": items, "param_light": ph.get("param_light", "")}
# Übungsnamen je Key sammeln (für Stufen-Namen)
name_by_key = {}
for d in days:
groups = [d.get("rotA", []) or [], d.get("rotB", []) or []]
for rot in d.get("rotations", []) or []:
groups.append(rot.get("exercises", []) or [])
for grp in groups:
for it in grp:
k = it.get("key")
if k and it.get("name") and k not in name_by_key:
name_by_key[k] = it["name"]
# stages: [..] -> {name, steps:[..], result_schemas:[..]}
# ``result_schemas`` liegt parallel zu ``steps``. Ein Eintrag kann ``None``
# sein und bedeutet dann: Übungsstandard bzw. Tracker-Fallback verwenden.
newstages = {}
for key, val in (cfg.get("stages", {}) or {}).items():
if isinstance(val, dict) and "steps" in val:
stage = copy.deepcopy(val)
steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
schemas = list(schemas[:len(steps)])
while len(schemas) < len(steps):
schemas.append(None)
stage["steps"] = steps
stage["result_schemas"] = schemas
newstages[key] = stage
else:
steps = val if isinstance(val, list) else []
nm = name_by_key.get(key) or next((s for s in steps if s), "") or key
newstages[key] = {"name": nm, "steps": steps, "result_schemas": [None] * len(steps)}
cfg["stages"] = newstages
# Explizites Trainingsformat. Alte Pläne bleiben auf ``auto`` und werden
# vom Tracker weiterhin aus den Plantexten erkannt. Neue Pläne können
# Tabata/Intervalle damit ohne Text-Heuristik eindeutig beschreiben.
fmt = cfg.get("training_format")
if not isinstance(fmt, dict):
fmt = {}
mode = str(fmt.get("mode") or "auto")
if mode not in {"auto", "sets_reps", "tabata", "fixed_interval"}:
mode = "auto"
normalized_fmt = {"mode": mode}
for field in ("work_seconds", "rest_seconds", "rounds"):
try:
value = int(fmt.get(field))
except (TypeError, ValueError):
value = 0
normalized_fmt[field] = value if value > 0 else None
normalized_fmt["fixed_interval"] = bool(
fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"}
)
normalized_fmt["rounds_scope"] = "block"
cfg["training_format"] = normalized_fmt
# days: rotA/rotB -> rotations (+ KB-Styling & light-Tag nur bei Alt-Konvertierung)
for d in days:
if "rotations" not in d:
rots = []
if d.get("rotA"):
rots.append({"label": "ROTATION A — 3 Sätze", "exercises": d.get("rotA")})
if d.get("rotB"):
rots.append({"label": "ROTATION B — 3 Sätze", "exercises": d.get("rotB")})
d["rotations"] = rots
d.pop("rotA", None)
d.pop("rotB", None)
if d.get("num") == 6:
d.setdefault("color", "#6D28D9")
d.setdefault("note", KB_NOTE)
d.setdefault("note_bg", "#F1ECFB")
if d.get("num") == 5:
d.setdefault("light", True)
cfg["days"] = days
meta.setdefault("days", len(days))
return migrate_training_config(cfg)
def default_config():
return migrate_config(copy.deepcopy(DEFAULTS))
# =========================================================================
# RENDERER
# =========================================================================
def build_pdf(cfg):
"""cfg: Trainings-Config (altes oder neues Format). Gibt PDF-Bytes zurück."""
_D = DEFAULTS
cfg = dict(cfg or {})
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
cols = dict(_D["colors"]); cols.update(cfg.get("colors") or {})
cfg["meta"], cfg["colors"] = meta, cols
for k in ("front", "library", "phases", "stages", "prepost", "days"):
if not cfg.get(k):
cfg[k] = copy.deepcopy(_D[k])
cfg = migrate_config(cfg) # auf neues Modell normalisieren
C = {k: colors.HexColor(v) for k, v in cfg["colors"].items()}
INK, ACCENT, ACC2, KBCOL = C["INK"], C["ACCENT"], C["ACC2"], C["KBCOL"]
LINK, LIGHT, GRID, MUTE = C["LINK"], C["LIGHT"], C["GRID"], C["MUTE"]
DARK, WARMBG, GOALBG, KBBG = C["DARK"], C["WARMBG"], C["GOALBG"], C["KBBG"]
link_hex = cfg["colors"]["LINK"]
styles = getSampleStyleSheet()
def Sty(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
title_s = Sty("title", fontName="Helvetica-Bold", fontSize=23, textColor=INK, leading=26, spaceAfter=2)
sub_s = Sty("sub", fontName="Helvetica", fontSize=10.5, textColor=MUTE, leading=14, spaceAfter=2)
h2_s = Sty("h2", fontName="Helvetica-Bold", fontSize=14, textColor=ACCENT, leading=17, spaceBefore=13, spaceAfter=5)
body_s = Sty("body", fontName="Helvetica", fontSize=9.6, textColor=INK, leading=13.5, spaceAfter=4)
smallm_s = Sty("smallm", fontName="Helvetica", fontSize=8.2, textColor=MUTE, leading=10.5)
cell_s = Sty("cell", fontName="Helvetica", fontSize=8.3, textColor=INK, leading=10.6)
cellb_s = Sty("cellb", fontName="Helvetica-Bold", fontSize=8.3, textColor=INK, leading=10.6)
link_s = Sty("link", fontName="Helvetica", fontSize=8.2, textColor=LINK, leading=10.6)
th_s = Sty("th", fontName="Helvetica-Bold", fontSize=8.6, textColor=colors.white, leading=11)
tag_s = Sty("tag", fontName="Helvetica-Bold", fontSize=8.4, textColor=colors.white, leading=11)
note_s = Sty("note", fontName="Helvetica", fontSize=9, textColor=INK, leading=13)
goal_s = Sty("goal", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
kb_s = Sty("kb", fontName="Helvetica", fontSize=8.9, textColor=INK, leading=12.5)
bandL = Sty("bandL", fontName="Helvetica-Bold", fontSize=17, textColor=colors.white, leading=19)
bandR = Sty("bandR", fontName="Helvetica-Bold", fontSize=11.5, textColor=colors.white, leading=14, alignment=TA_RIGHT)
howto = Sty("howto", fontName="Helvetica", fontSize=8.6, textColor=MUTE, leading=11.5, spaceBefore=4, spaceAfter=2)
warm = Sty("warm", fontName="Helvetica", fontSize=9, textColor=INK, leading=12.5)
roth = Sty("roth", fontName="Helvetica-Bold", fontSize=11, textColor=colors.white, leading=13)
exn = Sty("exn", fontName="Helvetica-Bold", fontSize=9.6, textColor=INK, leading=11.5)
stage_s = Sty("stage", fontName="Helvetica-Bold", fontSize=9, textColor=ACCENT, leading=11)
param_s = Sty("param", fontName="Helvetica", fontSize=8.8, textColor=INK, leading=12)
logst = Sty("logst", fontName="Helvetica", fontSize=9.5, textColor=INK, leading=18)
USABLE = A4[0] - 2 * 1.3 * cm
def link(url, label="Anleitung (Bilder/Video)"):
return Paragraph('<a href="%s" color="%s"><u>%s</u></a>' % (url, link_hex, label), link_s)
front = cfg["front"]
story = []
# ===================== FRONT MATTER =====================
story.append(Paragraph(cfg["meta"]["title"], title_s))
story.append(Paragraph(cfg["meta"]["subtitle"], sub_s))
story.append(HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=6, spaceAfter=6))
story.append(Paragraph(front["session_how_head"], h2_s))
story.append(Paragraph(front["session_how_body"], body_s))
flow = front["flow_rows"]
fr = [[Paragraph(c, th_s if i == 0 else (cellb_s if (j == 1 and r[0] == '') else cell_s)) for j, c in enumerate(r)] for i, r in enumerate(flow)]
t = Table(fr, colWidths=[3.2 * cm, 9.6 * cm, 2.7 * cm])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(t)
story.append(Spacer(1, 4))
story.append(Paragraph(front["timer_note"], smallm_s))
# KB evidence box
story.append(Paragraph(front["kb_head"], h2_s))
kb_rows = [[Paragraph(front["kb_box"], kb_s)]]
gt = Table(kb_rows, colWidths=[USABLE])
gt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), KBBG), ("BOX", (0, 0), (-1, -1), 0.8, KBCOL),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt)
story.append(Paragraph(front["goals_head"], h2_s))
goal_rows = [[Paragraph(front["goals_box"], goal_s)]]
gt2 = Table(goal_rows, colWidths=[USABLE])
gt2.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), GOALBG), ("BOX", (0, 0), (-1, -1), 0.8, ACCENT),
("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
story.append(gt2)
story.append(Spacer(1, 5))
story.append(Paragraph(front["goals_note"], note_s))
story.append(PageBreak())
# ---- exercise library ----
story.append(Paragraph(front["lib_head"], h2_s))
story.append(Paragraph(front["lib_intro"], smallm_s))
for grp in cfg["library"]:
gcol = C.get(grp["color"], ACC2)
rows = [[Paragraph(grp["group"], tag_s), "", ""],
[Paragraph("Übung", th_s), Paragraph("Ausführung (Kurz)", th_s), Paragraph("Anleitung", th_s)]]
for it in grp["items"]:
rows.append([Paragraph(it["name"], cellb_s), Paragraph(it["desc"], cell_s),
link(it["url"]) if it.get("url") else Paragraph("Standard-Bewegung", smallm_s)])
tstyle = [("SPAN", (0, 0), (-1, 0)), ("BACKGROUND", (0, 0), (-1, 0), gcol), ("BACKGROUND", (0, 1), (-1, 1), DARK),
("ROWBACKGROUNDS", (0, 2), (-1, -1), [colors.white, LIGHT]), ("GRID", (0, 1), (-1, -1), 0.5, GRID),
("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6)]
story.append(KeepTogether(Table(rows, colWidths=[4.0 * cm, 8.9 * cm, 3.5 * cm], style=TableStyle(tstyle))))
story.append(Spacer(1, 6))
story.append(PageBreak())
# ---- baseline + reminders ----
story.append(Paragraph(front["baseline_head"], h2_s))
story.append(Paragraph(front["baseline_intro"], body_s))
track = front["baseline_rows"]
tr = [[Paragraph(c, th_s if i == 0 else cell_s) for c in r] for i, r in enumerate(track)]
tt = Table(tr, colWidths=[9.5 * cm, 3.0 * cm, 3.0 * cm])
tt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.5, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 6)]))
story.append(tt)
story.append(Spacer(1, 8))
story.append(Paragraph(front["reminder_head"], h2_s))
for r in front["reminders"]:
story.append(Paragraph("•  " + r, body_s))
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", thickness=1, color=GRID))
story.append(Paragraph(front["sheets_note"], smallm_s))
story.append(PageBreak())
# ===================== SESSION SHEETS =====================
stages = cfg["stages"]
phase_items = cfg["phases"]["items"]
PARAM_LIGHT = cfg["phases"].get("param_light", "")
prepost = cfg["prepost"]
def phase_index(week):
acc = 0
for i, ph in enumerate(phase_items):
try:
acc += int(ph.get("weeks", 2) or 0)
except Exception:
acc += 2
if week <= acc:
return i
return max(0, len(phase_items) - 1)
def phase_name(pi):
return phase_items[pi]["name"] if 0 <= pi < len(phase_items) else ""
def phase_params(pi):
return phase_items[pi].get("params", "") if 0 <= pi < len(phase_items) else ""
def stage_text(key, pi):
st = stages.get(key) if key else None
if st:
steps = st.get("steps", []) if isinstance(st, dict) else st
if 0 <= pi < len(steps) and steps[pi]:
step = steps[pi]
return step.get("name", "") if isinstance(step, dict) else step
return "leicht"
def rotation_table(rot, pi):
rows = [[Paragraph("Übung", th_s), Paragraph("Stufe heute", th_s),
Paragraph("Satz 1", th_s), Paragraph("Satz 2", th_s), Paragraph("Satz 3", th_s)]]
for item in rot:
name, key, cue = item.get("name", ""), item.get("key") or None, item.get("cue", "")
ex = Paragraph("%s<br/><font size=7.6 color='#6B6256'>%s</font>" % (name, cue), exn)
sc = Paragraph(stage_text(key, pi), stage_s)
rows.append([ex, sc, "", "", ""])
tb = Table(rows, colWidths=[7.2 * cm, 3.5 * cm, 1.95 * cm, 1.95 * cm, 1.95 * cm])
tb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), DARK), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT]),
("GRID", (0, 0), (-1, -1), 0.6, GRID), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 1), (-1, -1), 8), ("BOTTOMPADDING", (0, 1), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, 0), 4), ("BOTTOMPADDING", (0, 0), (-1, 0), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("ALIGN", (2, 0), (-1, -1), "CENTER")]))
return tb
def day_has_exercises(day):
return any((rot.get("exercises") or []) for rot in (day.get("rotations") or []))
def session_page(week, day):
num, focus = day.get("num"), day.get("focus", "")
pi = phase_index(week)
color = day.get("color")
band_col = colors.HexColor(color) if color else ACCENT
rot_col = colors.HexColor(color) if color else ACC2
pp = prepost.get(str(num), {"warmup": "", "cooldown": "", "stretch": ""})
pre_txt = pp.get("warmup", ""); cd_txt = pp.get("cooldown", ""); stretch_txt = pp.get("stretch", "")
flow = []
badge = day.get("badge") or {}
btext = badge.get("text", "").strip()
left_html = "WOCHE %d · TAG %d" % (week, num)
if btext:
left_html += " <font size=8 color='white' backColor='%s'> %s </font>" % (badge.get("color", "#6D28D9"), btext)
band = Table([[Paragraph(left_html, bandL),
Paragraph("%s<br/><font size=8 color='#EAD9F7'>%s</font>" % (focus, phase_name(pi)), bandR)]],
colWidths=[USABLE * 0.5, USABLE * 0.5])
band.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), band_col), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LEFTPADDING", (0, 0), (0, 0), 12), ("RIGHTPADDING", (-1, 0), (-1, 0), 12)]))
flow.append(band)
flow.append(Paragraph("Tabata: 20s an / 10s aus · pro Block 4 Übungen × 2 Durchgänge = 1 Satz · 10s Vorlauf", howto))
wb = Table([[Paragraph("<b>WARM-UP · Gelenk-Prep</b> (dynamisch, ~3 Min, kein Halten): " + pre_txt, warm)]], colWidths=[USABLE])
wb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(Spacer(1, 5))
flow.append(wb)
note = (day.get("note") or "").strip()
if note:
nb = colors.HexColor(day["note_bg"]) if day.get("note_bg") else _lighten(color or "#0F766E")
nborder = colors.HexColor(color) if color else ACC2
flow.append(Spacer(1, 4))
nt = Table([[Paragraph(note, warm)]], colWidths=[USABLE])
nt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), nb), ("BOX", (0, 0), (-1, -1), 0.6, nborder),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(nt)
flow.append(Spacer(1, 8))
for rot in (day.get("rotations") or []):
exs = rot.get("exercises") or []
if not exs:
continue
rh = Table([[Paragraph(rot.get("label", ""), roth)]], colWidths=[USABLE])
rh.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), rot_col), ("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("LEFTPADDING", (0, 0), (-1, -1), 8)]))
flow.append(rh)
flow.append(Spacer(1, 2))
flow.append(rotation_table(exs, pi))
flow.append(Spacer(1, 8))
pbp = day.get("params_by_phase") or []
if 0 <= pi < len(pbp) and (pbp[pi] or "").strip():
ptxt = pbp[pi]
else:
ptxt = PARAM_LIGHT if day.get("light") else phase_params(pi)
pb = Table([[Paragraph(ptxt, param_s)]], colWidths=[USABLE])
pb.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), LIGHT), ("BOX", (0, 0), (-1, -1), 0.6, GRID),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(pb)
flow.append(Spacer(1, 7))
cd = Table([[Paragraph("<b>COOL-DOWN</b> (~12 Min): " + cd_txt +
"<br/><b>DEHNEN</b> (warm &amp; statisch, bis mildes Ziehen, ~46 Min): " + stretch_txt, warm)]], colWidths=[USABLE])
cd.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), WARMBG), ("BOX", (0, 0), (-1, -1), 0.6, ACC2),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9)]))
flow.append(cd)
flow.append(Spacer(1, 7))
flow.append(Paragraph("Gefühl heute (110): ______ Glocke (kg): ______ Notizen: ________________________________", logst))
return flow
weeks = int(cfg["meta"].get("weeks", 8) or 0)
for week in range(1, weeks + 1):
for day in cfg["days"]:
if not day_has_exercises(day):
continue
story += session_page(week, day)
story.append(PageBreak())
if story and isinstance(story[-1], PageBreak):
story.pop()
footer_left = cfg["meta"]["footer_left"]
def footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(MUTE)
canvas.drawString(1.3 * cm, 1.0 * cm, footer_left)
canvas.drawRightString(A4[0] - 1.3 * cm, 1.0 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=1.3 * cm, rightMargin=1.3 * cm,
topMargin=1.2 * cm, bottomMargin=1.4 * cm, title=cfg["meta"]["doc_title"])
doc.build(story, onFirstPage=footer, onLaterPages=footer)
return buf.getvalue()
if __name__ == "__main__":
data = build_pdf(default_config())
with open("/tmp/test_plan.pdf", "wb") as f:
f.write(data)
print("OK", len(data), "bytes")
+11 -17
View File
@@ -1,30 +1,24 @@
{ {
"id": "trainingsplan", "id": "trainingsplan",
"name": "Trainingsplan- & Rezept-Editor", "name": "Trainingsplan-Backend",
"summary": "Versionierte Trainingspläne mit stabilen IDs, Validierung und Tracker-Vorschau bearbeiten", "summary": "Schlankes Backend zum Anlegen von Trainingsplänen, Trainingstagen und Übungen",
"description": "Editor mit Entwurf und Veröffentlichung, stabilem Planvertrag, progressionsabhängigen Ergebnisschemata, planbezogener Übungsbibliothek, Konfliktschutz und strukturierten Vorschlägen aus dem Session-Tracker.", "description": "Eintragebackend für Trainingspläne mit zentraler Übungsbibliothek, Trainingstagen, Warm-up, Cool-down und Tagesübungen.",
"icon": "🏋", "icon": "🏋",
"category": "Planung", "category": "Planung",
"version": "2.0.0", "version": "3.0.0",
"entrypoint": "backend:create_app", "entrypoint": "backend:create_app",
"order": 10, "order": 10,
"requires": [], "requires": [],
"contract": { "contract": {
"plan_schema": 3, "plan_schema": 4,
"training_contract": 2, "training_contract": 3
"result_schema": 2
}, },
"features": [ "features": [
"Entwurf und veröffentlichte Planrevision sind getrennt", "Pläne anlegen, umbenennen, auswählen und löschen",
"Stabile IDs für Phasen, Tage, Rotationen, Übungen und Progressionsstufen", "Übungen in einer zentralen Bibliothek verwalten",
"Progressionsstufen als Objekte mit Ergebnisschema, Cluster und Faktor", "Trainingstage mit Warm-up und Cool-down verwalten",
"Planbezogene Übungsbibliothek für den Tracker", "Übungen an Trainingstagen hinterlegen",
"Validierung von IDs, Schemata und festen Intervallen", "Atomisches Speichern mit Backups und Revisionskonfliktschutz"
"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" "docs": "TOOL-README.md"
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-530
View File
@@ -1,530 +0,0 @@
{
"name": "Granola (Vorrat)",
"config": {
"type": "recipe",
"meta": {
"title": "Die Granola-Bibel",
"header_left": "GRANOLA · Vorrats-Edition · Begleitend zum 8-Wochen-Plan",
"footer_left": "Topping statt Schüssel · Portion abwiegen · Zartbitter statt Zucker",
"output_name": "Granola_Vorrat.pdf",
"doc_title": "Die Granola-Bibel",
"brand_color": "#6D28D9",
"steps_label": "SO GEHT'S"
},
"intro": {
"headline": "Die Granola-Bibel",
"subtitle": "28 Rezepte mit exakten Arbeitsschritten deutlich weniger Zucker als jedes Fertig-Granola, Nährwerte",
"paragraphs": [
"pro Portion als Richtwert.",
"Gekauftes Granola ist meist eine als Müsli getarnte Süßigkeit 2030 % Zucker sind normal. Diese Rezepte",
"drehen das um: Hafer, Nüsse, Kerne und Buchweizen tragen, gesüßt wird sparsam und bewusst. Das ist genau",
"dein Substitutions-Hebel: Granola ist hier ein Topping für Skyr oder Quark, kein Schüssel-Füller. Eine",
"Eiweiß-Basis plus Crunch schlägt abends auf der Couch die Schoko-Chips-Cola-Kombi nicht durch Verzicht,",
"sondern durch einen besseren Reiz. Ein Wort zur Ehrlichkeit: Granola bleibt kaloriendicht. Der Hebel ist die",
"Portion, nicht die Zutat. Wieg dir 4050 g ab und stell das Glas weg aus dem Glas gelöffelt wird jedes Granola",
"zur Falle. Die Nährwerte unter NÄHRWERTE ca. sind Richtwerte pro Portion (≈50 g), keine Zielvorgabe.",
"Die Grund-Technik gilt für fast jedes Rezept",
"1 Trocken mischen: Hafer, grob gehackte Nüsse, Kerne, Gewürze, ggf. Kakao/Proteinpulver in einer großen",
"Schüssel verrühren.",
"2 Bindung erwärmen: Öl bzw. Nussmus mit Süße und Aroma kurz schmelzen, bis es dünnflüssig ist so",
"verteilt es sich gleichmäßig.",
"3 Benetzen: über das Trockene gießen und gründlich mischen, bis alles glänzt. Trockene Nester = weiche",
"Stellen später.",
"4 Cluster-Trick: für feste Brocken 1 Eiweiß schaumig schlagen und unterheben, Masse fest aufs Blech",
"drücken und beim Backen nicht rühren.",
"5 Backen: meist 150160 °C Umluft. Niedrig und länger röstet gleichmäßig; ohne Cluster einmal wenden, mit",
"Cluster in Ruhe lassen.",
"6 Auskühlen = knusprig: Granola ist frisch aus dem Ofen noch weich. Erst vollständig auf dem Blech",
"auskühlen lassen, dann brechen.",
"7 Empfindliches später: Schokolade, Trockenfrüchte und Kokoschips erst nach dem Abkühlen zugeben, sonst",
"verbrennen sie.",
"8 Lagern: luftdicht, kühl und dunkel 46 Wochen. Nicht warm einfüllen, sonst schwitzt es und wird zäh."
],
"rule_head": "",
"rule_text": "",
"categories_head": "7 Kategorien"
},
"categories": [
{
"name": "Schoko & Kakao",
"color": "#7C3AED"
},
{
"name": "Nuss, Kern & Klassik",
"color": "#0EA5E9"
},
{
"name": "Frucht & Beere",
"color": "#EA580C"
},
{
"name": "Gewürz & Saison",
"color": "#16A34A"
},
{
"name": "Protein-Boost & Post-Workout",
"color": "#DC2626"
},
{
"name": "Grain-free & Low-Carb",
"color": "#DB2777"
},
{
"name": "Ohne Ofen & Overnight",
"color": "#B45309"
}
],
"recipes": [
{
"num": "01",
"category": "Schoko & Kakao",
"title": "Malz-Schoko-Buchweizen-Granola",
"tagline": "Die Referenz nach Anna Jones zuckerreduziert adaptiert.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 24 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken (kernig) · 100 g roher Buchweizen · 80 g Haselnüsse grob · 40 g Kakaonibs · 30 g Backkakao · 1 EL Gerstenmalz-Extrakt · 2 EL Ahornsirup · 3 EL Kokosöl · 1 Prise Meersalz · 60 g Zartbitter 85 % gehackt (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft vorheizen, Blech mit Backpapier.\n2 Hafer, Buchweizen, Haselnüsse, Kakaonibs, Kakao und Salz in einer großen Schüssel gut vermischen.\n3 Kokosöl, Malz-Extrakt und Ahornsirup in einem Töpfchen bei kleiner Hitze schmelzen und glatt rühren.\n4 Flüssige Mischung über das Trockene gießen und gründlich wenden, bis alles dunkel glänzt.\n5 Gleichmäßig aufs Blech verteilen und leicht andrücken.\n6 2530 Min backen, nach der Hälfte einmal wenden Kakao verbrennt schnell, lieber kürzer.\n7 Vollständig auf dem Blech auskühlen lassen, dann die gehackte Zartbitter untermischen.",
"variations": [
"Nuss-Tausch Mandeln oder Pekannüsse statt Haselnüssen",
"Ohne Malz Malz weglassen, dafür 1 TL Instant-Kaffee für Tiefe"
],
"tip": "Coach-Tipp Kakaonibs bringen Schoko-Bitterkeit fast ohne Zucker der Trick, um Vollmilch-Schoko-Gelüste auf der Couch abzufangen.",
"badge": "",
"yield": "ergibt ca. 800 g · ~16 Portionen à 50 g"
},
{
"num": "02",
"category": "Schoko & Kakao",
"title": "Tiramisu-Granola",
"tagline": "Nach The Doctor's Kitchen Kaffee, Kakao, feste Cluster.",
"nutrition": "ca. ≈ 245 kcal · Eiweiß 8 g · Fett 15 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 20 g Backkakao · 1 TL Zimt · 2 EL Mandelmus · 2 EL Honig · 3 EL Kokosöl · 60 ml starker Espresso (abgekühlt) · 1 TL Vanille · 1 Eiweiß · Kakao zum Bestäuben",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbis- und Sonnenblumenkerne, Leinsamen, Kakao und Zimt vermischen.\n3 Mandelmus, Honig, Kokosöl und Espresso leicht erwärmen und glatt rühren, Vanille einrühren.\n4 Über das Trockene gießen und gründlich mischen.\n5 Eiweiß schaumig schlagen und unterheben das gibt die Tiramisu-typischen Cluster.\n6 Masse fest aufs Blech drücken (ca. 1,5 cm dick), nicht verteilen.\n7 3035 Min backen, dabei NICHT rühren, damit die Brocken halten.\n8 Auskühlen lassen, in Brocken brechen und mit etwas Kakao bestäuben.",
"variations": [
"Entkoffeiniert Espresso durch Lupinen- oder Malzkaffee ersetzen",
"Nüsse pur Kakao weglassen für reine Cappuccino-Note"
],
"tip": "Coach-Tipp Kaffee + Kakao schmecken „Dessert“ ohne Süße-Bombe. Über Skyr wird das dein Abend-Dessert-Ersatz.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "03",
"category": "Schoko & Kakao",
"title": "Doppel-Kakao-Haselnuss",
"tagline": "Die „Nutella“-Idee als knuspriges Topping.",
"nutrition": "ca. ≈ 252 kcal · Eiweiß 8 g · Fett 16 g · KH 20 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 120 g Haselnüsse grob · 30 g Backkakao · 2 EL Haselnussmus · 2 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz · 40 g Kakaonibs (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Kakao und Salz mischen.\n3 Haselnussmus, Kokosöl und Ahornsirup schmelzen und glatt rühren.\n4 Über das Trockene gießen und mischen, bis alles dunkel und gleichmäßig ist.\n5 Aufs Blech, leicht andrücken.\n6 25 Min backen, nach der Hälfte wenden wegen Kakao auf die Farbe achten.\n7 Auskühlen lassen, Kakaonibs untermischen.",
"variations": [
"Extra Crunch 1 EL Buchweizen mitrösten",
"Milder Kakao halbieren, 1 TL Vanille zugeben"
],
"tip": "Coach-Tipp Haselnuss + Kakao trifft exakt den Nutella-Reiz aber mit Ballaststoffen statt Palmöl und Zucker.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "04",
"category": "Schoko & Kakao",
"title": "Schoko-Meersalz-Cluster",
"tagline": "Feste Brocken, salzig-bitter, sehr gering gesüßt.",
"nutrition": "ca. ≈ 248 kcal · Eiweiß 8 g · Fett 15 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 25 g Backkakao · 2 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · grobes Meersalz · 60 g Zartbitter 85 % (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, mit unterheben gibt die Cluster-Bindung.\n5 Fest aufs Blech drücken, mit etwas grobem Meersalz bestreuen.\n6 30 Min backen, NICHT rühren.\n7 Vollständig auskühlen lassen, in Brocken brechen, gehackte Zartbitter untermischen.",
"variations": [
"Orange Abrieb 1/2 Bio-Orange in die Bindung",
"Chili 1 Prise Chili für Schoko-Chili-Kick"
],
"tip": "Coach-Tipp Salz + 85 %-Schoko: der intensivste Schoko-Hit pro Gramm. Kleine Menge sättigt den Gusto komplett.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "05",
"category": "Schoko & Kakao",
"title": "Mokka-Walnuss",
"tagline": "Espresso trifft Röstaroma, kaum Süße.",
"nutrition": "ca. ≈ 250 kcal · Eiweiß 8 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 100 g Walnüsse grob · 40 g Leinsamen · 15 g Backkakao · 1 TL Instant-Espresso · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Leinsamen, Kakao, Espressopulver und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, leicht andrücken.\n6 2530 Min backen, einmal wenden.\n7 Auskühlen lassen wird erst kalt knusprig.",
"variations": [
"Pekan Pekannüsse statt Walnüssen",
"Stärker 2 TL Espressopulver für echten Mokka-Punch"
],
"tip": "Coach-Tipp Walnüsse liefern Omega-3 passt gut in die Regenerationswochen mit viel Volumen.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "06",
"category": "Nuss, Kern & Klassik",
"title": "Klassik pur · Nuss-Kern-Granola",
"tagline": "Die Basis. Minimaler Zucker, maximal variierbar.",
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 22 g · Zucker 4 g · Ballaststoffe 5 g",
"ingredients": "300 g Haferflocken · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 40 g Sonnenblumenkerne · 2 EL Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Alle trockenen Zutaten mit Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren, bis alles glänzt.\n4 Aufs Blech, gleichmäßig dünn verteilen.\n5 2530 Min backen, nach der Hälfte wenden, bis goldbraun.\n6 Auf dem Blech auskühlen lassen.",
"variations": [
"Frucht nach dem Backen 40 g Trockenfrüchte untermischen",
"Nüsse pur Kerne durch mehr Nüsse ersetzen für weniger KH",
"Vanille 1 TL Vanille in die Bindung"
],
"tip": "Coach-Tipp Lern dieses Rezept auswendig alle anderen sind nur Varianten davon. Sparsame Süße ist Absicht.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "07",
"category": "Nuss, Kern & Klassik",
"title": "Erdnuss-Protein-Cluster",
"tagline": "Post-Workout-tauglich, feste Brocken, extra Eiweiß.",
"nutrition": "ca. ≈ 258 kcal · Eiweiß 12 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 60 g Erdnüsse grob · 40 g Sonnenblumenkerne · 30 g Whey oder Erbsenprotein (neutral/vanille) · 3 EL Erdnussmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Erdnüsse, Kerne und Proteinpulver mischen (Pulver gut unterheben, keine Klumpen).\n3 Erdnussmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen und unterheben.\n5 Fest aufs Blech drücken.\n6 2832 Min bei niedriger Hitze backen, NICHT rühren Protein bräunt schneller, Farbe im Auge behalten.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko 20 g Kakao ins Trockene",
"Casein-Abend Casein statt Whey für langsame Abend-Variante"
],
"tip": "Coach-Tipp Proteinpulver bräunt früher lieber 150 °C und Sichtkontrolle. Über Skyr kommst du locker auf 2530 g Eiweiß.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "08",
"category": "Nuss, Kern & Klassik",
"title": "Mandel-Vanille",
"tagline": "Schlicht, hell, alltagstauglich.",
"nutrition": "ca. ≈ 240 kcal · Eiweiß 7 g · Fett 14 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "300 g Haferflocken · 120 g Mandeln grob · 40 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 2 TL Vanille · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Leinsamen und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, Vanille einrühren, über das Trockene gießen.\n4 Gut mischen, aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Mandel-Zimt 1 TL Zimt zugeben",
"Kirsch nach dem Backen getrocknete Sauerkirschen unter)"
],
"tip": "Coach-Tipp Die neutralste Basis perfekt, wenn der Rest der Schüssel (Beeren, Quark) den Geschmack trägt.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "09",
"category": "Nuss, Kern & Klassik",
"title": "Kokos-Cashew",
"tagline": "Cremig-tropisch, leicht süß von allein.",
"nutrition": "ca. ≈ 255 kcal · Eiweiß 7 g · Fett 16 g · KH 21 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "260 g Haferflocken · 100 g Cashews grob · 50 g Kokoschips · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne und Salz mischen Kokoschips noch NICHT zugeben.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 20 Min backen, dann wenden und die Kokoschips untermischen.\n6 Weitere 58 Min backen, bis die Kokoschips goldgelb sind sie verbrennen schnell.\n7 Auskühlen lassen.",
"variations": [
"Schoko-Kokos 40 g Zartbitter nach dem Backen",
"Limette Abrieb 1 Limette in die Bindung"
],
"tip": "Coach-Tipp Kokoschips zum Schluss zugeben ist Pflicht sonst sind sie schwarz, bevor der Rest fertig ist.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "10",
"category": "Nuss, Kern & Klassik",
"title": "Tahin-Sesam-Halva",
"tagline": "Nährig, orientalisch, unerwartet gut.",
"nutrition": "ca. ≈ 252 kcal · Eiweiß 9 g · Fett 16 g · KH 19 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 80 g Mandeln grob · 40 g Sesam · 40 g Sonnenblumenkerne · 3 EL Tahin · 2 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Sesam, Kerne und Salz mischen.\n3 Tahin, Kokosöl und Honig leicht erwärmen und glatt rühren.\n4 Über das Trockene gießen, gut mischen.\n5 Aufs Blech, andrücken.\n6 2226 Min backen, einmal wenden Sesam bräunt schnell.\n7 Auskühlen lassen.",
"variations": [
"Schoko 30 g Kakaonibs nach dem Backen",
"Kardamom 1/2 TL gemahlener Kardamom in die Bindung"
],
"tip": "Coach-Tipp Tahin bringt Kalzium und einen leicht bitteren Halva-Ton sättigt richtig, kleine Portion reicht.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "11",
"category": "Nuss, Kern & Klassik",
"title": "Ahorn-Pekan",
"tagline": "Der Klassiker hier bewusst sparsam gesüßt.",
"nutrition": "ca. ≈ 246 kcal · Eiweiß 6 g · Fett 15 g · KH 22 g · Zucker 7 g · Ballaststoffe 4 g",
"ingredients": "290 g Haferflocken · 110 g Pekannüsse grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 3 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen die Karamell-Note kommt erst kalt.",
"variations": [
"Ahorn-Apfel nach dem Backen getrocknete Apfelstücke unter",
"Weniger süß nur 2 EL Ahornsirup"
],
"tip": "Coach-Tipp Das klassische Frühstücks-Granola aber mit 7 g Zucker statt der üblichen 1520 g im Handel.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "12",
"category": "Frucht & Beere",
"title": "Beeren-Chia",
"tagline": "Säuerlich-fruchtig, Beeren als frisches Topping.",
"nutrition": "ca. ≈ 232 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 7 g · Ballaststoffe 6 g",
"ingredients": "280 g Haferflocken · 80 g Mandeln grob · 30 g Chiasamen · 30 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 40 g gefriergetrocknete Beeren (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Chia, Kerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann die gefriergetrockneten Beeren untermischen.\n7 Frische oder TK-Beeren erst in der Schüssel dazugeben, nicht ins Vorratsglas.",
"variations": [
"Himbeer-Kakao 20 g Kakao + gefriergetrocknete Himbeeren",
"Frisch in der Schüssel mit TK-Heidelbeeren toppen"
],
"tip": "Coach-Tipp Gefrorene Beeren statt Gummibärchen hier direkt eingebaut. Säure bremst die Süß-Gier.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "13",
"category": "Frucht & Beere",
"title": "Apfel-Zimt-Bratapfel",
"tagline": "Apfelkuchen-Gefühl ohne Teig.",
"nutrition": "ca. ≈ 234 kcal · Eiweiß 6 g · Fett 12 g · KH 25 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "290 g Haferflocken · 90 g Walnüsse grob · 40 g Sonnenblumenkerne · 2 TL Zimt · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 60 g getrocknete Apfelstücke (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Walnüsse, Kerne, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen, dann getrocknete Apfelstücke untermischen (sonst werden sie zäh/hart).",
"variations": [
"Rosine zusätzlich eine kleine Handvoll Rosinen",
"Warm in der Schüssel mit warmem, gewürfeltem Apfel toppen"
],
"tip": "Coach-Tipp Der Kuchen-Ersatz. Über Skyr mit warmem Apfel schmeckt es wie Apfelstrudel ohne Zuckerschock.",
"badge": "",
"yield": "ergibt ca. 760 g · ~15 Portionen à 50 g"
},
{
"num": "14",
"category": "Frucht & Beere",
"title": "Bananenbrot-Granola",
"tagline": "Reife Banane bringt Süße fast ohne Sirup.",
"nutrition": "ca. ≈ 228 kcal · Eiweiß 7 g · Fett 12 g · KH 24 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Walnüsse grob · 40 g Leinsamen · 1 sehr reife Banane · 2 EL Kokosöl · 1 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Banane mit einer Gabel fein zerdrücken.\n3 Hafer, Walnüsse, Leinsamen, Zimt und Salz mischen.\n4 Bananenpüree, geschmolzenes Kokosöl und Ahornsirup verrühren, über das Trockene geben.\n5 Gut mischen, aufs Blech, andrücken.\n6 3035 Min backen Banane braucht länger zum Trocknen; wenden nach 20 Min.\n7 Vollständig auskühlen lassen (bleibt sonst weich).",
"variations": [
"Schoko-Chunk 40 g Zartbitter nach dem Backen",
"Erdnuss 2 EL Erdnussmus in die Bindung"
],
"tip": "Coach-Tipp Braune Bananen sinnvoll verwerten statt wegwerfen sie ersetzen einen Gutteil des Sirups.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "15",
"category": "Frucht & Beere",
"title": "Orange-Cranberry",
"tagline": "Weihnachtlich-fruchtig, säuerlicher Kontrast.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 6 g · Fett 12 g · KH 26 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · Abrieb 1 Bio-Orange · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g getrocknete Cranberries (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne, Orangenabrieb und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen, dann Cranberries untermischen.",
"variations": [
"Pistazie statt Mandeln für Farbe und Weihnachtslook",
"Zitrone Zitronenabrieb statt Orange"
],
"tip": "Coach-Tipp Cranberries sind von Natur aus sauer und oft gezuckert lieber sparsam, der Orangenabrieb trägt den Geschmack.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "16",
"category": "Frucht & Beere",
"title": "Trail-Mix-Granola",
"tagline": "Studentenfutter-Idee, ideal als Wander-/Büro-Snack.",
"nutrition": "ca. ≈ 256 kcal · Eiweiß 8 g · Fett 15 g · KH 23 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "250 g Haferflocken · 120 g gemischte Nüsse grob · 40 g Kürbiskerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 50 g Rosinen + 30 g Zartbitter-Tropfen (nach dem Backen)",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kürbiskerne und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Vollständig auskühlen lassen, dann Rosinen und Zartbitter untermischen (Schoko schmilzt sonst).",
"variations": [
"Salzig 1 Prise mehr Salz + geröstete Erdnüsse",
"Ohne Schoko stattdessen mehr Nüsse"
],
"tip": "Coach-Tipp Portionsbeutel abwiegen und in die Tasche das ist dein Büro-Swap gegen Chips und Gummibärchen.",
"badge": "",
"yield": "ergibt ca. 780 g · ~15 Portionen à 50 g"
},
{
"num": "17",
"category": "Gewürz & Saison",
"title": "Kürbisgewürz (Pumpkin Spice)",
"tagline": "Herbst im Glas, wärmende Gewürze.",
"nutrition": "ca. ≈ 240 kcal · Eiweiß 8 g · Fett 14 g · KH 21 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Pekannüsse grob · 60 g Kürbiskerne · 2 TL Zimt · 1/2 TL Ingwer · 1/2 TL Muskat · 1 Prise Nelke · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Pekannüsse, Kürbiskerne, alle Gewürze und Salz gründlich mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Mit Kürbis 2 EL Kürbispüree in die Bindung, 5 Min länger backen",
"Ahorn-Pekan-Plus zusätzlich Vanille"
],
"tip": "Coach-Tipp Das Gewürz macht die Süße subjektiv größer typischer Trick, um mit weniger Zucker auszukommen.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "18",
"category": "Gewürz & Saison",
"title": "Ingwer-Kurkuma „Golden“",
"tagline": "Wärmend, leicht scharf, entzündungsfreundlich.",
"nutrition": "ca. ≈ 242 kcal · Eiweiß 7 g · Fett 14 g · KH 22 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "270 g Haferflocken · 90 g Cashews grob · 40 g Sonnenblumenkerne · 1 TL Kurkuma · 1 TL Ingwer · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Cashews, Kerne, Kurkuma, Ingwer, Pfeffer und Salz mischen (Pfeffer macht Kurkuma verfügbar).\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Kokos 40 g Kokoschips in den letzten 6 Min",
"Schärfer frischen geriebenen Ingwer in die Bindung"
],
"tip": "Coach-Tipp Kurkuma färbt alles gelb (auch das Blech-Papier) harmlos. Eine Prise Pfeffer gehört dazu.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "19",
"category": "Gewürz & Saison",
"title": "Spekulatius-Lebkuchen",
"tagline": "Winter-Edition, ohne die übliche Zuckerlast.",
"nutrition": "ca. ≈ 238 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 7 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Haselnüsse grob · 2 TL Lebkuchengewürz · 1 TL Zimt · 3 EL Kokosöl · 3 EL Ahornsirup · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Haselnüsse, Lebkuchengewürz, Zimt und Salz mischen.\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 2530 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Orange Orangenabrieb dazu",
"Schoko 40 g Zartbitter nach dem Backen"
],
"tip": "Coach-Tipp Weihnachtsgebäck-Ersatz für den Advent stillt den Spekulatius-Reflex, ohne die Kekstüte.",
"badge": "",
"yield": "ergibt ca. 750 g · ~15 Portionen à 50 g"
},
{
"num": "20",
"category": "Gewürz & Saison",
"title": "Chai-Gewürz",
"tagline": "Schwarzer Tee trifft wärmende Gewürze.",
"nutrition": "ca. ≈ 239 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 6 g · Ballaststoffe 5 g",
"ingredients": "280 g Haferflocken · 90 g Mandeln grob · 40 g Sonnenblumenkerne · 1 TL Zimt · 1/2 TL Kardamom · 1/2 TL Ingwer · 1 Prise Nelke · 1 Prise Pfeffer · 3 EL Kokosöl · 2 EL Honig · 1 Prise Salz",
"steps": "1 Ofen auf 155 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und alle Gewürze mischen.\n3 Kokosöl und Honig schmelzen, unterrühren.\n4 Aufs Blech, andrücken.\n5 25 Min backen, einmal wenden.\n6 Auskühlen lassen.",
"variations": [
"Vanille-Chai 1 TL Vanille zugeben",
"Milchkaffee über warme Milch als Chai-Porridge"
],
"tip": "Coach-Tipp Kardamom und Nelke sind intensiv sparsam dosieren, sonst überdeckt es alles.",
"badge": "",
"yield": "ergibt ca. 730 g · ~14 Portionen à 50 g"
},
{
"num": "21",
"category": "Gewürz & Saison",
"title": "Karottenkuchen-Granola",
"tagline": "Carrot Cake als Frühstücks-Topping.",
"nutrition": "ca. ≈ 236 kcal · Eiweiß 7 g · Fett 13 g · KH 23 g · Zucker 8 g · Ballaststoffe 5 g",
"ingredients": "260 g Haferflocken · 80 g Walnüsse grob · 40 g Sonnenblumenkerne · 80 g fein geraspelte Möhre · 2 TL Zimt · 1/2 TL Muskat · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Prise Salz · 30 g Rosinen (nach dem Backen)",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Möhre fein raspeln und mit Küchenpapier gut ausdrücken sonst wird das Granola nicht knusprig.\n3 Hafer, Walnüsse, Kerne, Gewürze, Salz und die geraspelte Möhre mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Aufs Blech, dünn verteilen, andrücken.\n6 3035 Min backen Möhre bringt Feuchtigkeit, länger und niedriger trocknen; nach 20 Min wenden.\n7 Vollständig auskühlen lassen, dann Rosinen untermischen.",
"variations": [
"Frischkäse-Note über Skyr = Carrot-Cake-Frosting-Effekt",
"Kokos 2 EL Kokosraspel dazu"
],
"tip": "Coach-Tipp Möhre gut ausdrücken ist der ganze Trick sonst bleibt es zäh statt knusprig.",
"badge": "",
"yield": "ergibt ca. 740 g · ~14 Portionen à 50 g"
},
{
"num": "22",
"category": "Protein-Boost & Post-Workout",
"title": "Vanille-Whey-Cluster",
"tagline": "Post-Workout: viel Eiweiß, feste Brocken.",
"nutrition": "ca. ≈ 260 kcal · Eiweiß 13 g · Fett 13 g · KH 22 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "240 g Haferflocken · 70 g Mandeln grob · 40 g Sonnenblumenkerne · 40 g Whey Vanille · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Mandeln, Kerne und Whey mischen Pulver klumpenfrei unterheben.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 2530 Min bei 150 °C backen, NICHT rühren Whey bräunt früh, Farbe kontrollieren.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko-Whey Schoko-Whey + 15 g Kakao",
"Erdnuss Erdnussmus statt Mandelmus"
],
"tip": "Coach-Tipp Über 150 g Skyr sind das ~30 g Eiweiß idealer Post-Session-Snack rund ums Training.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "23",
"category": "Protein-Boost & Post-Workout",
"title": "Schoko-Casein-Abend",
"tagline": "Langsames Eiweiß für den Couch-Abend.",
"nutrition": "ca. ≈ 256 kcal · Eiweiß 13 g · Fett 13 g · KH 21 g · Zucker 5 g · Ballaststoffe 6 g",
"ingredients": "240 g Haferflocken · 70 g Haselnüsse grob · 40 g Leinsamen · 40 g Casein Schoko · 20 g Kakao · 3 EL Mandelmus · 2 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Haselnüsse, Leinsamen, Casein und Kakao mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben.\n5 Fest aufs Blech drücken.\n6 2528 Min backen, NICHT rühren Kakao + Casein bräunen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Meersalz grobes Salz vor dem Backen aufstreuen",
"Nibs 30 g Kakaonibs nach dem Backen"
],
"tip": "Coach-Tipp Casein macht langsam satt als Abend-Dessert über Magerquark dein bester Schoko-Couch-Ersatz.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "24",
"category": "Protein-Boost & Post-Workout",
"title": "Skyr-Topper · grob & pur",
"tagline": "Extra grobe Brocken, kaum Süße nur zum Toppen.",
"nutrition": "ca. ≈ 244 kcal · Eiweiß 9 g · Fett 15 g · KH 19 g · Zucker 3 g · Ballaststoffe 6 g",
"ingredients": "240 g Haferflocken kernig · 100 g gemischte Nüsse grob · 40 g Kürbiskerne · 30 g Leinsamen · 3 EL Mandelmus · 2 EL Kokosöl · 1 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Hafer, Nüsse, Kerne und Leinsamen mischen.\n3 Mandelmus, Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben für maximal grobe Cluster sehr fest andrücken.\n5 2832 Min backen, NICHT rühren.\n6 Vollständig auskühlen lassen, in große Brocken brechen.",
"variations": [
"Herzhaft Salz erhöhen, Süße weglassen",
"Zimt 1 TL Zimt für milde Süße ohne Zucke"
],
"tip": "Coach-Tipp Bewusst wenig Zucker (3 g) die Süße kommt vom Obst in der Schüssel. Das gröbste Rezept, perfekt als Crunch auf Quark.",
"badge": "",
"yield": "ergibt ca. 720 g · ~14 Portionen à 50 g"
},
{
"num": "25",
"category": "Grain-free & Low-Carb",
"title": "Grain-free Nuss-Granola",
"tagline": "Ohne Getreide, sehr wenig KH, viel Crunch.",
"nutrition": "ca. ≈ 272 kcal · Eiweiß 9 g · Fett 23 g · KH 8 g · Zucker 4 g · Ballaststoffe 6 g",
"ingredients": "120 g Mandeln · 120 g Pekannüsse · 80 g Kürbiskerne · 60 g Sonnenblumenkerne · 80 g Kokoschips · 30 g Leinsamen · 3 EL Kokosöl · 2 EL Ahornsirup · 1 Eiweiß · 1 Prise Salz",
"steps": "1 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n2 Nüsse grob hacken; mit Kernen und Leinsamen mischen (Kokoschips zurückhalten).\n3 Kokosöl und Ahornsirup schmelzen, unterrühren.\n4 Eiweiß schaumig schlagen, unterheben bindet die Cluster ohne Hafer.\n5 Fest aufs Blech drücken.\n6 1822 Min backen; in den letzten 6 Min Kokoschips untermischen Nüsse und Kokos verbrennen schnell.\n7 Vollständig auskühlen lassen, in Brocken brechen.",
"variations": [
"Schoko 20 g Kakao ins Trockene",
"Zimt-Kokos 1 TL Zimt zugeben"
],
"tip": "Coach-Tipp Für Tage mit wenig Bewegung: kaum KH, sättigt lange über Fett und Ballaststoffe. Portion trotzdem abwiegen sehr kaloriendicht.",
"badge": "",
"yield": "ergibt ca. 650 g · ~13 Portionen à 50 g"
},
{
"num": "26",
"category": "Grain-free & Low-Carb",
"title": "Buchweizen-Buckini-Crunch",
"tagline": "Aktivierter Buchweizen extrem knusprig, glutenfrei.",
"nutrition": "ca. ≈ 234 kcal · Eiweiß 8 g · Fett 11 g · KH 27 g · Zucker 5 g · Ballaststoffe 5 g",
"ingredients": "200 g roher Buchweizen · 100 g Haferflocken (oder mehr Buchweizen für glutenfrei) · 80 g Mandeln grob · 40 g Sonnenblumenkerne · 3 EL Kokosöl · 2 EL Ahornsirup · 1 TL Zimt · 1 Prise Salz",
"steps": "1 Buchweizen 46 h in Wasser einweichen, dann gründlich abspülen und gut abtropfen lassen (macht ihn\nbekömmlicher und knuspriger).\n2 Ofen auf 150 °C Umluft, Blech mit Backpapier.\n3 Buchweizen, Hafer, Mandeln, Kerne, Zimt und Salz mischen.\n4 Kokosöl und Ahornsirup schmelzen, unterrühren.\n5 Dünn aufs Blech, andrücken.\n6 3035 Min backen, alle 10 Min wenden eingeweichter Buchweizen braucht länger zum Trocknen.\n7 Vollständig auskühlen lassen wird dann glasig-knusprig.",
"variations": [
"Kakao 20 g Kakao für Schoko-Buckini",
"Schnell Buchweizen ohne Einweichen etwas härter, geht abe"
],
"tip": "Coach-Tipp Der knusprigste Crunch überhaupt. Wenn Hafer dir schwer im Magen liegt, ist das die leichtere Basis.",
"badge": "",
"yield": "ergibt ca. 700 g · ~14 Portionen à 50 g"
},
{
"num": "27",
"category": "Ohne Ofen & Overnight",
"title": "No-Bake Erdnuss-Cluster",
"tagline": "Ohne Ofen, aus dem Kühlschrank fest.",
"nutrition": "ca. ≈ 248 kcal · Eiweiß 10 g · Fett 14 g · KH 22 g · Zucker 9 g · Ballaststoffe 5 g",
"ingredients": "200 g Haferflocken · 60 g Erdnüsse grob · 30 g Leinsamen · 6 entsteinte Datteln · 4 EL Erdnussmus · 2 EL Kokosöl · 1 Prise Salz · optional 20 g Zartbitter",
"steps": "1 Datteln 10 Min in warmem Wasser einweichen, abgießen und zu einer Paste zerdrücken.\n2 Erdnussmus und Kokosöl leicht erwärmen, mit der Dattelpaste glatt rühren.\n3 Hafer, Erdnüsse, Leinsamen und Salz untermischen, bis alles klebt.\n4 Masse fest in eine mit Papier ausgelegte Form drücken (ca. 1,5 cm).\n5 Mindestens 1 h kalt stellen, bis sie fest ist.\n6 In Riegel oder Brocken schneiden; optional mit geschmolzener Zartbitter bestreichen.\n7 Kühl gelagert 1 Woche haltbar (kein Backen = kürzer haltbar als gebäcken).",
"variations": [
"Schoko 20 g Kakao in die Paste",
"Riegel als Snack-Riegel schneiden für unterwegs"
],
"tip": "Coach-Tipp Kein Ofen nötig aber Datteln bringen Zucker (9 g). Portion klein halten, dann ein solider Riegel-Ersatz.",
"badge": "",
"yield": "ergibt ca. 600 g · ~12 Portionen à 50 g"
},
{
"num": "28",
"category": "Ohne Ofen & Overnight",
"title": "Overnight-Bircher-Mix",
"tagline": "Trockenmischung zum Einweichen quasi Granola rückwärts.",
"nutrition": "ca. ≈ 230 kcal · Eiweiß 8 g · Fett 10 g · KH 27 g · Zucker 6 g · Ballaststoffe 6 g",
"ingredients": "350 g Haferflocken · 60 g Mandeln grob · 40 g Leinsamen · 30 g Chiasamen · 1 TL Zimt · 40 g Rosinen pro Portion: 150 g Skyr/Milch + geraspelter Apfel Trockenmix für ~14 Portionen à 50 g",
"steps": "1 Alle trockenen Zutaten mischen und in ein großes Vorratsglas füllen (kein Backen).\n2 Am Vorabend 50 g Mix mit 150 g Skyr oder Milch übergießen und verrühren.\n3 Optional geraspelten Apfel oder Beeren unterheben.\n4 Abgedeckt über Nacht in den Kühlschrank.\n5 Am Morgen kurz umrühren; bei Bedarf einen Schuss Milch für die Konsistenz.\n6 Nach Wunsch mit frischem Obst oder einem Teelöffel gebackenem Granola toppen.",
"variations": [
"Schoko 1 TL Kakao einrühren",
"Protein 1/2 Portion Whey unterrühren",
"Beere TK-Beeren mit einweichen"
],
"tip": "Coach-Tipp Das Löst die Ausrede „keine Zeit“ abends 30 Sekunden Aufwand, morgens Eiweiß-Basis ohne Nachdenken.",
"badge": "",
"yield": ""
}
]
}
}
File diff suppressed because it is too large Load Diff
-399
View File
@@ -1,399 +0,0 @@
# -*- coding: utf-8 -*-
"""
recipe_builder.py
Rendert eine Rezeptsammlung als A4-PDF im Design der Original-Editionen:
farbiges Kategorie-Band, farbiges Rezept-Titelband (weiße Schrift), getönter
Untertitel, NÄHRWERTE-Strip, zweispaltig ZUTATEN | SO GEHT'S, getönte
VARIATIONEN- und Tipp-Box, dünn umrandete Karte.
Regeln (wie im Original-Generator):
* jedes Rezept ist KeepTogether -> wird nie durch einen Seitenumbruch geteilt
* vor jeder Kategorie ein PageBreak -> jede Kategorie beginnt auf neuer Seite
build_pdf(cfg) -> bytes ; default_config() -> dict
"""
import re
import copy
from io import BytesIO
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
KeepTogether, PageBreak)
DEFAULTS = {
"type": "recipe",
"meta": {
"title": "Neue Rezeptsammlung",
"header_left": "REZEPTE · Edition",
"header_right": "Begleitend zum 8-Wochen-Plan",
"footer_left": "Teller = Portionsgrenze · Trend über die Woche zählt",
"output_name": "Rezepte.pdf",
"doc_title": "Rezeptsammlung",
"brand_color": "#6D28D9",
"tip_label": "Tipp",
"steps_label": "SO GEHT'S",
},
"intro": {
"headline": "Neue Rezeptsammlung",
"subtitle": "Kurzbeschreibung der Edition.",
"paragraphs": ["Einleitender Absatz."],
"rule_head": "Die eine Grundregel",
"rule_text": "Teller fertig machen, Rest aus dem Sichtfeld.",
"categories_head": "1 Kategorie",
},
"categories": [{"name": "Cremig & süß", "color": "#7C3AED"}],
"recipes": [
{"num": "01", "category": "Cremig & süß", "title": "Beispiel-Rezept",
"tagline": "Kurzer Untertitel.", "nutrition": "", "badge": "", "yield": "",
"ingredients": "200 g Magerquark · etwas Süße",
"steps": "Alles verrühren, fertig.",
"variations": ["Schoko: + 1 TL Kakao"], "tip": "Tipp Eiweiß-reich."},
],
}
def default_config():
return copy.deepcopy(DEFAULTS)
# ---------------------------------------------------------------- helpers
_ENT = re.compile(r"&(?!(?:amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);)")
def esc(s):
if s is None:
return ""
s = str(s).replace("<", "&lt;").replace(">", "&gt;")
return _ENT.sub("&amp;", s)
def _hx(c):
return "#" + c.hexval()[2:]
def lighten(hexstr, w=0.92):
"""Mischt eine Farbe mit Weiß (w = Weiß-Anteil) -> heller Ton."""
try:
c = colors.HexColor(hexstr)
except Exception:
c = colors.HexColor("#334155")
r = c.red * 255 * (1 - w) + 255 * w
g = c.green * 255 * (1 - w) + 255 * w
b = c.blue * 255 * (1 - w) + 255 * w
return colors.Color(r / 255, g / 255, b / 255)
TIP_LABEL_RE = re.compile(r"^\s*([A-Za-zÄÖÜäöü]+-Tipp|Tipp)\b[:\s]*(.*)$", re.S)
def split_tip(text, default_label):
"""Zerlegt 'Morgen-Tipp Text' -> ('Morgen-Tipp', 'Text')."""
if not text:
return default_label, ""
m = TIP_LABEL_RE.match(text)
if m:
return m.group(1), m.group(2).strip()
return default_label, text.strip()
def bold_lead(v):
"""Erste Bezeichnung einer Variation fett (bis Doppelpunkt bzw. erstes Wort)."""
v = v.strip()
m = re.match(r"^([^:]{2,40}):\s*(.*)$", v, re.S)
if m:
return "<b>%s:</b> %s" % (esc(m.group(1)), esc(m.group(2)))
m = re.match(r"^(\S+)\s+(.*)$", v, re.S)
if m:
return "<b>%s</b> %s" % (esc(m.group(1)), esc(m.group(2)))
return esc(v)
NOSPLIT = {"bis", "damit", "sodass", "bevor", "nachdem", "während", "wenn", "falls", "sobald",
"weil", "sowie", "oder", "aber", "bei", "je", "ohne", "mit", "in", "im", "am", "zum",
"zur", "auf", "für", "über", "unter", "dabei", "dazu", "nach", "vor", "als"}
def split_steps(text):
t = (text or "").strip().replace("z. B.", "z.B.")
raw = re.split(r"(?<=[.!?])\s+|,\s+", t)
steps = []
for frag in raw:
frag = frag.strip()
if not frag:
continue
first = frag.split()[0].lower().strip("(„\"'.:;»«")
if steps and first in NOSPLIT:
steps[-1] = steps[-1].rstrip(".") + ", " + frag
else:
steps.append(frag)
steps = [s if s.endswith((".", "!", "?")) else s + "." for s in steps]
return [s[:1].upper() + s[1:] for s in steps]
def numbered_steps(steps_txt):
"""Bereits nummerierte (mehrzeilige) Schritte -> Zeilen; sonst Prosa splitten."""
if steps_txt and "\n" in steps_txt:
out = []
for ln in steps_txt.split("\n"):
ln = re.sub(r"^\s*\d+[\.\)]?\s+", "", ln.strip())
if ln:
out.append(ln)
return out
return split_steps(steps_txt)
# ---------------------------------------------------------------- renderer
def build_pdf(cfg):
_D = DEFAULTS
meta = dict(_D["meta"]); meta.update(cfg.get("meta") or {})
intro = dict(_D["intro"]); intro.update(cfg.get("intro") or {})
cats = cfg.get("categories") or _D["categories"]
recipes = cfg.get("recipes") or []
INK = colors.HexColor("#23272F")
SUB = colors.HexColor("#5B6270")
HAIR = colors.HexColor("#E3E1EA")
NUTBG = colors.HexColor("#F7F7F9")
BRAND = colors.HexColor(meta.get("brand_color", "#6D28D9"))
TIPBG = lighten(_hx(BRAND), 0.93)
def catcolor(name):
for c in cats:
if c.get("name") == name:
try:
return colors.HexColor(c.get("color", "#334155"))
except Exception:
return colors.HexColor("#334155")
return colors.HexColor("#334155")
styles = getSampleStyleSheet()
def S(name, **kw):
base = kw.pop("parent", styles["Normal"])
return ParagraphStyle(name, parent=base, **kw)
st_title = S("t", fontName="Helvetica-Bold", fontSize=22, leading=25, textColor=INK)
st_sub = S("s", fontName="Helvetica", fontSize=10.5, leading=14, textColor=SUB)
st_intro = S("i", fontName="Helvetica", fontSize=10, leading=15, textColor=INK)
st_callh = S("ch", fontName="Helvetica-Bold", fontSize=10.5, leading=13, textColor=INK)
st_legend= S("lg", fontName="Helvetica", fontSize=8.6, leading=12, textColor=INK)
st_cat = S("c", fontName="Helvetica-Bold", fontSize=12.5, leading=15, textColor=colors.white)
st_rname = S("rn", fontName="Helvetica-Bold", fontSize=11.5, leading=14, textColor=colors.white)
st_badge = S("bd", fontName="Helvetica-Bold", fontSize=7.5, leading=9)
st_tag = S("tg", fontName="Helvetica-Oblique", fontSize=9, leading=12, textColor=SUB)
st_lbl = S("lb", fontName="Helvetica-Bold", fontSize=7.5, leading=10, spaceAfter=1)
st_body = S("bd2", fontName="Helvetica", fontSize=8.8, leading=12, textColor=INK)
st_var = S("vr", fontName="Helvetica", fontSize=8.4, leading=11.6, textColor=INK)
st_tip = S("tp", fontName="Helvetica", fontSize=8.5, leading=12, textColor=INK)
st_yield = S("yl", fontName="Helvetica-Oblique", fontSize=8, leading=11, textColor=SUB)
PAGE_W, PAGE_H = A4
LM = RM = 1.5 * cm
CW = PAGE_W - LM - RM
def lbl(text, acc):
return Paragraph(text, S("lx", parent=st_lbl, textColor=acc))
# ---- category band ----
def category_band(name):
acc = catcolor(name)
t = Table([[Paragraph(esc(name), st_cat)]], colWidths=[CW])
t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6)]))
return t
# ---- one recipe card ----
def recipe_card(r):
acc = catcolor(r.get("category", ""))
accx = _hx(acc)
tint = lighten(accx, 0.92)
# Titelband (+ optional AIRFRYER-Badge)
name_p = Paragraph("%s&nbsp;&nbsp;%s" % (esc(r.get("num", "")), esc(r.get("title", ""))), st_rname)
if r.get("badge"):
badge = Table([[Paragraph(esc(r["badge"]), S("bx", parent=st_badge, textColor=acc))]])
badge.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
badge.hAlign = "RIGHT"
head = Table([[name_p, badge]], colWidths=[CW - 82, 82])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (0, 0), 9), ("RIGHTPADDING", (0, 0), (0, 0), 4),
("LEFTPADDING", (1, 0), (1, 0), 0), ("RIGHTPADDING", (1, 0), (1, 0), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
else:
head = Table([[name_p]], colWidths=[CW])
head.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), acc),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows = [[head]]
# Untertitel (getönt)
if r.get("tagline"):
tg = Table([[Paragraph(esc(r["tagline"]), st_tag)]], colWidths=[CW])
tg.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
rows.append([tg])
# NÄHRWERTE-Strip
if r.get("nutrition"):
ntext = '<b><font color="%s">NÄHRWERTE</font></b>&nbsp;&nbsp;%s' % (accx, esc(r["nutrition"]))
n = Table([[Paragraph(ntext, st_var)]], colWidths=[CW])
n.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), NUTBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LINEBELOW", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([n])
# ZUTATEN volle Breite
zut_cell = [lbl("ZUTATEN", acc), Paragraph(esc(r.get("ingredients", "")), st_body)]
if r.get("yield"):
zut_cell.append(Spacer(1, 2))
zut_cell.append(Paragraph("<i>%s</i>" % esc(r["yield"]), st_yield))
zut = Table([[zut_cell]], colWidths=[CW])
zut.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([zut])
# Schritte nummeriert, volle Breite
steps = numbered_steps(r.get("steps", ""))
srows = [[Paragraph('<b><font color="%s">%d</font></b>' % (accx, i), st_body),
Paragraph(esc(s), st_body)] for i, s in enumerate(steps, 1)]
if srows:
steps_inner = Table(srows, colWidths=[15, CW - 18 - 15])
steps_inner.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 4), ("RIGHTPADDING", (1, 0), (1, 0), 0),
("TOPPADDING", (0, 0), (-1, 0), 0), ("TOPPADDING", (0, 1), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc), Spacer(1, 2), steps_inner]
else:
arb_cell = [lbl(meta.get("steps_label", "SO GEHT'S"), acc)]
arb = Table([[arb_cell]], colWidths=[CW])
arb.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR), ("BACKGROUND", (0, 0), (-1, -1), colors.white)]))
rows.append([arb])
# ZUBEREITUNG (Airfryer/Ofen), optional
if r.get("methods"):
m_cell = [lbl("ZUBEREITUNG · AIRFRYER / OFEN", acc), Paragraph(esc(r["methods"]), st_var)]
mt = Table([[m_cell]], colWidths=[CW])
mt.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F3F4F6")),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([mt])
# VARIATIONEN (getönt)
if r.get("variations"):
vhtml = "<br/>".join(bold_lead(v) for v in r["variations"] if v)
v = Table([[[lbl("VARIATIONEN", acc), Paragraph(vhtml, st_var)]]], colWidths=[CW])
v.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), tint),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LINEABOVE", (0, 0), (-1, 0), 0.6, HAIR)]))
rows.append([v])
# Tipp (Marken-Tint)
if r.get("tip"):
lab, txt = split_tip(r["tip"], meta.get("tip_label", "Tipp"))
tip_html = '<b><font color="%s">%s</font></b>&nbsp;&nbsp;%s' % (accx, esc(lab), esc(txt))
tp = Table([[Paragraph(tip_html, st_tip)]], colWidths=[CW])
tp.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 9), ("RIGHTPADDING", (0, 0), (-1, -1), 9),
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5)]))
rows.append([tp])
card = Table(rows, colWidths=[CW])
card.setStyle(TableStyle([("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("BOX", (0, 0), (-1, -1), 0.8, HAIR)]))
return KeepTogether([card, Spacer(1, 7)])
# ---- story ----
story = []
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(esc(intro.get("headline", meta["title"])), st_title))
story.append(Spacer(1, 3))
if intro.get("subtitle"):
story.append(Paragraph(esc(intro["subtitle"]), st_sub))
story.append(Spacer(1, 10))
for p in intro.get("paragraphs", []):
story.append(Paragraph(esc(p), st_intro))
story.append(Spacer(1, 6))
if intro.get("rule_head") or intro.get("rule_text"):
gr = Table([[[Paragraph(esc(intro.get("rule_head", "")), st_callh), Spacer(1, 3),
Paragraph(esc(intro.get("rule_text", "")), st_intro)]]], colWidths=[CW])
gr.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), TIPBG),
("LEFTPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 12),
("TOPPADDING", (0, 0), (-1, -1), 9), ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
("LINEBEFORE", (0, 0), (0, 0), 3, BRAND)]))
story.append(Spacer(1, 4))
story.append(gr)
story.append(Spacer(1, 12))
story.append(Paragraph(esc(intro.get("categories_head", "%d Kategorien" % len(cats))),
S("lh", parent=st_callh, fontSize=10)))
story.append(Spacer(1, 4))
leg_rows = []
for c in cats:
sw = Table([[""]], colWidths=[10], rowHeights=[10])
sw.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, -1), catcolor(c["name"])),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0)]))
leg_rows.append([sw, Paragraph(esc(c["name"]), st_legend)])
if leg_rows:
leg = Table(leg_rows, colWidths=[16, CW - 16])
leg.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (0, 0), 6),
("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2)]))
story.append(leg)
# Rezepte, gruppiert je Kategorie -> neue Seite pro Kategorie, Rezept nie geteilt
cur = None
for r in recipes:
c = r.get("category", "")
if c != cur:
story.append(PageBreak())
story.append(category_band(c))
story.append(Spacer(1, 7))
cur = c
story.append(recipe_card(r))
# ---- Kopf-/Fußzeile ----
header_left = meta.get("header_left", "")
header_right = meta.get("header_right", "Begleitend zum 8-Wochen-Plan")
footer_left = meta.get("footer_left", "")
def deco(canvas, doc):
canvas.saveState()
canvas.setFillColor(BRAND); canvas.setFont("Helvetica-Bold", 8)
canvas.drawString(LM, PAGE_H - 1.05 * cm, header_left)
canvas.setFont("Helvetica", 8); canvas.setFillColor(SUB)
canvas.drawRightString(PAGE_W - RM, PAGE_H - 1.05 * cm, header_right)
canvas.setStrokeColor(HAIR); canvas.setLineWidth(0.7)
canvas.line(LM, PAGE_H - 1.2 * cm, PAGE_W - RM, PAGE_H - 1.2 * cm)
canvas.line(LM, 1.15 * cm, PAGE_W - RM, 1.15 * cm)
canvas.setFont("Helvetica", 7.5); canvas.setFillColor(SUB)
canvas.drawString(LM, 0.8 * cm, footer_left)
canvas.drawRightString(PAGE_W - RM, 0.8 * cm, "Seite %d" % doc.page)
canvas.restoreState()
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=LM, rightMargin=RM,
topMargin=1.6 * cm, bottomMargin=1.5 * cm,
title=meta.get("doc_title", meta.get("title", "Rezepte")))
doc.build(story, onFirstPage=deco, onLaterPages=deco)
return buf.getvalue()
if __name__ == "__main__":
open("/tmp/recipe_test.pdf", "wb").write(build_pdf(default_config()))
print("OK")
+229 -339
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Versionierter Datenvertrag zwischen Trainingsplan-Editor und Session-Tracker.""" """Schlanker Datenvertrag fuer den Trainingsplan-Editor."""
from __future__ import annotations from __future__ import annotations
import copy import copy
@@ -7,15 +7,8 @@ import hashlib
import re import re
from typing import Any from typing import Any
PLAN_SCHEMA_VERSION = 3 PLAN_SCHEMA_VERSION = 4
CONTRACT_VERSION = 2 CONTRACT_VERSION = 3
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: def slug(value: Any) -> str:
@@ -30,289 +23,220 @@ def stable_id(prefix: str, *parts: Any) -> str:
return f"{prefix}-{slug(parts[-1] if parts else prefix)[:30]}-{digest}" 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: def default_config() -> dict[str, Any]:
if value is None: return {
return None "schema_version": PLAN_SCHEMA_VERSION,
if not isinstance(value, dict): "contract_version": CONTRACT_VERSION,
value = {} "plan_id": "neuer-trainingsplan",
mode = str(value.get("mode") or ("auto" if not partial else "")) "meta": {"title": "Neuer Trainingsplan"},
weight_mode = str(value.get("weight_mode") or ("none" if not partial else "")) "exercises": [],
laterality = str(value.get("laterality") or ("bilateral" if not partial else "")) "days": [],
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 def _string(value: Any) -> str:
elif not partial: return str(value or "").strip()
result["mode"] = "auto"
if weight_mode in VALID_WEIGHT_MODES:
result["weight_mode"] = weight_mode def _int(value: Any, fallback: int = 0) -> int:
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: try:
sets = int(value.get("sets")) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
sets = 0 return fallback
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 _text_items(value: Any, fallback: Any = None) -> dict[str, list[str]]:
def infer_result_schema(name: Any, cue: Any = "", *, stage: bool = False) -> dict[str, Any]: source = value if value not in (None, "") else fallback
"""Leitet für Legacy- und neue Standardpläne ein explizites Basisschema ab. if isinstance(source, dict):
source = source.get("items")
Die Ableitung ist nur eine Migrationshilfe. Nach dem Speichern liegt das if isinstance(source, list):
Ergebnis fest im Plan und kann im Editor jederzeit korrigiert werden. items = [_string(item) for item in source if _string(item)]
"""
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: else:
mode = "reps" text = _string(source)
parts = re.split(r"<br\s*/?>|\n|·", text, flags=re.I)
items = [_string(re.sub(r"<[^>]+>", "", item)) for item in parts]
items = [item for item in items if item]
return {"items": items}
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)) def _normalize_exercise(raw: Any, plan_id: str, index: int) -> dict[str, Any] | None:
schema = { if not isinstance(raw, dict):
"mode": mode, return None
"weight_mode": weight_mode, name = _string(raw.get("name") or raw.get("title"))
"laterality": "unilateral" if unilateral else "bilateral", exercise_id = _string(raw.get("id") or raw.get("exercise_id"))
"sides_mode": "separate" if unilateral else "same", if not exercise_id:
} exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
# Nicht messbare Checklistenpunkte benötigen nie Gewicht oder Seitenfelder. exercise = {"id": exercise_id, "name": name}
if mode == "none": for key in ("cue", "description", "equipment", "notes"):
schema.update({"weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"}) value = _string(raw.get(key))
return normalize_result_schema(schema, partial=False) or { if value:
"mode": "auto", "weight_mode": "none", "laterality": "bilateral", "sides_mode": "same" exercise[key] = value
tags = raw.get("tags")
if isinstance(tags, list):
clean_tags = [_string(tag) for tag in tags if _string(tag)]
if clean_tags:
exercise["tags"] = clean_tags
return exercise
def _catalog_sources(cfg: dict[str, Any]) -> list[Any]:
sources: list[Any] = []
raw_exercises = cfg.get("exercises")
if isinstance(raw_exercises, list):
sources.extend(raw_exercises)
raw_catalog = cfg.get("exercise_catalog")
if isinstance(raw_catalog, dict):
sources.extend(raw_catalog.values())
elif isinstance(raw_catalog, list):
sources.extend(raw_catalog)
return sources
def _legacy_day_exercises(day: dict[str, Any]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
direct = day.get("exercises")
if isinstance(direct, list):
result.extend(item for item in direct if isinstance(item, dict))
rotations = day.get("rotations")
if not isinstance(rotations, list):
rotations = []
if isinstance(day.get("rotA"), list):
rotations.append({"label": "Rotation A", "exercises": day["rotA"]})
if isinstance(day.get("rotB"), list):
rotations.append({"label": "Rotation B", "exercises": day["rotB"]})
for rotation in rotations:
if not isinstance(rotation, dict):
continue
label = _string(rotation.get("label"))
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
for exercise in exercises:
if not isinstance(exercise, dict):
continue
item = copy.deepcopy(exercise)
item["__legacy_catalog"] = True
if label and not _string(item.get("notes")):
item["notes"] = label
result.append(item)
return result
def _ensure_catalog_entry(
catalog: dict[str, dict[str, Any]],
raw: dict[str, Any],
plan_id: str,
index: int,
) -> str:
exercise_id = _string(raw.get("exercise_id") or raw.get("id"))
name = _string(raw.get("name") or raw.get("title"))
if not exercise_id:
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
if exercise_id not in catalog:
source = {
"id": exercise_id,
"name": name,
"cue": raw.get("cue"),
"description": raw.get("description"),
"equipment": raw.get("equipment"),
"tags": raw.get("tags"),
}
catalog[exercise_id] = _normalize_exercise(source, plan_id, index) or {
"id": exercise_id,
"name": name,
}
return exercise_id
def _normalize_day_exercise(raw: dict[str, Any], day_id: str, exercise_id: str, index: int) -> dict[str, Any]:
item_id = _string(raw.get("id"))
if not item_id or item_id == exercise_id:
item_id = stable_id("day-exercise", day_id, exercise_id, index)
item: dict[str, Any] = {"id": item_id, "exercise_id": exercise_id}
for key in ("sets", "duration_seconds"):
value = _int(raw.get(key), 0)
if value > 0:
item[key] = value
for key in ("reps", "tempo", "rest", "notes", "legacy_id"):
value = _string(raw.get(key))
if value:
item[key] = value
return item
def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
cfg = copy.deepcopy(config) if isinstance(config, dict) else {}
meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {}
resolved_plan_id = _string(cfg.get("plan_id") or plan_id or stable_id("plan", meta.get("title") or "training"))
title = _string(meta.get("title") or cfg.get("title") or "Neuer Trainingsplan")
normalized: dict[str, Any] = {
"schema_version": PLAN_SCHEMA_VERSION,
"contract_version": CONTRACT_VERSION,
"plan_id": resolved_plan_id,
"meta": {"title": title},
"exercises": [],
"days": [],
} }
subtitle = _string(meta.get("subtitle") or cfg.get("subtitle"))
if subtitle:
normalized["meta"]["subtitle"] = subtitle
def infer_cluster(name: str, key: str = "") -> tuple[str, str]: catalog: dict[str, dict[str, Any]] = {}
text = f"{key} {name}".lower() for index, raw in enumerate(_catalog_sources(cfg)):
rules = [ exercise = _normalize_exercise(raw, resolved_plan_id, index)
(r"liegest|push.?up|\bls\b", "horizontal_push", "Push-up"), if exercise:
(r"dip", "vertical_push", "Dip"), catalog[exercise["id"]] = exercise
(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"
legacy_prepost = cfg.get("prepost") if isinstance(cfg.get("prepost"), dict) else {}
days = cfg.get("days") if isinstance(cfg.get("days"), list) else []
exercise_counter = len(catalog)
for day_index, raw_day in enumerate(days):
if not isinstance(raw_day, dict):
continue
day_num = _int(raw_day.get("num"), day_index + 1) or day_index + 1
name = _string(raw_day.get("name") or raw_day.get("focus") or f"Tag {day_num}")
day_id = _string(raw_day.get("id")) or stable_id("day", resolved_plan_id, day_num, name)
prepost = legacy_prepost.get(str(day_num)) if isinstance(legacy_prepost.get(str(day_num)), dict) else {}
day: dict[str, Any] = {
"id": day_id,
"num": day_num,
"name": name,
"warmup": _text_items(raw_day.get("warmup"), prepost.get("warmup")),
"cooldown": _text_items(raw_day.get("cooldown"), prepost.get("cooldown")),
"exercises": [],
}
focus = _string(raw_day.get("focus"))
if focus and focus != name:
day["focus"] = focus
notes = _string(raw_day.get("notes") or raw_day.get("note"))
if notes:
day["notes"] = notes
def infer_factor(name: str, cluster_id: str) -> float: for item_index, raw_item in enumerate(_legacy_day_exercises(raw_day)):
text = str(name or "").lower() should_create = bool(raw_item.pop("__legacy_catalog", False) or _string(raw_item.get("name")))
if cluster_id == "horizontal_push": if should_create:
if "wand" in text: return 0.05 exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter)
if "hoch" in text and ("incline" in text or "inkline" in text): return 0.10 exercise_counter += 1
if "mittel" in text and ("incline" in text or "inkline" in text): return 0.20 else:
if "tief" in text and ("incline" in text or "inkline" in text): return 0.35 exercise_id = _string(raw_item.get("exercise_id"))
if "negativ" in text and ("voll" in text or "erste" in text): return 0.85 day["exercises"].append(_normalize_day_exercise(raw_item, day_id, exercise_id, item_index))
if "negativ" in text: return 0.70 normalized["days"].append(day)
if "assist" in text: return 0.55
if "knie" in text: return 0.45 normalized["exercises"] = sorted(catalog.values(), key=lambda item: (slug(item.get("name")), item.get("id")))
if "teil" in text or "partial" in text: return 0.35 normalized["days"] = sorted(normalized["days"], key=lambda item: item.get("num", 0))
if "voll" in text or "standard" in text or "normal" in text: return 1.00 return normalized
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]: def migrate_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
cfg = copy.deepcopy(config or {}) return normalize_training_config(config, plan_id=plan_id)
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]]]: def validate_training_config(config: Any) -> dict[str, Any]:
cfg = migrate_training_config(config) cfg = normalize_training_config(config)
errors: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = [] warnings: list[dict[str, Any]] = []
ids: dict[str, str] = {} ids: dict[str, str] = {}
def register(value: Any, location: str) -> None: def register(value: Any, location: str) -> None:
sid = str(value or "") sid = _string(value)
if not sid: if not sid:
errors.append({"code": "missing_id", "location": location, "message": "Stabile ID fehlt."}) errors.append({"code": "missing_id", "location": location, "message": "Stabile ID fehlt."})
elif sid in ids: elif sid in ids:
@@ -320,72 +244,38 @@ def validate_training_config(config: Any) -> dict[str, list[dict[str, Any]]]:
else: else:
ids[sid] = location ids[sid] = location
def validate_result_schema(schema: Any, location: str, *, required: bool) -> None: if not _string(cfg.get("meta", {}).get("title")):
if not isinstance(schema, dict): errors.append({"code": "empty_title", "location": "Plan", "message": "Plantitel fehlt."})
target = errors if required else warnings
target.append({"code": "missing_result_schema", "location": location, "message": "Ergebniserfassung ist nicht eindeutig festgelegt."}) exercise_ids: set[str] = set()
return for index, exercise in enumerate(cfg.get("exercises", [])):
mode = str(schema.get("mode") or "auto") location = f"Übung {index + 1}"
weight = str(schema.get("weight_mode") or "none") register(exercise.get("id"), location)
laterality = str(schema.get("laterality") or "bilateral") exercise_ids.add(_string(exercise.get("id")))
sides = str(schema.get("sides_mode") or "same") if not _string(exercise.get("name")):
if mode == "none" and weight != "none": errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
errors.append({"code": "invalid_result_schema", "location": location, "message": "Ohne Messwert darf kein Gewicht verlangt werden."})
if laterality == "bilateral" and sides == "separate": if not cfg.get("days"):
errors.append({"code": "invalid_sides", "location": location, "message": "Getrennte Seitenwerte sind nur bei einseitigen Übungen möglich."}) warnings.append({"code": "no_days", "location": "Plan", "message": "Noch keine Trainingstage angelegt."})
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."}) for day_index, day in enumerate(cfg.get("days", [])):
try: location = f"Tag {day_index + 1}"
sets = int(schema.get("sets") or 0) register(day.get("id"), location)
except (TypeError, ValueError): if not _string(day.get("name")):
sets = 0 errors.append({"code": "empty_day", "location": location, "message": "Tagesname fehlt."})
if "sets" in schema and not 1 <= sets <= 40: if not day.get("exercises"):
errors.append({"code": "invalid_sets", "location": location, "message": "Die feste Anzahl muss zwischen 1 und 40 liegen."}) warnings.append({"code": "empty_day", "location": location, "message": "Tag enthält keine Übungen."})
for item_index, item in enumerate(day.get("exercises", [])):
item_location = f"{location}, Übung {item_index + 1}"
register(item.get("id"), item_location)
exercise_id = _string(item.get("exercise_id"))
if exercise_id not in exercise_ids:
errors.append({"code": "unknown_exercise", "location": item_location, "message": "Hinterlegte Übung existiert nicht in der Übungsbibliothek."})
sets = _int(item.get("sets"), 0)
if "sets" in item and not 1 <= sets <= 40:
errors.append({"code": "invalid_sets", "location": item_location, "message": "Sätze müssen zwischen 1 und 40 liegen."})
duration = _int(item.get("duration_seconds"), 0)
if "duration_seconds" in item and duration <= 0:
errors.append({"code": "invalid_duration", "location": item_location, "message": "Dauer muss größer als 0 sein."})
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} return {"errors": errors, "warnings": warnings, "config": cfg}
File diff suppressed because it is too large Load Diff
+105 -52
View File
@@ -1,70 +1,123 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1])) sys.path.insert(0, str(Path(__file__).parents[1]))
from schema_contract import migrate_training_config, validate_training_config from schema_contract import migrate_training_config, validate_training_config
def sample_config(): def legacy_config():
return { return {
"meta": {"title": "Test", "weeks": 2}, "meta": {
"training_format": {"mode": "tabata", "fixed_interval": True, "work_seconds": 20, "rest_seconds": 10, "rounds": 8}, "title": "Alter Plan",
"phases": {"items": [{"name": "Start", "weeks": 1}, {"name": "Aufbau", "weeks": 1}]}, "subtitle": "wird reduziert",
"days": [{"num": 1, "focus": "A", "rotations": [{"label": "Block", "exercises": [{ "output_name": "plan.pdf",
"name": "Squat-Progression", "key": "squat", "result_schema": {"mode": "reps", "weight_mode": "none", "laterality": "bilateral"} },
}]}]}], "colors": {"ACCENT": "#ff00ff"},
"stages": {"squat": {"name": "Squat-Progression", "steps": [ "training_format": {"mode": "tabata", "rounds": 8},
"Deep Squat Hold", "Volle Squats" "phases": {"items": [{"name": "Phase 1"}]},
], "result_schemas": [ "stages": {"squat": {"name": "Squat", "steps": ["leicht", "schwer"]}},
{"mode": "seconds", "weight_mode": "optional", "laterality": "bilateral"}, "prepost": {
{"mode": "reps", "weight_mode": "optional", "laterality": "bilateral"}, "1": {
]}}, "warmup": "Handgelenke · Kniebeugen",
"cooldown": "Atmen<br/>Locker gehen",
"stretch": "alte Dehnung",
}
},
"days": [
{
"num": 1,
"focus": "Beine",
"color": "#123456",
"rotations": [
{
"label": "Block A",
"exercises": [
{
"id": "old-placement",
"exercise_id": "exercise-squat",
"name": "Squat",
"key": "squat",
"cue": "tief",
"result_schema": {"mode": "reps"},
}
],
}
],
}
],
} }
def test_migration_creates_stable_ids_object_steps_and_catalog(): def test_migration_flattens_legacy_plan_to_days_and_exercises():
first = migrate_training_config(sample_config(), plan_id="test") migrated = migrate_training_config(legacy_config(), plan_id="test")
assert migrated["schema_version"] == 4
assert migrated["contract_version"] == 3
assert migrated["plan_id"] == "test"
assert "colors" not in migrated
assert "training_format" not in migrated
assert "phases" not in migrated
assert "stages" not in migrated
assert "exercise_catalog" not in migrated
assert migrated["meta"] == {"title": "Alter Plan", "subtitle": "wird reduziert"}
assert migrated["exercises"] == [{"id": "exercise-squat", "name": "Squat", "cue": "tief"}]
day = migrated["days"][0]
assert day["name"] == "Beine"
assert day["warmup"]["items"] == ["Handgelenke", "Kniebeugen"]
assert day["cooldown"]["items"] == ["Atmen", "Locker gehen"]
assert "stretch" not in day
assert "rotations" not in day
item = day["exercises"][0]
assert item == {
"id": "old-placement",
"exercise_id": "exercise-squat",
"notes": "Block A",
}
def test_migration_is_stable_across_repeated_runs():
first = migrate_training_config(legacy_config(), plan_id="test")
second = migrate_training_config(first, plan_id="test") second = migrate_training_config(first, plan_id="test")
assert first["schema_version"] == 3 assert first == second
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(): def test_validation_accepts_complete_minimal_plan():
result = validate_training_config(sample_config()) cfg = {
assert result["errors"] == [] "meta": {"title": "Minimal"},
"exercises": [{"id": "exercise-pushup", "name": "Push-up"}],
"days": [
{
"id": "day-1",
"num": 1,
"name": "Tag 1",
"warmup": {"items": ["Mobilisieren"]},
"cooldown": {"items": ["Atmen"]},
"exercises": [{"id": "item-1", "exercise_id": "exercise-pushup", "sets": 3, "reps": "8-12"}],
}
],
}
assert validate_training_config(cfg)["errors"] == []
def test_missing_result_schemas_are_migrated_to_explicit_defaults(): def test_validation_rejects_unknown_day_exercise_reference():
cfg = sample_config() cfg = {
exercise = cfg["days"][0]["rotations"][0]["exercises"][0] "meta": {"title": "Kaputt"},
exercise.pop("result_schema", None) "exercises": [{"id": "exercise-pushup", "name": "Push-up"}],
cfg["stages"]["squat"].pop("result_schemas", None) "days": [
migrated = migrate_training_config(cfg, plan_id="test") {
ex_schema = migrated["days"][0]["rotations"][0]["exercises"][0]["result_schema"] "id": "day-1",
assert ex_schema["mode"] == "reps" "num": 1,
steps = migrated["stages"]["squat"]["steps"] "name": "Tag 1",
assert steps[0]["result_schema"]["mode"] == "seconds" "exercises": [{"id": "item-1", "exercise_id": "missing"}],
assert steps[1]["result_schema"]["mode"] == "reps" }
assert validate_training_config(migrated)["errors"] == [] ],
}
errors = validate_training_config(cfg)["errors"]
def test_legacy_position_id_is_persisted_across_reordering(): assert [error["code"] for error in errors] == ["unknown_exercise"]
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
+357 -1
View File
@@ -1234,6 +1234,8 @@ def _default_tracker(plan: dict[str, Any]) -> dict[str, Any]:
"revision": 1, "revision": 1,
"source_file": plan["source_file"], "source_file": plan["source_file"],
"source_hash": plan["source_hash"], "source_hash": plan["source_hash"],
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
"profile": { "profile": {
"start_date": "", "start_date": "",
"display_name": "", "display_name": "",
@@ -1274,6 +1276,7 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]:
tracker = _read_json(_tracker_path(plan["source_file"]), None) tracker = _read_json(_tracker_path(plan["source_file"]), None)
if not isinstance(tracker, dict): if not isinstance(tracker, dict):
return _default_tracker(plan) return _default_tracker(plan)
original_tracker = deepcopy(tracker)
tracker.setdefault("version", TRACKER_SCHEMA_VERSION) tracker.setdefault("version", TRACKER_SCHEMA_VERSION)
tracker.setdefault("revision", 1) tracker.setdefault("revision", 1)
tracker.setdefault("profile", {}) tracker.setdefault("profile", {})
@@ -1290,7 +1293,18 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]:
tracker.pop("analyses", None) tracker.pop("analyses", None)
tracker["version"] = TRACKER_SCHEMA_VERSION tracker["version"] = TRACKER_SCHEMA_VERSION
tracker["source_file"] = plan["source_file"] tracker["source_file"] = plan["source_file"]
tracker["source_changed"] = tracker.get("source_hash") not in (None, plan["source_hash"]) source_changed = tracker.get("source_hash") not in (None, plan["source_hash"])
tracker, migrated = _migrate_tracker_sessions(plan, tracker)
if migrated:
tracker["revision"] = int(tracker.get("revision") or 1) + 1
tracker["updated_at"] = _utc_now()
persisted = deepcopy(tracker)
persisted.pop("source_changed", None)
backup_path = SESSIONS_DIR / ".migration-backups" / f"{plan['source_file']}.pre-result-data-v2.json"
if not backup_path.exists():
_atomic_json_write(backup_path, original_tracker)
_atomic_json_write(_tracker_path(plan["source_file"]), persisted)
tracker["source_changed"] = source_changed
return tracker return tracker
@@ -1394,6 +1408,347 @@ def _format_result_data(value: Any) -> str:
return f"{prefix}{joined(data['values'])}{unit}{side}".strip() return f"{prefix}{joined(data['values'])}{unit}{side}".strip()
def _item_has_user_data(item: Any) -> bool:
if not isinstance(item, dict):
return False
status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned"))
if status != "planned" or bool(item.get("done")):
return True
if isinstance(item.get("result_data"), dict):
return True
return any(_has_text(item.get(field)) for field in (
"result", "note", "progression", "exercise_name", "progression_id", "skip_reason"
))
def _merge_migrated_item(legacy: dict[str, Any], stable: dict[str, Any]) -> dict[str, Any]:
"""Vereinigt doppelte Legacy-/Stable-ID-Einträge ohne befüllte Werte zu verlieren.
Ein durch die neue UI angelegter leerer Stable-ID-Platzhalter darf einen
bereits abgeschlossenen Legacy-Eintrag insbesondere nicht wieder auf
``planned``/``done=false`` zurücksetzen.
"""
merged = deepcopy(legacy)
legacy_status = str(legacy.get("completion_status") or ("completed" if legacy.get("done") else "planned"))
stable_status = str(stable.get("completion_status") or ("completed" if stable.get("done") else "planned"))
for key, value in stable.items():
if key not in merged:
merged[key] = deepcopy(value)
continue
empty = value is None or value == "" or value == [] or value == {}
if empty:
continue
if key in {"done", "completion_status"} and stable_status == "planned" and legacy_status != "planned":
continue
merged[key] = deepcopy(value)
if stable_status != "planned":
merged["completion_status"] = stable_status
merged["done"] = stable_status in {"completed", "partial"}
elif legacy_status != "planned":
merged["completion_status"] = legacy_status
merged["done"] = legacy_status in {"completed", "partial"}
return merged
def _exercise_lookup_for_day(plan: dict[str, Any], day_num: int) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
by_id: dict[str, dict[str, Any]] = {}
legacy_to_stable: dict[str, str] = {}
day = next((entry for entry in plan.get("days", []) if isinstance(entry, dict) and int(entry.get("num") or 0) == day_num), None)
if not isinstance(day, dict):
return by_id, legacy_to_stable
for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []:
if not isinstance(rotation, dict):
continue
for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []:
if not isinstance(exercise, dict):
continue
stable_id = str(exercise.get("id") or "")
legacy_id = str(exercise.get("legacy_id") or "")
if stable_id:
by_id[stable_id] = exercise
if legacy_id and stable_id:
by_id[legacy_id] = exercise
legacy_to_stable[legacy_id] = stable_id
return by_id, legacy_to_stable
def _progression_step_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None:
progression_id = str(item.get("progression_id") or exercise.get("progression_id") or "")
stage = plan.get("stages", {}).get(progression_id) if isinstance(plan.get("stages"), dict) else None
steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else []
wanted_id = str(item.get("progression_step_id") or "")
if wanted_id:
found = next((step for step in steps if isinstance(step, dict) and str(step.get("id") or "") == wanted_id), None)
if found:
return found
wanted_name = _plain_text(item.get("progression") or "").casefold()
if wanted_name:
return next((
step for step in steps
if isinstance(step, dict) and _plain_text(step.get("name") or "").casefold() == wanted_name
), None)
return None
def _resolved_schema_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]:
schema = deepcopy(exercise.get("result_schema") if isinstance(exercise.get("result_schema"), dict) else {})
step = _progression_step_for_item(plan, exercise, item)
if isinstance(step, dict) and isinstance(step.get("result_schema"), dict):
schema.update(step["result_schema"])
manual = item.get("result_format")
if isinstance(manual, dict):
for key in ("mode", "weight_mode", "laterality", "sides_mode", "sets", "default_sets", "locked_sets"):
if key in manual:
schema[key] = manual[key]
mode = str(schema.get("mode") or "auto")
if mode not in {"auto", "reps", "seconds", "minutes", "none"}:
mode = "auto"
weight_mode = str(schema.get("weight_mode") or "none")
if weight_mode not in {"none", "optional", "required"}:
weight_mode = "none"
laterality = str(schema.get("laterality") or "bilateral")
if laterality not in {"bilateral", "unilateral"}:
laterality = "bilateral"
sides_mode = str(schema.get("sides_mode") or "same")
if sides_mode not in {"same", "separate"}:
sides_mode = "same"
try:
sets = max(1, min(20, int(schema.get("sets") or schema.get("default_sets") or 1)))
except (TypeError, ValueError):
sets = 1
return {
"mode": mode,
"weight_mode": weight_mode,
"laterality": laterality,
"sides_mode": sides_mode if laterality == "unilateral" else "same",
"default_sets": sets,
"locked_sets": bool(schema.get("locked_sets")),
}
def _parse_legacy_result_data(value: Any, schema: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Überführt eindeutige alte Freitextergebnisse verlustfrei in result_data 2.
Die historische Eingabe ist für die konkrete Session bindend. Weicht ihre
eindeutig erkennbare Messart vom heutigen Plan ab, wird deshalb zusätzlich
ein manueller ``result_format``-Override gespeichert, statt Werte zu verwerfen.
"""
raw = str(value or "").strip()
if not raw:
return None, None
low = raw.lower().replace("×", "x")
explicit_mode: str | None = None
if re.search(r"(?:\d|\s)(?:min\.?|minuten)\b", low, flags=re.I):
explicit_mode = "minutes"
elif re.search(r"(?:\d|\s)(?:s|sek\.?|sekunden)\b", low, flags=re.I):
explicit_mode = "seconds"
recommended_mode = str(schema.get("mode") or "auto")
mode = explicit_mode or (recommended_mode if recommended_mode in {"reps", "seconds", "minutes"} else "reps")
weight_match = re.search(r"(\d+(?:[.,]\d+)?)\s*kg\b", low, flags=re.I)
weight = _result_number(weight_match.group(1)) if weight_match else None
def number_list(part: Any) -> list[float | None]:
return [
number for number in (_result_number(match.group(0)) for match in re.finditer(r"\d+(?:[.,]\d+)?", str(part or "")))
if number is not None
]
def clean_part(part: Any) -> str:
text = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", str(part or ""), flags=re.I)
return re.sub(
r"\b(?:reps?|wiederholungen|s|sek\.?|sekunden|min\.?|minuten|je seite|pro seite)\b",
" ", text, flags=re.I,
)
separate = re.search(
r"\bL(?:inks)?\s*:?\s*([^·;|]+)[·;|]\s*R(?:echts)?\s*:?\s*(.+)$",
raw, flags=re.I,
)
if separate:
left = number_list(clean_part(separate.group(1)))
right = number_list(clean_part(separate.group(2)))
if not left and not right:
return None, None
sets = max(len(left), len(right), 1)
data = _sanitize_result_data({
"version": RESULT_DATA_VERSION,
"mode": mode,
"laterality": "unilateral",
"sides_mode": "separate",
"sets": sets,
"weight_kg": weight,
"values": [],
"left_values": left,
"right_values": right,
})
else:
work = clean_part(raw)
repeated = re.search(r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)", work, flags=re.I)
if repeated:
count = max(1, min(20, int(float(repeated.group(1).replace(",", ".")))))
repeated_value = _result_number(repeated.group(2))
values = [repeated_value] * count if repeated_value is not None else []
else:
values = number_list(work)
if not values and weight is None:
return None, None
per_side = bool(re.search(r"(?:je|pro)\s+seite|/\s*seite", low, flags=re.I))
laterality = "unilateral" if per_side else str(schema.get("laterality") or "bilateral")
if laterality not in {"bilateral", "unilateral"}:
laterality = "bilateral"
# Eine einzige historische Zahlenreihe ist nie eine belastbare Links-/
# Rechts-Trennung. Bei einseitigen Übungen bedeutet sie daher "je Seite".
sides_mode = "same"
sets = max(1, min(20, len(values) or int(schema.get("default_sets") or 1)))
data = _sanitize_result_data({
"version": RESULT_DATA_VERSION,
"mode": mode,
"laterality": laterality,
"sides_mode": sides_mode,
"sets": sets,
"weight_kg": weight,
"values": values,
"left_values": [],
"right_values": [],
})
if data is None:
return None, None
actual_weight_mode = str(schema.get("weight_mode") or "none")
if weight is not None and actual_weight_mode == "none":
actual_weight_mode = "optional"
differs = (
data["mode"] != recommended_mode
or data["laterality"] != str(schema.get("laterality") or "bilateral")
or data["sides_mode"] != str(schema.get("sides_mode") or "same")
or actual_weight_mode != str(schema.get("weight_mode") or "none")
)
result_format = None
if differs:
result_format = {
"mode": data["mode"],
"laterality": data["laterality"],
"sides_mode": data["sides_mode"],
"weight_mode": actual_weight_mode,
}
return data, result_format
def _migrate_tracker_sessions(plan: dict[str, Any], tracker: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migriert alle vorhandenen Sessions serverseitig und atomar auf stabile IDs.
Dadurch hängt die Datenmigration nicht davon ab, welche Session zufällig im
Browser geöffnet oder verändert wurde. Uneindeutige Freitexte bleiben als
``result`` erhalten; eindeutige Werte werden zusätzlich strukturiert gespeichert.
"""
changed = False
tracker = deepcopy(tracker)
desired_root = {
"version": TRACKER_SCHEMA_VERSION,
"source_file": plan.get("source_file"),
"source_hash": plan.get("source_hash"),
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
}
for key, value in desired_root.items():
if tracker.get(key) != value:
tracker[key] = value
changed = True
sessions = tracker.get("sessions") if isinstance(tracker.get("sessions"), dict) else {}
if tracker.get("sessions") is not sessions:
tracker["sessions"] = sessions
changed = True
for session_key, session in sessions.items():
if not isinstance(session, dict):
continue
_, day_num = _session_key_parts(str(session_key))
if day_num == 9999:
continue
by_id, legacy_to_stable = _exercise_lookup_for_day(plan, day_num)
items = session.get("items") if isinstance(session.get("items"), dict) else {}
if session.get("items") is not items:
session["items"] = items
changed = True
for legacy_id, stable_id in list(legacy_to_stable.items()):
if legacy_id not in items or legacy_id == stable_id:
continue
legacy_item = items.pop(legacy_id)
if isinstance(legacy_item, dict):
stable_item = items.get(stable_id)
items[stable_id] = _merge_migrated_item(
legacy_item,
stable_item if isinstance(stable_item, dict) else {},
)
changed = True
for item_id, item in list(items.items()):
if not isinstance(item, dict):
continue
exercise = by_id.get(str(item_id))
status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned"))
if status not in VALID_ITEM_STATUSES:
status = "planned"
if item.get("completion_status") != status:
item["completion_status"] = status
changed = True
done = status in {"completed", "partial"}
if bool(item.get("done")) != done:
item["done"] = done
changed = True
if status != "skipped" and "skip_reason" in item:
item.pop("skip_reason", None)
changed = True
if isinstance(exercise, dict) and _item_has_user_data(item):
metadata = {
"progression_id": str(item.get("progression_id") or exercise.get("progression_id") or ""),
"exercise_name": _plain_text(item.get("exercise_name") or exercise.get("name") or ""),
}
for key, value in metadata.items():
if value and item.get(key) != value:
item[key] = value
changed = True
step = _progression_step_for_item(plan, exercise, item)
if isinstance(step, dict) and step.get("id") and not item.get("progression_step_id"):
item["progression_step_id"] = str(step["id"])
changed = True
if isinstance(item.get("result_data"), dict):
data = _sanitize_result_data(item.get("result_data"))
if data is not None:
formatted = _format_result_data(data)
if item.get("result_data") != data:
item["result_data"] = data
changed = True
if item.get("result") != formatted:
item["result"] = formatted
changed = True
else:
item.pop("result_data", None)
changed = True
elif isinstance(exercise, dict) and _has_text(item.get("result")):
schema = _resolved_schema_for_item(plan, exercise, item)
data, result_format = _parse_legacy_result_data(item.get("result"), schema)
if data is not None:
item["result_data"] = data
item["result"] = _format_result_data(data)
if result_format is not None:
item["result_format"] = result_format
changed = True
for key, value in {
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
}.items():
if session.get(key) != value:
session[key] = value
changed = True
return tracker, changed
def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]: def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]:
if not isinstance(incoming, dict): if not isinstance(incoming, dict):
raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein") raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein")
@@ -1436,6 +1791,7 @@ def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: di
for week, status in list(clean["week_statuses"].items()): for week, status in list(clean["week_statuses"].items()):
if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue
status["status"] = "closed" if status.get("status") == "closed" else "open" status["status"] = "closed" if status.get("status") == "closed" else "open"
clean, _ = _migrate_tracker_sessions(plan, clean)
return clean return clean
+31 -28
View File
@@ -72,7 +72,7 @@
.save-pill.saved { color: var(--ok); } .save-pill.saved { color: var(--ok); }
.save-pill.error { color: var(--danger); } .save-pill.error { color: var(--danger); }
.app { width: min(900px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 104px; } .app { width: min(1180px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 104px; }
.card { .card {
min-width: 0; max-width: 100%; min-width: 0; max-width: 100%;
background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius);
@@ -117,7 +117,7 @@
.status-badge.in_progress { background: var(--accent-soft); color: var(--accent); } .status-badge.in_progress { background: var(--accent-soft); color: var(--accent); }
.status-badge.stopped { background: color-mix(in srgb, var(--muted) 13%, var(--paper)); color: var(--muted); } .status-badge.stopped { background: color-mix(in srgb, var(--muted) 13%, var(--paper)); color: var(--muted); }
.status-badge.completed { background: color-mix(in srgb, var(--ok) 16%, var(--paper)); color: var(--ok); } .status-badge.completed { background: color-mix(in srgb, var(--ok) 16%, var(--paper)); color: var(--ok); }
.item-status-select{width:auto;min-width:112px;padding:5px 7px;font-size:11px;font-weight:750} .item-status-select{width:100%;min-width:0;max-width:132px;padding:5px 7px;font-size:11px;font-weight:750}
.exercise.skipped{opacity:.74}.exercise.partial{border-left-color:var(--accent)} .exercise.skipped{opacity:.74}.exercise.partial{border-left-color:var(--accent)}
.skip-reason{margin-top:9px} .skip-reason{margin-top:9px}
.notice { padding: 11px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); font-size: 12px; margin-bottom: 10px; } .notice { padding: 11px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); font-size: 12px; margin-bottom: 10px; }
@@ -139,7 +139,7 @@
.rotation-label { padding: 9px 14px; color: var(--muted); background: var(--wash-2); font-size: 11px; font-weight: 800; letter-spacing: .04em; } .rotation-label { padding: 9px 14px; color: var(--muted); background: var(--wash-2); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.exercise { padding: 13px 14px; border-top: 1px solid var(--line-soft); } .exercise { padding: 13px 14px; border-top: 1px solid var(--line-soft); }
.exercise:first-child { border-top: 0; } .exercise:first-child { border-top: 0; }
.exercise-title { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 8px; align-items: start; } .exercise-title { display: grid; grid-template-columns: minmax(112px,132px) minmax(0,1fr); gap: 10px; align-items: start; }
.exercise.done { background: color-mix(in srgb, var(--ok) 7%, var(--paper)); } .exercise.done { background: color-mix(in srgb, var(--ok) 7%, var(--paper)); }
.cue { color: var(--muted); font-size: 12px; line-height: 1.45; margin-top: 4px; } .cue { color: var(--muted); font-size: 12px; line-height: 1.45; margin-top: 4px; }
.progression-box { margin-top: 9px; padding: 9px; background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 25%, var(--line)); border-radius: var(--radius-sm); font-size: 12px; } .progression-box { margin-top: 9px; padding: 9px; background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 25%, var(--line)); border-radius: var(--radius-sm); font-size: 12px; }
@@ -284,6 +284,8 @@
.error-card { border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); background: color-mix(in srgb, var(--danger) 13%, var(--paper)); color: var(--danger); padding: 13px; border-radius: var(--radius); } .error-card { border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); background: color-mix(in srgb, var(--danger) 13%, var(--paper)); color: var(--danger); padding: 13px; border-radius: var(--radius); }
@media (max-width: 420px) { @media (max-width: 420px) {
.exercise-title { grid-template-columns: minmax(0,1fr); }
.item-status-select { max-width: none; }
.result-controls { grid-template-columns: minmax(0,1fr); } .result-controls { grid-template-columns: minmax(0,1fr); }
.result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } .result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }
.result-editor { padding: 10px; } .result-editor { padding: 10px; }
@@ -360,6 +362,7 @@
(() => { (() => {
'use strict'; 'use strict';
const BASE = document.body.dataset.base || ''; const BASE = document.body.dataset.base || '';
const RESULT_DATA_VERSION = 2;
const api = path => `${BASE}${path}`; const api = path => `${BASE}${path}`;
const state = { const state = {
plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false}, plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false},
@@ -923,7 +926,7 @@
weightMode = 'optional'; weightMode = 'optional';
} }
return { return {
version: 1, mode, laterality, sides_mode: laterality === 'unilateral' ? sidesMode : 'same', version: RESULT_DATA_VERSION, mode, laterality, sides_mode: laterality === 'unilateral' ? sidesMode : 'same',
weight_mode: weightMode, weight_mode: weightMode,
default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval), default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval),
work_seconds: Number(base.work_seconds) || null, work_seconds: Number(base.work_seconds) || null,
@@ -934,7 +937,7 @@
function emptyResultData(schema, sets = schema.default_sets) { function emptyResultData(schema, sets = schema.default_sets) {
const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1)); const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1));
return { return {
version: 1, mode: schema.mode, laterality: schema.laterality, version: RESULT_DATA_VERSION, mode: schema.mode, laterality: schema.laterality,
sides_mode: schema.laterality === 'unilateral' ? (schema.sides_mode || 'same') : 'same', sides_mode: schema.laterality === 'unilateral' ? (schema.sides_mode || 'same') : 'same',
sets: count, weight_kg: null, sets: count, weight_kg: null,
values: Array(count).fill(null), left_values: Array(count).fill(null), right_values: Array(count).fill(null) values: Array(count).fill(null), left_values: Array(count).fill(null), right_values: Array(count).fill(null)
@@ -946,7 +949,7 @@
const mode = ['reps','seconds','minutes','none'].includes(source.mode) ? source.mode : schema.mode; const mode = ['reps','seconds','minutes','none'].includes(source.mode) ? source.mode : schema.mode;
const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality; const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality;
return { return {
version: 1, mode, laterality, version: RESULT_DATA_VERSION, mode, laterality,
sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same', sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same',
sets, weight_kg: numericOrNull(source.weight_kg), sets, weight_kg: numericOrNull(source.weight_kg),
values: ensureResultArray(source.values, sets), values: ensureResultArray(source.values, sets),
@@ -956,11 +959,11 @@
} }
function parseLegacyResultData(result, schema) { function parseLegacyResultData(result, schema) {
const raw = String(result || '').trim(); const raw = String(result || '').trim();
if (!raw) return {data:null, incompatible:false}; if (!raw) return {data:null, incompatible:false, formatOverride:null};
const low = raw.toLowerCase().replace(/×/g, 'x'); const low = raw.toLowerCase().replace(/×/g, 'x');
const detectedMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes' const explicitMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes'
: /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : 'reps'; : /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : null;
if (schema.mode === 'none' || detectedMode !== schema.mode) return {data:null, incompatible:true}; const detectedMode = explicitMode || (['reps','seconds','minutes'].includes(schema.mode) ? schema.mode : 'reps');
const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i); const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i);
const weight = weightMatch ? numericOrNull(weightMatch[1]) : null; const weight = weightMatch ? numericOrNull(weightMatch[1]) : null;
const numberList = part => [...String(part || '').matchAll(/\d+(?:[.,]\d+)?/g)].map(match => numericOrNull(match[0])).filter(value => value !== null); const numberList = part => [...String(part || '').matchAll(/\d+(?:[.,]\d+)?/g)].map(match => numericOrNull(match[0])).filter(value => value !== null);
@@ -971,9 +974,12 @@
const right = numberList(cleanPart(separate[2])); const right = numberList(cleanPart(separate[2]));
const sets = Math.max(left.length, right.length, 1); const sets = Math.max(left.length, right.length, 1);
const data = emptyResultData({...schema,laterality:'unilateral'}, sets); const data = emptyResultData({...schema,laterality:'unilateral'}, sets);
data.sides_mode = 'separate'; data.weight_kg = weight; data.mode = detectedMode; data.sides_mode = 'separate'; data.weight_kg = weight;
data.left_values = ensureResultArray(left, sets); data.right_values = ensureResultArray(right, sets); data.left_values = ensureResultArray(left, sets); data.right_values = ensureResultArray(right, sets);
return {data, incompatible:false}; const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode;
const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode
? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null;
return {data, incompatible:false, formatOverride};
} }
let work = cleanPart(raw); let work = cleanPart(raw);
let values = []; let values = [];
@@ -984,21 +990,17 @@
} else { } else {
values = numberList(work); values = numberList(work);
} }
if (!values.length) return {data:null, incompatible:false}; if (!values.length && weight === null) return {data:null, incompatible:false, formatOverride:null};
const perSide = /(?:je|pro)\s+seite|\/\s*seite/i.test(low); const perSide = /(?:je|pro)\s+seite|\/\s*seite/i.test(low);
if (!perSide && schema.laterality === 'unilateral' && schema.sides_mode === 'separate' const sets = Math.max(1, Math.min(20, values.length || Number(schema.default_sets) || 1));
&& values.length === Math.max(1, Number(schema.default_sets) || 1) * 2) { const data = emptyResultData({...schema,mode:detectedMode,laterality: perSide ? 'unilateral' : schema.laterality,sides_mode:'same'}, sets);
const sets = Math.max(1, Number(schema.default_sets) || 1); data.mode = detectedMode;
const data = emptyResultData({...schema,laterality:'unilateral',sides_mode:'separate'}, sets); data.sides_mode = 'same';
data.sides_mode = 'separate'; data.weight_kg = weight;
data.left_values = ensureResultArray(values.filter((_, index) => index % 2 === 0), sets);
data.right_values = ensureResultArray(values.filter((_, index) => index % 2 === 1), sets);
return {data, incompatible:false};
}
const sets = Math.max(1, Math.min(20, values.length));
const data = emptyResultData({...schema,laterality: perSide ? 'unilateral' : schema.laterality}, sets);
data.weight_kg = weight; data.values = ensureResultArray(values, sets); data.weight_kg = weight; data.values = ensureResultArray(values, sets);
return {data, incompatible:false}; const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode;
const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode
? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null;
return {data, incompatible:false, formatOverride};
} }
function resultEditorModel(exercise, item, progression) { function resultEditorModel(exercise, item, progression) {
const recommended = resolvedResultSchema(exercise, progression, item); const recommended = resolvedResultSchema(exercise, progression, item);
@@ -1016,9 +1018,10 @@
const legacy = parseLegacyResultData(item?.result || '', recommended); const legacy = parseLegacyResultData(item?.result || '', recommended);
if (legacy.data) { if (legacy.data) {
const weightMode = numericOrNull(legacy.data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode; const weightMode = numericOrNull(legacy.data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode;
return {schema:{...recommended,weight_mode:weightMode}, data:legacy.data, source:'legacy', incompatible:false}; const legacySchema = {...recommended,...(legacy.formatOverride || {}),weight_mode:legacy.formatOverride?.weight_mode || weightMode,schema_source:legacy.formatOverride ? 'session' : recommended.schema_source};
return {schema:legacySchema, data:legacy.data, source:'legacy', incompatible:false, formatOverride:legacy.formatOverride};
} }
return {schema:recommended, data:emptyResultData(recommended), source:item?.result ? 'legacy-incompatible' : 'empty', incompatible:legacy.incompatible}; return {schema:recommended, data:emptyResultData(recommended), source:item?.result ? 'legacy-incompatible' : 'empty', incompatible:legacy.incompatible, formatOverride:null};
} }
function formatResultData(data) { function formatResultData(data) {
@@ -1113,7 +1116,7 @@
const data = normalizeResultData(model.data, model.schema); const data = normalizeResultData(model.data, model.schema);
mutator(data, model.schema); mutator(data, model.schema);
const normalized = normalizeResultData(data, {...model.schema,mode:data.mode,laterality:data.laterality,default_sets:data.sets}); const normalized = normalizeResultData(data, {...model.schema,mode:data.mode,laterality:data.laterality,default_sets:data.sets});
if (storeFormat) item.result_format = {mode: normalized.mode, laterality: normalized.laterality, sides_mode: normalized.sides_mode, weight_mode: model.schema.weight_mode}; if (storeFormat || model.formatOverride) item.result_format = {mode: normalized.mode, laterality: normalized.laterality, sides_mode: normalized.sides_mode, weight_mode: model.schema.weight_mode};
if (normalized.mode !== 'none' && (resultHasValues(normalized) || numericOrNull(normalized.weight_kg) !== null)) { if (normalized.mode !== 'none' && (resultHasValues(normalized) || numericOrNull(normalized.weight_kg) !== null)) {
item.result_data = normalized; item.result_data = normalized;
item.result = formatResultData(normalized); item.result = formatResultData(normalized);
+121 -1
View File
@@ -379,8 +379,12 @@ def test_structured_result_is_saved_canonically_and_used_by_analysis(tmp_path: P
assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200
raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8")) raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8"))
item = raw["sessions"]["w01-d01"]["items"]["d1-r0-e0"] plan = module._normalize_plan(training)
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
assert "d1-r0-e0" not in raw["sessions"]["w01-d01"]["items"]
item = raw["sessions"]["w01-d01"]["items"][stable_id]
assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps" assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps"
assert item["result_data"]["version"] == 2
assert item["result_data"]["weight_kg"] == 4.0 assert item["result_data"]["weight_kg"] == 4.0
assert item["result_data"]["left_values"] == [9.0, 8.0, 8.0] assert item["result_data"]["left_values"] == [9.0, 8.0, 8.0]
@@ -458,3 +462,119 @@ def test_frontend_contains_explicit_week_status_and_item_skip_states():
assert "Übersprungen" in source assert "Übersprungen" in source
assert "Teilweise" in source assert "Teilweise" in source
assert "eine neue analyse überschreibt die vorherige" in source.lower() assert "eine neue analyse überschreibt die vorherige" in source.lower()
def test_legacy_results_are_migrated_server_side_without_hidden_values(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
payload = sample_plan()
payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = {
"mode": "reps", "weight_mode": "required", "laterality": "unilateral",
"sides_mode": "separate", "sets": 3,
}
training.write_text(json.dumps(payload), encoding="utf-8")
sessions = tracker_data / "sessions"
sessions.mkdir(parents=True)
(sessions / "training.json").write_text(json.dumps({
"version": 5,
"revision": 4,
"sessions": {
"w01-d01": {
"status": "completed",
"items": {
"d1-r0-e0": {
"done": True,
"progression": "assistiert",
"result": "4kg, 6/6/5 je Seite",
"note": "Altwert",
}
},
}
},
}), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
detail = client.get("/api/plans/training.json").get_json()
plan = detail["plan"]
tracker = detail["tracker"]
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
item = tracker["sessions"]["w01-d01"]["items"][stable_id]
assert "d1-r0-e0" not in tracker["sessions"]["w01-d01"]["items"]
assert item["result_data"] == {
"version": 2,
"mode": "reps",
"laterality": "unilateral",
"sides_mode": "same",
"sets": 3,
"weight_kg": 4.0,
"values": [6.0, 6.0, 5.0],
"left_values": [None, None, None],
"right_values": [None, None, None],
}
assert item["result_format"]["sides_mode"] == "same"
assert item["result"] == "4 kg · 6/6/5 Reps je Seite"
assert tracker["revision"] == 5
persisted = json.loads((sessions / "training.json").read_text(encoding="utf-8"))
assert persisted["sessions"]["w01-d01"]["items"][stable_id]["result_data"]["version"] == 2
backup = tracker_data / "sessions" / ".migration-backups" / "training.json.pre-result-data-v2.json"
assert json.loads(backup.read_text(encoding="utf-8"))["version"] == 5
assert client.get("/api/plans/training.json").get_json()["tracker"]["revision"] == 5
def test_legacy_mode_override_and_zero_weight_are_preserved(tmp_path: Path):
plans = tmp_path / "plans"
tracker_data = tmp_path / "tracker"
plans.mkdir()
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
seconds, override = module._parse_legacy_result_data("6x20 s", {
"mode": "reps", "weight_mode": "none", "laterality": "bilateral",
"sides_mode": "same", "default_sets": 6,
})
assert seconds["mode"] == "seconds"
assert seconds["values"] == [20.0] * 6
assert override["mode"] == "seconds"
weighted, override = module._parse_legacy_result_data("0kg, 8/7", {
"mode": "reps", "weight_mode": "required", "laterality": "bilateral",
"sides_mode": "same", "default_sets": 2,
})
assert weighted["weight_kg"] == 0.0
assert weighted["values"] == [8.0, 7.0]
assert override is None
def test_empty_stable_placeholder_does_not_erase_completed_legacy_item(tmp_path: Path):
plans = tmp_path / "plans"
tracker_data = tmp_path / "tracker"
plans.mkdir()
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
merged = module._merge_migrated_item(
{"done": True, "completion_status": "completed", "result": "8/7", "note": "wichtig"},
{"done": False, "completion_status": "planned", "result": "", "note": "", "exercise_name": "Squat"},
)
assert merged["done"] is True
assert merged["completion_status"] == "completed"
assert merged["result"] == "8/7"
assert merged["note"] == "wichtig"
assert merged["exercise_name"] == "Squat"
def test_frontend_hotfix_uses_result_data_v2_and_non_overlapping_status_layout():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "const RESULT_DATA_VERSION = 2" in source
assert "grid-template-columns: minmax(112px,132px) minmax(0,1fr)" in source
assert "@media (max-width: 420px)" in source
assert ".exercise-title { grid-template-columns: minmax(0,1fr); }" in source
assert ".app { width: min(1180px, 100%)" in source
assert "formatOverride" in source
-16995
View File
File diff suppressed because it is too large Load Diff