Compare commits

...
15 Commits
Author SHA1 Message Date
AI-Agent e9265ca2ab Aufgabenbeschreibung aus dem oeffentlichen Repo entfernen (bleibt lokal)
Image bauen und in die Registry schieben / build (push) Successful in 5s
Die Rebuild-Spec ist der interne Auftrag/Prompt und gehoert nicht ins
public Repo. Datei bleibt im Arbeitsverzeichnis, per .gitignore ausgeschlossen.
2026-10-05 14:30:50 +00:00
AI-Agent b2b4a2e770 tools/ aus dem oeffentlichen Repo entfernen (bleibt lokal, gitignored)
Image bauen und in die Registry schieben / build (push) Successful in 6s
Interne Dev-Tools (Screenshot-Skript, Demo-Generator) gehoeren nicht in
das public Repo. Dateien bleiben im Arbeitsverzeichnis, sind jetzt aber
per .gitignore ausgeschlossen.
2026-10-05 14:28:19 +00:00
AI-Agent 314f4f329e tools/: Screenshot-Skript (headless Chromium) + Demo-Generator dauerhaft ablegen
Image bauen und in die Registry schieben / build (push) Successful in 6s
- tools/screenshot.sh: konsolidiert (--view today|history, --size, --out,
  --full); startet eigenen Server mit temporaerem Zustand, befuellt eine
  Session bzw. importiert Demo-Daten, schießt den Screenshot, raeumt auf
- tools/gen_demo.py: Demo-Daten-Generator (Pfade relativ zum Repo)
- tools/README.md: Nutzung
2026-10-05 14:23:46 +00:00
AI-Agent c7647ba962 Verlauf aufgeraeumt + vollstaendiger README-Screenshot
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Verlauf zeigt nur abgehakte Saetze (nicht gemachte werden nicht mehr
  uebernommen); keine ✓/□-Boxen mehr, Uebungsname ohne Tages-Praefix,
  Saetze kompakt in einer Zeile -> deutlich weniger Text
- assets/screenshot.png neu: vollstaendige Session (alle Uebungen mit
  Werten) statt halb-leerer Ansicht
- Tests: 170 Checks gruen (40+18+52+32+28)
2026-10-05 12:21:14 +00:00
AI-Agent 6290242adf README: Feature-Liste einfach und menschlich, Screenshot, kein Technik-Jargon
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Technische/Prompt-nahe Formulierungen raus ('eine Box', 'Set n [ ]',
  'Kette', 'inline', 'serverseitig', 'herstellerunabhängig')
- Feature-Liste knapp und verstaendlich gegliedert
- Screenshot der App (assets/screenshot.png) eingebunden
2026-10-05 11:58:25 +00:00
AI-Agent aebd7f40e7 Drag-Griff statt ganzem Element + Uebungs-Reihenfolge beim Start uebernehmen
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Sortieren nur ueber einen kleinen Griff (Punkt-Icon) links im
  Uebungskopf; das ganze Element ist nicht mehr draggable (kein
  versehentliches Ziehen beim Tippen/Auswaehlen)
- Beim Session-Start wird die Uebungs-Reihenfolge der letzten
  abgeschlossenen Session desselben Tags uebernommen (server-seitig in
  create_session, in session.fields.exerciseOrder)
- Tests: 170 Checks gruen (40+18+52+32+28)
2026-10-05 11:31:07 +00:00
AI-Agent 7c5045cec8 Layout-Fix Satz-Zeile (mobil), README knapp/oeffentlich, Roadmap aktualisiert
Image bauen und in die Registry schieben / build (push) Successful in 6s
- ex-grid: minmax(min(460px,100%),1fr) -> keine 460px-Mindestspalte auf dem
  Handy mehr; Block und X-Button nicht mehr abgeschnitten
- Satz-Zeile: Aktionen (Reset/X) rechtsbuendig per flexiblem Spacer; auf
  sehr schmalen Handys kompaktere Felder (72px)
- README: gekuerzt, bodenstaendiger Ton, interne CI/CD-/Infra-Notizen raus
- ROADMAP: Umgesetztes markiert, veraltete Punkte entfernt, gestrafft
- Tests: 169 Checks gruen (39+18+52+32+28)
2026-10-05 11:24:51 +00:00
AI-Agent 1759da8437 DESIGN.md: Design-Sprache normiert (Tokens, Komponenten, Zustaende, Anti-Patterns)
Image bauen und in die Registry schieben / build (push) Successful in 16s
- Philosophie (Nutzbarkeit vor Stil), Leitprinzipien, vollstaendige
  Farb-/Theme-/Akzent-Tokens mit Werten, Typografie, Form & Abstand
