Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9265ca2ab | ||
|
|
b2b4a2e770 | ||
|
|
314f4f329e | ||
|
|
c7647ba962 | ||
|
|
6290242adf | ||
|
|
aebd7f40e7 | ||
|
|
7c5045cec8 | ||
|
|
1759da8437 | ||
|
|
3806dbc717 | ||
|
|
f93d88abd3 | ||
|
|
6d57200032 | ||
|
|
135425c781 | ||
|
|
655316278a | ||
|
|
0ebaa8b132 | ||
|
|
e91cd30479 |
No files matched your search
@@ -15,3 +15,8 @@ server.log
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# interne Entwicklungstools, nicht oeffentlich
|
||||
/tools/
|
||||
|
||||
# interne Aufgaben-/Prompt-Datei, nicht oeffentlich
|
||||
/trainingsplan-rebuild-spec.md
|
||||
@@ -0,0 +1,223 @@
|
||||
# REKORD - Design-Sprache
|
||||
|
||||
Normative Design-Doku für REKORD (Trainings-Tracker). Sie beschreibt die
|
||||
Design-Entscheidungen so präzise, dass sie auch in anderen Projekten
|
||||
wiederverwendbar sind. Wo ein Punkt geklärt wird, gilt er verbindlich - mit
|
||||
der einen Ausnahme: **Nutzbarkeit schlägt Stil.** Eine Regel, die die Software
|
||||
unbrauchbar macht, darf gebrochen werden - dann aber bewusst und begründet.
|
||||
|
||||
---
|
||||
|
||||
## 1. Philosophie
|
||||
|
||||
REKORD ist ein Werkzeug für den täglichen Gebrauch, kein Schaufenster. Daraus
|
||||
folgt:
|
||||
|
||||
- **Erst Funktion, dann Form.** Die App reibt sich nicht zwischen dich und die
|
||||
Übung. Kompaktheit und Bedienbarkeit gehen jedem ästhetischen Detail voraus.
|
||||
- **Ehrliche Fläche.** Alles ist flach, kantig, offen. Kein Schein, keine
|
||||
Tiefe, kein Verlauf. Eine Kante ist eine Kante (2px), ein Zustand ist ein
|
||||
Zustand (klar unterscheidbar).
|
||||
- **Konfiguration statt Auftrag.** Der Nicht-Programmierer pflegt
|
||||
`config.json`; das Design muss das widerspiegeln: ruhig, vorhersehbar,
|
||||
nicht überraschend.
|
||||
- **Daten bleiben Daten.** Zahlen sind Daten und werden in einer Daten-Schrift
|
||||
gesetzt; Einheiten sind immer sichtbar (kg, km, min - nie nackte Zahlen).
|
||||
- **Weniger ist mehr.** Ein Akzent. Eine Karte. Eine Idee pro Bildschirm.
|
||||
|
||||
## 2. Leitprinzipien
|
||||
|
||||
1. **0px Radius.** Keine abgerundeten Ecken. Kanten sind hart.
|
||||
2. **2px-Border.** Trennungen und Rahmen sind durchgängig 2px, nie schlanker.
|
||||
3. **Dunkler Grund.** Dunkle Themes sind der Default. Schwarz bleibt Schwarz
|
||||
(OLED), Hell ist eine Option, nie der Standard.
|
||||
4. **Ein Akzent.** Eine Akzentfarbe pro Sitzung, flach eingesetzt für aktive/
|
||||
gewählte Zustände. Kein zweiter Akzent, kein Farbverlauf.
|
||||
5. **Zustände denken.** Hover, Fokus, Aktiv, Deaktiviert, "Erledigt" - jeder
|
||||
Zustand ist visuell eindeutig.
|
||||
6. **Zwei Schriften, zwei Aufgaben.** Sans für UI und Fließtext, Mono für
|
||||
Zahlen und Daten. Kein unreflektierter System-Stack als einzige Schrift.
|
||||
7. **Bewegung nur, wenn sie dient.** Und dann `prefers-reduced-motion`
|
||||
respektieren.
|
||||
|
||||
## 3. Tokens
|
||||
|
||||
Die Tokens leben als CSS-Variablen in `:root` und werden über
|
||||
`data-theme` / `data-accent` auf `<html>` gewechselt. Komponenten nutzen **nur**
|
||||
Tokens - keine hartkodierten Farben.
|
||||
|
||||
### 3.1 Farbe (Dunkel, Default)
|
||||
|
||||
| Token | Wert | Verwendung |
|
||||
|---|---|---|
|
||||
| `--bg` | `#0e0e0e` | Seitenhintergrund |
|
||||
| `--bg-panel` | `#161616` | Karten, Panels, Modals |
|
||||
| `--bg-input` | `#0a0a0a` | Eingabefelder |
|
||||
| `--bg-hover` | `#202020` | Hover-Hintergrund |
|
||||
| `--bg-subtle` | `#1d1d1d` | Zebra, Header, Done-Zeilen |
|
||||
| `--border` | `#333` | Standardrahmen |
|
||||
| `--border-hover` | `#4d4d4d` | Rahmen im Hover |
|
||||
| `--text` | `#e8e8e8` | Primärtext |
|
||||
| `--muted` | `#a0a0a0` | Sekundärtext (Kontrast >= 7:1 auf Panel) |
|
||||
| `--danger` | `#c0392b` | destruktive Aktionen |
|
||||
| `--ok` | `#6fae5c` | Erfolg |
|
||||
|
||||
### 3.2 Akzent-Palette
|
||||
|
||||
Zehn flache Akzentfarben, alle mit **einer einheitlichen dunklen Tinte**
|
||||
(`--accent-ink: #141414`, Kontrast >= 4,2:1 überall - mathematisch geprüft).
|
||||
Jede Farbe hat `--accent-hover` (heller) und `--accent-active` (dunkler).
|
||||
|
||||
| Akzent | Wert | Hover | Active |
|
||||
|---|---|---|---|
|
||||
| amber | `#e0a33c` | `#e9b257` | `#cf9130` |
|
||||
| orange | `#e4582e` | `#ed6a36` | `#ca4a22` |
|
||||
| red | `#d64541` | `#e1514c` | `#be3a37` |
|
||||
| green | `#3fae5a` | `#4ebc68` | `#36984b` |
|
||||
| teal | `#2aa198` | `#39afa5` | `#258d84` |
|
||||
| cyan | `#25b8cc` | `#39c5d8` | `#219fb2` |
|
||||
| blue | `#4a86c8` | `#5a96d6` | `#4073b0` |
|
||||
| violet | `#9a6fd0` | `#ab80e0` | `#875db7` |
|
||||
| pink | `#e0567f` | `#eb6890` | `#c74b6e` |
|
||||
| **white** | `#ffffff` | `#f2f2f2` | `#e6e6e6` |
|
||||
|
||||
Sonderfall weißer Akzent: auf hellem Theme braucht er eine sichtbare
|
||||
Umrandung (`--accent-border: #9a9a9a`), sonst verschwinden Buttons
|
||||
weiß-auf-weiß. Gleiches gilt für Fortschrittssegmente (auf hellem Theme
|
||||
schwarz statt unsichtbar-weiß).
|
||||
|
||||
### 3.3 Themes
|
||||
|
||||
| Theme | `--bg` | `--bg-panel` | `--bg-input` | `--border` | `--text` | `--muted` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| dark | `#0e0e0e` | `#161616` | `#0a0a0a` | `#333` | `#e8e8e8` | `#a0a0a0` |
|
||||
| light | `#f2f2f0` | `#ffffff` | `#ffffff` | `#c6c6c2` | `#1c1c1c` | `#5c5c59` |
|
||||
| oled | `#000000` | `#050505` | `#000000` | `#232323` | `#e8e8e8` | `#a0a0a0` |
|
||||
|
||||
- `system` folgt `prefers-color-scheme` (mit Live-Watch über Media Query).
|
||||
- `oled` ist eine **Eigenschaft** des dunklen Modus (Schalter), kein eigenes
|
||||
Theme: Wenn `oled` aktiv und die Auflösung dunkel, gelten reines-Schwarz-
|
||||
Tokens.
|
||||
- `color-scheme` wird pro Theme gesetzt (native Formulare folgen).
|
||||
|
||||
### 3.4 Typografie
|
||||
|
||||
- `--font-ui`: System-Sans für UI, Labels, Fließtext.
|
||||
- `--font-data`: Mono (tabular-nums) für Zahlen, Gewichte, Zeiten, Datums-,
|
||||
Statistik- und Eingabefelder.
|
||||
- Hierarchie über **Gewicht und Abstand**, nicht über Größen-Vielfalt:
|
||||
h1 > h2 > h3, bei begrenzter Größenspreizung. Kein "alles gleich".
|
||||
- Kein webfont-Download nötig - offline-fähig.
|
||||
|
||||
### 3.5 Form, Abstand, Bewegung
|
||||
|
||||
- Radius: **0**.
|
||||
- Border: **2px** (Buttons, Karten, Inputs, Tabs, Segmente).
|
||||
- Abstand: konsistente Raster (8/10/12/14/16/20px), Modals großzügig
|
||||
(Padding >= 32px, min-height fuer konstante Groesse).
|
||||
- Schatten: **keine**. Trennung über Kanten und Abstand.
|
||||
- Motion: höchstens kurzzeitige Transitions (Farbwechsel); nichts Animiertes
|
||||
ohne `prefers-reduced-motion`-Schutz.
|
||||
|
||||
## 4. Komponenten & Regeln
|
||||
|
||||
### 4.1 Buttons
|
||||
|
||||
- **Primary** (`.btn-primary`): Akzent-Hintergrund + `--accent-ink`-Tinte.
|
||||
Hover = `--accent-hover`, Active = `--accent-active` (Border bleibt
|
||||
`--accent-border`). Nur für die eine Hauptaktion.
|
||||
- **Secondary** (`.btn`): transparent, 2px-Border, `--text`. Für Nebenaktionen.
|
||||
- **Danger** (`.btn-danger-ghost`): rote Border/Tinte. Destruktiv.
|
||||
- **`min-width`** (96px / 64px bei `.btn-small`): Buttons springen nicht in
|
||||
der Breite, wenn die Sprache wechselt.
|
||||
- **Disabled**: Opacity 0.45 + `not-allowed`.
|
||||
- Icons statt Text bei platzsparenden Aktionen (Reset = SVG-Pfeilkreislauf,
|
||||
Loeschen = ×). Keine Emojis als Icons.
|
||||
|
||||
### 4.2 Eingabefelder (Field-Box)
|
||||
|
||||
Eine zusammengehoerige Box: **Zahl links, Einheit rechts als festes Suffix in
|
||||
derselben Box** (z.B. `[18,5 | kg]`). Klick auf die ganze Box fokussiert den
|
||||
Input. Einheiten sind nie nackt. `:focus-within` faerbt die Border im Akzent.
|
||||
Leer bleibt leer - nie 0.
|
||||
|
||||
### 4.3 Set-Zeile
|
||||
|
||||
Kompakte 5-Spalten-Grid: `auto auto auto auto auto` (Check | kg | reps |
|
||||
Reset-Icon | X-Icon). Check = "Set n [ ]", ganzer Button klickbar, Kette ohne
|
||||
Luecken. Die Zeile muss auf ein normales Handy-Display passen - ohne
|
||||
horizontales Scrollen.
|
||||
|
||||
### 4.4 Karten & Panels
|
||||
|
||||
2px-Border, `--bg-panel`, Radius 0. Keine Schatten, keine `rounded-2xl`-Karten.
|
||||
Karten durch Trennlinien/Kanten strukturieren statt durch Abstaende allein.
|
||||
|
||||
### 4.5 Tabellen
|
||||
|
||||
2px-Border auf allen Zellen, Header auf `--bg-subtle`, Zahlen rechtsbündig in
|
||||
Mono. Balken (Statistik) als flache Akzent-Rechtecke.
|
||||
|
||||
### 4.6 Modals / Setup-Wizard
|
||||
|
||||
- Feste Mindesthoehe (konstante Groesse zwischen Schritten), Navigation unten
|
||||
verankert, Inhalt intern scrollbar.
|
||||
- Fortschritt = **segmentierter Balken** oben rechts (Striche mit gap).
|
||||
Erledigt = gefuellt, aktuell = nur Umrandung, kommend = ausgegraut.
|
||||
- Explizite Navigation (Zurueck / Weiter / Abschliessen), Auswahl springt
|
||||
nie automatisch weiter.
|
||||
|
||||
### 4.7 Toasts
|
||||
|
||||
Fixiert unten, 2px-Border. Fehler = `--danger`, Erfolg = `--ok`, und
|
||||
**Einstellungs-Bestaetigungen in der Akzentfarbe** (nicht fix gruen).
|
||||
|
||||
## 5. Zustände
|
||||
|
||||
| Zustand | Regel |
|
||||
|---|---|
|
||||
| hover | `--bg-hover` / `--border-hover`; bei Akzent-Buttons `--accent-hover` |
|
||||
| focus | `:focus-visible` 2px Akzent-Outline (nie `outline: none` ohne Ersatz) |
|
||||
| active/pressed | `--accent-active` bei Primary, `--bg-hover` bei Secondary |
|
||||
| disabled | Opacity 0.45, `cursor: not-allowed` |
|
||||
| done/selected | Akzent-Hintergrund + `--accent-ink` (Check, aktiver Tab, Toggle) |
|
||||
| error | `--danger` (Border + Text), nie nur Farb-Luecke |
|
||||
|
||||
## 6. i18n & Sprache
|
||||
|
||||
- Alle UI-Texte über `t(key)` aus einem `LANG`-Dictionary (de/en). Keine
|
||||
hartkodierten Strings in Views.
|
||||
- Buttons bekommen `min-width`, damit Sprachwechsel die Groesse nicht
|
||||
aendern.
|
||||
- Server-Fehlermeldungen werden ueber `eMsg()` lokalisiert.
|
||||
- Der Login-Screen erscheint in der Server-Standardsprache (aus `/api/meta`).
|
||||
|
||||
## 7. Accessibility & Responsive
|
||||
|
||||
- Tastatur: alles bedienbar, sichtbarer Fokus.
|
||||
- Kontrast: `--muted` auf Panel >= 7:1; Akzent-Tinte >= 4,2:1.
|
||||
- `aria-label`/`aria-pressed` an interaktiven Icons/Checks.
|
||||
- `prefers-reduced-motion` schaltet Transitions aus.
|
||||
- Mobil: keine horizontalen Scrollleisten fuer Inhalte; Tabs wrappen statt
|
||||
abzuschneiden; Eingaben flexibel-schrumpfbar mit `min-width: 0`.
|
||||
|
||||
## 8. Verboten (Anti-Patterns)
|
||||
|
||||
- Verlaeufe (auch subtile), Glow, Glassmorphism, `backdrop-filter`.
|
||||
- Schatten statt Kanten.
|
||||
- Mehr als ein Akzent gleichzeitig; Lila-zu-Blau-Heroes.
|
||||
- Emojis als Icons, rohe Unicode-Pfeile in Labels, Em-Dashes als Stilmittel.
|
||||
- Abgerundete Pillen/Buttons ohne Grund.
|
||||
- "Nahtlos", "Leistungsstark", "Elevate" - Vage-Floskel-Copy.
|
||||
- Zentrierter Hero + 3-Karten-SaaS-Skelett.
|
||||
- Eingaben, die bei jedem Tastendruck neu gerendert werden (Fokus-Verlust).
|
||||
- Nackte Zahlen ohne Einheit.
|
||||
|
||||
## 9. Abschluss-Check (vor dem Shippen)
|
||||
|
||||
1. Diff gegen die Tokens: keine hartkodierten Farben in Komponenten.
|
||||
2. Scan auf Em-Dashes, Verlaeufe, rohe Pfeile, Emojis, Bannwoerter.
|
||||
3. Einheiten/Labels ueberall sichtbar?
|
||||
4. Zustaende (hover/focus/active/disabled/done) durchgeklickt?
|
||||
5. Mobil ohne horizontales Scrollen geprueft?
|
||||
6. Sprachwechsel (de/en) auf Button-Groessen getestet?
|
||||
@@ -1,122 +1,80 @@
|
||||
# REKORD
|
||||
|
||||
**Rekord & Record: dein Training, festgehalten.**
|
||||
Selbstgehosteter Trainings-Tracker für Gym, Laufen und Radfahren. Läuft im
|
||||
Browser, speichert auf deinem Server, gehört dir.
|
||||
|
||||
Ein leichtgewichtiger, selbst gehosteter Trainings-Tracker fürs Gym, Laufen
|
||||
und Radfahren. Läuft in jedem modernen Browser, speichert auf deiner Platte
|
||||
und wertet deine Fitnessdaten nicht aus. Kein Abo, kein Konto bei einem
|
||||
Anbieter, keine Datenkraken.
|
||||
Eine SQLite-Datei als Zustand, ein Python-Backend aus der Standardbibliothek,
|
||||
ein HTML/CSS/JS-Frontend ohne Framework. Kein Cloud-Konto, keine Telemetrie.
|
||||
|
||||
## Warum REKORD?
|
||||
## Was es kann
|
||||
|
||||
- **Kein Vendor Lock-in.** Alles läuft bei dir: Backend auf deinem Server,
|
||||
Zustand in einer portablen SQLite-Datei. Export jederzeit als JSON oder
|
||||
CSV, Import genauso. Zieh um oder hör auf - deine Daten gehören dir.
|
||||
- **Leichtgewichtig.** Python-Standardbibliothek + SQLite im Backend, ein
|
||||
HTML-, ein CSS-, ein JS-File im Frontend. Kein Framework, kein
|
||||
Build-Schritt, keine Node-Installation. Läuft auf einem Mini-PC, einem
|
||||
alten Laptop oder in einem Container.
|
||||
- **Browserbasiert.** Keine App, kein Store, keine Installation. Aufschlagen,
|
||||
loslegen - auf jedem Gerät mit Browser.
|
||||
- **Cross-Device Sync.** Der Zustand liegt serverseitig. Öffne die App am
|
||||
Handy, am Laptop oder am Desktop-Rechner - es ist derselbe Stand. Mehrere
|
||||
Benutzer mit eigenen Konten und getrennten Daten sind eingebaut.
|
||||
- **UX zuerst.** Sätze eintragen mit einer Box (Zahl + Einheit in einem
|
||||
Feld), Set-Kette durch Abhaken, "Alles zurücksetzen", Verlauf inline
|
||||
bearbeiten, Wochenübersicht, Statistik mit Differenz zur ersten Messung.
|
||||
Der Tracker reibt sich nicht in den Weg zwischen dich und die Übung.
|
||||
- **Dein Take aufs Aufzeichnen.** Die App zeichnet auf - Gewichte, Reps,
|
||||
Cardio-Felder - und zeigt die Entwicklung über die Zeit. Sie verkauft
|
||||
nichts, personalisiert nichts und schickt nichts nach Hause.
|
||||
- **Open Source.** AGPL-3.0. Nachvollziehbar, erweiterbar, auditierbar.
|
||||

