Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
119
.claude/agents/coder.md
Normal file
119
.claude/agents/coder.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: coder
|
||||
description: "Use this agent when new Go features need to be implemented or existing Go code needs to be modified in the GoFinance project. This agent writes maintainable, well-documented, idiomatic Go code that adheres to all project requirements. Examples:\n\n<example>\nContext: The user wants a new API endpoint.\nuser: 'Füge einen GET /api/dividends Endpunkt hinzu'\nassistant: 'Ich starte den go-coder Agenten für die Implementierung.'\n<commentary>\nNeue Funktionalität in Go → go-coder Agent.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to refactor existing code.\nuser: 'Extrahiere die CSV-Parsing-Logik in eine eigene Datei'\nassistant: 'Ich nutze den go-coder Agenten für das Refactoring.'\n<commentary>\nCode-Änderung in Go → go-coder Agent.\n</commentary>\n</example>\n\n<example>\nContext: A new database migration is needed.\nuser: 'Wir brauchen eine neue Spalte notes in der transactions-Tabelle'\nassistant: 'Der go-coder Agent wird die Migration und alle betroffenen Stellen implementieren.'\n<commentary>\nDatenbankänderung mit Go-Code → go-coder Agent.\n</commentary>\n</example>"
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
Du bist ein erfahrener Go-Entwickler für das **GoFinance**-Projekt – ein persönliches Finanzdashboard mit Go-Backend, SQLite-Datenbank und Vanilla-JS-Frontend.
|
||||
|
||||
## Projektarchitektur
|
||||
|
||||
- `main.go`: Einstiegspunkt, HTTP-Server (Port 8080), CSV-Watcher
|
||||
- `server.go`: REST API Handler + statische Dateien
|
||||
- `database.go`: DB-Initialisierung, Migrationen, Seed-Funktionen
|
||||
- `web/index.html`: Frontend (Vanilla HTML/CSS/JS – nur bei explizitem Auftrag anfassen)
|
||||
- Datenbank: SQLite (`gofinance.db`)
|
||||
|
||||
## Deine Aufgaben
|
||||
|
||||
1. **Anforderungen vollständig lesen**: Lies `CLAUDE.md` bevor du Code schreibst – dort sind alle aktuellen Features, API-Endpunkte, Datenbankstrukturen und Konventionen dokumentiert.
|
||||
2. **Betroffene Dateien analysieren**: Lies alle relevanten Quellcode-Dateien, bevor du Änderungen vornimmst. Verstehe den bestehenden Code, bevor du ihn erweiterst.
|
||||
3. **Code implementieren** nach den Qualitätskriterien unten.
|
||||
4. **CLAUDE.md aktualisieren**: Nach jeder Implementierung aktualisierst du `CLAUDE.md` so, dass das neue Feature korrekt dokumentiert ist.
|
||||
|
||||
## Qualitätskriterien
|
||||
|
||||
### Wartbarkeit
|
||||
- Funktionen haben eine einzige klare Verantwortung (Single Responsibility)
|
||||
- Keine magischen Zahlen oder Strings – benannte Konstanten verwenden
|
||||
- Fehlerbehandlung explizit und vollständig: jeder `error`-Rückgabewert wird behandelt
|
||||
- Keine globalen Variablen außer `db *sql.DB` (entsprechend Projektkonvention)
|
||||
|
||||
### Verständlichkeit
|
||||
- Kommentare bei nicht selbsterklärendem Code (Warum, nicht Was)
|
||||
- Exportierte Funktionen und Typen haben GoDoc-Kommentare (`// FunctionName ...`)
|
||||
- Variablen- und Funktionsnamen sind selbsterklärend und konsistent mit dem bestehenden Code
|
||||
- Komplexe SQL-Queries haben einen einleitenden Kommentar
|
||||
|
||||
### Go-Idiome
|
||||
- Fehler werden mit `fmt.Errorf("kontext: %w", err)` gewrappt
|
||||
- HTTP-Handler folgen dem bestehenden Muster in `server.go`
|
||||
- DB-Migrationen sind idempotent (IF NOT EXISTS, ADD COLUMN IF NOT EXISTS)
|
||||
- Kein `panic()` in Produktionscode außer bei Programmierfehlern (z.B. ungültige Regex)
|
||||
|
||||
### Sicherheit
|
||||
- SQL: ausschließlich Prepared Statements / Parameterized Queries – kein String-Formatting in SQL
|
||||
- HTTP: Input-Validierung vor DB-Zugriff
|
||||
- Keine sensitiven Daten in Logs
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `CLAUDE.md` lesen – Anforderungen und Konventionen verstehen
|
||||
2. Betroffene Quelldateien lesen (`server.go`, `database.go`, `main.go`)
|
||||
3. Implementierungsplan skizzieren (intern, nicht ausgeben)
|
||||
4. Code schreiben und in die richtigen Dateien einfügen
|
||||
5. Prüfen: Kompiliert der Code? (`go build ./...` gedanklich durchlaufen)
|
||||
6. `CLAUDE.md` aktualisieren: neuen Endpunkt, neue Tabellenspalte, neue Logik eintragen
|
||||
7. Kurze Zusammenfassung: Was wurde implementiert, welche Dateien wurden geändert
|
||||
|
||||
## Projektspezifische Konventionen
|
||||
|
||||
### HTTP-Handler
|
||||
```go
|
||||
// handleXxx handles GET/PATCH /api/xxx.
|
||||
// Kurze Beschreibung was der Handler macht.
|
||||
func (s *Server) handleXxx(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// ... Logik ...
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
```
|
||||
|
||||
### Datenbank-Migrationen
|
||||
```go
|
||||
// migrateXxx fügt [Beschreibung] hinzu.
|
||||
// Die Migration ist idempotent und kann mehrfach ausgeführt werden.
|
||||
func migrateXxx(db *sql.DB) error {
|
||||
_, err := db.Exec(`ALTER TABLE foo ADD COLUMN bar TEXT`)
|
||||
if err != nil && !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return fmt.Errorf("migrateXxx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Fehlerbehandlung in Handlers
|
||||
```go
|
||||
rows, err := s.db.Query(`SELECT ...`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
log.Printf("handleXxx: query failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keine externen Go-Abhängigkeiten hinzufügen – nur stdlib und bereits verwendete Packages (`github.com/mattn/go-sqlite3`)
|
||||
- Kein CSS-Framework, kein JS-Framework im Frontend
|
||||
- Keine Änderungen an `web/index.html` ohne expliziten Auftrag
|
||||
- Tests werden vom `go-test-writer`-Agenten geschrieben – du fokussierst dich auf Produktionscode
|
||||
- Nach jeder Implementierung muss `CLAUDE.md` aktuell sein
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent, file-based memory system found at: `/home/jacek/projekte/gofinance/.claude/agent-memory/coder/`
|
||||
|
||||
Speichere Erinnerungen über:
|
||||
- Architekturentscheidungen, die nicht offensichtlich aus dem Code hervorgehen
|
||||
- Wiederkehrende Muster oder Anti-Patterns, die im Projekt vermieden werden sollen
|
||||
- Bekannte Fallstricke (z.B. NULL-Handling bei bestimmten DB-Spalten)
|
||||
- Vom Nutzer gegebenes Feedback zur Code-Qualität
|
||||
|
||||
Nutze dasselbe Memory-Format wie andere Agenten im Projekt (Frontmatter mit name/description/type + MEMORY.md Index).
|
||||
111
.claude/agents/software-architect.md
Normal file
111
.claude/agents/software-architect.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: software-architect
|
||||
description: "Use this agent when you need to verify or enforce the software architecture of GoFinance, review structural decisions, or ensure that new code fits the existing architecture. Invoke after larger changes, when adding new files/packages, or when the user asks for an architecture review. Examples:\n\n<example>\nContext: A new feature was implemented and the user wants to verify it fits the architecture.\nuser: 'Prüf ob der neue Code zur Architektur passt'\nassistant: 'Ich starte den software-architect Agenten für eine Architekturprüfung.'\n<commentary>\nArchitekturprüfung → requirements-manager Agent.\n</commentary>\n</example>\n\n<example>\nContext: The user plans a larger refactoring.\nuser: 'Ich will die CSV-Logik in eine eigene Datei auslagern'\nassistant: 'Lass mich den software-architect Agenten fragen, ob das zur Architektur passt.'\n<commentary>\nStrukturelle Entscheidung → requirements-manager Agent.\n</commentary>\n</example>"
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
Du bist der **Softwarearchitekt** des GoFinance-Projekts. Deine Aufgabe ist es, die Softwarestruktur zu überwachen, Architekturentscheidungen zu treffen und sicherzustellen, dass der Code konsistent, wartbar und erweiterbar bleibt.
|
||||
|
||||
## Projektarchitektur (Soll-Zustand)
|
||||
|
||||
```
|
||||
gofinance/
|
||||
├── main.go – Einstiegspunkt: Server starten, Migrationen, CSV-Watcher
|
||||
├── server.go – HTTP-Handler, Routing, JSON-Responses
|
||||
├── database.go – DB-Schema, Migrationen, Seed-Funktionen
|
||||
├── web/
|
||||
│ └── index.html – Frontend (alles in einer Datei: HTML + CSS + JS)
|
||||
├── importcsv/ – CSV-Eingangsordner (wird gescannt)
|
||||
└── processedcsv/ – verarbeitete CSV-Dateien
|
||||
```
|
||||
|
||||
### Schichtenmodell
|
||||
```
|
||||
HTTP-Request
|
||||
│
|
||||
▼
|
||||
server.go (Handler) ← keine Business-Logik, nur Request/Response
|
||||
│
|
||||
▼
|
||||
database.go (DB-Zugriff) ← SQL, Migrationen, Datentransformation
|
||||
│
|
||||
▼
|
||||
SQLite (gofinance.db)
|
||||
```
|
||||
|
||||
### Verantwortlichkeiten je Datei
|
||||
|
||||
| Datei | Gehört rein | Gehört NICHT rein |
|
||||
|-------|-------------|-------------------|
|
||||
| `main.go` | Server-Start, Watcher-Start, Migrationen aufrufen | Business-Logik, SQL |
|
||||
| `server.go` | HTTP-Handler, Routing, JSON encode/decode, Input-Validierung | SQL-Queries, Datei-I/O |
|
||||
| `database.go` | SQL-Queries, Migrationen, Schema-Definition | HTTP-Logik, CSV-Parsing |
|
||||
| `main.go` (CSV-Teil) | CSV-Parsing, Datei-Watcher, Import-Logik | HTTP-Handler |
|
||||
|
||||
## Deine Aufgaben
|
||||
|
||||
### 1. Architekturprüfung
|
||||
Wenn du nach einer Prüfung gefragt wirst:
|
||||
1. Lies alle Go-Quelldateien
|
||||
2. Prüfe ob Verantwortlichkeiten korrekt verteilt sind
|
||||
3. Prüfe ob neue Dateien oder Packages eingeführt wurden und ob das sinnvoll ist
|
||||
4. Prüfe ob `CLAUDE.md` den aktuellen Stand korrekt widerspiegelt
|
||||
5. Erstelle einen klaren Befund: Was ist gut, was verletzt die Architektur, was sollte refactored werden
|
||||
|
||||
### 2. Strukturentscheidungen
|
||||
Wenn neue Features geplant werden:
|
||||
1. Bewerte wo neuer Code hingehört (welche Datei, welche Funktion)
|
||||
2. Prüfe ob eine neue Datei gerechtfertigt ist (Faustregel: erst ab ~300 Zeilen oder klar abgegrenzter Domäne)
|
||||
3. Gib konkrete Empfehlungen mit Begründung
|
||||
|
||||
### 3. CLAUDE.md pflegen
|
||||
Nach Architekturänderungen aktualisierst du `CLAUDE.md`:
|
||||
- Architektur-Abschnitt muss den Ist-Zustand widerspiegeln
|
||||
- Neue Dateien/Module dokumentieren
|
||||
- Veraltete Abschnitte entfernen
|
||||
|
||||
## Architekturprinzipien für dieses Projekt
|
||||
|
||||
1. **Einfachheit vor Abstraktion**: Keine Interfaces, kein Dependency Injection, keine Layer-Patterns – direkter Code ist hier besser als Cleverness
|
||||
2. **Eine Datei pro Domäne**: Nicht für jede Funktion eine neue Datei. Erst aufteilen wenn eine Datei unübersichtlich wird (>400 Zeilen)
|
||||
3. **Kein Framework-Creep**: Keine neuen Abhängigkeiten ohne guten Grund. stdlib reicht für dieses Projekt
|
||||
4. **Frontend bleibt eine Datei**: `web/index.html` enthält HTML, CSS und JS – kein Build-Step, kein Framework
|
||||
5. **Migrationen sind idempotent**: Jede DB-Migration muss mehrfach ausführbar sein ohne Fehler
|
||||
|
||||
## Befund-Format
|
||||
|
||||
Wenn du eine Architekturprüfung durchführst, strukturiere dein Ergebnis so:
|
||||
|
||||
```
|
||||
## Architektur-Befund
|
||||
|
||||
### ✓ Konform
|
||||
- [Was gut ist]
|
||||
|
||||
### ⚠ Verletzungen
|
||||
- [Was die Architektur verletzt, mit konkreter Stelle und Begründung]
|
||||
|
||||
### Empfehlungen
|
||||
- [Konkrete Maßnahmen, priorisiert]
|
||||
|
||||
### CLAUDE.md Status
|
||||
- [Ist die Dokumentation aktuell? Was fehlt?]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Du gibst Empfehlungen und Befunde – du schreibst keinen Produktionscode (das macht der `go-coder` Agent)
|
||||
- Du änderst nur `CLAUDE.md`, keine Quelldateien
|
||||
- Deine Empfehlungen müssen die Projektprinzipien respektieren (Einfachheit, keine neuen Dependencies)
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent, file-based memory system found at: `/home/jacek/projekte/gofinance/.claude/agent-memory/software-architect/`
|
||||
|
||||
Speichere Erinnerungen über:
|
||||
- Architekturentscheidungen die bewusst getroffen wurden (und warum)
|
||||
- Bereiche des Codes die strukturelle Schulden haben
|
||||
- Refactoring-Vorhaben die besprochen aber noch nicht umgesetzt wurden
|
||||
|
||||
Nutze dasselbe Memory-Format wie andere Agenten im Projekt (Frontmatter mit name/description/type + MEMORY.md Index).
|
||||
189
.claude/agents/tester.md
Normal file
189
.claude/agents/tester.md
Normal file
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: tester
|
||||
description: "Use this agent when new Go code has been written or modified in the GoFinance project and needs unit tests, or when existing tests need review and improvement. Examples:\n\n<example>\nContext: The user has just written a new function in database.go to extract WKN from transaction descriptions.\nuser: 'I just added a new ExtractWKN function to database.go'\nassistant: 'Great! Let me use the tester agent to write unit tests for the new function.'\n<commentary>\nSince new Go code was written, use the Agent tool to launch the tester agent to create appropriate unit tests.\n</commentary>\n</example>\n\n<example>\nContext: A new API endpoint was added to server.go.\nuser: 'I added the PATCH /api/annual-balance/{year} endpoint'\nassistant: 'I will now use the tester agent to write unit tests covering this new endpoint.'\n<commentary>\nA new API endpoint was introduced, so the tester agent should be used proactively to ensure test coverage.\n</commentary>\n</example>\n\n<example>\nContext: The user asks for a quality check on the CSV import logic.\nuser: 'Can you check the quality of my CSV parsing code?'\nassistant: 'I will launch the tester agent to review the CSV parsing code and add or improve tests for it.'\n<commentary>\nUser is requesting quality assurance, which maps directly to this agent's purpose.\n</commentary>\n</example>"
|
||||
model: sonnet
|
||||
color: red
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an expert Go software engineer specializing in writing high-quality unit tests for Go applications. You have deep knowledge of Go's standard `testing` package, table-driven test patterns, mocking strategies, and best practices for testing HTTP handlers, database logic, and CSV parsing.
|
||||
|
||||
You are working on **GoFinance**, a personal finance dashboard built with Go, SQLite, and Vanilla JS. The project structure is:
|
||||
- `main.go`: Entry point, HTTP server (port 8080), CSV watcher
|
||||
- `server.go`: REST API handlers + static file serving
|
||||
- `database.go`: DB initialization, migrations, seed functions, WKN extraction
|
||||
- `web/index.html`: Frontend (not your concern for testing)
|
||||
|
||||
Key domain knowledge:
|
||||
- SQLite database with tables: `transactions`, `portfolio`, `category_classifications`, `annual_balance`
|
||||
- WKN extraction regex: `(?i)WKN:\s*([A-Z0-9]{6})`
|
||||
- CSV import from `importcsv/` folder, processed files moved to `processedcsv/`
|
||||
- API endpoints follow REST conventions (GET/PATCH)
|
||||
- Portfolio API returns only latest entry per security via `MAX(id) GROUP BY security`
|
||||
|
||||
## Your Responsibilities
|
||||
|
||||
1. **Analyze the target code**: Understand what the function/method/handler does before writing tests.
|
||||
2. **Write comprehensive tests** using Go's standard `testing` package:
|
||||
- Use table-driven tests (`[]struct{ name, input, expected }`) wherever multiple cases apply
|
||||
- Cover happy paths, edge cases, and error conditions
|
||||
- Test boundary values (empty strings, nil, zero values, large inputs)
|
||||
3. **Test HTTP handlers** using `net/http/httptest` (no external dependencies)
|
||||
4. **Test database functions** using an in-memory SQLite database (`:memory:`) to keep tests isolated and fast
|
||||
5. **Test CSV parsing** with inline test data (no file I/O dependencies)
|
||||
6. **Ensure test quality**:
|
||||
- Tests must be deterministic and not rely on external state
|
||||
- Each test must be independently runnable
|
||||
- Use `t.Helper()` in helper functions
|
||||
- Use `t.Cleanup()` for resource teardown
|
||||
- Avoid `time.Sleep` – use channels or synchronization primitives if needed
|
||||
7. **Follow Go conventions**:
|
||||
- Test files named `*_test.go`
|
||||
- Test functions named `TestXxx`
|
||||
- Benchmark functions named `BenchmarkXxx` when performance matters
|
||||
- Use `t.Errorf` for non-fatal failures, `t.Fatalf` for fatal ones
|
||||
- No external testing frameworks (no testify, gomock, etc.) – use only stdlib
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the code to be tested carefully
|
||||
2. Identify all testable units (functions, methods, handlers)
|
||||
3. List test cases covering: success path, error path, edge cases
|
||||
4. Write the test file with clear, self-documenting test names
|
||||
5. Verify that the tests compile correctly by checking imports and types
|
||||
6. Self-review: ensure no test is trivially always-passing (e.g., `assert(1 == 1)`)
|
||||
7. Report: summarize what was tested and what coverage gaps remain
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide:
|
||||
1. The complete test file content (ready to save as `*_test.go`)
|
||||
2. A brief summary of what each test group covers
|
||||
3. Any noted gaps in testability (e.g., functions that need refactoring for testability) with concrete suggestions
|
||||
|
||||
## Constraints
|
||||
- No external dependencies – only Go stdlib
|
||||
- No CSS/JS/HTML testing – that is out of scope
|
||||
- Keep tests fast: prefer in-memory SQLite over file-based DB in tests
|
||||
- Tests must pass with `go test ./...` without any special setup
|
||||
|
||||
**Update your agent memory** as you discover patterns, common issues, and architectural decisions in the GoFinance codebase that affect how tests should be written. Record:
|
||||
- Which functions are already tested and which lack coverage
|
||||
- Patterns used for DB setup in tests (e.g., helper functions for schema creation)
|
||||
- Known edge cases in WKN extraction, CSV parsing, or API handlers
|
||||
- Any refactoring done to make code more testable
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent, file-based memory system found at: `/home/jacek/projekte/gofinance/.claude/agent-memory/tester/`
|
||||
|
||||
You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
|
||||
|
||||
If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
|
||||
|
||||
## Types of memory
|
||||
|
||||
There are several discrete types of memory that you can store in your memory system:
|
||||
|
||||
<types>
|
||||
<type>
|
||||
<name>user</name>
|
||||
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
|
||||
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
|
||||
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
|
||||
<examples>
|
||||
user: I'm a data scientist investigating what logging we have in place
|
||||
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
|
||||
|
||||
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
|
||||
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>feedback</name>
|
||||
<description>Guidance or correction the user has given you. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Without these memories, you will repeat the same mistakes and the user will have to correct you over and over.</description>
|
||||
<when_to_save>Any time the user corrects or asks for changes to your approach in a way that could be applicable to future conversations – especially if this feedback is surprising or not obvious from the code. These often take the form of "no not that, instead do...", "lets not...", "don't...". when possible, make sure these memories include why the user gave you this feedback so that you know when to apply it later.</when_to_save>
|
||||
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
|
||||
<examples>
|
||||
user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
|
||||
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
|
||||
|
||||
user: stop summarizing what you just did at the end of every response, I can read the diff
|
||||
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>project</name>
|
||||
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
|
||||
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
|
||||
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
|
||||
<examples>
|
||||
user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
|
||||
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
|
||||
|
||||
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
|
||||
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
|
||||
</examples>
|
||||
</type>
|
||||
<type>
|
||||
<name>reference</name>
|
||||
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
|
||||
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
|
||||
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
|
||||
<examples>
|
||||
user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
|
||||
assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
|
||||
|
||||
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
|
||||
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
|
||||
</examples>
|
||||
</type>
|
||||
</types>
|
||||
|
||||
## What NOT to save in memory
|
||||
|
||||
- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
|
||||
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
|
||||
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
|
||||
- Anything already documented in CLAUDE.md files.
|
||||
- Ephemeral task details: in-progress work, temporary state, current conversation context.
|
||||
|
||||
## How to save memories
|
||||
|
||||
Saving a memory is a two-step process:
|
||||
|
||||
**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: {{memory name}}
|
||||
description: {{one-line description — used to decide relevance in future conversations, so be specific}}
|
||||
type: {{user, feedback, project, reference}}
|
||||
---
|
||||
|
||||
{{memory content}}
|
||||
```
|
||||
|
||||
**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
|
||||
|
||||
- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
|
||||
- Keep the name, description, and type fields in memory files up-to-date with the content
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
|
||||
|
||||
## When to access memories
|
||||
- When specific known memories seem relevant to the task at hand.
|
||||
- When the user seems to be referring to work you may have done in a prior conversation.
|
||||
- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember.
|
||||
|
||||
## Memory and other forms of persistence
|
||||
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
|
||||
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
|
||||
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
|
||||
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you save new memories, they will appear here.
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Binary
|
||||
krafttrainer
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Frontend build
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
backend/static/*
|
||||
!backend/static/.gitkeep
|
||||
|
||||
# Go
|
||||
backend/vendor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
93
CLAUDE.md
Executable file
93
CLAUDE.md
Executable file
@@ -0,0 +1,93 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
# Krafttrainer
|
||||
|
||||
Einzelnutzer-Webapplikation zur Verwaltung und Protokollierung von Kraftübungen.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Go 1.22+ mit `net/http` stdlib Router, SQLite (`go-sqlite3`), `golang-migrate`
|
||||
- **Frontend:** React 19, Vite 8, TypeScript (strict), Tailwind CSS 4 (`@tailwindcss/vite`), Zustand, Recharts
|
||||
- **Paketmanager:** pnpm (Version in `packageManager` field fixiert)
|
||||
- **Produktion:** Single Binary via `embed` (Frontend in `backend/static/` eingebettet)
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
make dev-backend # Go-Server auf :8090
|
||||
make dev-frontend # Vite Dev-Server auf :5173 (Proxy → :8090)
|
||||
make build # Single Binary ./krafttrainer (Frontend-Build + Go-Build mit CGO_ENABLED=1)
|
||||
make clean # Binary + Dist-Ordner entfernen
|
||||
```
|
||||
|
||||
Frontend TypeScript-Typecheck (ohne Build):
|
||||
```bash
|
||||
cd frontend && pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
Es gibt keine automatisierten Tests (weder Go-Tests noch Frontend-Tests).
|
||||
|
||||
## Deployment
|
||||
|
||||
- Läuft auf `192.168.1.118:8090` als systemd-Service (`krafttrainer.service`)
|
||||
- User: `christoph`, Binary: `/home/christoph/krafttrainer/krafttrainer`
|
||||
- DB: `/home/christoph/krafttrainer/krafttrainer.db`
|
||||
- Deploy: `scp krafttrainer christoph@192.168.1.118:~/krafttrainer/`, dann `sudo systemctl restart krafttrainer`
|
||||
|
||||
## Architektur
|
||||
|
||||
### Backend-Flow
|
||||
|
||||
`Handler → Store → DB`. Jeder Handler folgt exakt diesem Muster:
|
||||
1. `decodeJSON()` mit `DisallowUnknownFields()`
|
||||
2. `model.Validate()` aufrufen
|
||||
3. Store-Methode aufrufen
|
||||
4. Fehler differenzieren und `writeJSON()` / `writeError()` aufrufen
|
||||
|
||||
Store-Methoden geben nach Mutationen immer **frisch aus der DB gelesene Objekte** zurück (kein Rekonstruieren aus Input).
|
||||
|
||||
### Fehlerbehandlung (Backend)
|
||||
|
||||
- Store-Fehler → 500 (generisch, kein DB-Leak)
|
||||
- Validierungsfehler → 400
|
||||
- Nicht gefunden (`sql.ErrNoRows`) → 404
|
||||
- UNIQUE-Verletzung → 409
|
||||
|
||||
Sentinel-Strings im Error-Message für Handler-Differenzierung: `"UNIQUE_VIOLATION:"`, `"SESSION_CLOSED"`. Diese werden mit `strings.Contains()` geprüft — kein custom error type.
|
||||
|
||||
### Routing
|
||||
|
||||
Go 1.22+ Pattern-Matching im stdlib ServeMux mit `{id}`-Platzhaltern. Middleware-Chain: `Recoverer → RequestLogger → CORS`. SPA-Fallback in `main.go`: prüft zuerst ob statische Datei existiert, serviert sonst `index.html`.
|
||||
|
||||
### Frontend-Stores (Zustand)
|
||||
|
||||
Stores sind **flach und unabhängig** — keine direkte Store-zu-Store-Kommunikation. Feedback läuft ausschließlich über `useToastStore.getState().addToast()`. `activeSessionStore` verwaltet einen Timer-Interval manuell (kein React-Effect-Cleanup — beim `stopTimer()` explizit clearen).
|
||||
|
||||
Alle HTTP-Aufrufe gehen über `src/api/client.ts`. `ApiError` (extends Error) hat `status`-Property für HTTP-Statuscodes.
|
||||
|
||||
### Datenbank
|
||||
|
||||
- SQLite mit WAL-Mode und Foreign Keys (via Connection-String-Parameter in `store.go`)
|
||||
- Migrations auto-run beim Start via embedded FS (`backend/migrations/embed.go`)
|
||||
- `exercise_name` in `session_logs` **denormalisiert** gespeichert (damit gelöschte Übungen historische Daten nicht verwaisen lassen)
|
||||
- UNIQUE-Constraint auf `(session_id, exercise_id, set_number)`
|
||||
- Soft-Delete bei Übungen via `deleted_at` Timestamp
|
||||
|
||||
## Konventionen
|
||||
|
||||
- **API Prefix:** Alle Endpoints unter `/api/v1`. Fehler als `{ "error": "..." }`.
|
||||
- **Gewichte:** Immer in kg. Feldnamen mit `_kg` Suffix (`weight_kg`, `weight_step_kg`).
|
||||
- **UI-Sprache:** Deutsch.
|
||||
- **Dark Mode:** Default. `bg-gray-950` Body, `bg-gray-900` Cards, `blue-500` Primary.
|
||||
- **Touch-Targets:** min 44×44px.
|
||||
|
||||
## Vollständige Spezifikation
|
||||
|
||||
Siehe `PRD.md` im übergeordneten Verzeichnis (`03_Projekte/fitness-pad/PRD.md`) für:
|
||||
- Vollständiges SQLite-Schema (Abschnitt 4.1)
|
||||
- Alle Go-Structs und TypeScript-Typen (Abschnitt 4.2–4.3)
|
||||
- Komplette API-Spezifikation mit Request/Response-Beispielen (Abschnitt 5)
|
||||
- Validierungsregeln (Abschnitt 6)
|
||||
- Frontend-Spezifikation und Komponentenverhalten (Abschnitt 7)
|
||||
18
Makefile
Executable file
18
Makefile
Executable file
@@ -0,0 +1,18 @@
|
||||
.PHONY: dev-backend dev-frontend build clean
|
||||
|
||||
dev-backend:
|
||||
cd backend && go run ./cmd/server
|
||||
|
||||
dev-frontend:
|
||||
cd frontend && pnpm dev
|
||||
|
||||
build:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
rm -rf backend/static/*
|
||||
cp -r frontend/dist/* backend/static/
|
||||
cd backend && CGO_ENABLED=1 go build -o ../krafttrainer ./cmd/server
|
||||
|
||||
clean:
|
||||
rm -f krafttrainer
|
||||
rm -rf frontend/dist
|
||||
find backend/static -not -name '.gitkeep' -not -path backend/static -delete 2>/dev/null || true
|
||||
67
backend/cmd/server/main.go
Executable file
67
backend/cmd/server/main.go
Executable file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"krafttrainer/internal/handler"
|
||||
mig "krafttrainer/internal/migrate"
|
||||
"krafttrainer/internal/store"
|
||||
"krafttrainer/migrations"
|
||||
"krafttrainer/static"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Datenbank initialisieren
|
||||
s, err := store.New("krafttrainer.db")
|
||||
if err != nil {
|
||||
log.Fatalf("Datenbank: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// Migrationen ausführen
|
||||
if err := mig.Run(s.DB(), migrations.FS); err != nil {
|
||||
log.Fatalf("Migrationen: %v", err)
|
||||
}
|
||||
log.Println("Migrationen erfolgreich")
|
||||
|
||||
// HTTP-Routen
|
||||
mux := http.NewServeMux()
|
||||
h := handler.New(s)
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
// SPA-Fallback: statische Dateien aus embed.FS servieren
|
||||
mux.Handle("/", spaHandler(static.FS))
|
||||
|
||||
// Middleware-Chain und Server starten
|
||||
srv := handler.Chain(mux, handler.Recoverer, handler.RequestLogger, handler.CORS)
|
||||
|
||||
log.Println("Server startet auf :8090")
|
||||
if err := http.ListenAndServe(":8090", srv); err != nil {
|
||||
log.Fatalf("Server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// spaHandler serviert statische Dateien und fällt auf index.html zurück (SPA-Routing).
|
||||
func spaHandler(embeddedFS fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(embeddedFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "index.html"
|
||||
} else if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
f, err := embeddedFS.Open(path)
|
||||
if err != nil {
|
||||
// SPA-Fallback: index.html für unbekannte Pfade
|
||||
r.URL.Path = "/"
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
1
backend/db/migrations/001_create_exercises.down.sql
Executable file
1
backend/db/migrations/001_create_exercises.down.sql
Executable file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS exercises;
|
||||
16
backend/db/migrations/001_create_exercises.up.sql
Executable file
16
backend/db/migrations/001_create_exercises.up.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE exercises (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 100),
|
||||
description TEXT DEFAULT '',
|
||||
muscle_group TEXT NOT NULL CHECK(muscle_group IN (
|
||||
'brust', 'ruecken', 'schultern', 'bizeps', 'trizeps',
|
||||
'beine', 'bauch', 'ganzkoerper', 'sonstiges'
|
||||
)),
|
||||
weight_step_kg REAL NOT NULL DEFAULT 2.5 CHECK(weight_step_kg > 0),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exercises_muscle_group ON exercises(muscle_group) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_exercises_deleted_at ON exercises(deleted_at);
|
||||
2
backend/db/migrations/002_create_training_sets.down.sql
Executable file
2
backend/db/migrations/002_create_training_sets.down.sql
Executable file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS set_exercises;
|
||||
DROP TABLE IF EXISTS training_sets;
|
||||
16
backend/db/migrations/002_create_training_sets.up.sql
Executable file
16
backend/db/migrations/002_create_training_sets.up.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE training_sets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 100),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE set_exercises (
|
||||
set_id INTEGER NOT NULL REFERENCES training_sets(id) ON DELETE CASCADE,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
position INTEGER NOT NULL CHECK(position >= 0),
|
||||
PRIMARY KEY (set_id, exercise_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_set_exercises_set_id ON set_exercises(set_id);
|
||||
2
backend/db/migrations/003_create_sessions.down.sql
Executable file
2
backend/db/migrations/003_create_sessions.down.sql
Executable file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS session_logs;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
25
backend/db/migrations/003_create_sessions.up.sql
Executable file
25
backend/db/migrations/003_create_sessions.up.sql
Executable file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
set_id INTEGER NOT NULL REFERENCES training_sets(id),
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME,
|
||||
note TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE session_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
exercise_name TEXT NOT NULL,
|
||||
set_number INTEGER NOT NULL CHECK(set_number >= 1),
|
||||
weight_kg REAL NOT NULL CHECK(weight_kg >= 0 AND weight_kg <= 999),
|
||||
reps INTEGER NOT NULL CHECK(reps >= 0 AND reps <= 999),
|
||||
note TEXT DEFAULT '',
|
||||
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, exercise_id, set_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_set_id ON sessions(set_id);
|
||||
CREATE INDEX idx_sessions_started_at ON sessions(started_at);
|
||||
CREATE INDEX idx_session_logs_session_id ON session_logs(session_id);
|
||||
CREATE INDEX idx_session_logs_exercise_id ON session_logs(exercise_id);
|
||||
8
backend/go.mod
Executable file
8
backend/go.mod
Executable file
@@ -0,0 +1,8 @@
|
||||
module krafttrainer
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
)
|
||||
4
backend/go.sum
Executable file
4
backend/go.sum
Executable file
@@ -0,0 +1,4 @@
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
86
backend/internal/handler/exercise.go
Executable file
86
backend/internal/handler/exercise.go
Executable file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||
muscleGroup := r.URL.Query().Get("muscle_group")
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
exercises, err := h.store.ListExercises(muscleGroup, query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exercises)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateExerciseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.CreateExercise(&req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, exercise)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateExerciseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.UpdateExercise(id, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung")
|
||||
return
|
||||
}
|
||||
if exercise == nil {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exercise)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteExercise(id)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen der Übung")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
83
backend/internal/handler/handler.go
Executable file
83
backend/internal/handler/handler.go
Executable file
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"krafttrainer/internal/store"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Handler bündelt alle HTTP-Handler und hält eine Referenz auf den Store.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// New erstellt einen neuen Handler.
|
||||
func New(store *store.Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
|
||||
// RegisterRoutes registriert alle API-Routen am ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Exercises
|
||||
mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises)
|
||||
mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise)
|
||||
mux.HandleFunc("PUT /api/v1/exercises/{id}", h.handleUpdateExercise)
|
||||
mux.HandleFunc("DELETE /api/v1/exercises/{id}", h.handleDeleteExercise)
|
||||
|
||||
// Training Sets
|
||||
mux.HandleFunc("GET /api/v1/sets", h.handleListSets)
|
||||
mux.HandleFunc("POST /api/v1/sets", h.handleCreateSet)
|
||||
mux.HandleFunc("PUT /api/v1/sets/{id}", h.handleUpdateSet)
|
||||
mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
|
||||
|
||||
// Sessions
|
||||
mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession)
|
||||
mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions)
|
||||
mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession)
|
||||
mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession)
|
||||
|
||||
// Session Logs
|
||||
mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog)
|
||||
mux.HandleFunc("PUT /api/v1/sessions/{id}/logs/{logId}", h.handleUpdateLog)
|
||||
mux.HandleFunc("DELETE /api/v1/sessions/{id}/logs/{logId}", h.handleDeleteLog)
|
||||
|
||||
// Stats
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/last-log", h.handleGetLastLog)
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/history", h.handleGetExerciseHistory)
|
||||
mux.HandleFunc("GET /api/v1/stats/overview", h.handleGetStatsOverview)
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, defaultVal int) int {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return n
|
||||
}
|
||||
52
backend/internal/handler/middleware.go
Executable file
52
backend/internal/handler/middleware.go
Executable file
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain wendet Middlewares in der angegebenen Reihenfolge an.
|
||||
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
h = middlewares[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// CORS erlaubt Cross-Origin-Requests vom Vite Dev-Server.
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequestLogger loggt eingehende Requests mit Dauer.
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// Recoverer fängt Panics in Handlern ab und gibt 500 zurück.
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Interner Serverfehler")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
190
backend/internal/handler/session.go
Executable file
190
backend/internal/handler/session.go
Executable file
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateSessionRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if req.SetID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "set_id ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.CreateSession(req.SetID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Starten der Session")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 20)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
|
||||
sessions, err := h.store.ListSessions(limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetSession(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeError(w, http.StatusNotFound, "Session nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
// Body ist optional
|
||||
decodeJSON(r, &body)
|
||||
|
||||
session, err := h.store.EndSession(id, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeError(w, http.StatusNotFound, "Session nicht gefunden oder bereits beendet")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateLogRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log, err := h.store.CreateLog(sessionID, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNIQUE_VIOLATION") {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Loggen des Satzes")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, log)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
logID, err := pathID(r, "logId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.UpdateLogRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log, err := h.store.UpdateLog(sessionID, logID, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Satzes")
|
||||
return
|
||||
}
|
||||
if log == nil {
|
||||
writeError(w, http.StatusNotFound, "Log nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, log)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
|
||||
return
|
||||
}
|
||||
logID, err := pathID(r, "logId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteLog(sessionID, logID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_CLOSED") {
|
||||
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
|
||||
return
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Log nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Satzes")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
47
backend/internal/handler/stats.go
Executable file
47
backend/internal/handler/stats.go
Executable file
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
lastLog, err := h.store.GetLastLog(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs")
|
||||
return
|
||||
}
|
||||
if lastLog == nil {
|
||||
writeError(w, http.StatusNotFound, "Noch kein Log für diese Übung")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lastLog)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
|
||||
logs, err := h.store.GetExerciseHistory(id, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, logs)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
overview, err := h.store.GetStatsOverview()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, overview)
|
||||
}
|
||||
92
backend/internal/handler/training_set.go
Executable file
92
backend/internal/handler/training_set.go
Executable file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
sets, err := h.store.ListSets()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sets)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateSetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.CreateSet(&req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Sets")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, set)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.UpdateSetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.UpdateSet(id, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Sets")
|
||||
return
|
||||
}
|
||||
if set == nil {
|
||||
writeError(w, http.StatusNotFound, "Set nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, set)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteSet(id)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Set nicht gefunden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Sets")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
36
backend/internal/migrate/migrate.go
Executable file
36
backend/internal/migrate/migrate.go
Executable file
@@ -0,0 +1,36 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
// Run führt alle ausstehenden Migrationen aus.
|
||||
// migrationsFS muss die Migrations-Dateien enthalten.
|
||||
func Run(db *sql.DB, migrationsFS fs.FS) error {
|
||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrations-Treiber erstellen: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationsFS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrations-Quelle erstellen: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migrator erstellen: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("Migrationen ausführen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
backend/internal/model/exercise.go
Executable file
47
backend/internal/model/exercise.go
Executable file
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exercise repräsentiert eine Kraftübung.
|
||||
type Exercise struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg float64 `json:"weight_step_kg"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateExerciseRequest enthält die Felder zum Anlegen einer Übung.
|
||||
type CreateExerciseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg *float64 `json:"weight_step_kg"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg.
|
||||
func (r *CreateExerciseRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if !ValidMuscleGroup(r.MuscleGroup) {
|
||||
return errors.New("Ungültige Muskelgruppe")
|
||||
}
|
||||
if r.WeightStepKg != nil {
|
||||
if *r.WeightStepKg <= 0 {
|
||||
return errors.New("Gewichtsschritt muss > 0 sein")
|
||||
}
|
||||
} else {
|
||||
def := 2.5
|
||||
r.WeightStepKg = &def
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
backend/internal/model/session.go
Executable file
19
backend/internal/model/session.go
Executable file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Session repräsentiert eine Trainingseinheit.
|
||||
type Session struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
SetName string `json:"set_name"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Note string `json:"note"`
|
||||
Logs []SessionLog `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSessionRequest enthält die Felder zum Starten einer Session.
|
||||
type CreateSessionRequest struct {
|
||||
SetID int64 `json:"set_id"`
|
||||
}
|
||||
76
backend/internal/model/session_log.go
Executable file
76
backend/internal/model/session_log.go
Executable file
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionLog repräsentiert einen einzelnen Satz innerhalb einer Session.
|
||||
type SessionLog struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID int64 `json:"session_id"`
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
ExerciseName string `json:"exercise_name"`
|
||||
SetNumber int `json:"set_number"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
Note string `json:"note"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
}
|
||||
|
||||
// CreateLogRequest enthält die Felder zum Loggen eines Satzes.
|
||||
type CreateLogRequest struct {
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
SetNumber int `json:"set_number"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Validate prüft den Request.
|
||||
func (r *CreateLogRequest) Validate() error {
|
||||
if r.SetNumber < 1 {
|
||||
return errors.New("Satznummer muss ≥ 1 sein")
|
||||
}
|
||||
if r.WeightKg < 0 || r.WeightKg > 999 {
|
||||
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
|
||||
}
|
||||
if r.Reps < 0 || r.Reps > 999 {
|
||||
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLogRequest enthält die Felder zum Korrigieren eines Satzes.
|
||||
type UpdateLogRequest struct {
|
||||
WeightKg *float64 `json:"weight_kg"`
|
||||
Reps *int `json:"reps"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
// Validate prüft den Request.
|
||||
func (r *UpdateLogRequest) Validate() error {
|
||||
if r.WeightKg != nil && (*r.WeightKg < 0 || *r.WeightKg > 999) {
|
||||
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
|
||||
}
|
||||
if r.Reps != nil && (*r.Reps < 0 || *r.Reps > 999) {
|
||||
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastLogResponse enthält die letzten Werte einer Übung.
|
||||
type LastLogResponse struct {
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
Reps int `json:"reps"`
|
||||
}
|
||||
|
||||
// ExerciseStats enthält aggregierte Statistiken einer Übung.
|
||||
type ExerciseStats struct {
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
ExerciseName string `json:"exercise_name"`
|
||||
MaxWeightKg float64 `json:"max_weight_kg"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
TotalSets int `json:"total_sets"`
|
||||
LastTrained string `json:"last_trained"`
|
||||
}
|
||||
52
backend/internal/model/training_set.go
Executable file
52
backend/internal/model/training_set.go
Executable file
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrainingSet ist eine benannte Zusammenstellung von Übungen.
|
||||
type TrainingSet struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSetRequest enthält die Felder zum Anlegen eines Sets.
|
||||
type CreateSetRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExerciseIDs []int64 `json:"exercise_ids"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request.
|
||||
func (r *CreateSetRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if len(r.ExerciseIDs) == 0 {
|
||||
return errors.New("Mindestens eine Übung erforderlich")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSetRequest enthält die Felder zum Aktualisieren eines Sets.
|
||||
type UpdateSetRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExerciseIDs []int64 `json:"exercise_ids"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request.
|
||||
func (r *UpdateSetRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 || len(r.Name) > 100 {
|
||||
return errors.New("Name muss 1–100 Zeichen lang sein")
|
||||
}
|
||||
if len(r.ExerciseIDs) == 0 {
|
||||
return errors.New("Mindestens eine Übung erforderlich")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
backend/internal/model/validation.go
Executable file
28
backend/internal/model/validation.go
Executable file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
// Gültige Muskelgruppen für Übungen.
|
||||
var muscleGroups = map[string]bool{
|
||||
"brust": true,
|
||||
"ruecken": true,
|
||||
"schultern": true,
|
||||
"bizeps": true,
|
||||
"trizeps": true,
|
||||
"beine": true,
|
||||
"bauch": true,
|
||||
"ganzkoerper": true,
|
||||
"sonstiges": true,
|
||||
}
|
||||
|
||||
// ValidMuscleGroup prüft ob die übergebene Muskelgruppe gültig ist.
|
||||
func ValidMuscleGroup(mg string) bool {
|
||||
return muscleGroups[mg]
|
||||
}
|
||||
|
||||
// MuscleGroups gibt alle gültigen Muskelgruppen zurück.
|
||||
func MuscleGroups() []string {
|
||||
groups := make([]string, 0, len(muscleGroups))
|
||||
for g := range muscleGroups {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
105
backend/internal/store/exercise_store.go
Executable file
105
backend/internal/store/exercise_store.go
Executable file
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert.
|
||||
func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at
|
||||
FROM exercises
|
||||
WHERE deleted_at IS NULL
|
||||
AND (muscle_group = ? OR ? = '')
|
||||
AND (name LIKE '%' || ? || '%' OR ? = '')
|
||||
ORDER BY name`,
|
||||
muscleGroup, muscleGroup, query, query,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungen abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var exercises []model.Exercise
|
||||
for rows.Next() {
|
||||
var e model.Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Übung scannen: %w", err)
|
||||
}
|
||||
exercises = append(exercises, e)
|
||||
}
|
||||
if exercises == nil {
|
||||
exercises = []model.Exercise{}
|
||||
}
|
||||
return exercises, rows.Err()
|
||||
}
|
||||
|
||||
// GetExercise gibt eine einzelne Übung zurück.
|
||||
func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
|
||||
var e model.Exercise
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at, deleted_at
|
||||
FROM exercises WHERE id = ?`, id,
|
||||
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung abfragen: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// CreateExercise legt eine neue Übung an und gibt sie zurück.
|
||||
func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO exercises (name, description, muscle_group, weight_step_kg)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.GetExercise(id)
|
||||
}
|
||||
|
||||
// UpdateExercise aktualisiert eine Übung und gibt sie zurück.
|
||||
func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung aktualisieren: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.GetExercise(id)
|
||||
}
|
||||
|
||||
// SoftDeleteExercise markiert eine Übung als gelöscht.
|
||||
func (s *Store) SoftDeleteExercise(id int64) error {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Übung löschen: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
269
backend/internal/store/session_store.go
Executable file
269
backend/internal/store/session_store.go
Executable file
@@ -0,0 +1,269 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateSession startet eine neue Trainingseinheit.
|
||||
func (s *Store) CreateSession(setID int64) (*model.Session, error) {
|
||||
// Set prüfen
|
||||
var setName string
|
||||
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND deleted_at IS NULL`, setID).Scan(&setName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Set %d existiert nicht", setID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`INSERT INTO sessions (set_id) VALUES (?)`, setID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// GetSession gibt eine Session mit allen Logs zurück.
|
||||
func (s *Store) GetSession(id int64) (*model.Session, error) {
|
||||
var sess model.Session
|
||||
err := s.db.QueryRow(`
|
||||
SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note
|
||||
FROM sessions s
|
||||
JOIN training_sets ts ON ts.id = s.set_id
|
||||
WHERE s.id = ?`, id,
|
||||
).Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session abfragen: %w", err)
|
||||
}
|
||||
|
||||
logs, err := s.getSessionLogs(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.Logs = logs
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// EndSession beendet eine Session.
|
||||
func (s *Store) EndSession(id int64, note string) (*model.Session, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, note = ?
|
||||
WHERE id = ? AND ended_at IS NULL`, note, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session beenden: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// ListSessions gibt paginierte Sessions zurück (neueste zuerst).
|
||||
func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note
|
||||
FROM sessions s
|
||||
JOIN training_sets ts ON ts.id = s.set_id
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT ? OFFSET ?`, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sessions abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []model.Session
|
||||
for rows.Next() {
|
||||
var sess model.Session
|
||||
if err := rows.Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note); err != nil {
|
||||
return nil, fmt.Errorf("Session scannen: %w", err)
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []model.Session{}
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLog fügt einen Satz zu einer offenen Session hinzu.
|
||||
func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) {
|
||||
// Session offen?
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Übungsname denormalisiert speichern
|
||||
var exerciseName string
|
||||
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", req.ExerciseID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung abfragen: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO session_logs (session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionID, req.ExerciseID, exerciseName, req.SetNumber, req.WeightKg, req.Reps, req.Note,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
return nil, fmt.Errorf("UNIQUE_VIOLATION: Satz %d für diese Übung existiert bereits", req.SetNumber)
|
||||
}
|
||||
return nil, fmt.Errorf("Log erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return s.getLog(id)
|
||||
}
|
||||
|
||||
// UpdateLog korrigiert einen Satz in einer offenen Session.
|
||||
func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (*model.SessionLog, error) {
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log gehört zur Session?
|
||||
var exists bool
|
||||
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM session_logs WHERE id = ? AND session_id = ?)`, logID, sessionID).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Partielle Updates
|
||||
updates := []string{}
|
||||
args := []any{}
|
||||
if req.WeightKg != nil {
|
||||
updates = append(updates, "weight_kg = ?")
|
||||
args = append(args, *req.WeightKg)
|
||||
}
|
||||
if req.Reps != nil {
|
||||
updates = append(updates, "reps = ?")
|
||||
args = append(args, *req.Reps)
|
||||
}
|
||||
if req.Note != nil {
|
||||
updates = append(updates, "note = ?")
|
||||
args = append(args, *req.Note)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return s.getLog(logID)
|
||||
}
|
||||
|
||||
args = append(args, logID)
|
||||
_, err = s.db.Exec(
|
||||
fmt.Sprintf("UPDATE session_logs SET %s WHERE id = ?", strings.Join(updates, ", ")),
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log aktualisieren: %w", err)
|
||||
}
|
||||
return s.getLog(logID)
|
||||
}
|
||||
|
||||
// DeleteLog löscht einen Satz aus einer offenen Session.
|
||||
func (s *Store) DeleteLog(sessionID, logID int64) error {
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`DELETE FROM session_logs WHERE id = ? AND session_id = ?`, logID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Log löschen: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastLog gibt die letzten Werte einer Übung zurück.
|
||||
func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) {
|
||||
var resp model.LastLogResponse
|
||||
err := s.db.QueryRow(`
|
||||
SELECT weight_kg, reps FROM session_logs
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY logged_at DESC LIMIT 1`, exerciseID,
|
||||
).Scan(&resp.WeightKg, &resp.Reps)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Letzten Log abfragen: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// checkSessionOpen prüft ob eine Session offen ist.
|
||||
func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||
var endedAt *string
|
||||
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("Session %d existiert nicht", sessionID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Session prüfen: %w", err)
|
||||
}
|
||||
if endedAt != nil {
|
||||
return fmt.Errorf("SESSION_CLOSED: Session ist bereits beendet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLog gibt einen einzelnen Log-Eintrag zurück.
|
||||
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||
var log model.SessionLog
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs WHERE id = ?`, id,
|
||||
).Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Log abfragen: %w", err)
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
|
||||
// getSessionLogs gibt alle Logs einer Session zurück.
|
||||
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs
|
||||
WHERE session_id = ?
|
||||
ORDER BY exercise_id, set_number`, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Logs abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []model.SessionLog
|
||||
for rows.Next() {
|
||||
var log model.SessionLog
|
||||
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
|
||||
return nil, fmt.Errorf("Log scannen: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.SessionLog{}
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
200
backend/internal/store/set_store.go
Executable file
200
backend/internal/store/set_store.go
Executable file
@@ -0,0 +1,200 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück.
|
||||
func (s *Store) ListSets() ([]model.TrainingSet, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, created_at FROM training_sets
|
||||
WHERE deleted_at IS NULL ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sets abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sets []model.TrainingSet
|
||||
for rows.Next() {
|
||||
var ts model.TrainingSet
|
||||
if err := rows.Scan(&ts.ID, &ts.Name, &ts.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Set scannen: %w", err)
|
||||
}
|
||||
sets = append(sets, ts)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sets == nil {
|
||||
sets = []model.TrainingSet{}
|
||||
}
|
||||
|
||||
for i := range sets {
|
||||
exercises, err := s.getSetExercises(sets[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sets[i].Exercises = exercises
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// GetSet gibt ein einzelnes Set mit Übungen zurück.
|
||||
func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
|
||||
var ts model.TrainingSet
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, name, created_at, deleted_at FROM training_sets WHERE id = ?`, id,
|
||||
).Scan(&ts.ID, &ts.Name, &ts.CreatedAt, &ts.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set abfragen: %w", err)
|
||||
}
|
||||
|
||||
exercises, err := s.getSetExercises(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts.Exercises = exercises
|
||||
return &ts, nil
|
||||
}
|
||||
|
||||
// CreateSet legt ein neues Set an (in einer Transaktion).
|
||||
func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Transaktion starten: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Prüfen ob alle Übungen existieren
|
||||
for _, eid := range req.ExerciseIDs {
|
||||
var exists bool
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set erstellen: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
for pos, eid := range req.ExerciseIDs {
|
||||
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("Transaktion committen: %w", err)
|
||||
}
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen).
|
||||
func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Transaktion starten: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Prüfen ob Set existiert
|
||||
var exists bool
|
||||
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND deleted_at IS NULL)`, id).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Prüfen ob alle Übungen existieren
|
||||
for _, eid := range req.ExerciseIDs {
|
||||
var eExists bool
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&eExists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
if !eExists {
|
||||
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE training_sets SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, req.Name, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set aktualisieren: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM set_exercises WHERE set_id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übungen löschen: %w", err)
|
||||
}
|
||||
|
||||
for pos, eid := range req.ExerciseIDs {
|
||||
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("Transaktion committen: %w", err)
|
||||
}
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// SoftDeleteSet markiert ein Set als gelöscht.
|
||||
func (s *Store) SoftDeleteSet(id int64) error {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE training_sets SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Set löschen: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSetExercises lädt die Übungen eines Sets sortiert nach Position.
|
||||
func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.created_at, e.updated_at
|
||||
FROM exercises e
|
||||
JOIN set_exercises se ON se.exercise_id = e.id
|
||||
WHERE se.set_id = ?
|
||||
ORDER BY se.position`, setID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übungen abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var exercises []model.Exercise
|
||||
for rows.Next() {
|
||||
var e model.Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Übung scannen: %w", err)
|
||||
}
|
||||
exercises = append(exercises, e)
|
||||
}
|
||||
if exercises == nil {
|
||||
exercises = []model.Exercise{}
|
||||
}
|
||||
return exercises, rows.Err()
|
||||
}
|
||||
85
backend/internal/store/stats_store.go
Executable file
85
backend/internal/store/stats_store.go
Executable file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// StatsOverview enthält die Gesamtübersicht.
|
||||
type StatsOverview struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
SessionsThisWeek int `json:"sessions_this_week"`
|
||||
Exercises []model.ExerciseStats `json:"exercises"`
|
||||
}
|
||||
|
||||
// GetStatsOverview gibt die Gesamtstatistik zurück.
|
||||
func (s *Store) GetStatsOverview() (*StatsOverview, error) {
|
||||
var overview StatsOverview
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL),
|
||||
(SELECT COALESCE(SUM(weight_kg * reps), 0) FROM session_logs),
|
||||
(SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL AND started_at >= date('now', '-7 days'))
|
||||
`).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übersicht abfragen: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
sl.exercise_id,
|
||||
sl.exercise_name,
|
||||
MAX(sl.weight_kg) as max_weight_kg,
|
||||
SUM(sl.weight_kg * sl.reps) as total_volume_kg,
|
||||
COUNT(*) as total_sets,
|
||||
MAX(sl.logged_at) as last_trained
|
||||
FROM session_logs sl
|
||||
GROUP BY sl.exercise_id
|
||||
ORDER BY last_trained DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var es model.ExerciseStats
|
||||
if err := rows.Scan(&es.ExerciseID, &es.ExerciseName, &es.MaxWeightKg, &es.TotalVolumeKg, &es.TotalSets, &es.LastTrained); err != nil {
|
||||
return nil, fmt.Errorf("Übungs-Stats scannen: %w", err)
|
||||
}
|
||||
overview.Exercises = append(overview.Exercises, es)
|
||||
}
|
||||
if overview.Exercises == nil {
|
||||
overview.Exercises = []model.ExerciseStats{}
|
||||
}
|
||||
return &overview, rows.Err()
|
||||
}
|
||||
|
||||
// GetExerciseHistory gibt die letzten N Logs einer Übung zurück.
|
||||
func (s *Store) GetExerciseHistory(exerciseID int64, limit int) ([]model.SessionLog, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
FROM session_logs
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY logged_at DESC
|
||||
LIMIT ?`, exerciseID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungshistorie abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []model.SessionLog
|
||||
for rows.Next() {
|
||||
var log model.SessionLog
|
||||
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
|
||||
return nil, fmt.Errorf("Log scannen: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.SessionLog{}
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
37
backend/internal/store/store.go
Executable file
37
backend/internal/store/store.go
Executable file
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Store kapselt den Datenbankzugriff.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New erstellt einen neuen Store und konfiguriert SQLite.
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datenbank öffnen: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("Datenbank-Verbindung prüfen: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// DB gibt die zugrundeliegende Datenbankverbindung zurück (für Migrations).
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Close schliesst die Datenbankverbindung.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
1
backend/migrations/001_create_exercises.down.sql
Executable file
1
backend/migrations/001_create_exercises.down.sql
Executable file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS exercises;
|
||||
16
backend/migrations/001_create_exercises.up.sql
Executable file
16
backend/migrations/001_create_exercises.up.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE exercises (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 100),
|
||||
description TEXT DEFAULT '',
|
||||
muscle_group TEXT NOT NULL CHECK(muscle_group IN (
|
||||
'brust', 'ruecken', 'schultern', 'bizeps', 'trizeps',
|
||||
'beine', 'bauch', 'ganzkoerper', 'sonstiges'
|
||||
)),
|
||||
weight_step_kg REAL NOT NULL DEFAULT 2.5 CHECK(weight_step_kg > 0),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exercises_muscle_group ON exercises(muscle_group) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_exercises_deleted_at ON exercises(deleted_at);
|
||||
2
backend/migrations/002_create_training_sets.down.sql
Executable file
2
backend/migrations/002_create_training_sets.down.sql
Executable file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS set_exercises;
|
||||
DROP TABLE IF EXISTS training_sets;
|
||||
16
backend/migrations/002_create_training_sets.up.sql
Executable file
16
backend/migrations/002_create_training_sets.up.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE training_sets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 100),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE set_exercises (
|
||||
set_id INTEGER NOT NULL REFERENCES training_sets(id) ON DELETE CASCADE,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
position INTEGER NOT NULL CHECK(position >= 0),
|
||||
PRIMARY KEY (set_id, exercise_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_set_exercises_set_id ON set_exercises(set_id);
|
||||
2
backend/migrations/003_create_sessions.down.sql
Executable file
2
backend/migrations/003_create_sessions.down.sql
Executable file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS session_logs;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
25
backend/migrations/003_create_sessions.up.sql
Executable file
25
backend/migrations/003_create_sessions.up.sql
Executable file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
set_id INTEGER NOT NULL REFERENCES training_sets(id),
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME,
|
||||
note TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE session_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
exercise_name TEXT NOT NULL,
|
||||
set_number INTEGER NOT NULL CHECK(set_number >= 1),
|
||||
weight_kg REAL NOT NULL CHECK(weight_kg >= 0 AND weight_kg <= 999),
|
||||
reps INTEGER NOT NULL CHECK(reps >= 0 AND reps <= 999),
|
||||
note TEXT DEFAULT '',
|
||||
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, exercise_id, set_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_set_id ON sessions(set_id);
|
||||
CREATE INDEX idx_sessions_started_at ON sessions(started_at);
|
||||
CREATE INDEX idx_session_logs_session_id ON session_logs(session_id);
|
||||
CREATE INDEX idx_session_logs_exercise_id ON session_logs(exercise_id);
|
||||
8
backend/migrations/embed.go
Executable file
8
backend/migrations/embed.go
Executable file
@@ -0,0 +1,8 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS enthält die eingebetteten SQL-Migrations-Dateien.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
0
backend/static/.gitkeep
Executable file
0
backend/static/.gitkeep
Executable file
9
backend/static/embed.go
Executable file
9
backend/static/embed.go
Executable file
@@ -0,0 +1,9 @@
|
||||
package static
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS enthält die eingebetteten Frontend-Dateien (nach vite build).
|
||||
// In Entwicklung ist dieser FS leer (nur .gitkeep).
|
||||
//
|
||||
//go:embed all:*
|
||||
var FS embed.FS
|
||||
12
frontend/index.html
Executable file
12
frontend/index.html
Executable file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Krafttrainer</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
frontend/package.json
Executable file
33
frontend/package.json
Executable file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"zustand": "^5.0.12"
|
||||
}
|
||||
}
|
||||
1299
frontend/pnpm-lock.yaml
generated
Executable file
1299
frontend/pnpm-lock.yaml
generated
Executable file
File diff suppressed because it is too large
Load Diff
22
frontend/src/App.tsx
Executable file
22
frontend/src/App.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { PageShell } from './components/layout/PageShell';
|
||||
import { ExercisesPage } from './pages/ExercisesPage';
|
||||
import { SetsPage } from './pages/SetsPage';
|
||||
import { TrainingPage } from './pages/TrainingPage';
|
||||
import { HistoryPage } from './pages/HistoryPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
{ path: '/', element: <ExercisesPage /> },
|
||||
{ path: '/sets', element: <SetsPage /> },
|
||||
{ path: '/training', element: <TrainingPage /> },
|
||||
{ path: '/history', element: <HistoryPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
178
frontend/src/api/client.ts
Executable file
178
frontend/src/api/client.ts
Executable file
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
Exercise,
|
||||
TrainingSet,
|
||||
Session,
|
||||
SessionLog,
|
||||
LastLogResponse,
|
||||
ExerciseStats,
|
||||
CreateExerciseRequest,
|
||||
CreateSetRequest,
|
||||
UpdateSetRequest,
|
||||
CreateSessionRequest,
|
||||
CreateLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from '../types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error || 'Unbekannter Fehler');
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
exercises: {
|
||||
list(muscleGroup?: string, q?: string): Promise<Exercise[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (muscleGroup) params.set('muscle_group', muscleGroup);
|
||||
if (q) params.set('q', q);
|
||||
const qs = params.toString();
|
||||
return request<Exercise[]>(`/api/v1/exercises${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
create(data: CreateExerciseRequest): Promise<Exercise> {
|
||||
return request<Exercise>('/api/v1/exercises', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update(id: number, data: CreateExerciseRequest): Promise<Exercise> {
|
||||
return request<Exercise>(`/api/v1/exercises/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/exercises/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
lastLog(id: number): Promise<LastLogResponse> {
|
||||
return request<LastLogResponse>(`/api/v1/exercises/${id}/last-log`);
|
||||
},
|
||||
|
||||
history(id: number, limit?: number): Promise<SessionLog[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return request<SessionLog[]>(
|
||||
`/api/v1/exercises/${id}/history${qs ? '?' + qs : ''}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
sets: {
|
||||
list(): Promise<TrainingSet[]> {
|
||||
return request<TrainingSet[]>('/api/v1/sets');
|
||||
},
|
||||
|
||||
create(data: CreateSetRequest): Promise<TrainingSet> {
|
||||
return request<TrainingSet>('/api/v1/sets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update(id: number, data: UpdateSetRequest): Promise<TrainingSet> {
|
||||
return request<TrainingSet>(`/api/v1/sets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/sets/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
sessions: {
|
||||
create(data: CreateSessionRequest): Promise<Session> {
|
||||
return request<Session>('/api/v1/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
list(limit?: number, offset?: number): Promise<Session[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (offset) params.set('offset', String(offset));
|
||||
const qs = params.toString();
|
||||
return request<Session[]>(`/api/v1/sessions${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
get(id: number): Promise<Session> {
|
||||
return request<Session>(`/api/v1/sessions/${id}`);
|
||||
},
|
||||
|
||||
end(id: number, note?: string): Promise<Session> {
|
||||
return request<Session>(`/api/v1/sessions/${id}/end`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ note: note || '' }),
|
||||
});
|
||||
},
|
||||
|
||||
createLog(sessionId: number, data: CreateLogRequest): Promise<SessionLog> {
|
||||
return request<SessionLog>(`/api/v1/sessions/${sessionId}/logs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
updateLog(
|
||||
sessionId: number,
|
||||
logId: number,
|
||||
data: UpdateLogRequest,
|
||||
): Promise<SessionLog> {
|
||||
return request<SessionLog>(
|
||||
`/api/v1/sessions/${sessionId}/logs/${logId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
deleteLog(sessionId: number, logId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/sessions/${sessionId}/logs/${logId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
stats: {
|
||||
overview(): Promise<ExerciseStats[]> {
|
||||
return request<ExerciseStats[]>('/api/v1/stats/overview');
|
||||
},
|
||||
},
|
||||
};
|
||||
54
frontend/src/components/exercises/ExerciseCard.tsx
Executable file
54
frontend/src/components/exercises/ExerciseCard.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import type { Exercise } from '../../types';
|
||||
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
|
||||
|
||||
interface ExerciseCardProps {
|
||||
exercise: Exercise;
|
||||
onEdit: (exercise: Exercise) => void;
|
||||
onDelete: (exercise: Exercise) => void;
|
||||
}
|
||||
|
||||
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
|
||||
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
|
||||
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-100 truncate">{exercise.name}</h3>
|
||||
{exercise.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Schritt: {exercise.weight_step_kg} kg
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(exercise)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(exercise)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/exercises/ExerciseForm.tsx
Executable file
116
frontend/src/components/exercises/ExerciseForm.tsx
Executable file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
|
||||
interface ExerciseFormProps {
|
||||
exercise?: Exercise | null;
|
||||
onSubmit: (data: CreateExerciseRequest) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
|
||||
const [weightStep, setWeightStep] = useState(2.5);
|
||||
|
||||
useEffect(() => {
|
||||
if (exercise) {
|
||||
setName(exercise.name);
|
||||
setDescription(exercise.description);
|
||||
setMuscleGroup(exercise.muscle_group);
|
||||
setWeightStep(exercise.weight_step_kg);
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setMuscleGroup('brust');
|
||||
setWeightStep(2.5);
|
||||
}
|
||||
}, [exercise]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
muscle_group: muscleGroup,
|
||||
weight_step_kg: weightStep,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{exercise ? 'Übung bearbeiten' : 'Neue Übung'}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
placeholder="Übungsname"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[80px] resize-y"
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Muskelgruppe *</label>
|
||||
<select
|
||||
value={muscleGroup}
|
||||
onChange={(e) => setMuscleGroup(e.target.value as MuscleGroup)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Gewichtsschritt (kg)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weightStep}
|
||||
onChange={(e) => setWeightStep(parseFloat(e.target.value) || 0)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{exercise ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/exercises/ExerciseList.tsx
Executable file
66
frontend/src/components/exercises/ExerciseList.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import { ExerciseCard } from './ExerciseCard';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
import type { Exercise, MuscleGroup } from '../../types';
|
||||
|
||||
interface ExerciseListProps {
|
||||
onEdit: (exercise: Exercise) => void;
|
||||
onDelete: (exercise: Exercise) => void;
|
||||
}
|
||||
|
||||
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
||||
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercises();
|
||||
}, [fetchExercises, filter.muscleGroup, filter.query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter-Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={filter.muscleGroup}
|
||||
onChange={(e) => setFilter({ muscleGroup: e.target.value as MuscleGroup | '' })}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
>
|
||||
<option value="">Alle Muskelgruppen</option>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.query}
|
||||
onChange={(e) => setFilter({ query: e.target.value })}
|
||||
placeholder="Suche..."
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : exercises.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Übungen angelegt</p>
|
||||
<p className="text-sm mt-1">Erstelle deine erste Übung mit dem Button oben.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/history/ExerciseChart.tsx
Executable file
113
frontend/src/components/history/ExerciseChart.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { api } from '../../api/client';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
export function ExerciseChart() {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [chartData, setChartData] = useState<{ date: string; weight: number }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercises();
|
||||
}, [fetchExercises]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setChartData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
api.exercises
|
||||
.history(selectedId, 50)
|
||||
.then((logs: SessionLog[]) => {
|
||||
// Gruppiere nach Datum, nehme max Gewicht pro Tag
|
||||
const byDate = new Map<string, number>();
|
||||
for (const log of logs) {
|
||||
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
const current = byDate.get(date) || 0;
|
||||
if (log.weight_kg > current) {
|
||||
byDate.set(date, log.weight_kg);
|
||||
}
|
||||
}
|
||||
const data = Array.from(byDate.entries())
|
||||
.map(([date, weight]) => ({ date, weight }))
|
||||
.reverse();
|
||||
setChartData(data);
|
||||
})
|
||||
.catch(() => setChartData([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedId]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">Gewichtsverlauf</h3>
|
||||
|
||||
<select
|
||||
value={selectedId ?? ''}
|
||||
onChange={(e) =>
|
||||
setSelectedId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-4"
|
||||
>
|
||||
<option value="">Übung auswählen...</option>
|
||||
{exercises.map((ex) => (
|
||||
<option key={ex.id} value={ex.id}>
|
||||
{ex.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{selectedId
|
||||
? 'Keine Daten vorhanden'
|
||||
: 'Wähle eine Übung aus'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" stroke="#9CA3AF" fontSize={12} />
|
||||
<YAxis stroke="#9CA3AF" fontSize={12} unit=" kg" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '8px',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value) => [`${value} kg`, 'Max. Gewicht']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="weight"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3B82F6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/history/SessionDetail.tsx
Executable file
48
frontend/src/components/history/SessionDetail.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
interface SessionDetailProps {
|
||||
logs: SessionLog[];
|
||||
}
|
||||
|
||||
export function SessionDetail({ logs }: SessionDetailProps) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
|
||||
}
|
||||
|
||||
// Gruppiere nach Übung
|
||||
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
|
||||
for (const log of logs) {
|
||||
if (!grouped.has(log.exercise_id)) {
|
||||
grouped.set(log.exercise_id, { name: log.exercise_name, logs: [] });
|
||||
}
|
||||
grouped.get(log.exercise_id)!.logs.push(log);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from(grouped.entries()).map(([exerciseId, { name, logs: exLogs }]) => (
|
||||
<div key={exerciseId}>
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-1">{name}</h4>
|
||||
<div className="space-y-1">
|
||||
{exLogs
|
||||
.sort((a, b) => a.set_number - b.set_number)
|
||||
.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800 rounded px-3 py-1.5"
|
||||
>
|
||||
<span className="text-gray-500 w-14">Satz {log.set_number}</span>
|
||||
<span className="font-medium text-gray-200">
|
||||
{log.weight_kg} kg x {log.reps}
|
||||
</span>
|
||||
{log.note && (
|
||||
<span className="text-gray-500 text-xs ml-auto">({log.note})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/history/SessionList.tsx
Executable file
107
frontend/src/components/history/SessionList.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useHistoryStore } from '../../stores/historyStore';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import type { Session } from '../../types';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, loading, fetchSessions } = useHistoryStore();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedSession, setExpandedSession] = useState<Session | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(50);
|
||||
}, [fetchSessions]);
|
||||
|
||||
const toggleSession = async (id: number) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
setExpandedSession(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(id);
|
||||
try {
|
||||
const session = await api.sessions.get(id);
|
||||
setExpandedSession(session);
|
||||
} catch {
|
||||
setExpandedSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (start: string, end?: string) => {
|
||||
if (!end) return 'laufend';
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 60) return `${mins} Min.`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Trainings absolviert</p>
|
||||
<p className="text-sm mt-1">Starte dein erstes Training im Training-Tab.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSession(session.id)}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">{session.set_name}</div>
|
||||
<div className="text-sm text-gray-400 mt-0.5">
|
||||
{formatDate(session.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400">
|
||||
{formatDuration(session.started_at, session.ended_at)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ml-auto ${expandedId === session.id ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{expandedId === session.id && expandedSession && (
|
||||
<div className="px-4 pb-4 border-t border-gray-800 pt-3">
|
||||
<SessionDetail logs={expandedSession.logs || []} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/layout/BottomNav.tsx
Executable file
91
frontend/src/components/layout/BottomNav.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Übungen',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/sets',
|
||||
label: 'Sets',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/training',
|
||||
label: 'Training',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/history',
|
||||
label: 'Historie',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
|
||||
<div className="flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center py-2 px-3 min-h-[44px] min-w-[44px] text-xs transition-colors ${
|
||||
isActive ? 'text-blue-500' : 'text-gray-400 hover:text-gray-200'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="mt-1">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<nav className="hidden md:flex flex-col w-56 bg-gray-900 border-r border-gray-800 min-h-screen p-4">
|
||||
<h1 className="text-xl font-bold text-blue-500 mb-8">Krafttrainer</h1>
|
||||
<div className="flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-3 rounded-lg min-h-[44px] transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/layout/ConfirmDialog.tsx
Executable file
40
frontend/src/components/layout/ConfirmDialog.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-2">{title}</h3>
|
||||
<p className="text-gray-300 mb-6">{message}</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/layout/PageShell.tsx
Executable file
18
frontend/src/components/layout/PageShell.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BottomNav, Sidebar } from './BottomNav';
|
||||
import { ToastContainer } from './Toast';
|
||||
|
||||
export function PageShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/layout/Toast.tsx
Executable file
33
frontend/src/components/layout/Toast.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
|
||||
const colorMap = {
|
||||
success: 'bg-green-700 border-green-500',
|
||||
error: 'bg-red-700 border-red-500',
|
||||
info: 'bg-blue-700 border-blue-500',
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${colorMap[toast.type]} border rounded-lg px-4 py-3 text-white shadow-lg flex items-center justify-between gap-2`}
|
||||
>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-white/70 hover:text-white min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/sets/SetDetail.tsx
Executable file
33
frontend/src/components/sets/SetDetail.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import type { TrainingSet } from '../../types';
|
||||
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
|
||||
|
||||
interface SetDetailProps {
|
||||
trainingSet: TrainingSet;
|
||||
}
|
||||
|
||||
export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-gray-100 text-lg mb-3">{trainingSet.name}</h3>
|
||||
{(!trainingSet.exercises || trainingSet.exercises.length === 0) ? (
|
||||
<p className="text-gray-500 text-sm">Keine Übungen in diesem Set.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trainingSet.exercises.map((ex, index) => {
|
||||
const label = MUSCLE_GROUP_LABELS[ex.muscle_group] || ex.muscle_group;
|
||||
const color = MUSCLE_GROUP_COLORS[ex.muscle_group] || 'bg-gray-600';
|
||||
return (
|
||||
<div key={ex.id} className="flex items-center gap-3 bg-gray-800 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-200">{ex.name}</span>
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/sets/SetForm.tsx
Executable file
193
frontend/src/components/sets/SetForm.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
import type { TrainingSet, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
|
||||
|
||||
interface SetFormProps {
|
||||
trainingSet?: TrainingSet | null;
|
||||
onSubmit: (name: string, exerciseIds: number[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Lade alle Übungen ohne Filter
|
||||
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
|
||||
fetchExercises();
|
||||
}, [fetchExercises]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trainingSet) {
|
||||
setName(trainingSet.name);
|
||||
setSelectedIds(trainingSet.exercises?.map((e) => e.id) || []);
|
||||
} else {
|
||||
setName('');
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [trainingSet]);
|
||||
|
||||
const toggleExercise = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index === 0) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index >= selectedIds.length - 1) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(name.trim(), selectedIds);
|
||||
};
|
||||
|
||||
const filteredExercises = filterMg
|
||||
? exercises.filter((e) => e.muscle_group === filterMg)
|
||||
: exercises;
|
||||
|
||||
const isValid = name.trim().length > 0 && selectedIds.length > 0;
|
||||
|
||||
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{trainingSet ? 'Set bearbeiten' : 'Neues Set'}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
placeholder="Set-Name"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Übungsauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
|
||||
<select
|
||||
value={filterMg}
|
||||
onChange={(e) => setFilterMg(e.target.value as MuscleGroup | '')}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-2"
|
||||
>
|
||||
<option value="">Alle Muskelgruppen</option>
|
||||
{MUSCLE_GROUPS.map((mg) => (
|
||||
<option key={mg.value} value={mg.value}>
|
||||
{mg.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 border border-gray-700 rounded-lg p-2">
|
||||
{filteredExercises.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-2 text-center">Keine Übungen gefunden</p>
|
||||
) : (
|
||||
filteredExercises.map((ex) => (
|
||||
<label
|
||||
key={ex.id}
|
||||
className="flex items-center gap-2 px-2 py-2 rounded hover:bg-gray-800 cursor-pointer min-h-[44px]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(ex.id)}
|
||||
onChange={() => toggleExercise(ex.id)}
|
||||
className="w-5 h-5 accent-blue-500"
|
||||
/>
|
||||
<span className="flex-1 text-gray-200">{ex.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{MUSCLE_GROUP_LABELS[ex.muscle_group]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sortierbare ausgewählte Übungen */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Reihenfolge ({selectedIds.length} ausgewählt)
|
||||
</label>
|
||||
<div className="space-y-1 border border-gray-700 rounded-lg p-2">
|
||||
{selectedIds.map((id, index) => {
|
||||
const ex = exerciseMap.get(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
|
||||
<span className="flex-1 text-gray-200 text-sm">
|
||||
{ex?.name || `Übung #${id}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDown(index)}
|
||||
disabled={index === selectedIds.length - 1}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{trainingSet ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/sets/SetList.tsx
Executable file
81
frontend/src/components/sets/SetList.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetStore } from '../../stores/setStore';
|
||||
import type { TrainingSet } from '../../types';
|
||||
|
||||
interface SetListProps {
|
||||
onEdit: (set: TrainingSet) => void;
|
||||
onDelete: (set: TrainingSet) => void;
|
||||
}
|
||||
|
||||
export function SetList({ onEdit, onDelete }: SetListProps) {
|
||||
const { sets, loading, fetchSets } = useSetStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets();
|
||||
}, [fetchSets]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Noch keine Sets angelegt</p>
|
||||
<p className="text-sm mt-1">Erstelle dein erstes Set mit dem Button oben.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{sets.map((set) => (
|
||||
<div
|
||||
key={set.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-100">{set.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{set.exercises?.length || 0} Übungen
|
||||
</p>
|
||||
{set.exercises && set.exercises.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{set.exercises.map((ex) => (
|
||||
<span
|
||||
key={ex.id}
|
||||
className="text-xs bg-gray-800 text-gray-300 px-2 py-1 rounded"
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(set)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(set)}
|
||||
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/training/ActiveSession.tsx
Executable file
224
frontend/src/components/training/ActiveSession.tsx
Executable file
@@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { ConfirmDialog } from '../layout/ConfirmDialog';
|
||||
import { LogEntryForm } from './LogEntryForm';
|
||||
import { RestTimer } from './RestTimer';
|
||||
import type { Exercise, SessionLog } from '../../types';
|
||||
|
||||
interface ActiveSessionProps {
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
|
||||
useActiveSessionStore();
|
||||
const [expandedExercise, setExpandedExercise] = useState<number | null>(null);
|
||||
const [editingLog, setEditingLog] = useState<SessionLog | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const logs = session.logs || [];
|
||||
|
||||
const getExerciseLogs = (exerciseId: number) =>
|
||||
logs
|
||||
.filter((l) => l.exercise_id === exerciseId)
|
||||
.sort((a, b) => a.set_number - b.set_number);
|
||||
|
||||
const getNextSetNumber = (exerciseId: number) => {
|
||||
const exLogs = getExerciseLogs(exerciseId);
|
||||
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
|
||||
};
|
||||
|
||||
const handleAddLog = async (
|
||||
exercise: Exercise,
|
||||
weight: number,
|
||||
reps: number,
|
||||
note: string,
|
||||
) => {
|
||||
await addLog({
|
||||
exercise_id: exercise.id,
|
||||
set_number: getNextSetNumber(exercise.id),
|
||||
weight_kg: weight,
|
||||
reps,
|
||||
note,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateLog = async (
|
||||
log: SessionLog,
|
||||
weight: number,
|
||||
reps: number,
|
||||
note: string,
|
||||
) => {
|
||||
await updateLog(log.id, { weight_kg: weight, reps, note });
|
||||
setEditingLog(null);
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (log: SessionLog) => {
|
||||
const ok = await confirm('Satz löschen', 'Möchtest du diesen Satz wirklich löschen?');
|
||||
if (ok) {
|
||||
await deleteLog(log.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async () => {
|
||||
const ok = await confirm(
|
||||
'Training beenden',
|
||||
'Möchtest du das Training wirklich beenden?',
|
||||
);
|
||||
if (ok) {
|
||||
const success = await endSession();
|
||||
if (success) onEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExercise = (id: number) => {
|
||||
setExpandedExercise((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-100">{session.set_name}</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{new Date(session.started_at).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RestTimer />
|
||||
|
||||
{/* Übungen als Accordion */}
|
||||
{exercises.map((exercise) => {
|
||||
const exLogs = getExerciseLogs(exercise.id);
|
||||
const isExpanded = expandedExercise === exercise.id;
|
||||
const lastLog = lastLogs.get(exercise.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={exercise.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => toggleExercise(exercise.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-100">{exercise.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Vorherige Werte */}
|
||||
{lastLog && (
|
||||
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
||||
Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bisherige Sätze */}
|
||||
{exLogs.map((log) => (
|
||||
<div key={log.id}>
|
||||
{editingLog?.id === log.id ? (
|
||||
<LogEntryForm
|
||||
exercise={exercise}
|
||||
setNumber={log.set_number}
|
||||
initialWeight={log.weight_kg}
|
||||
initialReps={log.reps}
|
||||
onSubmit={(w, r, n) => handleUpdateLog(log, w, r, n)}
|
||||
submitLabel="Aktualisieren"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div>
|
||||
<span className="text-gray-500 text-sm mr-2">
|
||||
Satz {log.set_number}:
|
||||
</span>
|
||||
<span className="font-semibold text-gray-100">
|
||||
{log.weight_kg} kg
|
||||
</span>
|
||||
<span className="text-gray-400 mx-1">x</span>
|
||||
<span className="font-semibold text-gray-100">
|
||||
{log.reps} Wdh.
|
||||
</span>
|
||||
{log.note && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({log.note})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setEditingLog(log)}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-blue-400"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log)}
|
||||
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-red-400"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */}
|
||||
<LogEntryForm
|
||||
key={`new-${exercise.id}-${exLogs.length}`}
|
||||
exercise={exercise}
|
||||
setNumber={getNextSetNumber(exercise.id)}
|
||||
initialWeight={exLogs.length > 0 ? exLogs[exLogs.length - 1].weight_kg : (lastLog?.weight_kg ?? 0)}
|
||||
initialReps={exLogs.length > 0 ? exLogs[exLogs.length - 1].reps : (lastLog?.reps ?? 0)}
|
||||
onSubmit={(w, r, n) => handleAddLog(exercise, w, r, n)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Training beenden */}
|
||||
<button
|
||||
onClick={handleEndSession}
|
||||
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"
|
||||
>
|
||||
Training beenden
|
||||
</button>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/training/LogEntryForm.tsx
Executable file
135
frontend/src/components/training/LogEntryForm.tsx
Executable file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import type { Exercise } from '../../types';
|
||||
|
||||
interface LogEntryFormProps {
|
||||
exercise: Exercise;
|
||||
setNumber: number;
|
||||
initialWeight?: number;
|
||||
initialReps?: number;
|
||||
onSubmit: (weight: number, reps: number, note: string) => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function LogEntryForm({
|
||||
exercise,
|
||||
setNumber,
|
||||
initialWeight = 0,
|
||||
initialReps = 0,
|
||||
onSubmit,
|
||||
submitLabel = 'Satz speichern',
|
||||
}: LogEntryFormProps) {
|
||||
const [weight, setWeight] = useState(initialWeight);
|
||||
const [reps, setReps] = useState(initialReps);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const step = exercise.weight_step_kg;
|
||||
|
||||
const adjustWeight = (delta: number) => {
|
||||
setWeight((w) => Math.max(0, Math.round((w + delta) * 100) / 100));
|
||||
};
|
||||
|
||||
const adjustReps = (delta: number) => {
|
||||
setReps((r) => Math.max(0, r + delta));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(weight, reps, note);
|
||||
setNote('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-400">Satz {setNumber}</div>
|
||||
|
||||
{/* Gewicht */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(-2 * step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
-{2 * step}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(-step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
-{step}
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(parseFloat(e.target.value) || 0)}
|
||||
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
min={0}
|
||||
step={step}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
+{step}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustWeight(2 * step)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
|
||||
>
|
||||
+{2 * step}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wiederholungen */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustReps(-1)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
|
||||
>
|
||||
-1
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={reps}
|
||||
onChange={(e) => setReps(parseInt(e.target.value) || 0)}
|
||||
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustReps(1)}
|
||||
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notiz */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Notiz (optional)"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg py-3 min-h-[44px]"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/training/RestTimer.tsx
Executable file
54
frontend/src/components/training/RestTimer.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||
|
||||
const PRESETS = [60, 90, 120, 180];
|
||||
|
||||
export function RestTimer() {
|
||||
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
|
||||
const isRunning = timerSeconds > 0;
|
||||
|
||||
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const mins = Math.floor(s / 60);
|
||||
const secs = s % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">Pause-Timer</h3>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-4xl font-bold text-blue-400 font-mono">
|
||||
{formatTime(timerSeconds)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-1000"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopTimer}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 min-h-[44px]"
|
||||
>
|
||||
Stopp
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{PRESETS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => startTimer(s)}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-200 min-h-[44px] min-w-[44px] font-mono"
|
||||
>
|
||||
{formatTime(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/hooks/useConfirm.ts
Executable file
43
frontend/src/hooks/useConfirm.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface ConfirmState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((title: string, message: string): Promise<boolean> => {
|
||||
setState({ isOpen: true, title, message });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setState({ isOpen: false, title: '', message: '' });
|
||||
resolveRef.current?.(true);
|
||||
resolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setState({ isOpen: false, title: '', message: '' });
|
||||
resolveRef.current?.(false);
|
||||
resolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
}
|
||||
26
frontend/src/hooks/useNavigationGuard.ts
Executable file
26
frontend/src/hooks/useNavigationGuard.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { useActiveSessionStore } from '../stores/activeSessionStore';
|
||||
|
||||
export function useNavigationGuard() {
|
||||
const session = useActiveSessionStore((s) => s.session);
|
||||
const hasActiveSession = session !== null && !session.ended_at;
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
hasActiveSession && currentLocation.pathname !== nextLocation.pathname,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveSession) return;
|
||||
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [hasActiveSession]);
|
||||
|
||||
return blocker;
|
||||
}
|
||||
1
frontend/src/index.css
Executable file
1
frontend/src/index.css
Executable file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
frontend/src/main.tsx
Executable file
10
frontend/src/main.tsx
Executable file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
87
frontend/src/pages/ExercisesPage.tsx
Executable file
87
frontend/src/pages/ExercisesPage.tsx
Executable file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { ExerciseList } from '../components/exercises/ExerciseList';
|
||||
import { ExerciseForm } from '../components/exercises/ExerciseForm';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
import { useExerciseStore } from '../stores/exerciseStore';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import type { Exercise, CreateExerciseRequest } from '../types';
|
||||
|
||||
export function ExercisesPage() {
|
||||
const { createExercise, updateExercise, deleteExercise } = useExerciseStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingExercise, setEditingExercise] = useState<Exercise | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
const handleCreate = async (data: CreateExerciseRequest) => {
|
||||
const result = await createExercise(data);
|
||||
if (result) {
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: CreateExerciseRequest) => {
|
||||
if (!editingExercise) return;
|
||||
const result = await updateExercise(editingExercise.id, data);
|
||||
if (result) {
|
||||
setEditingExercise(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (exercise: Exercise) => {
|
||||
setEditingExercise(exercise);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (exercise: Exercise) => {
|
||||
const ok = await confirm(
|
||||
'Übung löschen',
|
||||
`Möchtest du "${exercise.name}" wirklich löschen?`,
|
||||
);
|
||||
if (ok) {
|
||||
await deleteExercise(exercise.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingExercise(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Übungen</h1>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingExercise(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
|
||||
>
|
||||
+ Neue Übung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ExerciseForm
|
||||
exercise={editingExercise}
|
||||
onSubmit={editingExercise ? handleUpdate : handleCreate}
|
||||
onCancel={handleCancelForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExerciseList onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/pages/HistoryPage.tsx
Executable file
41
frontend/src/pages/HistoryPage.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { SessionList } from '../components/history/SessionList';
|
||||
import { ExerciseChart } from '../components/history/ExerciseChart';
|
||||
|
||||
type Tab = 'history' | 'stats';
|
||||
|
||||
export function HistoryPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('history');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Historie</h1>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex bg-gray-900 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Trainings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
|
||||
activeTab === 'stats'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Statistiken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'history' ? <SessionList /> : <ExerciseChart />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/SetsPage.tsx
Executable file
90
frontend/src/pages/SetsPage.tsx
Executable file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { SetList } from '../components/sets/SetList';
|
||||
import { SetForm } from '../components/sets/SetForm';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
import { useSetStore } from '../stores/setStore';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import type { TrainingSet } from '../types';
|
||||
|
||||
export function SetsPage() {
|
||||
const { createSet, updateSet, deleteSet } = useSetStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingSet, setEditingSet] = useState<TrainingSet | null>(null);
|
||||
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
|
||||
|
||||
const handleCreate = async (name: string, exerciseIds: number[]) => {
|
||||
const result = await createSet({ name, exercise_ids: exerciseIds });
|
||||
if (result) {
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (name: string, exerciseIds: number[]) => {
|
||||
if (!editingSet) return;
|
||||
const result = await updateSet(editingSet.id, {
|
||||
name,
|
||||
exercise_ids: exerciseIds,
|
||||
});
|
||||
if (result) {
|
||||
setEditingSet(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (set: TrainingSet) => {
|
||||
setEditingSet(set);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (set: TrainingSet) => {
|
||||
const ok = await confirm(
|
||||
'Set löschen',
|
||||
`Möchtest du "${set.name}" wirklich löschen?`,
|
||||
);
|
||||
if (ok) {
|
||||
await deleteSet(set.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingSet(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Training-Sets</h1>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSet(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
|
||||
>
|
||||
+ Neues Set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<SetForm
|
||||
trainingSet={editingSet}
|
||||
onSubmit={editingSet ? handleUpdate : handleCreate}
|
||||
onCancel={handleCancelForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SetList onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/TrainingPage.tsx
Executable file
107
frontend/src/pages/TrainingPage.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSetStore } from '../stores/setStore';
|
||||
import { useActiveSessionStore } from '../stores/activeSessionStore';
|
||||
import { useNavigationGuard } from '../hooks/useNavigationGuard';
|
||||
import { ActiveSession } from '../components/training/ActiveSession';
|
||||
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
|
||||
export function TrainingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { sets, fetchSets, loading } = useSetStore();
|
||||
const { session, startSession } = useActiveSessionStore();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const blocker = useNavigationGuard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets();
|
||||
}, [fetchSets]);
|
||||
|
||||
const handleStart = async (setId: number) => {
|
||||
const trainingSet = sets.find((s) => s.id === setId);
|
||||
if (!trainingSet) return;
|
||||
|
||||
setStarting(true);
|
||||
const success = await startSession(setId, trainingSet.exercises || []);
|
||||
setStarting(false);
|
||||
|
||||
if (!success) return;
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
navigate('/history');
|
||||
};
|
||||
|
||||
// Aktive Session anzeigen
|
||||
if (session) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ActiveSession onEnd={handleEnd} />
|
||||
|
||||
{/* Navigation Blocker Dialog */}
|
||||
{blocker.state === 'blocked' && (
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
title="Training verlassen?"
|
||||
message="Du hast ein aktives Training. Wenn du die Seite verlässt, kannst du später fortfahren. Möchtest du trotzdem navigieren?"
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
onCancel={() => blocker.reset?.()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Set-Auswahl
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Training starten</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : sets.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="text-lg">Keine Sets vorhanden</p>
|
||||
<p className="text-sm mt-1">Erstelle zuerst ein Set unter "Sets".</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{sets.map((set) => (
|
||||
<div
|
||||
key={set.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-100">{set.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
{set.exercises?.length || 0} Übungen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStart(set.id)}
|
||||
disabled={starting}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg min-h-[44px] font-medium disabled:opacity-50"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
{set.exercises && set.exercises.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{set.exercises.map((ex) => (
|
||||
<span
|
||||
key={ex.id}
|
||||
className="text-xs bg-gray-800 text-gray-400 px-2 py-1 rounded"
|
||||
>
|
||||
{ex.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/stores/activeSessionStore.ts
Executable file
181
frontend/src/stores/activeSessionStore.ts
Executable file
@@ -0,0 +1,181 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type {
|
||||
Session,
|
||||
Exercise,
|
||||
SessionLog,
|
||||
LastLogResponse,
|
||||
CreateLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface ActiveSessionState {
|
||||
session: Session | null;
|
||||
exercises: Exercise[];
|
||||
lastLogs: Map<number, LastLogResponse>;
|
||||
timerSeconds: number;
|
||||
timerTarget: number;
|
||||
timerInterval: ReturnType<typeof setInterval> | null;
|
||||
|
||||
startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>;
|
||||
loadSession: (sessionId: number) => Promise<boolean>;
|
||||
addLog: (data: CreateLogRequest) => Promise<SessionLog | null>;
|
||||
updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>;
|
||||
deleteLog: (logId: number) => Promise<boolean>;
|
||||
endSession: (note?: string) => Promise<boolean>;
|
||||
fetchLastLog: (exerciseId: number) => Promise<LastLogResponse | null>;
|
||||
copyLastValues: (exerciseId: number) => LastLogResponse | null;
|
||||
startTimer: (seconds: number) => void;
|
||||
stopTimer: () => void;
|
||||
clearSession: () => void;
|
||||
}
|
||||
|
||||
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
session: null,
|
||||
exercises: [],
|
||||
lastLogs: new Map(),
|
||||
timerSeconds: 0,
|
||||
timerTarget: 0,
|
||||
timerInterval: null,
|
||||
|
||||
startSession: async (setId, exercises) => {
|
||||
try {
|
||||
const session = await api.sessions.create({ set_id: setId });
|
||||
set({ session, exercises });
|
||||
|
||||
// Lade letzte Logs für alle Übungen
|
||||
for (const ex of exercises) {
|
||||
await get().fetchLastLog(ex.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Starten';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
loadSession: async (sessionId) => {
|
||||
try {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
set({ session });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
addLog: async (data) => {
|
||||
const { session } = get();
|
||||
if (!session) return null;
|
||||
try {
|
||||
const log = await api.sessions.createLog(session.id, data);
|
||||
// Reload session to get updated logs
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
return log;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Speichern';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateLog: async (logId, data) => {
|
||||
const { session } = get();
|
||||
if (!session) return null;
|
||||
try {
|
||||
const log = await api.sessions.updateLog(session.id, logId, data);
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
return log;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteLog: async (logId) => {
|
||||
const { session } = get();
|
||||
if (!session) return false;
|
||||
try {
|
||||
await api.sessions.deleteLog(session.id, logId);
|
||||
const updated = await api.sessions.get(session.id);
|
||||
set({ session: updated });
|
||||
useToastStore.getState().addToast('success', 'Satz gelöscht');
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
endSession: async (note) => {
|
||||
const { session } = get();
|
||||
if (!session) return false;
|
||||
try {
|
||||
await api.sessions.end(session.id, note);
|
||||
get().stopTimer();
|
||||
set({ session: null, exercises: [], lastLogs: new Map() });
|
||||
useToastStore.getState().addToast('success', 'Training beendet');
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Beenden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchLastLog: async (exerciseId) => {
|
||||
try {
|
||||
const lastLog = await api.exercises.lastLog(exerciseId);
|
||||
set((state) => {
|
||||
const newMap = new Map(state.lastLogs);
|
||||
newMap.set(exerciseId, lastLog);
|
||||
return { lastLogs: newMap };
|
||||
});
|
||||
return lastLog;
|
||||
} catch {
|
||||
// 404 = noch kein Log vorhanden
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
copyLastValues: (exerciseId) => {
|
||||
return get().lastLogs.get(exerciseId) || null;
|
||||
},
|
||||
|
||||
startTimer: (seconds) => {
|
||||
const { timerInterval } = get();
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
|
||||
set({ timerTarget: seconds, timerSeconds: seconds });
|
||||
const interval = setInterval(() => {
|
||||
const current = get().timerSeconds;
|
||||
if (current <= 1) {
|
||||
clearInterval(interval);
|
||||
set({ timerSeconds: 0, timerInterval: null });
|
||||
} else {
|
||||
set({ timerSeconds: current - 1 });
|
||||
}
|
||||
}, 1000);
|
||||
set({ timerInterval: interval });
|
||||
},
|
||||
|
||||
stopTimer: () => {
|
||||
const { timerInterval } = get();
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
set({ timerSeconds: 0, timerTarget: 0, timerInterval: null });
|
||||
},
|
||||
|
||||
clearSession: () => {
|
||||
get().stopTimer();
|
||||
set({ session: null, exercises: [], lastLogs: new Map() });
|
||||
},
|
||||
}));
|
||||
85
frontend/src/stores/exerciseStore.ts
Executable file
85
frontend/src/stores/exerciseStore.ts
Executable file
@@ -0,0 +1,85 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface ExerciseFilter {
|
||||
muscleGroup: MuscleGroup | '';
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface ExerciseState {
|
||||
exercises: Exercise[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filter: ExerciseFilter;
|
||||
fetchExercises: () => Promise<void>;
|
||||
createExercise: (data: CreateExerciseRequest) => Promise<Exercise | null>;
|
||||
updateExercise: (id: number, data: CreateExerciseRequest) => Promise<Exercise | null>;
|
||||
deleteExercise: (id: number) => Promise<boolean>;
|
||||
setFilter: (filter: Partial<ExerciseFilter>) => void;
|
||||
}
|
||||
|
||||
export const useExerciseStore = create<ExerciseState>((set, get) => ({
|
||||
exercises: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filter: { muscleGroup: '', query: '' },
|
||||
|
||||
fetchExercises: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { muscleGroup, query } = get().filter;
|
||||
const exercises = await api.exercises.list(muscleGroup || undefined, query || undefined);
|
||||
set({ exercises: exercises || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
set({ error: message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createExercise: async (data) => {
|
||||
try {
|
||||
const exercise = await api.exercises.create(data);
|
||||
useToastStore.getState().addToast('success', 'Übung erstellt');
|
||||
await get().fetchExercises();
|
||||
return exercise;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateExercise: async (id, data) => {
|
||||
try {
|
||||
const exercise = await api.exercises.update(id, data);
|
||||
useToastStore.getState().addToast('success', 'Übung aktualisiert');
|
||||
await get().fetchExercises();
|
||||
return exercise;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteExercise: async (id) => {
|
||||
try {
|
||||
await api.exercises.delete(id);
|
||||
useToastStore.getState().addToast('success', 'Übung gelöscht');
|
||||
await get().fetchExercises();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setFilter: (filter) => {
|
||||
set((state) => ({
|
||||
filter: { ...state.filter, ...filter },
|
||||
}));
|
||||
},
|
||||
}));
|
||||
51
frontend/src/stores/historyStore.ts
Executable file
51
frontend/src/stores/historyStore.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { Session, ExerciseStats } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface HistoryState {
|
||||
sessions: Session[];
|
||||
loading: boolean;
|
||||
stats: ExerciseStats[];
|
||||
fetchSessions: (limit?: number, offset?: number) => Promise<void>;
|
||||
fetchSession: (id: number) => Promise<Session | null>;
|
||||
fetchStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useHistoryStore = create<HistoryState>((set) => ({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
stats: [],
|
||||
|
||||
fetchSessions: async (limit, offset) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const sessions = await api.sessions.list(limit, offset);
|
||||
set({ sessions: sessions || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSession: async (id) => {
|
||||
try {
|
||||
return await api.sessions.get(id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
fetchStats: async () => {
|
||||
try {
|
||||
const stats = await api.stats.overview();
|
||||
set({ stats: stats || [] });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
70
frontend/src/stores/setStore.ts
Executable file
70
frontend/src/stores/setStore.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import type { TrainingSet, CreateSetRequest, UpdateSetRequest } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
interface SetState {
|
||||
sets: TrainingSet[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchSets: () => Promise<void>;
|
||||
createSet: (data: CreateSetRequest) => Promise<TrainingSet | null>;
|
||||
updateSet: (id: number, data: UpdateSetRequest) => Promise<TrainingSet | null>;
|
||||
deleteSet: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useSetStore = create<SetState>((set, get) => ({
|
||||
sets: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSets: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const sets = await api.sets.list();
|
||||
set({ sets: sets || [], loading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
|
||||
set({ error: message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createSet: async (data) => {
|
||||
try {
|
||||
const newSet = await api.sets.create(data);
|
||||
useToastStore.getState().addToast('success', 'Set erstellt');
|
||||
await get().fetchSets();
|
||||
return newSet;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateSet: async (id, data) => {
|
||||
try {
|
||||
const updated = await api.sets.update(id, data);
|
||||
useToastStore.getState().addToast('success', 'Set aktualisiert');
|
||||
await get().fetchSets();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteSet: async (id) => {
|
||||
try {
|
||||
await api.sets.delete(id);
|
||||
useToastStore.getState().addToast('success', 'Set gelöscht');
|
||||
await get().fetchSets();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
35
frontend/src/stores/toastStore.ts
Executable file
35
frontend/src/stores/toastStore.ts
Executable file
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (type: Toast['type'], message: string) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message) => {
|
||||
const id = crypto.randomUUID();
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id, type, message }],
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
136
frontend/src/types/index.ts
Executable file
136
frontend/src/types/index.ts
Executable file
@@ -0,0 +1,136 @@
|
||||
export type MuscleGroup =
|
||||
| 'brust'
|
||||
| 'ruecken'
|
||||
| 'schultern'
|
||||
| 'bizeps'
|
||||
| 'trizeps'
|
||||
| 'beine'
|
||||
| 'bauch'
|
||||
| 'ganzkoerper'
|
||||
| 'sonstiges';
|
||||
|
||||
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
||||
{ value: 'brust', label: 'Brust' },
|
||||
{ value: 'ruecken', label: 'Rücken' },
|
||||
{ value: 'schultern', label: 'Schultern' },
|
||||
{ value: 'bizeps', label: 'Bizeps' },
|
||||
{ value: 'trizeps', label: 'Trizeps' },
|
||||
{ value: 'beine', label: 'Beine' },
|
||||
{ value: 'bauch', label: 'Bauch' },
|
||||
{ value: 'ganzkoerper', label: 'Ganzkörper' },
|
||||
{ value: 'sonstiges', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
||||
brust: 'Brust',
|
||||
ruecken: 'Rücken',
|
||||
schultern: 'Schultern',
|
||||
bizeps: 'Bizeps',
|
||||
trizeps: 'Trizeps',
|
||||
beine: 'Beine',
|
||||
bauch: 'Bauch',
|
||||
ganzkoerper: 'Ganzkörper',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
||||
brust: 'bg-red-600',
|
||||
ruecken: 'bg-blue-600',
|
||||
schultern: 'bg-yellow-600',
|
||||
bizeps: 'bg-purple-600',
|
||||
trizeps: 'bg-pink-600',
|
||||
beine: 'bg-green-600',
|
||||
bauch: 'bg-orange-600',
|
||||
ganzkoerper: 'bg-teal-600',
|
||||
sonstiges: 'bg-gray-600',
|
||||
};
|
||||
|
||||
export interface Exercise {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export interface TrainingSet {
|
||||
id: number;
|
||||
name: string;
|
||||
exercises: Exercise[];
|
||||
created_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export interface SessionLog {
|
||||
id: number;
|
||||
session_id: number;
|
||||
exercise_id: number;
|
||||
exercise_name: string;
|
||||
set_number: number;
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
note: string;
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: number;
|
||||
set_id: number;
|
||||
set_name: string;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
note: string;
|
||||
logs?: SessionLog[];
|
||||
}
|
||||
|
||||
export interface LastLogResponse {
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
}
|
||||
|
||||
export interface ExerciseStats {
|
||||
exercise_id: number;
|
||||
exercise_name: string;
|
||||
max_weight_kg: number;
|
||||
total_volume_kg: number;
|
||||
total_sets: number;
|
||||
last_trained: string;
|
||||
}
|
||||
|
||||
export interface CreateExerciseRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg?: number;
|
||||
}
|
||||
|
||||
export interface CreateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
export interface UpdateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
set_id: number;
|
||||
}
|
||||
|
||||
export interface CreateLogRequest {
|
||||
exercise_id: number;
|
||||
set_number: number;
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface UpdateLogRequest {
|
||||
weight_kg?: number;
|
||||
reps?: number;
|
||||
note?: string;
|
||||
}
|
||||
21
frontend/tsconfig.json
Executable file
21
frontend/tsconfig.json
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
frontend/vite.config.ts
Executable file
12
frontend/vite.config.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user