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

|
||||||
Zustand in einer portablen SQLite-Datei. Export jederzeit als JSON oder
|
|
||||||
CSV, Import genauso. Zieh um oder hör auf - deine Daten gehören dir.
|
|
||||||
- **Leichtgewichtig.** Python-Standardbibliothek + SQLite im Backend, ein
|
|
||||||
HTML-, ein CSS-, ein JS-File im Frontend. Kein Framework, kein
|
|
||||||
Build-Schritt, keine Node-Installation. Läuft auf einem Mini-PC, einem
|
|
||||||
alten Laptop oder in einem Container.
|
|
||||||
- **Browserbasiert.** Keine App, kein Store, keine Installation. Aufschlagen,
|
|
||||||
loslegen - auf jedem Gerät mit Browser.
|
|
||||||
- **Cross-Device Sync.** Der Zustand liegt serverseitig. Öffne die App am
|
|
||||||
Handy, am Laptop oder am Desktop-Rechner - es ist derselbe Stand. Mehrere
|
|
||||||
Benutzer mit eigenen Konten und getrennten Daten sind eingebaut.
|
|
||||||
- **UX zuerst.** Sätze eintragen mit einer Box (Zahl + Einheit in einem
|
|
||||||
Feld), Set-Kette durch Abhaken, "Alles zurücksetzen", Verlauf inline
|
|
||||||
bearbeiten, Wochenübersicht, Statistik mit Differenz zur ersten Messung.
|
|
||||||
Der Tracker reibt sich nicht in den Weg zwischen dich und die Übung.
|
|
||||||
- **Dein Take aufs Aufzeichnen.** Die App zeichnet auf - Gewichte, Reps,
|
|
||||||
Cardio-Felder - und zeigt die Entwicklung über die Zeit. Sie verkauft
|
|
||||||
nichts, personalisiert nichts und schickt nichts nach Hause.
|
|
||||||
- **Open Source.** AGPL-3.0. Nachvollziehbar, erweiterbar, auditierbar.
|
|
||||||
|
|
||||||
## Features im Überblick
|
**Training**
|
||||||
|
|
||||||
**Training erfassen**
|
- Gym mit Push/Pull/Legs (anpassbar), dazu Laufen und Radfahren
|
||||||
|
- Gewicht und Wiederholungen eintragen, Sätze abhaken, auf den letzten Stand
|
||||||
|
zurücksetzen
|
||||||
|
- Reihenfolge der Übungen selbst festlegen, spontan eine Übung ergänzen
|
||||||
|
- Zu jeder Übung eine kurze Erklärung
|
||||||
|
|
||||||
- Gym mit Push/Pull/Legs (frei konfigurierbar) plus Laufen und Radfahren
|
**Verlauf & Statistik**
|
||||||
- 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
|
|
||||||
|
|
||||||
**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
|
**Einstellungen**
|
||||||
- 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
|
|
||||||
|
|
||||||
**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)
|
**Mehrere 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
|
|
||||||
|
|
||||||
**Mehrbenutzer**
|
- Eigene Konten mit Passwort, getrennte Daten
|
||||||
|
- Der erste Benutzer ist Administrator
|
||||||
- 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)
|
|
||||||
|
|
||||||
**Daten**
|
**Daten**
|
||||||
|
|
||||||
- Export/Import als JSON und CSV (Backup, Umzug, Tabellen-Auswertung)
|
- Backup als JSON oder CSV, jederzeit wieder einspielbar
|
||||||
- Workouts über eine kleine API abrufbar (`/api/workouts`)
|
- Beispiel-Verlauf zum Ausprobieren
|
||||||
- Demo-Daten (`demo-data.json`) zum direkten Ausprobieren
|
|
||||||
|
|
||||||
## Schnellstart
|
## Starten
|
||||||
|
|
||||||
Python (nur Standardbibliothek, kein `pip install`):
|
Python (nur Standardbibliothek):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 server.py 8080
|
python3 server.py 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Oder mit Docker:
|
Oder Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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
|
## Plan anpassen
|
||||||
Mehrbenutzermodus und Darstellung - danach ist die App einsatzbereit.
|
|
||||||
|
|
||||||
## 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
|
## Daten
|
||||||
`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
|
|
||||||
|
|
||||||
- Alles liegt in `data/state.db` (SQLite) - backupbar, überlebt Neustart und
|
- Alles liegt in `data/state.db` (SQLite) - backupbar, überlebt Neustart und
|
||||||
Container-Rebuild
|
Container-Rebuild
|
||||||
- Datenverzeichnis frei wählbar (`TRAINING_STATE_DIR`)
|
- Datenverzeichnis per `TRAINING_STATE_DIR` frei wählbar
|
||||||
- Keine externen Dienste, keine Telemetrie, keine Analyse deiner Daten
|
- 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
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
# REKORD - Roadmap
|
||||||
|
|
||||||
|
Was geplant ist, und was schon steht. Priorität: P1 = als Nächstes, P2 = bald,
|
||||||
|
P3 = später. Status: Fertig / Teilweise / Geplant.
|
||||||
|
|
||||||
|
Leitplanken bleiben: config-getrieben, leichtgewichtig, Daten lokal, keine
|
||||||
|
Telemetrie.
|
||||||
|
|
||||||
|
## Umgesetzt
|
||||||
|
|
||||||
|
- Config-getriebener Plan (`config.json`), SQLite-Zustand serverseitig
|
||||||
|
- Gym (Push/Pull/Legs), Laufen, Radfahren; Cardio-Felder aus der Config
|
||||||
|
- Set-Kette, Reset-Satz, "Alles zurücksetzen", Vorbefüllung (letztes Workout
|
||||||
|
oder Plan-Gewichte)
|
||||||
|
- Übungen per Drag & Drop sortieren, Übungen aus anderen Tagen hinzufügen
|
||||||
|
- Verlauf als Liste und Wochenübersicht, Inline-Bearbeitung, Löschen
|
||||||
|
- Statistik: bestes Gewicht je Tag, Differenz zur ersten Messung
|
||||||
|
- Mehrbenutzer mit Admin, Kennwort ändern/zurücksetzen, Server-Reset
|
||||||
|
- Einrichtungs-Wizard (Sprache, Darstellung, Modus)
|
||||||
|
- i18n de/en, Themes dunkel/system/hell + OLED, zehn Akzentfarben
|
||||||
|
- Export/Import JSON/CSV, Workouts-API, Demo-Daten
|
||||||
|
- Übungs-Infos herstellerunabhängig; mobile-taugliche Satz-Zeile
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
### Inhalte & Bedienbarkeit
|
||||||
|
|
||||||
|
| Prio | Thema | Status | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | Übungen/Maschinen in der App bearbeiten | Geplant | Statt `config.json` von Hand: Übungen, Tage und Info-Texte direkt in der App pflegen. |
|
||||||
|
| P1 | Info-Texte erweitern | Geplant | Optional Bilder, Schritte als Checkliste, ein Link (neutral, nicht herstellerspezifisch). |
|
||||||
|
| P1 | Übungsvarianten & Maschinen-Tags | Geplant | Eine Übung mit Varianten (KH, Maschine, Kabel) und schnellem Wechsel im Training. |
|
||||||
|
| P2 | Set-Vorlagen pro Übung | Teilweise | Plan-Gewichte vorbefüllen gibt es; noch nicht pro Satz steuerbar (Ziel-Reps, Pause). |
|
||||||
|
| P2 | Einheiten pro Übung | Geplant | Bodyweight, Band, Maschinen-Gewicht - neben dem globalen kg/lb. |
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
| Prio | Thema | Status | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | Plugin-System | Geplant | Kleine Plugins mit Hooks (nach Session-Speichern, beim Export, beim Anzeigen). |
|
||||||
|
| P1 | Ollama/Cloud-Coach | Geplant | Form-Tipps/Zusammenfassungen per API - local-first (Ollama), Cloud nur auf Wunsch. |
|
||||||
|
| P2 | Webhooks bei Events | Geplant | Nach "Session abgeschlossen" o.ä. für eigene Automation. |
|
||||||
|
| P2 | API-Keys pro Benutzer | Geplant | Read/write Keys für Scripts, ohne das Konto freizugeben. |
|
||||||
|
| P2 | CLI-Harness | Geplant | Kleines Kommandozeilen-Interface gegen die API (Cron, TUI). |
|
||||||
|
| P3 | Wearable-Import | Geplant | Garmin/Apple/Fitbit/Samsung-Exporte (CSV/JSON) als Plugin. |
|
||||||
|
|
||||||
|
### Analyse & UX
|
||||||
|
|
||||||
|
| Prio | Thema | Status | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | Volumen & 1RM-Schätzung | Geplant | kg × Reps je Übung/Tag, 1RM-Schätzung als zusätzliche Kennzahl. |
|
||||||
|
| P2 | Wochenvergleich & Trends | Geplant | Gleitende Durchschnitte, PR-Markierungen, Verlauf ohne Chart-Lib. |
|
||||||
|
| P2 | PWA / Offline | Geplant | Installierbar auf dem Handy (Service Worker, Icon). |
|
||||||
|
| P2 | Mehrere Sessions am Tag | Teilweise | Funktioniert; Aktivitäts-/Tag-Wechsel im UI vereinfachen. |
|
||||||
|
|
||||||
|
### Qualität
|
||||||
|
|
||||||
|
| Prio | Thema | Status | Notiz |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P2 | Barrierefreiheit | Teilweise | Fokus, Kontraste, Labels gepflegt; systematischer Test offen. |
|
||||||
|
| P2 | Backup/Erinnerung | Geplant | Export gibt es; automatische Backup-Erinnerung offen. |
|
||||||
|
| P3 | Weitere Sprachen | Geplant | i18n-Architektur (de/en) steht; weitere Sprachen als Beitrag. |
|
||||||
@@ -114,6 +114,11 @@ const LANG = {
|
|||||||
setting_saved: "Einstellung gespeichert.",
|
setting_saved: "Einstellung gespeichert.",
|
||||||
account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:",
|
account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:",
|
||||||
change_pw: "Kennwort ändern", current_pw: "Aktuelles Kennwort",
|
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.",
|
new_pw: "Neues Kennwort", pw_changed: "Kennwort geändert.",
|
||||||
users: "Benutzer", setup_users: "Benutzer einrichten",
|
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.",
|
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",
|
reorder_hint: "Übungen per Ziehen sortieren",
|
||||||
prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom",
|
prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom",
|
||||||
wizard_done: "Alles erledigt - die App ist bereit.",
|
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.",
|
logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.",
|
||||||
app_load_fail: "App konnte nicht geladen werden: {m}",
|
app_load_fail: "App konnte nicht geladen werden: {m}",
|
||||||
},
|
},
|
||||||
@@ -223,6 +232,11 @@ const LANG = {
|
|||||||
setting_saved: "Setting saved.",
|
setting_saved: "Setting saved.",
|
||||||
account: "Account", account_desc: "Logged in as '{u}'. Change password:",
|
account: "Account", account_desc: "Logged in as '{u}'. Change password:",
|
||||||
change_pw: "Change password", current_pw: "Current 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.",
|
new_pw: "New password", pw_changed: "Password changed.",
|
||||||
users: "Users", setup_users: "Set up users",
|
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.",
|
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",
|
reorder_hint: "Drag to reorder exercises",
|
||||||
prev_week: "Previous week", next_week: "Next week", week_of: "Week of",
|
prev_week: "Previous week", next_week: "Next week", week_of: "Week of",
|
||||||
wizard_done: "All done - the app is ready.",
|
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.",
|
logged_in: "Logged in.", user_created_toast: "User created.",
|
||||||
app_load_fail: "Could not load the app: {m}",
|
app_load_fail: "Could not load the app: {m}",
|
||||||
},
|
},
|
||||||
@@ -764,17 +782,16 @@ function reorderExercise(a, model, fromKey, toKey) {
|
|||||||
scheduleAutosave(model);
|
scheduleAutosave(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachDragReorder(a, model, ex, block) {
|
function attachDragReorder(a, model, ex, block, grip) {
|
||||||
block.draggable = true;
|
grip.draggable = true;
|
||||||
block.title = t("reorder_hint");
|
grip.addEventListener("dragstart", ev => {
|
||||||
block.addEventListener("dragstart", ev => {
|
|
||||||
app.dragKey = ex.key;
|
app.dragKey = ex.key;
|
||||||
try {
|
try {
|
||||||
ev.dataTransfer = ev.dataTransfer || {};
|
ev.dataTransfer = ev.dataTransfer || {};
|
||||||
ev.dataTransfer.setData("text/plain", ex.key);
|
ev.dataTransfer.setData("text/plain", ex.key);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
block.addEventListener("dragend", () => {
|
grip.addEventListener("dragend", () => {
|
||||||
app.dragKey = null;
|
app.dragKey = null;
|
||||||
for (const b of $$(".ex-block.drag-over")) b.classList.remove("drag-over");
|
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;
|
model.exByKey[ex.key] = ex;
|
||||||
|
|
||||||
const head = el("div", "ex-head");
|
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", "?");
|
const infoBtn = el("button", "icon-btn", "?");
|
||||||
infoBtn.type = "button";
|
infoBtn.type = "button";
|
||||||
infoBtn.setAttribute("aria-label", t("info_aria") + ex.name);
|
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"));
|
block.appendChild(el("div", "set-rows"));
|
||||||
renderExRows(a, model, ex);
|
renderExRows(a, model, ex);
|
||||||
attachDragReorder(a, model, ex, block);
|
attachDragReorder(a, model, ex, block, grip);
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,10 +938,12 @@ function buildSetRow(a, model, ex, entry, i) {
|
|||||||
row.appendChild(rBox);
|
row.appendChild(rBox);
|
||||||
entry.wInput = wBox.querySelector("input");
|
entry.wInput = wBox.querySelector("input");
|
||||||
entry.rInput = rBox.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.type = "button";
|
||||||
reset.setAttribute("aria-label", t("reset_set_title"));
|
reset.setAttribute("aria-label", t("reset_set_title"));
|
||||||
|
reset.appendChild(resetSvg());
|
||||||
reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry));
|
reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry));
|
||||||
row.appendChild(reset);
|
row.appendChild(reset);
|
||||||
|
|
||||||
@@ -929,6 +956,15 @@ function buildSetRow(a, model, ex, entry, i) {
|
|||||||
return row;
|
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) {
|
function makeFieldBox(label, unit, value, onChange, inputmode) {
|
||||||
const box = el("label", "field-box");
|
const box = el("label", "field-box");
|
||||||
box.setAttribute("aria-label", label + " in " + unit);
|
box.setAttribute("aria-label", label + " in " + unit);
|
||||||
@@ -1033,7 +1069,7 @@ async function flushAutosave(activity) {
|
|||||||
const sets = (model.exercises[exKey] || []).map((e, i) => ({
|
const sets = (model.exercises[exKey] || []).map((e, i) => ({
|
||||||
exercise: exKey, sort: i, weight: e.weight || "", reps: e.reps || "",
|
exercise: exKey, sort: i, weight: e.weight || "", reps: e.reps || "",
|
||||||
done: e.done ? 1 : 0,
|
done: e.done ? 1 : 0,
|
||||||
}));
|
})).filter(s => s.weight !== "" || s.reps !== "");
|
||||||
try {
|
try {
|
||||||
await api("/api/sessions/" + model.id, jsonOpts("PATCH", { sets }));
|
await api("/api/sessions/" + model.id, jsonOpts("PATCH", { sets }));
|
||||||
model.dirtyEx.delete(exKey);
|
model.dirtyEx.delete(exKey);
|
||||||
@@ -1261,23 +1297,17 @@ function renderSetSummary(s, a) {
|
|||||||
const byEx = {};
|
const byEx = {};
|
||||||
for (const e of s.entries || []) (byEx[e.exercise] = byEx[e.exercise] || []).push(e);
|
for (const e of s.entries || []) (byEx[e.exercise] = byEx[e.exercise] || []).push(e);
|
||||||
for (const exKey of Object.keys(byEx)) {
|
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");
|
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 setsWrap = el("span", "hist-sets");
|
||||||
const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort);
|
setsWrap.textContent = sorted.map(e =>
|
||||||
sorted.forEach((e, idx) => {
|
(e.weight || t("empty_label")) + " " + unit + " × "
|
||||||
const w = e.weight || t("empty_label");
|
+ (e.reps || t("empty_label")) + " " + repsUnit).join(" · ");
|
||||||
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(" · "));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
li.appendChild(setsWrap);
|
li.appendChild(setsWrap);
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
}
|
}
|
||||||
@@ -1635,17 +1665,19 @@ function switchView(name) {
|
|||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
const wizard = { mode: "setup", step: 0, multi: null, lang: null,
|
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) {
|
function renderSetupWizard(mode) {
|
||||||
wizard.mode = mode;
|
wizard.mode = mode;
|
||||||
wizard.step = 0; // Sprache zuerst
|
wizard.step = 0; // Reihenfolge: Sprache -> Darstellung -> Modus
|
||||||
wizard.multi = null;
|
wizard.multi = null;
|
||||||
wizard.lang = (app.settings && app.settings.language) || "de";
|
wizard.lang = (app.settings && app.settings.language) || "de";
|
||||||
wizard.theme = (app.settings && app.settings.theme) || "dark";
|
wizard.theme = (app.settings && app.settings.theme) || "dark";
|
||||||
wizard.oled = !!(app.settings && app.settings.oled);
|
wizard.oled = !!(app.settings && app.settings.oled);
|
||||||
wizard.accent = (app.settings && app.settings.accent) || "white";
|
wizard.accent = (app.settings && app.settings.accent) || "white";
|
||||||
wizard.userCreated = false;
|
wizard.regUser = null;
|
||||||
|
wizard.regPass = null;
|
||||||
document.body.classList.add("no-auth");
|
document.body.classList.add("no-auth");
|
||||||
$("#setup-gate").hidden = false;
|
$("#setup-gate").hidden = false;
|
||||||
renderWizardStep();
|
renderWizardStep();
|
||||||
@@ -1684,11 +1716,11 @@ function previewWizardAppearance() {
|
|||||||
|
|
||||||
function wizardStepValid() {
|
function wizardStepValid() {
|
||||||
if (wizard.step === 0) return !!wizard.lang;
|
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;
|
if (wizard.multi === null) return false;
|
||||||
return wizard.multi === false || wizard.userCreated;
|
if (wizard.multi === false) return true;
|
||||||
}
|
return !!(wizard.regUser && wizard.regPass);
|
||||||
return !!wizard.theme && !!wizard.accent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wizardMaxStep() {
|
function wizardMaxStep() {
|
||||||
@@ -1714,12 +1746,13 @@ function renderWizardStep() {
|
|||||||
head.appendChild(el("h2", "auth-title", BRAND));
|
head.appendChild(el("h2", "auth-title", BRAND));
|
||||||
head.appendChild(wizardProgress());
|
head.appendChild(wizardProgress());
|
||||||
box.appendChild(head);
|
box.appendChild(head);
|
||||||
|
const body = el("div", "wizard-body");
|
||||||
|
|
||||||
if (wizard.step === 0) {
|
if (wizard.step === 0) {
|
||||||
// Schritt 0: Sprache (zuerst)
|
// Schritt 0: Sprache
|
||||||
box.appendChild(el("h3", "wizard-title", t("lang_step")));
|
body.appendChild(el("h3", "wizard-title", t("lang_step")));
|
||||||
if (wizard.mode === "setup") {
|
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");
|
const row = el("div", "day-picker");
|
||||||
for (const [code, label] of [["de", t("german")], ["en", t("english")]]) {
|
for (const [code, label] of [["de", t("german")], ["en", t("english")]]) {
|
||||||
@@ -1732,45 +1765,20 @@ function renderWizardStep() {
|
|||||||
});
|
});
|
||||||
row.appendChild(b);
|
row.appendChild(b);
|
||||||
}
|
}
|
||||||
box.appendChild(row);
|
body.appendChild(row);
|
||||||
} else if (wizard.step === 1 && wizard.mode === "setup") {
|
if (wizard.mode === "setup") {
|
||||||
// Schritt 1: Einzel- oder Mehrbenutzermodus
|
body.appendChild(el("p", "auth-sub", t("wizard_lang_hint")));
|
||||||
box.appendChild(el("p", "auth-sub", t("welcome")));
|
const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan");
|
||||||
const grid = el("div", "setup-options");
|
repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan";
|
||||||
const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true);
|
repo.target = "_blank";
|
||||||
multi.addEventListener("click", () => {
|
repo.rel = "noopener";
|
||||||
wizard.multi = true;
|
body.appendChild(repo);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (wizard.step === 1) {
|
||||||
// Letzter Schritt: Darstellung (Theme + OLED + Akzent)
|
// Schritt 1: Darstellung (Theme + OLED + Akzent)
|
||||||
box.appendChild(el("h3", "wizard-title", t("appearance")));
|
body.appendChild(el("h3", "wizard-title", t("appearance")));
|
||||||
box.appendChild(el("p", "auth-sub", t("appearance_desc")));
|
body.appendChild(el("p", "auth-sub", t("appearance_desc")));
|
||||||
box.appendChild(el("div", "picker-label", t("theme_label")));
|
body.appendChild(el("div", "picker-label", t("theme_label")));
|
||||||
const themeRow = el("div", "day-picker");
|
const themeRow = el("div", "day-picker");
|
||||||
for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) {
|
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);
|
const b = el("button", "day-btn" + (wizard.theme === key ? " is-active" : ""), label);
|
||||||
@@ -1782,10 +1790,10 @@ function renderWizardStep() {
|
|||||||
});
|
});
|
||||||
themeRow.appendChild(b);
|
themeRow.appendChild(b);
|
||||||
}
|
}
|
||||||
box.appendChild(themeRow);
|
body.appendChild(themeRow);
|
||||||
box.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled,
|
body.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled,
|
||||||
previewWizardAppearance));
|
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");
|
const accentRow = el("div", "accent-row");
|
||||||
for (const [key, color] of Object.entries(ACCENTS)) {
|
for (const [key, color] of Object.entries(ACCENTS)) {
|
||||||
const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), "");
|
const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), "");
|
||||||
@@ -1799,8 +1807,49 @@ function renderWizardStep() {
|
|||||||
});
|
});
|
||||||
accentRow.appendChild(sw);
|
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
|
// Fusszeile: Zurueck links, Weiter/Abschliessen rechts
|
||||||
const nav = el("div", "wizard-nav");
|
const nav = el("div", "wizard-nav");
|
||||||
@@ -1839,8 +1888,27 @@ function renderWizardStep() {
|
|||||||
async function wizardFinish() {
|
async function wizardFinish() {
|
||||||
const patch = {
|
const patch = {
|
||||||
theme: wizard.theme, oled: wizard.oled, accent: wizard.accent,
|
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;
|
if (app.me && app.me.username) patch.onboarded = true;
|
||||||
try {
|
try {
|
||||||
app.settings = await api("/api/settings", jsonOpts("PUT", patch));
|
app.settings = await api("/api/settings", jsonOpts("PUT", patch));
|
||||||
@@ -1851,29 +1919,6 @@ async function wizardFinish() {
|
|||||||
await afterAuth();
|
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)
|
Anmeldung (Auth-Gate)
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
@@ -1933,18 +1978,15 @@ async function doAuth(mode, username, password, errEl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupLogout() {
|
function logout() {
|
||||||
const btn = $("#logout-btn");
|
api("/api/logout", { method: "POST" }).catch(() => {});
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await api("/api/logout", { method: "POST" });
|
|
||||||
} catch (_) {}
|
|
||||||
localStorage.removeItem("tp_token");
|
localStorage.removeItem("tp_token");
|
||||||
app.me = null;
|
app.me = null;
|
||||||
app.models = {};
|
app.models = {};
|
||||||
$("#view-today").innerHTML = "";
|
$("#view-today").innerHTML = "";
|
||||||
$("#today-label").textContent = "";
|
$("#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" };
|
app.settings = { language: app.meta ? app.meta.language : "de" };
|
||||||
document.body.classList.add("no-auth");
|
document.body.classList.add("no-auth");
|
||||||
$("#setup-gate").hidden = true;
|
$("#setup-gate").hidden = true;
|
||||||
@@ -1953,14 +1995,11 @@ function setupLogout() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserChip() {
|
function logoutButton() {
|
||||||
const chip = $("#user-chip");
|
const b = el("button", "btn btn-danger-ghost", t("logout_label"));
|
||||||
const btn = $("#logout-btn");
|
b.type = "button";
|
||||||
if (app.me && app.me.username) {
|
b.addEventListener("click", () => logout());
|
||||||
chip.hidden = false;
|
return b;
|
||||||
chip.textContent = app.me.username;
|
|
||||||
btn.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -2095,6 +2134,9 @@ function renderSettingsView() {
|
|||||||
if (app.me && (!app.me.username || app.me.is_owner)) {
|
if (app.me && (!app.me.username || app.me.is_owner)) {
|
||||||
view.appendChild(renderUsersSection());
|
view.appendChild(renderUsersSection());
|
||||||
}
|
}
|
||||||
|
if (app.me && (!app.me.username || app.me.is_owner)) {
|
||||||
|
view.appendChild(renderResetSection());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsSelect(key, label, options) {
|
function settingsSelect(key, label, options) {
|
||||||
@@ -2139,6 +2181,9 @@ function renderAccountSection() {
|
|||||||
row.appendChild(neu);
|
row.appendChild(neu);
|
||||||
row.appendChild(btn);
|
row.appendChild(btn);
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
|
const logoutRow = el("div", "form-row");
|
||||||
|
logoutRow.appendChild(logoutButton());
|
||||||
|
body.appendChild(logoutRow);
|
||||||
sec.appendChild(body);
|
sec.appendChild(body);
|
||||||
return sec;
|
return sec;
|
||||||
}
|
}
|
||||||
@@ -2170,6 +2215,10 @@ add.addEventListener("click", async () => {
|
|||||||
localStorage.setItem("tp_token", r.token);
|
localStorage.setItem("tp_token", r.token);
|
||||||
toast(t("user_created", { u: r.username }), "ok");
|
toast(t("user_created", { u: r.username }), "ok");
|
||||||
await afterAuth();
|
await afterAuth();
|
||||||
|
// Einstellungen frisch rendern, falls gerade geoeffnet
|
||||||
|
if ($("#view-settings").classList.contains("is-active")) {
|
||||||
|
renderSettingsView();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 403) {
|
if (e.status === 403) {
|
||||||
toast(eMsg(e), "error");
|
toast(eMsg(e), "error");
|
||||||
@@ -2253,6 +2302,7 @@ function renderOwnerUsersSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
reload();
|
reload();
|
||||||
|
body.appendChild(el("div", "divider"));
|
||||||
const row = el("div", "form-row");
|
const row = el("div", "form-row");
|
||||||
const name = el("input", "file-input");
|
const name = el("input", "file-input");
|
||||||
name.type = "text";
|
name.type = "text";
|
||||||
@@ -2281,6 +2331,41 @@ function renderOwnerUsersSection() {
|
|||||||
return sec;
|
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) {
|
function toggleRow(key, label, desc) {
|
||||||
const row = el("label", "toggle-row");
|
const row = el("label", "toggle-row");
|
||||||
const cb = el("input", "toggle-input");
|
const cb = el("input", "toggle-input");
|
||||||
@@ -2304,7 +2389,6 @@ function setupUi() {
|
|||||||
tab.addEventListener("click", () => switchView(tab.dataset.view));
|
tab.addEventListener("click", () => switchView(tab.dataset.view));
|
||||||
}
|
}
|
||||||
setupOverlay();
|
setupOverlay();
|
||||||
setupLogout();
|
|
||||||
setupThemeWatch();
|
setupThemeWatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2324,12 +2408,29 @@ async function init() {
|
|||||||
await loadAppData();
|
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() {
|
async function loadAppData() {
|
||||||
app.cfg = await api("/api/config");
|
app.cfg = await api("/api/config");
|
||||||
app.settings = await api("/api/settings");
|
app.settings = await api("/api/settings");
|
||||||
applySettings(app.settings);
|
applySettings(app.settings);
|
||||||
$("#today-label").textContent = t("today_header") + fmtDateLong(app.meta.today);
|
for (const tab of $$(".tab")) {
|
||||||
setUserChip();
|
const key = "tab_" + tab.dataset.view;
|
||||||
|
if (LANG[lang()][key]) tab.textContent = t(key);
|
||||||
|
}
|
||||||
|
updateTodayLabel();
|
||||||
await loadToday();
|
await loadToday();
|
||||||
const startTab = app.settings.startTab || "today";
|
const startTab = app.settings.startTab || "today";
|
||||||
if (startTab !== "today") switchView(startTab);
|
if (startTab !== "today") switchView(startTab);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+164
-43
@@ -23,15 +23,21 @@
|
|||||||
"sets": 4,
|
"sets": 4,
|
||||||
"reps": "8-10",
|
"reps": "8-10",
|
||||||
"startKg": 18,
|
"startKg": 18,
|
||||||
"tags": ["brust", "kh"],
|
"tags": [
|
||||||
"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."
|
"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)",
|
"name": "Schrägbank KH (je Hand)",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "10-12",
|
"reps": "10-12",
|
||||||
"startKg": 14,
|
"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."
|
"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,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 20,
|
"startKg": 20,
|
||||||
"tags": ["brust", "maschine"],
|
"tags": [
|
||||||
"info": "Technogym Pectoral (Selection 900) oder Butterfly-Maschine. Unterarme auf den Polstern, Griffe langsam zusammenführen, in der Endstellung kurz anspannen, kontrolliert zurück."
|
"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)",
|
"name": "Seitheben (je Hand)",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 8,
|
"startKg": 8,
|
||||||
"tags": ["schulter", "kh"],
|
"tags": [
|
||||||
"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)."
|
"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",
|
"name": "Schulterpresse Maschine",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "10-12",
|
"reps": "10-12",
|
||||||
"startKg": 25,
|
"startKg": 25,
|
||||||
"tags": ["schulter", "maschine"],
|
"tags": [
|
||||||
"info": "Technogym Shoulder Press (Selection 900/700). Sitzen, hintere Schulter gegen Rückenpolster, Gewicht über Kopf drücken, oben nicht komplett durchstrecken."
|
"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",
|
"name": "Kabel-Trizeps-Pushdown",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 25,
|
"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."
|
"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,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 15,
|
"startKg": 15,
|
||||||
"tags": ["trizeps", "maschine"],
|
"tags": [
|
||||||
"info": "Technogym Arm Extension (Selection 900) - Oberarme liegen auf dem Polster, Gewicht über Kopf strecken, nur die Unterarme bewegen sich. Alternativ: Kabel-Überkopf."
|
"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)",
|
"name": "Kabel-Curls (Bizeps)",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 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."
|
"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,
|
"sets": 4,
|
||||||
"reps": "8-10",
|
"reps": "8-10",
|
||||||
"startKg": 55,
|
"startKg": 55,
|
||||||
"tags": ["ruecken", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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)",
|
"name": "Rudern (Low Row)",
|
||||||
"sets": 4,
|
"sets": 4,
|
||||||
"reps": "10",
|
"reps": "10",
|
||||||
"startKg": 50,
|
"startKg": 50,
|
||||||
"tags": ["ruecken", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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",
|
"name": "Face Pulls",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 20,
|
"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."
|
"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,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 14,
|
"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."
|
"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,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 20,
|
"startKg": 20,
|
||||||
"tags": ["schulter", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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,
|
"sets": 4,
|
||||||
"reps": "8-10",
|
"reps": "8-10",
|
||||||
"startKg": 120,
|
"startKg": 120,
|
||||||
"tags": ["beine", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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)",
|
"name": "Beinbeuger (Leg Curl)",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "10-12",
|
"reps": "10-12",
|
||||||
"startKg": 50,
|
"startKg": 50,
|
||||||
"tags": ["beine", "maschine"],
|
"tags": [
|
||||||
"info": "Technogym Leg Curl (Selection 900/700), sitzend oder liegend. Knie beugen, Fersen zum Gesäß ziehen, oben kurz anspannen. Trifft Beinrückseite (Hamstrings)."
|
"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)",
|
"name": "Beinstrecker (Leg Extension)",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "12-15",
|
"reps": "12-15",
|
||||||
"startKg": 55,
|
"startKg": 55,
|
||||||
"tags": ["beine", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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",
|
"name": "Wadenheben stehend",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "15-20",
|
"reps": "15-20",
|
||||||
"startKg": 65,
|
"startKg": 65,
|
||||||
"tags": ["waden", "maschine"],
|
"tags": [
|
||||||
"info": "Technogym Standing Calf (Selection 900). Auf Zehenspitzen hochdrücken, oben kurz halten, bis die Wade gedehnt ist senken. Trifft die Wadenmuskulatur."
|
"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",
|
"name": "Glute Press / Hip Thrust",
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"reps": "10-12",
|
"reps": "10-12",
|
||||||
"startKg": 60,
|
"startKg": 60,
|
||||||
"tags": ["po", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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)",
|
"name": "(opz.) Bauch-Finisher (Ab Crunch)",
|
||||||
"sets": 2,
|
"sets": 2,
|
||||||
"reps": "10-15",
|
"reps": "10-15",
|
||||||
"startKg": 0,
|
"startKg": 0,
|
||||||
"tags": ["bauch", "maschine"],
|
"tags": [
|
||||||
"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."
|
"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,
|
"active": true,
|
||||||
"displayOrder": 2,
|
"displayOrder": 2,
|
||||||
"fieldTemplates": [
|
"fieldTemplates": [
|
||||||
{ "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 },
|
{
|
||||||
{ "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 },
|
"key": "distanceKm",
|
||||||
{ "key": "pacePerKm", "label": "Pace", "unit": "min/km", "kind": "pace", "step": null },
|
"label": "Distanz",
|
||||||
{ "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true },
|
"unit": "km",
|
||||||
{ "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true }
|
"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,
|
"active": true,
|
||||||
"displayOrder": 3,
|
"displayOrder": 3,
|
||||||
"fieldTemplates": [
|
"fieldTemplates": [
|
||||||
{ "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 },
|
{
|
||||||
{ "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 },
|
"key": "distanceKm",
|
||||||
{ "key": "avgSpeed", "label": "Ø Tempo", "unit": "km/h", "kind": "number", "step": 0.1 },
|
"label": "Distanz",
|
||||||
{ "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true },
|
"unit": "km",
|
||||||
{ "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true }
|
"kind": "number",
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "minutes",
|
||||||
|
"label": "Dauer",
|
||||||
|
"unit": "min",
|
||||||
|
"kind": "number",
|
||||||
|
"step": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "avgSpeed",
|
||||||
|
"label": "Ø Tempo",
|
||||||
|
"unit": "km/h",
|
||||||
|
"kind": "number",
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "heartRate",
|
||||||
|
"label": "Puls Ø",
|
||||||
|
"unit": "bpm",
|
||||||
|
"kind": "number",
|
||||||
|
"step": 1,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "elevation",
|
||||||
|
"label": "Höhenmeter",
|
||||||
|
"unit": "m",
|
||||||
|
"kind": "number",
|
||||||
|
"step": 1,
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+4074
-1405
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,6 @@
|
|||||||
<button class="tab" data-view="data" type="button">Daten</button>
|
<button class="tab" data-view="data" type="button">Daten</button>
|
||||||
<button class="tab" data-view="settings" type="button">Einstellungen</button>
|
<button class="tab" data-view="settings" type="button">Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="user-chip" id="user-chip" hidden></span>
|
|
||||||
<button class="btn btn-small" id="logout-btn" hidden type="button">Abmelden</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -434,6 +434,26 @@ def fetch_session(user_id, sid):
|
|||||||
return d
|
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):
|
def create_session(user_id, data):
|
||||||
activity = _clean_str(data.get("activity"))
|
activity = _clean_str(data.get("activity"))
|
||||||
if not activity:
|
if not activity:
|
||||||
@@ -441,12 +461,15 @@ def create_session(user_id, data):
|
|||||||
sdate = _clean_str(data.get("date")) or date.today().isoformat()
|
sdate = _clean_str(data.get("date")) or date.today().isoformat()
|
||||||
day = _clean_str(data.get("day"))
|
day = _clean_str(data.get("day"))
|
||||||
start = _clean_str(data.get("start_time")) or datetime.now().strftime("%H:%M")
|
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()
|
con = db()
|
||||||
cur = con.execute(
|
cur = con.execute(
|
||||||
"INSERT INTO sessions(activity,day,date,start_time,status,created_at,user_id) "
|
"INSERT INTO sessions(activity,day,date,start_time,status,created_at,user_id,fields) "
|
||||||
"VALUES(?,?,?,?,?,?,?)",
|
"VALUES(?,?,?,?,?,?,?,?)",
|
||||||
(activity, day, sdate, start, "active",
|
(activity, day, sdate, start, "active",
|
||||||
datetime.now().isoformat(timespec="seconds"), user_id))
|
datetime.now().isoformat(timespec="seconds"), user_id, fields))
|
||||||
con.commit()
|
con.commit()
|
||||||
sid = cur.lastrowid
|
sid = cur.lastrowid
|
||||||
con.close()
|
con.close()
|
||||||
@@ -651,6 +674,8 @@ def create_user(username, password):
|
|||||||
# - Sessions wandern zum Owner (Alt-Daten gehoeren ihm),
|
# - Sessions wandern zum Owner (Alt-Daten gehoeren ihm),
|
||||||
# - Settings (Sprache/Akzent aus dem Wizard) werden KOPIERT, damit der
|
# - Settings (Sprache/Akzent aus dem Wizard) werden KOPIERT, damit der
|
||||||
# Server-Standard (user_id=0) fuer weitere Benutzer erhalten bleibt.
|
# 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:
|
if uid == 1:
|
||||||
con.execute("UPDATE sessions SET user_id=? WHERE user_id=?",
|
con.execute("UPDATE sessions SET user_id=? WHERE user_id=?",
|
||||||
(uid, OPEN_USER))
|
(uid, OPEN_USER))
|
||||||
@@ -658,6 +683,9 @@ def create_user(username, password):
|
|||||||
"INSERT INTO settings(user_id,key,value) "
|
"INSERT INTO settings(user_id,key,value) "
|
||||||
"SELECT ?, key, value FROM settings WHERE user_id=?",
|
"SELECT ?, key, value FROM settings WHERE user_id=?",
|
||||||
(uid, OPEN_USER))
|
(uid, OPEN_USER))
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO settings(user_id,key,value) VALUES(?, 'onboarded', 'true')",
|
||||||
|
(uid,))
|
||||||
token = _issue_token(con, uid)
|
token = _issue_token(con, uid)
|
||||||
con.commit()
|
con.commit()
|
||||||
con.close()
|
con.close()
|
||||||
@@ -770,6 +798,16 @@ def set_user_password(uid, new):
|
|||||||
return True
|
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
|
# Auto-Reset: letztes Workout einer Kategorie laden
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1415,6 +1453,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(400, json.dumps(
|
self._send(400, json.dumps(
|
||||||
{"error": "Aktuelles Passwort ist falsch"}),
|
{"error": "Aktuelles Passwort ist falsch"}),
|
||||||
"application/json; charset=utf-8")
|
"application/json; charset=utf-8")
|
||||||
|
elif path in ("/api/reset", "/api/reset/"):
|
||||||
|
self.do_reset()
|
||||||
else:
|
else:
|
||||||
uid = self._require_user()
|
uid = self._require_user()
|
||||||
if uid is None:
|
if uid is None:
|
||||||
@@ -1450,6 +1490,36 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(500, json.dumps({"error": str(exc)}),
|
self._send(500, json.dumps({"error": str(exc)}),
|
||||||
"application/json; charset=utf-8")
|
"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):
|
def do_register(self):
|
||||||
if user_count() > 0:
|
if user_count() > 0:
|
||||||
self._send(403, json.dumps(
|
self._send(403, json.dumps(
|
||||||
|
|||||||
+87
-24
@@ -63,6 +63,10 @@ html[data-accent="violet"] { --accent: #9a6fd0; --accent-hover: #ab80e0; --accen
|
|||||||
html[data-accent="pink"] { --accent: #e0567f; --accent-hover: #eb6890; --accent-active: #c74b6e; --accent-ink: #141414; }
|
html[data-accent="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-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="dark"], html[data-theme="oled"] { color-scheme: dark; }
|
||||||
html[data-theme="light"] { color-scheme: light; }
|
html[data-theme="light"] { color-scheme: light; }
|
||||||
|
|
||||||
@@ -135,9 +139,23 @@ html, body {
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border: 2px solid var(--border);
|
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 {
|
.tab {
|
||||||
@@ -199,6 +217,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
|
min-width: 96px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +243,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.btn-danger-ghost:hover { background: var(--bg-hover); }
|
.btn-danger-ghost:hover { background: var(--bg-hover); }
|
||||||
.btn-danger-ghost.is-armed { background: var(--danger); color: #fff; }
|
.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%; }
|
.btn-block { width: 100%; }
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@@ -232,13 +251,13 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 30px;
|
width: 26px;
|
||||||
height: 30px;
|
height: 26px;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -381,12 +400,21 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.ex-block {
|
.ex-block {
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
margin: 0;
|
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 {
|
.ex-grid {
|
||||||
display: 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;
|
gap: 14px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -418,13 +446,21 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.set-rows { padding: 0; }
|
.set-rows { padding: 0; }
|
||||||
|
|
||||||
.set-row {
|
.set-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: auto auto auto 1fr auto auto;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
align-items: center;
|
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-row.is-done { background: var(--bg-subtle); }
|
||||||
|
|
||||||
/* Set-Abhaken: ganzer Button klickbar, mit Ankreuz-Box */
|
/* Set-Abhaken: ganzer Button klickbar, mit Ankreuz-Box */
|
||||||
@@ -433,16 +469,17 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 4px;
|
||||||
min-height: 34px;
|
min-height: 30px;
|
||||||
padding: 0 12px;
|
padding: 0 8px;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-check:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 14px;
|
width: 12px;
|
||||||
height: 14px;
|
height: 12px;
|
||||||
border: 2px solid currentColor;
|
border: 2px solid currentColor;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +518,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.field-box {
|
.field-box {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
width: 84px;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
@@ -490,13 +528,15 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.field-box:hover { border-color: var(--border-hover); }
|
.field-box:hover { border-color: var(--border-hover); }
|
||||||
|
|
||||||
.field-input {
|
.field-input {
|
||||||
width: 74px;
|
flex: 1 1 auto;
|
||||||
padding: 6px 4px 6px 8px;
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-data);
|
font-family: var(--font-data);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
@@ -506,12 +546,13 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
.field-unit {
|
.field-unit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 8px;
|
padding: 0 4px;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: var(--font-data);
|
font-family: var(--font-data);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cardio-Felder */
|
/* Cardio-Felder */
|
||||||
@@ -523,7 +564,7 @@ h3 { font-size: 15px; font-weight: 700; }
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardio-fields .field-input { width: 90px; }
|
.cardio-fields .field-box { width: 120px; }
|
||||||
|
|
||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
Hinweise / Leer-Zustaende
|
Hinweise / Leer-Zustaende
|
||||||
@@ -919,9 +960,25 @@ body.boot #setup-gate { visibility: hidden; }
|
|||||||
width: 640px;
|
width: 640px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
text-align: left;
|
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 {
|
.auth-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1038,7 +1095,8 @@ body.boot #setup-gate { visibility: hidden; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 20px;
|
margin-top: auto;
|
||||||
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-nav-right {
|
.wizard-nav-right {
|
||||||
@@ -1073,6 +1131,11 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-select-row {
|
.settings-select-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ def main():
|
|||||||
check("Statistik: bestes Gewicht je Termin",
|
check("Statistik: bestes Gewicht je Termin",
|
||||||
s0["weight"] == 22 and s1["weight"] == 20, stats)
|
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")
|
st, exp = req("GET", "/api/export?format=json")
|
||||||
check("Export JSON Format", st == 200
|
check("Export JSON Format", st == 200
|
||||||
and exp["format"] == "trainingsplan-export-v2"
|
and exp["format"] == "trainingsplan-export-v2"
|
||||||
|
|||||||
+44
-7
@@ -81,6 +81,8 @@ def main():
|
|||||||
req("PATCH", f"/api/sessions/{sess['id']}",
|
req("PATCH", f"/api/sessions/{sess['id']}",
|
||||||
{"meta": {"status": "done"}})
|
{"meta": {"status": "done"}})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Registrierung (erster Benutzer) --------------------------------
|
# --- Registrierung (erster Benutzer) --------------------------------
|
||||||
st, reg = req("POST", "/api/register",
|
st, reg = req("POST", "/api/register",
|
||||||
{"username": "alex", "password": "geheim"})
|
{"username": "alex", "password": "geheim"})
|
||||||
@@ -147,7 +149,14 @@ def main():
|
|||||||
st, imp = req("POST", "/api/import",
|
st, imp = req("POST", "/api/import",
|
||||||
{"format": "trainingsplan-export-v2",
|
{"format": "trainingsplan-export-v2",
|
||||||
"data": json.dumps(demo)}, token=token_b)
|
"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 -------------------------------------------
|
# --- Berta trainiert selbst -------------------------------------------
|
||||||
st, sess_b = req("POST", "/api/sessions",
|
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_b = req("GET", "/api/sessions?status=done", token=token_b)
|
||||||
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
|
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
|
||||||
check("Isolation: B sieht 23 eigene Sessions (22 Demo + 1)",
|
check("Isolation: B sieht alle Demo-Sessions + 1 eigene",
|
||||||
len(done_b) == 23, len(done_b))
|
len(done_b) == demo_sessions + 1, len(done_b))
|
||||||
check("Isolation: A sieht seine 1 Session (nicht B)",
|
check("Isolation: A sieht seine 1 Session (nicht B)",
|
||||||
len(done_a) == 1, len(done_a))
|
len(done_a) == 1, len(done_a))
|
||||||
|
|
||||||
@@ -191,22 +200,23 @@ def main():
|
|||||||
|
|
||||||
# --- Workouts-API -----------------------------------------------------
|
# --- Workouts-API -----------------------------------------------------
|
||||||
st, wo_b = req("GET", "/api/workouts", token=token_b)
|
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
|
w = wo_b[0] # neueste Session = Berta heute
|
||||||
check("Workouts-API: Shape", w["activity_name"] == "Gym (Kraft)"
|
check("Workouts-API: Shape", w["activity_name"] == "Gym (Kraft)"
|
||||||
and w["day"] == "Push" and w["duration_min"] is not None
|
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]["name"] == "Bankdrücken KH (je Hand)"
|
||||||
and w["exercises"][0]["sets"][0]["weight"] == "40", w)
|
and w["exercises"][0]["sets"][0]["weight"] == "40", w)
|
||||||
st, wo_b = req("GET", "/api/workouts?activity=gym", token=token_b)
|
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,
|
check("Workouts nach Import (gym): Demo + 1 eigene",
|
||||||
len(wo_b))
|
len(wo_b) == demo_gym + 1, len(wo_b))
|
||||||
st, wo_a = req("GET", "/api/workouts", token=token_a)
|
st, wo_a = req("GET", "/api/workouts", token=token_a)
|
||||||
check("A bleibt unberuehrt vom Import", len(wo_a) == 1, len(wo_a))
|
check("A bleibt unberuehrt vom Import", len(wo_a) == 1, len(wo_a))
|
||||||
st, stats = req("GET", "/api/stats?exercise="
|
st, stats = req("GET", "/api/stats?exercise="
|
||||||
+ urllib.parse.quote("Push/Bankdrücken KH (je Hand)"),
|
+ urllib.parse.quote("Push/Bankdrücken KH (je Hand)"),
|
||||||
token=token_b)
|
token=token_b)
|
||||||
check("Statistik aus Demo-Daten + eigene Session",
|
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[0]["weight"] < stats[-1]["weight"]
|
||||||
and stats[-1]["weight"] == 40, stats)
|
and stats[-1]["weight"] == 40, stats)
|
||||||
|
|
||||||
@@ -262,6 +272,33 @@ def main():
|
|||||||
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
|
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))
|
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:
|
finally:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
|
|||||||
+9
-9
@@ -97,18 +97,18 @@ async function main() {
|
|||||||
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
|
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
|
||||||
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||||
.find(b => b.textContent === "Weiter").click();
|
.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",
|
check("Setup: Fortschritt aktualisiert",
|
||||||
gate().querySelectorAll(".wp-step.is-done").length === 1
|
gate().querySelectorAll(".wp-step.is-done").length === 1
|
||||||
&& gate().querySelectorAll(".wp-step.is-current").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"));
|
const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
|
||||||
check("Setup: zwei gleichwertige Optionen",
|
check("Setup: zwei gleichwertige Optionen",
|
||||||
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
|
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
|
||||||
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
|
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
|
||||||
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click();
|
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"))
|
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||||
.find(b => b.textContent === "Einrichtung abschließen").click();
|
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||||
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
|
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();
|
window.document.querySelector('.tab[data-view="history"]').click();
|
||||||
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
|
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
|
||||||
const histCard = () => window.document.querySelector(".hist-card");
|
const histCard = () => window.document.querySelector(".hist-card");
|
||||||
check("Verlauf: Haken-Box zeigt Abhak-Status",
|
check("Verlauf: nur abgehakte Saetze, kompakt",
|
||||||
!!histCard().querySelector(".set-done-box")
|
!histCard().querySelector(".set-done-box")
|
||||||
&& histCard().querySelector(".set-done-box").textContent === "✓");
|
&& !histCard().querySelector(".hist-sets").textContent.includes("□"));
|
||||||
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
|
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
|
||||||
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
|
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
|
||||||
|
|
||||||
@@ -238,8 +238,8 @@ async function main() {
|
|||||||
check("Hinweiszeile ausgeblendet",
|
check("Hinweiszeile ausgeblendet",
|
||||||
!gymCard().querySelector(".ex-block .ex-hint"));
|
!gymCard().querySelector(".ex-block .ex-hint"));
|
||||||
|
|
||||||
// --- Uebungen per Drag & Drop sortieren --------------------------------
|
// --- Uebungen per Drag & Drop (Griff) sortieren ------------------------
|
||||||
gymCard().querySelectorAll(".ex-block")[0]
|
gymCard().querySelectorAll(".ex-block")[0].querySelector(".ex-grip")
|
||||||
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
|
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
|
||||||
const target = gymCard().querySelectorAll(".ex-block")[1];
|
const target = gymCard().querySelectorAll(".ex-block")[1];
|
||||||
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
|
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
|
||||||
|
|||||||
+51
-37
@@ -85,6 +85,13 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
.find(b => b.textContent === "Next").click();
|
.find(b => b.textContent === "Next").click();
|
||||||
await sleep(200);
|
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"));
|
const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
|
||||||
check("Setup: zwei gleichwertige Optionen",
|
check("Setup: zwei gleichwertige Optionen",
|
||||||
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
|
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
|
||||||
@@ -97,15 +104,18 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
check("Setup: Formular nach Mehrbenutzermodus-Klick",
|
check("Setup: Formular nach Mehrbenutzermodus-Klick",
|
||||||
!!sgate().querySelector("input#setup-user")
|
!!sgate().querySelector("input#setup-user")
|
||||||
&& !!sgate().querySelector("input#setup-pass"));
|
&& !!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();
|
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"))
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
.find(b => b.textContent === "Finish setup").click();
|
.find(b => b.textContent === "Finish setup").click();
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
@@ -116,6 +126,8 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
|
|
||||||
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
|
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
|
||||||
check("API: Register", reg.status === 200 && !!reg.body.token, reg.body);
|
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 ---
|
// --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place ---
|
||||||
const winNoToken = await loadApp(html, appJs, null);
|
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-user')
|
||||||
&& !!gate().querySelector('input#auth-pass')
|
&& !!gate().querySelector('input#auth-pass')
|
||||||
&& !!gate().querySelector(".btn-primary"));
|
&& !!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"))
|
Array.from(gate().querySelectorAll(".btn"))
|
||||||
.some(b => b.textContent === "Log in")
|
.some(b => b.textContent === "Anmelden")
|
||||||
&& gate().querySelector(".auth-sub").textContent.includes("Log in to see"));
|
&& gate().querySelector(".auth-sub").textContent.includes("Anmelden, um deine Trainingsdaten zu sehen"));
|
||||||
// falsches Kennwort -> Inline-Fehler
|
// falsches Kennwort -> Inline-Fehler
|
||||||
gate().querySelector('input#auth-user').value = "alex";
|
gate().querySelector('input#auth-user').value = "alex";
|
||||||
gate().querySelector('input#auth-pass').value = "falsch";
|
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);
|
await sleep(500);
|
||||||
const authErr = gate().querySelector(".auth-error");
|
const authErr = gate().querySelector(".auth-error");
|
||||||
check("ohne Token: Inline-Fehler bei falschem Kennwort",
|
check("ohne Token: Inline-Fehler bei falschem Kennwort",
|
||||||
authErr && !authErr.hidden
|
authErr && !authErr.hidden
|
||||||
&& authErr.textContent.includes("Incorrect username or password"),
|
&& authErr.textContent.includes("Kennwort inkorrekt"),
|
||||||
authErr && authErr.textContent);
|
authErr && authErr.textContent);
|
||||||
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
|
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
|
||||||
gate().querySelector('input#auth-pass').value = "geheim";
|
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;
|
let loggedIn = false;
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
const og = winNoToken.document.querySelector("#setup-gate");
|
const og = winNoToken.document.querySelector("#setup-gate");
|
||||||
@@ -152,32 +164,16 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (winNoToken.document.querySelectorAll(".act-card").length === 3
|
if (winNoToken.document.querySelectorAll(".act-card").length === 3
|
||||||
&& winNoToken.document.querySelector("#user-chip").textContent === "alex") {
|
&& winNoToken.document.querySelector("#logout-btn") === null) {
|
||||||
loggedIn = true;
|
loggedIn = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
|
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
|
||||||
// Onboarding abschliessen (im selben Fenster)
|
check("Login: Admin ohne Onboarding direkt in Heute-Ansicht",
|
||||||
const ogate = () => winNoToken.document.querySelector("#setup-gate");
|
winNoToken.document.querySelector("#setup-gate").hidden
|
||||||
if (ogate() && !ogate().hidden) {
|
&& winNoToken.document.querySelectorAll(".act-card").length === 3);
|
||||||
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: kein Auth-Gate mehr",
|
check("Login: kein Auth-Gate mehr",
|
||||||
winNoToken.document.querySelector("#auth-gate").hidden);
|
winNoToken.document.querySelector("#auth-gate").hidden);
|
||||||
|
|
||||||
@@ -186,8 +182,7 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
await sleep(1200);
|
await sleep(1200);
|
||||||
let tokOk = false;
|
let tokOk = false;
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
if (winTok.document.querySelectorAll(".act-card").length === 3
|
if (winTok.document.querySelectorAll(".act-card").length === 3) {
|
||||||
&& winTok.document.querySelector("#user-chip").textContent === "alex") {
|
|
||||||
tokOk = true;
|
tokOk = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -210,7 +205,9 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
&& Array.from(winTok.document.querySelectorAll(".data-section"))
|
&& Array.from(winTok.document.querySelectorAll(".data-section"))
|
||||||
.some(s => s.querySelector("h3").textContent === "Konto"
|
.some(s => s.querySelector("h3").textContent === "Konto"
|
||||||
&& !!s.querySelector('input[placeholder="Aktuelles Kennwort"]')
|
&& !!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 () => {
|
check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => {
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
if (Array.from(winTok.document.querySelectorAll(".user-row"))
|
if (Array.from(winTok.document.querySelectorAll(".user-row"))
|
||||||
@@ -234,6 +231,23 @@ check("Setup: Sprache kommt zuerst",
|
|||||||
Array.from(rows()[0].querySelectorAll(".btn"))
|
Array.from(rows()[0].querySelectorAll(".btn"))
|
||||||
.some(b => b.textContent === "Kennwort ändern"));
|
.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 --------------------------------------
|
// --- Szenario 4: API-Login-Faelle --------------------------------------
|
||||||
check("API: Login mit falschem Passwort -> 401",
|
check("API: Login mit falschem Passwort -> 401",
|
||||||
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
|
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# Trainingsplan - Neubau-Spezifikation
|
|
||||||
|
|
||||||
Ziel fuer den Agenten: einen selbstgehosteten Trainingsverlaufs-Tracker von NULL neu bauen.
|
|
||||||
Nicht den alten Code reparieren. Das Konzept verstehen und besser umsetzen.
|
|
||||||
Es gibt KEINen bestehenden Code gestern. Alles folgt dieser Beschreibung + Wahrnehmung bei Bedarf.
|
|
||||||
|
|
||||||
## Auftrag
|
|
||||||
|
|
||||||
Baue eine schlanke Web-App fuer den taeglichen Gang ins Fitnessstudio.
|
|
||||||
Eine Person, ein Server, keine Cloud. Fokus: mit minimaler Reibung erfassen,
|
|
||||||
was trainiert wurde, und ueber Wochen erkennen, ob man staerker wird.
|
|
||||||
|
|
||||||
Aktivitaeten: **Gym (Kraft)**, **Laufen**, **Radfahren**. Die App lebt auf dem Server
|
|
||||||
(daten liegen serverseitig auf der Platte, nicht im Browser-Cache).
|
|
||||||
|
|
||||||
## Wichtigste Prinzipien (nicht verhandelbar)
|
|
||||||
|
|
||||||
1. **Konfiguration statt Code.** Der Trainingsplan (Uebungen, Saeetze, Reps, Gewichte,
|
|
||||||
Maschinen, Erklaerungen) liegt in einer Config-Datei (`config.json`). Keine
|
|
||||||
Uebungsliste hart im Code. Ein Nicht-Programmierer aendert den Plan in der Config.
|
|
||||||
2. **Serverseitige Persistenz.** Daten in SQLite. Mehrere Sessions am Tag, editierbarer
|
|
||||||
und loeschbarer Verlauf. Ueberlebt Neustart.
|
|
||||||
3. **Zustand strikt isolieren.** Eine Aenderung in Uebung A fasst nie Uebung B an.
|
|
||||||
Keine flachen Kopien, keine globalen Re-Builds. Jedes Set hat einen stabilen Identifier.
|
|
||||||
4. **Kein Datenverlust.** Waehrend des Tippens keine Re-Renders, die Fokus/Cursor killen.
|
|
||||||
Leer eingeben = leer, nicht 0. Eingaben werden erst gespeichert, wenn's Sinn ergibt.
|
|
||||||
5. **Keine Geister-Eintraege.** Es entsteht kein Verlaufseintrag, solange keine Session
|
|
||||||
wirklich gestartet oder explizit gespeichert wurde.
|
|
||||||
6. **Sichtbare Einheiten.** Ueberall "kg", "km", "min" sichtbar, nie nackte Zahlen.
|
|
||||||
7. **Sets modular.** Pro Uebung Anzahl und Gewicht der Saeetze konfigurierbar,
|
|
||||||
auch einzelne Sets individuell (z.B. Set-Array mit Gewicht/Reps je Set).
|
|
||||||
Hinzufuegen/Entfernen von Sets im UI moeglich.
|
|
||||||
8. **Portabel + Docker.** Laeuft per `python3` (nur Standardbibliothek) und als Docker-
|
|
||||||
Container. Daten-Verzeichnis per Environment-Variable (`TRAINING_STATE_DIR`).
|
|
||||||
Bestehende Daten- und Repo-Config nicht zerstoeren.
|
|
||||||
|
|
||||||
## Funktionalitaet (vollstaendig)
|
|
||||||
|
|
||||||
### Heutige Ansicht
|
|
||||||
- Zyklus Push/Pull/Legs (oder frei konfigurierbar in der Config).
|
|
||||||
- Jede Uebung als Liste ihrer Saeetze.
|
|
||||||
- Pro Satz: **Set-Nummer (klickbar als Abhak-Status), Gewicht, Reps** als Eingabe.
|
|
||||||
- Eingabe als **eine zusammengehoerige Box**: Zahl links, Einheit ("kg"/"reps") als
|
|
||||||
festes Suffix rechts IN derselben Box. Klick auf die ganze Box fokussiert den Input.
|
|
||||||
- **Set-Kette**: Set 3 waehlen aktiviert 1..3 (keine Luecken). Deaktivieren faengt
|
|
||||||
rueckwaerts. Klick auf Set darf Gewicht/Reps nie veraendern.
|
|
||||||
- **Reset-Satz**: Knopf setzt das Set auf das Gewicht des letzten abgeschlossenen Tages
|
|
||||||
(set-spezifisch, heutige in-Arbeit-Session zaehlt nicht).
|
|
||||||
- **Fragezeichen-Icon** neben jedem Uebungsnamen: oeffnet Overlay mit Text-Erklaerung
|
|
||||||
der Uebung + der passenden Maschine. (Text reicht, kein Bild.)
|
|
||||||
- Optionale Notiz pro Session.
|
|
||||||
|
|
||||||
### Session-Lebenszyklus
|
|
||||||
- "Session starten" / "Session speichern". Mehrere Sessions am Tag erlaubt.
|
|
||||||
- Start- und Endzeit. Nach "abschliessen" erscheint die Session SOFORT im Verlauf
|
|
||||||
(ohne Seiten-Reload).
|
|
||||||
- Solange keine Session gestartet, passiert nichts im Verlauf (kein Geist).
|
|
||||||
|
|
||||||
### Verlauf
|
|
||||||
- Abgeschlossene Sessions mit Datum + Zeit von-bis.
|
|
||||||
- **Inline bearbeiten unter dem Verlauf**: Datum, Start/Ende aenderbar; Workout-Eintraege
|
|
||||||
(Gewicht/Reps bzw. Cardio-Felder) direkt aenderbar; Session loeschbar.
|
|
||||||
- Bearbeiten-Tab bleibt im Verlauf, blutet nicht in die Heutige-Ansicht.
|
|
||||||
|
|
||||||
### Statistik
|
|
||||||
- Pro Uebung die Steigerung ueber die Zeit (bestes Gewicht je Termin,
|
|
||||||
Differenz zur ersten Messung).
|
|
||||||
|
|
||||||
### Export / Import
|
|
||||||
- Kompletter Stand als JSON und als CSV raus und rein (fuer Back-ups und
|
|
||||||
Tabellen-Auswertung). Format klar allabelt.
|
|
||||||
|
|
||||||
### Cardio (Laufen / Radfahren)
|
|
||||||
- Feld-Templates aus der Config (Distanz, Dauer, Pace, Puls, Hoehenmeter).
|
|
||||||
- Einheiten aus der Config (km, min, min/km, bpm, m).
|
|
||||||
|
|
||||||
## Maschinen / Trainingspraeferenz
|
|
||||||
|
|
||||||
- Trainiert mit Freihanteln UND Maschinen, tendenziell eher Maschine (Technogym).
|
|
||||||
- **Bizeps bevorzugt am Kabelturm.**
|
|
||||||
- Der Plan soll die fehlenden Standard-Maschinen abdecken (Technogym Selection-Linie
|
|
||||||
als Referenz: Chest Press, Pectoral, Shoulder Press, Lat Machine, Low Row, Reverse
|
|
||||||
Fly, Leg Press, Leg Curl, Leg Extension, Calf, Arm Curl/Preacher, Glute/Hip Thrust
|
|
||||||
u.a.) inkl. der Preacher-(Arm Cavlier)-Maschine.
|
|
||||||
- Das sind Richtwerte, kein Muss: Welche Maschinen im Studio wirklich stehen,
|
|
||||||
kann beim Training angepasst werden - Config, nicht Code.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
We-geilektung, KEIN Detail-Diktat: Nutzbarkeit hat Vorrang. Eine Regel, die die
|
|
||||||
Software unbrauchbar macht, wird gebrochen und begruendet.
|
|
||||||
|
|
||||||
- Harte Kanten: 0px Radius, klare 2px-Border, dunkler Grund.
|
|
||||||
- Flache Farben, ein Akzent, keine Verlaeufe, kein Glow, kein Glassmorphism.
|
|
||||||
- Kein Inter/Roboto-Systemstack als einzige Schrift unreflektiert.
|
|
||||||
- Bewusste Typo/Hierarchie ueber Abstand und Gewicht statt "alles gleich".
|
|
||||||
- Zustaende (Hover/Mouse/Focus/Active) mitdenken.
|
|
||||||
- Keine Emojis als Icons, keine rohen Unicode-Pfeile in Labels, keine Em-Dashes als
|
|
||||||
Stilmittel.
|
|
||||||
- Kompakt, kein "zentrierter Hero + 3 Karten"-SaaS-Skelett.
|
|
||||||
- **WICHTIG**: nicht ueberstilisiert designen. Es fehlt Feinarbeit - simpel, klar,
|
|
||||||
benutzbar. Design gibt die Richtung, macht die App nicht zerbrechlich.
|
|
||||||
|
|
||||||
## Technischer Rahmen (Empfehlung, nicht zwingend)
|
|
||||||
|
|
||||||
- Frontend: eine HTML-Datei Struktur, eine CSS-Datei, eine JS-Datei (kein Framework).
|
|
||||||
- Backend: `python3` mit Standardbibliothek (`http.server`) als JSON-API + statische
|
|
||||||
Dateien. SQLite fuer die Daten. Kein Inline-Script/Style in HTML - Klassen + CSS.
|
|
||||||
- Dockerfile + docker-compose (CasaOS-tauglich: Daten-Verzeichnis als echter Ordner).
|
|
||||||
|
|
||||||
## Abgeben
|
|
||||||
|
|
||||||
Code + kurze README (was es kann, wie starten, wie den Plan in der Config aendern).
|
|
||||||
Getestet und lauffaehig, nicht nur beschrieben.
|
|
||||||
Reference in new issue
Block a user