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 # OS
.DS_Store .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 - Nicht gefunden (`sql.ErrNoRows`) → 404
- UNIQUE-Verletzung → 409 - 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 ### 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) - `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)` - UNIQUE-Constraint auf `(session_id, exercise_id, set_number)`
- Soft-Delete bei Übungen via `deleted_at` Timestamp - 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 ## Konventionen

View File

@@ -8,14 +8,15 @@ COPY frontend/ ./
RUN pnpm build RUN pnpm build
# Stage 2: Go-Binary bauen (CGO nötig für go-sqlite3) # 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 WORKDIR /app
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY backend/ ./ COPY backend/ ./
# Frontend-Build in static/ einhängen (wird per embed eingebettet) # Frontend-Build in static/ einhängen (wird per embed eingebettet)
COPY --from=frontend-builder /app/frontend/dist/ ./static/ 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 # Stage 3: Minimales Runtime-Image
FROM debian:bookworm-slim 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 .PHONY: dev-backend dev-frontend build clean
dev-backend: dev-backend:
cd backend && go run ./cmd/server cd backend && GO111MODULE=on go run ./cmd/server
dev-frontend: dev-frontend:
cd frontend && pnpm dev cd frontend && pnpm dev
build: build:
cd frontend && pnpm install && pnpm 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/ 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: clean:
rm -f krafttrainer 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" "krafttrainer/static"
) )
// Version wird beim Build per ldflags gesetzt.
var Version = "dev"
func main() { func main() {
// Datenbank initialisieren // Datenbank initialisieren
s, err := store.New("krafttrainer.db") s, err := store.New("krafttrainer.db")
@@ -30,6 +33,13 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
h := handler.New(s) h := handler.New(s)
h.RegisterRoutes(mux) 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 // SPA-Fallback: statische Dateien aus embed.FS servieren
mux.Handle("/", spaHandler(static.FS)) mux.Handle("/", spaHandler(static.FS))

View File

@@ -3,6 +3,7 @@ module krafttrainer
go 1.26.1 go 1.26.1
require ( require (
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect github.com/golang-migrate/migrate/v4 v4.19.1
github.com/mattn/go-sqlite3 v1.14.37 // indirect 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 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= 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 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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" "net/http"
) )
// handleListExercises behandelt GET /api/v1/exercises.
// Unterstützt optionale Query-Parameter: muscle_group und q (Namenssuche).
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
muscleGroup := r.URL.Query().Get("muscle_group") muscleGroup := r.URL.Query().Get("muscle_group")
query := r.URL.Query().Get("q") query := r.URL.Query().Get("q")
exercises, err := h.store.ListExercises(muscleGroup, query) exercises, err := h.store.ListExercises(uid, muscleGroup, query)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen") writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
return return
@@ -18,7 +25,14 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, exercises) writeJSON(w, http.StatusOK, exercises)
} }
// handleCreateExercise behandelt POST /api/v1/exercises.
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req model.CreateExerciseRequest var req model.CreateExerciseRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
@@ -29,7 +43,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
return return
} }
exercise, err := h.store.CreateExercise(&req) exercise, err := h.store.CreateExercise(uid, &req)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung") writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung")
return return
@@ -37,7 +51,13 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, exercise) writeJSON(w, http.StatusCreated, exercise)
} }
// handleUpdateExercise behandelt PUT /api/v1/exercises/{id}.
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
@@ -54,7 +74,7 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
return return
} }
exercise, err := h.store.UpdateExercise(id, &req) exercise, err := h.store.UpdateExercise(id, uid, &req)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung") writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung")
return return
@@ -66,14 +86,21 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, exercise) writeJSON(w, http.StatusOK, exercise)
} }
// handleDeleteExercise behandelt DELETE /api/v1/exercises/{id}.
// Führt einen Soft-Delete durch (setzt deleted_at).
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
return return
} }
err = h.store.SoftDeleteExercise(id) err = h.store.SoftDeleteExercise(id, uid)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "Übung nicht gefunden") writeError(w, http.StatusNotFound, "Übung nicht gefunden")
return 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 ( import (
"encoding/json" "encoding/json"
"errors"
"krafttrainer/internal/store" "krafttrainer/internal/store"
"net/http" "net/http"
"strconv" "strconv"
@@ -19,6 +20,11 @@ func New(store *store.Store) *Handler {
// RegisterRoutes registriert alle API-Routen am ServeMux. // RegisterRoutes registriert alle API-Routen am ServeMux.
func (h *Handler) RegisterRoutes(mux *http.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 // Exercises
mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises) mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises)
mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise) 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) mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
// Sessions // Sessions
mux.HandleFunc("GET /api/v1/sessions/active", h.handleGetActiveSession)
mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession) mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession)
mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions) mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession) mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession)
mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession) mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession)
mux.HandleFunc("DELETE /api/v1/sessions/{id}", h.handleDeleteSession)
// Session Logs // Session Logs
mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog) 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}/last-log", h.handleGetLastLog)
mux.HandleFunc("GET /api/v1/exercises/{id}/history", h.handleGetExerciseHistory) mux.HandleFunc("GET /api/v1/exercises/{id}/history", h.handleGetExerciseHistory)
mux.HandleFunc("GET /api/v1/stats/overview", h.handleGetStatsOverview) mux.HandleFunc("GET /api/v1/stats/overview", h.handleGetStatsOverview)
// Export
mux.HandleFunc("GET /api/v1/export", h.handleExport)
} }
// --- Hilfsfunktionen --- // --- Hilfsfunktionen ---
// writeJSON schreibt data als JSON mit dem angegebenen HTTP-Statuscode.
func writeJSON(w http.ResponseWriter, status int, data any) { func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
// writeError schreibt eine Fehlerantwort im Format {"error": "..."}.
func writeError(w http.ResponseWriter, status int, message string) { func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(w, status, map[string]string{"error": message})
} }
// decodeJSON dekodiert den Request-Body als JSON in dst.
// Unbekannte Felder werden als Fehler behandelt.
func decodeJSON(r *http.Request, dst any) error { func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
return dec.Decode(dst) return dec.Decode(dst)
} }
// pathID liest einen Integer-Pfadparameter aus dem Request.
func pathID(r *http.Request, name string) (int64, error) { func pathID(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64) return strconv.ParseInt(r.PathValue(name), 10, 64)
} }
// queryInt liest einen Integer-Query-Parameter. Gibt defaultVal zurück wenn der Parameter fehlt oder ungültig ist.
func queryInt(r *http.Request, name string, defaultVal int) int { func queryInt(r *http.Request, name string, defaultVal int) int {
v := r.URL.Query().Get(name) v := r.URL.Query().Get(name)
if v == "" { if v == "" {
@@ -81,3 +98,16 @@ func queryInt(r *http.Request, name string, defaultVal int) int {
} }
return n 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://*") 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-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 { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)

View File

@@ -7,7 +7,48 @@ import (
"strings" "strings"
) )
// handleGetActiveSession behandelt GET /api/v1/sessions/active.
// Gibt die offene Session des Nutzers mit den Übungen des zugehörigen Sets zurück,
// oder null wenn keine Session aktiv ist.
func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) {
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) { 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 var req model.CreateSessionRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
@@ -18,8 +59,12 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
return return
} }
session, err := h.store.CreateSession(req.SetID) session, err := h.store.CreateSession(uid, req.SetID)
if err != nil { 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") { if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -30,11 +75,18 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, session) writeJSON(w, http.StatusCreated, session)
} }
// handleListSessions behandelt GET /api/v1/sessions.
// Unterstützt Query-Parameter: limit (Standard: 20) und offset (Standard: 0).
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
limit := queryInt(r, "limit", 20) limit := queryInt(r, "limit", 20)
offset := queryInt(r, "offset", 0) offset := queryInt(r, "offset", 0)
sessions, err := h.store.ListSessions(limit, offset) sessions, err := h.store.ListSessions(uid, limit, offset)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions") writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions")
return return
@@ -42,6 +94,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sessions) writeJSON(w, http.StatusOK, sessions)
} }
// handleGetSession behandelt GET /api/v1/sessions/{id}.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
@@ -61,7 +114,14 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, session) writeJSON(w, http.StatusOK, session)
} }
// handleEndSession behandelt PUT /api/v1/sessions/{id}/end.
// Beendet eine offene Session und speichert optional eine Notiz.
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
@@ -71,10 +131,9 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Note string `json:"note"` Note string `json:"note"`
} }
// Body ist optional
decodeJSON(r, &body) decodeJSON(r, &body)
session, err := h.store.EndSession(id, body.Note) session, err := h.store.EndSession(id, uid, body.Note)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session") writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session")
return return
@@ -86,6 +145,41 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, session) 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) { func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id") sessionID, err := pathID(r, "id")
if err != nil { if err != nil {
@@ -123,6 +217,8 @@ func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, log) writeJSON(w, http.StatusCreated, log)
} }
// handleUpdateLog behandelt PUT /api/v1/sessions/{id}/logs/{logId}.
// Alle Felder im Request sind optional (Partial Update).
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id") sessionID, err := pathID(r, "id")
if err != nil { if err != nil {
@@ -161,6 +257,7 @@ func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, log) writeJSON(w, http.StatusOK, log)
} }
// handleDeleteLog behandelt DELETE /api/v1/sessions/{id}/logs/{logId}.
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id") sessionID, err := pathID(r, "id")
if err != nil { if err != nil {

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" import "net/http"
// handleGetLastLog behandelt GET /api/v1/exercises/{id}/last-log.
// Gibt den zuletzt geloggten Satz der Übung zurück, oder 404 wenn noch kein Satz existiert.
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
return return
} }
lastLog, err := h.store.GetLastLog(id) lastLog, err := h.store.GetLastLog(id, uid)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs") writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs")
return return
@@ -21,7 +28,14 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, lastLog) writeJSON(w, http.StatusOK, lastLog)
} }
// handleGetExerciseHistory behandelt GET /api/v1/exercises/{id}/history.
// Unterstützt Query-Parameter: limit (Standard: 30).
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") 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) limit := queryInt(r, "limit", 30)
logs, err := h.store.GetExerciseHistory(id, limit) logs, err := h.store.GetExerciseHistory(id, uid, limit)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie") writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie")
return return
@@ -37,8 +51,15 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, logs) writeJSON(w, http.StatusOK, logs)
} }
// handleGetStatsOverview behandelt GET /api/v1/stats/overview.
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
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 { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken") writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken")
return return

