Compare commits
10 Commits
150ebe7d1d
...
4db170b467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4db170b467 | ||
|
|
063aa67615 | ||
|
|
833ad04a6f | ||
|
|
6d7d353ea2 | ||
|
|
344bcfc755 | ||
|
|
f601c2030e | ||
|
|
17dc9dbf3b | ||
|
|
c992e2775c | ||
|
|
a954f2c59d | ||
|
|
bff85908c3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ backend/vendor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
backups/
|
||||
|
||||
@@ -55,7 +55,7 @@ Store-Methoden geben nach Mutationen immer **frisch aus der DB gelesene Objekte*
|
||||
- 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.
|
||||
Sentinel-Strings im Error-Message für Handler-Differenzierung: `"UNIQUE_VIOLATION:"`, `"SESSION_CLOSED"`, `"SESSION_OPEN"`, `"SESSION_NOT_FOUND"`. Diese werden mit `strings.Contains()` geprüft — kein custom error type.
|
||||
|
||||
### Routing
|
||||
|
||||
@@ -74,6 +74,7 @@ Alle HTTP-Aufrufe gehen über `src/api/client.ts`. `ApiError` (extends Error) ha
|
||||
- `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
|
||||
- Hard-Delete bei Sessions: löscht zuerst explizit `session_logs`, dann `sessions` (kein ON DELETE CASCADE)
|
||||
|
||||
## Konventionen
|
||||
|
||||
|
||||
@@ -8,14 +8,15 @@ COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Go-Binary bauen (CGO nötig für go-sqlite3)
|
||||
FROM golang:1.24-bookworm AS go-builder
|
||||
FROM golang:1.26-bookworm AS go-builder
|
||||
WORKDIR /app
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
# Frontend-Build in static/ einhängen (wird per embed eingebettet)
|
||||
COPY --from=frontend-builder /app/frontend/dist/ ./static/
|
||||
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o krafttrainer ./cmd/server
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=1 go build -ldflags="-s -w -X main.Version=${VERSION}" -o krafttrainer ./cmd/server
|
||||
|
||||
# Stage 3: Minimales Runtime-Image
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
8
Makefile
8
Makefile
@@ -1,16 +1,18 @@
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
|
||||
.PHONY: dev-backend dev-frontend build clean
|
||||
|
||||
dev-backend:
|
||||
cd backend && go run ./cmd/server
|
||||
cd backend && GO111MODULE=on go run ./cmd/server
|
||||
|
||||
dev-frontend:
|
||||
cd frontend && pnpm dev
|
||||
|
||||
build:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
rm -rf backend/static/*
|
||||
find backend/static -not -name 'embed.go' -not -name '.gitkeep' -not -path backend/static -delete
|
||||
cp -r frontend/dist/* backend/static/
|
||||
cd backend && CGO_ENABLED=1 go build -o ../krafttrainer ./cmd/server
|
||||
cd backend && GO111MODULE=on CGO_ENABLED=1 go build -ldflags "-X main.Version=$(VERSION)" -o ../krafttrainer ./cmd/server
|
||||
|
||||
clean:
|
||||
rm -f krafttrainer
|
||||
|
||||
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) })
|
||||
```
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"krafttrainer/static"
|
||||
)
|
||||
|
||||
// Version wird beim Build per ldflags gesetzt.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
// Datenbank initialisieren
|
||||
s, err := store.New("krafttrainer.db")
|
||||
@@ -30,6 +33,13 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
h := handler.New(s)
|
||||
h.RegisterRoutes(mux)
|
||||
h.RegisterImageRoutes(mux)
|
||||
|
||||
// Version-Endpoint
|
||||
mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"version":"` + Version + `"}`))
|
||||
})
|
||||
|
||||
// SPA-Fallback: statische Dateien aus embed.FS servieren
|
||||
mux.Handle("/", spaHandler(static.FS))
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
)
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
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=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -6,11 +6,18 @@ import (
|
||||
"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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
muscleGroup := r.URL.Query().Get("muscle_group")
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
exercises, err := h.store.ListExercises(muscleGroup, query)
|
||||
exercises, err := h.store.ListExercises(uid, muscleGroup, query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
|
||||
return
|
||||
@@ -18,7 +25,14 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, exercises)
|
||||
}
|
||||
|
||||
// handleCreateExercise behandelt POST /api/v1/exercises.
|
||||
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateExerciseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
@@ -29,7 +43,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.CreateExercise(&req)
|
||||
exercise, err := h.store.CreateExercise(uid, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung")
|
||||
return
|
||||
@@ -37,7 +51,13 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, exercise)
|
||||
}
|
||||
|
||||
// handleUpdateExercise behandelt PUT /api/v1/exercises/{id}.
|
||||
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
@@ -54,7 +74,7 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.store.UpdateExercise(id, &req)
|
||||
exercise, err := h.store.UpdateExercise(id, uid, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung")
|
||||
return
|
||||
@@ -66,14 +86,21 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteExercise(id)
|
||||
err = h.store.SoftDeleteExercise(id, uid)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
|
||||
42
backend/internal/handler/export.go
Normal file
42
backend/internal/handler/export.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleExport gibt alle Trainingsdaten eines Nutzers als CSV zurück.
|
||||
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.store.ExportLogs(uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Export fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
filename := "training-export-" + time.Now().Format("2006-01-02") + ".csv"
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Write([]string{"datum", "uebung", "satz", "gewicht_kg", "wiederholungen", "notiz"})
|
||||
|
||||
for _, row := range rows {
|
||||
cw.Write([]string{
|
||||
row.SessionStarted.Format("2006-01-02 15:04"),
|
||||
row.ExerciseName,
|
||||
strconv.Itoa(row.SetNumber),
|
||||
strconv.FormatFloat(row.WeightKg, 'f', 2, 64),
|
||||
strconv.Itoa(row.Reps),
|
||||
row.Note,
|
||||
})
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"krafttrainer/internal/store"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -19,6 +20,11 @@ func New(store *store.Store) *Handler {
|
||||
|
||||
// RegisterRoutes registriert alle API-Routen am ServeMux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Users (kein X-User-ID Header nötig)
|
||||
mux.HandleFunc("GET /api/v1/users", h.handleListUsers)
|
||||
mux.HandleFunc("POST /api/v1/users", h.handleCreateUser)
|
||||
mux.HandleFunc("DELETE /api/v1/users/{id}", h.handleDeleteUser)
|
||||
|
||||
// Exercises
|
||||
mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises)
|
||||
mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise)
|
||||
@@ -32,10 +38,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
|
||||
|
||||
// Sessions
|
||||
mux.HandleFunc("GET /api/v1/sessions/active", h.handleGetActiveSession)
|
||||
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)
|
||||
mux.HandleFunc("DELETE /api/v1/sessions/{id}", h.handleDeleteSession)
|
||||
|
||||
// Session Logs
|
||||
mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog)
|
||||
@@ -46,30 +54,39 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
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)
|
||||
|
||||
// Export
|
||||
mux.HandleFunc("GET /api/v1/export", h.handleExport)
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
// writeJSON schreibt data als JSON mit dem angegebenen HTTP-Statuscode.
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// writeError schreibt eine Fehlerantwort im Format {"error": "..."}.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
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 {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
// pathID liest einen Integer-Pfadparameter aus dem Request.
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
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 {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
@@ -81,3 +98,16 @@ func queryInt(r *http.Request, name string, defaultVal int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// userID liest die X-User-ID aus dem Request-Header.
|
||||
func userID(r *http.Request) (int64, error) {
|
||||
v := r.Header.Get("X-User-ID")
|
||||
if v == "" {
|
||||
return 0, errors.New("X-User-ID Header fehlt")
|
||||
}
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, errors.New("ungültige User-ID")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
141
backend/internal/handler/image_handler.go
Normal file
141
backend/internal/handler/image_handler.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImageSize = 5 << 20 // 5 MB
|
||||
uploadDir = "uploads"
|
||||
)
|
||||
|
||||
// handleListImages behandelt GET /api/v1/exercises/{id}/images.
|
||||
func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
images, err := h.store.ListExerciseImages(exerciseID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Bilder")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Übung muss existieren
|
||||
exercise, err := h.store.GetExercise(exerciseID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Prüfen der Übung")
|
||||
return
|
||||
}
|
||||
if exercise == nil {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImageSize)
|
||||
if err := r.ParseMultipartForm(maxImageSize); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Datei zu groß (max 5 MB)")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Kein Bild im Request")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Content-Type prüfen
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if ct != "image/jpeg" {
|
||||
writeError(w, http.StatusBadRequest, "Nur JPG-Bilder erlaubt")
|
||||
return
|
||||
}
|
||||
|
||||
// Datei speichern
|
||||
filename := uuid.New().String() + ".jpg"
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Upload-Verzeichnisses")
|
||||
return
|
||||
}
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern der Datei")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
os.Remove(destPath)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Schreiben der Datei")
|
||||
return
|
||||
}
|
||||
|
||||
img, err := h.store.CreateExerciseImage(exerciseID, filename)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern des Bildes")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
imageID, err := pathID(r, "imageId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Bild-ID")
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := h.store.DeleteExerciseImage(imageID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Bild nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
// Datei vom Dateisystem löschen
|
||||
os.Remove(filepath.Join(uploadDir, filename))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RegisterImageRoutes registriert die Bild-spezifischen Routen und den statischen File-Server.
|
||||
func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/images", h.handleListImages)
|
||||
mux.HandleFunc("POST /api/v1/exercises/{id}/images", h.handleUploadImage)
|
||||
mux.HandleFunc("DELETE /api/v1/exercises/{id}/images/{imageId}", h.handleDeleteImage)
|
||||
|
||||
// Bilder statisch servieren
|
||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
|
||||
http.FileServer(http.Dir(uploadDir))))
|
||||
|
||||
// uploads-Verzeichnis beim Start sicherstellen
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
log.Printf("Warnung: Upload-Verzeichnis konnte nicht erstellt werden: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ 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")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-User-ID")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -7,7 +7,48 @@ import (
|
||||
"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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetActiveSession(uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Suchen der aktiven Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Exercises des Sets mitliefern
|
||||
exercises, err := h.store.GetSetExercises(session.SetID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session": session,
|
||||
"exercises": exercises,
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateSessionRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
@@ -18,8 +59,12 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.CreateSession(req.SetID)
|
||||
session, err := h.store.CreateSession(uid, req.SetID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_OPEN") {
|
||||
writeError(w, http.StatusConflict, "Es läuft bereits ein Training. Bitte zuerst beenden.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -30,11 +75,18 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", 20)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
|
||||
sessions, err := h.store.ListSessions(limit, offset)
|
||||
sessions, err := h.store.ListSessions(uid, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions")
|
||||
return
|
||||
@@ -42,6 +94,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// handleGetSession behandelt GET /api/v1/sessions/{id}.
|
||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -61,7 +114,14 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
@@ -71,10 +131,9 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
// Body ist optional
|
||||
decodeJSON(r, &body)
|
||||
|
||||
session, err := h.store.EndSession(id, body.Note)
|
||||
session, err := h.store.EndSession(id, uid, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session")
|
||||
return
|
||||
@@ -86,6 +145,41 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
// handleDeleteSession handles DELETE /api/v1/sessions/{id}.
|
||||
// Löscht eine abgeschlossene Session samt aller Logs. Offene Sessions werden
|
||||
// mit 409 abgelehnt. Sessions anderer Nutzer oder nicht vorhandene Sessions
|
||||
// antworten mit 404.
|
||||
func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteSession(id, uid)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_NOT_FOUND") {
|
||||
writeError(w, http.StatusNotFound, "Session nicht gefunden")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "SESSION_OPEN") {
|
||||
writeError(w, http.StatusConflict, "Nur abgeschlossene Sessions können gelöscht werden")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen der Session")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -123,6 +217,8 @@ func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -161,6 +257,7 @@ func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, log)
|
||||
}
|
||||
|
||||
// handleDeleteLog behandelt DELETE /api/v1/sessions/{id}/logs/{logId}.
|
||||
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
|
||||
262
backend/internal/handler/session_resume_test.go
Normal file
262
backend/internal/handler/session_resume_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
migrate "krafttrainer/internal/migrate"
|
||||
"krafttrainer/internal/handler"
|
||||
"krafttrainer/internal/store"
|
||||
"krafttrainer/migrations"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newHandlerWithStore(t *testing.T) (*handler.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp("", "krafttrainer-handler-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp db file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
|
||||
s, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
os.Remove(dbPath)
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
s.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
|
||||
if err := migrate.Run(s.DB(), migrations.FS); err != nil {
|
||||
t.Fatalf("migrate.Run: %v", err)
|
||||
}
|
||||
return handler.New(s), s
|
||||
}
|
||||
|
||||
func seedUserH(t *testing.T, db *sql.DB, name string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
||||
if err != nil {
|
||||
t.Fatalf("seedUser %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func seedTrainingSetH(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, name, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("seedTrainingSet %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func seedExerciseH(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`,
|
||||
name, userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seedExercise %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func openSessionH(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, setID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("openSession: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// requestWithUserID builds a request for path with an optional X-User-ID header.
|
||||
func requestWithUserID(method, path string, userIDHeader string) *http.Request {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
if userIDHeader != "" {
|
||||
r.Header.Set("X-User-ID", userIDHeader)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// decodeBody parses the JSON response body into dst.
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("decode response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newMux registers all handler routes on a fresh ServeMux so that Go 1.22+
|
||||
// pattern matching (including the /active literal segment) is active.
|
||||
func newMux(h *handler.Handler) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleGetActiveSession tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleGetActiveSession_MissingUserIDHeader(t *testing.T) {
|
||||
h, _ := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", ""))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_InvalidUserIDHeader(t *testing.T) {
|
||||
h, _ := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
for _, bad := range []string{"not-a-number", "0", "-1"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", bad))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("header %q: status want 400, got %d", bad, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ReturnsNullWhenNoActiveSession(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
userID := seedUserH(t, s.DB(), "NoSession")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", userID)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// writeJSON(w, 200, nil) encodes the Go nil interface value as JSON "null".
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
if body != "null" {
|
||||
t.Errorf("body: want %q, got %q", "null", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ReturnsSessionAndExercises(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "ActiveUser")
|
||||
setID := seedTrainingSetH(t, s.DB(), "Push Tag", uid)
|
||||
exID := seedExerciseH(t, s.DB(), "Bankdrücken", uid)
|
||||
|
||||
if _, err := s.DB().Exec(
|
||||
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, 0)`,
|
||||
setID, exID,
|
||||
); err != nil {
|
||||
t.Fatalf("link exercise to set: %v", err)
|
||||
}
|
||||
|
||||
sessionID := openSessionH(t, s.DB(), setID, uid)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Session struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
SetName string `json:"set_name"`
|
||||
} `json:"session"`
|
||||
Exercises []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"exercises"`
|
||||
}
|
||||
decodeBody(t, rec, &resp)
|
||||
|
||||
if resp.Session.ID != sessionID {
|
||||
t.Errorf("session.id: want %d, got %d", sessionID, resp.Session.ID)
|
||||
}
|
||||
if resp.Session.SetID != setID {
|
||||
t.Errorf("session.set_id: want %d, got %d", setID, resp.Session.SetID)
|
||||
}
|
||||
if resp.Session.SetName != "Push Tag" {
|
||||
t.Errorf("session.set_name: want %q, got %q", "Push Tag", resp.Session.SetName)
|
||||
}
|
||||
if len(resp.Exercises) != 1 {
|
||||
t.Fatalf("exercises: want 1, got %d", len(resp.Exercises))
|
||||
}
|
||||
if resp.Exercises[0].ID != exID {
|
||||
t.Errorf("exercise.id: want %d, got %d", exID, resp.Exercises[0].ID)
|
||||
}
|
||||
if resp.Exercises[0].Name != "Bankdrücken" {
|
||||
t.Errorf("exercise.name: want %q, got %q", "Bankdrücken", resp.Exercises[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ExercisesEmptyWhenSetHasNone(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "NoExUser")
|
||||
setID := seedTrainingSetH(t, s.DB(), "Leeres Set", uid)
|
||||
openSessionH(t, s.DB(), setID, uid)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Exercises []json.RawMessage `json:"exercises"`
|
||||
}
|
||||
decodeBody(t, rec, &resp)
|
||||
|
||||
if len(resp.Exercises) != 0 {
|
||||
t.Errorf("exercises: want empty slice, got %d items", len(resp.Exercises))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ContentTypeIsJSON(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "CTUser")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type: want application/json, got %q", ct)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,21 @@ package handler
|
||||
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
lastLog, err := h.store.GetLastLog(id)
|
||||
lastLog, err := h.store.GetLastLog(id, uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs")
|
||||
return
|
||||
@@ -21,7 +28,14 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
@@ -29,7 +43,7 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
limit := queryInt(r, "limit", 30)
|
||||
|
||||
logs, err := h.store.GetExerciseHistory(id, limit)
|
||||
logs, err := h.store.GetExerciseHistory(id, uid, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie")
|
||||
return
|
||||
@@ -37,8 +51,15 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, logs)
|
||||
}
|
||||
|
||||
// handleGetStatsOverview behandelt GET /api/v1/stats/overview.
|
||||
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
overview, err := h.store.GetStatsOverview()
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := h.store.GetStatsOverview(uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken")
|
||||
return
|
||||
|
||||
@@ -7,8 +7,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleListSets behandelt GET /api/v1/sets.
|
||||
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
sets, err := h.store.ListSets()
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sets, err := h.store.ListSets(uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets")
|
||||
return
|
||||
@@ -16,7 +23,14 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sets)
|
||||
}
|
||||
|
||||
// handleCreateSet behandelt POST /api/v1/sets.
|
||||
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateSetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
@@ -27,7 +41,7 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.CreateSet(&req)
|
||||
set, err := h.store.CreateSet(uid, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -39,7 +53,14 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
@@ -56,7 +77,7 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
set, err := h.store.UpdateSet(id, &req)
|
||||
set, err := h.store.UpdateSet(id, uid, &req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -72,14 +93,21 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.SoftDeleteSet(id)
|
||||
err = h.store.SoftDeleteSet(id, uid)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Set nicht gefunden")
|
||||
return
|
||||
|
||||
63
backend/internal/handler/user.go
Normal file
63
backend/internal/handler/user.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"krafttrainer/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleListUsers behandelt GET /api/v1/users.
|
||||
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Nutzer")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// handleCreateUser behandelt POST /api/v1/users.
|
||||
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateUserRequest
|
||||
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
|
||||
}
|
||||
|
||||
user, err := h.store.CreateUser(req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Nutzers")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteUser(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "LAST_USER") {
|
||||
writeError(w, http.StatusConflict, "Der letzte Nutzer kann nicht gelöscht werden")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Nutzers")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Exercise struct {
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg float64 `json:"weight_step_kg"`
|
||||
ExerciseNumber *int `json:"exercise_number,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
@@ -24,6 +25,7 @@ type CreateExerciseRequest struct {
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg *float64 `json:"weight_step_kg"`
|
||||
ExerciseNumber *int `json:"exercise_number,omitempty"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg.
|
||||
|
||||
12
backend/internal/model/exercise_image.go
Normal file
12
backend/internal/model/exercise_image.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ExerciseImage repräsentiert ein Bild einer Übung.
|
||||
type ExerciseImage struct {
|
||||
ID int64 `json:"id"`
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
Filename string `json:"filename"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
31
backend/internal/model/user.go
Normal file
31
backend/internal/model/user.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User repräsentiert einen Nutzer der Applikation.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateUserRequest enthält die Felder zum Anlegen eines Nutzers.
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Validate prüft den Request.
|
||||
func (r *CreateUserRequest) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
if len(r.Name) == 0 {
|
||||
return errors.New("Name darf nicht leer sein")
|
||||
}
|
||||
if len(r.Name) > 50 {
|
||||
return errors.New("Name darf maximal 50 Zeichen lang sein")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,16 +6,17 @@ import (
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert.
|
||||
func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, error) {
|
||||
// ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert.
|
||||
func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, created_at, updated_at
|
||||
FROM exercises
|
||||
WHERE deleted_at IS NULL
|
||||
AND user_id = ?
|
||||
AND (muscle_group = ? OR ? = '')
|
||||
AND (name LIKE '%' || ? || '%' OR ? = '')
|
||||
ORDER BY name`,
|
||||
muscleGroup, muscleGroup, query, query,
|
||||
userID, muscleGroup, muscleGroup, query, query,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungen abfragen: %w", err)
|
||||
@@ -25,7 +26,7 @@ func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, erro
|
||||
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 {
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Übung scannen: %w", err)
|
||||
}
|
||||
exercises = append(exercises, e)
|
||||
@@ -36,13 +37,13 @@ func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, erro
|
||||
return exercises, rows.Err()
|
||||
}
|
||||
|
||||
// GetExercise gibt eine einzelne Übung zurück.
|
||||
// GetExercise gibt eine einzelne Übung zurück (intern, ohne User-Scope).
|
||||
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
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, 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)
|
||||
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -53,11 +54,11 @@ func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
|
||||
}
|
||||
|
||||
// CreateExercise legt eine neue Übung an und gibt sie zurück.
|
||||
func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
func (s *Store) CreateExercise(userID int64, 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,
|
||||
INSERT INTO exercises (name, description, muscle_group, weight_step_kg, exercise_number, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung erstellen: %w", err)
|
||||
@@ -67,14 +68,14 @@ func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercis
|
||||
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) {
|
||||
// UpdateExercise aktualisiert eine Übung eines Nutzers und gibt sie zurück.
|
||||
func (s *Store) UpdateExercise(id, userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?,
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, exercise_number = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id,
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung aktualisieren: %w", err)
|
||||
@@ -87,11 +88,11 @@ func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*mod
|
||||
return s.GetExercise(id)
|
||||
}
|
||||
|
||||
// SoftDeleteExercise markiert eine Übung als gelöscht.
|
||||
func (s *Store) SoftDeleteExercise(id int64) error {
|
||||
// SoftDeleteExercise markiert eine Übung eines Nutzers als gelöscht.
|
||||
func (s *Store) SoftDeleteExercise(id, userID 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,
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Übung löschen: %w", err)
|
||||
|
||||
40
backend/internal/store/export_store.go
Normal file
40
backend/internal/store/export_store.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExportRow repräsentiert eine Zeile im Trainingsexport.
|
||||
type ExportRow struct {
|
||||
SessionStarted time.Time
|
||||
ExerciseName string
|
||||
SetNumber int
|
||||
WeightKg float64
|
||||
Reps int
|
||||
Note string
|
||||
}
|
||||
|
||||
// ExportLogs gibt alle Logs abgeschlossener Sessions eines Nutzers zurück (für AI-Export).
|
||||
func (s *Store) ExportLogs(userID int64) ([]ExportRow, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT s.started_at, sl.exercise_name, sl.set_number, sl.weight_kg, sl.reps, sl.note
|
||||
FROM session_logs sl
|
||||
JOIN sessions s ON s.id = sl.session_id
|
||||
WHERE s.ended_at IS NOT NULL AND s.user_id = ?
|
||||
ORDER BY s.started_at, sl.exercise_name, sl.set_number`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Export abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ExportRow
|
||||
for rows.Next() {
|
||||
var row ExportRow
|
||||
if err := rows.Scan(&row.SessionStarted, &row.ExerciseName, &row.SetNumber, &row.WeightKg, &row.Reps, &row.Note); err != nil {
|
||||
return nil, fmt.Errorf("Export scannen: %w", err)
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
74
backend/internal/store/image_store.go
Normal file
74
backend/internal/store/image_store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListExerciseImages gibt alle Bilder einer Übung zurück.
|
||||
func (s *Store) ListExerciseImages(exerciseID int64) ([]model.ExerciseImage, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, exercise_id, filename, sort_order, created_at
|
||||
FROM exercise_images
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY sort_order, id`, exerciseID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bilder abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var images []model.ExerciseImage
|
||||
for rows.Next() {
|
||||
var img model.ExerciseImage
|
||||
if err := rows.Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Bild scannen: %w", err)
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
if images == nil {
|
||||
images = []model.ExerciseImage{}
|
||||
}
|
||||
return images, rows.Err()
|
||||
}
|
||||
|
||||
// CreateExerciseImage speichert einen Bild-Eintrag.
|
||||
func (s *Store) CreateExerciseImage(exerciseID int64, filename string) (*model.ExerciseImage, error) {
|
||||
// sort_order = bisherige Anzahl
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM exercise_images WHERE exercise_id = ?`, exerciseID).Scan(&count)
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO exercise_images (exercise_id, filename, sort_order)
|
||||
VALUES (?, ?, ?)`, exerciseID, filename, count,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bild speichern: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
var img model.ExerciseImage
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, exercise_id, filename, sort_order, created_at
|
||||
FROM exercise_images WHERE id = ?`, id,
|
||||
).Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bild abfragen: %w", err)
|
||||
}
|
||||
return &img, nil
|
||||
}
|
||||
|
||||
// DeleteExerciseImage löscht ein Bild und gibt den Dateinamen zurück.
|
||||
func (s *Store) DeleteExerciseImage(imageID int64) (string, error) {
|
||||
var filename string
|
||||
err := s.db.QueryRow(`SELECT filename FROM exercise_images WHERE id = ?`, imageID).Scan(&filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Bild nicht gefunden: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM exercise_images WHERE id = ?`, imageID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Bild löschen: %w", err)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
435
backend/internal/store/session_resume_test.go
Normal file
435
backend/internal/store/session_resume_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
migrate "krafttrainer/internal/migrate"
|
||||
"krafttrainer/internal/store"
|
||||
"krafttrainer/migrations"
|
||||
)
|
||||
|
||||
// newTestStore creates a temporary SQLite database file, runs all migrations,
|
||||
// and returns a fully initialised *store.Store. A t.Cleanup removes the temp
|
||||
// file and closes the store after the test finishes.
|
||||
//
|
||||
// We cannot use ":memory:" directly because store.New appends query parameters
|
||||
// to the path string with "?", which would corrupt a URI already containing "?".
|
||||
// A temp file avoids this and provides the same isolation guarantee.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
|
||||
// Create a temp file; store.New will open it by path.
|
||||
f, err := os.CreateTemp("", "krafttrainer-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp db file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
|
||||
s, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
os.Remove(dbPath)
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
s.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
|
||||
if err := migrate.Run(s.DB(), migrations.FS); err != nil {
|
||||
t.Fatalf("migrate.Run: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// seedUser inserts a user row and returns the new id.
|
||||
func seedUser(t *testing.T, db *sql.DB, name string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
||||
if err != nil {
|
||||
t.Fatalf("seedUser: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// seedExercise inserts a minimal exercise row and returns the new id.
|
||||
func seedExercise(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`,
|
||||
name, userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seedExercise %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// seedTrainingSet inserts a training set and returns its id.
|
||||
func seedTrainingSet(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`,
|
||||
name, userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seedTrainingSet %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// seedSetExercise links an exercise to a training set at the given position.
|
||||
func seedSetExercise(t *testing.T, db *sql.DB, setID, exerciseID int64, position int) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`,
|
||||
setID, exerciseID, position,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seedSetExercise set=%d exercise=%d: %v", setID, exerciseID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// openSession inserts a session that has no ended_at (open).
|
||||
func openSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`,
|
||||
setID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("openSession: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// closedSession inserts a session with ended_at set.
|
||||
func closedSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO sessions (set_id, user_id, ended_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||
setID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("closedSession: %v", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetActiveSession tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetActiveSession_NoOpenSession(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Alice")
|
||||
|
||||
got, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got session id=%d", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_OnlyClosedSessions(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Bob")
|
||||
setID := seedTrainingSet(t, s.DB(), "Set A", userID)
|
||||
closedSession(t, s.DB(), setID, userID)
|
||||
|
||||
got, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for user with only closed sessions, got id=%d", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_ReturnsOpenSession(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Carol")
|
||||
setID := seedTrainingSet(t, s.DB(), "Brust-Tag", userID)
|
||||
sessionID := openSession(t, s.DB(), setID, userID)
|
||||
|
||||
got, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil session, got nil")
|
||||
}
|
||||
if got.ID != sessionID {
|
||||
t.Errorf("session ID: want %d, got %d", sessionID, got.ID)
|
||||
}
|
||||
if got.SetID != setID {
|
||||
t.Errorf("set ID: want %d, got %d", setID, got.SetID)
|
||||
}
|
||||
if got.SetName != "Brust-Tag" {
|
||||
t.Errorf("set name: want %q, got %q", "Brust-Tag", got.SetName)
|
||||
}
|
||||
if got.EndedAt != nil {
|
||||
t.Errorf("expected EndedAt to be nil for open session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_IsolatedByUser(t *testing.T) {
|
||||
// An open session belonging to another user must not be returned.
|
||||
s := newTestStore(t)
|
||||
user1 := seedUser(t, s.DB(), "User1")
|
||||
user2 := seedUser(t, s.DB(), "User2")
|
||||
setID := seedTrainingSet(t, s.DB(), "Set", user1)
|
||||
openSession(t, s.DB(), setID, user1)
|
||||
|
||||
got, err := s.GetActiveSession(user2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for user2, got session id=%d (belongs to user1)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_ReturnsLatestWhenMultipleOpen(t *testing.T) {
|
||||
// If (by some data anomaly) two open sessions exist, the latest one is
|
||||
// returned (ORDER BY started_at DESC LIMIT 1).
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Dave")
|
||||
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
||||
|
||||
firstID := openSession(t, s.DB(), setID, userID)
|
||||
secondID := openSession(t, s.DB(), setID, userID)
|
||||
|
||||
got, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected a session, got nil")
|
||||
}
|
||||
// The second insert has a higher id; SQLite CURRENT_TIMESTAMP resolution
|
||||
// may produce equal timestamps, so we accept either the second or the one
|
||||
// with the higher id (both reflect "latest").
|
||||
if got.ID != secondID && got.ID != firstID {
|
||||
t.Errorf("unexpected session id %d (want %d or %d)", got.ID, firstID, secondID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSession / SESSION_OPEN guard tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateSession_RejectsWhenOpenSessionExists(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Eve")
|
||||
setID := seedTrainingSet(t, s.DB(), "Leg Day", userID)
|
||||
|
||||
// Create a first session via the store so the guard logic runs.
|
||||
first, err := s.CreateSession(userID, setID)
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateSession: %v", err)
|
||||
}
|
||||
if first == nil {
|
||||
t.Fatal("first CreateSession returned nil")
|
||||
}
|
||||
|
||||
// A second call must be rejected with SESSION_OPEN.
|
||||
_, err = s.CreateSession(userID, setID)
|
||||
if err == nil {
|
||||
t.Fatal("expected SESSION_OPEN error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SESSION_OPEN") {
|
||||
t.Errorf("expected error to contain SESSION_OPEN, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_AllowsNewSessionAfterClose(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Frank")
|
||||
setID := seedTrainingSet(t, s.DB(), "Push", userID)
|
||||
|
||||
first, err := s.CreateSession(userID, setID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.EndSession(first.ID, userID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("EndSession: %v", err)
|
||||
}
|
||||
|
||||
second, err := s.CreateSession(userID, setID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession after close: %v", err)
|
||||
}
|
||||
if second == nil {
|
||||
t.Fatal("expected new session, got nil")
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Errorf("expected a new session ID, got the same id=%d", second.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_OpenSessionOfOtherUserDoesNotBlock(t *testing.T) {
|
||||
// An open session for user1 must not prevent user2 from starting one.
|
||||
s := newTestStore(t)
|
||||
user1 := seedUser(t, s.DB(), "Greg")
|
||||
user2 := seedUser(t, s.DB(), "Hanna")
|
||||
set1 := seedTrainingSet(t, s.DB(), "Set1", user1)
|
||||
set2 := seedTrainingSet(t, s.DB(), "Set2", user2)
|
||||
|
||||
if _, err := s.CreateSession(user1, set1); err != nil {
|
||||
t.Fatalf("CreateSession user1: %v", err)
|
||||
}
|
||||
|
||||
sess2, err := s.CreateSession(user2, set2)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession user2 should succeed: %v", err)
|
||||
}
|
||||
if sess2 == nil {
|
||||
t.Fatal("expected session for user2, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSession_ErrorOnNonExistentSet(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Iris")
|
||||
|
||||
_, err := s.CreateSession(userID, 9999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent set, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existiert nicht") {
|
||||
t.Errorf("expected 'existiert nicht' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetSetExercises tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetSetExercises_EmptySet(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Jack")
|
||||
setID := seedTrainingSet(t, s.DB(), "Empty Set", userID)
|
||||
|
||||
exercises, err := s.GetSetExercises(setID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(exercises) != 0 {
|
||||
t.Errorf("expected 0 exercises, got %d", len(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetExercises_ReturnsExercisesInPositionOrder(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Kira")
|
||||
setID := seedTrainingSet(t, s.DB(), "Full Body", userID)
|
||||
|
||||
e1 := seedExercise(t, s.DB(), "Bankdrücken", userID)
|
||||
e2 := seedExercise(t, s.DB(), "Kniebeugen", userID)
|
||||
e3 := seedExercise(t, s.DB(), "Kreuzheben", userID)
|
||||
|
||||
// Insert in non-sequential position order to verify ORDER BY position.
|
||||
seedSetExercise(t, s.DB(), setID, e3, 2)
|
||||
seedSetExercise(t, s.DB(), setID, e1, 0)
|
||||
seedSetExercise(t, s.DB(), setID, e2, 1)
|
||||
|
||||
exercises, err := s.GetSetExercises(setID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(exercises) != 3 {
|
||||
t.Fatalf("expected 3 exercises, got %d", len(exercises))
|
||||
}
|
||||
wantOrder := []int64{e1, e2, e3}
|
||||
for i, ex := range exercises {
|
||||
if ex.ID != wantOrder[i] {
|
||||
t.Errorf("position %d: want exercise id=%d, got id=%d", i, wantOrder[i], ex.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetExercises_ExcludesSoftDeletedExercises(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Leo")
|
||||
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
||||
|
||||
active := seedExercise(t, s.DB(), "Aktive Übung", userID)
|
||||
deleted := seedExercise(t, s.DB(), "Gelöschte Übung", userID)
|
||||
|
||||
seedSetExercise(t, s.DB(), setID, active, 0)
|
||||
seedSetExercise(t, s.DB(), setID, deleted, 1)
|
||||
|
||||
// Soft-delete the second exercise.
|
||||
if _, err := s.DB().Exec(`UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`, deleted); err != nil {
|
||||
t.Fatalf("soft-delete exercise: %v", err)
|
||||
}
|
||||
|
||||
exercises, err := s.GetSetExercises(setID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(exercises) != 1 {
|
||||
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
||||
}
|
||||
if exercises[0].ID != active {
|
||||
t.Errorf("expected active exercise id=%d, got id=%d", active, exercises[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetExercises_NonExistentSet(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
exercises, err := s.GetSetExercises(9999)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(exercises) != 0 {
|
||||
t.Errorf("expected 0 exercises for non-existent set, got %d", len(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetExercises_PopulatesAllFields(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
userID := seedUser(t, s.DB(), "Mia")
|
||||
setID := seedTrainingSet(t, s.DB(), "Detail Set", userID)
|
||||
exID := seedExercise(t, s.DB(), "Rudern", userID)
|
||||
seedSetExercise(t, s.DB(), setID, exID, 0)
|
||||
|
||||
exercises, err := s.GetSetExercises(setID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(exercises) != 1 {
|
||||
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
||||
}
|
||||
ex := exercises[0]
|
||||
if ex.ID != exID {
|
||||
t.Errorf("ID: want %d, got %d", exID, ex.ID)
|
||||
}
|
||||
if ex.Name != "Rudern" {
|
||||
t.Errorf("Name: want %q, got %q", "Rudern", ex.Name)
|
||||
}
|
||||
if ex.MuscleGroup != "brust" {
|
||||
t.Errorf("MuscleGroup: want %q, got %q", "brust", ex.MuscleGroup)
|
||||
}
|
||||
if ex.WeightStepKg != 2.5 {
|
||||
t.Errorf("WeightStepKg: want 2.5, got %f", ex.WeightStepKg)
|
||||
}
|
||||
if ex.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt must not be zero")
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateSession startet eine neue Trainingseinheit.
|
||||
func (s *Store) CreateSession(setID int64) (*model.Session, error) {
|
||||
// Set prüfen
|
||||
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
|
||||
// Gibt einen Fehler zurück wenn noch eine offene Session existiert.
|
||||
func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) {
|
||||
// Prüfe ob bereits eine offene Session existiert
|
||||
active, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Aktive Session prüfen: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return nil, fmt.Errorf("SESSION_OPEN: Es läuft bereits ein Training (%s)", active.SetName)
|
||||
}
|
||||
|
||||
var setName string
|
||||
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND deleted_at IS NULL`, setID).Scan(&setName)
|
||||
err = s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Set %d existiert nicht", setID)
|
||||
}
|
||||
@@ -19,7 +28,7 @@ func (s *Store) CreateSession(setID int64) (*model.Session, error) {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`INSERT INTO sessions (set_id) VALUES (?)`, setID)
|
||||
result, err := s.db.Exec(`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, setID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session erstellen: %w", err)
|
||||
}
|
||||
@@ -28,7 +37,7 @@ func (s *Store) CreateSession(setID int64) (*model.Session, error) {
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// GetSession gibt eine Session mit allen Logs zurück.
|
||||
// GetSession gibt eine Session mit allen Logs zurück (intern, ohne User-Scope).
|
||||
func (s *Store) GetSession(id int64) (*model.Session, error) {
|
||||
var sess model.Session
|
||||
err := s.db.QueryRow(`
|
||||
@@ -52,11 +61,11 @@ func (s *Store) GetSession(id int64) (*model.Session, error) {
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// EndSession beendet eine Session.
|
||||
func (s *Store) EndSession(id int64, note string) (*model.Session, error) {
|
||||
// EndSession beendet eine Session eines Nutzers.
|
||||
func (s *Store) EndSession(id, userID 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,
|
||||
WHERE id = ? AND user_id = ? AND ended_at IS NULL`, note, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Session beenden: %w", err)
|
||||
@@ -68,14 +77,15 @@ func (s *Store) EndSession(id int64, note string) (*model.Session, error) {
|
||||
return s.GetSession(id)
|
||||
}
|
||||
|
||||
// ListSessions gibt paginierte Sessions zurück (neueste zuerst).
|
||||
func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) {
|
||||
// ListSessions gibt paginierte Sessions eines Nutzers zurück (neueste zuerst).
|
||||
func (s *Store) ListSessions(userID int64, 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
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT ? OFFSET ?`, limit, offset,
|
||||
LIMIT ? OFFSET ?`, userID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sessions abfragen: %w", err)
|
||||
@@ -96,14 +106,59 @@ func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) {
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// GetActiveSession gibt die offene Session eines Nutzers zurück (falls vorhanden).
|
||||
func (s *Store) GetActiveSession(userID int64) (*model.Session, error) {
|
||||
var sessionID int64
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id FROM sessions
|
||||
WHERE user_id = ? AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1`, userID,
|
||||
).Scan(&sessionID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Aktive Session suchen: %w", err)
|
||||
}
|
||||
return s.GetSession(sessionID)
|
||||
}
|
||||
|
||||
// GetSetExercises gibt die Übungen eines Training-Sets zurück.
|
||||
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.exercise_number, e.created_at, e.updated_at
|
||||
FROM exercises e
|
||||
JOIN set_exercises se ON se.exercise_id = e.id
|
||||
WHERE se.set_id = ? AND e.deleted_at IS NULL
|
||||
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.ExerciseNumber, &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()
|
||||
}
|
||||
|
||||
// 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
|
||||
// exercise_name wird denormalisiert gespeichert, damit historische Logs
|
||||
// erhalten bleiben wenn die Übung später gelöscht wird.
|
||||
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 {
|
||||
@@ -135,7 +190,6 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
|
||||
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 {
|
||||
@@ -145,7 +199,7 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Partielle Updates
|
||||
// Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update).
|
||||
updates := []string{}
|
||||
args := []any{}
|
||||
if req.WeightKg != nil {
|
||||
@@ -192,13 +246,15 @@ func (s *Store) DeleteLog(sessionID, logID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastLog gibt die letzten Werte einer Übung zurück.
|
||||
func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) {
|
||||
// GetLastLog gibt die letzten Werte einer Übung für einen Nutzer zurück.
|
||||
func (s *Store) GetLastLog(exerciseID, userID 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,
|
||||
SELECT sl.weight_kg, sl.reps
|
||||
FROM session_logs sl
|
||||
JOIN sessions s ON s.id = sl.session_id
|
||||
WHERE sl.exercise_id = ? AND s.user_id = ?
|
||||
ORDER BY sl.logged_at DESC LIMIT 1`, exerciseID, userID,
|
||||
).Scan(&resp.WeightKg, &resp.Reps)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -209,7 +265,39 @@ func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) {
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// checkSessionOpen prüft ob eine Session offen ist.
|
||||
// DeleteSession löscht eine abgeschlossene Session und alle zugehörigen Logs.
|
||||
// Gibt einen Fehler mit "SESSION_NOT_FOUND" zurück wenn die Session nicht existiert
|
||||
// oder nicht zum angegebenen Nutzer gehört. Gibt einen Fehler mit "SESSION_OPEN"
|
||||
// zurück wenn die Session noch nicht beendet wurde.
|
||||
func (s *Store) DeleteSession(id, userID int64) error {
|
||||
var endedAt *string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT ended_at FROM sessions WHERE id = ? AND user_id = ?`, id, userID,
|
||||
).Scan(&endedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("SESSION_NOT_FOUND: Session %d nicht gefunden", id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Session prüfen: %w", err)
|
||||
}
|
||||
if endedAt == nil {
|
||||
return fmt.Errorf("SESSION_OPEN: Session ist noch nicht beendet")
|
||||
}
|
||||
|
||||
// Logs zuerst löschen (kein ON DELETE CASCADE garantiert), dann Session
|
||||
_, err = s.db.Exec(`DELETE FROM session_logs WHERE session_id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Session-Logs löschen: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Session löschen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var endedAt *string
|
||||
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
||||
@@ -225,7 +313,7 @@ func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||
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) {
|
||||
var log model.SessionLog
|
||||
err := s.db.QueryRow(`
|
||||
@@ -241,7 +329,7 @@ func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||
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) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück.
|
||||
func (s *Store) ListSets() ([]model.TrainingSet, error) {
|
||||
// ListSets gibt alle nicht-gelöschten Sets eines Nutzers mit ihren Übungen zurück.
|
||||
func (s *Store) ListSets(userID int64) ([]model.TrainingSet, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, created_at FROM training_sets
|
||||
WHERE deleted_at IS NULL ORDER BY name`)
|
||||
WHERE deleted_at IS NULL AND user_id = ? ORDER BY name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Sets abfragen: %w", err)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (s *Store) ListSets() ([]model.TrainingSet, error) {
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// GetSet gibt ein einzelnes Set mit Übungen zurück.
|
||||
// GetSet gibt ein einzelnes Set mit Übungen zurück (intern, ohne User-Scope).
|
||||
func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
|
||||
var ts model.TrainingSet
|
||||
err := s.db.QueryRow(`
|
||||
@@ -63,17 +63,16 @@ func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
|
||||
}
|
||||
|
||||
// CreateSet legt ein neues Set an (in einer Transaktion).
|
||||
func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, error) {
|
||||
func (s *Store) CreateSet(userID int64, 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)
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
@@ -82,7 +81,7 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name)
|
||||
result, err := tx.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, req.Name, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set erstellen: %w", err)
|
||||
}
|
||||
@@ -102,17 +101,16 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen).
|
||||
func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) {
|
||||
// UpdateSet aktualisiert ein Set eines Nutzers (Name + Übungszuordnungen).
|
||||
func (s *Store) UpdateSet(id, userID 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)
|
||||
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, id, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set prüfen: %w", err)
|
||||
}
|
||||
@@ -120,10 +118,9 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin
|
||||
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)
|
||||
err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&eExists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung prüfen: %w", err)
|
||||
}
|
||||
@@ -155,11 +152,11 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin
|
||||
return s.GetSet(id)
|
||||
}
|
||||
|
||||
// SoftDeleteSet markiert ein Set als gelöscht.
|
||||
func (s *Store) SoftDeleteSet(id int64) error {
|
||||
// SoftDeleteSet markiert ein Set eines Nutzers als gelöscht.
|
||||
func (s *Store) SoftDeleteSet(id, userID 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,
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Set löschen: %w", err)
|
||||
@@ -171,7 +168,8 @@ func (s *Store) SoftDeleteSet(id int64) error {
|
||||
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) {
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// StatsOverview enthält die Gesamtübersicht.
|
||||
// StatsOverview enthält aggregierte Trainingsdaten eines Nutzers.
|
||||
type StatsOverview struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
@@ -13,16 +13,16 @@ type StatsOverview struct {
|
||||
Exercises []model.ExerciseStats `json:"exercises"`
|
||||
}
|
||||
|
||||
// GetStatsOverview gibt die Gesamtstatistik zurück.
|
||||
func (s *Store) GetStatsOverview() (*StatsOverview, error) {
|
||||
// GetStatsOverview gibt die Gesamtstatistik eines Nutzers zurück.
|
||||
func (s *Store) GetStatsOverview(userID int64) (*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)
|
||||
(SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL),
|
||||
(SELECT COALESCE(SUM(sl.weight_kg * sl.reps), 0) FROM session_logs sl JOIN sessions s ON s.id = sl.session_id WHERE s.user_id = ?),
|
||||
(SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL AND started_at >= date('now', '-7 days'))
|
||||
`, userID, userID, userID).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übersicht abfragen: %w", err)
|
||||
}
|
||||
@@ -36,8 +36,10 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) {
|
||||
COUNT(*) as total_sets,
|
||||
MAX(sl.logged_at) as last_trained
|
||||
FROM session_logs sl
|
||||
JOIN sessions s ON s.id = sl.session_id
|
||||
WHERE s.user_id = ?
|
||||
GROUP BY sl.exercise_id
|
||||
ORDER BY last_trained DESC`)
|
||||
ORDER BY last_trained DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err)
|
||||
}
|
||||
@@ -56,14 +58,15 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) {
|
||||
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) {
|
||||
// GetExerciseHistory gibt die letzten N Logs einer Übung für einen Nutzer zurück.
|
||||
func (s *Store) GetExerciseHistory(exerciseID, userID 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,
|
||||
SELECT sl.id, sl.session_id, sl.exercise_id, sl.exercise_name, sl.set_number, sl.weight_kg, sl.reps, sl.note, sl.logged_at
|
||||
FROM session_logs sl
|
||||
JOIN sessions s ON s.id = sl.session_id
|
||||
WHERE sl.exercise_id = ? AND s.user_id = ?
|
||||
ORDER BY sl.logged_at DESC
|
||||
LIMIT ?`, exerciseID, userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übungshistorie abfragen: %w", err)
|
||||
|
||||
74
backend/internal/store/user_store.go
Normal file
74
backend/internal/store/user_store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListUsers gibt alle Nutzer zurück.
|
||||
func (s *Store) ListUsers() ([]model.User, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, created_at FROM users ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Nutzer abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []model.User
|
||||
for rows.Next() {
|
||||
var u model.User
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Nutzer scannen: %w", err)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if users == nil {
|
||||
users = []model.User{}
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer an.
|
||||
func (s *Store) CreateUser(name string) (*model.User, error) {
|
||||
result, err := s.db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Nutzer erstellen: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return s.getUser(id)
|
||||
}
|
||||
|
||||
// DeleteUser löscht einen Nutzer, sofern noch mindestens ein weiterer existiert.
|
||||
func (s *Store) DeleteUser(id int64) error {
|
||||
var count int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("Nutzeranzahl prüfen: %w", err)
|
||||
}
|
||||
if count <= 1 {
|
||||
return fmt.Errorf("LAST_USER: letzter Nutzer kann nicht gelöscht werden")
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Nutzer löschen: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUser lädt einen Nutzer anhand seiner ID.
|
||||
func (s *Store) getUser(id int64) (*model.User, error) {
|
||||
var u model.User
|
||||
err := s.db.QueryRow(`SELECT id, name, created_at FROM users WHERE id = ?`, id).
|
||||
Scan(&u.ID, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Nutzer abfragen: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
4
backend/migrations/004_add_users.down.sql
Normal file
4
backend/migrations/004_add_users.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE sessions DROP COLUMN user_id;
|
||||
ALTER TABLE training_sets DROP COLUMN user_id;
|
||||
ALTER TABLE exercises DROP COLUMN user_id;
|
||||
DROP TABLE IF EXISTS users;
|
||||
12
backend/migrations/004_add_users.up.sql
Normal file
12
backend/migrations/004_add_users.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 50),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Standardnutzer für bestehende Daten
|
||||
INSERT INTO users (name) VALUES ('Standard');
|
||||
|
||||
ALTER TABLE exercises ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE training_sets ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1;
|
||||
1
backend/migrations/005_add_exercise_number.down.sql
Normal file
1
backend/migrations/005_add_exercise_number.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE exercises DROP COLUMN exercise_number;
|
||||
24
backend/migrations/005_add_exercise_number.up.sql
Normal file
24
backend/migrations/005_add_exercise_number.up.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE exercises ADD COLUMN exercise_number INTEGER;
|
||||
|
||||
-- Trainingsplan krafttraining2026: Übungsnummern (UF#) setzen
|
||||
UPDATE exercises SET exercise_number = 32 WHERE name = 'Bankdrücken sitzend' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 94 WHERE name = 'Trizepsdrücken Kabel (Pushdown)' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 35 WHERE name = 'Schrägbankdrücken einarmig' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 92 WHERE name = 'Trizepsstrecken überkopf' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 36 WHERE name = 'Fliegende stehend' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 99 WHERE name = 'Trizeps-Kickbacks einarmig' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 60 WHERE name = 'Schulterdrücken sitzend' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 66 WHERE name = 'Seitheben' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 42 WHERE name = 'Latzug zur Brust (Obergriff)' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 87 WHERE name = 'Bizeps-Curls mit Curl-Stange' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 45 WHERE name = 'Latzug Untergriff' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 83 WHERE name = 'Hammer-Curls' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 53 WHERE name = 'Rudern stehend' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 89 WHERE name = 'Konzentrations-Curls einarmig' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 54 WHERE name = 'Einarmiges Rudern Kabel' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 81 WHERE name = '21er-Curls (Finisher)' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 106 WHERE name = 'Crunches am Kabel' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 110 WHERE name = 'Rückenstrecker sitzend' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 107 WHERE name = 'Schräge Crunches' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 109 WHERE name = 'Seitbeugen einarmig' AND deleted_at IS NULL;
|
||||
UPDATE exercises SET exercise_number = 14 WHERE name = 'Hüftbeugen am Kabel' AND deleted_at IS NULL;
|
||||
1
backend/migrations/006_add_exercise_images.down.sql
Normal file
1
backend/migrations/006_add_exercise_images.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS exercise_images;
|
||||
9
backend/migrations/006_add_exercise_images.up.sql
Normal file
9
backend/migrations/006_add_exercise_images.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE exercise_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
filename TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exercise_images_exercise_id ON exercise_images(exercise_id);
|
||||
BIN
backend/server
BIN
backend/server
Binary file not shown.
148
deployment.md
148
deployment.md
@@ -1,70 +1,154 @@
|
||||
# Deployment
|
||||
|
||||
## Voraussetzungen
|
||||
Es gibt zwei Deployment-Varianten: **systemd** (Binary direkt) und **Docker** (empfohlen).
|
||||
|
||||
---
|
||||
|
||||
## Variante 1: Docker (empfohlen)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose auf dem Zielrechner
|
||||
- Quellcode (git clone)
|
||||
- SSH-Zugang zum Zielrechner
|
||||
|
||||
## Starten
|
||||
### Aktuelle Konfiguration (192.168.1.118)
|
||||
|
||||
| Parameter | Wert |
|
||||
|-----------|------|
|
||||
| User | `christoph` |
|
||||
| Quellcode | `/home/christoph/krafttrainer-src/` |
|
||||
| Datenbank | `/home/christoph/fitnesspad/krafttrainer.db` |
|
||||
| Port | `8090` |
|
||||
|
||||
Die `docker-compose.yml` mountet `/home/christoph/fitnesspad` als Bind-Mount in den Container (`/data`). Die DB liegt damit direkt auf dem Dateisystem des Servers — kein Docker-Volume.
|
||||
|
||||
### Erstinstallation
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
# 1. Quellcode auf den Server übertragen
|
||||
ssh christoph@192.168.1.118 "mkdir -p ~/krafttrainer-src ~/fitnesspad"
|
||||
git archive --format=tar HEAD | ssh christoph@192.168.1.118 "tar -x -C ~/krafttrainer-src"
|
||||
|
||||
# 2. Container bauen und starten (~2-3 Min. beim ersten Mal)
|
||||
ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose up --build -d"
|
||||
```
|
||||
|
||||
Beim ersten Start:
|
||||
- Docker baut das Image (Frontend + Go-Binary, dauert ~2 Min.)
|
||||
- Migrations laufen automatisch beim Start
|
||||
- Server lauscht auf Port `8090`
|
||||
Beim ersten Start laufen die Datenbankmigrationen automatisch.
|
||||
|
||||
## Nach Code-Änderungen
|
||||
### Update deployen
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
git archive --format=tar HEAD | ssh christoph@192.168.1.118 "tar -x -C ~/krafttrainer-src"
|
||||
ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose up --build -d"
|
||||
```
|
||||
|
||||
Docker-Layer-Cache beschleunigt den Rebuild:
|
||||
- `go.mod` unverändert → `go mod download` wird nicht wiederholt
|
||||
- `pnpm-lock.yaml` unverändert → `pnpm install` wird nicht wiederholt
|
||||
|
||||
## Logs
|
||||
### Datenbank einspielen
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
# Lokale DB auf den Server kopieren (überschreibt bestehende Daten!)
|
||||
scp backend/krafttrainer.db christoph@192.168.1.118:/home/christoph/fitnesspad/krafttrainer.db
|
||||
|
||||
# Container neu starten (Migrationen laufen automatisch)
|
||||
ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose down && docker compose up -d"
|
||||
```
|
||||
|
||||
## Stoppen
|
||||
> **Wichtig:** Immer `docker compose down && docker compose up -d` verwenden (nicht `docker restart`), damit Änderungen an der `docker-compose.yml` übernommen werden.
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
docker compose down # Container stoppen (DB bleibt erhalten)
|
||||
docker compose down -v # Container + DB-Volume löschen (Datenverlust!)
|
||||
ssh christoph@192.168.1.118 "docker logs krafttrainer-src-krafttrainer-1 -f"
|
||||
```
|
||||
|
||||
## Datenbank
|
||||
### Stoppen / Neustarten
|
||||
|
||||
Die SQLite-DB liegt im Docker-Volume `db-data` → `/data/krafttrainer.db` im Container.
|
||||
|
||||
**Backup:**
|
||||
```bash
|
||||
docker run --rm -v krafttrainer_db-data:/data -v $(pwd):/backup \
|
||||
debian:bookworm-slim cp /data/krafttrainer.db /backup/krafttrainer.db.bak
|
||||
ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose down"
|
||||
ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose up -d"
|
||||
```
|
||||
|
||||
**Restore:**
|
||||
### Datenbank-Backup
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm -v krafttrainer_db-data:/data -v $(pwd):/backup \
|
||||
debian:bookworm-slim cp /backup/krafttrainer.db.bak /data/krafttrainer.db
|
||||
docker compose up -d
|
||||
scp christoph@192.168.1.118:/home/christoph/fitnesspad/krafttrainer.db ./krafttrainer.db.bak
|
||||
```
|
||||
|
||||
## Image-Aufbau (3-Stage-Build)
|
||||
### Image-Aufbau (3-Stage-Build)
|
||||
|
||||
| Stage | Basis | Aufgabe |
|
||||
|-------|-------|---------|
|
||||
| 1 | `node:22-slim` | `pnpm build` → `frontend/dist/` |
|
||||
| 2 | `golang:1.24-bookworm` | Frontend einbetten + `go build` (CGO für SQLite) |
|
||||
| 2 | `golang:1.26-bookworm` | Frontend einbetten + `go build` (CGO für SQLite) |
|
||||
| 3 | `debian:bookworm-slim` | Nur Binary + glibc, ~100MB finales Image |
|
||||
|
||||
---
|
||||
|
||||
## Variante 2: systemd (Binary direkt)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Linux mit systemd
|
||||
- GCC/glibc (für CGO/SQLite) auf dem Build-Rechner
|
||||
- SSH-Zugang zum Zielrechner
|
||||
|
||||
### Erstinstallation
|
||||
|
||||
```bash
|
||||
# 1. Binary bauen (auf dem Entwicklungsrechner)
|
||||
make build
|
||||
|
||||
# 2. Verzeichnis auf dem Zielrechner anlegen
|
||||
ssh christoph@192.168.1.118 "mkdir -p ~/krafttrainer"
|
||||
|
||||
# 3. Binary übertragen
|
||||
scp krafttrainer christoph@192.168.1.118:~/krafttrainer/
|
||||
|
||||
# 4. Service-Datei installieren
|
||||
sudo cp krafttrainer.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable krafttrainer
|
||||
sudo systemctl start krafttrainer
|
||||
```
|
||||
|
||||
Die Service-Datei (`krafttrainer.service`) liegt im Repo-Root und geht von folgendem aus:
|
||||
|
||||
| Parameter | Wert |
|
||||
|-----------|------|
|
||||
| User | `christoph` |
|
||||
| Binary | `/home/christoph/krafttrainer/krafttrainer` |
|
||||
| Datenbank | `/home/christoph/krafttrainer/krafttrainer.db` |
|
||||
| Port | `8090` |
|
||||
|
||||
Anpassen falls User oder Pfade abweichen.
|
||||
|
||||
### Update deployen
|
||||
|
||||
```bash
|
||||
make build
|
||||
scp krafttrainer christoph@192.168.1.118:~/krafttrainer/
|
||||
ssh christoph@192.168.1.118 "sudo systemctl restart krafttrainer"
|
||||
```
|
||||
|
||||
### Service verwalten
|
||||
|
||||
```bash
|
||||
sudo systemctl status krafttrainer # Status
|
||||
sudo journalctl -u krafttrainer -f # Logs live
|
||||
sudo systemctl stop krafttrainer # Stoppen
|
||||
sudo systemctl restart krafttrainer # Neustarten
|
||||
```
|
||||
|
||||
### Service entfernen
|
||||
|
||||
```bash
|
||||
sudo systemctl stop krafttrainer
|
||||
sudo systemctl disable krafttrainer
|
||||
sudo rm /etc/systemd/system/krafttrainer.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Dienst |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
services:
|
||||
krafttrainer:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: "${VERSION:-dev}"
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- db-data:/data
|
||||
- /home/christoph/fitnesspad:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ExercisesPage } from './pages/ExercisesPage';
|
||||
import { SetsPage } from './pages/SetsPage';
|
||||
import { TrainingPage } from './pages/TrainingPage';
|
||||
import { HistoryPage } from './pages/HistoryPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -13,6 +14,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/sets', element: <SetsPage /> },
|
||||
{ path: '/training', element: <TrainingPage /> },
|
||||
{ path: '/history', element: <HistoryPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
Exercise,
|
||||
ExerciseImage,
|
||||
TrainingSet,
|
||||
Session,
|
||||
SessionLog,
|
||||
LastLogResponse,
|
||||
ExerciseStats,
|
||||
User,
|
||||
CreateExerciseRequest,
|
||||
CreateSetRequest,
|
||||
UpdateSetRequest,
|
||||
@@ -12,7 +14,13 @@ import type {
|
||||
CreateLogRequest,
|
||||
UpdateLogRequest,
|
||||
} from '../types';
|
||||
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 {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -23,12 +31,21 @@ 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>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const uid = getActiveUserId();
|
||||
if (uid) headers['X-User-ID'] = uid;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -45,7 +62,23 @@ async function request<T>(
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Typisiertes API-Objekt mit allen Backend-Endpunkten unter `/api/v1`. */
|
||||
export const api = {
|
||||
users: {
|
||||
list(): Promise<User[]> {
|
||||
return request<User[]>('/api/v1/users');
|
||||
},
|
||||
create(name: string): Promise<User> {
|
||||
return request<User>('/api/v1/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/users/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
},
|
||||
|
||||
exercises: {
|
||||
list(muscleGroup?: string, q?: string): Promise<Exercise[]> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -79,6 +112,41 @@ export const api = {
|
||||
return request<LastLogResponse>(`/api/v1/exercises/${id}/last-log`);
|
||||
},
|
||||
|
||||
listImages(id: number): Promise<ExerciseImage[]> {
|
||||
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> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const uid = getActiveUserId();
|
||||
if (uid) headers['X-User-ID'] = uid;
|
||||
|
||||
const res = await fetch(`/api/v1/exercises/${id}/images`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error || 'Upload fehlgeschlagen');
|
||||
}
|
||||
return data as ExerciseImage;
|
||||
},
|
||||
|
||||
deleteImage(exerciseId: number, imageId: number): Promise<void> {
|
||||
return request<void>(`/api/v1/exercises/${exerciseId}/images/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
history(id: number, limit?: number): Promise<SessionLog[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
@@ -116,6 +184,10 @@ export const api = {
|
||||
},
|
||||
|
||||
sessions: {
|
||||
active(): Promise<{ session: Session; exercises: Exercise[] } | null> {
|
||||
return request<{ session: Session; exercises: Exercise[] } | null>('/api/v1/sessions/active');
|
||||
},
|
||||
|
||||
create(data: CreateSessionRequest): Promise<Session> {
|
||||
return request<Session>('/api/v1/sessions', {
|
||||
method: 'POST',
|
||||
@@ -168,6 +240,12 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<void> {
|
||||
return request<void>(`/api/v1/sessions/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
stats: {
|
||||
@@ -175,4 +253,8 @@ export const api = {
|
||||
return request<ExerciseStats[]>('/api/v1/stats/overview');
|
||||
},
|
||||
},
|
||||
|
||||
version(): Promise<{ version: string }> {
|
||||
return request<{ version: string }>('/api/v1/version');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,10 @@ interface ExerciseCardProps {
|
||||
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) {
|
||||
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
|
||||
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
|
||||
@@ -15,7 +19,12 @@ export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps)
|
||||
<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>
|
||||
<h3 className="font-semibold text-gray-100 truncate">
|
||||
{exercise.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
|
||||
)}
|
||||
{exercise.name}
|
||||
</h3>
|
||||
{exercise.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS } from '../../types';
|
||||
import { ImageGallery } from './ImageGallery';
|
||||
|
||||
interface ExerciseFormProps {
|
||||
/** Vorhandene Übung beim Bearbeiten; `null`/`undefined` beim Erstellen. */
|
||||
exercise?: Exercise | null;
|
||||
onSubmit: (data: CreateExerciseRequest) => 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) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
|
||||
const [weightStep, setWeightStep] = useState(2.5);
|
||||
const [exerciseNumber, setExerciseNumber] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (exercise) {
|
||||
@@ -20,21 +28,25 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
setDescription(exercise.description);
|
||||
setMuscleGroup(exercise.muscle_group);
|
||||
setWeightStep(exercise.weight_step_kg);
|
||||
setExerciseNumber(exercise.exercise_number != null ? String(exercise.exercise_number) : '');
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setMuscleGroup('brust');
|
||||
setWeightStep(2.5);
|
||||
setExerciseNumber('');
|
||||
}
|
||||
}, [exercise]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const num = exerciseNumber.trim() ? parseInt(exerciseNumber.trim(), 10) : undefined;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
muscle_group: muscleGroup,
|
||||
weight_step_kg: weightStep,
|
||||
exercise_number: Number.isFinite(num) ? num : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,6 +70,18 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Übungsnummer (UF#)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={exerciseNumber}
|
||||
onChange={(e) => setExerciseNumber(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="Optional"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
@@ -95,6 +119,10 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exercise && (
|
||||
<ImageGallery exerciseId={exercise.id} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,11 @@ interface ExerciseListProps {
|
||||
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) {
|
||||
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
|
||||
|
||||
@@ -18,7 +23,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter-Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={filter.muscleGroup}
|
||||
@@ -41,7 +45,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-8">Laden...</div>
|
||||
) : exercises.length === 0 ? (
|
||||
|
||||
132
frontend/src/components/exercises/ImageGallery.tsx
Normal file
132
frontend/src/components/exercises/ImageGallery.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import type { ExerciseImage } from '../../types';
|
||||
|
||||
interface ImageGalleryProps {
|
||||
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) {
|
||||
const [images, setImages] = useState<ExerciseImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [viewImage, setViewImage] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadImages();
|
||||
}, [exerciseId]);
|
||||
|
||||
async function loadImages() {
|
||||
try {
|
||||
const imgs = await api.exercises.listImages(exerciseId);
|
||||
setImages(imgs || []);
|
||||
} catch {
|
||||
// Fehler ignorieren – Bilder sind optional
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== 'image/jpeg') {
|
||||
useToastStore.getState().addToast('error', 'Nur JPG-Bilder erlaubt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
useToastStore.getState().addToast('error', 'Datei zu groß (max 5 MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await api.exercises.uploadImage(exerciseId, file);
|
||||
useToastStore.getState().addToast('success', 'Bild hochgeladen');
|
||||
await loadImages();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(imageId: number) {
|
||||
try {
|
||||
await api.exercises.deleteImage(exerciseId, imageId);
|
||||
useToastStore.getState().addToast('success', 'Bild gelöscht');
|
||||
setImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Löschen fehlgeschlagen';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-gray-400">Bilder</label>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={`/uploads/${img.filename}`}
|
||||
alt="Übungsbild"
|
||||
className="w-full h-24 object-cover rounded-lg cursor-pointer"
|
||||
onClick={() => setViewImage(img.filename)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(img.id)}
|
||||
className="absolute top-1 right-1 w-6 h-6 bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full py-2 border border-dashed border-gray-600 rounded-lg text-sm text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-colors min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : '+ Bild hinzufügen (JPG)'}
|
||||
</button>
|
||||
|
||||
{viewImage && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setViewImage(null)}
|
||||
>
|
||||
<img
|
||||
src={`/uploads/${viewImage}`}
|
||||
alt="Übungsbild"
|
||||
className="max-w-full max-h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { api } from '../../api/client';
|
||||
import { useExerciseStore } from '../../stores/exerciseStore';
|
||||
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() {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -32,7 +36,7 @@ export function ExerciseChart() {
|
||||
api.exercises
|
||||
.history(selectedId, 50)
|
||||
.then((logs: SessionLog[]) => {
|
||||
// Gruppiere nach Datum, nehme max Gewicht pro Tag
|
||||
// Pro Datum das Maximalgewicht ermitteln
|
||||
const byDate = new Map<string, number>();
|
||||
for (const log of logs) {
|
||||
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||
|
||||
@@ -4,12 +4,16 @@ interface SessionDetailProps {
|
||||
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) {
|
||||
if (!logs || logs.length === 0) {
|
||||
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[] }>();
|
||||
for (const log of logs) {
|
||||
if (!grouped.has(log.exercise_id)) {
|
||||
|
||||
@@ -2,12 +2,19 @@ import { useEffect, useState } from 'react';
|
||||
import { useHistoryStore } from '../../stores/historyStore';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import type { Session } from '../../types';
|
||||
import { api } from '../../api/client';
|
||||
import { api, ApiError } from '../../api/client';
|
||||
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() {
|
||||
const { sessions, loading, fetchSessions } = useHistoryStore();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedSession, setExpandedSession] = useState<Session | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(50);
|
||||
@@ -28,6 +35,7 @@ export function SessionList() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Formatiert ein ISO-Datum als lokalisiertes Datum mit Uhrzeit. */
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
@@ -38,6 +46,7 @@ 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();
|
||||
@@ -48,6 +57,31 @@ export function SessionList() {
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: number) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Session wirklich löschen? Alle Sätze werden unwiderruflich entfernt.')) return;
|
||||
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await api.sessions.delete(id);
|
||||
useToastStore.getState().addToast('success', 'Session erfolgreich gelöscht');
|
||||
// Aufgeklappte Session schließen, falls sie gerade gelöscht wurde
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
setExpandedSession(null);
|
||||
}
|
||||
fetchSessions(50);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'Session konnte nicht gelöscht werden';
|
||||
useToastStore.getState().addToast('error', message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
@@ -68,9 +102,10 @@ export function SessionList() {
|
||||
key={session.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
<button
|
||||
onClick={() => toggleSession(session.id)}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
className="flex-1 px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -95,6 +130,27 @@ export function SessionList() {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{session.ended_at && (
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
disabled={deletingId === session.id}
|
||||
className="flex items-center justify-center w-11 min-h-[44px] text-gray-500 hover:text-red-400 hover:bg-gray-800/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-l border-gray-800"
|
||||
title="Session löschen"
|
||||
aria-label="Session löschen"
|
||||
>
|
||||
{deletingId === session.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<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>
|
||||
{expandedId === session.id && expandedSession && (
|
||||
<div className="px-4 pb-4 border-t border-gray-800 pt-3">
|
||||
<SessionDetail logs={expandedSession.logs || []} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useUserStore } from '../../stores/userStore';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
@@ -37,8 +38,19 @@ const navItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: 'Einstellungen',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Mobile Bottom-Navigation mit Links zu allen Hauptseiten. */
|
||||
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">
|
||||
@@ -63,11 +75,14 @@ export function BottomNav() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Desktop-Seitenleiste mit Navigation und aktivem Nutzer-Indikator. */
|
||||
export function Sidebar() {
|
||||
const { activeUser } = useUserStore();
|
||||
|
||||
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">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -86,6 +101,14 @@ export function Sidebar() {
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
{activeUser && (
|
||||
<div className="pt-4 border-t border-gray-800 flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{activeUser.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-gray-300 truncate">{activeUser.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BottomNav, Sidebar } from './BottomNav';
|
||||
import { ToastContainer } from './Toast';
|
||||
import { useUserStore } from '../../stores/userStore';
|
||||
|
||||
function UserGate({ children }: { children: React.ReactNode }) {
|
||||
const { users, activeUser, setActiveUser, fetchUsers, createUser } = useUserStore();
|
||||
const [newName, setNewName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers().then(() => setFetched(true));
|
||||
}, [fetchUsers]);
|
||||
|
||||
if (!fetched) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="text-gray-400">Laden…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeUser) {
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
const user = await createUser(name);
|
||||
setLoading(false);
|
||||
if (user) setActiveUser(user);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-blue-500 mb-1">Krafttrainer</h1>
|
||||
<p className="text-gray-400">Wer trainiert heute?</p>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
onClick={() => setActiveUser(user)}
|
||||
className="w-full flex items-center gap-3 bg-gray-900 hover:bg-gray-800 rounded-lg px-4 py-3 min-h-[56px] transition-colors text-left"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-gray-100 font-medium">{user.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<p className="text-sm text-gray-400">
|
||||
{users.length === 0 ? 'Ersten Nutzer anlegen:' : 'Oder neuen Nutzer anlegen:'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newName.trim()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg font-medium min-h-[44px] transition-colors"
|
||||
>
|
||||
Los
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function PageShell() {
|
||||
return (
|
||||
<UserGate>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
@@ -14,5 +103,6 @@ export function PageShell() {
|
||||
<BottomNav />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</UserGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ interface SetDetailProps {
|
||||
trainingSet: TrainingSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt die Übungsliste eines Trainings-Sets mit Muskelgruppen-Badges.
|
||||
* Wird in der Set-Übersicht als Detailansicht verwendet.
|
||||
*/
|
||||
export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
@@ -19,7 +23,12 @@ export function SetDetail({ trainingSet }: SetDetailProps) {
|
||||
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="flex-1 text-gray-200">
|
||||
{ex.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{ex.exercise_number}</span>
|
||||
)}
|
||||
{ex.name}
|
||||
</span>
|
||||
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -4,11 +4,17 @@ import type { TrainingSet, MuscleGroup } from '../../types';
|
||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
|
||||
|
||||
interface SetFormProps {
|
||||
/** Vorhandenes Set beim Bearbeiten; `null`/`undefined` beim Erstellen. */
|
||||
trainingSet?: TrainingSet | null;
|
||||
onSubmit: (name: string, exerciseIds: number[]) => 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) {
|
||||
const { exercises, fetchExercises } = useExerciseStore();
|
||||
const [name, setName] = useState('');
|
||||
@@ -16,7 +22,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Lade alle Übungen ohne Filter
|
||||
// Alle Übungen ohne aktiven Filter laden
|
||||
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
|
||||
fetchExercises();
|
||||
}, [fetchExercises]);
|
||||
@@ -66,6 +72,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
|
||||
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]));
|
||||
|
||||
return (
|
||||
@@ -86,7 +93,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Übungsauswahl */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
|
||||
<select
|
||||
@@ -127,7 +133,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sortierbare ausgewählte Übungen */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
|
||||
@@ -7,6 +7,10 @@ interface SetListProps {
|
||||
onDelete: (set: TrainingSet) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listet alle Trainings-Sets mit ihren Übungen sowie Bearbeiten-
|
||||
* und Löschen-Aktionen auf.
|
||||
*/
|
||||
export function SetList({ onEdit, onDelete }: SetListProps) {
|
||||
const { sets, loading, fetchSets } = useSetStore();
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { ConfirmDialog } from '../layout/ConfirmDialog';
|
||||
import { LogEntryForm } from './LogEntryForm';
|
||||
import { RestTimer } from './RestTimer';
|
||||
import { ExerciseSparkline } from './ExerciseSparkline';
|
||||
import type { Exercise, SessionLog } from '../../types';
|
||||
|
||||
interface ActiveSessionProps {
|
||||
/** Wird aufgerufen, nachdem das Training erfolgreich beendet wurde. */
|
||||
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) {
|
||||
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
|
||||
useActiveSessionStore();
|
||||
@@ -21,11 +28,13 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
|
||||
const logs = session.logs || [];
|
||||
|
||||
/** Gibt alle protokollierten Sätze für eine Übung sortiert nach Satznummer zurück. */
|
||||
const getExerciseLogs = (exerciseId: number) =>
|
||||
logs
|
||||
.filter((l) => l.exercise_id === exerciseId)
|
||||
.sort((a, b) => a.set_number - b.set_number);
|
||||
|
||||
/** Berechnet die nächste Satznummer als Maximum der bisherigen + 1. */
|
||||
const getNextSetNumber = (exerciseId: number) => {
|
||||
const exLogs = getExerciseLogs(exerciseId);
|
||||
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
|
||||
@@ -92,7 +101,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
|
||||
<RestTimer />
|
||||
|
||||
{/* Übungen als Accordion */}
|
||||
{exercises.map((exercise) => {
|
||||
const exLogs = getExerciseLogs(exercise.id);
|
||||
const isExpanded = expandedExercise === exercise.id;
|
||||
@@ -103,13 +111,17 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
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="font-semibold text-gray-100">
|
||||
{exercise.exercise_number != null && (
|
||||
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
|
||||
)}
|
||||
{exercise.name}
|
||||
</span>
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
|
||||
</span>
|
||||
@@ -125,17 +137,16 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Vorherige Werte */}
|
||||
<ExerciseSparkline exerciseId={exercise.id} />
|
||||
|
||||
{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 ? (
|
||||
@@ -189,7 +200,11 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
</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
|
||||
key={`new-${exercise.id}-${exLogs.length}`}
|
||||
exercise={exercise}
|
||||
@@ -204,7 +219,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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]"
|
||||
|
||||
104
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
104
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { api } from '../../api/client';
|
||||
import type { SessionLog } from '../../types';
|
||||
|
||||
interface ExerciseSparklineProps {
|
||||
exerciseId: number;
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
date: string;
|
||||
e1rm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den geschätzten 1-Wiederholungs-Maximalwert (e1RM) nach der Epley-Formel:
|
||||
* e1RM = Gewicht × (1 + Wiederholungen / 30)
|
||||
*/
|
||||
function calcE1RM(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
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) {
|
||||
const [data, setData] = useState<DataPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.exercises
|
||||
.history(exerciseId, 100)
|
||||
.then((logs: SessionLog[]) => {
|
||||
// Pro Session den besten e1RM ermitteln
|
||||
const bySession = new Map<number, { date: string; e1rm: number }>();
|
||||
for (const log of logs) {
|
||||
const e1rm = calcE1RM(log.weight_kg, log.reps);
|
||||
const current = bySession.get(log.session_id);
|
||||
if (!current || e1rm > current.e1rm) {
|
||||
bySession.set(log.session_id, {
|
||||
date: new Date(log.logged_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
}),
|
||||
e1rm,
|
||||
});
|
||||
}
|
||||
}
|
||||
// API liefert neueste Einträge zuerst; umkehren für chronologische Darstellung
|
||||
const points = Array.from(bySession.values()).reverse();
|
||||
setData(points);
|
||||
})
|
||||
.catch(() => setData([]));
|
||||
}, [exerciseId]);
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const trend = data[data.length - 1].e1rm - data[0].e1rm;
|
||||
const trendColor = trend >= 0 ? '#22C55E' : '#EF4444';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-500">Fortschritt (e1RM)</span>
|
||||
<span className="text-xs font-medium" style={{ color: trendColor }}>
|
||||
{trend >= 0 ? '+' : ''}{trend.toFixed(1)} kg
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-16">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<YAxis hide domain={['dataMin - 2', 'dataMax + 2']} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '8px',
|
||||
color: '#F3F4F6',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value) => [`${value} kg`, 'e1RM']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="e1rm"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#3B82F6' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,15 @@ interface LogEntryFormProps {
|
||||
initialWeight?: number;
|
||||
initialReps?: number;
|
||||
onSubmit: (weight: number, reps: number, note: string) => void;
|
||||
/** Beschriftung des Speichern-Buttons; Standard: "Satz speichern". */
|
||||
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({
|
||||
exercise,
|
||||
setNumber,
|
||||
@@ -41,7 +47,6 @@ export function LogEntryForm({
|
||||
<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">
|
||||
@@ -84,7 +89,6 @@ export function LogEntryForm({
|
||||
</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">
|
||||
@@ -112,7 +116,6 @@ export function LogEntryForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notiz */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useActiveSessionStore } from '../../stores/activeSessionStore';
|
||||
|
||||
/** Voreingestellte Pausenzeiten in Sekunden. */
|
||||
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() {
|
||||
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
|
||||
const isRunning = timerSeconds > 0;
|
||||
|
||||
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
|
||||
|
||||
/** Formatiert Sekunden als `m:ss`. */
|
||||
const formatTime = (s: number) => {
|
||||
const mins = Math.floor(s / 60);
|
||||
const secs = s % 60;
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { SessionList } from '../components/history/SessionList';
|
||||
import { ExerciseChart } from '../components/history/ExerciseChart';
|
||||
import { getActiveUserId } from '../stores/userStore';
|
||||
|
||||
type Tab = 'history' | 'stats';
|
||||
|
||||
async function downloadExport() {
|
||||
const uid = getActiveUserId();
|
||||
const headers: Record<string, string> = {};
|
||||
if (uid) headers['X-User-ID'] = uid;
|
||||
|
||||
const res = await fetch('/api/v1/export', { headers });
|
||||
if (!res.ok) return;
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `training-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function HistoryPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('history');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Historie</h1>
|
||||
<button
|
||||
onClick={downloadExport}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-800 text-gray-300 hover:text-white hover:bg-gray-700 text-sm min-h-[44px] transition-colors"
|
||||
title="Trainingsdaten als CSV exportieren"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Toggle */}
|
||||
<div className="flex bg-gray-900 rounded-lg p-1">
|
||||
|
||||
102
frontend/src/pages/SettingsPage.tsx
Normal file
102
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { users, activeUser, setActiveUser, fetchUsers, createUser, deleteUser } =
|
||||
useUserStore();
|
||||
const [newName, setNewName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
api.version().then((v) => setVersion(v.version)).catch(() => {});
|
||||
}, [fetchUsers]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
const user = await createUser(name);
|
||||
setLoading(false);
|
||||
if (user) setNewName('');
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await deleteUser(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Einstellungen</h1>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-200">Nutzer</h2>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex items-center justify-between bg-gray-900 rounded-lg px-4 py-3"
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveUser(user)}
|
||||
className="flex items-center gap-3 flex-1 text-left"
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
activeUser?.id === user.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-gray-100">{user.name}</span>
|
||||
{activeUser?.id === user.id && (
|
||||
<span className="text-xs text-blue-400 font-medium">Aktiv</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{users.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-400 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
title="Nutzer löschen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Neuer Nutzername"
|
||||
maxLength={50}
|
||||
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 min-h-[44px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newName.trim()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg font-medium min-h-[44px] transition-colors"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{version && (
|
||||
<p className="text-xs text-gray-600 pt-4">Version {version}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,19 @@ import { ConfirmDialog } from '../components/layout/ConfirmDialog';
|
||||
export function TrainingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { sets, fetchSets, loading } = useSetStore();
|
||||
const { session, startSession } = useActiveSessionStore();
|
||||
const { session, startSession, resumeSession } = useActiveSessionStore();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [resuming, setResuming] = useState(true);
|
||||
const blocker = useNavigationGuard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets();
|
||||
// Prüfe ob eine offene Session existiert und setze sie fort
|
||||
if (!session) {
|
||||
resumeSession().finally(() => setResuming(false));
|
||||
} else {
|
||||
setResuming(false);
|
||||
}
|
||||
}, [fetchSets]);
|
||||
|
||||
const handleStart = async (setId: number) => {
|
||||
@@ -52,6 +59,11 @@ export function TrainingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Laden
|
||||
if (resuming) {
|
||||
return <div className="text-center text-gray-500 py-8">Laden...</div>;
|
||||
}
|
||||
|
||||
// Set-Auswahl
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -13,12 +13,16 @@ import { useToastStore } from './toastStore';
|
||||
interface ActiveSessionState {
|
||||
session: Session | null;
|
||||
exercises: Exercise[];
|
||||
/** Zuletzt protokollierte Werte pro Übungs-ID – wird für Vorausfüllung genutzt. */
|
||||
lastLogs: Map<number, LastLogResponse>;
|
||||
/** Verbleibende Sekunden des laufenden Pause-Timers. */
|
||||
timerSeconds: number;
|
||||
/** Ursprünglich eingestellte Sekunden – für Fortschrittsbalken-Berechnung. */
|
||||
timerTarget: number;
|
||||
timerInterval: ReturnType<typeof setInterval> | null;
|
||||
|
||||
startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>;
|
||||
resumeSession: () => Promise<boolean>;
|
||||
loadSession: (sessionId: number) => Promise<boolean>;
|
||||
addLog: (data: CreateLogRequest) => Promise<SessionLog | null>;
|
||||
updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>;
|
||||
@@ -31,6 +35,13 @@ interface ActiveSessionState {
|
||||
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) => ({
|
||||
session: null,
|
||||
exercises: [],
|
||||
@@ -44,7 +55,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
const session = await api.sessions.create({ set_id: setId });
|
||||
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) {
|
||||
await get().fetchLastLog(ex.id);
|
||||
}
|
||||
@@ -57,6 +68,23 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
resumeSession: async () => {
|
||||
try {
|
||||
const result = await api.sessions.active();
|
||||
if (!result || !result.session) return false;
|
||||
|
||||
set({ session: result.session, exercises: result.exercises || [] });
|
||||
|
||||
for (const ex of (result.exercises || [])) {
|
||||
await get().fetchLastLog(ex.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
loadSession: async (sessionId) => {
|
||||
try {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
@@ -74,7 +102,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
if (!session) return null;
|
||||
try {
|
||||
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);
|
||||
set({ session: updated });
|
||||
return log;
|
||||
@@ -142,7 +170,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
|
||||
});
|
||||
return lastLog;
|
||||
} catch {
|
||||
// 404 = noch kein Log vorhanden
|
||||
// 404 bedeutet: noch kein Trainingseintrag für diese Übung vorhanden
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api/client';
|
||||
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
|
||||
import { useToastStore } from './toastStore';
|
||||
|
||||
/** Filterkriterien für die Übungsliste. */
|
||||
interface ExerciseFilter {
|
||||
muscleGroup: MuscleGroup | '';
|
||||
query: string;
|
||||
@@ -20,6 +21,7 @@ interface ExerciseState {
|
||||
setFilter: (filter: Partial<ExerciseFilter>) => void;
|
||||
}
|
||||
|
||||
/** Store für Übungsverwaltung inkl. Filterung und CRUD-Operationen. */
|
||||
export const useExerciseStore = create<ExerciseState>((set, get) => ({
|
||||
exercises: [],
|
||||
loading: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ interface HistoryState {
|
||||
fetchStats: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Store für Trainings-Historie und Übungsstatistiken. */
|
||||
export const useHistoryStore = create<HistoryState>((set) => ({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SetState {
|
||||
deleteSet: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Store für Trainings-Sets inkl. CRUD-Operationen. */
|
||||
export const useSetStore = create<SetState>((set, get) => ({
|
||||
sets: [],
|
||||
loading: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** Eine einzelne Toast-Benachrichtigung. */
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
@@ -12,6 +13,13 @@ interface ToastState {
|
||||
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) => ({
|
||||
toasts: [],
|
||||
|
||||
|
||||
84
frontend/src/stores/userStore.ts
Normal file
84
frontend/src/stores/userStore.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '../types';
|
||||
|
||||
interface UserState {
|
||||
users: User[];
|
||||
activeUser: User | null;
|
||||
setActiveUser: (user: User) => void;
|
||||
fetchUsers: () => Promise<void>;
|
||||
createUser: (name: string) => Promise<User | null>;
|
||||
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>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
users: [],
|
||||
activeUser: null,
|
||||
|
||||
setActiveUser: (user) => set({ activeUser: user }),
|
||||
|
||||
fetchUsers: async () => {
|
||||
const res = await fetch('/api/v1/users', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const users: User[] = await res.json();
|
||||
set({ users });
|
||||
|
||||
// Aktiven Nutzer aktualisieren, falls sich seine Daten geändert haben
|
||||
const { activeUser } = get();
|
||||
if (activeUser) {
|
||||
const updated = users.find((u) => u.id === activeUser.id);
|
||||
if (updated) set({ activeUser: updated });
|
||||
}
|
||||
},
|
||||
|
||||
createUser: async (name) => {
|
||||
const res = await fetch('/api/v1/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const user: User = await res.json();
|
||||
set((s) => ({ users: [...s.users, user] }));
|
||||
return user;
|
||||
},
|
||||
|
||||
deleteUser: async (id) => {
|
||||
const res = await fetch(`/api/v1/users/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) return false;
|
||||
set((s) => ({
|
||||
users: s.users.filter((u) => u.id !== id),
|
||||
activeUser: s.activeUser?.id === id ? null : s.activeUser,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'user-store',
|
||||
partialize: (state) => ({ activeUser: state.activeUser }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const { activeUser } = useUserStore.getState();
|
||||
return activeUser ? String(activeUser.id) : null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Bezeichner für Muskelgruppen – spiegeln die DB-Enum-Werte wider. */
|
||||
export type MuscleGroup =
|
||||
| 'brust'
|
||||
| 'ruecken'
|
||||
@@ -9,6 +10,7 @@ export type MuscleGroup =
|
||||
| 'ganzkoerper'
|
||||
| 'sonstiges';
|
||||
|
||||
/** Alle Muskelgruppen mit Anzeigebezeichnungen für Select-Felder. */
|
||||
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
||||
{ value: 'brust', label: 'Brust' },
|
||||
{ value: 'ruecken', label: 'Rücken' },
|
||||
@@ -21,6 +23,7 @@ export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
|
||||
{ value: 'sonstiges', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
/** Anzeigebezeichnungen für Muskelgruppen, direkt per Schlüssel abrufbar. */
|
||||
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
||||
brust: 'Brust',
|
||||
ruecken: 'Rücken',
|
||||
@@ -33,6 +36,7 @@ export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
/** Tailwind-Hintergrundfarben für Muskelgruppen-Badges. */
|
||||
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
||||
brust: 'bg-red-600',
|
||||
ruecken: 'bg-blue-600',
|
||||
@@ -45,17 +49,21 @@ export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
|
||||
sonstiges: 'bg-gray-600',
|
||||
};
|
||||
|
||||
/** Eine einzelne Kraftübung aus der Datenbank. */
|
||||
export interface Exercise {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg: number;
|
||||
/** Optionale Nummerierung aus dem Übungskatalog (UF#). */
|
||||
exercise_number?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
/** Ein Trainings-Set: Sammlung von Übungen, die gemeinsam trainiert werden. */
|
||||
export interface TrainingSet {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -64,6 +72,11 @@ export interface TrainingSet {
|
||||
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 {
|
||||
id: number;
|
||||
session_id: number;
|
||||
@@ -76,6 +89,7 @@ export interface SessionLog {
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
/** Eine Trainings-Session, optional mit allen protokollierten Sätzen. */
|
||||
export interface Session {
|
||||
id: number;
|
||||
set_id: number;
|
||||
@@ -86,11 +100,13 @@ export interface Session {
|
||||
logs?: SessionLog[];
|
||||
}
|
||||
|
||||
/** Antwort des Endpunkts "letztes Training" für eine Übung. */
|
||||
export interface LastLogResponse {
|
||||
weight_kg: number;
|
||||
reps: number;
|
||||
}
|
||||
|
||||
/** Aggregierte Statistiken für eine einzelne Übung. */
|
||||
export interface ExerciseStats {
|
||||
exercise_id: number;
|
||||
exercise_name: string;
|
||||
@@ -100,27 +116,54 @@ export interface ExerciseStats {
|
||||
last_trained: string;
|
||||
}
|
||||
|
||||
/** Metadaten eines hochgeladenen Übungsbilds. */
|
||||
export interface ExerciseImage {
|
||||
id: number;
|
||||
exercise_id: number;
|
||||
filename: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Request-Body zum Erstellen oder Aktualisieren einer Übung. */
|
||||
export interface CreateExerciseRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
muscle_group: MuscleGroup;
|
||||
weight_step_kg?: number;
|
||||
exercise_number?: number;
|
||||
}
|
||||
|
||||
/** Request-Body zum Erstellen eines Trainings-Sets. */
|
||||
export interface CreateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
/** Request-Body zum Aktualisieren eines Trainings-Sets. */
|
||||
export interface UpdateSetRequest {
|
||||
name: string;
|
||||
exercise_ids: number[];
|
||||
}
|
||||
|
||||
/** Request-Body zum Starten einer neuen Trainings-Session. */
|
||||
export interface CreateSessionRequest {
|
||||
set_id: number;
|
||||
}
|
||||
|
||||
/** Anwendungsnutzer. */
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Request-Body zum Anlegen eines neuen Nutzers. */
|
||||
export interface CreateUserRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Request-Body zum Protokollieren eines Satzes in einer laufenden Session. */
|
||||
export interface CreateLogRequest {
|
||||
exercise_id: number;
|
||||
set_number: number;
|
||||
@@ -129,6 +172,7 @@ export interface CreateLogRequest {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Request-Body zum nachträglichen Bearbeiten eines protokollierten Satzes. */
|
||||
export interface UpdateLogRequest {
|
||||
weight_kg?: number;
|
||||
reps?: number;
|
||||
|
||||
17
krafttrainer.service
Normal file
17
krafttrainer.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Krafttrainer Web App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=christoph
|
||||
WorkingDirectory=/home/christoph/krafttrainer
|
||||
ExecStart=/home/christoph/krafttrainer/krafttrainer
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Environment=PORT=8090
|
||||
Environment=DB_PATH=/home/christoph/krafttrainer/krafttrainer.db
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
23
scripts/backup-db.sh
Executable file
23
scripts/backup-db.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Erstellt ein Backup der Krafttrainer-Datenbank vom Server.
|
||||
# Verwendung: ./scripts/backup-db.sh [user@host]
|
||||
#
|
||||
# Standard-Ziel: christoph@192.168.1.118
|
||||
# Backups werden in ./backups/ gespeichert.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${1:-christoph@192.168.1.118}"
|
||||
REMOTE_DB="/home/christoph/fitnesspad/krafttrainer.db"
|
||||
BACKUP_DIR="$(dirname "$0")/../backups"
|
||||
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
BACKUP_FILE="${BACKUP_DIR}/krafttrainer_${TIMESTAMP}.db"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "Backup von ${REMOTE}:${REMOTE_DB} ..."
|
||||
scp "${REMOTE}:${REMOTE_DB}" "${BACKUP_FILE}"
|
||||
echo "Gespeichert: ${BACKUP_FILE}"
|
||||
|
||||
# Alte Backups (älter als 30 Tage) löschen
|
||||
find "$BACKUP_DIR" -name "krafttrainer_*.db" -mtime +30 -delete 2>/dev/null && true
|
||||
Reference in New Issue
Block a user