Compare commits

..

10 Commits

Author SHA1 Message Date
Christoph K.
4db170b467 init 2026-04-07 09:49:17 +02:00
Christoph K.
063aa67615 Add exercise numbers, image uploads, version display, session resume, and training sparklines
- Exercise number (UF#): optional field on exercises, displayed in cards, training, and sets
- Import training plan numbers via migration 005 (UPDATE by name)
- Exercise images: JPG upload with multi-image support per exercise (migration 006)
- Version endpoint (GET /api/v1/version) with ldflags injection in Makefile and Dockerfile
- Version displayed on settings page
- Session resume: GET /api/v1/sessions/active endpoint, auto-resume on training page load
- Block new session while one is active (409 Conflict)
- e1RM sparkline chart per exercise during training (Epley formula)
- Fix CORS: add X-User-ID to allowed headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:37:29 +01:00
Christoph K.
833ad04a6f Add delete session functionality
- DELETE /api/v1/sessions/{id}: only closed sessions, user-scoped
- Returns 404 if not found/wrong user, 409 if session still open
- Deletes session_logs first, then session (no CASCADE)
- Frontend: trash button per session in SessionList (closed sessions only)
- Confirm dialog before delete, toast feedback, list reloads after

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:50:48 +01:00
Christoph K.
6d7d353ea2 Add database backup script
scripts/backup-db.sh copies the remote DB via scp to ./backups/
with a timestamp. Backups older than 30 days are auto-deleted.
Backup directory is gitignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:43:58 +01:00
Christoph K.
344bcfc755 Update deployment docs to reflect current Docker setup
- Docker is now the recommended variant
- Documents bind mount at /home/christoph/fitnesspad instead of Docker volume
- Adds DB import workflow via scp + docker compose down/up
- Notes that docker restart does NOT pick up compose changes
- Updates Go version in image table to 1.26
- Removes outdated Docker volume backup commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:43:21 +01:00
Christoph K.
f601c2030e Switch Docker volume to bind mount at /home/christoph/fitnesspad
Makes the database directly accessible on the host filesystem instead
of an opaque Docker volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:31:41 +01:00
Christoph K.
17dc9dbf3b Update Dockerfile to Go 1.26 to match go.mod requirement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:01:11 +01:00
Christoph K.
c992e2775c Add systemd service file and installation guide
Adds krafttrainer.service for running as a systemd unit and expands
deployment.md with step-by-step instructions for both systemd and Docker deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:59:04 +01:00
Christoph K.
a954f2c59d Add multi-user support with export feature
- New users table (migration 004) with user_id on exercises, training_sets, sessions
- User CRUD endpoints (GET/POST /api/v1/users, DELETE /api/v1/users/{id})
- All existing endpoints scoped to X-User-ID header
- CSV export endpoint (GET /api/v1/export) for completed sessions
- UserGate in PageShell: blocks app until a user is selected
- Settings page for managing users (create, switch, delete)
- BottomNav/Sidebar updated with settings navigation
- Fix: nil pointer panic in handleDeleteUser on success path
- Fix: export download now uses fetch with X-User-ID header instead of window.location.href

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:55:51 +01:00
Christoph K.
bff85908c3 make file update 2026-03-21 18:49:46 +01:00
69 changed files with 2799 additions and 213 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ backend/vendor/
# OS
.DS_Store
backups/

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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) })
```

View File

@@ -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))

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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

View 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()
}

View File

@@ -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
}

View 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)
}
}

View File

@@ -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)

View File

@@ -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 {

View 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)
}
}

View File

@@ -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

View File

@@ -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

View 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)
}

View File

@@ -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.

View 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"`
}

View 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
}

View File

@@ -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)

View 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()
}

View 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
}

View 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")
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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
}

View 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;

View 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;

View File

@@ -0,0 +1 @@
ALTER TABLE exercises DROP COLUMN exercise_number;

View 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;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS exercise_images;

View 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);

Binary file not shown.

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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 /> },
],
},
]);

View File

@@ -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');
},
};

View File

@@ -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>
)}

View File

@@ -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"

View File

@@ -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 ? (

View 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>
);
}

View File

@@ -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', {

View File

@@ -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)) {

View File

@@ -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 || []} />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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();

View File

@@ -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]"

View 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>
);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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">

View 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>
);
}

View File

@@ -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">

View File

@@ -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;
}
},

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: [],

View 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;
}

View File

@@ -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
View 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
View 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