View File

@@ -7,8 +7,15 @@ import (
"strings" "strings"
) )
// handleListSets behandelt GET /api/v1/sets.
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
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 { if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets") writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets")
return return
@@ -16,7 +23,14 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sets) writeJSON(w, http.StatusOK, sets)
} }
// handleCreateSet behandelt POST /api/v1/sets.
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req model.CreateSetRequest var req model.CreateSetRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
@@ -27,7 +41,7 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
return return
} }
set, err := h.store.CreateSet(&req) set, err := h.store.CreateSet(uid, &req)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "existiert nicht") { if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error()) 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) writeJSON(w, http.StatusCreated, set)
} }
// handleUpdateSet behandelt PUT /api/v1/sets/{id}.
// Ersetzt Name und Übungszuordnungen vollständig.
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
@@ -56,7 +77,7 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
return return
} }
set, err := h.store.UpdateSet(id, &req) set, err := h.store.UpdateSet(id, uid, &req)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "existiert nicht") { if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error()) 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) writeJSON(w, http.StatusOK, set)
} }
// handleDeleteSet behandelt DELETE /api/v1/sets/{id}.
// Führt einen Soft-Delete durch (setzt deleted_at).
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
uid, err := userID(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID") writeError(w, http.StatusBadRequest, "Ungültige ID")
return return
} }
err = h.store.SoftDeleteSet(id) err = h.store.SoftDeleteSet(id, uid)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "Set nicht gefunden") writeError(w, http.StatusNotFound, "Set nicht gefunden")
return 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"` Description string `json:"description"`
MuscleGroup string `json:"muscle_group"` MuscleGroup string `json:"muscle_group"`
WeightStepKg float64 `json:"weight_step_kg"` WeightStepKg float64 `json:"weight_step_kg"`
ExerciseNumber *int `json:"exercise_number,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
@@ -24,6 +25,7 @@ type CreateExerciseRequest struct {
Description string `json:"description"` Description string `json:"description"`
MuscleGroup string `json:"muscle_group"` MuscleGroup string `json:"muscle_group"`
WeightStepKg *float64 `json:"weight_step_kg"` WeightStepKg *float64 `json:"weight_step_kg"`
ExerciseNumber *int `json:"exercise_number,omitempty"`
} }
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg. // 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" "krafttrainer/internal/model"
) )
// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert. // ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert.
func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, error) { func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) {
rows, err := s.db.Query(` 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 FROM exercises
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND user_id = ?
AND (muscle_group = ? OR ? = '') AND (muscle_group = ? OR ? = '')
AND (name LIKE '%' || ? || '%' OR ? = '') AND (name LIKE '%' || ? || '%' OR ? = '')
ORDER BY name`, ORDER BY name`,
muscleGroup, muscleGroup, query, query, userID, muscleGroup, muscleGroup, query, query,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Übungen abfragen: %w", err) 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 var exercises []model.Exercise
for rows.Next() { for rows.Next() {
var e model.Exercise 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) return nil, fmt.Errorf("Übung scannen: %w", err)
} }
exercises = append(exercises, e) exercises = append(exercises, e)
@@ -36,13 +37,13 @@ func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, erro
return exercises, rows.Err() 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) { func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
var e model.Exercise var e model.Exercise
err := s.db.QueryRow(` 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, 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 { if err == sql.ErrNoRows {
return nil, nil 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. // 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(` result, err := s.db.Exec(`
INSERT INTO exercises (name, description, muscle_group, weight_step_kg) INSERT INTO exercises (name, description, muscle_group, weight_step_kg, exercise_number, user_id)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, userID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Übung erstellen: %w", err) 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) return s.GetExercise(id)
} }
// UpdateExercise aktualisiert eine Übung und gibt sie zurück. // UpdateExercise aktualisiert eine Übung eines Nutzers und gibt sie zurück.
func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { func (s *Store) UpdateExercise(id, userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
result, err := s.db.Exec(` result, err := s.db.Exec(`
UPDATE exercises UPDATE exercises
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, exercise_number = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND deleted_at IS NULL`, WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id, req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, id, userID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Übung aktualisieren: %w", err) 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) return s.GetExercise(id)
} }
// SoftDeleteExercise markiert eine Übung als gelöscht. // SoftDeleteExercise markiert eine Übung eines Nutzers als gelöscht.
func (s *Store) SoftDeleteExercise(id int64) error { func (s *Store) SoftDeleteExercise(id, userID int64) error {
result, err := s.db.Exec(` result, err := s.db.Exec(`
UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP 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 { if err != nil {
return fmt.Errorf("Übung löschen: %w", err) 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" "strings"
) )
// CreateSession startet eine neue Trainingseinheit. // CreateSession startet eine neue Trainingseinheit für einen Nutzer.
func (s *Store) CreateSession(setID int64) (*model.Session, error) { // Gibt einen Fehler zurück wenn noch eine offene Session existiert.
// Set prüfen 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 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 { if err == sql.ErrNoRows {
return nil, fmt.Errorf("Set %d existiert nicht", setID) 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) 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 { if err != nil {
return nil, fmt.Errorf("Session erstellen: %w", err) 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) 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) { func (s *Store) GetSession(id int64) (*model.Session, error) {
var sess model.Session var sess model.Session
err := s.db.QueryRow(` err := s.db.QueryRow(`
@@ -52,11 +61,11 @@ func (s *Store) GetSession(id int64) (*model.Session, error) {
return &sess, nil return &sess, nil
} }
// EndSession beendet eine Session. // EndSession beendet eine Session eines Nutzers.
func (s *Store) EndSession(id int64, note string) (*model.Session, error) { func (s *Store) EndSession(id, userID int64, note string) (*model.Session, error) {
result, err := s.db.Exec(` result, err := s.db.Exec(`
UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, note = ? 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 { if err != nil {
return nil, fmt.Errorf("Session beenden: %w", err) 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) return s.GetSession(id)
} }
// ListSessions gibt paginierte Sessions zurück (neueste zuerst). // ListSessions gibt paginierte Sessions eines Nutzers zurück (neueste zuerst).
func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) { func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note
FROM sessions s FROM sessions s
JOIN training_sets ts ON ts.id = s.set_id JOIN training_sets ts ON ts.id = s.set_id
WHERE s.user_id = ?
ORDER BY s.started_at DESC ORDER BY s.started_at DESC
LIMIT ? OFFSET ?`, limit, offset, LIMIT ? OFFSET ?`, userID, limit, offset,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Sessions abfragen: %w", err) 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() 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. // CreateLog fügt einen Satz zu einer offenen Session hinzu.
func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) { func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) {
// Session offen?
if err := s.checkSessionOpen(sessionID); err != nil { if err := s.checkSessionOpen(sessionID); err != nil {
return nil, err 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 var exerciseName string
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName) err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -135,7 +190,6 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
return nil, err return nil, err
} }
// Log gehört zur Session?
var exists bool var exists bool
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM session_logs WHERE id = ? AND session_id = ?)`, logID, sessionID).Scan(&exists) err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM session_logs WHERE id = ? AND session_id = ?)`, logID, sessionID).Scan(&exists)
if err != nil { if err != nil {
@@ -145,7 +199,7 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
return nil, nil return nil, nil
} }
// Partielle Updates // Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update).
updates := []string{} updates := []string{}
args := []any{} args := []any{}
if req.WeightKg != nil { if req.WeightKg != nil {
@@ -192,13 +246,15 @@ func (s *Store) DeleteLog(sessionID, logID int64) error {
return nil return nil
} }
// GetLastLog gibt die letzten Werte einer Übung zurück. // GetLastLog gibt die letzten Werte einer Übung für einen Nutzer zurück.
func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) { func (s *Store) GetLastLog(exerciseID, userID int64) (*model.LastLogResponse, error) {
var resp model.LastLogResponse var resp model.LastLogResponse
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT weight_kg, reps FROM session_logs SELECT sl.weight_kg, sl.reps
WHERE exercise_id = ? FROM session_logs sl
ORDER BY logged_at DESC LIMIT 1`, exerciseID, 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) ).Scan(&resp.WeightKg, &resp.Reps)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -209,7 +265,39 @@ func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) {
return &resp, nil 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 { func (s *Store) checkSessionOpen(sessionID int64) error {
var endedAt *string var endedAt *string
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt) err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
@@ -225,7 +313,7 @@ func (s *Store) checkSessionOpen(sessionID int64) error {
return nil return nil
} }
// getLog gibt einen einzelnen Log-Eintrag zurück. // getLog lädt einen einzelnen Session-Log-Eintrag anhand seiner ID.
func (s *Store) getLog(id int64) (*model.SessionLog, error) { func (s *Store) getLog(id int64) (*model.SessionLog, error) {
var log model.SessionLog var log model.SessionLog
err := s.db.QueryRow(` err := s.db.QueryRow(`
@@ -241,7 +329,7 @@ func (s *Store) getLog(id int64) (*model.SessionLog, error) {
return &log, nil return &log, nil
} }
// getSessionLogs gibt alle Logs einer Session zurück. // getSessionLogs lädt alle Logs einer Session, sortiert nach Übung und Satznummer.
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) { func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at

View File

@@ -6,11 +6,11 @@ import (
"krafttrainer/internal/model" "krafttrainer/internal/model"
) )
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück. // ListSets gibt alle nicht-gelöschten Sets eines Nutzers mit ihren Übungen zurück.
func (s *Store) ListSets() ([]model.TrainingSet, error) { func (s *Store) ListSets(userID int64) ([]model.TrainingSet, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, name, created_at FROM training_sets 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 { if err != nil {
return nil, fmt.Errorf("Sets abfragen: %w", err) return nil, fmt.Errorf("Sets abfragen: %w", err)
} }
@@ -41,7 +41,7 @@ func (s *Store) ListSets() ([]model.TrainingSet, error) {
return sets, nil 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) { func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
var ts model.TrainingSet var ts model.TrainingSet
err := s.db.QueryRow(` 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). // 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() tx, err := s.db.Begin()
if err != nil { if err != nil {
return nil, fmt.Errorf("Transaktion starten: %w", err) return nil, fmt.Errorf("Transaktion starten: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
// Prüfen ob alle Übungen existieren
for _, eid := range req.ExerciseIDs { for _, eid := range req.ExerciseIDs {
var exists bool 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 { if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("Set erstellen: %w", err) 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) return s.GetSet(id)
} }
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen). // UpdateSet aktualisiert ein Set eines Nutzers (Name + Übungszuordnungen).
func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) { func (s *Store) UpdateSet(id, userID int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return nil, fmt.Errorf("Transaktion starten: %w", err) return nil, fmt.Errorf("Transaktion starten: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
// Prüfen ob Set existiert
var exists bool 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 { if err != nil {
return nil, fmt.Errorf("Set prüfen: %w", err) 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 return nil, nil
} }
// Prüfen ob alle Übungen existieren
for _, eid := range req.ExerciseIDs { for _, eid := range req.ExerciseIDs {
var eExists bool 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 { if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err) 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) return s.GetSet(id)
} }
// SoftDeleteSet markiert ein Set als gelöscht. // SoftDeleteSet markiert ein Set eines Nutzers als gelöscht.
func (s *Store) SoftDeleteSet(id int64) error { func (s *Store) SoftDeleteSet(id, userID int64) error {
result, err := s.db.Exec(` result, err := s.db.Exec(`
UPDATE training_sets SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP 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 { if err != nil {
return fmt.Errorf("Set löschen: %w", err) return fmt.Errorf("Set löschen: %w", err)
@@ -171,7 +168,8 @@ func (s *Store) SoftDeleteSet(id int64) error {
return nil return nil
} }
// getSetExercises lädt die Übungen eines Sets sortiert nach Position. // getSetExercises lädt die nicht-gelöschten Übungen eines Sets, sortiert nach Position.
// Soft-gelöschte Übungen werden nicht zurückgegeben.
func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) { func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.created_at, e.updated_at SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.created_at, e.updated_at

View File

@@ -5,7 +5,7 @@ import (
"krafttrainer/internal/model" "krafttrainer/internal/model"
) )
// StatsOverview enthält die Gesamtübersicht. // StatsOverview enthält aggregierte Trainingsdaten eines Nutzers.
type StatsOverview struct { type StatsOverview struct {
TotalSessions int `json:"total_sessions"` TotalSessions int `json:"total_sessions"`
TotalVolumeKg float64 `json:"total_volume_kg"` TotalVolumeKg float64 `json:"total_volume_kg"`
@@ -13,16 +13,16 @@ type StatsOverview struct {
Exercises []model.ExerciseStats `json:"exercises"` Exercises []model.ExerciseStats `json:"exercises"`
} }
// GetStatsOverview gibt die Gesamtstatistik zurück. // GetStatsOverview gibt die Gesamtstatistik eines Nutzers zurück.
func (s *Store) GetStatsOverview() (*StatsOverview, error) { func (s *Store) GetStatsOverview(userID int64) (*StatsOverview, error) {
var overview StatsOverview var overview StatsOverview
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT SELECT
(SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL), (SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL),
(SELECT COALESCE(SUM(weight_kg * reps), 0) FROM session_logs), (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 ended_at IS NOT NULL AND started_at >= date('now', '-7 days')) (SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL AND started_at >= date('now', '-7 days'))
`).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek) `, userID, userID, userID).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek)
if err != nil { if err != nil {
return nil, fmt.Errorf("Übersicht abfragen: %w", err) return nil, fmt.Errorf("Übersicht abfragen: %w", err)
} }
@@ -36,8 +36,10 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) {
COUNT(*) as total_sets, COUNT(*) as total_sets,
MAX(sl.logged_at) as last_trained MAX(sl.logged_at) as last_trained
FROM session_logs sl FROM session_logs sl
JOIN sessions s ON s.id = sl.session_id
WHERE s.user_id = ?
GROUP BY sl.exercise_id GROUP BY sl.exercise_id
ORDER BY last_trained DESC`) ORDER BY last_trained DESC`, userID)
if err != nil { if err != nil {
return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err) return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err)
} }
@@ -56,14 +58,15 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) {
return &overview, rows.Err() return &overview, rows.Err()
} }
// GetExerciseHistory gibt die letzten N Logs einer Übung zurück. // GetExerciseHistory gibt die letzten N Logs einer Übung für einen Nutzer zurück.
func (s *Store) GetExerciseHistory(exerciseID int64, limit int) ([]model.SessionLog, error) { func (s *Store) GetExerciseHistory(exerciseID, userID int64, limit int) ([]model.SessionLog, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at SELECT 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 FROM session_logs sl
WHERE exercise_id = ? JOIN sessions s ON s.id = sl.session_id
ORDER BY logged_at DESC WHERE sl.exercise_id = ? AND s.user_id = ?
LIMIT ?`, exerciseID, limit, ORDER BY sl.logged_at DESC
LIMIT ?`, exerciseID, userID, limit,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Übungshistorie abfragen: %w", err) 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 # Deployment
## Voraussetzungen Es gibt zwei Deployment-Varianten: **systemd** (Binary direkt) und **Docker** (empfohlen).
---
## Variante 1: Docker (empfohlen)
### Voraussetzungen
- Docker + Docker Compose auf dem Zielrechner - 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 ```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: Beim ersten Start laufen die Datenbankmigrationen automatisch.
- Docker baut das Image (Frontend + Go-Binary, dauert ~2 Min.)
- Migrations laufen automatisch beim Start
- Server lauscht auf Port `8090`
## Nach Code-Änderungen ### Update deployen
```bash ```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: ### Datenbank einspielen
- `go.mod` unverändert → `go mod download` wird nicht wiederholt
- `pnpm-lock.yaml` unverändert → `pnpm install` wird nicht wiederholt
## Logs
```bash ```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 ```bash
docker compose down # Container stoppen (DB bleibt erhalten) ssh christoph@192.168.1.118 "docker logs krafttrainer-src-krafttrainer-1 -f"
docker compose down -v # Container + DB-Volume löschen (Datenverlust!)
``` ```
## Datenbank ### Stoppen / Neustarten
Die SQLite-DB liegt im Docker-Volume `db-data``/data/krafttrainer.db` im Container.
**Backup:**
```bash ```bash
docker run --rm -v krafttrainer_db-data:/data -v $(pwd):/backup \ ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose down"
debian:bookworm-slim cp /data/krafttrainer.db /backup/krafttrainer.db.bak ssh christoph@192.168.1.118 "cd ~/krafttrainer-src && docker compose up -d"
``` ```
**Restore:** ### Datenbank-Backup
```bash ```bash
docker compose down scp christoph@192.168.1.118:/home/christoph/fitnesspad/krafttrainer.db ./krafttrainer.db.bak
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
``` ```
## Image-Aufbau (3-Stage-Build) ### Image-Aufbau (3-Stage-Build)
| Stage | Basis | Aufgabe | | Stage | Basis | Aufgabe |
|-------|-------|---------| |-------|-------|---------|
| 1 | `node:22-slim` | `pnpm build``frontend/dist/` | | 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 | | 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 ## Ports
| Port | Dienst | | Port | Dienst |

View File

@@ -1,11 +1,11 @@
services: services:
krafttrainer: krafttrainer:
build: . build:
context: .
args:
VERSION: "${VERSION:-dev}"
ports: ports:
- "8090:8090" - "8090:8090"
volumes: volumes:
- db-data:/data - /home/christoph/fitnesspad:/data
restart: unless-stopped restart: unless-stopped
volumes:
db-data:

View File

@@ -4,6 +4,7 @@ import { ExercisesPage } from './pages/ExercisesPage';
import { SetsPage } from './pages/SetsPage'; import { SetsPage } from './pages/SetsPage';
import { TrainingPage } from './pages/TrainingPage'; import { TrainingPage } from './pages/TrainingPage';
import { HistoryPage } from './pages/HistoryPage'; import { HistoryPage } from './pages/HistoryPage';
import { SettingsPage } from './pages/SettingsPage';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -13,6 +14,7 @@ const router = createBrowserRouter([
{ path: '/sets', element: <SetsPage /> }, { path: '/sets', element: <SetsPage /> },
{ path: '/training', element: <TrainingPage /> }, { path: '/training', element: <TrainingPage /> },
{ path: '/history', element: <HistoryPage /> }, { path: '/history', element: <HistoryPage /> },
{ path: '/settings', element: <SettingsPage /> },
], ],
}, },
]); ]);

View File

@@ -1,10 +1,12 @@
import type { import type {
Exercise, Exercise,
ExerciseImage,
TrainingSet, TrainingSet,
Session, Session,
SessionLog, SessionLog,
LastLogResponse, LastLogResponse,
ExerciseStats, ExerciseStats,
User,
CreateExerciseRequest, CreateExerciseRequest,
CreateSetRequest, CreateSetRequest,
UpdateSetRequest, UpdateSetRequest,
@@ -12,7 +14,13 @@ import type {
CreateLogRequest, CreateLogRequest,
UpdateLogRequest, UpdateLogRequest,
} from '../types'; } 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 { export class ApiError extends Error {
constructor( constructor(
public status: number, 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>( async function request<T>(
url: string, url: string,
options?: RequestInit, options?: RequestInit,
): Promise<T> { ): 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, { const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' }, headers,
...options, ...options,
}); });
@@ -45,7 +62,23 @@ async function request<T>(
return data as T; return data as T;
} }
/** Typisiertes API-Objekt mit allen Backend-Endpunkten unter `/api/v1`. */
export const api = { export const api = {
users: {
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: { exercises: {
list(muscleGroup?: string, q?: string): Promise<Exercise[]> { list(muscleGroup?: string, q?: string): Promise<Exercise[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -79,6 +112,41 @@ export const api = {
return request<LastLogResponse>(`/api/v1/exercises/${id}/last-log`); 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[]> { history(id: number, limit?: number): Promise<SessionLog[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (limit) params.set('limit', String(limit)); if (limit) params.set('limit', String(limit));
@@ -116,6 +184,10 @@ export const api = {
}, },
sessions: { sessions: {
active(): Promise<{ session: Session; exercises: Exercise[] } | null> {
return request<{ session: Session; exercises: Exercise[] } | null>('/api/v1/sessions/active');
},
create(data: CreateSessionRequest): Promise<Session> { create(data: CreateSessionRequest): Promise<Session> {
return request<Session>('/api/v1/sessions', { return request<Session>('/api/v1/sessions', {
method: 'POST', method: 'POST',
@@ -168,6 +240,12 @@ export const api = {
method: 'DELETE', method: 'DELETE',
}); });
}, },
delete(id: number): Promise<void> {
return request<void>(`/api/v1/sessions/${id}`, {
method: 'DELETE',
});
},
}, },
stats: { stats: {
@@ -175,4 +253,8 @@ export const api = {
return request<ExerciseStats[]>('/api/v1/stats/overview'); 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; onDelete: (exercise: Exercise) => void;
} }
/**
* Zeigt eine einzelne Übung als Karte mit Muskelgruppen-Badge,
* Gewichtsschritt und Aktions-Buttons für Bearbeiten und Löschen.
*/
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) { export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group; const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600'; const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
@@ -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="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <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 && ( {exercise.description && (
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p> <p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
)} )}

View File

@@ -1,18 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types'; import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS } from '../../types'; import { MUSCLE_GROUPS } from '../../types';
import { ImageGallery } from './ImageGallery';
interface ExerciseFormProps { interface ExerciseFormProps {
/** Vorhandene Übung beim Bearbeiten; `null`/`undefined` beim Erstellen. */
exercise?: Exercise | null; exercise?: Exercise | null;
onSubmit: (data: CreateExerciseRequest) => void; onSubmit: (data: CreateExerciseRequest) => void;
onCancel: () => void; onCancel: () => void;
} }
/**
* Formular zum Erstellen und Bearbeiten einer Übung.
* Im Bearbeitungsmodus werden die vorhandenen Werte automatisch befüllt
* und die Bildergalerie der Übung angezeigt.
*/
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) { export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust'); const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
const [weightStep, setWeightStep] = useState(2.5); const [weightStep, setWeightStep] = useState(2.5);
const [exerciseNumber, setExerciseNumber] = useState<string>('');
useEffect(() => { useEffect(() => {
if (exercise) { if (exercise) {
@@ -20,21 +28,25 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
setDescription(exercise.description); setDescription(exercise.description);
setMuscleGroup(exercise.muscle_group); setMuscleGroup(exercise.muscle_group);
setWeightStep(exercise.weight_step_kg); setWeightStep(exercise.weight_step_kg);
setExerciseNumber(exercise.exercise_number != null ? String(exercise.exercise_number) : '');
} else { } else {
setName(''); setName('');
setDescription(''); setDescription('');
setMuscleGroup('brust'); setMuscleGroup('brust');
setWeightStep(2.5); setWeightStep(2.5);
setExerciseNumber('');
} }
}, [exercise]); }, [exercise]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const num = exerciseNumber.trim() ? parseInt(exerciseNumber.trim(), 10) : undefined;
onSubmit({ onSubmit({
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
muscle_group: muscleGroup, muscle_group: muscleGroup,
weight_step_kg: weightStep, weight_step_kg: weightStep,
exercise_number: Number.isFinite(num) ? num : undefined,
}); });
}; };
@@ -58,6 +70,18 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
/> />
</div> </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> <div>
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label> <label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
<textarea <textarea
@@ -95,6 +119,10 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
/> />
</div> </div>
{exercise && (
<ImageGallery exerciseId={exercise.id} />
)}
<div className="flex gap-3 justify-end pt-2"> <div className="flex gap-3 justify-end pt-2">
<button <button
type="button" type="button"

View File

@@ -9,6 +9,11 @@ interface ExerciseListProps {
onDelete: (exercise: Exercise) => void; onDelete: (exercise: Exercise) => void;
} }
/**
* Zeigt alle Übungen als gefilterte Liste.
* Filter nach Muskelgruppe und Freitext werden direkt im Store verwaltet
* und lösen automatisch einen Neuladevorgang aus.
*/
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) { export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore(); const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
@@ -18,7 +23,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Filter-Bar */}
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<select <select
value={filter.muscleGroup} value={filter.muscleGroup}
@@ -41,7 +45,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
/> />
</div> </div>
{/* Liste */}
{loading ? ( {loading ? (
<div className="text-center text-gray-500 py-8">Laden...</div> <div className="text-center text-gray-500 py-8">Laden...</div>
) : exercises.length === 0 ? ( ) : exercises.length === 0 ? (

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 { useExerciseStore } from '../../stores/exerciseStore';
import type { SessionLog } from '../../types'; import type { SessionLog } from '../../types';
/**
* Zeigt den Gewichtsverlauf einer ausgewählten Übung als Liniendiagramm.
* Pro Trainingstag wird das jeweils höchste verwendete Gewicht dargestellt.
*/
export function ExerciseChart() { export function ExerciseChart() {
const { exercises, fetchExercises } = useExerciseStore(); const { exercises, fetchExercises } = useExerciseStore();
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -32,7 +36,7 @@ export function ExerciseChart() {
api.exercises api.exercises
.history(selectedId, 50) .history(selectedId, 50)
.then((logs: SessionLog[]) => { .then((logs: SessionLog[]) => {
// Gruppiere nach Datum, nehme max Gewicht pro Tag // Pro Datum das Maximalgewicht ermitteln
const byDate = new Map<string, number>(); const byDate = new Map<string, number>();
for (const log of logs) { for (const log of logs) {
const date = new Date(log.logged_at).toLocaleDateString('de-DE', { const date = new Date(log.logged_at).toLocaleDateString('de-DE', {

View File

@@ -4,12 +4,16 @@ interface SessionDetailProps {
logs: SessionLog[]; logs: SessionLog[];
} }
/**
* Zeigt alle protokollierten Sätze einer Session gruppiert nach Übung.
* Innerhalb jeder Übung sind die Sätze aufsteigend nach Satznummer sortiert.
*/
export function SessionDetail({ logs }: SessionDetailProps) { export function SessionDetail({ logs }: SessionDetailProps) {
if (!logs || logs.length === 0) { if (!logs || logs.length === 0) {
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>; return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
} }
// Gruppiere nach Übung // Sätze nach Übungs-ID gruppieren
const grouped = new Map<number, { name: string; logs: SessionLog[] }>(); const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
for (const log of logs) { for (const log of logs) {
if (!grouped.has(log.exercise_id)) { if (!grouped.has(log.exercise_id)) {

View File

@@ -2,12 +2,19 @@ import { useEffect, useState } from 'react';
import { useHistoryStore } from '../../stores/historyStore'; import { useHistoryStore } from '../../stores/historyStore';
import { SessionDetail } from './SessionDetail'; import { SessionDetail } from './SessionDetail';
import type { Session } from '../../types'; 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() { export function SessionList() {
const { sessions, loading, fetchSessions } = useHistoryStore(); const { sessions, loading, fetchSessions } = useHistoryStore();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [expandedSession, setExpandedSession] = useState<Session | null>(null); const [expandedSession, setExpandedSession] = useState<Session | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
fetchSessions(50); fetchSessions(50);
@@ -28,6 +35,7 @@ export function SessionList() {
} }
}; };
/** Formatiert ein ISO-Datum als lokalisiertes Datum mit Uhrzeit. */
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', { return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit', day: '2-digit',
@@ -38,6 +46,7 @@ export function SessionList() {
}); });
}; };
/** Berechnet Trainingsdauer als lesbaren String oder "laufend". */
const formatDuration = (start: string, end?: string) => { const formatDuration = (start: string, end?: string) => {
if (!end) return 'laufend'; if (!end) return 'laufend';
const ms = new Date(end).getTime() - new Date(start).getTime(); const ms = new Date(end).getTime() - new Date(start).getTime();
@@ -48,6 +57,31 @@ export function SessionList() {
return `${h}h ${m}m`; 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) { if (loading) {
return <div className="text-center text-gray-500 py-8">Laden...</div>; return <div className="text-center text-gray-500 py-8">Laden...</div>;
} }
@@ -68,9 +102,10 @@ export function SessionList() {
key={session.id} key={session.id}
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden" className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
> >
<div className="flex items-stretch">
<button <button
onClick={() => toggleSession(session.id)} 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 className="flex items-center justify-between">
<div> <div>
@@ -95,6 +130,27 @@ export function SessionList() {
</div> </div>
</div> </div>
</button> </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 && ( {expandedId === session.id && expandedSession && (
<div className="px-4 pb-4 border-t border-gray-800 pt-3"> <div className="px-4 pb-4 border-t border-gray-800 pt-3">
<SessionDetail logs={expandedSession.logs || []} /> <SessionDetail logs={expandedSession.logs || []} />

View File

@@ -1,4 +1,5 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useUserStore } from '../../stores/userStore';
const navItems = [ const navItems = [
{ {
@@ -37,8 +38,19 @@ const navItems = [
</svg> </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() { export function BottomNav() {
return ( return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40"> <nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
@@ -63,11 +75,14 @@ export function BottomNav() {
); );
} }
/** Desktop-Seitenleiste mit Navigation und aktivem Nutzer-Indikator. */
export function Sidebar() { export function Sidebar() {
const { activeUser } = useUserStore();
return ( return (
<nav className="hidden md:flex flex-col w-56 bg-gray-900 border-r border-gray-800 min-h-screen p-4"> <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> <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) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
@@ -86,6 +101,14 @@ export function Sidebar() {
</NavLink> </NavLink>
))} ))}
</div> </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> </nav>
); );
} }

View File

@@ -1,9 +1,98 @@
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { BottomNav, Sidebar } from './BottomNav'; import { BottomNav, Sidebar } from './BottomNav';
import { ToastContainer } from './Toast'; 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() { export function PageShell() {
return ( return (
<UserGate>
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar /> <Sidebar />
<main className="flex-1 pb-20 md:pb-0"> <main className="flex-1 pb-20 md:pb-0">
@@ -14,5 +103,6 @@ export function PageShell() {
<BottomNav /> <BottomNav />
<ToastContainer /> <ToastContainer />
</div> </div>
</UserGate>
); );
} }

View File

@@ -5,6 +5,10 @@ interface SetDetailProps {
trainingSet: TrainingSet; trainingSet: TrainingSet;
} }
/**
* Zeigt die Übungsliste eines Trainings-Sets mit Muskelgruppen-Badges.
* Wird in der Set-Übersicht als Detailansicht verwendet.
*/
export function SetDetail({ trainingSet }: SetDetailProps) { export function SetDetail({ trainingSet }: SetDetailProps) {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4"> <div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
@@ -19,7 +23,12 @@ export function SetDetail({ trainingSet }: SetDetailProps) {
return ( return (
<div key={ex.id} className="flex items-center gap-3 bg-gray-800 rounded-lg px-3 py-2"> <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="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`}> <span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
{label} {label}
</span> </span>

View File

@@ -4,11 +4,17 @@ import type { TrainingSet, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types'; import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
interface SetFormProps { interface SetFormProps {
/** Vorhandenes Set beim Bearbeiten; `null`/`undefined` beim Erstellen. */
trainingSet?: TrainingSet | null; trainingSet?: TrainingSet | null;
onSubmit: (name: string, exerciseIds: number[]) => void; onSubmit: (name: string, exerciseIds: number[]) => void;
onCancel: () => void; onCancel: () => void;
} }
/**
* Formular zum Erstellen und Bearbeiten eines Trainings-Sets.
* Übungen können nach Muskelgruppe gefiltert und per Checkbox ausgewählt werden.
* Die Reihenfolge der ausgewählten Übungen ist per Pfeil-Buttons sortierbar.
*/
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) { export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const { exercises, fetchExercises } = useExerciseStore(); const { exercises, fetchExercises } = useExerciseStore();
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -16,7 +22,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>(''); const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
useEffect(() => { useEffect(() => {
// Lade alle Übungen ohne Filter // Alle Übungen ohne aktiven Filter laden
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' }); useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
fetchExercises(); fetchExercises();
}, [fetchExercises]); }, [fetchExercises]);
@@ -66,6 +72,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const isValid = name.trim().length > 0 && selectedIds.length > 0; const isValid = name.trim().length > 0 && selectedIds.length > 0;
/** Map für effizienten Zugriff auf Übungsnamen in der Reihenfolge-Ansicht. */
const exerciseMap = new Map(exercises.map((e) => [e.id, e])); const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
return ( return (
@@ -86,7 +93,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
/> />
</div> </div>
{/* Übungsauswahl */}
<div> <div>
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label> <label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
<select <select
@@ -127,7 +133,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
</div> </div>
</div> </div>
{/* Sortierbare ausgewählte Übungen */}
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<div> <div>
<label className="block text-sm text-gray-400 mb-1"> <label className="block text-sm text-gray-400 mb-1">

View File

@@ -7,6 +7,10 @@ interface SetListProps {
onDelete: (set: TrainingSet) => void; onDelete: (set: TrainingSet) => void;
} }
/**
* Listet alle Trainings-Sets mit ihren Übungen sowie Bearbeiten-
* und Löschen-Aktionen auf.
*/
export function SetList({ onEdit, onDelete }: SetListProps) { export function SetList({ onEdit, onDelete }: SetListProps) {
const { sets, loading, fetchSets } = useSetStore(); const { sets, loading, fetchSets } = useSetStore();

View File

@@ -4,12 +4,19 @@ import { useConfirm } from '../../hooks/useConfirm';
import { ConfirmDialog } from '../layout/ConfirmDialog'; import { ConfirmDialog } from '../layout/ConfirmDialog';
import { LogEntryForm } from './LogEntryForm'; import { LogEntryForm } from './LogEntryForm';
import { RestTimer } from './RestTimer'; import { RestTimer } from './RestTimer';
import { ExerciseSparkline } from './ExerciseSparkline';
import type { Exercise, SessionLog } from '../../types'; import type { Exercise, SessionLog } from '../../types';
interface ActiveSessionProps { interface ActiveSessionProps {
/** Wird aufgerufen, nachdem das Training erfolgreich beendet wurde. */
onEnd: () => void; onEnd: () => void;
} }
/**
* Hauptansicht einer laufenden Trainings-Session.
* Zeigt alle Übungen des Sets als aufklappbares Accordion mit
* Fortschritts-Sparkline, letzten Trainingswerten und Satz-Eingabeformular.
*/
export function ActiveSession({ onEnd }: ActiveSessionProps) { export function ActiveSession({ onEnd }: ActiveSessionProps) {
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } = const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
useActiveSessionStore(); useActiveSessionStore();
@@ -21,11 +28,13 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
const logs = session.logs || []; const logs = session.logs || [];
/** Gibt alle protokollierten Sätze für eine Übung sortiert nach Satznummer zurück. */
const getExerciseLogs = (exerciseId: number) => const getExerciseLogs = (exerciseId: number) =>
logs logs
.filter((l) => l.exercise_id === exerciseId) .filter((l) => l.exercise_id === exerciseId)
.sort((a, b) => a.set_number - b.set_number); .sort((a, b) => a.set_number - b.set_number);
/** Berechnet die nächste Satznummer als Maximum der bisherigen + 1. */
const getNextSetNumber = (exerciseId: number) => { const getNextSetNumber = (exerciseId: number) => {
const exLogs = getExerciseLogs(exerciseId); const exLogs = getExerciseLogs(exerciseId);
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1; return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
@@ -92,7 +101,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
<RestTimer /> <RestTimer />
{/* Übungen als Accordion */}
{exercises.map((exercise) => { {exercises.map((exercise) => {
const exLogs = getExerciseLogs(exercise.id); const exLogs = getExerciseLogs(exercise.id);
const isExpanded = expandedExercise === exercise.id; const isExpanded = expandedExercise === exercise.id;
@@ -103,13 +111,17 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
key={exercise.id} key={exercise.id}
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden" className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
> >
{/* Header */}
<button <button
onClick={() => toggleExercise(exercise.id)} onClick={() => toggleExercise(exercise.id)}
className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50" className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
> >
<div> <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"> <span className="ml-2 text-sm text-gray-500">
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'}) ({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
</span> </span>
@@ -125,17 +137,16 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
</svg> </svg>
</button> </button>
{/* Expanded content */}
{isExpanded && ( {isExpanded && (
<div className="px-4 pb-4 space-y-3"> <div className="px-4 pb-4 space-y-3">
{/* Vorherige Werte */} <ExerciseSparkline exerciseId={exercise.id} />
{lastLog && ( {lastLog && (
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2"> <div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh. Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh.
</div> </div>
)} )}
{/* Bisherige Sätze */}
{exLogs.map((log) => ( {exLogs.map((log) => (
<div key={log.id}> <div key={log.id}>
{editingLog?.id === log.id ? ( {editingLog?.id === log.id ? (
@@ -189,7 +200,11 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
</div> </div>
))} ))}
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */} {/*
Formular für neuen Satz: Vorausfüllung mit Werten des letzten Satzes
dieser Session, falls vorhanden, sonst mit dem letzten Trainingseintrag.
Der key-Wechsel erzwingt Reset des Formularzustands bei jedem neuen Satz.
*/}
<LogEntryForm <LogEntryForm
key={`new-${exercise.id}-${exLogs.length}`} key={`new-${exercise.id}-${exLogs.length}`}
exercise={exercise} exercise={exercise}
@@ -204,7 +219,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
); );
})} })}
{/* Training beenden */}
<button <button
onClick={handleEndSession} onClick={handleEndSession}
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]" className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"

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; initialWeight?: number;
initialReps?: number; initialReps?: number;
onSubmit: (weight: number, reps: number, note: string) => void; onSubmit: (weight: number, reps: number, note: string) => void;
/** Beschriftung des Speichern-Buttons; Standard: "Satz speichern". */
submitLabel?: string; submitLabel?: string;
} }
/**
* Eingabeformular für einen einzelnen Trainingssatz.
* Gewicht wird in Schritten von `exercise.weight_step_kg` angepasst,
* Wiederholungen in Einerschritten. Nach dem Speichern wird die Notiz geleert.
*/
export function LogEntryForm({ export function LogEntryForm({
exercise, exercise,
setNumber, setNumber,
@@ -41,7 +47,6 @@ export function LogEntryForm({
<div className="space-y-4 bg-gray-800 rounded-lg p-4"> <div className="space-y-4 bg-gray-800 rounded-lg p-4">
<div className="text-sm text-gray-400">Satz {setNumber}</div> <div className="text-sm text-gray-400">Satz {setNumber}</div>
{/* Gewicht */}
<div> <div>
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label> <label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
<div className="flex items-center gap-2 justify-center"> <div className="flex items-center gap-2 justify-center">
@@ -84,7 +89,6 @@ export function LogEntryForm({
</div> </div>
</div> </div>
{/* Wiederholungen */}
<div> <div>
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label> <label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
<div className="flex items-center gap-2 justify-center"> <div className="flex items-center gap-2 justify-center">
@@ -112,7 +116,6 @@ export function LogEntryForm({
</div> </div>
</div> </div>
{/* Notiz */}
<div> <div>
<input <input
type="text" type="text"

View File

@@ -1,13 +1,20 @@
import { useActiveSessionStore } from '../../stores/activeSessionStore'; import { useActiveSessionStore } from '../../stores/activeSessionStore';
/** Voreingestellte Pausenzeiten in Sekunden. */
const PRESETS = [60, 90, 120, 180]; const PRESETS = [60, 90, 120, 180];
/**
* Pause-Timer für die aktive Trainings-Session.
* Zeigt bei laufendem Timer Countdown und Fortschrittsbalken an;
* andernfalls Schnellauswahl-Buttons für die vordefinierten Pausenzeiten.
*/
export function RestTimer() { export function RestTimer() {
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore(); const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
const isRunning = timerSeconds > 0; const isRunning = timerSeconds > 0;
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0; const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
/** Formatiert Sekunden als `m:ss`. */
const formatTime = (s: number) => { const formatTime = (s: number) => {
const mins = Math.floor(s / 60); const mins = Math.floor(s / 60);
const secs = s % 60; const secs = s % 60;

View File

@@ -1,15 +1,47 @@
import { useState } from 'react'; import { useState } from 'react';
import { SessionList } from '../components/history/SessionList'; import { SessionList } from '../components/history/SessionList';
import { ExerciseChart } from '../components/history/ExerciseChart'; import { ExerciseChart } from '../components/history/ExerciseChart';
import { getActiveUserId } from '../stores/userStore';
type Tab = 'history' | 'stats'; 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() { export function HistoryPage() {
const [activeTab, setActiveTab] = useState<Tab>('history'); const [activeTab, setActiveTab] = useState<Tab>('history');
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-100">Historie</h1> <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 */} {/* Tab Toggle */}
<div className="flex bg-gray-900 rounded-lg p-1"> <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() { export function TrainingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { sets, fetchSets, loading } = useSetStore(); const { sets, fetchSets, loading } = useSetStore();
const { session, startSession } = useActiveSessionStore(); const { session, startSession, resumeSession } = useActiveSessionStore();
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [resuming, setResuming] = useState(true);
const blocker = useNavigationGuard(); const blocker = useNavigationGuard();
useEffect(() => { useEffect(() => {
fetchSets(); fetchSets();
// Prüfe ob eine offene Session existiert und setze sie fort
if (!session) {
resumeSession().finally(() => setResuming(false));
} else {
setResuming(false);
}
}, [fetchSets]); }, [fetchSets]);
const handleStart = async (setId: number) => { 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 // Set-Auswahl
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -13,12 +13,16 @@ import { useToastStore } from './toastStore';
interface ActiveSessionState { interface ActiveSessionState {
session: Session | null; session: Session | null;
exercises: Exercise[]; exercises: Exercise[];
/** Zuletzt protokollierte Werte pro Übungs-ID wird für Vorausfüllung genutzt. */
lastLogs: Map<number, LastLogResponse>; lastLogs: Map<number, LastLogResponse>;
/** Verbleibende Sekunden des laufenden Pause-Timers. */
timerSeconds: number; timerSeconds: number;
/** Ursprünglich eingestellte Sekunden für Fortschrittsbalken-Berechnung. */
timerTarget: number; timerTarget: number;
timerInterval: ReturnType<typeof setInterval> | null; timerInterval: ReturnType<typeof setInterval> | null;
startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>; startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>;
resumeSession: () => Promise<boolean>;
loadSession: (sessionId: number) => Promise<boolean>; loadSession: (sessionId: number) => Promise<boolean>;
addLog: (data: CreateLogRequest) => Promise<SessionLog | null>; addLog: (data: CreateLogRequest) => Promise<SessionLog | null>;
updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>; updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>;
@@ -31,6 +35,13 @@ interface ActiveSessionState {
clearSession: () => void; clearSession: () => void;
} }
/**
* Store für die laufende Trainings-Session.
*
* Verwaltet Session-Zustand, protokollierte Sätze, letzte Trainingswerte
* und den Pause-Timer. Der Timer-Interval wird manuell verwaltet;
* beim Stopp muss `stopTimer()` explizit aufgerufen werden.
*/
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
session: null, session: null,
exercises: [], exercises: [],
@@ -44,7 +55,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
const session = await api.sessions.create({ set_id: setId }); const session = await api.sessions.create({ set_id: setId });
set({ session, exercises }); set({ session, exercises });
// Lade letzte Logs für alle Übungen // Letzte Logs für alle Übungen vorladen, um Formular-Vorausfüllung zu ermöglichen
for (const ex of exercises) { for (const ex of exercises) {
await get().fetchLastLog(ex.id); await get().fetchLastLog(ex.id);
} }
@@ -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) => { loadSession: async (sessionId) => {
try { try {
const session = await api.sessions.get(sessionId); const session = await api.sessions.get(sessionId);
@@ -74,7 +102,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
if (!session) return null; if (!session) return null;
try { try {
const log = await api.sessions.createLog(session.id, data); const log = await api.sessions.createLog(session.id, data);
// Reload session to get updated logs // Session neu laden, damit die Logs-Liste aktuell ist
const updated = await api.sessions.get(session.id); const updated = await api.sessions.get(session.id);
set({ session: updated }); set({ session: updated });
return log; return log;
@@ -142,7 +170,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
}); });
return lastLog; return lastLog;
} catch { } catch {
// 404 = noch kein Log vorhanden // 404 bedeutet: noch kein Trainingseintrag für diese Übung vorhanden
return null; return null;
} }
}, },

View File

@@ -3,6 +3,7 @@ import { api } from '../api/client';
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types'; import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
import { useToastStore } from './toastStore'; import { useToastStore } from './toastStore';
/** Filterkriterien für die Übungsliste. */
interface ExerciseFilter { interface ExerciseFilter {
muscleGroup: MuscleGroup | ''; muscleGroup: MuscleGroup | '';
query: string; query: string;
@@ -20,6 +21,7 @@ interface ExerciseState {
setFilter: (filter: Partial<ExerciseFilter>) => void; setFilter: (filter: Partial<ExerciseFilter>) => void;
} }
/** Store für Übungsverwaltung inkl. Filterung und CRUD-Operationen. */
export const useExerciseStore = create<ExerciseState>((set, get) => ({ export const useExerciseStore = create<ExerciseState>((set, get) => ({
exercises: [], exercises: [],
loading: false, loading: false,

View File

@@ -12,6 +12,7 @@ interface HistoryState {
fetchStats: () => Promise<void>; fetchStats: () => Promise<void>;
} }
/** Store für Trainings-Historie und Übungsstatistiken. */
export const useHistoryStore = create<HistoryState>((set) => ({ export const useHistoryStore = create<HistoryState>((set) => ({
sessions: [], sessions: [],
loading: false, loading: false,

View File

@@ -13,6 +13,7 @@ interface SetState {
deleteSet: (id: number) => Promise<boolean>; deleteSet: (id: number) => Promise<boolean>;
} }
/** Store für Trainings-Sets inkl. CRUD-Operationen. */
export const useSetStore = create<SetState>((set, get) => ({ export const useSetStore = create<SetState>((set, get) => ({
sets: [], sets: [],
loading: false, loading: false,

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
/** Eine einzelne Toast-Benachrichtigung. */
export interface Toast { export interface Toast {
id: string; id: string;
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info';
@@ -12,6 +13,13 @@ interface ToastState {
removeToast: (id: string) => void; removeToast: (id: string) => void;
} }
/**
* Store für temporäre Benachrichtigungen.
*
* Toasts verschwinden nach 3 Sekunden automatisch.
* Wird aus anderen Stores via `useToastStore.getState().addToast()` aufgerufen,
* um Store-zu-Store-Abhängigkeiten zu vermeiden.
*/
export const useToastStore = create<ToastState>((set) => ({ export const useToastStore = create<ToastState>((set) => ({
toasts: [], toasts: [],

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 = export type MuscleGroup =
| 'brust' | 'brust'
| 'ruecken' | 'ruecken'
@@ -9,6 +10,7 @@ export type MuscleGroup =
| 'ganzkoerper' | 'ganzkoerper'
| 'sonstiges'; | 'sonstiges';
/** Alle Muskelgruppen mit Anzeigebezeichnungen für Select-Felder. */
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [ export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
{ value: 'brust', label: 'Brust' }, { value: 'brust', label: 'Brust' },
{ value: 'ruecken', label: 'Rücken' }, { value: 'ruecken', label: 'Rücken' },
@@ -21,6 +23,7 @@ export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
{ value: 'sonstiges', label: 'Sonstiges' }, { value: 'sonstiges', label: 'Sonstiges' },
]; ];
/** Anzeigebezeichnungen für Muskelgruppen, direkt per Schlüssel abrufbar. */
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = { export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
brust: 'Brust', brust: 'Brust',
ruecken: 'Rücken', ruecken: 'Rücken',
@@ -33,6 +36,7 @@ export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
sonstiges: 'Sonstiges', sonstiges: 'Sonstiges',
}; };
/** Tailwind-Hintergrundfarben für Muskelgruppen-Badges. */
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = { export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
brust: 'bg-red-600', brust: 'bg-red-600',
ruecken: 'bg-blue-600', ruecken: 'bg-blue-600',
@@ -45,17 +49,21 @@ export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
sonstiges: 'bg-gray-600', sonstiges: 'bg-gray-600',
}; };
/** Eine einzelne Kraftübung aus der Datenbank. */
export interface Exercise { export interface Exercise {
id: number; id: number;
name: string; name: string;
description: string; description: string;
muscle_group: MuscleGroup; muscle_group: MuscleGroup;
weight_step_kg: number; weight_step_kg: number;
/** Optionale Nummerierung aus dem Übungskatalog (UF#). */
exercise_number?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at?: string; deleted_at?: string;
} }
/** Ein Trainings-Set: Sammlung von Übungen, die gemeinsam trainiert werden. */
export interface TrainingSet { export interface TrainingSet {
id: number; id: number;
name: string; name: string;
@@ -64,6 +72,11 @@ export interface TrainingSet {
deleted_at?: string; deleted_at?: string;
} }
/**
* Ein einzelner protokollierter Satz innerhalb einer Trainings-Session.
* Der Übungsname wird denormalisiert gespeichert, damit gelöschte Übungen
* historische Einträge nicht verwaisen lassen.
*/
export interface SessionLog { export interface SessionLog {
id: number; id: number;
session_id: number; session_id: number;
@@ -76,6 +89,7 @@ export interface SessionLog {
logged_at: string; logged_at: string;
} }
/** Eine Trainings-Session, optional mit allen protokollierten Sätzen. */
export interface Session { export interface Session {
id: number; id: number;
set_id: number; set_id: number;
@@ -86,11 +100,13 @@ export interface Session {
logs?: SessionLog[]; logs?: SessionLog[];
} }
/** Antwort des Endpunkts "letztes Training" für eine Übung. */
export interface LastLogResponse { export interface LastLogResponse {
weight_kg: number; weight_kg: number;
reps: number; reps: number;
} }
/** Aggregierte Statistiken für eine einzelne Übung. */
export interface ExerciseStats { export interface ExerciseStats {
exercise_id: number; exercise_id: number;
exercise_name: string; exercise_name: string;
@@ -100,27 +116,54 @@ export interface ExerciseStats {
last_trained: string; 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 { export interface CreateExerciseRequest {
name: string; name: string;
description: string; description: string;
muscle_group: MuscleGroup; muscle_group: MuscleGroup;
weight_step_kg?: number; weight_step_kg?: number;
exercise_number?: number;
} }
/** Request-Body zum Erstellen eines Trainings-Sets. */
export interface CreateSetRequest { export interface CreateSetRequest {
name: string; name: string;
exercise_ids: number[]; exercise_ids: number[];
} }
/** Request-Body zum Aktualisieren eines Trainings-Sets. */
export interface UpdateSetRequest { export interface UpdateSetRequest {
name: string; name: string;
exercise_ids: number[]; exercise_ids: number[];
} }
/** Request-Body zum Starten einer neuen Trainings-Session. */
export interface CreateSessionRequest { export interface CreateSessionRequest {
set_id: number; set_id: number;
} }
/** Anwendungsnutzer. */
export interface User {
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 { export interface CreateLogRequest {
exercise_id: number; exercise_id: number;
set_number: number; set_number: number;
@@ -129,6 +172,7 @@ export interface CreateLogRequest {
note: string; note: string;
} }
/** Request-Body zum nachträglichen Bearbeiten eines protokollierten Satzes. */
export interface UpdateLogRequest { export interface UpdateLogRequest {
weight_kg?: number; weight_kg?: number;
reps?: number; reps?: number;

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