- Komponenten-Regeln (Buttons, Field-Box, Set-Zeile, Karten, Tabellen,
  Modals/Wizard, Toasts), Zustands-Matrix, i18n, A11y & Responsive
- Verbotene Anti-Patterns + Abschluss-Checkliste; README verlinkt darauf
2026-10-04 17:06:39 +00:00
AI-Agent 3806dbc717 Eingabefelder normale Breite + Uebungs-Infos herstellerneutral
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Satz-Zeile: kg/reps-Felder mit fester, normaler Breite (84px) statt
  jeweils halber Zeile; nur Check/Buttons passen sich an (Grid
  auto auto auto auto auto)
- config.json: Technogym-Referenzen (Selection 900/700 etc.) aus allen
  Uebungs-Infotexten entfernt - generische Maschinenbeschreibungen
  (Chest-Press-, Latzug-, Beinpresse-Maschine ...)
- demo-data.json neu generiert (Config-Snapshot synchron)
- Tests: 169 Checks gruen (39+18+52+32+28)
2026-10-02 20:41:18 +00:00
AI-Agent f93d88abd3 Fix: Reset-SVG per appendChild, Satz-Zeile als kompaktes 5-Spalten-Grid
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Reset-Icon korrekt eingefuegt (war '[object HTMLSpanElement]' weil das
  SVG-Element als Text-Argument an el() ging)
- Satz-Zeile: display:grid mit auto 1fr 1fr auto auto, kg/reps-Inputs
  flexibel (1fr, min-width 0), Buttons als kleine Icons (26px) - passt
  komplett auf ein Handy-Display ohne horizontales Scrollen
- Label zurueck auf 'Set n' (klarer als 'S1')
- Tests: 169 Checks gruen (39+18+52+32+28)
2026-10-02 20:33:03 +00:00
AI-Agent 6d57200032 Wizard-Texte, Admin-Onboarding ueberspringen, kompakte Set-Zeile mobil
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Wizard: Sprach-Hinweis gekuerzt, Repo-Link nur beim Erst-Setup (nicht
  im Onboarding), Hinweis 'erster Benutzer wird Administrator'
- Erster Benutzer (Admin) ist automatisch onboarded und erbt die
  festgelegten Sprache/Design-Einstellungen (kein erneuter Wizard);
  Einstellungen werden nach Admin-Anlage sofort frisch gerendert
- Set-Zeile mobil-tauglich: nowrap, width 100%, kg/reps-Inputs flexibel
  (flex 1, min-width 0), Einheit kompakt im Feld, 'S1' statt 'Set 1',
  Reset als SVG-Icon, kleinere Buttons - kein horizontales Scrollen
- Tests: 169 Checks gruen (39+18+52+32+28)
2026-10-02 20:26:09 +00:00
AI-Agent 135425c781 Wizard: User erst beim Abschliessen, Reihenfolge Sprache/Design/Modus, Reset im Einzelmodus
Image bauen und in die Registry schieben / build (push) Successful in 6s
- Benutzer wird erst beim 'Einrichtung abschliessen' angelegt (POST
  /api/register im wizardFinish); bis dahin frei umentscheidbar, im Modus-
  Schritt Hinweis + Live-Validierung (Finish deaktiviert bis ausgefuellt)
- Reihenfolge: Sprache -> Darstellung (Theme/OLED/Akzent) -> Modus
  (Einzel/Mehrbenutzer); Onboarding bleibt Sprache -> Darstellung
- Sprach-Schritt: Repo-Link + Kurztext zum Starten (nutzt den Raum)
- Wizard-Modal: min-height statt fixer Hoehe -> Modus-Schritt mit Formular
  wird nicht mehr abgeschnitten
- Server-Reset auch im Einzelmodus moeglich (nur Bestaetigungstext,
  kein Passwort noetig); Reset-Sektion erscheint dann entsprechend
- Tests: 167 Checks gruen (39+18+52+32+26)
2026-10-02 19:57:59 +00:00
AI-Agent 655316278a Wizard-Hoehe fix, Logout in Konto, Server-Reset, responsive Tabs, Set-Zeilen, Brand-Date
Image bauen und in die Registry schieben / build (push) Successful in 7s
- Wizard: feste Hoehe (600px, intern scrollbar), Schritt-Inhalt zentriert
  (.wizard-body) -> Schritte gleich gross, auch beim User anlegen