|
||||
|
||||
## Features im Überblick
|
||||
**Training**
|
||||
|
||||
**Training erfassen**
|
||||
- Gym mit Push/Pull/Legs (anpassbar), dazu Laufen und Radfahren
|
||||
- Gewicht und Wiederholungen eintragen, Sätze abhaken, auf den letzten Stand
|
||||
zurücksetzen
|
||||
- Reihenfolge der Übungen selbst festlegen, spontan eine Übung ergänzen
|
||||
- Zu jeder Übung eine kurze Erklärung
|
||||
|
||||
- Gym mit Push/Pull/Legs (frei konfigurierbar) plus Laufen und Radfahren
|
||||
- Eingabe als eine Box: Zahl links, Einheit fest rechts (kg, reps, km, min,
|
||||
min/km, bpm, m)
|
||||
- Set-Abhaken als "Set n [ ]": der ganze Button ist klickbar, das Set hakt
|
||||
eine Kette ab - ohne Lücken
|
||||
- Reset-Satz und "Alles zurücksetzen" laden das letzte abgeschlossene
|
||||
Training
|
||||
- Vorbefüllung beim Start (optional): "Letztes Training vorbefüllen" oder
|
||||
"Plan-Gewichte vorbefüllen" (startKg aus der Config, auch ohne Verlauf)
|
||||
- Übungen per Drag & Drop sortieren, Übungen aus anderen Tagen spontan
|
||||
hinzufügen
|
||||
- Fragezeichen pro Übung: Erklärung plus die passende Maschine
|
||||
**Verlauf & Statistik**
|
||||
|
||||
**Sessions & Verlauf**
|
||||
- Sessions starten und abschließen, mehrere pro Tag
|
||||
- Verlauf zum Nachschauen, Ändern und Löschen - als Liste oder Woche
|
||||
- Fortschritt je Übung: bestes Gewicht und Entwicklung seit dem ersten Mal
|
||||
|
||||
- "Session starten" / "Session abschließen", mehrere Sessions am Tag
|
||||
- Keine Geister: erst abgeschlossene Sessions erscheinen im Verlauf
|
||||
- Verlauf als Liste oder Wochenübersicht, inline bearbeitbar (Datum, Zeiten,
|
||||
Abhak-Status, Werte) und löschbar
|
||||
- Statistik: bestes Gewicht je Trainingstag mit Balken, Differenz zur ersten
|
||||
Messung
|
||||
**Einstellungen**
|
||||
|
||||
**Ansicht & Einstellungen**
|
||||
- Deutsch oder Englisch
|
||||
- Dunkel, System oder Hell, dazu ein OLED-Schwarz
|
||||
- Akzentfarbe, Gewichtseinheit kg/lb, Startansicht
|
||||
- Gilt auf allen Geräten
|
||||
|
||||
- Deutsch und Englisch (Server-Standard und pro Benutzer)
|
||||
- Farbthema: Dunkel, System (folgt dem Gerät), Hell - plus OLED-Dark-Schalter
|
||||
- Akzentfarbe aus zehn Farben (inklusive Weiß)
|
||||
- Gewichtseinheit kg oder lb, Start-Ansicht wählbar
|
||||
- Einstellungen serverseitig, gelten auf allen Geräten
|
||||
**Mehrere Benutzer**
|
||||
|
||||
**Mehrbenutzer**
|
||||
|
||||
- Eigene Konten mit Kennwort (gehasht), Daten strikt getrennt
|
||||
- Der erste Benutzer ist Administrator und verwaltet Konten und Kennwörter
|
||||
- Einmaliger Einrichtungs-Wizard beim ersten Start (Sprache, Modus,
|
||||
Darstellung)
|
||||
- Eigene Konten mit Passwort, getrennte Daten
|
||||
- Der erste Benutzer ist Administrator
|
||||
|
||||
**Daten**
|
||||
|
||||
- Export/Import als JSON und CSV (Backup, Umzug, Tabellen-Auswertung)
|
||||
- Workouts über eine kleine API abrufbar (`/api/workouts`)
|
||||
- Demo-Daten (`demo-data.json`) zum direkten Ausprobieren
|
||||
- Backup als JSON oder CSV, jederzeit wieder einspielbar
|
||||
- Beispiel-Verlauf zum Ausprobieren
|
||||
|
||||
## Schnellstart
|
||||
## Starten
|
||||
|
||||
Python (nur Standardbibliothek, kein `pip install`):
|
||||
Python (nur Standardbibliothek):
|
||||
|
||||
```bash
|
||||
python3 server.py 8080
|
||||
```
|
||||
|
||||
Oder mit Docker:
|
||||
Oder Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Danach im Browser: `http://<host>:8080/`
|
||||
Danach im Browser: `http://<host>:8080/`. Beim ersten Start führt ein kurzer
|
||||
Wizard durch Sprache, Einzel- oder Mehrbenutzermodus und Darstellung.
|
||||
|
||||
Beim ersten Start führt dich ein kurzer Wizard durch Sprache, Einzel- oder
|
||||
Mehrbenutzermodus und Darstellung - danach ist die App einsatzbereit.
|
||||
## Plan anpassen
|
||||
|
||||
## Den Übungsplan anpassen
|
||||
Übungen, Sätze, Gewichte und Erklärungen stehen in `config.json` - ohne
|
||||
Code-Eingriff. Schema: [TECH.md](TECH.md).
|
||||
|
||||
Alle Übungen, Sätze, Gewichte, Maschinen und Erklärungen stehen in
|
||||
`config.json` - ohne Code-Eingriff. Das komplette Schema steht in der
|
||||
[technischen Dokumentation](TECH.md).
|
||||
|
||||
## Automatischer Build (CI/CD)
|
||||
|
||||
Ein Push auf `main` baut automatisch ein Docker-Image und legt es in der
|
||||
Gitea-Registry ab (`git.somantis.net/j4stmarkus/trainingsplan`). Dafür läuft
|
||||
ein Gitea-Actions-Runner auf dem Server; die Workflow-Datei liegt unter
|
||||
`.gitea/workflows/build-image.yml`. Die CasaOS-Installation
|
||||
(`docker-compose.casaos.yml`) bezieht die App aus diesem Registry-Image.
|
||||
Fürs lokale Entwickeln bleibt `docker-compose.yml` mit `build: .` bestehen.
|
||||
|
||||
## Daten & Privatsphäre
|
||||
## Daten
|
||||
|
||||
- Alles liegt in `data/state.db` (SQLite) - backupbar, überlebt Neustart und
|
||||
Container-Rebuild
|
||||
- Datenverzeichnis frei wählbar (`TRAINING_STATE_DIR`)
|
||||
- Keine externen Dienste, keine Telemetrie, keine Analyse deiner Daten
|
||||
- Datenverzeichnis per `TRAINING_STATE_DIR` frei wählbar
|
||||
- Keine externen Dienste, keine Telemetrie
|
||||
|
||||
## Mehr
|
||||
|
||||
- [TECH.md](TECH.md) - Architektur, API, Datenmodell
|
||||
- [DESIGN.md](DESIGN.md) - Design-Sprache (Tokens, Komponenten, Anti-Patterns)
|
||||
- [ROADMAP.md](ROADMAP.md) - geplante Erweiterungen
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# REKORD - Roadmap
|
||||
|
||||
Was geplant ist, und was schon steht. Priorität: P1 = als Nächstes, P2 = bald,
|
||||
P3 = später. Status: Fertig / Teilweise / Geplant.
|
||||
|
||||
Leitplanken bleiben: config-getrieben, leichtgewichtig, Daten lokal, keine
|
||||
Telemetrie.
|
||||
|
||||
## Umgesetzt
|
||||
|
||||
- Config-getriebener Plan (`config.json`), SQLite-Zustand serverseitig
|
||||
- Gym (Push/Pull/Legs), Laufen, Radfahren; Cardio-Felder aus der Config
|
||||
- Set-Kette, Reset-Satz, "Alles zurücksetzen", Vorbefüllung (letztes Workout
|
||||
oder Plan-Gewichte)
|
||||
- Übungen per Drag & Drop sortieren, Übungen aus anderen Tagen hinzufügen
|
||||
- Verlauf als Liste und Wochenübersicht, Inline-Bearbeitung, Löschen
|
||||
- Statistik: bestes Gewicht je Tag, Differenz zur ersten Messung
|
||||
- Mehrbenutzer mit Admin, Kennwort ändern/zurücksetzen, Server-Reset
|
||||
- Einrichtungs-Wizard (Sprache, Darstellung, Modus)
|
||||
- i18n de/en, Themes dunkel/system/hell + OLED, zehn Akzentfarben
|
||||
- Export/Import JSON/CSV, Workouts-API, Demo-Daten
|
||||
- Übungs-Infos herstellerunabhängig; mobile-taugliche Satz-Zeile
|
||||
|
||||
## Offen
|
||||
|
||||
### Inhalte & Bedienbarkeit
|
||||
|
||||
| Prio | Thema | Status | Notiz |
|
||||
|---|---|---|---|
|
||||
| P1 | Übungen/Maschinen in der App bearbeiten | Geplant | Statt `config.json` von Hand: Übungen, Tage und Info-Texte direkt in der App pflegen. |
|
||||
| P1 | Info-Texte erweitern | Geplant | Optional Bilder, Schritte als Checkliste, ein Link (neutral, nicht herstellerspezifisch). |
|
||||
| P1 | Übungsvarianten & Maschinen-Tags | Geplant | Eine Übung mit Varianten (KH, Maschine, Kabel) und schnellem Wechsel im Training. |
|
||||
| P2 | Set-Vorlagen pro Übung | Teilweise | Plan-Gewichte vorbefüllen gibt es; noch nicht pro Satz steuerbar (Ziel-Reps, Pause). |
|
||||
| P2 | Einheiten pro Übung | Geplant | Bodyweight, Band, Maschinen-Gewicht - neben dem globalen kg/lb. |
|
||||
|
||||
### Integration
|
||||
|
||||
| Prio | Thema | Status | Notiz |
|
||||
|---|---|---|---|
|
||||
| P1 | Plugin-System | Geplant | Kleine Plugins mit Hooks (nach Session-Speichern, beim Export, beim Anzeigen). |
|
||||
| P1 | Ollama/Cloud-Coach | Geplant | Form-Tipps/Zusammenfassungen per API - local-first (Ollama), Cloud nur auf Wunsch. |
|
||||
| P2 | Webhooks bei Events | Geplant | Nach "Session abgeschlossen" o.ä. für eigene Automation. |
|
||||
| P2 | API-Keys pro Benutzer | Geplant | Read/write Keys für Scripts, ohne das Konto freizugeben. |
|
||||
| P2 | CLI-Harness | Geplant | Kleines Kommandozeilen-Interface gegen die API (Cron, TUI). |
|
||||
| P3 | Wearable-Import | Geplant | Garmin/Apple/Fitbit/Samsung-Exporte (CSV/JSON) als Plugin. |
|
||||
|
||||
### Analyse & UX
|
||||
|
||||
| Prio | Thema | Status | Notiz |
|
||||
|---|---|---|---|
|
||||
| P1 | Volumen & 1RM-Schätzung | Geplant | kg × Reps je Übung/Tag, 1RM-Schätzung als zusätzliche Kennzahl. |
|
||||
| P2 | Wochenvergleich & Trends | Geplant | Gleitende Durchschnitte, PR-Markierungen, Verlauf ohne Chart-Lib. |
|
||||
| P2 | PWA / Offline | Geplant | Installierbar auf dem Handy (Service Worker, Icon). |
|
||||
| P2 | Mehrere Sessions am Tag | Teilweise | Funktioniert; Aktivitäts-/Tag-Wechsel im UI vereinfachen. |
|
||||
|
||||
### Qualität
|
||||
|
||||
| Prio | Thema | Status | Notiz |
|
||||
|---|---|---|---|
|
||||
| P2 | Barrierefreiheit | Teilweise | Fokus, Kontraste, Labels gepflegt; systematischer Test offen. |
|
||||
| P2 | Backup/Erinnerung | Geplant | Export gibt es; automatische Backup-Erinnerung offen. |
|
||||
| P3 | Weitere Sprachen | Geplant | i18n-Architektur (de/en) steht; weitere Sprachen als Beitrag. |
|
||||
@@ -114,6 +114,11 @@ const LANG = {
|
||||
setting_saved: "Einstellung gespeichert.",
|
||||
account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:",
|
||||
change_pw: "Kennwort ändern", current_pw: "Aktuelles Kennwort",
|
||||
logout_label: "Abmelden", session_active: "aktiv",
|
||||
reset_server: "Server zurücksetzen",
|
||||
reset_server_desc: "Löscht alle Daten und Benutzer dieser Instanz. Die App startet danach wie neu (Einrichtungs-Wizard). Gib dein Kennwort ein und tippe RESET zum Bestätigen.",
|
||||
reset_confirm_hint: "Zum Bestätigen RESET eintippen",
|
||||
reset_server_go: "Zurücksetzen", reset_done: "Server zurückgesetzt.",
|
||||
new_pw: "Neues Kennwort", pw_changed: "Kennwort geändert.",
|
||||
users: "Benutzer", setup_users: "Benutzer einrichten",
|
||||
users_solo_desc: "Du bist im Einzelmodus (ohne Anmeldung). Lege den ersten Benutzer an, um Login und mehrere Benutzer zu aktivieren.",
|
||||
@@ -152,6 +157,10 @@ const LANG = {
|
||||
reorder_hint: "Übungen per Ziehen sortieren",
|
||||
prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom",
|
||||
wizard_done: "Alles erledigt - die App ist bereit.",
|
||||
wizard_user_created: "Benutzer '{u}' angelegt.",
|
||||
wizard_lang_hint: "Anleitung, Demo-Daten und der Übungsplan stehen im Repo.",
|
||||
wizard_user_later: "Der Benutzer wird beim Abschließen angelegt - bis dahin kannst du dich umentscheiden.",
|
||||
wizard_admin_hint: "Der erste Benutzer wird Administrator (verwaltet Konten und Kennwörter).",
|
||||
logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.",
|
||||
app_load_fail: "App konnte nicht geladen werden: {m}",
|
||||
},
|
||||
@@ -223,6 +232,11 @@ const LANG = {
|
||||
setting_saved: "Setting saved.",
|
||||
account: "Account", account_desc: "Logged in as '{u}'. Change password:",
|
||||
change_pw: "Change password", current_pw: "Current password",
|
||||
logout_label: "Log out", session_active: "active",
|
||||
reset_server: "Reset server",
|
||||
reset_server_desc: "Deletes all data and users of this instance. The app starts fresh afterwards (setup wizard). Enter your password and type RESET to confirm.",
|
||||
reset_confirm_hint: "Type RESET to confirm",
|
||||
reset_server_go: "Reset", reset_done: "Server reset.",
|
||||
new_pw: "New password", pw_changed: "Password changed.",
|
||||
users: "Users", setup_users: "Set up users",
|
||||
users_solo_desc: "You are in single-user mode (no login). Create the first user to enable login and multiple users.",
|
||||
@@ -261,6 +275,10 @@ const LANG = {
|
||||
reorder_hint: "Drag to reorder exercises",
|
||||
prev_week: "Previous week", next_week: "Next week", week_of: "Week of",
|
||||
wizard_done: "All done - the app is ready.",
|
||||
wizard_user_created: "User '{u}' created.",
|
||||
wizard_lang_hint: "Guide, demo data and the exercise plan live in the repo.",
|
||||
wizard_user_later: "The user will be created when you finish - you can still change your mind until then.",
|
||||
wizard_admin_hint: "The first user becomes the administrator (manages accounts and passwords).",
|
||||
logged_in: "Logged in.", user_created_toast: "User created.",
|
||||
app_load_fail: "Could not load the app: {m}",
|
||||
},
|
||||
@@ -764,17 +782,16 @@ function reorderExercise(a, model, fromKey, toKey) {
|
||||
scheduleAutosave(model);
|
||||
}
|
||||
|
||||
function attachDragReorder(a, model, ex, block) {
|
||||
block.draggable = true;
|
||||
block.title = t("reorder_hint");
|
||||
block.addEventListener("dragstart", ev => {
|
||||
function attachDragReorder(a, model, ex, block, grip) {
|
||||
grip.draggable = true;
|
||||
grip.addEventListener("dragstart", ev => {
|
||||
app.dragKey = ex.key;
|
||||
try {
|
||||
ev.dataTransfer = ev.dataTransfer || {};
|
||||
ev.dataTransfer.setData("text/plain", ex.key);
|
||||
} catch (_) {}
|
||||
});
|
||||
block.addEventListener("dragend", () => {
|
||||
grip.addEventListener("dragend", () => {
|
||||
app.dragKey = null;
|
||||
for (const b of $$(".ex-block.drag-over")) b.classList.remove("drag-over");
|
||||
});
|
||||
@@ -832,6 +849,14 @@ function buildExBlock(a, model, ex, removable) {
|
||||
model.exByKey[ex.key] = ex;
|
||||
|
||||
const head = el("div", "ex-head");
|
||||
const grip = el("span", "ex-grip", "");
|
||||
grip.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">'
|
||||
+ '<circle cx="6" cy="4" r="1.4"/><circle cx="10" cy="4" r="1.4"/>'
|
||||
+ '<circle cx="6" cy="8" r="1.4"/><circle cx="10" cy="8" r="1.4"/>'
|
||||
+ '<circle cx="6" cy="12" r="1.4"/><circle cx="10" cy="12" r="1.4"/></svg>';
|
||||
grip.title = t("reorder_hint");
|
||||
grip.setAttribute("aria-label", t("reorder_hint"));
|
||||
head.appendChild(grip);
|
||||
const infoBtn = el("button", "icon-btn", "?");
|
||||
infoBtn.type = "button";
|
||||
infoBtn.setAttribute("aria-label", t("info_aria") + ex.name);
|
||||
@@ -860,7 +885,7 @@ function buildExBlock(a, model, ex, removable) {
|
||||
|
||||
block.appendChild(el("div", "set-rows"));
|
||||
renderExRows(a, model, ex);
|
||||
attachDragReorder(a, model, ex, block);
|
||||
attachDragReorder(a, model, ex, block, grip);
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -913,10 +938,12 @@ function buildSetRow(a, model, ex, entry, i) {
|
||||
row.appendChild(rBox);
|
||||
entry.wInput = wBox.querySelector("input");
|
||||
entry.rInput = rBox.querySelector("input");
|
||||
row.appendChild(el("span", "set-gap"));
|
||||
|
||||
const reset = el("button", "btn btn-small", t("reset_set"));
|
||||
const reset = el("button", "icon-btn", "");
|
||||
reset.type = "button";
|
||||
reset.setAttribute("aria-label", t("reset_set_title"));
|
||||
reset.appendChild(resetSvg());
|
||||
reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry));
|
||||
row.appendChild(reset);
|
||||
|
||||
@@ -929,6 +956,15 @@ function buildSetRow(a, model, ex, entry, i) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function resetSvg() {
|
||||
const svg = el("span", "");
|
||||
svg.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">'
|
||||
+ '<circle cx="8" cy="8" r="5" stroke-dasharray="23.5 8" transform="rotate(-55 8 8)"/>'
|
||||
+ '<path d="M10.6 5.3 l2.4 -1.7 l0.7 3.4 l-2.4 -1.7 Z" fill="currentColor"/>'
|
||||
+ '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function makeFieldBox(label, unit, value, onChange, inputmode) {
|
||||
const box = el("label", "field-box");
|
||||
box.setAttribute("aria-label", label + " in " + unit);
|
||||
@@ -1033,7 +1069,7 @@ async function flushAutosave(activity) {
|
||||
const sets = (model.exercises[exKey] || []).map((e, i) => ({
|
||||
exercise: exKey, sort: i, weight: e.weight || "", reps: e.reps || "",
|
||||
done: e.done ? 1 : 0,
|
||||
}));
|
||||
})).filter(s => s.weight !== "" || s.reps !== "");
|
||||
try {
|
||||
await api("/api/sessions/" + model.id, jsonOpts("PATCH", { sets }));
|
||||
model.dirtyEx.delete(exKey);
|
||||
@@ -1261,23 +1297,17 @@ function renderSetSummary(s, a) {
|
||||
const byEx = {};
|
||||
for (const e of s.entries || []) (byEx[e.exercise] = byEx[e.exercise] || []).push(e);
|
||||
for (const exKey of Object.keys(byEx)) {
|
||||
const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort)
|
||||
.filter(e => e.done);
|
||||
if (!sorted.length) continue;
|
||||
const li = el("li", "hist-entry");
|
||||
li.appendChild(el("span", "ex-name", exKey + ": "));
|
||||
const name = exKey.includes("/")
|
||||
? exKey.split("/").slice(1).join("/") : exKey;
|
||||
li.appendChild(el("span", "ex-name", name + ": "));
|
||||
const setsWrap = el("span", "hist-sets");
|
||||
const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort);
|
||||
sorted.forEach((e, idx) => {
|
||||
const w = e.weight || t("empty_label");
|
||||
const r = e.reps || t("empty_label");
|
||||
const span = el("span", "hist-set");
|
||||
span.appendChild(el("span", e.done ? "set-done-box" : "set-undone-box",
|
||||
e.done ? "✓" : "□"));
|
||||
span.appendChild(document.createTextNode(
|
||||
w + " " + unit + " × " + r + " " + repsUnit));
|
||||
setsWrap.appendChild(span);
|
||||
if (idx < sorted.length - 1) {
|
||||
setsWrap.appendChild(document.createTextNode(" · "));
|
||||
}
|
||||
});
|
||||
setsWrap.textContent = sorted.map(e =>
|
||||
(e.weight || t("empty_label")) + " " + unit + " × "
|
||||
+ (e.reps || t("empty_label")) + " " + repsUnit).join(" · ");
|
||||
li.appendChild(setsWrap);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
@@ -1635,17 +1665,19 @@ function switchView(name) {
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
const wizard = { mode: "setup", step: 0, multi: null, lang: null,
|
||||
theme: null, oled: false, accent: null, userCreated: false };
|
||||
theme: null, oled: false, accent: null,
|
||||
regUser: null, regPass: null };
|
||||
|
||||
function renderSetupWizard(mode) {
|
||||
wizard.mode = mode;
|
||||
wizard.step = 0; // Sprache zuerst
|
||||
wizard.step = 0; // Reihenfolge: Sprache -> Darstellung -> Modus
|
||||
wizard.multi = null;
|
||||
wizard.lang = (app.settings && app.settings.language) || "de";
|
||||
wizard.theme = (app.settings && app.settings.theme) || "dark";
|
||||
wizard.oled = !!(app.settings && app.settings.oled);
|
||||
wizard.accent = (app.settings && app.settings.accent) || "white";
|
||||
wizard.userCreated = false;
|
||||
wizard.regUser = null;
|
||||
wizard.regPass = null;
|
||||
document.body.classList.add("no-auth");
|
||||
$("#setup-gate").hidden = false;
|
||||
renderWizardStep();
|
||||
@@ -1684,11 +1716,11 @@ function previewWizardAppearance() {
|
||||
|
||||
function wizardStepValid() {
|
||||
if (wizard.step === 0) return !!wizard.lang;
|
||||
if (wizard.mode === "setup" && wizard.step === 1) {
|
||||
if (wizard.multi === null) return false;
|
||||
return wizard.multi === false || wizard.userCreated;
|
||||
}
|
||||
return !!wizard.theme && !!wizard.accent;
|
||||
if (wizard.step === 1) return !!wizard.theme && !!wizard.accent;
|
||||
// Schritt 2 (nur Setup): Modus
|
||||
if (wizard.multi === null) return false;
|
||||
if (wizard.multi === false) return true;
|
||||
return !!(wizard.regUser && wizard.regPass);
|
||||
}
|
||||
|
||||
function wizardMaxStep() {
|
||||
@@ -1714,12 +1746,13 @@ function renderWizardStep() {
|
||||
head.appendChild(el("h2", "auth-title", BRAND));
|
||||
head.appendChild(wizardProgress());
|
||||
box.appendChild(head);
|
||||
const body = el("div", "wizard-body");
|
||||
|
||||
if (wizard.step === 0) {
|
||||
// Schritt 0: Sprache (zuerst)
|
||||
box.appendChild(el("h3", "wizard-title", t("lang_step")));
|
||||
// Schritt 0: Sprache
|
||||
body.appendChild(el("h3", "wizard-title", t("lang_step")));
|
||||
if (wizard.mode === "setup") {
|
||||
box.appendChild(el("p", "auth-sub", t("lang_server")));
|
||||
body.appendChild(el("p", "auth-sub", t("lang_server")));
|
||||
}
|
||||
const row = el("div", "day-picker");
|
||||
for (const [code, label] of [["de", t("german")], ["en", t("english")]]) {
|
||||
@@ -1732,45 +1765,20 @@ function renderWizardStep() {
|
||||
});
|
||||
row.appendChild(b);
|
||||
}
|
||||
box.appendChild(row);
|
||||
} else if (wizard.step === 1 && wizard.mode === "setup") {
|
||||
// Schritt 1: Einzel- oder Mehrbenutzermodus
|
||||
box.appendChild(el("p", "auth-sub", t("welcome")));
|
||||
const grid = el("div", "setup-options");
|
||||
const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true);
|
||||
multi.addEventListener("click", () => {
|
||||
wizard.multi = true;
|
||||
renderWizardStep();
|
||||
});
|
||||
grid.appendChild(multi);
|
||||
const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false);
|
||||
solo.addEventListener("click", () => {
|
||||
wizard.multi = false;
|
||||
renderWizardStep();
|
||||
});
|
||||
grid.appendChild(solo);
|
||||
box.appendChild(grid);
|
||||
if (wizard.multi === true) {
|
||||
const form = el("form", "auth-form");
|
||||
form.appendChild(authField(t("username"), "setup-user"));
|
||||
form.appendChild(authField(t("password"), "setup-pass"));
|
||||
const err = el("div", "auth-error", "");
|
||||
err.hidden = true;
|
||||
const submit = el("button", "btn btn-primary btn-block", t("first_user"));
|
||||
submit.type = "submit";
|
||||
form.appendChild(submit);
|
||||
form.appendChild(err);
|
||||
form.addEventListener("submit", ev => {
|
||||
ev.preventDefault();
|
||||
wizardRegister($("#setup-user").value, $("#setup-pass").value, err);
|
||||
});
|
||||
box.appendChild(form);
|
||||
body.appendChild(row);
|
||||
if (wizard.mode === "setup") {
|
||||
body.appendChild(el("p", "auth-sub", t("wizard_lang_hint")));
|
||||
const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan");
|
||||
repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan";
|
||||
repo.target = "_blank";
|
||||
repo.rel = "noopener";
|
||||
body.appendChild(repo);
|
||||
}
|
||||
} else {
|
||||
// Letzter Schritt: Darstellung (Theme + OLED + Akzent)
|
||||
box.appendChild(el("h3", "wizard-title", t("appearance")));
|
||||
box.appendChild(el("p", "auth-sub", t("appearance_desc")));
|
||||
box.appendChild(el("div", "picker-label", t("theme_label")));
|
||||
} else if (wizard.step === 1) {
|
||||
// Schritt 1: Darstellung (Theme + OLED + Akzent)
|
||||
body.appendChild(el("h3", "wizard-title", t("appearance")));
|
||||
body.appendChild(el("p", "auth-sub", t("appearance_desc")));
|
||||
body.appendChild(el("div", "picker-label", t("theme_label")));
|
||||
const themeRow = el("div", "day-picker");
|
||||
for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) {
|
||||
const b = el("button", "day-btn" + (wizard.theme === key ? " is-active" : ""), label);
|
||||
@@ -1782,10 +1790,10 @@ function renderWizardStep() {
|
||||
});
|
||||
themeRow.appendChild(b);
|
||||
}
|
||||
box.appendChild(themeRow);
|
||||
box.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled,
|
||||
body.appendChild(themeRow);
|
||||
body.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled,
|
||||
previewWizardAppearance));
|
||||
box.appendChild(el("div", "picker-label", t("accent_label")));
|
||||
body.appendChild(el("div", "picker-label", t("accent_label")));
|
||||
const accentRow = el("div", "accent-row");
|
||||
for (const [key, color] of Object.entries(ACCENTS)) {
|
||||
const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), "");
|
||||
@@ -1799,9 +1807,50 @@ function renderWizardStep() {
|
||||
});
|
||||
accentRow.appendChild(sw);
|
||||
}
|
||||
box.appendChild(accentRow);
|
||||
body.appendChild(accentRow);
|
||||
} else {
|
||||
// Schritt 2 (nur Setup): Einzel- oder Mehrbenutzermodus
|
||||
body.appendChild(el("p", "auth-sub", t("welcome")));
|
||||
const grid = el("div", "setup-options");
|
||||
const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true);
|
||||
multi.addEventListener("click", () => {
|
||||
wizard.multi = true;
|
||||
renderWizardStep();
|
||||
});
|
||||
grid.appendChild(multi);
|
||||
const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false);
|
||||
solo.addEventListener("click", () => {
|
||||
wizard.multi = false;
|
||||
renderWizardStep();
|
||||
});
|
||||
grid.appendChild(solo);
|
||||
body.appendChild(grid);
|
||||
if (wizard.multi === true) {
|
||||
const form = el("form", "auth-form");
|
||||
form.appendChild(authField(t("username"), "setup-user"));
|
||||
form.appendChild(authField(t("password"), "setup-pass"));
|
||||
form.appendChild(el("div", "auth-register", t("wizard_user_later")));
|
||||
form.appendChild(el("div", "auth-register", t("wizard_admin_hint")));
|
||||
form.addEventListener("submit", ev => ev.preventDefault());
|
||||
body.appendChild(form);
|
||||
// Felder in wizard-Zustand schreiben; Finish live aktivieren
|
||||
const uInput = form.querySelector("#setup-user");
|
||||
const pInput = form.querySelector("#setup-pass");
|
||||
uInput.value = wizard.regUser || "";
|
||||
pInput.value = wizard.regPass || "";
|
||||
const sync = () => {
|
||||
wizard.regUser = uInput.value.trim();
|
||||
wizard.regPass = pInput.value.trim();
|
||||
const fin = box.querySelector(".wizard-nav .btn-primary");
|
||||
if (fin) fin.disabled = !wizardStepValid();
|
||||
};
|
||||
uInput.addEventListener("input", sync);
|
||||
pInput.addEventListener("input", sync);
|
||||
}
|
||||
}
|
||||
|
||||
box.appendChild(body);
|
||||
|
||||
// Fusszeile: Zurueck links, Weiter/Abschliessen rechts
|
||||
const nav = el("div", "wizard-nav");
|
||||
if (wizard.step > 0) {
|
||||
@@ -1839,8 +1888,27 @@ function renderWizardStep() {
|
||||
async function wizardFinish() {
|
||||
const patch = {
|
||||
theme: wizard.theme, oled: wizard.oled, accent: wizard.accent,
|
||||
language: wizard.lang, setupDone: true,
|
||||
language: wizard.lang,
|
||||
};
|
||||
if (wizard.mode === "setup") {
|
||||
patch.setupDone = true;
|
||||
if (wizard.multi) {
|
||||
try {
|
||||
const r = await api("/api/register", jsonOpts("POST", {
|
||||
username: wizard.regUser, password: wizard.regPass,
|
||||
}));
|
||||
localStorage.setItem("tp_token", r.token);
|
||||
app.me = { username: r.username, is_owner: true };
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
setTimeout(() => afterAuth(), 600);
|
||||
} else {
|
||||
flashError(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (app.me && app.me.username) patch.onboarded = true;
|
||||
try {
|
||||
app.settings = await api("/api/settings", jsonOpts("PUT", patch));
|
||||
@@ -1851,29 +1919,6 @@ async function wizardFinish() {
|
||||
await afterAuth();
|
||||
}
|
||||
|
||||
async function wizardRegister(username, password, errEl) {
|
||||
try {
|
||||
const r = await api("/api/register", jsonOpts("POST", {
|
||||
username, password,
|
||||
}));
|
||||
localStorage.setItem("tp_token", r.token);
|
||||
app.me = { username: r.username, is_owner: true };
|
||||
wizard.userCreated = true;
|
||||
renderWizardStep();
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
// Ein Benutzer existiert inzwischen (z.B. zweiter Tab): neu laden
|
||||
if (errEl) errEl.textContent = eMsg(e);
|
||||
setTimeout(() => afterAuth(), 600);
|
||||
} else if (errEl) {
|
||||
errEl.hidden = false;
|
||||
errEl.textContent = eMsg(e);
|
||||
} else {
|
||||
flashError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Anmeldung (Auth-Gate)
|
||||
------------------------------------------------------------------------- */
|
||||
@@ -1933,18 +1978,15 @@ async function doAuth(mode, username, password, errEl) {
|
||||
}
|
||||
}
|
||||
|
||||
function setupLogout() {
|
||||
const btn = $("#logout-btn");
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/api/logout", { method: "POST" });
|
||||
} catch (_) {}
|
||||
localStorage.removeItem("tp_token");
|
||||
app.me = null;
|
||||
app.models = {};
|
||||
$("#view-today").innerHTML = "";
|
||||
$("#today-label").textContent = "";
|
||||
app.meta = await api("/api/meta");
|
||||
function logout() {
|
||||
api("/api/logout", { method: "POST" }).catch(() => {});
|
||||
localStorage.removeItem("tp_token");
|
||||
app.me = null;
|
||||
app.models = {};
|
||||
$("#view-today").innerHTML = "";
|
||||
$("#today-label").textContent = "";
|
||||
api("/api/meta").then(meta => {
|
||||
app.meta = meta;
|
||||
app.settings = { language: app.meta ? app.meta.language : "de" };
|
||||
document.body.classList.add("no-auth");
|
||||
$("#setup-gate").hidden = true;
|
||||
@@ -1953,14 +1995,11 @@ function setupLogout() {
|
||||
});
|
||||
}
|
||||
|
||||
function setUserChip() {
|
||||
const chip = $("#user-chip");
|
||||
const btn = $("#logout-btn");
|
||||
if (app.me && app.me.username) {
|
||||
chip.hidden = false;
|
||||
chip.textContent = app.me.username;
|
||||
btn.hidden = false;
|
||||
}
|
||||
function logoutButton() {
|
||||
const b = el("button", "btn btn-danger-ghost", t("logout_label"));
|
||||
b.type = "button";
|
||||
b.addEventListener("click", () => logout());
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -2095,6 +2134,9 @@ function renderSettingsView() {
|
||||
if (app.me && (!app.me.username || app.me.is_owner)) {
|
||||
view.appendChild(renderUsersSection());
|
||||
}
|
||||
if (app.me && (!app.me.username || app.me.is_owner)) {
|
||||
view.appendChild(renderResetSection());
|
||||
}
|
||||
}
|
||||
|
||||
function settingsSelect(key, label, options) {
|
||||
@@ -2139,6 +2181,9 @@ function renderAccountSection() {
|
||||
row.appendChild(neu);
|
||||
row.appendChild(btn);
|
||||
body.appendChild(row);
|
||||
const logoutRow = el("div", "form-row");
|
||||
logoutRow.appendChild(logoutButton());
|
||||
body.appendChild(logoutRow);
|
||||
sec.appendChild(body);
|
||||
return sec;
|
||||
}
|
||||
@@ -2170,6 +2215,10 @@ add.addEventListener("click", async () => {
|
||||
localStorage.setItem("tp_token", r.token);
|
||||
toast(t("user_created", { u: r.username }), "ok");
|
||||
await afterAuth();
|
||||
// Einstellungen frisch rendern, falls gerade geoeffnet
|
||||
if ($("#view-settings").classList.contains("is-active")) {
|
||||
renderSettingsView();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
toast(eMsg(e), "error");
|
||||
@@ -2253,6 +2302,7 @@ function renderOwnerUsersSection() {
|
||||
}
|
||||
};
|
||||
reload();
|
||||
body.appendChild(el("div", "divider"));
|
||||
const row = el("div", "form-row");
|
||||
const name = el("input", "file-input");
|
||||
name.type = "text";
|
||||
@@ -2281,6 +2331,41 @@ function renderOwnerUsersSection() {
|
||||
return sec;
|
||||
}
|
||||
|
||||
function renderResetSection() {
|
||||
const sec = el("section", "data-section");
|
||||
sec.appendChild(el("h3", "", t("reset_server")));
|
||||
const body = el("div", "data-body");
|
||||
body.appendChild(el("p", "", t("reset_server_desc")));
|
||||
const row = el("div", "form-row");
|
||||
let pw = null;
|
||||
if (app.me.username) {
|
||||
pw = el("input", "file-input");
|
||||
pw.type = "password";
|
||||
pw.placeholder = t("current_pw");
|
||||
}
|
||||
const conf = el("input", "file-input");
|
||||
conf.type = "text";
|
||||
conf.placeholder = t("reset_confirm_hint");
|
||||
const btn = el("button", "btn btn-danger-ghost", t("reset_server_go"));
|
||||
btn.type = "button";
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
const body_pw = pw ? pw.value : "";
|
||||
await api("/api/reset", jsonOpts("POST", {
|
||||
password: body_pw, confirm: conf.value.trim(),
|
||||
}));
|
||||
toast(t("reset_done"), "accent");
|
||||
await afterAuth();
|
||||
} catch (e) { flashError(e); }
|
||||
});
|
||||
if (pw) row.appendChild(pw);
|
||||
row.appendChild(conf);
|
||||
row.appendChild(btn);
|
||||
body.appendChild(row);
|
||||
sec.appendChild(body);
|
||||
return sec;
|
||||
}
|
||||
|
||||
function toggleRow(key, label, desc) {
|
||||
const row = el("label", "toggle-row");
|
||||
const cb = el("input", "toggle-input");
|
||||
@@ -2304,7 +2389,6 @@ function setupUi() {
|
||||
tab.addEventListener("click", () => switchView(tab.dataset.view));
|
||||
}
|
||||
setupOverlay();
|
||||
setupLogout();
|
||||
setupThemeWatch();
|
||||
}
|
||||
|
||||
@@ -2324,12 +2408,29 @@ async function init() {
|
||||
await loadAppData();
|
||||
}
|
||||
|
||||
function updateTodayLabel() {
|
||||
const label = $("#today-label");
|
||||
if (!label) return;
|
||||
const active = Object.values(app.models)
|
||||
.find(m => m.id && m.date === app.meta.today);
|
||||
if (active) {
|
||||
const a = cfgActivity(active.activity);
|
||||
const day = active.day ? " · " + active.day : "";
|
||||
label.textContent = fmtDateLong(app.meta.today) + day + " " + t("session_active");
|
||||
} else {
|
||||
label.textContent = fmtDateLong(app.meta.today);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppData() {
|
||||
app.cfg = await api("/api/config");
|
||||
app.settings = await api("/api/settings");
|
||||
applySettings(app.settings);
|
||||
$("#today-label").textContent = t("today_header") + fmtDateLong(app.meta.today);
|
||||
setUserChip();
|
||||
for (const tab of $$(".tab")) {
|
||||
const key = "tab_" + tab.dataset.view;
|
||||
if (LANG[lang()][key]) tab.textContent = t(key);
|
||||
}
|
||||
updateTodayLabel();
|
||||
await loadToday();
|
||||
const startTab = app.settings.startTab || "today";
|
||||
if (startTab !== "today") switchView(startTab);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+164
-43
@@ -23,15 +23,21 @@
|
||||
"sets": 4,
|
||||
"reps": "8-10",
|
||||
"startKg": 18,
|
||||
"tags": ["brust", "kh"],
|
||||
"info": "Technogym Chest Press (Selection 900/700). Hanteldrücken mit Kurzhanteln: auf dem Polster sitzen, Schulterblätter zusammen, Gewicht langsam nach vorn-oben drücken, oben kurz halten, kontrolliert senken. Macht Brust + hintere Schulter."
|
||||
"tags": [
|
||||
"brust",
|
||||
"kh"
|
||||
],
|
||||
"info": "Chest-Press-Maschine oder freie Hantelbank. Hanteldrücken mit Kurzhanteln: auf der Bank sitzen, Schulterblätter zusammen, Gewicht langsam nach vorn-oben drücken, oben kurz halten, kontrolliert senken. Macht Brust + hintere Schulter."
|
||||
},
|
||||
{
|
||||
"name": "Schrägbank KH (je Hand)",
|
||||
"sets": 3,
|
||||
"reps": "10-12",
|
||||
"startKg": 14,
|
||||
"tags": ["brust", "kh"],
|
||||
"tags": [
|
||||
"brust",
|
||||
"kh"
|
||||
],
|
||||
"info": "Wie Bankdrücken, aber Bank auf ~30-45 Grad geneigt. Ziel: obere Brust + vordere Schulter. Langsam senken bis Brust und zwingend kontrolliert drücken."
|
||||
},
|
||||
{
|
||||
@@ -39,31 +45,43 @@
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 20,
|
||||
"tags": ["brust", "maschine"],
|
||||
"info": "Technogym Pectoral (Selection 900) oder Butterfly-Maschine. Unterarme auf den Polstern, Griffe langsam zusammenführen, in der Endstellung kurz anspannen, kontrolliert zurück."
|
||||
"tags": [
|
||||
"brust",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Butterfly- oder Pectoral-Maschine (Brustfalter). Unterarme auf den Polstern, Griffe langsam zusammenführen, in der Endstellung kurz anspannen, kontrolliert zurück."
|
||||
},
|
||||
{
|
||||
"name": "Seitheben (je Hand)",
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 8,
|
||||
"tags": ["schulter", "kh"],
|
||||
"info": "Technogym Delts Machine (falls Maschine) oder Kurzhanteln. Seitliches Anheben bis Schulterhöhe, Ellenbogen leicht gebeugt, oben kurz halten, langsam senken. Trifft seitliche Schulter (Delts)."
|
||||
"tags": [
|
||||
"schulter",
|
||||
"kh"
|
||||
],
|
||||
"info": "Seitheben-Maschine (Delts) oder Kurzhanteln. Seitliches Anheben bis Schulterhöhe, Ellenbogen leicht gebeugt, oben kurz halten, langsam senken. Trifft seitliche Schulter (Delts)."
|
||||
},
|
||||
{
|
||||
"name": "Schulterpresse Maschine",
|
||||
"sets": 3,
|
||||
"reps": "10-12",
|
||||
"startKg": 25,
|
||||
"tags": ["schulter", "maschine"],
|
||||
"info": "Technogym Shoulder Press (Selection 900/700). Sitzen, hintere Schulter gegen Rückenpolster, Gewicht über Kopf drücken, oben nicht komplett durchstrecken."
|
||||
"tags": [
|
||||
"schulter",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Schulterpresse-Maschine. Sitzen, hintere Schulter gegen das Rückenpolster, Gewicht über Kopf drücken, oben nicht komplett durchstrecken."
|
||||
},
|
||||
{
|
||||
"name": "Kabel-Trizeps-Pushdown",
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 25,
|
||||
"tags": ["trizeps", "kabel"],
|
||||
"tags": [
|
||||
"trizeps",
|
||||
"kabel"
|
||||
],
|
||||
"info": "Am Kabelturm: Griff auf Brusthöhe, Ellenbogen dicht am Körper lassen, Unterarm nach unten strecken, am Ende anspannen, kontrolliert zurück. Dein Favorit am Kabelturm."
|
||||
},
|
||||
{
|
||||
@@ -71,15 +89,21 @@
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 15,
|
||||
"tags": ["trizeps", "maschine"],
|
||||
"info": "Technogym Arm Extension (Selection 900) - Oberarme liegen auf dem Polster, Gewicht über Kopf strecken, nur die Unterarme bewegen sich. Alternativ: Kabel-Überkopf."
|
||||
"tags": [
|
||||
"trizeps",
|
||||
"maschine"
|
||||
],
|
||||
"info": "French-Press-/Arm-Streckmaschine. Oberarme liegen auf dem Polster, Gewicht über Kopf strecken, nur die Unterarme bewegen sich. Alternativ: Kabel-Überkopf."
|
||||
},
|
||||
{
|
||||
"name": "Kabel-Curls (Bizeps)",
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 15,
|
||||
"tags": ["bizeps", "kabel"],
|
||||
"tags": [
|
||||
"bizeps",
|
||||
"kabel"
|
||||
],
|
||||
"info": "Bizeps am Kabelturm (Favorit): Stange oder Seil auf niedriger Höhe, Ellenbogen dicht am Körper, Unterarme nach oben rollen bis der Bizeps angespannt ist, oben kurz halten, langsam zurück. Macht den kompletten Bizeps, gut kontrollierbar."
|
||||
}
|
||||
]
|
||||
@@ -93,23 +117,32 @@
|
||||
"sets": 4,
|
||||
"reps": "8-10",
|
||||
"startKg": 55,
|
||||
"tags": ["ruecken", "maschine"],
|
||||
"info": "Technogym Lat Machine (Selection 900/700) oder Vertical Traction. Griff auf Brust- bzw. Nackenhöhe ziehen, Schulterblätter zuerst nach unten, Ellbogen zum Rumpf."
|
||||
"tags": [
|
||||
"ruecken",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Latzug-Maschine (vertikales Rudern). Griff auf Brust- bzw. Nackenhöhe ziehen, Schulterblätter zuerst nach unten, Ellbogen zum Rumpf."
|
||||
},
|
||||
{
|
||||
"name": "Rudern (Low Row)",
|
||||
"sets": 4,
|
||||
"reps": "10",
|
||||
"startKg": 50,
|
||||
"tags": ["ruecken", "maschine"],
|
||||
"info": "Technogym Low Row (Selection 900/700). Griff zum Bauch ziehen, Rumpf stabil, Schulterblätter hinten zusammenziehen, langsam zurück. Trifft breiten Rücken + Rhomboiden."
|
||||
"tags": [
|
||||
"ruecken",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Rudern-Maschine (Low Row, horizontales Rudern). Griff zum Bauch ziehen, Rumpf stabil, Schulterblätter hinten zusammenziehen, langsam zurück. Trifft breiten Rücken + Rhomboiden."
|
||||
},
|
||||
{
|
||||
"name": "Face Pulls",
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 20,
|
||||
"tags": ["schulter", "kabel"],
|
||||
"tags": [
|
||||
"schulter",
|
||||
"kabel"
|
||||
],
|
||||
"info": "Am Kabelturm, Band oder Seil auf Augenhöhe: zu dir zum Gesicht ziehen, Ellbogen auf Schulterhöhe ausbreiten. Trifft hintere Schulter + hält die Schulterblätter gesund."
|
||||
},
|
||||
{
|
||||
@@ -117,7 +150,10 @@
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 14,
|
||||
"tags": ["bizeps", "kabel"],
|
||||
"tags": [
|
||||
"bizeps",
|
||||
"kabel"
|
||||
],
|
||||
"info": "Kabelturm-Curls mit höherem Griff: leichte Neigung, Zug von oben-vorne. Alternativ oder als zweite Bizeps-Übung, anderer Winkel als der normale Kabel-Curl."
|
||||
},
|
||||
{
|
||||
@@ -125,8 +161,11 @@
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 20,
|
||||
"tags": ["schulter", "maschine"],
|
||||
"info": "Technogym Reverse Fly (Selection 900) oder Dual Pectoral/Reverse Fly (700). Nach vorn lehnen bzw. sitzen, Arme nach hinten-aussen hebend, Schulterblätter zusammen. Trifft hintere Delts + Rhomboiden, gute Haltung."
|
||||
"tags": [
|
||||
"schulter",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Reverse-Fly-Maschine (Rückwärtsheben). Nach vorn lehnen bzw. sitzen, Arme nach hinten-aussen hebend, Schulterblätter zusammen. Trifft hintere Delts + Rhomboiden, gute Haltung."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -139,48 +178,66 @@
|
||||
"sets": 4,
|
||||
"reps": "8-10",
|
||||
"startKg": 120,
|
||||
"tags": ["beine", "maschine"],
|
||||
"info": "Technogym Leg Press (Selection 900/700). Sitzen, Füße schulterbreit auf der Platte, nicht komplett durchstrecken (Knie nie ganz gestreckt), kontrolliert bis 90 Grad senken, drücken. Schwerste Übung des Tages."
|
||||
"tags": [
|
||||
"beine",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Beinpresse-Maschine. Sitzen, Füße schulterbreit auf der Platte, nicht komplett durchstrecken (Knie nie ganz gestreckt), kontrolliert bis 90 Grad senken, drücken. Schwerste Übung des Tages."
|
||||
},
|
||||
{
|
||||
"name": "Beinbeuger (Leg Curl)",
|
||||
"sets": 3,
|
||||
"reps": "10-12",
|
||||
"startKg": 50,
|
||||
"tags": ["beine", "maschine"],
|
||||
"info": "Technogym Leg Curl (Selection 900/700), sitzend oder liegend. Knie beugen, Fersen zum Gesäß ziehen, oben kurz anspannen. Trifft Beinrückseite (Hamstrings)."
|
||||
"tags": [
|
||||
"beine",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Beinbeuger-Maschine (Leg Curl), sitzend oder liegend. Knie beugen, Fersen zum Gesäß ziehen, oben kurz anspannen. Trifft Beinrückseite (Hamstrings)."
|
||||
},
|
||||
{
|
||||
"name": "Beinstrecker (Leg Extension)",
|
||||
"sets": 3,
|
||||
"reps": "12-15",
|
||||
"startKg": 55,
|
||||
"tags": ["beine", "maschine"],
|
||||
"info": "Technogym Leg Extension (Selection 900/700) - dein Kind. Beine unter dem Polster, Knie voll strecken und oben kurz halten, langsam senken. Trifft die Oberschenkelvorderseite (Quadrizeps). Dein 55er-Gewicht passt hier perfekt."
|
||||
"tags": [
|
||||
"beine",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Beinstrecker-Maschine (Leg Extension) - dein Kind. Beine unter dem Polster, Knie voll strecken und oben kurz halten, langsam senken. Trifft die Oberschenkelvorderseite (Quadrizeps). Dein 55er-Gewicht passt hier perfekt."
|
||||
},
|
||||
{
|
||||
"name": "Wadenheben stehend",
|
||||
"sets": 3,
|
||||
"reps": "15-20",
|
||||
"startKg": 65,
|
||||
"tags": ["waden", "maschine"],
|
||||
"info": "Technogym Standing Calf (Selection 900). Auf Zehenspitzen hochdrücken, oben kurz halten, bis die Wade gedehnt ist senken. Trifft die Wadenmuskulatur."
|
||||
"tags": [
|
||||
"waden",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Wadenheben stehend an der Maschine. Auf Zehenspitzen hochdrücken, oben kurz halten, bis die Wade gedehnt ist senken. Trifft die Wadenmuskulatur."
|
||||
},
|
||||
{
|
||||
"name": "Glute Press / Hip Thrust",
|
||||
"sets": 3,
|
||||
"reps": "10-12",
|
||||
"startKg": 60,
|
||||
"tags": ["po", "maschine"],
|
||||
"info": "Technogym Glute Press oder Hip Thrust (Selection 900). Schulterblätter auf das Polster, Hüfte nach oben drücken, oben anspannen. Trifft Gesäß + hintere Oberschenkel."
|
||||
"tags": [
|
||||
"po",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Glute-Press- oder Hip-Thrust-Maschine. Schulterblätter auf das Polster, Hüfte nach oben drücken, oben anspannen. Trifft Gesäß + hintere Oberschenkel."
|
||||
},
|
||||
{
|
||||
"name": "(opz.) Bauch-Finisher (Ab Crunch)",
|
||||
"sets": 2,
|
||||
"reps": "10-15",
|
||||
"startKg": 0,
|
||||
"tags": ["bauch", "maschine"],
|
||||
"info": "Technogym Abdominal Crunch (Selection 900/700). Optional zum Abschluss: Rumpf runden, Bauch anspannen, nicht am Nacken ziehen. Nur wenn es sich gut anfühlt."
|
||||
"tags": [
|
||||
"bauch",
|
||||
"maschine"
|
||||
],
|
||||
"info": "Abdominal-Crunch-Maschine. Optional zum Abschluss: Rumpf runden, Bauch anspannen, nicht am Nacken ziehen. Nur wenn es sich gut anfühlt."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -194,11 +251,43 @@
|
||||
"active": true,
|
||||
"displayOrder": 2,
|
||||
"fieldTemplates": [
|
||||
{ "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 },
|
||||
{ "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 },
|
||||
{ "key": "pacePerKm", "label": "Pace", "unit": "min/km", "kind": "pace", "step": null },
|
||||
{ "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true },
|
||||
{ "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true }
|
||||
{
|
||||
"key": "distanceKm",
|
||||
"label": "Distanz",
|
||||
"unit": "km",
|
||||
"kind": "number",
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "minutes",
|
||||
"label": "Dauer",
|
||||
"unit": "min",
|
||||
"kind": "number",
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "pacePerKm",
|
||||
"label": "Pace",
|
||||
"unit": "min/km",
|
||||
"kind": "pace",
|
||||
"step": null
|
||||
},
|
||||
{
|
||||
"key": "heartRate",
|
||||
"label": "Puls Ø",
|
||||
"unit": "bpm",
|
||||
"kind": "number",
|
||||
"step": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"key": "elevation",
|
||||
"label": "Höhenmeter",
|
||||
"unit": "m",
|
||||
"kind": "number",
|
||||
"step": 1,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -208,11 +297,43 @@
|
||||
"active": true,
|
||||
"displayOrder": 3,
|
||||
"fieldTemplates": [
|
||||
{ "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 },
|
||||
{ "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 },
|
||||
{ "key": "avgSpeed", "label": "Ø Tempo", "unit": "km/h", "kind": "number", "step": 0.1 },
|
||||
{ "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true },
|
||||
{ "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true }
|
||||
{
|
||||
"key": "distanceKm",
|
||||
"label": "Distanz",
|
||||
"unit": "km",
|
||||
"kind": "number",
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "minutes",
|
||||
"label": "Dauer",
|
||||
"unit": "min",
|
||||
"kind": "number",
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "avgSpeed",
|
||||
"label": "Ø Tempo",
|
||||
"unit": "km/h",
|
||||
"kind": "number",
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "heartRate",
|
||||
"label": "Puls Ø",
|
||||
"unit": "bpm",
|
||||
"kind": "number",
|
||||
"step": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"key": "elevation",
|
||||
"label": "Höhenmeter",
|
||||
"unit": "m",
|
||||
"kind": "number",
|
||||
"step": 1,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+4074
-1405
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,6 @@
|
||||
<button class="tab" data-view="data" type="button">Daten</button>
|
||||
<button class="tab" data-view="settings" type="button">Einstellungen</button>
|
||||
</nav>
|
||||
<span class="user-chip" id="user-chip" hidden></span>
|
||||
<button class="btn btn-small" id="logout-btn" hidden type="button">Abmelden</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -434,6 +434,26 @@ def fetch_session(user_id, sid):
|
||||
return d
|
||||
|
||||
|
||||
def last_exercise_order(user_id, activity, day):
|
||||
"""Uebungs-Reihenfolge der letzten abgeschlossenen Session (Tag/Kategorie)."""
|
||||
con = db()
|
||||
clause, extra = _user_clause(user_id)
|
||||
rows = con.execute(
|
||||
f"SELECT fields FROM sessions WHERE activity=? AND day=? AND status='done' "
|
||||
f"AND {clause} ORDER BY date DESC, id DESC LIMIT 10",
|
||||
[activity, day] + extra).fetchall()
|
||||
con.close()
|
||||
for r in rows:
|
||||
try:
|
||||
f = json.loads(r["fields"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
order = f.get("exerciseOrder")
|
||||
if isinstance(order, list) and order:
|
||||
return order
|
||||
return None
|
||||
|
||||
|
||||
def create_session(user_id, data):
|
||||
activity = _clean_str(data.get("activity"))
|
||||
if not activity:
|
||||
@@ -441,12 +461,15 @@ def create_session(user_id, data):
|
||||
sdate = _clean_str(data.get("date")) or date.today().isoformat()
|
||||
day = _clean_str(data.get("day"))
|
||||
start = _clean_str(data.get("start_time")) or datetime.now().strftime("%H:%M")
|
||||
# Reihenfolge der Uebungen aus der letzten abgeschlossenen Session uebernehmen
|
||||
order = last_exercise_order(user_id, activity, day)
|
||||
fields = json.dumps({"exerciseOrder": order}) if order else "{}"
|
||||
con = db()
|
||||
cur = con.execute(
|
||||
"INSERT INTO sessions(activity,day,date,start_time,status,created_at,user_id) "
|
||||
"VALUES(?,?,?,?,?,?,?)",
|
||||
"INSERT INTO sessions(activity,day,date,start_time,status,created_at,user_id,fields) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
(activity, day, sdate, start, "active",
|
||||
datetime.now().isoformat(timespec="seconds"), user_id))
|
||||
datetime.now().isoformat(timespec="seconds"), user_id, fields))
|
||||
con.commit()
|
||||
sid = cur.lastrowid
|
||||
con.close()
|
||||
@@ -651,6 +674,8 @@ def create_user(username, password):
|
||||
# - Sessions wandern zum Owner (Alt-Daten gehoeren ihm),
|
||||
# - Settings (Sprache/Akzent aus dem Wizard) werden KOPIERT, damit der
|
||||
# Server-Standard (user_id=0) fuer weitere Benutzer erhalten bleibt.
|
||||
# - Der Admin ist automatisch "onboarded" (Sprache/Darstellung kommen aus
|
||||
# dem Server-Standard, kein erneuter Wizard).
|
||||
if uid == 1:
|
||||
con.execute("UPDATE sessions SET user_id=? WHERE user_id=?",
|
||||
(uid, OPEN_USER))
|
||||
@@ -658,6 +683,9 @@ def create_user(username, password):
|
||||
"INSERT INTO settings(user_id,key,value) "
|
||||
"SELECT ?, key, value FROM settings WHERE user_id=?",
|
||||
(uid, OPEN_USER))
|
||||
con.execute(
|
||||
"INSERT INTO settings(user_id,key,value) VALUES(?, 'onboarded', 'true')",
|
||||
(uid,))
|
||||
token = _issue_token(con, uid)
|
||||
con.commit()
|
||||
con.close()
|
||||
@@ -770,6 +798,16 @@ def set_user_password(uid, new):
|
||||
return True
|
||||
|
||||
|
||||
def reset_server():
|
||||
"""Kompletter Reset: alle Sessions, Settings, Tokens und Benutzer loeschen."""
|
||||
con = db()
|
||||
for t in ("entries", "sessions", "settings", "tokens", "users"):
|
||||
con.execute(f"DELETE FROM {t}")
|
||||
con.execute("DELETE FROM sqlite_sequence")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-Reset: letztes Workout einer Kategorie laden
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1415,6 +1453,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send(400, json.dumps(
|
||||
{"error": "Aktuelles Passwort ist falsch"}),
|
||||
"application/json; charset=utf-8")
|
||||
elif path in ("/api/reset", "/api/reset/"):
|
||||
self.do_reset()
|
||||
else:
|
||||
uid = self._require_user()
|
||||
if uid is None:
|
||||
@@ -1450,6 +1490,36 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send(500, json.dumps({"error": str(exc)}),
|
||||
"application/json; charset=utf-8")
|
||||
|
||||
def do_reset(self):
|
||||
uid = self._require_user()
|
||||
if uid is None:
|
||||
return
|
||||
data = self._read_json()
|
||||
if uid != OPEN_USER:
|
||||
# Mehrbenutzermodus: nur der Owner, mit Passwort
|
||||
if uid != 1:
|
||||
self._send(403, json.dumps({"error": "nur der erste Benutzer"}),
|
||||
"application/json; charset=utf-8")
|
||||
return
|
||||
con = db()
|
||||
row = con.execute("SELECT * FROM users WHERE id=?",
|
||||
(uid,)).fetchone()
|
||||
con.close()
|
||||
if not row or not _verify_pw(data.get("password", ""),
|
||||
row["salt"], row["password_hash"]):
|
||||
self._send(400, json.dumps(
|
||||
{"error": "Aktuelles Passwort ist falsch"}),
|
||||
"application/json; charset=utf-8")
|
||||
return
|
||||
# Einzelmodus (offen): kein Passwort noetig, nur Bestaetigungstext
|
||||
if _clean_str(data.get("confirm")) != "RESET":
|
||||
self._send(400, json.dumps(
|
||||
{"error": "Bestätigungstext ist falsch"}),
|
||||
"application/json; charset=utf-8")
|
||||
return
|
||||
reset_server()
|
||||
self._send_json(200, {"ok": True})
|
||||
|
||||
def do_register(self):
|
||||
if user_count() > 0:
|
||||
self._send(403, json.dumps(
|
||||
|
||||
+87
-24
@@ -63,6 +63,10 @@ html[data-accent="violet"] { --accent: #9a6fd0; --accent-hover: #ab80e0; --accen
|
||||
html[data-accent="pink"] { --accent: #e0567f; --accent-hover: #eb6890; --accent-active: #c74b6e; --accent-ink: #141414; }
|
||||
html[data-accent="white"] { --accent: #ffffff; --accent-hover: #f2f2f2; --accent-active: #e6e6e6; --accent-ink: #141414; --accent-border: #9a9a9a; }
|
||||
|
||||
html[data-theme="light"][data-accent="white"] .wp-step { border-color: #8a8a8a; }
|
||||
html[data-theme="light"][data-accent="white"] .wp-step.is-done { border-color: #000; background: #000; }
|
||||
html[data-theme="light"][data-accent="white"] .wp-step.is-current { border-color: #000; background: transparent; }
|
||||
|
||||
html[data-theme="dark"], html[data-theme="oled"] { color-scheme: dark; }
|
||||
html[data-theme="light"] { color-scheme: light; }
|
||||
|
||||
@@ -135,9 +139,23 @@ html, body {
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
border: 2px solid var(--border);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tab {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topbar { padding: 10px 12px; }
|
||||
.tabs { margin-left: 0; width: 100%; }
|
||||
.tab { padding: 6px 10px; font-size: 13px; }
|
||||
.brand-name { font-size: 15px; }
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -199,6 +217,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 7px 14px;
|
||||
min-width: 96px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -224,7 +243,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.btn-danger-ghost:hover { background: var(--bg-hover); }
|
||||
.btn-danger-ghost.is-armed { background: var(--danger); color: #fff; }
|
||||
|
||||
.btn-small { padding: 4px 10px; font-size: 13px; }
|
||||
.btn-small { padding: 4px 10px; font-size: 13px; min-width: 64px; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.icon-btn {
|
||||
@@ -232,13 +251,13 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 2px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -381,12 +400,21 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.ex-block {
|
||||
border: 2px solid var(--border);
|
||||
margin: 0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ex-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ex-grip:active { cursor: grabbing; }
|
||||
|
||||
.ex-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(460px, 100%), 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -418,13 +446,21 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.set-rows { padding: 0; }
|
||||
|
||||
.set-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto 1fr auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.set-gap { min-width: 0; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.field-box { width: 72px; }
|
||||
}
|
||||
|
||||
.set-rows { overflow: hidden; }
|
||||
|
||||
.set-row.is-done { background: var(--bg-subtle); }
|
||||
|
||||
/* Set-Abhaken: ganzer Button klickbar, mit Ankreuz-Box */
|
||||
@@ -433,16 +469,17 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
gap: 4px;
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 2px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.set-check:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
||||
@@ -456,10 +493,10 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -481,6 +518,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.field-box {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
width: 84px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
cursor: text;
|
||||
@@ -490,13 +528,15 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.field-box:hover { border-color: var(--border-hover); }
|
||||
|
||||
.field-input {
|
||||
width: 74px;
|
||||
padding: 6px 4px 6px 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-data);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
outline: 0;
|
||||
}
|
||||
@@ -506,12 +546,13 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
.field-unit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
padding: 0 4px;
|
||||
border-left: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-data);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cardio-Felder */
|
||||
@@ -523,7 +564,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardio-fields .field-input { width: 90px; }
|
||||
.cardio-fields .field-box { width: 120px; }
|
||||
|
||||
/* ===========================================================================
|
||||
Hinweise / Leer-Zustaende
|
||||
@@ -919,9 +960,25 @@ body.boot #setup-gate { visibility: hidden; }
|
||||
width: 640px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#setup-gate .auth-box {
|
||||
min-height: 600px;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1038,7 +1095,8 @@ body.boot #setup-gate { visibility: hidden; }
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.wizard-nav-right {
|
||||
@@ -1073,6 +1131,11 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 2px solid var(--border);
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.settings-select-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -212,6 +212,15 @@ def main():
|
||||
check("Statistik: bestes Gewicht je Termin",
|
||||
s0["weight"] == 22 and s1["weight"] == 20, stats)
|
||||
|
||||
# Uebungs-Reihenfolge der letzten abgeschlossenen Session uebernehmen
|
||||
order = ["Push/Schrägbank KH (je Hand)", "Push/Bankdrücken KH (je Hand)"]
|
||||
req("PATCH", f"/api/sessions/{pid}",
|
||||
{"meta": {"fields": {"exerciseOrder": order}}})
|
||||
st, ns = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
|
||||
check("Uebungs-Reihenfolge beim Start uebernommen",
|
||||
ns["fields"].get("exerciseOrder") == order, ns["fields"])
|
||||
req("DELETE", f"/api/sessions/{ns['id']}")
|
||||
|
||||
st, exp = req("GET", "/api/export?format=json")
|
||||
check("Export JSON Format", st == 200
|
||||
and exp["format"] == "trainingsplan-export-v2"
|
||||
|
||||
+44
-7
@@ -81,6 +81,8 @@ def main():
|
||||
req("PATCH", f"/api/sessions/{sess['id']}",
|
||||
{"meta": {"status": "done"}})
|
||||
|
||||
|
||||
|
||||
# --- Registrierung (erster Benutzer) --------------------------------
|
||||
st, reg = req("POST", "/api/register",
|
||||
{"username": "alex", "password": "geheim"})
|
||||
@@ -147,7 +149,14 @@ def main():
|
||||
st, imp = req("POST", "/api/import",
|
||||
{"format": "trainingsplan-export-v2",
|
||||
"data": json.dumps(demo)}, token=token_b)
|
||||
check("Demo-Import in Berta", st == 200 and imp["created"] == 22, imp)
|
||||
demo_sessions = len(demo["sessions"])
|
||||
demo_gym = sum(1 for s in demo["sessions"] if s["activity"] == "gym")
|
||||
demo_ex_dates = len({s["date"] for s in demo["sessions"]
|
||||
for e in s.get("entries", [])
|
||||
if e.get("exercise") == "Push/Bankdrücken KH (je Hand)"
|
||||
and e.get("weight")})
|
||||
check("Demo-Import in Berta", st == 200
|
||||
and imp["created"] == demo_sessions, imp)
|
||||
|
||||
# --- Berta trainiert selbst -------------------------------------------
|
||||
st, sess_b = req("POST", "/api/sessions",
|
||||
@@ -161,8 +170,8 @@ def main():
|
||||
|
||||
st, done_b = req("GET", "/api/sessions?status=done", token=token_b)
|
||||
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
|
||||
check("Isolation: B sieht 23 eigene Sessions (22 Demo + 1)",
|
||||
len(done_b) == 23, len(done_b))
|
||||
check("Isolation: B sieht alle Demo-Sessions + 1 eigene",
|
||||
len(done_b) == demo_sessions + 1, len(done_b))
|
||||
check("Isolation: A sieht seine 1 Session (nicht B)",
|
||||
len(done_a) == 1, len(done_a))
|
||||
|
||||
@@ -191,22 +200,23 @@ def main():
|
||||
|
||||
# --- Workouts-API -----------------------------------------------------
|
||||
st, wo_b = req("GET", "/api/workouts", token=token_b)
|
||||
check("Workouts-API: 23 Workouts", st == 200 and len(wo_b) == 23, len(wo_b))
|
||||
check("Workouts-API: alle Demo-Workouts + eigene",
|
||||
st == 200 and len(wo_b) == demo_sessions + 1, len(wo_b))
|
||||
w = wo_b[0] # neueste Session = Berta heute
|
||||
check("Workouts-API: Shape", w["activity_name"] == "Gym (Kraft)"
|
||||
and w["day"] == "Push" and w["duration_min"] is not None
|
||||
and w["exercises"][0]["name"] == "Bankdrücken KH (je Hand)"
|
||||
and w["exercises"][0]["sets"][0]["weight"] == "40", w)
|
||||
st, wo_b = req("GET", "/api/workouts?activity=gym", token=token_b)
|
||||
check("Workouts nach Import (gym): 15 Demo + 1 eigene", len(wo_b) == 16,
|
||||
len(wo_b))
|
||||
check("Workouts nach Import (gym): Demo + 1 eigene",
|
||||
len(wo_b) == demo_gym + 1, len(wo_b))
|
||||
st, wo_a = req("GET", "/api/workouts", token=token_a)
|
||||
check("A bleibt unberuehrt vom Import", len(wo_a) == 1, len(wo_a))
|
||||
st, stats = req("GET", "/api/stats?exercise="
|
||||
+ urllib.parse.quote("Push/Bankdrücken KH (je Hand)"),
|
||||
token=token_b)
|
||||
check("Statistik aus Demo-Daten + eigene Session",
|
||||
st == 200 and len(stats) == 6
|
||||
st == 200 and len(stats) == demo_ex_dates + 1
|
||||
and stats[0]["weight"] < stats[-1]["weight"]
|
||||
and stats[-1]["weight"] == 40, stats)
|
||||
|
||||
@@ -262,6 +272,33 @@ def main():
|
||||
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
|
||||
check("A-Daten nach Loeschung von B intakt", len(done_a) == 1, len(done_a))
|
||||
|
||||
# --- Server-Reset (doppelt gesichert) -----------------------------------
|
||||
st, _ = req("POST", "/api/reset",
|
||||
{"password": "falsch", "confirm": "RESET"}, token=token_a)
|
||||
check("Reset: falsches Passwort -> 400", st == 400, st)
|
||||
st, _ = req("POST", "/api/reset",
|
||||
{"password": "neu123", "confirm": "NEIN"}, token=token_a)
|
||||
check("Reset: falscher Bestaetigungstext -> 400", st == 400, st)
|
||||
st, _ = req("POST", "/api/reset",
|
||||
{"password": "neu123", "confirm": "RESET"}, token=token_a)
|
||||
check("Reset: korrekt -> 200", st == 200, st)
|
||||
st, meta = req("GET", "/api/meta")
|
||||
check("Reset: offener Modus (kein Benutzer mehr)",
|
||||
meta["users"] == 0 and meta["auth_required"] is False, meta)
|
||||
st, me = req("GET", "/api/me", token=token_a)
|
||||
check("Reset: im offenen Modus kein Benutzername",
|
||||
st == 200 and me.get("username") is None, me)
|
||||
st, _ = req("GET", "/api/sessions")
|
||||
check("Reset: Sessions ohne Anmeldung wieder sichtbar (offen)", st == 200, st)
|
||||
|
||||
# Reset im Einzelmodus (nach Owner-Reset ist der Server offen)
|
||||
st, _ = req("POST", "/api/reset", {"confirm": "RESET"})
|
||||
check("Reset im Einzelmodus: ohne Passwort -> 200", st == 200, st)
|
||||
st, _ = req("POST", "/api/reset", {"confirm": "NEIN"})
|
||||
check("Reset im Einzelmodus: falscher Text -> 400", st == 400, st)
|
||||
st, meta = req("GET", "/api/meta")
|
||||
check("nach Einzelmodus-Reset: weiterhin offen", meta["users"] == 0, meta)
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
+9
-9
@@ -97,18 +97,18 @@ async function main() {
|
||||
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
|
||||
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Weiter").click();
|
||||
await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2);
|
||||
await waitFor(dom, d => gate().querySelector(".accent-row"));
|
||||
check("Setup: Fortschritt aktualisiert",
|
||||
gate().querySelectorAll(".wp-step.is-done").length === 1
|
||||
&& gate().querySelectorAll(".wp-step.is-current").length === 1);
|
||||
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Weiter").click();
|
||||
await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2);
|
||||
const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
|
||||
check("Setup: zwei gleichwertige Optionen",
|
||||
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
|
||||
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
|
||||
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click();
|
||||
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Weiter").click();
|
||||
await waitFor(dom, d => gate().querySelector(".accent-row"));
|
||||
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
|
||||
@@ -169,9 +169,9 @@ async function main() {
|
||||
window.document.querySelector('.tab[data-view="history"]').click();
|
||||
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
|
||||
const histCard = () => window.document.querySelector(".hist-card");
|
||||
check("Verlauf: Haken-Box zeigt Abhak-Status",
|
||||
!!histCard().querySelector(".set-done-box")
|
||||
&& histCard().querySelector(".set-done-box").textContent === "✓");
|
||||
check("Verlauf: nur abgehakte Saetze, kompakt",
|
||||
!histCard().querySelector(".set-done-box")
|
||||
&& !histCard().querySelector(".hist-sets").textContent.includes("□"));
|
||||
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
|
||||
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
|
||||
|
||||
@@ -238,8 +238,8 @@ async function main() {
|
||||
check("Hinweiszeile ausgeblendet",
|
||||
!gymCard().querySelector(".ex-block .ex-hint"));
|
||||
|
||||
// --- Uebungen per Drag & Drop sortieren --------------------------------
|
||||
gymCard().querySelectorAll(".ex-block")[0]
|
||||
// --- Uebungen per Drag & Drop (Griff) sortieren ------------------------
|
||||
gymCard().querySelectorAll(".ex-block")[0].querySelector(".ex-grip")
|
||||
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
|
||||
const target = gymCard().querySelectorAll(".ex-block")[1];
|
||||
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
|
||||
|
||||
+51
-37
@@ -85,6 +85,13 @@ check("Setup: Sprache kommt zuerst",
|
||||
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Next").click();
|
||||
await sleep(200);
|
||||
check("Setup: Darstellung kommt als zweites",
|
||||
sgate().querySelector(".wizard-title").textContent === "Appearance"
|
||||
&& !!sgate().querySelector(".accent-row")
|
||||
&& !!sgate().querySelector(".toggle-row"));
|
||||
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Next").click();
|
||||
await sleep(200);
|
||||
const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
|
||||
check("Setup: zwei gleichwertige Optionen",
|
||||
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
|
||||
@@ -97,15 +104,18 @@ check("Setup: Sprache kommt zuerst",
|
||||
check("Setup: Formular nach Mehrbenutzermodus-Klick",
|
||||
!!sgate().querySelector("input#setup-user")
|
||||
&& !!sgate().querySelector("input#setup-pass"));
|
||||
// Einzelmodus waehlen -> Weiter -> Darstellung -> abschliessen
|
||||
// User wird erst beim Abschliessen erstellt (nicht beim Tippen)
|
||||
sgate().querySelector("#setup-user").value = "alex";
|
||||
sgate().querySelector("#setup-pass").value = "geheim";
|
||||
sgate().querySelector("#setup-user")
|
||||
.dispatchEvent(new winSetup.window.Event("input", { bubbles: true }));
|
||||
sgate().querySelector("#setup-pass")
|
||||
.dispatchEvent(new winSetup.window.Event("input", { bubbles: true }));
|
||||
await sleep(150);
|
||||
check("Setup: User wird erst beim Abschliessen erstellt",
|
||||
(await api("GET", "/api/meta")).body.users === 0);
|
||||
// Einzelmodus waehlen -> abschliessen (User wird NICHT erstellt)
|
||||
setupOptions().find(b => b.textContent.includes("Single-user mode")).click();
|
||||
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Next").click();
|
||||
await sleep(200);
|
||||
check("Setup: Darstellung-Schritt (Theme + Akzent)",
|
||||
sgate().querySelector(".wizard-title").textContent === "Appearance"
|
||||
&& !!sgate().querySelector(".accent-row")
|
||||
&& !!sgate().querySelector(".toggle-row"));
|
||||
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Finish setup").click();
|
||||
await sleep(400);
|
||||
@@ -116,6 +126,8 @@ check("Setup: Sprache kommt zuerst",
|
||||
|
||||
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
|
||||
check("API: Register", reg.status === 200 && !!reg.body.token, reg.body);
|
||||
// Admin ist automatisch eingestellt (kein Onboarding), Sprache fuer Tests auf Deutsch
|
||||
await api("PUT", "/api/settings", { language: "de" }, reg.body.token);
|
||||
|
||||
// --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place ---
|
||||
const winNoToken = await loadApp(html, appJs, null);
|
||||
@@ -127,23 +139,23 @@ check("Setup: Sprache kommt zuerst",
|
||||
!!gate().querySelector('input#auth-user')
|
||||
&& !!gate().querySelector('input#auth-pass')
|
||||
&& !!gate().querySelector(".btn-primary"));
|
||||
check("ohne Token: Login-Screen in Server-Sprache (en)",
|
||||
check("ohne Token: Login-Screen in Server-Sprache",
|
||||
Array.from(gate().querySelectorAll(".btn"))
|
||||
.some(b => b.textContent === "Log in")
|
||||
&& gate().querySelector(".auth-sub").textContent.includes("Log in to see"));
|
||||
.some(b => b.textContent === "Anmelden")
|
||||
&& gate().querySelector(".auth-sub").textContent.includes("Anmelden, um deine Trainingsdaten zu sehen"));
|
||||
// falsches Kennwort -> Inline-Fehler
|
||||
gate().querySelector('input#auth-user').value = "alex";
|
||||
gate().querySelector('input#auth-pass').value = "falsch";
|
||||
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
|
||||
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Anmelden").click();
|
||||
await sleep(500);
|
||||
const authErr = gate().querySelector(".auth-error");
|
||||
check("ohne Token: Inline-Fehler bei falschem Kennwort",
|
||||
authErr && !authErr.hidden
|
||||
&& authErr.textContent.includes("Incorrect username or password"),
|
||||
&& authErr.textContent.includes("Kennwort inkorrekt"),
|
||||
authErr && authErr.textContent);
|
||||
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
|
||||
gate().querySelector('input#auth-pass').value = "geheim";
|
||||
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
|
||||
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Anmelden").click();
|
||||
let loggedIn = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const og = winNoToken.document.querySelector("#setup-gate");
|
||||
@@ -152,32 +164,16 @@ check("Setup: Sprache kommt zuerst",
|
||||
break;
|
||||
}
|
||||
if (winNoToken.document.querySelectorAll(".act-card").length === 3
|
||||
&& winNoToken.document.querySelector("#user-chip").textContent === "alex") {
|
||||
&& winNoToken.document.querySelector("#logout-btn") === null) {
|
||||
loggedIn = true;
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
|
||||
// Onboarding abschliessen (im selben Fenster)
|
||||
const ogate = () => winNoToken.document.querySelector("#setup-gate");
|
||||
if (ogate() && !ogate().hidden) {
|
||||
Array.from(ogate().querySelectorAll(".day-btn"))
|
||||
.find(b => b.textContent === "German").click();
|
||||
await sleep(150);
|
||||
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Weiter").click();
|
||||
await sleep(200);
|
||||
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||
await sleep(400);
|
||||
}
|
||||
check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar",
|
||||
ogate() && ogate().hidden
|
||||
&& winNoToken.document.querySelectorAll(".act-card").length === 3
|
||||
&& winNoToken.document.querySelector("#user-chip").textContent === "alex");
|
||||
check("Login: Abmelden-Knopf sichtbar",
|
||||
!winNoToken.document.querySelector("#logout-btn").hidden);
|
||||
check("Login: Admin ohne Onboarding direkt in Heute-Ansicht",
|
||||
winNoToken.document.querySelector("#setup-gate").hidden
|
||||
&& winNoToken.document.querySelectorAll(".act-card").length === 3);
|
||||
check("Login: kein Auth-Gate mehr",
|
||||
winNoToken.document.querySelector("#auth-gate").hidden);
|
||||
|
||||
@@ -186,8 +182,7 @@ check("Setup: Sprache kommt zuerst",
|
||||
await sleep(1200);
|
||||
let tokOk = false;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (winTok.document.querySelectorAll(".act-card").length === 3
|
||||
&& winTok.document.querySelector("#user-chip").textContent === "alex") {
|
||||
if (winTok.document.querySelectorAll(".act-card").length === 3) {
|
||||
tokOk = true;
|
||||
break;
|
||||
}
|
||||
@@ -210,7 +205,9 @@ check("Setup: Sprache kommt zuerst",
|
||||
&& Array.from(winTok.document.querySelectorAll(".data-section"))
|
||||
.some(s => s.querySelector("h3").textContent === "Konto"
|
||||
&& !!s.querySelector('input[placeholder="Aktuelles Kennwort"]')
|
||||
&& !!s.querySelector('input[placeholder="Neues Kennwort"]')));
|
||||
&& !!s.querySelector('input[placeholder="Neues Kennwort"]')
|
||||
&& Array.from(s.querySelectorAll(".btn"))
|
||||
.some(b => b.textContent === "Abmelden")));
|
||||
check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (Array.from(winTok.document.querySelectorAll(".user-row"))
|
||||
@@ -234,6 +231,23 @@ check("Setup: Sprache kommt zuerst",
|
||||
Array.from(rows()[0].querySelectorAll(".btn"))
|
||||
.some(b => b.textContent === "Kennwort ändern"));
|
||||
|
||||
// Berta (normaler User) durchlaeuft einmaliges Onboarding (Sprache zuerst)
|
||||
const loginB = await api("POST", "/api/login",
|
||||
{ username: "berta", password: "geheim" });
|
||||
const winB = await loadApp(html, appJs, loginB.body.token);
|
||||
await sleep(1200);
|
||||
const bg = () => winB.document.querySelector("#setup-gate");
|
||||
check("Berta: Onboarding erscheint (einmalig)",
|
||||
bg() && !bg().hidden);
|
||||
Array.from(bg().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Weiter").click();
|
||||
await sleep(150);
|
||||
Array.from(bg().querySelectorAll(".wizard-nav .btn"))
|
||||
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||
await sleep(400);
|
||||
check("Berta: nach Onboarding Heute-Ansicht",
|
||||
bg().hidden && winB.document.querySelectorAll(".act-card").length === 3);
|
||||
|
||||
// --- Szenario 4: API-Login-Faelle --------------------------------------
|
||||
check("API: Login mit falschem Passwort -> 401",
|
||||
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Trainingsplan - Neubau-Spezifikation
|
||||
|
||||
Ziel fuer den Agenten: einen selbstgehosteten Trainingsverlaufs-Tracker von NULL neu bauen.
|
||||
Nicht den alten Code reparieren. Das Konzept verstehen und besser umsetzen.
|
||||
Es gibt KEINen bestehenden Code gestern. Alles folgt dieser Beschreibung + Wahrnehmung bei Bedarf.
|
||||
|
||||
## Auftrag
|
||||
|
||||
Baue eine schlanke Web-App fuer den taeglichen Gang ins Fitnessstudio.
|
||||
Eine Person, ein Server, keine Cloud. Fokus: mit minimaler Reibung erfassen,
|
||||
was trainiert wurde, und ueber Wochen erkennen, ob man staerker wird.
|
||||
|
||||
Aktivitaeten: **Gym (Kraft)**, **Laufen**, **Radfahren**. Die App lebt auf dem Server
|
||||
(daten liegen serverseitig auf der Platte, nicht im Browser-Cache).
|
||||
|
||||
## Wichtigste Prinzipien (nicht verhandelbar)
|
||||
|
||||
1. **Konfiguration statt Code.** Der Trainingsplan (Uebungen, Saeetze, Reps, Gewichte,
|
||||
Maschinen, Erklaerungen) liegt in einer Config-Datei (`config.json`). Keine
|
||||
Uebungsliste hart im Code. Ein Nicht-Programmierer aendert den Plan in der Config.
|
||||
2. **Serverseitige Persistenz.** Daten in SQLite. Mehrere Sessions am Tag, editierbarer
|
||||
und loeschbarer Verlauf. Ueberlebt Neustart.
|
||||
3. **Zustand strikt isolieren.** Eine Aenderung in Uebung A fasst nie Uebung B an.
|
||||
Keine flachen Kopien, keine globalen Re-Builds. Jedes Set hat einen stabilen Identifier.
|
||||
4. **Kein Datenverlust.** Waehrend des Tippens keine Re-Renders, die Fokus/Cursor killen.
|
||||
Leer eingeben = leer, nicht 0. Eingaben werden erst gespeichert, wenn's Sinn ergibt.
|
||||
5. **Keine Geister-Eintraege.** Es entsteht kein Verlaufseintrag, solange keine Session
|
||||
wirklich gestartet oder explizit gespeichert wurde.
|
||||
6. **Sichtbare Einheiten.** Ueberall "kg", "km", "min" sichtbar, nie nackte Zahlen.
|
||||
7. **Sets modular.** Pro Uebung Anzahl und Gewicht der Saeetze konfigurierbar,
|
||||
auch einzelne Sets individuell (z.B. Set-Array mit Gewicht/Reps je Set).
|
||||
Hinzufuegen/Entfernen von Sets im UI moeglich.
|
||||
8. **Portabel + Docker.** Laeuft per `python3` (nur Standardbibliothek) und als Docker-
|
||||
Container. Daten-Verzeichnis per Environment-Variable (`TRAINING_STATE_DIR`).
|
||||
Bestehende Daten- und Repo-Config nicht zerstoeren.
|
||||
|
||||
## Funktionalitaet (vollstaendig)
|
||||
|
||||
### Heutige Ansicht
|
||||
- Zyklus Push/Pull/Legs (oder frei konfigurierbar in der Config).
|
||||
- Jede Uebung als Liste ihrer Saeetze.
|
||||
- Pro Satz: **Set-Nummer (klickbar als Abhak-Status), Gewicht, Reps** als Eingabe.
|
||||
- Eingabe als **eine zusammengehoerige Box**: Zahl links, Einheit ("kg"/"reps") als
|
||||
festes Suffix rechts IN derselben Box. Klick auf die ganze Box fokussiert den Input.
|
||||
- **Set-Kette**: Set 3 waehlen aktiviert 1..3 (keine Luecken). Deaktivieren faengt
|
||||
rueckwaerts. Klick auf Set darf Gewicht/Reps nie veraendern.
|
||||
- **Reset-Satz**: Knopf setzt das Set auf das Gewicht des letzten abgeschlossenen Tages
|
||||
(set-spezifisch, heutige in-Arbeit-Session zaehlt nicht).
|
||||
- **Fragezeichen-Icon** neben jedem Uebungsnamen: oeffnet Overlay mit Text-Erklaerung
|
||||
der Uebung + der passenden Maschine. (Text reicht, kein Bild.)
|
||||
- Optionale Notiz pro Session.
|
||||
|
||||
### Session-Lebenszyklus
|
||||
- "Session starten" / "Session speichern". Mehrere Sessions am Tag erlaubt.
|
||||
- Start- und Endzeit. Nach "abschliessen" erscheint die Session SOFORT im Verlauf
|
||||
(ohne Seiten-Reload).
|
||||
- Solange keine Session gestartet, passiert nichts im Verlauf (kein Geist).
|
||||
|
||||
### Verlauf
|
||||
- Abgeschlossene Sessions mit Datum + Zeit von-bis.
|
||||
- **Inline bearbeiten unter dem Verlauf**: Datum, Start/Ende aenderbar; Workout-Eintraege
|
||||
(Gewicht/Reps bzw. Cardio-Felder) direkt aenderbar; Session loeschbar.
|
||||
- Bearbeiten-Tab bleibt im Verlauf, blutet nicht in die Heutige-Ansicht.
|
||||
|
||||
### Statistik
|
||||
- Pro Uebung die Steigerung ueber die Zeit (bestes Gewicht je Termin,
|
||||
Differenz zur ersten Messung).
|
||||
|
||||
### Export / Import
|
||||
- Kompletter Stand als JSON und als CSV raus und rein (fuer Back-ups und
|
||||
Tabellen-Auswertung). Format klar allabelt.
|
||||
|
||||
### Cardio (Laufen / Radfahren)
|
||||
- Feld-Templates aus der Config (Distanz, Dauer, Pace, Puls, Hoehenmeter).
|
||||
- Einheiten aus der Config (km, min, min/km, bpm, m).
|
||||
|
||||
## Maschinen / Trainingspraeferenz
|
||||
|
||||
- Trainiert mit Freihanteln UND Maschinen, tendenziell eher Maschine (Technogym).
|
||||
- **Bizeps bevorzugt am Kabelturm.**
|
||||
- Der Plan soll die fehlenden Standard-Maschinen abdecken (Technogym Selection-Linie
|
||||
als Referenz: Chest Press, Pectoral, Shoulder Press, Lat Machine, Low Row, Reverse
|
||||
Fly, Leg Press, Leg Curl, Leg Extension, Calf, Arm Curl/Preacher, Glute/Hip Thrust
|
||||
u.a.) inkl. der Preacher-(Arm Cavlier)-Maschine.
|
||||
- Das sind Richtwerte, kein Muss: Welche Maschinen im Studio wirklich stehen,
|
||||
kann beim Training angepasst werden - Config, nicht Code.
|
||||
|
||||
## Design
|
||||
|
||||
We-geilektung, KEIN Detail-Diktat: Nutzbarkeit hat Vorrang. Eine Regel, die die
|
||||
Software unbrauchbar macht, wird gebrochen und begruendet.
|
||||
|
||||
- Harte Kanten: 0px Radius, klare 2px-Border, dunkler Grund.
|
||||
- Flache Farben, ein Akzent, keine Verlaeufe, kein Glow, kein Glassmorphism.
|
||||
- Kein Inter/Roboto-Systemstack als einzige Schrift unreflektiert.
|
||||
- Bewusste Typo/Hierarchie ueber Abstand und Gewicht statt "alles gleich".
|
||||
- Zustaende (Hover/Mouse/Focus/Active) mitdenken.
|
||||
- Keine Emojis als Icons, keine rohen Unicode-Pfeile in Labels, keine Em-Dashes als
|
||||
Stilmittel.
|
||||
- Kompakt, kein "zentrierter Hero + 3 Karten"-SaaS-Skelett.
|
||||
- **WICHTIG**: nicht ueberstilisiert designen. Es fehlt Feinarbeit - simpel, klar,
|
||||
benutzbar. Design gibt die Richtung, macht die App nicht zerbrechlich.
|
||||
|
||||
## Technischer Rahmen (Empfehlung, nicht zwingend)
|
||||
|
||||
- Frontend: eine HTML-Datei Struktur, eine CSS-Datei, eine JS-Datei (kein Framework).
|
||||
- Backend: `python3` mit Standardbibliothek (`http.server`) als JSON-API + statische
|
||||
Dateien. SQLite fuer die Daten. Kein Inline-Script/Style in HTML - Klassen + CSS.
|
||||
- Dockerfile + docker-compose (CasaOS-tauglich: Daten-Verzeichnis als echter Ordner).
|
||||
|
||||
## Abgeben
|
||||
|
||||
Code + kurze README (was es kann, wie starten, wie den Plan in der Config aendern).
|
||||
Getestet und lauffaehig, nicht nur beschrieben.
|
||||
Reference in new issue
Block a user