Startpunkt 02.10.2026: aktueller Stand des Trainingsplans
Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt.
This commit is contained in:
commit
8fc77212cb
18 files changed
+10863
No files matched your search
@@ -0,0 +1,538 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user