feat: add complete workout management system with file parsing
This commit is contained in:
391
internal/workout/handler.go
Normal file
391
internal/workout/handler.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
package workout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rideaware/internal/config"
|
||||||
|
"rideaware/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler() *Handler {
|
||||||
|
return &Handler{
|
||||||
|
service: NewService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorkout POST /api/protected/workouts
|
||||||
|
func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("CreateWorkout handler called")
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
if claims == nil {
|
||||||
|
log.Printf("Claims is nil")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("UserID: %d", claims.UserID)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ScheduledDate string `json:"scheduled_date"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
WorkoutData *WorkoutDataJSON `json:"workout_data"`
|
||||||
|
FileType string `json:"file_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("Decode error: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("CreateWorkout request: %+v", req)
|
||||||
|
|
||||||
|
if req.Title == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "title is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ScheduledDate == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "scheduled_date is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse scheduled date
|
||||||
|
scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Date parse error: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default duration if not provided
|
||||||
|
if req.Duration <= 0 {
|
||||||
|
req.Duration = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WorkoutData if nil
|
||||||
|
workoutData := req.WorkoutData
|
||||||
|
if workoutData == nil {
|
||||||
|
workoutData = &WorkoutDataJSON{
|
||||||
|
Segments: []WorkoutSegment{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workout
|
||||||
|
workout := &Workout{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
Type: req.Type,
|
||||||
|
Status: "planned",
|
||||||
|
ScheduledDate: scheduledDate,
|
||||||
|
Duration: req.Duration,
|
||||||
|
Notes: req.Notes,
|
||||||
|
FileType: req.FileType,
|
||||||
|
WorkoutData: *workoutData,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Creating workout: %+v", workout)
|
||||||
|
|
||||||
|
if err := h.service.repo.CreateWorkout(workout); err != nil {
|
||||||
|
log.Printf("CreateWorkout error: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create workout"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Workout created successfully with ID: %d", workout.ID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(workout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkouts GET /api/protected/workouts
|
||||||
|
func (h *Handler) GetWorkouts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
workouts, err := h.service.GetUserWorkouts(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch workouts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if workouts == nil {
|
||||||
|
workouts = []Workout{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(workouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkoutsByMonth GET /api/protected/workouts/month?year=2025&month=11
|
||||||
|
func (h *Handler) GetWorkoutsByMonth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
yearStr := r.URL.Query().Get("year")
|
||||||
|
monthStr := r.URL.Query().Get("month")
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(yearStr)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid year"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
month, err := strconv.Atoi(monthStr)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid month"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workouts, err := h.service.GetWorkoutsByMonth(claims.UserID, year, month)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch workouts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if workouts == nil {
|
||||||
|
workouts = []Workout{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(workouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWorkout PUT /api/protected/workouts
|
||||||
|
func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Distance float64 `json:"distance"`
|
||||||
|
ElevGain int `json:"elev_gain"`
|
||||||
|
AvgPower int `json:"avg_power"`
|
||||||
|
AvgHR int `json:"avg_hr"`
|
||||||
|
MaxPower int `json:"max_power"`
|
||||||
|
MaxHR int `json:"max_hr"`
|
||||||
|
CaloriesBurned int `json:"calories_burned"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workout, err := h.service.repo.GetWorkoutByID(uint(id), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "workout not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Title != "" {
|
||||||
|
workout.Title = req.Title
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
workout.Description = req.Description
|
||||||
|
}
|
||||||
|
if req.Type != "" {
|
||||||
|
workout.Type = req.Type
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
workout.Status = req.Status
|
||||||
|
}
|
||||||
|
if req.Duration > 0 {
|
||||||
|
workout.Duration = req.Duration
|
||||||
|
}
|
||||||
|
if req.Distance > 0 {
|
||||||
|
workout.Distance = req.Distance
|
||||||
|
}
|
||||||
|
if req.ElevGain > 0 {
|
||||||
|
workout.ElevGain = req.ElevGain
|
||||||
|
}
|
||||||
|
if req.AvgPower > 0 {
|
||||||
|
workout.AvgPower = req.AvgPower
|
||||||
|
}
|
||||||
|
if req.AvgHR > 0 {
|
||||||
|
workout.AvgHR = req.AvgHR
|
||||||
|
}
|
||||||
|
if req.MaxPower > 0 {
|
||||||
|
workout.MaxPower = req.MaxPower
|
||||||
|
}
|
||||||
|
if req.MaxHR > 0 {
|
||||||
|
workout.MaxHR = req.MaxHR
|
||||||
|
}
|
||||||
|
if req.CaloriesBurned > 0 {
|
||||||
|
workout.CaloriesBurned = req.CaloriesBurned
|
||||||
|
}
|
||||||
|
if req.Notes != "" {
|
||||||
|
workout.Notes = req.Notes
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.repo.UpdateWorkout(workout); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to update workout"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(workout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWorkout DELETE /api/protected/workouts
|
||||||
|
func (h *Handler) DeleteWorkout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.DeleteWorkout(uint(id), claims.UserID); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkoutTypes GET /api/protected/workout-types
|
||||||
|
func (h *Handler) GetWorkoutTypes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
types := []map[string]interface{}{
|
||||||
|
{"id": 1, "name": "Recovery", "color": "#4285F4", "icon": "🔵"},
|
||||||
|
{"id": 2, "name": "Endurance", "color": "#34A853", "icon": "🟢"},
|
||||||
|
{"id": 3, "name": "Tempo", "color": "#FBBC04", "icon": "🟡"},
|
||||||
|
{"id": 4, "name": "Threshold", "color": "#EA4335", "icon": "🔴"},
|
||||||
|
{"id": 5, "name": "VO2 Max", "color": "#A61C00", "icon": "⭐"},
|
||||||
|
{"id": 6, "name": "Strength", "color": "#800080", "icon": "💪"},
|
||||||
|
{"id": 7, "name": "Race", "color": "#FF1744", "icon": "🏁"},
|
||||||
|
{"id": 8, "name": "Rest", "color": "#CCCCCC", "icon": "😴"},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(types)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadWorkoutFile POST /api/protected/workouts/upload
|
||||||
|
func (h *Handler) UploadWorkoutFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||||
|
|
||||||
|
err := r.ParseMultipartForm(10 << 20) // 10MB max
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "file too large"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, handler, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "no file provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fileContent := make([]byte, handler.Size)
|
||||||
|
file.Read(fileContent)
|
||||||
|
|
||||||
|
// Parse ZWO file
|
||||||
|
parsedData, err := ParseZWO(fileContent)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scheduled date from form
|
||||||
|
scheduledDateStr := r.FormValue("scheduled_date")
|
||||||
|
scheduledDate, err := time.Parse("2006-01-02", scheduledDateStr)
|
||||||
|
if err != nil {
|
||||||
|
scheduledDate = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workout with parsed data
|
||||||
|
workout := &Workout{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
Title: parsedData.Name,
|
||||||
|
Description: parsedData.Description,
|
||||||
|
Type: "imported",
|
||||||
|
Status: "planned",
|
||||||
|
ScheduledDate: scheduledDate,
|
||||||
|
Duration: parsedData.TotalDuration,
|
||||||
|
FileType: "zwo",
|
||||||
|
WorkoutData: WorkoutDataJSON{
|
||||||
|
Name: parsedData.Name,
|
||||||
|
Author: parsedData.Author,
|
||||||
|
TotalDuration: parsedData.TotalDuration,
|
||||||
|
Segments: parsedData.Segments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.repo.CreateWorkout(workout); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create workout"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(workout)
|
||||||
|
}
|
||||||
66
internal/workout/model.go
Normal file
66
internal/workout/model.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package workout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Workout struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
Title string `gorm:"not null" json:"title"`
|
||||||
|
Description string `gorm:"default:''" json:"description"`
|
||||||
|
Type string `gorm:"default:''" json:"type"`
|
||||||
|
Status string `gorm:"default:'planned'" json:"status"`
|
||||||
|
ScheduledDate time.Time `gorm:"index" json:"scheduled_date"`
|
||||||
|
Duration int `gorm:"default:0" json:"duration"`
|
||||||
|
Distance float64 `gorm:"default:0" json:"distance"`
|
||||||
|
ElevGain int `gorm:"default:0" json:"elev_gain"`
|
||||||
|
AvgPower int `gorm:"default:0" json:"avg_power"`
|
||||||
|
AvgHR int `gorm:"default:0" json:"avg_hr"`
|
||||||
|
MaxPower int `gorm:"default:0" json:"max_power"`
|
||||||
|
MaxHR int `gorm:"default:0" json:"max_hr"`
|
||||||
|
CaloriesBurned int `gorm:"default:0" json:"calories_burned"`
|
||||||
|
FileType string `gorm:"default:''" json:"file_type"`
|
||||||
|
FileURL string `gorm:"default:''" json:"file_url"`
|
||||||
|
WorkoutData WorkoutDataJSON `gorm:"type:jsonb" json:"workout_data,omitempty"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkoutDataJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
TotalDuration int `json:"total_duration"`
|
||||||
|
Segments []WorkoutSegment `json:"segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkoutSegment struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
PowerLow float64 `json:"power_low"`
|
||||||
|
PowerHigh float64 `json:"power_high"`
|
||||||
|
Power float64 `json:"power"`
|
||||||
|
Cadence int `json:"cadence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner interface
|
||||||
|
func (w *WorkoutDataJSON) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*w = WorkoutDataJSON{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bytes := value.([]byte)
|
||||||
|
return json.Unmarshal(bytes, &w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer interface
|
||||||
|
func (w WorkoutDataJSON) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Workout) TableName() string {
|
||||||
|
return "workouts"
|
||||||
|
}
|
||||||
65
internal/workout/repository.go
Normal file
65
internal/workout/repository.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package workout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"rideaware/pkg/database"
|
||||||
|
"time"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct{}
|
||||||
|
|
||||||
|
func NewRepository() *Repository {
|
||||||
|
return &Repository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateWorkout(workout *Workout) error {
|
||||||
|
return database.DB.Create(workout).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetWorkoutByID(id, userID uint) (*Workout, error) {
|
||||||
|
var workout Workout
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).
|
||||||
|
First(&workout).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("workout not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &workout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetUserWorkouts(userID uint) ([]Workout, error) {
|
||||||
|
var workouts []Workout
|
||||||
|
if err := database.DB.Where("user_id = ?", userID).
|
||||||
|
Order("scheduled_date DESC").
|
||||||
|
Find(&workouts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return workouts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetWorkoutsByDateRange(userID uint, start, end time.Time) ([]Workout, error) {
|
||||||
|
var workouts []Workout
|
||||||
|
if err := database.DB.Where("user_id = ? AND scheduled_date BETWEEN ? AND ?", userID, start, end).
|
||||||
|
Order("scheduled_date ASC").
|
||||||
|
Find(&workouts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return workouts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetWorkoutsByMonth(userID uint, year, month int) ([]Workout, error) {
|
||||||
|
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := start.AddDate(0, 1, 0).Add(-time.Second)
|
||||||
|
return r.GetWorkoutsByDateRange(userID, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateWorkout(workout *Workout) error {
|
||||||
|
return database.DB.Model(workout).Updates(workout).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) DeleteWorkout(id, userID uint) error {
|
||||||
|
return database.DB.Where("id = ? AND user_id = ?", id, userID).
|
||||||
|
Delete(&Workout{}).Error
|
||||||
|
}
|
||||||
88
internal/workout/service.go
Normal file
88
internal/workout/service.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package workout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService() *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateWorkout(userID uint, title string, scheduledDate time.Time, duration int) (*Workout, error) {
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
workout := &Workout{
|
||||||
|
UserID: userID,
|
||||||
|
Title: title,
|
||||||
|
ScheduledDate: scheduledDate,
|
||||||
|
Duration: duration,
|
||||||
|
Status: "planned",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.CreateWorkout(workout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return workout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUserWorkouts(userID uint) ([]Workout, error) {
|
||||||
|
return s.repo.GetUserWorkouts(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetWorkoutsByMonth(userID uint, year, month int) ([]Workout, error) {
|
||||||
|
return s.repo.GetWorkoutsByMonth(userID, year, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateWorkoutStatus(id, userID uint, status string) (*Workout, error) {
|
||||||
|
if status != "planned" && status != "completed" && status != "skipped" {
|
||||||
|
return nil, errors.New("invalid status")
|
||||||
|
}
|
||||||
|
|
||||||
|
workout, err := s.repo.GetWorkoutByID(id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
workout.Status = status
|
||||||
|
if status == "completed" {
|
||||||
|
workout.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateWorkout(workout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return workout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateWorkoutWithMetrics(id, userID uint, distance float64, avgPower, avgHR int) (*Workout, error) {
|
||||||
|
workout, err := s.repo.GetWorkoutByID(id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
workout.Distance = distance
|
||||||
|
workout.AvgPower = avgPower
|
||||||
|
workout.AvgHR = avgHR
|
||||||
|
workout.Status = "completed"
|
||||||
|
|
||||||
|
if err := s.repo.UpdateWorkout(workout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return workout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteWorkout(id, userID uint) error {
|
||||||
|
return s.repo.DeleteWorkout(id, userID)
|
||||||
|
}
|
||||||
169
internal/workout/zwo_parser.go
Normal file
169
internal/workout/zwo_parser.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package workout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ParsedWorkoutData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
SportType string `json:"sport_type"`
|
||||||
|
TotalDuration int `json:"total_duration"`
|
||||||
|
Segments []WorkoutSegment `json:"segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoFile struct {
|
||||||
|
Author string `xml:"author,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Description string `xml:"description,attr"`
|
||||||
|
SportType string `xml:"sportType,attr"`
|
||||||
|
Workout zwoWorkout `xml:"workout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoWorkout struct {
|
||||||
|
Warmups []zwoWarmup `xml:"Warmup"`
|
||||||
|
SteadyStates []zwoSteadyState `xml:"SteadyState"`
|
||||||
|
Cooldowns []zwoCooldown `xml:"Cooldown"`
|
||||||
|
Intervals []zwoInterval `xml:"Interval"`
|
||||||
|
Ramps []zwoRamp `xml:"Ramp"`
|
||||||
|
FreeRides []zwoFreeRide `xml:"FreeRide"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoWarmup struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
PowerLow float64 `xml:"PowerLow,attr"`
|
||||||
|
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoSteadyState struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
Power float64 `xml:"Power,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoCooldown struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
PowerLow float64 `xml:"PowerLow,attr"`
|
||||||
|
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoInterval struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
PowerLow float64 `xml:"PowerLow,attr"`
|
||||||
|
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoRamp struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
PowerLow float64 `xml:"PowerLow,attr"`
|
||||||
|
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zwoFreeRide struct {
|
||||||
|
Duration int `xml:"Duration,attr"`
|
||||||
|
Cadence int `xml:"Cadence,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseZWO(content []byte) (*ParsedWorkoutData, error) {
|
||||||
|
var zwo zwoFile
|
||||||
|
err := xml.Unmarshal(content, &zwo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse ZWO file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if zwo.Name == "" {
|
||||||
|
return nil, errors.New("workout name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := &ParsedWorkoutData{
|
||||||
|
Name: zwo.Name,
|
||||||
|
Description: zwo.Description,
|
||||||
|
Author: zwo.Author,
|
||||||
|
SportType: zwo.SportType,
|
||||||
|
Segments: []WorkoutSegment{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse warmups
|
||||||
|
for _, w := range zwo.Workout.Warmups {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "warmup",
|
||||||
|
Duration: w.Duration,
|
||||||
|
PowerLow: w.PowerLow,
|
||||||
|
PowerHigh: w.PowerHigh,
|
||||||
|
Cadence: w.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += w.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse steady states
|
||||||
|
for _, s := range zwo.Workout.SteadyStates {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "steadystate",
|
||||||
|
Duration: s.Duration,
|
||||||
|
Power: s.Power,
|
||||||
|
Cadence: s.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += s.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cooldowns
|
||||||
|
for _, c := range zwo.Workout.Cooldowns {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "cooldown",
|
||||||
|
Duration: c.Duration,
|
||||||
|
PowerLow: c.PowerLow,
|
||||||
|
PowerHigh: c.PowerHigh,
|
||||||
|
Cadence: c.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += c.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse intervals
|
||||||
|
for _, i := range zwo.Workout.Intervals {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "interval",
|
||||||
|
Duration: i.Duration,
|
||||||
|
PowerLow: i.PowerLow,
|
||||||
|
PowerHigh: i.PowerHigh,
|
||||||
|
Cadence: i.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += i.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ramps
|
||||||
|
for _, r := range zwo.Workout.Ramps {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "ramp",
|
||||||
|
Duration: r.Duration,
|
||||||
|
PowerLow: r.PowerLow,
|
||||||
|
PowerHigh: r.PowerHigh,
|
||||||
|
Cadence: r.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += r.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse free rides
|
||||||
|
for _, f := range zwo.Workout.FreeRides {
|
||||||
|
seg := WorkoutSegment{
|
||||||
|
Type: "freeride",
|
||||||
|
Duration: f.Duration,
|
||||||
|
Cadence: f.Cadence,
|
||||||
|
}
|
||||||
|
parsed.Segments = append(parsed.Segments, seg)
|
||||||
|
parsed.TotalDuration += f.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user