23 KiB
REKORD - Technische Dokumentation
Vollständige technische Doku für den selbstgehosteten Trainingsverlaufs-Tracker. Funktionen und Bedienung: siehe README.md.
- Überblick & Stack
- Projektstruktur
- Zentrale Architektur-Entscheidungen
- Backend
- Frontend
- config.json - Schema
- Deployment
- Tests
- Erweitern
Überblick & Stack
Ein Python-Backend aus der Standardbibliothek serviert statische Dateien und
eine JSON-API; ein JS-Frontend ohne Framework baut die UI. Persistenz: SQLite
in data/state.db (Pfad per TRAINING_STATE_DIR).
Browser (index.html, styles.css, app.js)
| JSON-API (http.server)
v
server.py -> SQLite (sessions, entries, settings)
|
config.json (fachlicher Plan, serverseitig normalisiert)
Kein Framework, kein Build-Schritt, keine externen Abhängigkeiten (nur
Python-Standardbibliothek). Portabel per python3 oder Docker.
Projektstruktur
server.py Backend: API, SQLite, Config-Normalisierung, Export/Import
index.html Seitengerüst (Tabs, View-Container, Overlay, Toast)
styles.css Design-Tokens + Komponenten (Theme/Akzent via data-Attribute)
app.js Frontend: Zustandsmodell, Views, Autosave
config.json Fachlicher Plan (Übungen, Sätze, Maschinen, Erklärungen)
demo-data.json 5 Wochen Verlauf zum Ausprobieren (Import über Tab "Daten")
tests/ Test-Suiten (API + jsdom-UI, offener Modus bzw. Auth)
Dockerfile Container-Image (slim Python, nur Standardbibliothek)
docker-compose.yml CasaOS-tauglich, Daten als echter Ordner
data/ Zustand (SQLite), gitignored
Zentrale Architektur-Entscheidungen
- Konfiguration statt Code. Der komplette Trainingsplan liegt in
config.json. Der Server normalisiert die Rohstruktur beim Start einmal; das Frontend kennt nur die normalisierte Form. Ein Nicht-Programmierer pflegt nur die JSON. - Stabile Set-IDs + Übungs-Isolierung. Jedes Set ist eine DB-Zeile
(
entries) mit Auto-ID. Updates sind Upserts über(session_id, exercise, sort). Löschen passiert nur innerhalb derselben Übung (NOT INder gelieferten Sorts). Eine Änderung in Übung A berührt Übung B strukturell nie. - Kein Re-Render beim Tippen. Die Heute-Ansicht wird einmal gebaut; Tippen schreibt nur ins Set-State-Objekt und löst einen Debounce-Autosave aus. Das DOM wird nie global neu gebaut - Fokus/Cursor bleiben intakt.
- Keine Geister-Einträge.
sessions.statusistactiveoderdone. Der Verlauf zeigt nurdone. Gestartete Sessions überleben Reloads und sind per Knopf übertragbar oder verwerfbar. - Serverzeit ist maßgeblich. Datum und Endzeit kommen vom Server
(
/api/meta), damit nichts am Browser-TZ scheitert. - Auto-Reset ist serverseitig und opt-in. Nur wenn die Einstellung gesetzt ist, füllt der Server beim Session-Start das letzte Workout ein.
- Mehrbenutzer mit offenem Einzelmodus. Solange kein Benutzer existiert, läuft die App ohne Anmeldung (user_id=0). Der erste registrierte Benutzer übernimmt vorhandene Daten; danach ist für alle Daten ein Bearer-Token nötig und alles ist strikt pro Benutzer getrennt.
- Design als Richtung, Nutzbarkeit zuerst. 0px Radius, 2px-Borders, ein Akzent, dunkler Grund. Zustaende (Hover/Fokus/Aktiv) ueberall. Keine Verläufe, kein Glow, keine Emojis, keine Em-Dashes.
Backend
HTTP-API (Referenz)
| Methode | Pfad | Beschreibung |
|---|---|---|
| GET | / |
index.html |
| GET | /styles.css |
Styles |
| GET | /app.js |
Frontend-Code |
| GET | /api/meta |
{today, now, auth_required, users, language} - Serverzeit + Auth-Status + Server-Sprache |
| GET | /api/config |
normalisierte Konfiguration |
| GET | /api/settings |
Einstellungen des Benutzers (Default + gespeicherte Werte) |
| PUT | /api/settings |
Teil-Update, Body {key: value}, liefert vollständige Settings |
| GET | /api/sessions |
Filter: activity, status (active|done), from, to |
| POST | /api/sessions |
Session starten, Body {activity, day?, date?, start_time?} |
| PATCH | /api/sessions/<id> |
Teil-Update: {meta: {...}} und/oder {sets: [...]} |
| POST | /api/sessions/<id>/autofill |
"Alles zurücksetzen": Sätze auf letztes Workout setzen |
| DELETE | /api/sessions/<id> |
Session samt Sätzen löschen |
| GET | /api/workouts |
abgeschlossene Workouts, Übungen gruppiert (Filter: activity, from, to, limit) |
| GET | /api/reset-values?exercise=&set= |
letzte abgeschlossene Messung eines Sets |
| GET | /api/stats?exercise= |
Steigerung je Termin (bestes Gewicht + Delta) |
| GET | /api/export?format=json|csv |
kompletter Stand des Benutzers |
| POST | /api/import |
{format, data} (siehe Export/Import) |
| POST | /api/register |
erster Benutzer (nur solange keine existieren) |
| POST | /api/login |
{username, password} -> {token, username, id} |
| POST | /api/logout |
Token invalidieren |
| POST | /api/password |
eigenes Passwort ändern {current_password, new_password} (Auth) |
| GET | /api/me |
{username, is_owner} (401 ohne gültigen Token) |
| GET | /api/users |
Benutzerliste (nur Owner) |
| POST | /api/users |
Benutzer anlegen {username, password} (nur Owner) |
| PUT | /api/users/<id>/password |
Passwort setzen/zurücksetzen {password} (nur Owner, invalidiert alte Tokens) |
| DELETE | /api/users/<id> |
Benutzer + Daten löschen (nur Owner, nicht selbst) |
PATCH /api/sessions/<id> akzeptiert zwei unabhängige Blöcke:
{ "meta": { "date": "2026-09-20", "start_time": "17:30", "end_time": "18:40",
"note": "...", "status": "done", "fields": { "distanceKm": "5,2" } },
"sets": [ { "exercise": "Push/Bankdrücken KH (je Hand)", "sort": 0,
"weight": "18,5", "reps": "10", "done": 1 } ] }
meta.statusaufdoneohneend_timeträgt die Serverzeit ein.setswird pro Übung reconciliert (siehe unten).- Fehler: 400 (Body/Validierung), 404 (unbekannte Session), 500.
Datenmodell (SQLite)
CREATE TABLE sessions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity TEXT NOT NULL, -- 'gym' | 'run' | 'ride'
day TEXT NOT NULL DEFAULT '', -- Zyklus-Tag (Kraft), z.B. 'Push'
date TEXT NOT NULL, -- YYYY-MM-DD (Serverzeit)
start_time TEXT NOT NULL DEFAULT '', -- HH:MM
end_time TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
fields TEXT NOT NULL DEFAULT '{}', -- Cardio-Felder als JSON
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'done'
created_at TEXT NOT NULL DEFAULT '',
user_id INTEGER NOT NULL DEFAULT 0 -- 0 = offener Modus
);
CREATE TABLE entries(
id INTEGER PRIMARY KEY AUTOINCREMENT, -- stabile Set-ID
session_id INTEGER NOT NULL,
exercise TEXT NOT NULL, -- Schlüssel 'Tag/Übungsname'
sort INTEGER NOT NULL DEFAULT 0, -- 0-basiert
weight TEXT NOT NULL DEFAULT '', -- Rohstring, '' = leer (nie 0)
reps TEXT NOT NULL DEFAULT '',
done INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE settings(
user_id INTEGER NOT NULL DEFAULT 0, -- 0 = offener Modus
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY(user_id, key)
);
CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tokens(
token_hash TEXT PRIMARY KEY, -- SHA-256 des Bearer-Tokens
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
);
Gewichte/Reps werden als Rohstrings gespeichert ('' = leer, nie "0"),
damit "leer eingeben = leer" gilt. Beide Dezimaltrennzeichen (, und .)
werden beim Parsen verstanden.
Set-Reconcile (Zustands-Isolierung)
_reconcile_sets(con, sid, sets) gruppiert den Payload nach exercise und
verarbeitet jede Übung getrennt:
- Für jedes übermittelte Set:
UPSERTüber(session_id, exercise, sort). Bestehende Zeilen behalten ihre ID. - Löschung nur von Sätzen dieser Übung, deren
sortim Payload fehlt (DELETE ... WHERE exercise=? AND sort NOT IN (...)).
Konsequenz: Der Client kann pro Übung einzeln speichern (nur "schmutzige"
Übungen), ohne dass andere Übungen angefasst werden. Das autofill-Endpoint
ist die einzige bulk-Ausnahme und bewusst explizit ("Alles zurücksetzen").
Einstellungen
Defaults in DEFAULT_SETTINGS:
{
"theme": "dark", # dark | system | light (system folgt dem Geraet)
"oled": False, # reines Schwarz fuer den dunklen Modus
"accent": "white", # Palette-Schluessel (siehe Frontend)
"autoReset": False, # letztes Workout der Kategorie beim Session-Start laden
"planPrefill": False, # Plan-Gewichte (startKg) beim Session-Start vorbefuellen
"showTips": True, # Uebungs-Hinweise anzeigen
"language": "de", # de | en
"setupDone": False, # Ersteinrichtungs-Wizard abgeschlossen (serverweit)
"onboarded": False, # persoenlicher Wizard (Sprache/Darstellung) abgeschlossen
"weightUnit": "kg", # kg | lb (Anzeige-Einheit fuer Gewichte)
"startTab": "today", # Standard-Ansicht beim Start
}
GET /api/settings liefert immer den vollständigen, typ-korrekten Stand
(Booleans als Boolean). Schichtung: Defaults < Server-Standard (user_id=0,
gesetzt im Ersteinrichtungs-Wizard) < eigene Werte des Benutzers. Setzt der
erste Benutzer (Owner) language/accent, wird das zugleich als
Server-Standard übernommen. GET /api/meta enthält language (Server-
Standard), damit auch der Login-Screen in der richtigen Sprache erscheint.
Beim ersten registrierten Benutzer werden die Sessions aus dem offenen Modus übernommen (MOVE); die Settings werden KOPIERT, damit der Server-Standard für weitere Benutzer erhalten bleibt.
Mehrbenutzer & Authentifizierung
Offener Modus: Solange die users-Tabelle leer ist, ist user_id = 0
(OPEN_USER) und alle /api/*-Endpunkte funktionieren ohne Token - exakt wie
im Einzelmodus. Beim ersten registrierten Benutzer (uid 1 = Owner) werden
vorhandene Sessions/Einstellungen per Migration übernommen
(UPDATE ... WHERE user_id = 0).
Auth: Sobald ein Benutzer existiert, liefert _require_user() 401, wenn
kein gültiges Authorization: Bearer <token> mitkommt. Öffentlich bleiben
nur /api/meta, /api/login, /api/register sowie die statischen Dateien.
- Passwörter: PBKDF2-HMAC-SHA256, 200.000 Iterationen, Salt pro Benutzer.
- Tokens: 32-Byte-Zufall, nur der SHA-256-Hash liegt in der DB
(
tokens.token_hash). - Passwort ändern:
POST /api/password(eigenes, mit aktuellem Passwort),PUT /api/users/<id>/password(Owner-Reset, invalidiert alte Tokens des Benutzers). - Rechte: Benutzerverwaltung (
GET/POST/PUT/DELETE /api/users) nur für den Owner (uid 1). Löschen entfernt Sessions, Entries, Settings und Tokens.
Daten-Scoping: Alle Datenabfragen laufen über _user_clause(user_id)
(user_id = ?), im offenen Modus user_id = 0. Sessions, Settings, Reset-
Werte, Statistiken, Export/Import und die Workouts-API sind damit strikt pro
Benutzer getrennt.
Workouts-API
GET /api/workouts liefert abgeschlossene Sessions in einer für externe
Auswertungen gedachten Form:
[ { "id": 5, "activity": "gym", "activity_name": "Gym (Kraft)",
"day": "Push", "date": "2026-09-20", "start_time": "18:00",
"end_time": "19:15", "duration_min": 75, "note": "",
"exercises": [ { "exercise": "Push/Bankdrücken KH (je Hand)",
"name": "Bankdrücken KH (je Hand)",
"sets": [ { "sort": 0, "weight": "18,5", "reps": "10", "done": 1 } ] } ],
"fields": {} } ]
Filter: activity, from, to, limit. Cardio-Workouts haben leere
exercises und die Feldwerte in fields.
Auto-Reset / Autofill
last_workout_sets(exercise, exclude_session):
- Findet die jüngste abgeschlossene Session (
status='done', ungleich der aktuellen in-Arbeit-Session) mit mindestens einem Satz, der ein Gewicht hat. - Liefert nur Sätze mit
weight != '', in Original-Reihenfolge.
autofill_session(sid, exercises=None):
exercises=None→ alle Übungen des Sessions-Tags (aus der Config) plus alle, die bereits Sätze haben.- Pro Übung: alte Sätze löschen, gefüllte Sätze des letzten Workouts neu
einfügen,
done = 0(frische Abhak-Status). - Übungen ohne Verlauf bleiben unangetastet.
Aufrufstellen:
POST /api/sessions- nur wennsettings.autoResetaktiv und die Aktivitätstrengthist.POST /api/sessions/<id>/autofill- manuell über den Knopf "Alles zurücksetzen" im Session-Kopf.
Export / Import
- JSON (
trainingsplan-export-v2):{format, exported_at, config, sessions}. Import = Merge per Session-ID: existiert die ID im eigenen Konto, wird die Session aktualisiert (Entries ersetzt), sonst neu angelegt- die Original-ID wird übernommen, wenn sie global frei ist; belegte IDs (z.B. durch einen anderen Benutzer) bekommen eine freie ID oberhalb aller bekannten. Nichts wird gelöscht, pro Benutzer getrennt.
- CSV: eine Zeile pro Kraft-Satz (
kind=set) bzw. pro Cardio-Session (kind=cardio). Spalten:session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, doneplus die Cardio-Feld-Schlüssel aus der Config. Import gruppiert persession_id(oder per Session-Tupel bei handgebauten Dateien). BOM wird beim Import entfernt.
Frontend
Zustandsmodell & Kein-Re-Render-Prinzip
Ein SessionModel (makeModel) hält pro Aktivität:
exercises[exerciseKey]- Array von Set-Objekten{id, weight, reps, done}exKeys- geordnete Liste der Übungen dieser Session (Tag + manuell hinzugefügte aus anderen Tagen)dirtyEx- Set der Übungen mit ungespeicherten ÄnderungenmetaDirty- Notiz/Cardio-Felder geändert
Die Heute-Ansicht wird einmal gebaut. Jede Eingabe schreibt nur in das eigene Set-Objekt und markiert die Übung als dirty. Es gibt keine globalen Re-Renders; lokale Rebuilds passieren nur bei expliziten Aktionen (Satz hinzufügen/entfernen, Übung hinzufügen/entfernen, "Alles zurücksetzen") - nie während des Tippens.
Autosave
scheduleAutosave debounced (700 ms) → flushAutosave:
- sendet nur dirty Übungen als
PATCH {sets}(pro Übung) und dirty Meta alsPATCH {meta}. - Entfernte Übungen werden mit leerem Set-Array übertragen → der Server löscht ihre Einträge.
- Ein globaler Busy-Flag mit Queue-Activity verhindert parallele Flushes mehrerer Aktivitäten.
finishSessionflusht zuerst, bricht bei nicht gespeicherten Änderungen ab, setzt dannstatus=done+end_timeund baut die Karte zurück auf "Session starten".
Set-Kette & Abhak-Status
toggleSetDone hält die Invariante "aktiv ist ein Präfix":
- Klick auf Set i (nicht aktiv) → Sets 1..i aktiv.
- Klick auf Set i (aktiv) → Sets i..n inaktiv (rückwärts).
- Gewicht/Reps werden nie verändert;
aria-pressedund die Ankreuz-Box (✓) werden mitgeführt.
Der Abhak-Status wird in entries.done gespeichert und im Verlauf angezeigt
(✓/□ je Satz) sowie im Inline-Editor als Checkbox bearbeitet.
Übung aus anderem Tag
Der Picker im Session-Kopf listet alle Übungen des Zyklus außer den bereits
enthaltenen. addExercise hängt die Übung an exKeys und rendert den Block;
removeExercise (nur für nicht-Tag-Übungen) entfernt ihn und meldet die
Übung als dirty (leeres Set-Array → Server löscht). Da Übungen erst mit
Werten persistieren, entstehen keine Geister-Einträge.
Theme & Akzent
applySettings löst das Theme auf: system wird über
prefers-color-scheme auf dark/light abgebildet (mit Media-Query-Watch);
ist oled aktiv und die Auflösung dark, greifen die rein-schwarzen Tokens.
Danach setzt es data-theme und data-accent auf <html>. Die
Design-Tokens liegen als CSS-Variablen in :root; Theme- und
Akzent-Overrides stehen in html[data-theme=...] / html[data-accent=...].
Alle Akzente nutzen eine einheitliche dunkle Tinte (--accent-ink) mit
dedizierten Hover-/Active-Tönen (mindestens 4,2:1 Kontrast). showTips
steuert zusätzlich die Klasse no-tips auf der Heute-Ansicht.
Sprache (i18n)
Alle UI-Texte laufen über t(key) aus dem LANG-Dictionary (de/en).
lang() liest app.settings.language; ohne Einstellung fällt es auf de
zurück. Häufige Server-Fehlermeldungen werden über eMsg() lokalisiert
(z.B. "Benutzername oder Kennwort inkorrekt"). Der Login-Screen nutzt die
Server-Standardsprache aus GET /api/meta (settings werden erst nach Login
geladen).
Ersteinrichtungs-Wizard & Onboarding
- Wizard (
renderSetupWizard): einmalig, solangesetupDonefehlt. Schritte mit expliziter Navigation (Zurück / Weiter / Einrichtung abschließen), Auswahl springt NICHT automatisch weiter; eine Segment-Fortschrittsleiste (wizardProgress) oben zeigt den Stand:- Sprache (serverweit) - kommt zuerst, damit der Wizard lesbar ist
- Einzel- oder Mehrbenutzermodus (bei Mehrbenutzer wird der erste Benutzer direkt angelegt)
- Darstellung: Theme (Dunkel/System/Hell) + OLED-Schalter + Akzentfarbe
Abschließen persistiert alles und setzt
setupDone(+onboardedfür den Owner).
- Onboarding: einloggende Benutzer ohne
onboardeddurchlaufen einmalig Sprache -> Darstellung für ihr eigenes Konto.
Session-Vorbefuellung beim Start
fill_session_start befüllt eine frische Session pro Übung:
autoReset(Letztes Training vorbefüllen): letztes abgeschlossenes Workout der Übung (nur gefüllte Sätze) - hat Vorrang.planPrefill(Plan-Gewichte vorbefüllen): startKg aus der Config, Anzahl wie im Plan, auch ohne Verlauf. Übungen ohne Quelle bleiben leer (der Client seedet Plan-Sätze leer).
Uebungen sortieren & Wochen-Uebersicht
- Sortieren: Drag & Drop auf dem Uebungs-Block (
attachDragReorder); die Reihenfolge wird insession.fields.exerciseOrdergespeichert und beimakeModelwiederhergestellt. - Wochen-Uebersicht: im Verlauf zwischen Liste und Woche umschaltbar; eine 7-Tage-Spalte (Montag-Sonntag) mit den Sessions des Tages, blätterbar über Vorige/Nächste Woche.
config.json - Schema
{
"profile": { "name": "", "weightUnit": "kg", "repsLabel": "reps" },
"activities": [
{
"id": "gym", // eindeutig
"name": "Gym (Kraft)",
"type": "strength", // strength | cardio
"active": true,
"displayOrder": 1,
"cycle": { // nur strength
"name": "Push/Pull/Legs",
"days": [
{
"key": "Push", // Teil des Übungs-Schlüssels
"title": "Push",
"exercises": [
{
"name": "Bankdrücken KH (je Hand)",
"sets": 4, // Zahl = uniform ODER Array für einzelne Sätze:
// "sets": [ { "weight": 18, "reps": 8 }, { "weight": 15, "reps": 10 } ],
"reps": "8-10", // Ziel, als Hinweis
"startKg": 18, // Start-Gewicht, als Hinweis
"tags": ["brust", "kh"],
"info": "Erklärung + passende Maschine (Fragezeichen-Overlay)"
}
]
}
]
}
},
{
"id": "run", // Cardio
"name": "Laufen",
"type": "cardio",
"displayOrder": 2,
"fieldTemplates": [
{ "key": "distanceKm", "label": "Distanz", "unit": "km",
"kind": "number", "step": 0.1 },
{ "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number" },
{ "key": "pacePerKm", "label": "Pace", "unit": "min/km", "kind": "pace" },
{ "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "optional": true },
{ "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "optional": true }
]
}
]
}
Der Server normalisiert die Struktur einmalig beim Start
(normalize_config). Verlaufsdaten hängen an den Übungsschlüsseln
Tag/Übungsname - Übungen umbenennen bricht die Zuordnung zum alten
Verlauf.
Deployment
Environment-Variablen
| Variable | Default | Bedeutung |
|---|---|---|
PORT |
8080 |
HTTP-Port |
TRAINING_STATE_DIR |
./data |
Verzeichnis für state.db |
Port auch als erstes Argument: python3 server.py 8080.
Docker / CasaOS
Dockerfile:python:3.13-slim,WORKDIR /app,VOLUME /app/data,HEALTHCHECKgegen/api/meta.docker-compose.yml: bindet/DATA/AppData/trainingsplan/dataals echten Ordner (CasaOS-tauglich, sichtbar/backupbar). Optional kannconfig.jsonals read-only Mount eingebunden werden, um den Plan ohne Image-Rebuild zu ändern (danach Container neu starten).
Tests
Vier Testläufe unter tests/ (laufen gegen einen echten Server-Subprozess
mit frischem TRAINING_STATE_DIR):
| Suite | Checks | Inhalt |
|---|---|---|
test_api.py |
39 | Session-Lebenszyklus, Set-Isolierung, stabile IDs, Reset, Statistik, Export/Import, Fehlerfälle (offener Modus) |
test_api_settings.py |
18 | Settings-Defaults/-Persistenz/-Typen, Auto-Reset (nur gefüllte Sets), Autofill, Plan-Gewichte vorbefüllen (mit/ohne Verlauf) |
test_auth.py |
43 | Register/Login/Logout, 401-Gate, Alt-Daten-Migration, Benutzerverwaltung, Passwort ändern/zurücksetzen, Server-Sprach-Standard, strikte Daten-Isolation, Workouts-API, Demo-Import, Benutzer-Löschung |
test_ui.js (jsdom) |
28 | Ersteinrichtungs-Wizard (Zurück/Weiter), Session starten, Tippen ohne Fokus-Verlust, Set-Button/Box, Set-Kette, Übung aus anderem Tag, Drag & Drop sortieren, Wochenübersicht, Theme/System/OLED/Akzent, Toggles, Auto-Reset-Flow |
test_ui_auth.js (jsdom) |
23 | Wizard (Modus, Sprache, Darstellung), Sprachwechsel live, Onboarding, Login-Gate mit Inline-Fehler, eingeloggter Start, Einstellungen (Konto-Kennwort, Admin nicht in Benutzerliste) |
test_ui.js benötigt jsdom: npm install jsdom@22.1.0 und Start mit
NODE_PATH=<...>/node_modules node tests/test_ui.js.
Erweitern
- Neue Übung/neuen Tag: in
config.jsonergänzen (siehe Schema). Server neu starten. - Neues Cardio-Feld:
fieldTemplateserweitern; Unit/Label kommen aus der Config, der Export/Import baut die CSV-Spalten automatisch daraus. - Neue Einstellung:
DEFAULT_SETTINGSinserver.py+ Eintrag inrenderSettingsViewinapp.js. - Neuer Theme-/Akzentwert: CSS-Override in
styles.cssergänzen; den Akzent zusätzlich inACCENTS(app.js) für die Swatches registrieren. - Neue API-Route: Handler in
server.pyunterdo_GET/do_POST/do_PATCH/do_PUT/do_DELETEergänzen.