From 608cc3fa6ac61da157e7c7e4708409534ad194c3 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 29 Nov 2025 12:47:07 -0600 Subject: [PATCH] feat: add complete workout management system with file parsing --- internal/workout/handler.go | 391 +++++++++++++++++++++++++++++++++ internal/workout/model.go | 66 ++++++ internal/workout/repository.go | 65 ++++++ internal/workout/service.go | 88 ++++++++ internal/workout/zwo_parser.go | 169 ++++++++++++++ 5 files changed, 779 insertions(+) create mode 100644 internal/workout/handler.go create mode 100644 internal/workout/model.go create mode 100644 internal/workout/repository.go create mode 100644 internal/workout/service.go create mode 100644 internal/workout/zwo_parser.go diff --git a/internal/workout/handler.go b/internal/workout/handler.go new file mode 100644 index 0000000..a887261 --- /dev/null +++ b/internal/workout/handler.go @@ -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) +} \ No newline at end of file diff --git a/internal/workout/model.go b/internal/workout/model.go new file mode 100644 index 0000000..bc7cc53 --- /dev/null +++ b/internal/workout/model.go @@ -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" +} \ No newline at end of file diff --git a/internal/workout/repository.go b/internal/workout/repository.go new file mode 100644 index 0000000..6672483 --- /dev/null +++ b/internal/workout/repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/workout/service.go b/internal/workout/service.go new file mode 100644 index 0000000..9593fda --- /dev/null +++ b/internal/workout/service.go @@ -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) +} \ No newline at end of file diff --git a/internal/workout/zwo_parser.go b/internal/workout/zwo_parser.go new file mode 100644 index 0000000..1332a3c --- /dev/null +++ b/internal/workout/zwo_parser.go @@ -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 +} \ No newline at end of file