init
This commit is contained in:
3
backend/.claude/agent-memory/tester/MEMORY.md
Normal file
3
backend/.claude/agent-memory/tester/MEMORY.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Tester Agent Memory
|
||||||
|
|
||||||
|
- [Test database setup pattern](feedback_test_db_setup.md) — store.New appends query params; use os.CreateTemp instead of ":memory:" URIs
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Test database setup pattern
|
||||||
|
description: How to create isolated test databases for store and handler tests in this project
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Use `os.CreateTemp` to create a temporary SQLite file for each test. Pass that file path to `store.New`.
|
||||||
|
|
||||||
|
**Why:** `store.New` always appends `?_journal_mode=WAL&_foreign_keys=ON` to the path argument. If you pass a URI like `file:name?mode=memory&cache=shared`, the result is a malformed DSN (`...?mode=memory&cache=shared?_journal_mode=WAL&...`) that SQLite rejects with "no such cache mode: shared".
|
||||||
|
|
||||||
|
**How to apply:** In every `newTestStore` / `newHandlerWithStore` helper, do:
|
||||||
|
```go
|
||||||
|
f, _ := os.CreateTemp("", "krafttrainer-test-*.db")
|
||||||
|
f.Close()
|
||||||
|
dbPath := f.Name()
|
||||||
|
s, err := store.New(dbPath)
|
||||||
|
t.Cleanup(func() { s.Close(); os.Remove(dbPath) })
|
||||||
|
```
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleListExercises behandelt GET /api/v1/exercises.
|
||||||
|
// Unterstützt optionale Query-Parameter: muscle_group und q (Namenssuche).
|
||||||
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -23,6 +25,7 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, exercises)
|
writeJSON(w, http.StatusOK, exercises)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateExercise behandelt POST /api/v1/exercises.
|
||||||
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,6 +51,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, exercise)
|
writeJSON(w, http.StatusCreated, exercise)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateExercise behandelt PUT /api/v1/exercises/{id}.
|
||||||
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,6 +86,8 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, exercise)
|
writeJSON(w, http.StatusOK, exercise)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteExercise behandelt DELETE /api/v1/exercises/{id}.
|
||||||
|
// Führt einen Soft-Delete durch (setzt deleted_at).
|
||||||
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,26 +61,32 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
|
|
||||||
// --- Hilfsfunktionen ---
|
// --- Hilfsfunktionen ---
|
||||||
|
|
||||||
|
// writeJSON schreibt data als JSON mit dem angegebenen HTTP-Statuscode.
|
||||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeError schreibt eine Fehlerantwort im Format {"error": "..."}.
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeJSON dekodiert den Request-Body als JSON in dst.
|
||||||
|
// Unbekannte Felder werden als Fehler behandelt.
|
||||||
func decodeJSON(r *http.Request, dst any) error {
|
func decodeJSON(r *http.Request, dst any) error {
|
||||||
dec := json.NewDecoder(r.Body)
|
dec := json.NewDecoder(r.Body)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
return dec.Decode(dst)
|
return dec.Decode(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathID liest einen Integer-Pfadparameter aus dem Request.
|
||||||
func pathID(r *http.Request, name string) (int64, error) {
|
func pathID(r *http.Request, name string) (int64, error) {
|
||||||
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryInt liest einen Integer-Query-Parameter. Gibt defaultVal zurück wenn der Parameter fehlt oder ungültig ist.
|
||||||
func queryInt(r *http.Request, name string, defaultVal int) int {
|
func queryInt(r *http.Request, name string, defaultVal int) int {
|
||||||
v := r.URL.Query().Get(name)
|
v := r.URL.Query().Get(name)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
uploadDir = "uploads"
|
uploadDir = "uploads"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleListImages behandelt GET /api/v1/exercises/{id}/images.
|
||||||
func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
||||||
exerciseID, err := pathID(r, "id")
|
exerciseID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,6 +31,9 @@ func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, images)
|
writeJSON(w, http.StatusOK, images)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUploadImage behandelt POST /api/v1/exercises/{id}/images.
|
||||||
|
// Erwartet multipart/form-data mit Feld "image" (nur JPEG, max 5 MB).
|
||||||
|
// Die Datei wird unter uploads/<uuid>.jpg gespeichert.
|
||||||
func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
||||||
exerciseID, err := pathID(r, "id")
|
exerciseID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,6 +103,8 @@ func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, img)
|
writeJSON(w, http.StatusCreated, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteImage behandelt DELETE /api/v1/exercises/{id}/images/{imageId}.
|
||||||
|
// Löscht den Datenbankeintrag und die zugehörige Datei vom Dateisystem.
|
||||||
func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||||
imageID, err := pathID(r, "imageId")
|
imageID, err := pathID(r, "imageId")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,8 +134,8 @@ func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) {
|
|||||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
|
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
|
||||||
http.FileServer(http.Dir(uploadDir))))
|
http.FileServer(http.Dir(uploadDir))))
|
||||||
|
|
||||||
// uploads-Verzeichnis sicherstellen
|
// uploads-Verzeichnis beim Start sicherstellen
|
||||||
os.MkdirAll(uploadDir, 0755)
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||||
|
log.Printf("Warnung: Upload-Verzeichnis konnte nicht erstellt werden: %v", err)
|
||||||
fmt.Println("Bild-Upload konfiguriert:", uploadDir)
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetActiveSession behandelt GET /api/v1/sessions/active.
|
||||||
|
// Gibt die offene Session des Nutzers mit den Übungen des zugehörigen Sets zurück,
|
||||||
|
// oder null wenn keine Session aktiv ist.
|
||||||
func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,6 +40,8 @@ func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateSession behandelt POST /api/v1/sessions.
|
||||||
|
// Gibt 409 zurück wenn bereits eine offene Session existiert.
|
||||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,6 +75,8 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, session)
|
writeJSON(w, http.StatusCreated, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListSessions behandelt GET /api/v1/sessions.
|
||||||
|
// Unterstützt Query-Parameter: limit (Standard: 20) und offset (Standard: 0).
|
||||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,6 +94,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, sessions)
|
writeJSON(w, http.StatusOK, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetSession behandelt GET /api/v1/sessions/{id}.
|
||||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r, "id")
|
id, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,6 +114,8 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, session)
|
writeJSON(w, http.StatusOK, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleEndSession behandelt PUT /api/v1/sessions/{id}/end.
|
||||||
|
// Beendet eine offene Session und speichert optional eine Notiz.
|
||||||
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,6 +177,9 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateLog behandelt POST /api/v1/sessions/{id}/logs.
|
||||||
|
// Fügt einen Satz zu einer offenen Session hinzu.
|
||||||
|
// Gibt 409 zurück bei doppelter (exercise_id, set_number)-Kombination.
|
||||||
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||||
sessionID, err := pathID(r, "id")
|
sessionID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -204,6 +217,8 @@ func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, log)
|
writeJSON(w, http.StatusCreated, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateLog behandelt PUT /api/v1/sessions/{id}/logs/{logId}.
|
||||||
|
// Alle Felder im Request sind optional (Partial Update).
|
||||||
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||||
sessionID, err := pathID(r, "id")
|
sessionID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,6 +257,7 @@ func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, log)
|
writeJSON(w, http.StatusOK, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteLog behandelt DELETE /api/v1/sessions/{id}/logs/{logId}.
|
||||||
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
||||||
sessionID, err := pathID(r, "id")
|
sessionID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package handler
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
|
// handleGetLastLog behandelt GET /api/v1/exercises/{id}/last-log.
|
||||||
|
// Gibt den zuletzt geloggten Satz der Übung zurück, oder 404 wenn noch kein Satz existiert.
|
||||||
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,6 +28,8 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, lastLog)
|
writeJSON(w, http.StatusOK, lastLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetExerciseHistory behandelt GET /api/v1/exercises/{id}/history.
|
||||||
|
// Unterstützt Query-Parameter: limit (Standard: 30).
|
||||||
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,6 +51,7 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, logs)
|
writeJSON(w, http.StatusOK, logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetStatsOverview behandelt GET /api/v1/stats/overview.
|
||||||
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleListSets behandelt GET /api/v1/sets.
|
||||||
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -22,6 +23,7 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, sets)
|
writeJSON(w, http.StatusOK, sets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateSet behandelt POST /api/v1/sets.
|
||||||
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,6 +53,8 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, set)
|
writeJSON(w, http.StatusCreated, set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateSet behandelt PUT /api/v1/sets/{id}.
|
||||||
|
// Ersetzt Name und Übungszuordnungen vollständig.
|
||||||
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +93,8 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, set)
|
writeJSON(w, http.StatusOK, set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteSet behandelt DELETE /api/v1/sets/{id}.
|
||||||
|
// Führt einen Soft-Delete durch (setzt deleted_at).
|
||||||
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleListUsers behandelt GET /api/v1/users.
|
||||||
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
users, err := h.store.ListUsers()
|
users, err := h.store.ListUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -16,6 +17,7 @@ func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, users)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCreateUser behandelt POST /api/v1/users.
|
||||||
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var req model.CreateUserRequest
|
var req model.CreateUserRequest
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
@@ -35,6 +37,8 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, user)
|
writeJSON(w, http.StatusCreated, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser behandelt DELETE /api/v1/users/{id}.
|
||||||
|
// Gibt 409 zurück wenn es sich um den letzten Nutzer handelt.
|
||||||
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r, "id")
|
id, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exercise_name wird denormalisiert gespeichert, damit historische Logs
|
||||||
|
// erhalten bleiben wenn die Übung später gelöscht wird.
|
||||||
var exerciseName string
|
var exerciseName string
|
||||||
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
|
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -197,6 +199,7 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update).
|
||||||
updates := []string{}
|
updates := []string{}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
if req.WeightKg != nil {
|
if req.WeightKg != nil {
|
||||||
@@ -293,7 +296,8 @@ func (s *Store) DeleteSession(id, userID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSessionOpen prüft ob eine Session offen ist.
|
// checkSessionOpen prüft ob eine Session existiert und noch nicht beendet wurde.
|
||||||
|
// Gibt einen Fehler mit "SESSION_CLOSED" zurück wenn ended_at bereits gesetzt ist.
|
||||||
func (s *Store) checkSessionOpen(sessionID int64) error {
|
func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||||
var endedAt *string
|
var endedAt *string
|
||||||
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
||||||
@@ -309,7 +313,7 @@ func (s *Store) checkSessionOpen(sessionID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLog gibt einen einzelnen Log-Eintrag zurück.
|
// getLog lädt einen einzelnen Session-Log-Eintrag anhand seiner ID.
|
||||||
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||||
var log model.SessionLog
|
var log model.SessionLog
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
@@ -325,7 +329,7 @@ func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
|||||||
return &log, nil
|
return &log, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSessionLogs gibt alle Logs einer Session zurück.
|
// getSessionLogs lädt alle Logs einer Session, sortiert nach Übung und Satznummer.
|
||||||
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
|
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ func (s *Store) SoftDeleteSet(id, userID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSetExercises lädt die Übungen eines Sets sortiert nach Position.
|
// getSetExercises lädt die nicht-gelöschten Übungen eines Sets, sortiert nach Position.
|
||||||
|
// Soft-gelöschte Übungen werden nicht zurückgegeben.
|
||||||
func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) {
|
func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) {
|
||||||
rows, err := s.db.Query(`
|
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
|
SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.created_at, e.updated_at
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"krafttrainer/internal/model"
|
"krafttrainer/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatsOverview enthält die Gesamtübersicht.
|
// StatsOverview enthält aggregierte Trainingsdaten eines Nutzers.
|
||||||
type StatsOverview struct {
|
type StatsOverview struct {
|
||||||
TotalSessions int `json:"total_sessions"`
|
TotalSessions int `json:"total_sessions"`
|
||||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (s *Store) DeleteUser(id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getUser lädt einen Nutzer anhand seiner ID.
|
||||||
func (s *Store) getUser(id int64) (*model.User, error) {
|
func (s *Store) getUser(id int64) (*model.User, error) {
|
||||||
var u model.User
|
var u model.User
|
||||||
err := s.db.QueryRow(`SELECT id, name, created_at FROM users WHERE id = ?`, id).
|
err := s.db.QueryRow(`SELECT id, name, created_at FROM users WHERE id = ?`, id).
|
||||||
|
|||||||
BIN
backend/server
BIN
backend/server
Binary file not shown.
@@ -16,6 +16,11 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { getActiveUserId } from '../stores/userStore';
|
import { getActiveUserId } from '../stores/userStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerklasse für HTTP-Antworten mit Nicht-2xx-Statuscodes.
|
||||||
|
* Die `status`-Property enthält den HTTP-Statuscode für differenzierte
|
||||||
|
* Fehlerbehandlung im aufrufenden Code.
|
||||||
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
@@ -26,6 +31,11 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Hilfsfunktion für alle API-Aufrufe.
|
||||||
|
* Setzt automatisch den `Content-Type`- und `X-User-ID`-Header
|
||||||
|
* und wirft bei Nicht-2xx-Antworten eine `ApiError`.
|
||||||
|
*/
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
@@ -52,6 +62,7 @@ async function request<T>(
|
|||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Typisiertes API-Objekt mit allen Backend-Endpunkten unter `/api/v1`. */
|
||||||
export const api = {
|
export const api = {
|
||||||
users: {
|
users: {
|
||||||
list(): Promise<User[]> {
|
list(): Promise<User[]> {
|
||||||
@@ -105,6 +116,10 @@ export const api = {
|
|||||||
return request<ExerciseImage[]>(`/api/v1/exercises/${id}/images`);
|
return request<ExerciseImage[]>(`/api/v1/exercises/${id}/images`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein Bild für eine Übung hoch (Multipart-Upload).
|
||||||
|
* Verwendet keinen JSON-Header, da `FormData` den Content-Type selbst setzt.
|
||||||
|
*/
|
||||||
async uploadImage(id: number, file: File): Promise<ExerciseImage> {
|
async uploadImage(id: number, file: File): Promise<ExerciseImage> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ interface ExerciseCardProps {
|
|||||||
onDelete: (exercise: Exercise) => void;
|
onDelete: (exercise: Exercise) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt eine einzelne Übung als Karte mit Muskelgruppen-Badge,
|
||||||
|
* Gewichtsschritt und Aktions-Buttons für Bearbeiten und Löschen.
|
||||||
|
*/
|
||||||
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
|
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
|
||||||
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
|
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
|
||||||
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
|
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { MUSCLE_GROUPS } from '../../types';
|
|||||||
import { ImageGallery } from './ImageGallery';
|
import { ImageGallery } from './ImageGallery';
|
||||||
|
|
||||||
interface ExerciseFormProps {
|
interface ExerciseFormProps {
|
||||||
|
/** Vorhandene Übung beim Bearbeiten; `null`/`undefined` beim Erstellen. */
|
||||||
exercise?: Exercise | null;
|
exercise?: Exercise | null;
|
||||||
onSubmit: (data: CreateExerciseRequest) => void;
|
onSubmit: (data: CreateExerciseRequest) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular zum Erstellen und Bearbeiten einer Übung.
|
||||||
|
* Im Bearbeitungsmodus werden die vorhandenen Werte automatisch befüllt
|
||||||
|
* und die Bildergalerie der Übung angezeigt.
|
||||||
|
*/
|
||||||
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
|
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ interface ExerciseListProps {
|
|||||||
onDelete: (exercise: Exercise) => void;
|
onDelete: (exercise: Exercise) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt alle Übungen als gefilterte Liste.
|
||||||
|
* Filter nach Muskelgruppe und Freitext werden direkt im Store verwaltet
|
||||||
|
* und lösen automatisch einen Neuladevorgang aus.
|
||||||
|
*/
|
||||||
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
||||||
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
|
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
|
||||||
|
|
||||||
@@ -18,7 +23,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Filter-Bar */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<select
|
<select
|
||||||
value={filter.muscleGroup}
|
value={filter.muscleGroup}
|
||||||
@@ -41,7 +45,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Liste */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||||
) : exercises.length === 0 ? (
|
) : exercises.length === 0 ? (
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ interface ImageGalleryProps {
|
|||||||
exerciseId: number;
|
exerciseId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildergalerie für eine Übung mit Upload- und Löschfunktion.
|
||||||
|
* Akzeptiert ausschließlich JPG-Dateien bis 5 MB.
|
||||||
|
* Ein Klick auf ein Bild öffnet eine Vollbildvorschau.
|
||||||
|
*/
|
||||||
export function ImageGallery({ exerciseId }: ImageGalleryProps) {
|
export function ImageGallery({ exerciseId }: ImageGalleryProps) {
|
||||||
const [images, setImages] = useState<ExerciseImage[]>([]);
|
const [images, setImages] = useState<ExerciseImage[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -22,7 +27,7 @@ export function ImageGallery({ exerciseId }: ImageGalleryProps) {
|
|||||||
const imgs = await api.exercises.listImages(exerciseId);
|
const imgs = await api.exercises.listImages(exerciseId);
|
||||||
setImages(imgs || []);
|
setImages(imgs || []);
|
||||||
} catch {
|
} catch {
|
||||||
// Fehler ignorieren
|
// Fehler ignorieren – Bilder sind optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { api } from '../../api/client';
|
|||||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||||
import type { SessionLog } from '../../types';
|
import type { SessionLog } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den Gewichtsverlauf einer ausgewählten Übung als Liniendiagramm.
|
||||||
|
* Pro Trainingstag wird das jeweils höchste verwendete Gewicht dargestellt.
|
||||||
|
*/
|
||||||
export function ExerciseChart() {
|
export function ExerciseChart() {
|
||||||
const { exercises, fetchExercises } = useExerciseStore();
|
const { exercises, fetchExercises } = useExerciseStore();
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
@@ -32,7 +36,7 @@ export function ExerciseChart() {
|
|||||||
api.exercises
|
api.exercises
|
||||||
.history(selectedId, 50)
|
.history(selectedId, 50)
|
||||||
.then((logs: SessionLog[]) => {
|
.then((logs: SessionLog[]) => {
|
||||||
// Gruppiere nach Datum, nehme max Gewicht pro Tag
|
// Pro Datum das Maximalgewicht ermitteln
|
||||||
const byDate = new Map<string, number>();
|
const byDate = new Map<string, number>();
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
|
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ interface SessionDetailProps {
|
|||||||
logs: SessionLog[];
|
logs: SessionLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt alle protokollierten Sätze einer Session gruppiert nach Übung.
|
||||||
|
* Innerhalb jeder Übung sind die Sätze aufsteigend nach Satznummer sortiert.
|
||||||
|
*/
|
||||||
export function SessionDetail({ logs }: SessionDetailProps) {
|
export function SessionDetail({ logs }: SessionDetailProps) {
|
||||||
if (!logs || logs.length === 0) {
|
if (!logs || logs.length === 0) {
|
||||||
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
|
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gruppiere nach Übung
|
// Sätze nach Übungs-ID gruppieren
|
||||||
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
|
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
if (!grouped.has(log.exercise_id)) {
|
if (!grouped.has(log.exercise_id)) {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import type { Session } from '../../types';
|
|||||||
import { api, ApiError } from '../../api/client';
|
import { api, ApiError } from '../../api/client';
|
||||||
import { useToastStore } from '../../stores/toastStore';
|
import { useToastStore } from '../../stores/toastStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste abgeschlossener Trainings-Sessions als aufklappbares Accordion.
|
||||||
|
* Beim Aufklappen einer Session werden deren Sätze per API nachgeladen.
|
||||||
|
* Laufende Sessions (ohne `ended_at`) können nicht gelöscht werden.
|
||||||
|
*/
|
||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { sessions, loading, fetchSessions } = useHistoryStore();
|
const { sessions, loading, fetchSessions } = useHistoryStore();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
@@ -30,6 +35,7 @@ export function SessionList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Formatiert ein ISO-Datum als lokalisiertes Datum mit Uhrzeit. */
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -40,6 +46,17 @@ export function SessionList() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Berechnet Trainingsdauer als lesbaren String oder "laufend". */
|
||||||
|
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`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent, id: number) => {
|
const handleDelete = async (e: React.MouseEvent, id: number) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirm('Session wirklich löschen? Alle Sätze werden unwiderruflich entfernt.')) return;
|
if (!confirm('Session wirklich löschen? Alle Sätze werden unwiderruflich entfernt.')) return;
|
||||||
@@ -48,7 +65,7 @@ export function SessionList() {
|
|||||||
try {
|
try {
|
||||||
await api.sessions.delete(id);
|
await api.sessions.delete(id);
|
||||||
useToastStore.getState().addToast('success', 'Session erfolgreich gelöscht');
|
useToastStore.getState().addToast('success', 'Session erfolgreich gelöscht');
|
||||||
// Aufgeklappte Session schließen falls sie gerade gelöscht wurde
|
// Aufgeklappte Session schließen, falls sie gerade gelöscht wurde
|
||||||
if (expandedId === id) {
|
if (expandedId === id) {
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
setExpandedSession(null);
|
setExpandedSession(null);
|
||||||
@@ -65,16 +82,6 @@ export function SessionList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
if (loading) {
|
||||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const navItems = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Mobile Bottom-Navigation mit Links zu allen Hauptseiten. */
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
return (
|
return (
|
||||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
|
||||||
@@ -74,6 +75,7 @@ export function BottomNav() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Desktop-Seitenleiste mit Navigation und aktivem Nutzer-Indikator. */
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { activeUser } = useUserStore();
|
const { activeUser } = useUserStore();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ interface SetDetailProps {
|
|||||||
trainingSet: TrainingSet;
|
trainingSet: TrainingSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Übungsliste eines Trainings-Sets mit Muskelgruppen-Badges.
|
||||||
|
* Wird in der Set-Übersicht als Detailansicht verwendet.
|
||||||
|
*/
|
||||||
export function SetDetail({ trainingSet }: SetDetailProps) {
|
export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import type { TrainingSet, MuscleGroup } from '../../types';
|
|||||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
|
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
|
||||||
|
|
||||||
interface SetFormProps {
|
interface SetFormProps {
|
||||||
|
/** Vorhandenes Set beim Bearbeiten; `null`/`undefined` beim Erstellen. */
|
||||||
trainingSet?: TrainingSet | null;
|
trainingSet?: TrainingSet | null;
|
||||||
onSubmit: (name: string, exerciseIds: number[]) => void;
|
onSubmit: (name: string, exerciseIds: number[]) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular zum Erstellen und Bearbeiten eines Trainings-Sets.
|
||||||
|
* Übungen können nach Muskelgruppe gefiltert und per Checkbox ausgewählt werden.
|
||||||
|
* Die Reihenfolge der ausgewählten Übungen ist per Pfeil-Buttons sortierbar.
|
||||||
|
*/
|
||||||
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||||
const { exercises, fetchExercises } = useExerciseStore();
|
const { exercises, fetchExercises } = useExerciseStore();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -16,7 +22,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
|||||||
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
|
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Lade alle Übungen ohne Filter
|
// Alle Übungen ohne aktiven Filter laden
|
||||||
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
|
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
|
||||||
fetchExercises();
|
fetchExercises();
|
||||||
}, [fetchExercises]);
|
}, [fetchExercises]);
|
||||||
@@ -66,6 +72,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
|||||||
|
|
||||||
const isValid = name.trim().length > 0 && selectedIds.length > 0;
|
const isValid = name.trim().length > 0 && selectedIds.length > 0;
|
||||||
|
|
||||||
|
/** Map für effizienten Zugriff auf Übungsnamen in der Reihenfolge-Ansicht. */
|
||||||
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
|
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,7 +93,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Übungsauswahl */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
|
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
|
||||||
<select
|
<select
|
||||||
@@ -127,7 +133,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sortierbare ausgewählte Übungen */}
|
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1">
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ interface SetListProps {
|
|||||||
onDelete: (set: TrainingSet) => void;
|
onDelete: (set: TrainingSet) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listet alle Trainings-Sets mit ihren Übungen sowie Bearbeiten-
|
||||||
|
* und Löschen-Aktionen auf.
|
||||||
|
*/
|
||||||
export function SetList({ onEdit, onDelete }: SetListProps) {
|
export function SetList({ onEdit, onDelete }: SetListProps) {
|
||||||
const { sets, loading, fetchSets } = useSetStore();
|
const { sets, loading, fetchSets } = useSetStore();
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ import { ExerciseSparkline } from './ExerciseSparkline';
|
|||||||
import type { Exercise, SessionLog } from '../../types';
|
import type { Exercise, SessionLog } from '../../types';
|
||||||
|
|
||||||
interface ActiveSessionProps {
|
interface ActiveSessionProps {
|
||||||
|
/** Wird aufgerufen, nachdem das Training erfolgreich beendet wurde. */
|
||||||
onEnd: () => void;
|
onEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauptansicht einer laufenden Trainings-Session.
|
||||||
|
* Zeigt alle Übungen des Sets als aufklappbares Accordion mit
|
||||||
|
* Fortschritts-Sparkline, letzten Trainingswerten und Satz-Eingabeformular.
|
||||||
|
*/
|
||||||
export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||||
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
|
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
|
||||||
useActiveSessionStore();
|
useActiveSessionStore();
|
||||||
@@ -22,11 +28,13 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
|
|
||||||
const logs = session.logs || [];
|
const logs = session.logs || [];
|
||||||
|
|
||||||
|
/** Gibt alle protokollierten Sätze für eine Übung sortiert nach Satznummer zurück. */
|
||||||
const getExerciseLogs = (exerciseId: number) =>
|
const getExerciseLogs = (exerciseId: number) =>
|
||||||
logs
|
logs
|
||||||
.filter((l) => l.exercise_id === exerciseId)
|
.filter((l) => l.exercise_id === exerciseId)
|
||||||
.sort((a, b) => a.set_number - b.set_number);
|
.sort((a, b) => a.set_number - b.set_number);
|
||||||
|
|
||||||
|
/** Berechnet die nächste Satznummer als Maximum der bisherigen + 1. */
|
||||||
const getNextSetNumber = (exerciseId: number) => {
|
const getNextSetNumber = (exerciseId: number) => {
|
||||||
const exLogs = getExerciseLogs(exerciseId);
|
const exLogs = getExerciseLogs(exerciseId);
|
||||||
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
|
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
|
||||||
@@ -93,7 +101,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
|
|
||||||
<RestTimer />
|
<RestTimer />
|
||||||
|
|
||||||
{/* Übungen als Accordion */}
|
|
||||||
{exercises.map((exercise) => {
|
{exercises.map((exercise) => {
|
||||||
const exLogs = getExerciseLogs(exercise.id);
|
const exLogs = getExerciseLogs(exercise.id);
|
||||||
const isExpanded = expandedExercise === exercise.id;
|
const isExpanded = expandedExercise === exercise.id;
|
||||||
@@ -104,7 +111,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
key={exercise.id}
|
key={exercise.id}
|
||||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExercise(exercise.id)}
|
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"
|
className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||||
@@ -131,20 +137,16 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="px-4 pb-4 space-y-3">
|
||||||
{/* Fortschritts-Sparkline */}
|
|
||||||
<ExerciseSparkline exerciseId={exercise.id} />
|
<ExerciseSparkline exerciseId={exercise.id} />
|
||||||
|
|
||||||
{/* Vorherige Werte */}
|
|
||||||
{lastLog && (
|
{lastLog && (
|
||||||
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
<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.
|
Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bisherige Sätze */}
|
|
||||||
{exLogs.map((log) => (
|
{exLogs.map((log) => (
|
||||||
<div key={log.id}>
|
<div key={log.id}>
|
||||||
{editingLog?.id === log.id ? (
|
{editingLog?.id === log.id ? (
|
||||||
@@ -198,7 +200,11 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */}
|
{/*
|
||||||
|
Formular für neuen Satz: Vorausfüllung mit Werten des letzten Satzes
|
||||||
|
dieser Session, falls vorhanden, sonst mit dem letzten Trainingseintrag.
|
||||||
|
Der key-Wechsel erzwingt Reset des Formularzustands bei jedem neuen Satz.
|
||||||
|
*/}
|
||||||
<LogEntryForm
|
<LogEntryForm
|
||||||
key={`new-${exercise.id}-${exLogs.length}`}
|
key={`new-${exercise.id}-${exLogs.length}`}
|
||||||
exercise={exercise}
|
exercise={exercise}
|
||||||
@@ -213,7 +219,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Training beenden */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleEndSession}
|
onClick={handleEndSession}
|
||||||
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"
|
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ interface DataPoint {
|
|||||||
e1rm: number;
|
e1rm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Epley-Formel: e1RM = Gewicht × (1 + Wdh / 30)
|
/**
|
||||||
|
* Berechnet den geschätzten 1-Wiederholungs-Maximalwert (e1RM) nach der Epley-Formel:
|
||||||
|
* e1RM = Gewicht × (1 + Wiederholungen / 30)
|
||||||
|
*/
|
||||||
function calcE1RM(weight: number, reps: number): number {
|
function calcE1RM(weight: number, reps: number): number {
|
||||||
if (reps <= 0 || weight <= 0) return 0;
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
return Math.round(weight * (1 + reps / 30) * 10) / 10;
|
return Math.round(weight * (1 + reps / 30) * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompaktes Liniendiagramm (Sparkline) für den e1RM-Verlauf einer Übung.
|
||||||
|
* Pro Trainings-Session wird der beste e1RM-Wert dargestellt.
|
||||||
|
* Rendert nichts, wenn weniger als 2 Datenpunkte vorhanden sind.
|
||||||
|
*/
|
||||||
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
||||||
const [data, setData] = useState<DataPoint[]>([]);
|
const [data, setData] = useState<DataPoint[]>([]);
|
||||||
|
|
||||||
@@ -31,7 +39,7 @@ export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
|||||||
api.exercises
|
api.exercises
|
||||||
.history(exerciseId, 100)
|
.history(exerciseId, 100)
|
||||||
.then((logs: SessionLog[]) => {
|
.then((logs: SessionLog[]) => {
|
||||||
// Gruppiere nach Session (= Trainingstag), nimm bestes e1RM pro Session
|
// Pro Session den besten e1RM ermitteln
|
||||||
const bySession = new Map<number, { date: string; e1rm: number }>();
|
const bySession = new Map<number, { date: string; e1rm: number }>();
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
const e1rm = calcE1RM(log.weight_kg, log.reps);
|
const e1rm = calcE1RM(log.weight_kg, log.reps);
|
||||||
@@ -46,6 +54,7 @@ export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// API liefert neueste Einträge zuerst; umkehren für chronologische Darstellung
|
||||||
const points = Array.from(bySession.values()).reverse();
|
const points = Array.from(bySession.values()).reverse();
|
||||||
setData(points);
|
setData(points);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ interface LogEntryFormProps {
|
|||||||
initialWeight?: number;
|
initialWeight?: number;
|
||||||
initialReps?: number;
|
initialReps?: number;
|
||||||
onSubmit: (weight: number, reps: number, note: string) => void;
|
onSubmit: (weight: number, reps: number, note: string) => void;
|
||||||
|
/** Beschriftung des Speichern-Buttons; Standard: "Satz speichern". */
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingabeformular für einen einzelnen Trainingssatz.
|
||||||
|
* Gewicht wird in Schritten von `exercise.weight_step_kg` angepasst,
|
||||||
|
* Wiederholungen in Einerschritten. Nach dem Speichern wird die Notiz geleert.
|
||||||
|
*/
|
||||||
export function LogEntryForm({
|
export function LogEntryForm({
|
||||||
exercise,
|
exercise,
|
||||||
setNumber,
|
setNumber,
|
||||||
@@ -41,7 +47,6 @@ export function LogEntryForm({
|
|||||||
<div className="space-y-4 bg-gray-800 rounded-lg p-4">
|
<div className="space-y-4 bg-gray-800 rounded-lg p-4">
|
||||||
<div className="text-sm text-gray-400">Satz {setNumber}</div>
|
<div className="text-sm text-gray-400">Satz {setNumber}</div>
|
||||||
|
|
||||||
{/* Gewicht */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
|
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
@@ -84,7 +89,6 @@ export function LogEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wiederholungen */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
|
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
@@ -112,7 +116,6 @@ export function LogEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notiz */}
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||||
|
|
||||||
|
/** Voreingestellte Pausenzeiten in Sekunden. */
|
||||||
const PRESETS = [60, 90, 120, 180];
|
const PRESETS = [60, 90, 120, 180];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause-Timer für die aktive Trainings-Session.
|
||||||
|
* Zeigt bei laufendem Timer Countdown und Fortschrittsbalken an;
|
||||||
|
* andernfalls Schnellauswahl-Buttons für die vordefinierten Pausenzeiten.
|
||||||
|
*/
|
||||||
export function RestTimer() {
|
export function RestTimer() {
|
||||||
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
|
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
|
||||||
const isRunning = timerSeconds > 0;
|
const isRunning = timerSeconds > 0;
|
||||||
|
|
||||||
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
|
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
|
||||||
|
|
||||||
|
/** Formatiert Sekunden als `m:ss`. */
|
||||||
const formatTime = (s: number) => {
|
const formatTime = (s: number) => {
|
||||||
const mins = Math.floor(s / 60);
|
const mins = Math.floor(s / 60);
|
||||||
const secs = s % 60;
|
const secs = s % 60;
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import { useToastStore } from './toastStore';
|
|||||||
interface ActiveSessionState {
|
interface ActiveSessionState {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
|
/** Zuletzt protokollierte Werte pro Übungs-ID – wird für Vorausfüllung genutzt. */
|
||||||
lastLogs: Map<number, LastLogResponse>;
|
lastLogs: Map<number, LastLogResponse>;
|
||||||
|
/** Verbleibende Sekunden des laufenden Pause-Timers. */
|
||||||
timerSeconds: number;
|
timerSeconds: number;
|
||||||
|
/** Ursprünglich eingestellte Sekunden – für Fortschrittsbalken-Berechnung. */
|
||||||
timerTarget: number;
|
timerTarget: number;
|
||||||
timerInterval: ReturnType<typeof setInterval> | null;
|
timerInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
|
||||||
@@ -32,6 +35,13 @@ interface ActiveSessionState {
|
|||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store für die laufende Trainings-Session.
|
||||||
|
*
|
||||||
|
* Verwaltet Session-Zustand, protokollierte Sätze, letzte Trainingswerte
|
||||||
|
* und den Pause-Timer. Der Timer-Interval wird manuell verwaltet;
|
||||||
|
* beim Stopp muss `stopTimer()` explizit aufgerufen werden.
|
||||||
|
*/
|
||||||
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||||
session: null,
|
session: null,
|
||||||
exercises: [],
|
exercises: [],
|
||||||
@@ -45,7 +55,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
|||||||
const session = await api.sessions.create({ set_id: setId });
|
const session = await api.sessions.create({ set_id: setId });
|
||||||
set({ session, exercises });
|
set({ session, exercises });
|
||||||
|
|
||||||
// Lade letzte Logs für alle Übungen
|
// Letzte Logs für alle Übungen vorladen, um Formular-Vorausfüllung zu ermöglichen
|
||||||
for (const ex of exercises) {
|
for (const ex of exercises) {
|
||||||
await get().fetchLastLog(ex.id);
|
await get().fetchLastLog(ex.id);
|
||||||
}
|
}
|
||||||
@@ -65,7 +75,6 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
|||||||
|
|
||||||
set({ session: result.session, exercises: result.exercises || [] });
|
set({ session: result.session, exercises: result.exercises || [] });
|
||||||
|
|
||||||
// Lade letzte Logs für alle Übungen
|
|
||||||
for (const ex of (result.exercises || [])) {
|
for (const ex of (result.exercises || [])) {
|
||||||
await get().fetchLastLog(ex.id);
|
await get().fetchLastLog(ex.id);
|
||||||
}
|
}
|
||||||
@@ -93,7 +102,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
|||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
try {
|
try {
|
||||||
const log = await api.sessions.createLog(session.id, data);
|
const log = await api.sessions.createLog(session.id, data);
|
||||||
// Reload session to get updated logs
|
// Session neu laden, damit die Logs-Liste aktuell ist
|
||||||
const updated = await api.sessions.get(session.id);
|
const updated = await api.sessions.get(session.id);
|
||||||
set({ session: updated });
|
set({ session: updated });
|
||||||
return log;
|
return log;
|
||||||
@@ -161,7 +170,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return lastLog;
|
return lastLog;
|
||||||
} catch {
|
} catch {
|
||||||
// 404 = noch kein Log vorhanden
|
// 404 bedeutet: noch kein Trainingseintrag für diese Übung vorhanden
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../api/client';
|
|||||||
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
|
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
|
||||||
import { useToastStore } from './toastStore';
|
import { useToastStore } from './toastStore';
|
||||||
|
|
||||||
|
/** Filterkriterien für die Übungsliste. */
|
||||||
interface ExerciseFilter {
|
interface ExerciseFilter {
|
||||||
muscleGroup: MuscleGroup | '';
|
muscleGroup: MuscleGroup | '';
|
||||||
query: string;
|
query: string;
|
||||||
@@ -20,6 +21,7 @@ interface ExerciseState {
|
|||||||
setFilter: (filter: Partial<ExerciseFilter>) => void;
|
setFilter: (filter: Partial<ExerciseFilter>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store für Übungsverwaltung inkl. Filterung und CRUD-Operationen. */
|
||||||
export const useExerciseStore = create<ExerciseState>((set, get) => ({
|
export const useExerciseStore = create<ExerciseState>((set, get) => ({
|
||||||
exercises: [],
|
exercises: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface HistoryState {
|
|||||||
fetchStats: () => Promise<void>;
|
fetchStats: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store für Trainings-Historie und Übungsstatistiken. */
|
||||||
export const useHistoryStore = create<HistoryState>((set) => ({
|
export const useHistoryStore = create<HistoryState>((set) => ({
|
||||||
sessions: [],
|
sessions: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SetState {
|
|||||||
deleteSet: (id: number) => Promise<boolean>;
|
deleteSet: (id: number) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store für Trainings-Sets inkl. CRUD-Operationen. */
|
||||||
export const useSetStore = create<SetState>((set, get) => ({
|
export const useSetStore = create<SetState>((set, get) => ({
|
||||||
sets: [],
|
sets: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/** Eine einzelne Toast-Benachrichtigung. */
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'success' | 'error' | 'info';
|
type: 'success' | 'error' | 'info';
|
||||||
@@ -12,6 +13,13 @@ interface ToastState {
|
|||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store für temporäre Benachrichtigungen.
|
||||||
|
*
|
||||||
|
* Toasts verschwinden nach 3 Sekunden automatisch.
|
||||||
|
* Wird aus anderen Stores via `useToastStore.getState().addToast()` aufgerufen,
|
||||||
|
* um Store-zu-Store-Abhängigkeiten zu vermeiden.
|
||||||
|
*/
|
||||||
export const useToastStore = create<ToastState>((set) => ({
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
toasts: [],
|
toasts: [],
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ interface UserState {
|
|||||||
deleteUser: (id: number) => Promise<boolean>;
|
deleteUser: (id: number) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store für Nutzerverwaltung.
|
||||||
|
*
|
||||||
|
* Der aktive Nutzer wird im localStorage persistiert (`activeUser`-Feld),
|
||||||
|
* damit die Auswahl nach einem Seitenneustart erhalten bleibt.
|
||||||
|
*
|
||||||
|
* Dieser Store verwendet bewusst direkte `fetch`-Aufrufe statt `api/client.ts`,
|
||||||
|
* da `api/client.ts` seinerseits `getActiveUserId()` aus diesem Store liest
|
||||||
|
* und ein zirkulärer Import vermieden werden muss.
|
||||||
|
*/
|
||||||
export const useUserStore = create<UserState>()(
|
export const useUserStore = create<UserState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -27,7 +37,7 @@ export const useUserStore = create<UserState>()(
|
|||||||
const users: User[] = await res.json();
|
const users: User[] = await res.json();
|
||||||
set({ users });
|
set({ users });
|
||||||
|
|
||||||
// Aktiven Nutzer aktualisieren falls er sich geändert hat
|
// Aktiven Nutzer aktualisieren, falls sich seine Daten geändert haben
|
||||||
const { activeUser } = get();
|
const { activeUser } = get();
|
||||||
if (activeUser) {
|
if (activeUser) {
|
||||||
const updated = users.find((u) => u.id === activeUser.id);
|
const updated = users.find((u) => u.id === activeUser.id);
|
||||||
@@ -64,7 +74,10 @@ export const useUserStore = create<UserState>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gibt die aktive User-ID zurück — wird von api/client.ts verwendet.
|
/**
|
||||||
|
* Gibt die ID des aktiven Nutzers als String zurück.
|
||||||
|
* Wird von `api/client.ts` verwendet, um den `X-User-ID`-Header zu setzen.
|
||||||
|
*/
|
||||||
export function getActiveUserId(): string | null {
|
export function getActiveUserId(): string | null {
|
||||||
const { activeUser } = useUserStore.getState();
|
const { activeUser } = useUserStore.getState();
|
||||||
return activeUser ? String(activeUser.id) : null;
|
return activeUser ? String(activeUser.id) : null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Bezeichner für Muskelgruppen – spiegeln die DB-Enum-Werte wider. */
|
||||||
export type MuscleGroup =
|
export type MuscleGroup =
|
||||||
| 'brust'
|
| 'brust'
|
||||||
| 'ruecken'
|
| 'ruecken'
|
||||||
@@ -9,6 +10,7 @@ export type MuscleGroup =
|
|||||||
| 'ganzkoerper'
|
| 'ganzkoerper'
|
||||||
| 'sonstiges';
|
| 'sonstiges';
|
||||||
|
|
||||||
|
/** Alle Muskelgruppen mit Anzeigebezeichnungen für Select-Felder. */
|
||||||
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
||||||
{ value: 'brust', label: 'Brust' },
|
{ value: 'brust', label: 'Brust' },
|
||||||
{ value: 'ruecken', label: 'Rücken' },
|
{ value: 'ruecken', label: 'Rücken' },
|
||||||
@@ -21,6 +23,7 @@ export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
|||||||
{ value: 'sonstiges', label: 'Sonstiges' },
|
{ value: 'sonstiges', label: 'Sonstiges' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Anzeigebezeichnungen für Muskelgruppen, direkt per Schlüssel abrufbar. */
|
||||||
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
||||||
brust: 'Brust',
|
brust: 'Brust',
|
||||||
ruecken: 'Rücken',
|
ruecken: 'Rücken',
|
||||||
@@ -33,6 +36,7 @@ export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
|||||||
sonstiges: 'Sonstiges',
|
sonstiges: 'Sonstiges',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tailwind-Hintergrundfarben für Muskelgruppen-Badges. */
|
||||||
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
||||||
brust: 'bg-red-600',
|
brust: 'bg-red-600',
|
||||||
ruecken: 'bg-blue-600',
|
ruecken: 'bg-blue-600',
|
||||||
@@ -45,18 +49,21 @@ export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
|||||||
sonstiges: 'bg-gray-600',
|
sonstiges: 'bg-gray-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Eine einzelne Kraftübung aus der Datenbank. */
|
||||||
export interface Exercise {
|
export interface Exercise {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
muscle_group: MuscleGroup;
|
muscle_group: MuscleGroup;
|
||||||
weight_step_kg: number;
|
weight_step_kg: number;
|
||||||
|
/** Optionale Nummerierung aus dem Übungskatalog (UF#). */
|
||||||
exercise_number?: number;
|
exercise_number?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Trainings-Set: Sammlung von Übungen, die gemeinsam trainiert werden. */
|
||||||
export interface TrainingSet {
|
export interface TrainingSet {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -65,6 +72,11 @@ export interface TrainingSet {
|
|||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner protokollierter Satz innerhalb einer Trainings-Session.
|
||||||
|
* Der Übungsname wird denormalisiert gespeichert, damit gelöschte Übungen
|
||||||
|
* historische Einträge nicht verwaisen lassen.
|
||||||
|
*/
|
||||||
export interface SessionLog {
|
export interface SessionLog {
|
||||||
id: number;
|
id: number;
|
||||||
session_id: number;
|
session_id: number;
|
||||||
@@ -77,6 +89,7 @@ export interface SessionLog {
|
|||||||
logged_at: string;
|
logged_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eine Trainings-Session, optional mit allen protokollierten Sätzen. */
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: number;
|
id: number;
|
||||||
set_id: number;
|
set_id: number;
|
||||||
@@ -87,11 +100,13 @@ export interface Session {
|
|||||||
logs?: SessionLog[];
|
logs?: SessionLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Antwort des Endpunkts "letztes Training" für eine Übung. */
|
||||||
export interface LastLogResponse {
|
export interface LastLogResponse {
|
||||||
weight_kg: number;
|
weight_kg: number;
|
||||||
reps: number;
|
reps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aggregierte Statistiken für eine einzelne Übung. */
|
||||||
export interface ExerciseStats {
|
export interface ExerciseStats {
|
||||||
exercise_id: number;
|
exercise_id: number;
|
||||||
exercise_name: string;
|
exercise_name: string;
|
||||||
@@ -101,6 +116,7 @@ export interface ExerciseStats {
|
|||||||
last_trained: string;
|
last_trained: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Metadaten eines hochgeladenen Übungsbilds. */
|
||||||
export interface ExerciseImage {
|
export interface ExerciseImage {
|
||||||
id: number;
|
id: number;
|
||||||
exercise_id: number;
|
exercise_id: number;
|
||||||
@@ -109,6 +125,7 @@ export interface ExerciseImage {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Erstellen oder Aktualisieren einer Übung. */
|
||||||
export interface CreateExerciseRequest {
|
export interface CreateExerciseRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -117,30 +134,36 @@ export interface CreateExerciseRequest {
|
|||||||
exercise_number?: number;
|
exercise_number?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Erstellen eines Trainings-Sets. */
|
||||||
export interface CreateSetRequest {
|
export interface CreateSetRequest {
|
||||||
name: string;
|
name: string;
|
||||||
exercise_ids: number[];
|
exercise_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Aktualisieren eines Trainings-Sets. */
|
||||||
export interface UpdateSetRequest {
|
export interface UpdateSetRequest {
|
||||||
name: string;
|
name: string;
|
||||||
exercise_ids: number[];
|
exercise_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Starten einer neuen Trainings-Session. */
|
||||||
export interface CreateSessionRequest {
|
export interface CreateSessionRequest {
|
||||||
set_id: number;
|
set_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anwendungsnutzer. */
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Anlegen eines neuen Nutzers. */
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum Protokollieren eines Satzes in einer laufenden Session. */
|
||||||
export interface CreateLogRequest {
|
export interface CreateLogRequest {
|
||||||
exercise_id: number;
|
exercise_id: number;
|
||||||
set_number: number;
|
set_number: number;
|
||||||
@@ -149,6 +172,7 @@ export interface CreateLogRequest {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request-Body zum nachträglichen Bearbeiten eines protokollierten Satzes. */
|
||||||
export interface UpdateLogRequest {
|
export interface UpdateLogRequest {
|
||||||
weight_kg?: number;
|
weight_kg?: number;
|
||||||
reps?: number;
|
reps?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user