- Benutzer-Anlage: Bestaetigung 'Benutzer x angelegt.' (ohne 'Weiter mit'),
  Solo-Option nach User-Anlage deaktiviert (kein irrefuehrendes Umschalten)
- User-Chip + Abmelden aus der Topbar entfernt, 'Abmelden' in Konto-Sektion
- Topbar-Datum: 'Fr 02.10.2026' + aktive Session ('Push aktiv') statt
  redundantem 'Today'
- Tabs: flex-wrap statt Scrollen, auf Mobil volle Breite + kompakter
- Server-Reset (Owner): Passwort + 'RESET'-Eingabe, loescht alle Daten
- Benutzerliste: Querbalken vor dem Anlegen-Formular
- Tests: 163 Checks gruen (39+18+49+32+25)
2026-10-02 19:42:46 +00:00
AI-Agent 0ebaa8b132 UI-Fixes: Wizard konsistent, Tabs uebersetzt/responsive, Set-Zeilen, leere Sets
Image bauen und in die Registry schieben / build (push) Successful in 8s
- Setup-Wizard: feste Mindesthoehe (#setup-gate), Navigation unten
  verankert -> Hoehe springt nicht mehr zwischen den Schritten
- Wizard Benutzer-Anlage: nach Erfolg Bestaetigung statt leerem Formular
  (kein erneutes Eingeben), 'Weiter' aktiv
- Tabs: Labels werden uebersetzt (t()-i18n), Topbar/Tabs horizontal
  scrollbar + kompakter auf Mobil -> 'Einstellungen' wird nicht mehr
  abgeschnitten
- Button-Min-Breite (.btn 96px, .btn-small 64px) fuer Sprach-Konsistenz
- Fortschrittssegmente: bei weissem Akzent auf hellem Theme schwarz
  (statt unsichtbar weiss)
- Set-Zeile: eine horizontale Reihe (nowrap) mit einheitlicher
  Eingabe-Mindestbreite/-hoehe; Grid-Spalten auf 460px angepasst
- Leere Saetze (ohne Gewicht UND Reps) werden nicht mehr persistiert und
  im Verlauf ausgeblendet (kein 'empty kg x empty reps'-Wand)
- Tests: 158 Checks gruen (39+18+43+32+26)
2026-10-02 19:16:52 +00:00
AI-Agent e91cd30479 Realistische Demo-Daten (12 Wochen, 49 Sessions) + Roadmap + README-Verweis
Image bauen und in die Registry schieben / build (push) Successful in 8s
- demo-data.json neu generiert: 12 Wochen Push/Pull/Legs mit Plateau-
  Phasen, Deload-Woche, ausgelassenen Trainingstagen, Reps-Streuung,
  nicht abgehakten Saetzen und Notizen; dazu 12 Laeufe und 5 Fahrten
- ROADMAP.md: interne Roadmap in 4 Phasen (Inhalt in der App bearbeiten,
  herstellerunabhaengige Info-Texte mit Bildern/Schritten, Plugin-System
  mit Ollama/Cloud-Coach, Wearable-Import, CLI-Harness, Volumen/1RM,
  PWA, a11y)
- README: Roadmap-Sektion
- test_auth: erwartete Demo-Zaehler dynamisch aus der Datei berechnet
- Tests: 158 Checks gruen (39+18+43+32+26)
2026-10-02 02:29:17 +00:00
16 changed files with 5062 additions and 1846 deletions

No files matched your search

+5
View File
@@ -15,3 +15,8 @@ server.log
# Python
__pycache__/
*.pyc
# interne Entwicklungstools, nicht oeffentlich
/tools/
# interne Aufgaben-/Prompt-Datei, nicht oeffentlich
/trainingsplan-rebuild-spec.md
+223
View File
@@ -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?
+43 -85
View File
@@ -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.
![Ansicht der App](assets/screenshot.png)
## 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
View File
@@ -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. |
+218 -117
View File
@@ -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.step === 1) return !!wizard.theme && !!wizard.accent;
// Schritt 2 (nur Setup): Modus
if (wizard.multi === null) return false;
return wizard.multi === false || wizard.userCreated;
}
return !!wizard.theme && !!wizard.accent;
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,8 +1807,49 @@ 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");
@@ -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 (_) {}
function logout() {
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");
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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -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>
+73 -3
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
-114
View File
@@ -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.