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>
This commit is contained in:
@@ -15,7 +15,8 @@ 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
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -1,3 +1,5 @@
|
|||||||
|
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:
|
||||||
@@ -10,7 +12,7 @@ build:
|
|||||||
cd frontend && pnpm install && pnpm build
|
cd frontend && pnpm install && pnpm build
|
||||||
find backend/static -not -name 'embed.go' -not -name '.gitkeep' -not -path backend/static -delete
|
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 && GO111MODULE=on 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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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)
|
||||||
|
|||||||
135
backend/internal/handler/image_handler.go
Normal file
135
backend/internal/handler/image_handler.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxImageSize = 5 << 20 // 5 MB
|
||||||
|
uploadDir = "uploads"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sicherstellen
|
||||||
|
os.MkdirAll(uploadDir, 0755)
|
||||||
|
|
||||||
|
fmt.Println("Bild-Upload konfiguriert:", uploadDir)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,6 +7,36 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, err := userID(r)
|
uid, err := userID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,6 +56,10 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
session, err := h.store.CreateSession(uid, 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
|
||||||
|
|||||||
262
backend/internal/handler/session_resume_test.go
Normal file
262
backend/internal/handler/session_resume_test.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package handler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
migrate "krafttrainer/internal/migrate"
|
||||||
|
"krafttrainer/internal/handler"
|
||||||
|
"krafttrainer/internal/store"
|
||||||
|
"krafttrainer/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test infrastructure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func newHandlerWithStore(t *testing.T) (*handler.Handler, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.CreateTemp("", "krafttrainer-handler-test-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp db file: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
dbPath := f.Name()
|
||||||
|
|
||||||
|
s, err := store.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(dbPath)
|
||||||
|
t.Fatalf("store.New: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.Close()
|
||||||
|
os.Remove(dbPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := migrate.Run(s.DB(), migrations.FS); err != nil {
|
||||||
|
t.Fatalf("migrate.Run: %v", err)
|
||||||
|
}
|
||||||
|
return handler.New(s), s
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedUserH(t *testing.T, db *sql.DB, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedUser %q: %v", name, err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTrainingSetH(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, name, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedTrainingSet %q: %v", name, err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedExerciseH(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`,
|
||||||
|
name, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedExercise %q: %v", name, err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSessionH(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, setID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openSession: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestWithUserID builds a request for path with an optional X-User-ID header.
|
||||||
|
func requestWithUserID(method, path string, userIDHeader string) *http.Request {
|
||||||
|
r := httptest.NewRequest(method, path, nil)
|
||||||
|
if userIDHeader != "" {
|
||||||
|
r.Header.Set("X-User-ID", userIDHeader)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBody parses the JSON response body into dst.
|
||||||
|
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
|
||||||
|
t.Fatalf("decode response body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMux registers all handler routes on a fresh ServeMux so that Go 1.22+
|
||||||
|
// pattern matching (including the /active literal segment) is active.
|
||||||
|
func newMux(h *handler.Handler) *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// handleGetActiveSession tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_MissingUserIDHeader(t *testing.T) {
|
||||||
|
h, _ := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", ""))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status: want 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_InvalidUserIDHeader(t *testing.T) {
|
||||||
|
h, _ := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
|
||||||
|
for _, bad := range []string{"not-a-number", "0", "-1"} {
|
||||||
|
t.Run(bad, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", bad))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("header %q: status want 400, got %d", bad, rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_ReturnsNullWhenNoActiveSession(t *testing.T) {
|
||||||
|
h, s := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
userID := seedUserH(t, s.DB(), "NoSession")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", userID)))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON(w, 200, nil) encodes the Go nil interface value as JSON "null".
|
||||||
|
body := strings.TrimSpace(rec.Body.String())
|
||||||
|
if body != "null" {
|
||||||
|
t.Errorf("body: want %q, got %q", "null", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_ReturnsSessionAndExercises(t *testing.T) {
|
||||||
|
h, s := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
|
||||||
|
uid := seedUserH(t, s.DB(), "ActiveUser")
|
||||||
|
setID := seedTrainingSetH(t, s.DB(), "Push Tag", uid)
|
||||||
|
exID := seedExerciseH(t, s.DB(), "Bankdrücken", uid)
|
||||||
|
|
||||||
|
if _, err := s.DB().Exec(
|
||||||
|
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, 0)`,
|
||||||
|
setID, exID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("link exercise to set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := openSessionH(t, s.DB(), setID, uid)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Session struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SetID int64 `json:"set_id"`
|
||||||
|
SetName string `json:"set_name"`
|
||||||
|
} `json:"session"`
|
||||||
|
Exercises []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"exercises"`
|
||||||
|
}
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
|
||||||
|
if resp.Session.ID != sessionID {
|
||||||
|
t.Errorf("session.id: want %d, got %d", sessionID, resp.Session.ID)
|
||||||
|
}
|
||||||
|
if resp.Session.SetID != setID {
|
||||||
|
t.Errorf("session.set_id: want %d, got %d", setID, resp.Session.SetID)
|
||||||
|
}
|
||||||
|
if resp.Session.SetName != "Push Tag" {
|
||||||
|
t.Errorf("session.set_name: want %q, got %q", "Push Tag", resp.Session.SetName)
|
||||||
|
}
|
||||||
|
if len(resp.Exercises) != 1 {
|
||||||
|
t.Fatalf("exercises: want 1, got %d", len(resp.Exercises))
|
||||||
|
}
|
||||||
|
if resp.Exercises[0].ID != exID {
|
||||||
|
t.Errorf("exercise.id: want %d, got %d", exID, resp.Exercises[0].ID)
|
||||||
|
}
|
||||||
|
if resp.Exercises[0].Name != "Bankdrücken" {
|
||||||
|
t.Errorf("exercise.name: want %q, got %q", "Bankdrücken", resp.Exercises[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_ExercisesEmptyWhenSetHasNone(t *testing.T) {
|
||||||
|
h, s := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
|
||||||
|
uid := seedUserH(t, s.DB(), "NoExUser")
|
||||||
|
setID := seedTrainingSetH(t, s.DB(), "Leeres Set", uid)
|
||||||
|
openSessionH(t, s.DB(), setID, uid)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Exercises []json.RawMessage `json:"exercises"`
|
||||||
|
}
|
||||||
|
decodeBody(t, rec, &resp)
|
||||||
|
|
||||||
|
if len(resp.Exercises) != 0 {
|
||||||
|
t.Errorf("exercises: want empty slice, got %d items", len(resp.Exercises))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetActiveSession_ContentTypeIsJSON(t *testing.T) {
|
||||||
|
h, s := newHandlerWithStore(t)
|
||||||
|
mux := newMux(h)
|
||||||
|
|
||||||
|
uid := seedUserH(t, s.DB(), "CTUser")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||||
|
|
||||||
|
ct := rec.Header().Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("Content-Type: want application/json, got %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,22 +8,24 @@ import (
|
|||||||
|
|
||||||
// Exercise repräsentiert eine Kraftübung.
|
// Exercise repräsentiert eine Kraftübung.
|
||||||
type Exercise struct {
|
type Exercise struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
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"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ExerciseNumber *int `json:"exercise_number,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateExerciseRequest enthält die Felder zum Anlegen einer Übung.
|
// CreateExerciseRequest enthält die Felder zum Anlegen einer Übung.
|
||||||
type CreateExerciseRequest struct {
|
type CreateExerciseRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
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.
|
||||||
|
|||||||
12
backend/internal/model/exercise_image.go
Normal file
12
backend/internal/model/exercise_image.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ExerciseImage repräsentiert ein Bild einer Übung.
|
||||||
|
type ExerciseImage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ExerciseID int64 `json:"exercise_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert.
|
// ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert.
|
||||||
func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) {
|
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 user_id = ?
|
||||||
@@ -26,7 +26,7 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.
|
|||||||
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)
|
||||||
@@ -41,9 +41,9 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -56,9 +56,9 @@ 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(userID int64, 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, user_id)
|
INSERT INTO exercises (name, description, muscle_group, weight_step_kg, exercise_number, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, userID,
|
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)
|
||||||
@@ -72,10 +72,10 @@ func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) (
|
|||||||
func (s *Store) UpdateExercise(id, userID 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 user_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, userID,
|
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)
|
||||||
|
|||||||
74
backend/internal/store/image_store.go
Normal file
74
backend/internal/store/image_store.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"krafttrainer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListExerciseImages gibt alle Bilder einer Übung zurück.
|
||||||
|
func (s *Store) ListExerciseImages(exerciseID int64) ([]model.ExerciseImage, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, exercise_id, filename, sort_order, created_at
|
||||||
|
FROM exercise_images
|
||||||
|
WHERE exercise_id = ?
|
||||||
|
ORDER BY sort_order, id`, exerciseID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Bilder abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var images []model.ExerciseImage
|
||||||
|
for rows.Next() {
|
||||||
|
var img model.ExerciseImage
|
||||||
|
if err := rows.Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("Bild scannen: %w", err)
|
||||||
|
}
|
||||||
|
images = append(images, img)
|
||||||
|
}
|
||||||
|
if images == nil {
|
||||||
|
images = []model.ExerciseImage{}
|
||||||
|
}
|
||||||
|
return images, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateExerciseImage speichert einen Bild-Eintrag.
|
||||||
|
func (s *Store) CreateExerciseImage(exerciseID int64, filename string) (*model.ExerciseImage, error) {
|
||||||
|
// sort_order = bisherige Anzahl
|
||||||
|
var count int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM exercise_images WHERE exercise_id = ?`, exerciseID).Scan(&count)
|
||||||
|
|
||||||
|
result, err := s.db.Exec(`
|
||||||
|
INSERT INTO exercise_images (exercise_id, filename, sort_order)
|
||||||
|
VALUES (?, ?, ?)`, exerciseID, filename, count,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Bild speichern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := result.LastInsertId()
|
||||||
|
var img model.ExerciseImage
|
||||||
|
err = s.db.QueryRow(`
|
||||||
|
SELECT id, exercise_id, filename, sort_order, created_at
|
||||||
|
FROM exercise_images WHERE id = ?`, id,
|
||||||
|
).Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Bild abfragen: %w", err)
|
||||||
|
}
|
||||||
|
return &img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExerciseImage löscht ein Bild und gibt den Dateinamen zurück.
|
||||||
|
func (s *Store) DeleteExerciseImage(imageID int64) (string, error) {
|
||||||
|
var filename string
|
||||||
|
err := s.db.QueryRow(`SELECT filename FROM exercise_images WHERE id = ?`, imageID).Scan(&filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Bild nicht gefunden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`DELETE FROM exercise_images WHERE id = ?`, imageID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Bild löschen: %w", err)
|
||||||
|
}
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
435
backend/internal/store/session_resume_test.go
Normal file
435
backend/internal/store/session_resume_test.go
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
migrate "krafttrainer/internal/migrate"
|
||||||
|
"krafttrainer/internal/store"
|
||||||
|
"krafttrainer/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestStore creates a temporary SQLite database file, runs all migrations,
|
||||||
|
// and returns a fully initialised *store.Store. A t.Cleanup removes the temp
|
||||||
|
// file and closes the store after the test finishes.
|
||||||
|
//
|
||||||
|
// We cannot use ":memory:" directly because store.New appends query parameters
|
||||||
|
// to the path string with "?", which would corrupt a URI already containing "?".
|
||||||
|
// A temp file avoids this and provides the same isolation guarantee.
|
||||||
|
func newTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a temp file; store.New will open it by path.
|
||||||
|
f, err := os.CreateTemp("", "krafttrainer-test-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp db file: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
dbPath := f.Name()
|
||||||
|
|
||||||
|
s, err := store.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(dbPath)
|
||||||
|
t.Fatalf("store.New: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.Close()
|
||||||
|
os.Remove(dbPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := migrate.Run(s.DB(), migrations.FS); err != nil {
|
||||||
|
t.Fatalf("migrate.Run: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedUser inserts a user row and returns the new id.
|
||||||
|
func seedUser(t *testing.T, db *sql.DB, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedUser: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExercise inserts a minimal exercise row and returns the new id.
|
||||||
|
func seedExercise(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`,
|
||||||
|
name, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedExercise %q: %v", name, err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTrainingSet inserts a training set and returns its id.
|
||||||
|
func seedTrainingSet(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`,
|
||||||
|
name, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedTrainingSet %q: %v", name, err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSetExercise links an exercise to a training set at the given position.
|
||||||
|
func seedSetExercise(t *testing.T, db *sql.DB, setID, exerciseID int64, position int) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`,
|
||||||
|
setID, exerciseID, position,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedSetExercise set=%d exercise=%d: %v", setID, exerciseID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSession inserts a session that has no ended_at (open).
|
||||||
|
func openSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`,
|
||||||
|
setID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openSession: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// closedSession inserts a session with ended_at set.
|
||||||
|
func closedSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`INSERT INTO sessions (set_id, user_id, ended_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
setID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closedSession: %v", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GetActiveSession tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetActiveSession_NoOpenSession(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Alice")
|
||||||
|
|
||||||
|
got, err := s.GetActiveSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil, got session id=%d", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveSession_OnlyClosedSessions(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Bob")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Set A", userID)
|
||||||
|
closedSession(t, s.DB(), setID, userID)
|
||||||
|
|
||||||
|
got, err := s.GetActiveSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil for user with only closed sessions, got id=%d", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveSession_ReturnsOpenSession(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Carol")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Brust-Tag", userID)
|
||||||
|
sessionID := openSession(t, s.DB(), setID, userID)
|
||||||
|
|
||||||
|
got, err := s.GetActiveSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil session, got nil")
|
||||||
|
}
|
||||||
|
if got.ID != sessionID {
|
||||||
|
t.Errorf("session ID: want %d, got %d", sessionID, got.ID)
|
||||||
|
}
|
||||||
|
if got.SetID != setID {
|
||||||
|
t.Errorf("set ID: want %d, got %d", setID, got.SetID)
|
||||||
|
}
|
||||||
|
if got.SetName != "Brust-Tag" {
|
||||||
|
t.Errorf("set name: want %q, got %q", "Brust-Tag", got.SetName)
|
||||||
|
}
|
||||||
|
if got.EndedAt != nil {
|
||||||
|
t.Errorf("expected EndedAt to be nil for open session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveSession_IsolatedByUser(t *testing.T) {
|
||||||
|
// An open session belonging to another user must not be returned.
|
||||||
|
s := newTestStore(t)
|
||||||
|
user1 := seedUser(t, s.DB(), "User1")
|
||||||
|
user2 := seedUser(t, s.DB(), "User2")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Set", user1)
|
||||||
|
openSession(t, s.DB(), setID, user1)
|
||||||
|
|
||||||
|
got, err := s.GetActiveSession(user2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil for user2, got session id=%d (belongs to user1)", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetActiveSession_ReturnsLatestWhenMultipleOpen(t *testing.T) {
|
||||||
|
// If (by some data anomaly) two open sessions exist, the latest one is
|
||||||
|
// returned (ORDER BY started_at DESC LIMIT 1).
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Dave")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
||||||
|
|
||||||
|
firstID := openSession(t, s.DB(), setID, userID)
|
||||||
|
secondID := openSession(t, s.DB(), setID, userID)
|
||||||
|
|
||||||
|
got, err := s.GetActiveSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected a session, got nil")
|
||||||
|
}
|
||||||
|
// The second insert has a higher id; SQLite CURRENT_TIMESTAMP resolution
|
||||||
|
// may produce equal timestamps, so we accept either the second or the one
|
||||||
|
// with the higher id (both reflect "latest").
|
||||||
|
if got.ID != secondID && got.ID != firstID {
|
||||||
|
t.Errorf("unexpected session id %d (want %d or %d)", got.ID, firstID, secondID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateSession / SESSION_OPEN guard tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCreateSession_RejectsWhenOpenSessionExists(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Eve")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Leg Day", userID)
|
||||||
|
|
||||||
|
// Create a first session via the store so the guard logic runs.
|
||||||
|
first, err := s.CreateSession(userID, setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
if first == nil {
|
||||||
|
t.Fatal("first CreateSession returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second call must be rejected with SESSION_OPEN.
|
||||||
|
_, err = s.CreateSession(userID, setID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected SESSION_OPEN error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "SESSION_OPEN") {
|
||||||
|
t.Errorf("expected error to contain SESSION_OPEN, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_AllowsNewSessionAfterClose(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Frank")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Push", userID)
|
||||||
|
|
||||||
|
first, err := s.CreateSession(userID, setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.EndSession(first.ID, userID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EndSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := s.CreateSession(userID, setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession after close: %v", err)
|
||||||
|
}
|
||||||
|
if second == nil {
|
||||||
|
t.Fatal("expected new session, got nil")
|
||||||
|
}
|
||||||
|
if second.ID == first.ID {
|
||||||
|
t.Errorf("expected a new session ID, got the same id=%d", second.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_OpenSessionOfOtherUserDoesNotBlock(t *testing.T) {
|
||||||
|
// An open session for user1 must not prevent user2 from starting one.
|
||||||
|
s := newTestStore(t)
|
||||||
|
user1 := seedUser(t, s.DB(), "Greg")
|
||||||
|
user2 := seedUser(t, s.DB(), "Hanna")
|
||||||
|
set1 := seedTrainingSet(t, s.DB(), "Set1", user1)
|
||||||
|
set2 := seedTrainingSet(t, s.DB(), "Set2", user2)
|
||||||
|
|
||||||
|
if _, err := s.CreateSession(user1, set1); err != nil {
|
||||||
|
t.Fatalf("CreateSession user1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess2, err := s.CreateSession(user2, set2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession user2 should succeed: %v", err)
|
||||||
|
}
|
||||||
|
if sess2 == nil {
|
||||||
|
t.Fatal("expected session for user2, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_ErrorOnNonExistentSet(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Iris")
|
||||||
|
|
||||||
|
_, err := s.CreateSession(userID, 9999)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent set, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "existiert nicht") {
|
||||||
|
t.Errorf("expected 'existiert nicht' in error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GetSetExercises tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetSetExercises_EmptySet(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Jack")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Empty Set", userID)
|
||||||
|
|
||||||
|
exercises, err := s.GetSetExercises(setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 0 {
|
||||||
|
t.Errorf("expected 0 exercises, got %d", len(exercises))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetExercises_ReturnsExercisesInPositionOrder(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Kira")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Full Body", userID)
|
||||||
|
|
||||||
|
e1 := seedExercise(t, s.DB(), "Bankdrücken", userID)
|
||||||
|
e2 := seedExercise(t, s.DB(), "Kniebeugen", userID)
|
||||||
|
e3 := seedExercise(t, s.DB(), "Kreuzheben", userID)
|
||||||
|
|
||||||
|
// Insert in non-sequential position order to verify ORDER BY position.
|
||||||
|
seedSetExercise(t, s.DB(), setID, e3, 2)
|
||||||
|
seedSetExercise(t, s.DB(), setID, e1, 0)
|
||||||
|
seedSetExercise(t, s.DB(), setID, e2, 1)
|
||||||
|
|
||||||
|
exercises, err := s.GetSetExercises(setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 3 {
|
||||||
|
t.Fatalf("expected 3 exercises, got %d", len(exercises))
|
||||||
|
}
|
||||||
|
wantOrder := []int64{e1, e2, e3}
|
||||||
|
for i, ex := range exercises {
|
||||||
|
if ex.ID != wantOrder[i] {
|
||||||
|
t.Errorf("position %d: want exercise id=%d, got id=%d", i, wantOrder[i], ex.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetExercises_ExcludesSoftDeletedExercises(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Leo")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
||||||
|
|
||||||
|
active := seedExercise(t, s.DB(), "Aktive Übung", userID)
|
||||||
|
deleted := seedExercise(t, s.DB(), "Gelöschte Übung", userID)
|
||||||
|
|
||||||
|
seedSetExercise(t, s.DB(), setID, active, 0)
|
||||||
|
seedSetExercise(t, s.DB(), setID, deleted, 1)
|
||||||
|
|
||||||
|
// Soft-delete the second exercise.
|
||||||
|
if _, err := s.DB().Exec(`UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`, deleted); err != nil {
|
||||||
|
t.Fatalf("soft-delete exercise: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exercises, err := s.GetSetExercises(setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 1 {
|
||||||
|
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
||||||
|
}
|
||||||
|
if exercises[0].ID != active {
|
||||||
|
t.Errorf("expected active exercise id=%d, got id=%d", active, exercises[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetExercises_NonExistentSet(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
exercises, err := s.GetSetExercises(9999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 0 {
|
||||||
|
t.Errorf("expected 0 exercises for non-existent set, got %d", len(exercises))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetExercises_PopulatesAllFields(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
userID := seedUser(t, s.DB(), "Mia")
|
||||||
|
setID := seedTrainingSet(t, s.DB(), "Detail Set", userID)
|
||||||
|
exID := seedExercise(t, s.DB(), "Rudern", userID)
|
||||||
|
seedSetExercise(t, s.DB(), setID, exID, 0)
|
||||||
|
|
||||||
|
exercises, err := s.GetSetExercises(setID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(exercises) != 1 {
|
||||||
|
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
||||||
|
}
|
||||||
|
ex := exercises[0]
|
||||||
|
if ex.ID != exID {
|
||||||
|
t.Errorf("ID: want %d, got %d", exID, ex.ID)
|
||||||
|
}
|
||||||
|
if ex.Name != "Rudern" {
|
||||||
|
t.Errorf("Name: want %q, got %q", "Rudern", ex.Name)
|
||||||
|
}
|
||||||
|
if ex.MuscleGroup != "brust" {
|
||||||
|
t.Errorf("MuscleGroup: want %q, got %q", "brust", ex.MuscleGroup)
|
||||||
|
}
|
||||||
|
if ex.WeightStepKg != 2.5 {
|
||||||
|
t.Errorf("WeightStepKg: want 2.5, got %f", ex.WeightStepKg)
|
||||||
|
}
|
||||||
|
if ex.CreatedAt.IsZero() {
|
||||||
|
t.Error("CreatedAt must not be zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
|
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
|
||||||
|
// Gibt einen Fehler zurück wenn noch eine offene Session existiert.
|
||||||
func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) {
|
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 user_id = ? AND deleted_at IS NULL`, setID, userID).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)
|
||||||
}
|
}
|
||||||
@@ -96,6 +106,51 @@ func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session,
|
|||||||
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) {
|
||||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||||
|
|||||||
1
backend/migrations/005_add_exercise_number.down.sql
Normal file
1
backend/migrations/005_add_exercise_number.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE exercises DROP COLUMN exercise_number;
|
||||||
24
backend/migrations/005_add_exercise_number.up.sql
Normal file
24
backend/migrations/005_add_exercise_number.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE exercises ADD COLUMN exercise_number INTEGER;
|
||||||
|
|
||||||
|
-- Trainingsplan krafttraining2026: Übungsnummern (UF#) setzen
|
||||||
|
UPDATE exercises SET exercise_number = 32 WHERE name = 'Bankdrücken sitzend' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 94 WHERE name = 'Trizepsdrücken Kabel (Pushdown)' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 35 WHERE name = 'Schrägbankdrücken einarmig' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 92 WHERE name = 'Trizepsstrecken überkopf' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 36 WHERE name = 'Fliegende stehend' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 99 WHERE name = 'Trizeps-Kickbacks einarmig' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 60 WHERE name = 'Schulterdrücken sitzend' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 66 WHERE name = 'Seitheben' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 42 WHERE name = 'Latzug zur Brust (Obergriff)' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 87 WHERE name = 'Bizeps-Curls mit Curl-Stange' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 45 WHERE name = 'Latzug Untergriff' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 83 WHERE name = 'Hammer-Curls' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 53 WHERE name = 'Rudern stehend' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 89 WHERE name = 'Konzentrations-Curls einarmig' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 54 WHERE name = 'Einarmiges Rudern Kabel' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 81 WHERE name = '21er-Curls (Finisher)' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 106 WHERE name = 'Crunches am Kabel' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 110 WHERE name = 'Rückenstrecker sitzend' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 107 WHERE name = 'Schräge Crunches' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 109 WHERE name = 'Seitbeugen einarmig' AND deleted_at IS NULL;
|
||||||
|
UPDATE exercises SET exercise_number = 14 WHERE name = 'Hüftbeugen am Kabel' AND deleted_at IS NULL;
|
||||||
1
backend/migrations/006_add_exercise_images.down.sql
Normal file
1
backend/migrations/006_add_exercise_images.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS exercise_images;
|
||||||
9
backend/migrations/006_add_exercise_images.up.sql
Normal file
9
backend/migrations/006_add_exercise_images.up.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE exercise_images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_exercise_images_exercise_id ON exercise_images(exercise_id);
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
krafttrainer:
|
krafttrainer:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: "${VERSION:-dev}"
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Exercise,
|
Exercise,
|
||||||
|
ExerciseImage,
|
||||||
TrainingSet,
|
TrainingSet,
|
||||||
Session,
|
Session,
|
||||||
SessionLog,
|
SessionLog,
|
||||||
@@ -100,6 +101,37 @@ 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`);
|
||||||
|
},
|
||||||
|
|
||||||
|
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));
|
||||||
@@ -137,6 +169,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',
|
||||||
@@ -202,4 +238,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');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 {
|
||||||
exercise?: Exercise | null;
|
exercise?: Exercise | null;
|
||||||
@@ -13,6 +14,7 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
|
|||||||
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 +22,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 +64,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 +113,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"
|
||||||
|
|||||||
127
frontend/src/components/exercises/ImageGallery.tsx
Normal file
127
frontend/src/components/exercises/ImageGallery.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 {
|
||||||
@@ -109,7 +110,12 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
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>
|
||||||
@@ -128,6 +134,9 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
|
|||||||
{/* Expanded content */}
|
{/* Expanded content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="px-4 pb-4 space-y-3">
|
||||||
|
{/* Fortschritts-Sparkline */}
|
||||||
|
<ExerciseSparkline exerciseId={exercise.id} />
|
||||||
|
|
||||||
{/* Vorherige Werte */}
|
{/* Vorherige Werte */}
|
||||||
{lastLog && (
|
{lastLog && (
|
||||||
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
|
||||||
|
|||||||
95
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
95
frontend/src/components/training/ExerciseSparkline.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Epley-Formel: e1RM = Gewicht × (1 + Wdh / 30)
|
||||||
|
function calcE1RM(weight: number, reps: number): number {
|
||||||
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
|
return Math.round(weight * (1 + reps / 30) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
|
||||||
|
const [data, setData] = useState<DataPoint[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.exercises
|
||||||
|
.history(exerciseId, 100)
|
||||||
|
.then((logs: SessionLog[]) => {
|
||||||
|
// Gruppiere nach Session (= Trainingstag), nimm bestes e1RM pro Session
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useUserStore } from '../stores/userStore';
|
import { useUserStore } from '../stores/userStore';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { users, activeUser, setActiveUser, fetchUsers, createUser, deleteUser } =
|
const { users, activeUser, setActiveUser, fetchUsers, createUser, deleteUser } =
|
||||||
useUserStore();
|
useUserStore();
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
api.version().then((v) => setVersion(v.version)).catch(() => {});
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
@@ -90,6 +93,10 @@ export function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{version && (
|
||||||
|
<p className="text-xs text-gray-600 pt-4">Version {version}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ActiveSessionState {
|
|||||||
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>;
|
||||||
@@ -57,6 +58,24 @@ 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 || [] });
|
||||||
|
|
||||||
|
// Lade letzte Logs für alle Übungen
|
||||||
|
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);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface Exercise {
|
|||||||
description: string;
|
description: string;
|
||||||
muscle_group: MuscleGroup;
|
muscle_group: MuscleGroup;
|
||||||
weight_step_kg: number;
|
weight_step_kg: number;
|
||||||
|
exercise_number?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
@@ -100,11 +101,20 @@ export interface ExerciseStats {
|
|||||||
last_trained: string;
|
last_trained: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExerciseImage {
|
||||||
|
id: number;
|
||||||
|
exercise_id: number;
|
||||||
|
filename: string;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSetRequest {
|
export interface CreateSetRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user