feat: extend equipment and workout models with service tracking

This commit is contained in:
Blake Ridgway
2026-02-12 10:09:50 -06:00
parent eb9ac1b67a
commit 178ffb3425
37 changed files with 4005 additions and 40 deletions

View File

@@ -0,0 +1,175 @@
package templates
import (
"encoding/json"
"log"
"net/http"
"time"
"rideaware/internal/config"
"rideaware/internal/middleware"
"rideaware/internal/workout"
)
type Handler struct {
workoutRepo *workout.Repository
}
func NewHandler() *Handler {
return &Handler{
workoutRepo: workout.NewRepository(),
}
}
// TemplateSummary is a lighter view of a template for listing.
type TemplateSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Duration int `json:"duration"`
Difficulty string `json:"difficulty"`
Category string `json:"category"`
Segments int `json:"segments"`
}
// ListTemplates GET /api/protected/workout-templates
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
var source []Template
if category != "" {
source = GetByCategory(category)
} else {
source = All
}
summaries := make([]TemplateSummary, 0, len(source))
for _, t := range source {
summaries = append(summaries, TemplateSummary{
ID: t.ID,
Name: t.Name,
Description: t.Description,
Type: t.Type,
Duration: t.Duration,
Difficulty: t.Difficulty,
Category: t.Category,
Segments: len(t.Segments),
})
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(summaries)
}
// GetTemplate GET /api/protected/workout-templates/detail?id=X
func (h *Handler) GetTemplate(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "template id is required"})
return
}
t := GetByID(id)
if t == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(t)
}
// CreateFromTemplate POST /api/protected/workouts/from-template
func (h *Handler) CreateFromTemplate(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
var req struct {
TemplateID string `json:"template_id"`
ScheduledDate string `json:"scheduled_date"`
Title string `json:"title"`
Notes string `json:"notes"`
EquipmentID *uint `json:"equipment_id"`
}
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 body"})
return
}
if req.TemplateID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "template_id is required"})
return
}
tmpl := GetByID(req.TemplateID)
if tmpl == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
return
}
scheduledDate := time.Now()
if req.ScheduledDate != "" {
parsed, err := time.Parse("2006-01-02", req.ScheduledDate)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid scheduled_date format, use YYYY-MM-DD"})
return
}
scheduledDate = parsed
}
title := req.Title
if title == "" {
title = tmpl.Name
}
newWorkout := &workout.Workout{
UserID: claims.UserID,
Title: title,
Description: tmpl.Description,
Type: tmpl.Type,
Status: "planned",
ScheduledDate: scheduledDate,
Duration: tmpl.Duration,
EquipmentID: req.EquipmentID,
Notes: req.Notes,
WorkoutData: workout.WorkoutDataJSON{
Name: tmpl.Name,
Author: "RideAware",
TotalDuration: tmpl.Duration,
Segments: tmpl.Segments,
},
}
if err := h.workoutRepo.CreateWorkout(newWorkout); err != nil {
log.Printf("Create from template 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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newWorkout)
}

View File

@@ -0,0 +1,304 @@
package templates
import "rideaware/internal/workout"
// Template represents a predefined workout template.
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Duration int `json:"duration"`
Difficulty string `json:"difficulty"` // "easy", "moderate", "hard", "very_hard"
Category string `json:"category"` // "endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"
Segments []workout.WorkoutSegment `json:"segments"`
}
// All returns the full list of workout templates.
var All = []Template{
{
ID: "recovery-spin",
Name: "Recovery Spin",
Description: "Easy spin to promote blood flow and recovery. Keep it light and conversational.",
Type: "Recovery",
Duration: 1800, // 30 min
Difficulty: "easy",
Category: "recovery",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 300, PowerLow: 0.30, PowerHigh: 0.45},
{Type: "steadystate", Duration: 1200, Power: 0.45, Cadence: 90},
{Type: "cooldown", Duration: 300, PowerLow: 0.45, PowerHigh: 0.30},
},
},
{
ID: "endurance-60",
Name: "Endurance Base",
Description: "Steady zone 2 ride to build aerobic base. Maintain a comfortable, sustainable effort.",
Type: "Endurance",
Duration: 3600, // 60 min
Difficulty: "easy",
Category: "endurance",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
{Type: "steadystate", Duration: 2400, Power: 0.65, Cadence: 85},
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "endurance-90",
Name: "Long Endurance",
Description: "Extended zone 2 ride for deep aerobic adaptation. Pack snacks.",
Type: "Endurance",
Duration: 5400, // 90 min
Difficulty: "moderate",
Category: "endurance",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
{Type: "steadystate", Duration: 4200, Power: 0.68, Cadence: 85},
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "tempo-45",
Name: "Tempo Ride",
Description: "Sustained zone 3 effort. Comfortably hard - you can talk in short sentences.",
Type: "Tempo",
Duration: 2700, // 45 min
Difficulty: "moderate",
Category: "endurance",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.70},
{Type: "steadystate", Duration: 1200, Power: 0.78, Cadence: 90},
{Type: "steadystate", Duration: 300, Power: 0.55},
{Type: "steadystate", Duration: 300, Power: 0.78, Cadence: 90},
{Type: "cooldown", Duration: 300, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "sweet-spot-60",
Name: "Sweet Spot",
Description: "The most time-efficient training zone. 88-94% FTP intervals with short recovery.",
Type: "Threshold",
Duration: 3600, // 60 min
Difficulty: "moderate",
Category: "sweet_spot",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
{Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
{Type: "steadystate", Duration: 180, Power: 0.55},
{Type: "steadystate", Duration: 600, Power: 0.92, Cadence: 90},
{Type: "steadystate", Duration: 180, Power: 0.55},
{Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
{Type: "steadystate", Duration: 180, Power: 0.55},
{Type: "cooldown", Duration: 660, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "threshold-intervals",
Name: "Threshold Intervals",
Description: "Classic 2x20 at FTP. The gold standard for raising your threshold.",
Type: "Threshold",
Duration: 3600, // 60 min
Difficulty: "hard",
Category: "threshold",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
{Type: "steadystate", Duration: 300, Power: 0.80, Cadence: 90},
{Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
{Type: "steadystate", Duration: 300, Power: 0.50},
{Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "over-unders",
Name: "Over-Under Intervals",
Description: "Alternating between just below and just above FTP. Builds lactate tolerance.",
Type: "Threshold",
Duration: 3600, // 60 min
Difficulty: "hard",
Category: "threshold",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
// Set 1
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 300, Power: 0.50},
// Set 2
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 300, Power: 0.50},
// Set 3
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
},
},
{
ID: "vo2max-intervals",
Name: "VO2max Intervals",
Description: "5x3min at 115% FTP with equal rest. Expands your aerobic ceiling.",
Type: "VO2 Max",
Duration: 2700, // 45 min
Difficulty: "very_hard",
Category: "vo2max",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
{Type: "steadystate", Duration: 180, Power: 0.45},
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
{Type: "steadystate", Duration: 180, Power: 0.45},
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
{Type: "steadystate", Duration: 180, Power: 0.45},
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
{Type: "steadystate", Duration: 180, Power: 0.45},
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
{Type: "cooldown", Duration: 420, PowerLow: 0.55, PowerHigh: 0.35},
},
},
{
ID: "vo2max-short",
Name: "VO2max Short-Shorts",
Description: "30/30s at 120% FTP. Accumulate VO2max time in manageable chunks.",
Type: "VO2 Max",
Duration: 2400, // 40 min
Difficulty: "very_hard",
Category: "vo2max",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
// Set 1: 10x 30/30
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 300, Power: 0.40},
// Set 2: 10x 30/30
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "steadystate", Duration: 30, Power: 0.40},
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
{Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.35},
},
},
{
ID: "sprint-intervals",
Name: "Sprint Power",
Description: "Short maximal efforts to build neuromuscular power and sprint capacity.",
Type: "VO2 Max",
Duration: 2700, // 45 min
Difficulty: "very_hard",
Category: "sprint",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
{Type: "steadystate", Duration: 300, Power: 0.85, Cadence: 95},
{Type: "steadystate", Duration: 120, Power: 0.50},
// Sprints: 8x 15s all-out / 105s recovery
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "steadystate", Duration: 105, Power: 0.40},
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
{Type: "cooldown", Duration: 480, PowerLow: 0.55, PowerHigh: 0.35},
},
},
{
ID: "ramp-test",
Name: "FTP Ramp Test",
Description: "Progressive ramp to estimate your FTP. Ride until you can't hold the target.",
Type: "Threshold",
Duration: 1500, // ~25 min (most people fail around 19-22 min)
Difficulty: "very_hard",
Category: "threshold",
Segments: []workout.WorkoutSegment{
{Type: "warmup", Duration: 300, PowerLow: 0.40, PowerHigh: 0.46},
{Type: "ramp", Duration: 60, PowerLow: 0.46, PowerHigh: 0.52},
{Type: "ramp", Duration: 60, PowerLow: 0.52, PowerHigh: 0.58},
{Type: "ramp", Duration: 60, PowerLow: 0.58, PowerHigh: 0.64},
{Type: "ramp", Duration: 60, PowerLow: 0.64, PowerHigh: 0.70},
{Type: "ramp", Duration: 60, PowerLow: 0.70, PowerHigh: 0.76},
{Type: "ramp", Duration: 60, PowerLow: 0.76, PowerHigh: 0.82},
{Type: "ramp", Duration: 60, PowerLow: 0.82, PowerHigh: 0.88},
{Type: "ramp", Duration: 60, PowerLow: 0.88, PowerHigh: 0.94},
{Type: "ramp", Duration: 60, PowerLow: 0.94, PowerHigh: 1.00},
{Type: "ramp", Duration: 60, PowerLow: 1.00, PowerHigh: 1.06},
{Type: "ramp", Duration: 60, PowerLow: 1.06, PowerHigh: 1.12},
{Type: "ramp", Duration: 60, PowerLow: 1.12, PowerHigh: 1.18},
{Type: "ramp", Duration: 60, PowerLow: 1.18, PowerHigh: 1.24},
{Type: "ramp", Duration: 60, PowerLow: 1.24, PowerHigh: 1.30},
{Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.30},
},
},
}
// GetByID returns a template by its ID, or nil if not found.
func GetByID(id string) *Template {
for i := range All {
if All[i].ID == id {
return &All[i]
}
}
return nil
}
// GetByCategory returns all templates matching a category.
func GetByCategory(category string) []Template {
var result []Template
for _, t := range All {
if t.Category == category {
result = append(result, t)
}
}
return result
}