538 lines
23 KiB
Markdown
538 lines
23 KiB
Markdown
# REKORD - Technische Dokumentation
|
|
|
|
Vollständige technische Doku für den selbstgehosteten Trainingsverlaufs-Tracker.
|
|
Funktionen und Bedienung: siehe [README.md](README.md).
|
|
|
|
- [Überblick & Stack](#überblick--stack)
|
|
- [Projektstruktur](#projektstruktur)
|
|
- [Zentrale Architektur-Entscheidungen](#zentrale-architektur-entscheidungen)
|
|
- [Backend](#backend)
|
|
- [HTTP-API (Referenz)](#http-api-referenz)
|
|
- [Datenmodell (SQLite)](#datenmodell-sqlite)
|
|
- [Set-Reconcile (Zustands-Isolierung)](#set-reconcile-zustands-isolierung)
|
|
- [Einstellungen](#einstellungen)
|
|
- [Auto-Reset / Autofill](#auto-reset--autofill)
|
|
- [Export / Import](#export--import)
|
|
- [Frontend](#frontend)
|
|
- [Zustandsmodell & Kein-Re-Render-Prinzip](#zustandsmodell--kein-re-render-prinzip)
|
|
- [Autosave](#autosave)
|
|
- [Set-Kette & Abhak-Status](#set-kette--abhak-status)
|
|
- [Theme & Akzent](#theme--akzent)
|
|
- [config.json - Schema](#configjson---schema)
|
|
- [Deployment](#deployment)
|
|
- [Environment-Variablen](#environment-variablen)
|
|
- [Docker / CasaOS](#docker--casaos)
|
|
- [Tests](#tests)
|
|
- [Erweitern](#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
|
|
|
|
1. **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.
|
|
2. **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 IN` der gelieferten Sorts). Eine Änderung in Übung A berührt
|
|
Übung B strukturell nie.
|
|
3. **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.
|
|
4. **Keine Geister-Einträge.** `sessions.status` ist `active` oder `done`.
|
|
Der Verlauf zeigt nur `done`. Gestartete Sessions überleben Reloads und
|
|
sind per Knopf übertragbar oder verwerfbar.
|
|
5. **Serverzeit ist maßgeblich.** Datum und Endzeit kommen vom Server
|
|
(`/api/meta`), damit nichts am Browser-TZ scheitert.
|
|
6. **Auto-Reset ist serverseitig und opt-in.** Nur wenn die Einstellung
|
|
gesetzt ist, füllt der Server beim Session-Start das letzte Workout ein.
|
|
7. **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.
|
|
8. **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:
|
|
|
|
```json
|
|
{ "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.status` auf `done` ohne `end_time` trägt die Serverzeit ein.
|
|
- `sets` wird pro Übung reconciliert (siehe unten).
|
|
- Fehler: 400 (Body/Validierung), 404 (unbekannte Session), 500.
|
|
|
|
### Datenmodell (SQLite)
|
|
|
|
```sql
|
|
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:
|
|
|
|
1. Für jedes übermittelte Set: `UPSERT` über `(session_id, exercise, sort)`.
|
|
Bestehende Zeilen behalten ihre ID.
|
|
2. Löschung nur von Sätzen **dieser Übung**, deren `sort` im 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`:
|
|
|
|
```python
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
[ { "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 wenn `settings.autoReset` aktiv und die
|
|
Aktivität `strength` ist.
|
|
- `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, done` plus die
|
|
Cardio-Feld-Schlüssel aus der Config. Import gruppiert per `session_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 Änderungen
|
|
- `metaDirty` - 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
|
|
als `PATCH {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.
|
|
- `finishSession` flusht zuerst, bricht bei nicht gespeicherten Änderungen ab,
|
|
setzt dann `status=done` + `end_time` und 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-pressed` und 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, solange `setupDone` fehlt.
|
|
Schritte mit expliziter Navigation (Zurück / Weiter / Einrichtung
|
|
abschließen), Auswahl springt NICHT automatisch weiter; eine
|
|
Segment-Fortschrittsleiste (`wizardProgress`) oben zeigt den Stand:
|
|
1. Sprache (serverweit) - kommt zuerst, damit der Wizard lesbar ist
|
|
2. Einzel- oder Mehrbenutzermodus (bei Mehrbenutzer wird der erste Benutzer
|
|
direkt angelegt)
|
|
3. Darstellung: Theme (Dunkel/System/Hell) + OLED-Schalter + Akzentfarbe
|
|
Abschließen persistiert alles und setzt `setupDone` (+ `onboarded` für den
|
|
Owner).
|
|
- **Onboarding**: einloggende Benutzer ohne `onboarded` durchlaufen 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 in `session.fields.exerciseOrder` gespeichert und bei
|
|
`makeModel` wiederhergestellt.
|
|
- **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
|
|
|
|
```jsonc
|
|
{
|
|
"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`,
|
|
`HEALTHCHECK` gegen `/api/meta`.
|
|
- `docker-compose.yml`: bindet `/DATA/AppData/trainingsplan/data` als echten
|
|
Ordner (CasaOS-tauglich, sichtbar/backupbar). Optional kann `config.json`
|
|
als 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.json` ergänzen (siehe Schema). Server
|
|
neu starten.
|
|
- **Neues Cardio-Feld:** `fieldTemplates` erweitern; Unit/Label kommen aus der
|
|
Config, der Export/Import baut die CSV-Spalten automatisch daraus.
|
|
- **Neue Einstellung:** `DEFAULT_SETTINGS` in `server.py` + Eintrag in
|
|
`renderSettingsView` in `app.js`.
|
|
- **Neuer Theme-/Akzentwert:** CSS-Override in `styles.css` ergänzen; den
|
|
Akzent zusätzlich in `ACCENTS` (app.js) für die Swatches registrieren.
|
|
- **Neue API-Route:** Handler in `server.py` unter `do_GET`/`do_POST`/
|
|
`do_PATCH`/`do_PUT`/`do_DELETE` ergänzen. |