lots of stuff, don't truly remember

This commit is contained in:
Blake Ridgway
2026-05-17 20:39:47 -05:00
parent 178ffb3425
commit dc4fe558b7
35 changed files with 3501 additions and 112 deletions

155
TODO.md
View File

@@ -4,37 +4,79 @@
- [x] **User Registration & Login**: Email authentication with JWT tokens
- [x] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP, weight
- [x] **Password Recovery**: Email-based reset with secure tokens
- [ ] **OAuth Integration**: Google, Apple, Strava, Garmin
- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc
- [x] **Onboarding Wizard**: Guided setup with profile, metrics, equipment, and preferences steps
- [~] **OAuth Integration**: Garmin & Wahoo OAuth implemented; Google, Apple, Strava pending
- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces
- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile
## Workout Planning
- [x] **Workout Scheduling**: Calendar view with monthly navigation, rich workout cards (duration, TSS, zone-colored segment charts, status borders)
- [x] **Templates Library**: 11 built-in templates (Recovery, Endurance, Tempo, Sweet Spot, Threshold, Over-Unders, VO2max, Sprint, Ramp Test) with category browsing
- [x] **Template Scheduling**: Browse and schedule templates directly from calendar Add Workout modal
- [x] **Structured Workout Builder**: Interval builder with warmup/main/cooldown sections, power targets (%FTP), cadence, repeats, rest intervals, and live preview chart
- [x] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo
- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level
- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather
- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook)
- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars
- [x] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength)
- [x] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad
- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist
## Workout Tracking
- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS
- [x] **Workout CRUD**: Create, read, update, delete workouts with calendar integration
- [x] **Device Capture**: File upload (FIT/TCX/GPX activity import with metric extraction)
- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used
- [x] **Equipment Tracking**: Bike/components mileage auto-tracking, service reminders
- [x] **ZWO Import**: Client-side Zwift workout file parsing
- [x] **Equipment Tracking**: Bike/components mileage auto-tracking, service interval reminders, distance/duration since last service
- [~] **Tags & Notes**: RPE & Notes fields implemented in backend; mood, conditions, injuries, and tag system still pending
- [ ] **Drag-drop Calendar**: Reorder/reschedule workouts by dragging
## Workout Visualization
- [x] **Workout Detail Modal**: Interval chart, auto-generated workout summary, zone distribution, completed metrics, equipment, Garmin/Wahoo push
- [x] **Power Zone System**: 7-zone model (Recovery through Neuromuscular) with color-coded visualization
- [x] **Mini Segment Charts**: Compact zone-colored bars on calendar cards
- [x] **Zone Distribution**: Horizontal stacked bar with time-in-zone breakdown
- [x] **TSS/IF Calculation**: Training Stress Score and Intensity Factor from segment data
## Dashboard
- [x] **Stats Overview**: Total rides, distance, time, elevation gain
- [x] **Calendar Widget**: Compact monthly calendar with workout cards, month navigation
- [x] **Next Workout**: Upcoming workout display with link to calendar
- [x] **Today's Progress**: Completion percentage for daily workouts
- [x] **Profile Display**: Welcome message with first/last name from user profile
- [ ] **Interactive Charts**: Activity trends (CTL/ATL/TSB), power curves
## Advanced Analytics
- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends
- [x] **Stats Page**: Summary, weekly, and monthly aggregated statistics
- [x] **Personal Bests**: Personal records tracking
- [ ] **Interactive Dashboards**: Charts for training load, power curves, trends
- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts
- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons
- [ ] **Custom Reports**: Export CSV/PDF; shareable report links
## Workout Library
- [x] **Browse Library**: System and user workouts with search, category, type, and difficulty filters
- [x] **Workout Detail Page**: Full workout view with interval chart and segment breakdown
- [x] **Create Custom Workouts**: Interval builder with warmup/main/cooldown structure
- [x] **My Workouts**: User's custom workout collection
- [x] **Favorites**: Save and browse favorite workouts
- [x] **Ratings**: Star rating system with comments
- [ ] **Community Sharing**: Public/private workout sharing
## Training & Coaching
- [x] **Structured Workouts**: Interval builder with targets (%FTP, cadence, repeats)
- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews
- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes
- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE)
- [ ] **Messaging**: Coachathlete chat, comments on sessions, file attachments
- [ ] **Messaging**: Coach-athlete chat, comments on sessions, file attachments
## Integrations & Data
- [x] **Garmin Connect**: OAuth2 PKCE flow, push workouts, connection management
- [x] **Wahoo Cloud API**: OAuth2 flow, push workouts as plans, connection management
- [x] **Activity Import**: FIT (session metrics), TCX (lap aggregation), GPX (Haversine distance, elevation)
- [x] **Workout Export**: FIT (Garmin-compatible), ZWO (Zwift-compatible)
- [ ] **Strava Sync**: OAuth login + activity sync
- [ ] **TrainingPeaks**: Calendar + workout push
- [x] **Intervals.icu**: Integration (push workouts + sync activities)
- [ ] **COROS, Apple Health, Google Fit**: Wearable sync
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists
- [ ] **Public API & Webhooks**: For partners, coaches, clubs
## Nutrition & Recovery
- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization
@@ -54,13 +96,6 @@
- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles
- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles
## Integrations & Data
- [~] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit (Garmin + Wahoo OAuth & push implemented)
- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push)
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists
- [~] **Data Import/Export**: FIT/TCX/GPX activity import implemented; CSV/JSON export & bulk import pending
- [ ] **Public API & Webhooks**: For partners, coaches, clubs
## Notifications & Comms
- [ ] **Reminders**: Email, push, SMS; smart timing
- [ ] **Digest Emails**: Weekly plan, monthly progress
@@ -100,7 +135,7 @@
---
## Completed - Phase 1: Authentication & User Management
## Completed - Phase 1: Authentication & User Management
### Infrastructure
- [x] Migrated from Python/Flask to Go with Chi router
@@ -130,37 +165,27 @@
---
## Completed - Phase 2: User Profiles, Equipment & Workouts
## Completed - Phase 2: Profiles, Equipment & Workouts
### Profile & Equipment (completed earlier)
### API Endpoints
- [x] GET/PUT `/api/protected/profile` - Full profile management
- [x] POST/GET/PUT/DELETE `/api/protected/equipment` - Bike/gear CRUD
- [x] GET `/api/protected/zones` - Calculate HR & power training zones
- [x] Equipment tracking (brand, model, weight)
- [x] Equipment usage stats from workouts
### Workouts (completed earlier)
- [x] POST/GET/PUT/DELETE `/api/protected/workouts` - Full workout CRUD
- [x] GET `/api/protected/workouts/month` - Calendar month filtering
- [x] GET `/api/protected/workout-types` - Predefined workout types
- [x] POST `/api/protected/workouts/upload` - ZWO file import & parsing
- [x] Structured workout segments (JSONB) with power/cadence targets
### Stats
- [x] GET `/api/protected/stats/summary` - Overall ride statistics
- [x] GET `/api/protected/stats/weekly` - Weekly aggregated stats
- [x] GET `/api/protected/stats/monthly` - Monthly aggregated stats
- [x] GET `/api/protected/stats/personal-bests` - Personal records
### Workout Templates
- [x] GET `/api/protected/workout-templates` - List predefined templates (with category filter)
- [x] GET `/api/protected/workout-templates/detail` - Get template with full segment data
- [x] GET `/api/protected/workout-templates` - List templates (with category filter)
- [x] GET `/api/protected/workout-templates/detail` - Template with full segments
- [x] POST `/api/protected/workouts/from-template` - Create workout from template
- [x] 11 built-in templates: Recovery, Endurance, Tempo, Sweet Spot, Threshold, Over-Unders, VO2max, Sprint, Ramp Test
---
## Completed - Phase 2.5: Workout Export & Device Integration
## Completed - Phase 2.5: Export & Device Integration
### Workout Export
- [x] GET `/api/protected/workouts/export/fit` - FIT workout file export (Garmin-compatible)
@@ -191,7 +216,7 @@
---
## Completed - Phase 2.6: Activity Import & Equipment Mileage
## Completed - Phase 2.6: Activity Import & Equipment Mileage
### Activity File Import (FIT/TCX/GPX)
- [x] POST `/api/protected/workouts/import` - Import activity files (multipart upload)
@@ -209,11 +234,60 @@
- [x] Distance and duration since last service counters
- [x] POST `/api/protected/equipment/service` - Record service (resets counters)
- [x] GET `/api/protected/equipment/service-status` - Check if equipment needs servicing
- [x] Service status in GET `/api/protected/equipment` response (total_distance, total_rides, etc.)
---
## Next Phase: Phase 3 - OAuth Login & Platform Sync
## Completed - Phase 3: Frontend & Full-Stack Features
### Vue 3 Frontend (Tailwind CSS v4)
- [x] Vue 3 Composition API with `<script setup>`, Pinia stores, Vue Router 4
- [x] Responsive dark/light mode UI with Tailwind CSS
- [x] Docker/Podman containerized with Nginx serving
### Dashboard
- [x] Stats overview cards (rides, distance, time, elevation)
- [x] Compact calendar widget with monthly navigation and workout cards
- [x] Next workout display with today's progress bar
- [x] Profile-aware welcome message (first/last name)
- [x] Workout detail modal from calendar clicks
### Calendar & Workout Management
- [x] Full monthly calendar with TrainingPeaks-style workout cards
- [x] Rich cards: duration, TSS badge, zone-colored mini segment chart, title, status borders
- [x] Add Workout modal with Manual and From Template tabs
- [x] Template browser with category filtering, detail preview (IntervalDisplay), and direct scheduling
- [x] Workout builder (IntervalBuilder) in manual tab with warmup/main/cooldown, power targets, cadence, repeats, presets, live chart preview
- [x] Structure-to-segments conversion for API submission
- [x] File upload support (FIT/TCX/GPX activity import, ZWO workout import)
### Workout Detail & Visualization
- [x] IntervalDisplay chart (section-colored bars, height = power, width = time)
- [x] Auto-generated workout summary text (detects repeat patterns)
- [x] Zone distribution (stacked bar + time-in-zone legend)
- [x] MiniSegmentChart for calendar cells
- [x] 7-zone power model with hex color mapping
- [x] TSS and IF calculation from segments
- [x] Garmin/Wahoo push buttons with connection status
### Workout Library
- [x] Browse system + user workouts with search, filters (type, category, difficulty)
- [x] Workout detail page with interval chart
- [x] Create/edit custom workouts with interval builder
- [x] My Workouts and Favorites collections
- [x] Star rating with comments
### Other Pages
- [x] Equipment management with service tracking, mileage display, record service
- [x] Training zones page (HR & power zones)
- [x] Stats page (summary, weekly, monthly, personal bests)
- [x] Templates browser page with category filter and scheduling
- [x] Onboarding wizard (profile, metrics, equipment, preferences steps)
- [x] Settings: Garmin/Wahoo connections page with OAuth flow
- [x] User profile editor
---
## Next Phase: Phase 4 - OAuth Login & Platform Sync
### OAuth Login
- [ ] Google OAuth 2.0 (sign in with Google)
@@ -223,4 +297,9 @@
### Platform Sync
- [ ] Strava activity sync (import completed rides)
- [ ] TrainingPeaks calendar sync
- [ ] Intervals.icu integration
- [x] Intervals.icu integration (push workouts + sync activities)
### Calendar Enhancements
- [ ] Drag-and-drop workout rescheduling
- [ ] ICS sync (Google Calendar / Apple Calendar / Outlook)
- [ ] Week view and day view options

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
@@ -11,12 +12,16 @@ import (
"github.com/joho/godotenv"
"rideaware/internal/activity"
"rideaware/internal/ai"
"rideaware/internal/auth"
"rideaware/internal/config"
"rideaware/internal/equipment"
"rideaware/internal/event"
"rideaware/internal/export"
"rideaware/internal/goal"
"rideaware/internal/integration"
"rideaware/internal/middleware"
"rideaware/internal/nutrition"
"rideaware/internal/stats"
"rideaware/internal/templates"
"rideaware/internal/user"
@@ -41,6 +46,8 @@ func main() {
&workout.Workout{},
&integration.OAuthConnection{},
&integration.OAuthState{},
&ai.AIRecommendation{},
&event.Event{},
); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
@@ -155,6 +162,8 @@ func setupRoutes(r *chi.Mux) {
r.Get("/workouts/equipment-stats", workoutHandler.GetEquipmentStats)
r.Put("/workouts", workoutHandler.UpdateWorkout)
r.Delete("/workouts", workoutHandler.DeleteWorkout)
r.Post("/workouts/remove-duplicates", workoutHandler.RemoveDuplicates)
r.Put("/workouts/reschedule", workoutHandler.RescheduleWorkout)
r.Get("/workout-types", workoutHandler.GetWorkoutTypes)
r.Post("/workouts/upload", workoutHandler.UploadWorkoutFile)
@@ -179,18 +188,65 @@ func setupRoutes(r *chi.Mux) {
r.Get("/wahoo/status", wahooHandler.ConnectionStatus)
r.Delete("/wahoo/disconnect", wahooHandler.Disconnect)
// Intervals.icu integration routes
intervalsHandler := integration.NewIntervalsHandler()
r.Post("/intervals/connect", intervalsHandler.SaveApiKey)
r.Get("/intervals/status", intervalsHandler.ConnectionStatus)
r.Delete("/intervals/disconnect", intervalsHandler.Disconnect)
r.Post("/workouts/push/intervals", intervalsHandler.PushWorkout)
r.Post("/intervals/sync", intervalsHandler.SyncActivities)
// Stats routes
statsHandler := stats.NewHandler()
r.Get("/stats/summary", statsHandler.GetSummary)
r.Get("/stats/weekly", statsHandler.GetWeeklyStats)
r.Get("/stats/monthly", statsHandler.GetMonthlyStats)
r.Get("/stats/personal-bests", statsHandler.GetPersonalBests)
r.Get("/stats/training-load", statsHandler.GetTrainingLoad)
r.Get("/stats/power-history", statsHandler.GetPowerHistory)
// AI Training Plan routes
aiHandler := ai.NewHandler()
r.Post("/ai/generate", aiHandler.GenerateRecommendations)
r.Post("/ai/schedule", aiHandler.ScheduleRecommendations)
r.Get("/ai/history", aiHandler.GetRecommendationHistory)
// Event routes
eventHandler := event.NewHandler()
r.Post("/events", eventHandler.CreateEvent)
r.Get("/events", eventHandler.GetEvents)
r.Get("/events/upcoming", eventHandler.GetUpcomingEvents)
r.Put("/events", eventHandler.UpdateEvent)
r.Delete("/events", eventHandler.DeleteEvent)
r.Get("/event-types", eventHandler.GetEventTypes)
// Nutrition routes
nutritionHandler := nutrition.NewHandler()
r.Get("/nutrition/targets", nutritionHandler.GetTargets)
r.Get("/nutrition/weekly", nutritionHandler.GetWeekly)
// Workout template routes
templateHandler := templates.NewHandler()
r.Get("/workout-templates", templateHandler.ListTemplates)
r.Get("/workout-templates/detail", templateHandler.GetTemplate)
r.Post("/workouts/from-template", templateHandler.CreateFromTemplate)
// Goal routes
goalHandler := goal.NewHandler()
r.Post("/goals", goalHandler.CreateGoal)
r.Get("/goals", goalHandler.GetGoals)
r.Put("/goals", goalHandler.UpdateGoal)
r.Delete("/goals", goalHandler.DeleteGoal)
// Admin-only routes (require 'admin' role in addition to valid JWT)
r.Route("/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin"))
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"status": "admin API operational",
})
})
})
})
log.Println("✅ Routes registered successfully")

133
internal/ai/handler.go Normal file
View File

@@ -0,0 +1,133 @@
package ai
import (
"encoding/json"
"log"
"net/http"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type Handler struct {
service *Service
}
func NewHandler() *Handler {
return &Handler{
service: NewService(),
}
}
// GenerateRecommendations POST /api/protected/ai/generate
func (h *Handler) GenerateRecommendations(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req GenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate request
if err := validateGenerateRequest(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
log.Printf("[AI] User %d requesting %d-day plan (focus: %v, intensity: %s)",
claims.UserID, req.PlanDuration, req.FocusAreas, req.IntensityLevel)
// Generate workouts
response, err := h.service.GenerateWorkouts(claims.UserID, req)
if err != nil {
log.Printf("[AI] Generation error for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, "failed to generate workouts: "+err.Error())
return
}
log.Printf("[AI] Successfully generated %d workouts for user %d (total TSS: %.1f)",
len(response.Workouts), claims.UserID, response.TotalTSS)
respondJSON(w, http.StatusOK, response)
}
// ScheduleRecommendations POST /api/protected/ai/schedule
func (h *Handler) ScheduleRecommendations(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
RecommendationID uint `json:"recommendation_id"`
WorkoutIndices []int `json:"workout_indices"` // Which workouts to schedule
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request")
return
}
if req.RecommendationID == 0 {
respondError(w, http.StatusBadRequest, "recommendation_id is required")
return
}
log.Printf("[AI] User %d scheduling %d workouts from recommendation %d",
claims.UserID, len(req.WorkoutIndices), req.RecommendationID)
// Schedule selected workouts
workouts, err := h.service.ScheduleWorkouts(claims.UserID, req.RecommendationID, req.WorkoutIndices)
if err != nil {
log.Printf("[AI] Schedule error for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("[AI] Successfully scheduled %d workouts for user %d", len(workouts), claims.UserID)
respondJSON(w, http.StatusCreated, map[string]interface{}{
"scheduled_count": len(workouts),
"workouts": workouts,
})
}
// GetRecommendationHistory GET /api/protected/ai/history
func (h *Handler) GetRecommendationHistory(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
history, err := h.service.GetUserRecommendations(claims.UserID, 10)
if err != nil {
log.Printf("[AI] Failed to fetch history for user %d: %v", claims.UserID, err)
respondError(w, http.StatusInternalServerError, "failed to fetch history")
return
}
// Return empty array instead of null
if history == nil {
history = []AIRecommendation{}
}
respondJSON(w, http.StatusOK, history)
}
// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}

170
internal/ai/model.go Normal file
View File

@@ -0,0 +1,170 @@
package ai
import (
"database/sql/driver"
"time"
"rideaware/internal/workout"
)
// GenerateRequest is the user's request for AI workout generation
type GenerateRequest struct {
PlanDuration int `json:"plan_duration"` // Number of days (1, 7, 14, 28)
FocusAreas []string `json:"focus_areas"` // ["endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"]
IntensityLevel string `json:"intensity_level"` // "easy", "moderate", "hard"
WeeklyHours int `json:"weekly_hours"` // Available training hours per week
StartDate string `json:"start_date"` // YYYY-MM-DD
IncludeRest bool `json:"include_rest"` // Whether to include rest days
TargetEventID *uint `json:"target_event_id"` // Optional target event to periodize for
}
// GenerateResponse contains AI-generated workouts
type GenerateResponse struct {
Workouts []AIWorkout `json:"workouts"`
Rationale string `json:"rationale"` // AI's explanation
TotalTSS float64 `json:"total_tss"`
RecommendationID uint `json:"recommendation_id"`
}
// AIWorkout represents a single AI-generated workout
type AIWorkout struct {
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
ScheduledDate string `json:"scheduled_date"`
Duration int `json:"duration"` // seconds
Segments []workout.WorkoutSegment `json:"segments"`
EstimatedTSS float64 `json:"estimated_tss"`
Notes string `json:"notes"`
}
// AIRecommendation database model
type AIRecommendation struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
PromptContext JSONB `gorm:"type:jsonb" json:"prompt_context"`
AIResponse string `json:"ai_response"`
GeneratedWorkouts JSONB `gorm:"type:jsonb" json:"generated_workouts"`
Parameters JSONB `gorm:"type:jsonb" json:"parameters"`
Status string `gorm:"default:'generated'" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (AIRecommendation) TableName() string {
return "ai_recommendations"
}
// JSONB type for PostgreSQL JSONB columns
type JSONB struct {
Data []byte
}
// Scan implements sql.Scanner interface
func (j *JSONB) Scan(value interface{}) error {
if value == nil {
j.Data = []byte("{}")
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
j.Data = bytes
return nil
}
// Value implements driver.Valuer interface
func (j JSONB) Value() (driver.Value, error) {
if len(j.Data) == 0 {
return []byte("{}"), nil
}
return j.Data, nil
}
// MarshalJSON implements json.Marshaler
func (j JSONB) MarshalJSON() ([]byte, error) {
if len(j.Data) == 0 {
return []byte("{}"), nil
}
return j.Data, nil
}
// UnmarshalJSON implements json.Unmarshaler
func (j *JSONB) UnmarshalJSON(data []byte) error {
j.Data = data
return nil
}
// UserContext contains all user data sent to AI
type UserContext struct {
FTP int `json:"ftp"`
MaxHR int `json:"max_hr"`
RestingHR int `json:"resting_hr"`
Weight float64 `json:"weight"`
TrainingGoal string `json:"training_goal"`
WeeklyHours int `json:"weekly_hours"`
RecentWorkouts []RecentWorkoutSummary `json:"recent_workouts"`
TrainingLoad TrainingLoadSummary `json:"training_load"`
UpcomingEvents []UpcomingEventSummary `json:"upcoming_events,omitempty"`
TargetEvent *UpcomingEventSummary `json:"target_event,omitempty"`
Nutrition *NutritionContext `json:"nutrition,omitempty"`
}
// NutritionContext contains nutrition data for AI
type NutritionContext struct {
Goal string `json:"goal"`
DailyCalories int `json:"daily_calories"`
ProteinG int `json:"protein_g"`
CarbsG int `json:"carbs_g"`
FatG int `json:"fat_g"`
DietaryPref string `json:"dietary_preference"`
}
// UpcomingEventSummary summarizes an upcoming race/event for AI context
type UpcomingEventSummary struct {
Name string `json:"name"`
Date string `json:"date"`
EventType string `json:"event_type"`
Distance float64 `json:"distance"`
Priority string `json:"priority"`
DaysAway int `json:"days_away"`
}
// RecentWorkoutSummary summarizes a completed workout
type RecentWorkoutSummary struct {
Date string `json:"date"`
Type string `json:"type"`
Duration int `json:"duration"`
AvgPower int `json:"avg_power"`
TSS float64 `json:"tss"`
}
// TrainingLoadSummary contains CTL/ATL/TSB metrics
type TrainingLoadSummary struct {
CTL float64 `json:"ctl"` // Chronic Training Load (42-day EMA)
ATL float64 `json:"atl"` // Acute Training Load (7-day EMA)
TSB float64 `json:"tsb"` // Training Stress Balance (CTL - ATL)
}
// DeepSeek API structures
type DeepSeekRequest struct {
Model string `json:"model"`
Messages []DeepSeekMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type DeepSeekMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type DeepSeekResponse struct {
Choices []struct {
Message DeepSeekMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}

214
internal/ai/prompt.go Normal file
View File

@@ -0,0 +1,214 @@
package ai
import (
"encoding/json"
"fmt"
"strings"
)
// BuildSystemPrompt creates the system message for DeepSeek
func BuildSystemPrompt() string {
return `You are an expert cycling coach with deep knowledge of power-based training, periodization, and workout design.
Your task is to generate structured cycling workouts based on user data and goals. You must respond ONLY with valid JSON.
POWER ZONES (% of FTP):
- Recovery: 30-55% FTP
- Endurance/Zone 2: 56-75% FTP
- Tempo: 76-87% FTP
- Sweet Spot: 88-94% FTP
- Threshold/FTP: 95-105% FTP
- VO2max: 106-120% FTP
- Anaerobic: 121-150% FTP
- Neuromuscular: >150% FTP
WORKOUT STRUCTURE:
- All workouts must have warmup, main intervals, and cooldown
- Duration is in seconds
- Power is expressed as decimal (0.65 = 65% FTP, 1.0 = 100% FTP)
- Segment types: "warmup", "steadystate", "interval", "ramp", "cooldown", "rest", "freeride"
- For steady state efforts, use "power" field
- For variable efforts, use "power_low" and "power_high" fields
TSS CALCULATION:
TSS = (duration_seconds × NP² / FTP²) / 36
Approximate: duration_minutes × intensity_factor²
TRAINING PRINCIPLES:
1. Progressive overload within user's current fitness
2. Balance intensity and volume
3. Include recovery when needed
4. Respect weekly hour constraints
5. Build on recent training load (CTL/ATL/TSB)
6. Vary workouts to prevent monotony
7. Include appropriate warmup and cooldown for all workouts
EVENT-BASED PERIODIZATION (when a target event is provided):
- Structure the plan around the target event date using standard cycling periodization:
- BUILD phase: Progressive volume/intensity increase (furthest from event)
- PEAK phase: Highest intensity, race-specific intervals
- TAPER phase: Reduce volume while maintaining intensity
- RACE DAY: Generate a "Race" type workout on the event date with pre-race notes
- RECOVERY: Easy rides after race day
- A-priority race: 7-10 day taper, reduce volume 30-50%, maintain intensity
- B-priority race: 3-5 day lighter taper
- C-priority race: No taper, treat as training race
- On race day, create a workout with type "Race" and include race distance/event details in notes
- If multiple events exist, avoid scheduling hard workouts within 2 days of any event
NUTRITION GUIDANCE (when nutrition context is provided):
- Include brief fueling notes in the "notes" field for each workout:
- Pre-ride: what to eat 1-2 hours before (e.g., "Pre: oatmeal + banana, 400kcal")
- During: fueling for rides >60min (e.g., "During: 60g carbs/hr, sports drink")
- Post-ride: recovery nutrition within 30min (e.g., "Post: protein shake + rice, 500kcal")
- For easy/recovery rides: lighter fueling notes
- For hard/long rides: emphasize carb loading and during-ride nutrition
- Keep fueling notes concise (one line each)
OUTPUT FORMAT (JSON only - BE CONCISE):
{
"workouts": [
{
"title": "Workout Name",
"description": "Brief description",
"type": "Endurance|Tempo|Threshold|VO2 Max|Recovery|Sprint",
"scheduled_date": "YYYY-MM-DD",
"duration": 3600,
"segments": [
{"type": "warmup", "duration": 600, "power_low": 0.40, "power_high": 0.65},
{"type": "steadystate", "duration": 2400, "power": 0.65, "cadence": 85},
{"type": "cooldown", "duration": 600, "power_low": 0.65, "power_high": 0.40}
],
"estimated_tss": 45.0,
"notes": "Brief coaching note"
}
],
"rationale": "Brief plan overview"
}
IMPORTANT: Keep descriptions and notes VERY SHORT (max 10 words each). Response must be valid, complete JSON.`
}
// BuildUserPrompt creates the user message with context
func BuildUserPrompt(ctx UserContext, req GenerateRequest) string {
contextJSON, _ := json.MarshalIndent(ctx, "", " ")
focusAreasStr := strings.Join(req.FocusAreas, ", ")
// Build event context section
eventSection := ""
if ctx.TargetEvent != nil {
eventSection = fmt.Sprintf(`
TARGET EVENT:
- Name: %s
- Date: %s (%d days away)
- Type: %s
- Distance: %.0f km
- Priority: %s-race
- IMPORTANT: Periodize the plan to peak for this event. Apply appropriate taper based on priority.
On event day (%s), generate a "Race" type workout with event details in notes.
`,
ctx.TargetEvent.Name,
ctx.TargetEvent.Date,
ctx.TargetEvent.DaysAway,
ctx.TargetEvent.EventType,
ctx.TargetEvent.Distance,
ctx.TargetEvent.Priority,
ctx.TargetEvent.Date,
)
}
if len(ctx.UpcomingEvents) > 0 {
eventSection += "\nUPCOMING EVENTS (avoid hard workouts within 2 days of these):\n"
for _, ev := range ctx.UpcomingEvents {
eventSection += fmt.Sprintf("- %s (%s, %s-race, %d days away)\n",
ev.Name, ev.Date, ev.Priority, ev.DaysAway)
}
}
// Build nutrition context section
nutritionSection := ""
if ctx.Nutrition != nil {
nutritionSection = fmt.Sprintf(`
NUTRITION CONTEXT:
- Goal: %s
- Daily Calorie Target: %d kcal (before workout additions)
- Macro Targets: Protein %dg, Carbs %dg, Fat %dg
- Dietary Preference: %s
- IMPORTANT: Include brief fueling notes (pre-ride, during, post-ride) in each workout's "notes" field. Adjust fueling intensity to match workout intensity.
`,
ctx.Nutrition.Goal,
ctx.Nutrition.DailyCalories,
ctx.Nutrition.ProteinG,
ctx.Nutrition.CarbsG,
ctx.Nutrition.FatG,
ctx.Nutrition.DietaryPref,
)
}
return fmt.Sprintf(`Generate a %d-day training plan with the following requirements:
USER CONTEXT:
%s
PLAN PARAMETERS:
- Duration: %d days
- Focus Areas: %s
- Intensity Level: %s
- Weekly Available Hours: %d
- Start Date: %s
- Include Rest Days: %v
%s%s
REQUIREMENTS:
1. Generate workouts that fit within %d weekly hours
2. Focus on: %s
3. Overall intensity: %s
4. Respect user's current fitness (FTP: %d, recent training load)
5. Include variety and progressive adaptation
6. Provide clear workout descriptions and coaching notes
7. Ensure total duration of segments matches workout duration
8. Use appropriate power zones for each workout type
9. Include proper warmup and cooldown for every workout
IMPORTANT:
- Return ONLY valid JSON in the exact format specified in the system prompt
- Do not include any explanatory text before or after the JSON
- Ensure all dates are sequential starting from %s
- Calculate realistic TSS values for each workout
- Segment durations must sum to workout duration (in seconds)
- Power values should be between 0.30 and 2.50 (30%% to 250%% FTP)
Return ONLY valid JSON in the exact format specified in the system prompt.`,
req.PlanDuration,
string(contextJSON),
req.PlanDuration,
focusAreasStr,
req.IntensityLevel,
req.WeeklyHours,
req.StartDate,
req.IncludeRest,
eventSection,
nutritionSection,
req.WeeklyHours,
focusAreasStr,
req.IntensityLevel,
ctx.FTP,
req.StartDate,
)
}
// extractJSON attempts to extract JSON from response that might be wrapped in markdown
func extractJSON(response string) string {
// Remove markdown code blocks if present
if idx := strings.Index(response, "```json"); idx != -1 {
response = response[idx+7:]
} else if idx := strings.Index(response, "```"); idx != -1 {
response = response[idx+3:]
}
if idx := strings.Index(response, "```"); idx != -1 {
response = response[:idx]
}
return strings.TrimSpace(response)
}

66
internal/ai/repository.go Normal file
View File

@@ -0,0 +1,66 @@
package ai
import (
"encoding/json"
"rideaware/pkg/database"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
// SaveRecommendation stores the AI recommendation
func (r *Repository) SaveRecommendation(
userID uint,
userCtx UserContext,
params GenerateRequest,
aiResponse string,
generated *GenerateResponse,
) (uint, error) {
contextJSON, _ := json.Marshal(userCtx)
paramsJSON, _ := json.Marshal(params)
workoutsJSON, _ := json.Marshal(generated.Workouts)
rec := &AIRecommendation{
UserID: userID,
PromptContext: JSONB{Data: contextJSON},
AIResponse: aiResponse,
GeneratedWorkouts: JSONB{Data: workoutsJSON},
Parameters: JSONB{Data: paramsJSON},
Status: "generated",
}
if err := database.DB.Create(rec).Error; err != nil {
return 0, err
}
return rec.ID, nil
}
// GetRecommendation retrieves a recommendation by ID
func (r *Repository) GetRecommendation(id uint, userID uint) (*AIRecommendation, error) {
var rec AIRecommendation
err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&rec).Error
return &rec, err
}
// GetUserRecommendations fetches recent recommendations for a user
func (r *Repository) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
var recs []AIRecommendation
err := database.DB.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Find(&recs).Error
return recs, err
}
// UpdateRecommendationStatus marks a recommendation as scheduled/rejected
func (r *Repository) UpdateRecommendationStatus(id uint, status string) error {
return database.DB.Model(&AIRecommendation{}).
Where("id = ?", id).
Update("status", status).Error
}

478
internal/ai/service.go Normal file
View File

@@ -0,0 +1,478 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"time"
"rideaware/internal/event"
"rideaware/internal/nutrition"
"rideaware/internal/stats"
"rideaware/internal/user"
"rideaware/internal/workout"
"rideaware/pkg/database"
)
type Service struct {
userRepo *user.Repository
statsRepo *stats.Repository
workoutRepo *workout.Repository
eventRepo *event.Repository
nutritionSvc *nutrition.Service
aiRepo *Repository
}
func NewService() *Service {
return &Service{
userRepo: user.NewRepository(),
statsRepo: stats.NewRepository(),
workoutRepo: workout.NewRepository(),
eventRepo: event.NewRepository(),
nutritionSvc: nutrition.NewService(),
aiRepo: NewRepository(),
}
}
// GenerateWorkouts orchestrates the AI generation process
func (s *Service) GenerateWorkouts(userID uint, req GenerateRequest) (*GenerateResponse, error) {
// 1. Gather user context
userCtx, err := s.buildUserContext(userID, req)
if err != nil {
return nil, fmt.Errorf("failed to build user context: %w", err)
}
// 2. Call DeepSeek API with retry logic
var aiResponse string
maxRetries := 2
for i := 0; i <= maxRetries; i++ {
if i > 0 {
log.Printf("[AI] Retry attempt %d/%d", i, maxRetries)
time.Sleep(time.Duration(i) * 2 * time.Second) // Exponential backoff
}
aiResponse, err = s.callDeepSeekAPI(userCtx, req)
if err == nil {
break
}
log.Printf("[AI] API call attempt %d failed: %v", i+1, err)
}
if err != nil {
return nil, fmt.Errorf("AI API call failed after %d attempts: %w", maxRetries+1, err)
}
// 3. Parse and validate response
genResponse, err := s.parseAIResponse(aiResponse)
if err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w", err)
}
// 4. Fix duration mismatches (AI sometimes miscalculates)
s.fixWorkoutDurations(genResponse.Workouts)
// 5. Filter out incomplete workouts (defensive against malformed AI responses)
validWorkouts := make([]AIWorkout, 0, len(genResponse.Workouts))
for i, w := range genResponse.Workouts {
if w.Duration == 0 || len(w.Segments) == 0 || w.Title == "" {
log.Printf("[AI] Skipping incomplete workout %d (duration=%d, segments=%d, title=%q)",
i, w.Duration, len(w.Segments), w.Title)
continue
}
validWorkouts = append(validWorkouts, w)
}
genResponse.Workouts = validWorkouts
if len(validWorkouts) == 0 {
return nil, fmt.Errorf("no valid workouts generated by AI")
}
// 6. Validate workout structures
if err := validateWorkouts(genResponse.Workouts); err != nil {
return nil, fmt.Errorf("workout validation failed: %w", err)
}
// 7. Calculate total TSS
totalTSS := 0.0
for _, w := range genResponse.Workouts {
totalTSS += w.EstimatedTSS
}
genResponse.TotalTSS = totalTSS
// 8. Store recommendation in database
recID, err := s.aiRepo.SaveRecommendation(userID, userCtx, req, aiResponse, genResponse)
if err != nil {
return nil, fmt.Errorf("failed to save recommendation: %w", err)
}
genResponse.RecommendationID = recID
return genResponse, nil
}
// buildUserContext gathers all relevant user data
func (s *Service) buildUserContext(userID uint, req GenerateRequest) (UserContext, error) {
var ctx UserContext
// Get user profile
userObj, err := s.userRepo.GetUserByID(userID)
if err != nil {
return ctx, err
}
// Set default values if profile doesn't exist
ctx.FTP = 200 // default FTP
ctx.MaxHR = 180
ctx.RestingHR = 60
ctx.Weight = 70.0
ctx.WeeklyHours = req.WeeklyHours
// Override with actual profile data if available
if userObj.Profile != nil {
if userObj.Profile.FTP > 0 {
ctx.FTP = userObj.Profile.FTP
}
if userObj.Profile.MaxHR > 0 {
ctx.MaxHR = userObj.Profile.MaxHR
}
if userObj.Profile.RestingHR > 0 {
ctx.RestingHR = userObj.Profile.RestingHR
}
if userObj.Profile.Weight > 0 {
ctx.Weight = userObj.Profile.Weight
}
}
// Get recent workouts (last 30 days)
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -30)
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, startDate, endDate)
if err == nil {
ctx.RecentWorkouts = s.summarizeWorkouts(workouts, ctx.FTP)
} else {
ctx.RecentWorkouts = []RecentWorkoutSummary{}
}
// Get training load (CTL/ATL/TSB)
ctx.TrainingLoad = s.calculateTrainingLoad(userID, ctx.FTP)
// Get upcoming events
upcomingEvents, err := s.eventRepo.GetUpcomingEvents(userID, 5)
if err == nil && len(upcomingEvents) > 0 {
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, ev := range upcomingEvents {
evDate := time.Date(ev.EventDate.Year(), ev.EventDate.Month(), ev.EventDate.Day(), 0, 0, 0, 0, ev.EventDate.Location())
daysAway := int(evDate.Sub(now).Hours() / 24)
summary := UpcomingEventSummary{
Name: ev.Name,
Date: ev.EventDate.Format("2006-01-02"),
EventType: ev.EventType,
Distance: ev.Distance,
Priority: ev.Priority,
DaysAway: daysAway,
}
ctx.UpcomingEvents = append(ctx.UpcomingEvents, summary)
// If this is the target event, set it
if req.TargetEventID != nil && ev.ID == *req.TargetEventID {
target := summary
ctx.TargetEvent = &target
}
}
}
// Get nutrition context
nutritionTargets, err := s.nutritionSvc.GetTargets(userID)
if err == nil && nutritionTargets != nil && nutritionTargets.IsConfigured {
ctx.Nutrition = &NutritionContext{
Goal: nutritionTargets.NutritionGoal,
DailyCalories: nutritionTargets.DailyCalories,
ProteinG: nutritionTargets.Protein,
CarbsG: nutritionTargets.Carbs,
FatG: nutritionTargets.Fat,
DietaryPref: nutritionTargets.DietaryPref,
}
}
return ctx, nil
}
// summarizeWorkouts converts workouts to summaries
func (s *Service) summarizeWorkouts(workouts []workout.Workout, ftp int) []RecentWorkoutSummary {
summaries := make([]RecentWorkoutSummary, 0, len(workouts))
// Limit to last 10 workouts to keep prompt size manageable
count := 0
for _, w := range workouts {
if w.Status != "completed" {
continue
}
if count >= 10 {
break
}
count++
tss := 0.0
if ftp > 0 && w.AvgPower > 0 && w.Duration > 0 {
// TSS = (duration_seconds × (avgPower/FTP)²) / 36
intensityFactor := float64(w.AvgPower) / float64(ftp)
tss = (float64(w.Duration) * intensityFactor * intensityFactor) / 36.0
}
summaries = append(summaries, RecentWorkoutSummary{
Date: w.ScheduledDate.Format("2006-01-02"),
Type: w.Type,
Duration: w.Duration,
AvgPower: w.AvgPower,
TSS: tss,
})
}
return summaries
}
// calculateTrainingLoad computes CTL/ATL/TSB
func (s *Service) calculateTrainingLoad(userID uint, ftp int) TrainingLoadSummary {
summary := TrainingLoadSummary{CTL: 0, ATL: 0, TSB: 0}
if ftp == 0 {
return summary
}
// Get daily TSS for last 42 days (for CTL)
dailyTSS, err := s.statsRepo.GetDailyTSS(userID, ftp, 42)
if err != nil || len(dailyTSS) == 0 {
return summary
}
// Calculate CTL (42-day exponential moving average)
ctlTC := 42.0 // time constant
ctl := 0.0
for _, day := range dailyTSS {
ctl = ctl + (day.TSS-ctl)*(1.0/ctlTC)
}
// Calculate ATL (7-day exponential moving average, using last 7 days)
atlTC := 7.0
atl := 0.0
start := len(dailyTSS) - 7
if start < 0 {
start = 0
}
for i := start; i < len(dailyTSS); i++ {
atl = atl + (dailyTSS[i].TSS-atl)*(1.0/atlTC)
}
summary.CTL = math.Round(ctl*10) / 10
summary.ATL = math.Round(atl*10) / 10
summary.TSB = math.Round((ctl-atl)*10) / 10
return summary
}
// callDeepSeekAPI makes the HTTP request to DeepSeek
func (s *Service) callDeepSeekAPI(userCtx UserContext, req GenerateRequest) (string, error) {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
return "", fmt.Errorf("DEEPSEEK_API_KEY not configured")
}
// Build prompts
systemPrompt := BuildSystemPrompt()
userPrompt := BuildUserPrompt(userCtx, req)
// Create request
deepseekReq := DeepSeekRequest{
Model: "deepseek-chat",
Messages: []DeepSeekMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
Temperature: 0.7,
MaxTokens: 8000, // Ensure enough tokens for complete response
}
reqBody, _ := json.Marshal(deepseekReq)
log.Printf("[AI] Calling DeepSeek API for user context with FTP=%d", userCtx.FTP)
// Make HTTP request
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", bytes.NewBuffer(reqBody))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
// Increase timeout to 180 seconds (3 minutes) for AI generation
client := &http.Client{Timeout: 180 * time.Second}
log.Printf("[AI] Sending request to DeepSeek API...")
resp, err := client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
var deepseekResp DeepSeekResponse
if err := json.Unmarshal(body, &deepseekResp); err != nil {
return "", err
}
if deepseekResp.Error != nil {
return "", fmt.Errorf("DeepSeek API error: %s", deepseekResp.Error.Message)
}
if len(deepseekResp.Choices) == 0 {
return "", fmt.Errorf("no response from DeepSeek API")
}
content := deepseekResp.Choices[0].Message.Content
log.Printf("[AI] DeepSeek API call successful, response length: %d bytes", len(content))
// Log preview of response
if len(content) > 200 {
log.Printf("[AI] Response preview: %s...", content[:200])
} else {
log.Printf("[AI] Full response: %s", content)
}
return content, nil
}
// parseAIResponse extracts structured workout data from AI response
func (s *Service) parseAIResponse(aiResponse string) (*GenerateResponse, error) {
var response GenerateResponse
// Try to extract JSON from response (AI might wrap it in markdown)
cleaned := extractJSON(aiResponse)
log.Printf("[AI] Raw response length: %d bytes", len(aiResponse))
log.Printf("[AI] Cleaned response length: %d bytes", len(cleaned))
// Log first 500 chars for debugging
if len(cleaned) > 500 {
log.Printf("[AI] Response preview: %s...", cleaned[:500])
} else {
log.Printf("[AI] Full cleaned response: %s", cleaned)
}
if err := json.Unmarshal([]byte(cleaned), &response); err != nil {
log.Printf("[AI] Failed to parse JSON: %v", err)
log.Printf("[AI] Full raw response: %s", aiResponse)
return nil, fmt.Errorf("failed to parse JSON: %w (check logs for full response)", err)
}
return &response, nil
}
// ScheduleWorkouts converts AI recommendations to actual workouts
func (s *Service) ScheduleWorkouts(userID uint, recommendationID uint, workoutIndices []int) ([]*workout.Workout, error) {
// Get the recommendation
rec, err := s.aiRepo.GetRecommendation(recommendationID, userID)
if err != nil {
return nil, err
}
// Prevent duplicate scheduling
if rec.Status == "scheduled" {
return nil, fmt.Errorf("this training plan has already been scheduled")
}
// Parse generated workouts
var aiWorkouts []AIWorkout
if err := json.Unmarshal(rec.GeneratedWorkouts.Data, &aiWorkouts); err != nil {
return nil, err
}
// Schedule selected workouts
var scheduled []*workout.Workout
for _, idx := range workoutIndices {
if idx < 0 || idx >= len(aiWorkouts) {
continue
}
aiWorkout := aiWorkouts[idx]
scheduledDate, _ := time.Parse("2006-01-02", aiWorkout.ScheduledDate)
// Convert to workout model
workoutData := workout.WorkoutDataJSON{
Name: aiWorkout.Title,
TotalDuration: aiWorkout.Duration,
Segments: aiWorkout.Segments,
}
newWorkout := &workout.Workout{
UserID: userID,
Title: aiWorkout.Title,
Description: aiWorkout.Description,
Type: aiWorkout.Type,
Status: "planned",
ScheduledDate: scheduledDate,
Duration: aiWorkout.Duration,
WorkoutData: workoutData,
Notes: aiWorkout.Notes,
}
if err := s.workoutRepo.CreateWorkout(newWorkout); err != nil {
log.Printf("[AI] Failed to create workout: %v", err)
continue
}
scheduled = append(scheduled, newWorkout)
}
// Update recommendation status
if len(scheduled) > 0 {
s.aiRepo.UpdateRecommendationStatus(recommendationID, "scheduled")
}
return scheduled, nil
}
// GetUserRecommendations fetches recommendation history
func (s *Service) GetUserRecommendations(userID uint, limit int) ([]AIRecommendation, error) {
return s.aiRepo.GetUserRecommendations(userID, limit)
}
// fixWorkoutDurations corrects duration mismatches by recalculating from segments
func (s *Service) fixWorkoutDurations(workouts []AIWorkout) {
for i := range workouts {
totalDuration := 0
for _, seg := range workouts[i].Segments {
totalDuration += seg.Duration
}
// If there's a mismatch, fix the workout duration
if workouts[i].Duration != totalDuration {
log.Printf("[AI] Fixing workout %d duration: %d -> %d (sum of segments)",
i, workouts[i].Duration, totalDuration)
workouts[i].Duration = totalDuration
}
}
}
// GetUserFTP queries FTP from user_profiles table directly
func (s *Service) GetUserFTP(userID uint) (int, error) {
var ftp int
err := database.DB.Table("user_profiles").
Select("ftp").
Where("user_id = ?", userID).
Scan(&ftp).Error
return ftp, err
}

156
internal/ai/validator.go Normal file
View File

@@ -0,0 +1,156 @@
package ai
import (
"fmt"
"math"
"time"
)
// validateWorkouts ensures AI-generated workouts meet quality standards
func validateWorkouts(workouts []AIWorkout) error {
if len(workouts) == 0 {
return fmt.Errorf("no workouts generated")
}
for i, w := range workouts {
if err := validateWorkout(w, i); err != nil {
return err
}
}
return nil
}
func validateWorkout(w AIWorkout, index int) error {
// Required fields
if w.Title == "" {
return fmt.Errorf("workout %d: title is required", index)
}
if w.Duration <= 0 {
return fmt.Errorf("workout %d: invalid duration %d", index, w.Duration)
}
if w.Duration > 86400 {
return fmt.Errorf("workout %d: duration exceeds 24 hours", index)
}
// Validate date format
if _, err := time.Parse("2006-01-02", w.ScheduledDate); err != nil {
return fmt.Errorf("workout %d: invalid date format %s", index, w.ScheduledDate)
}
// Validate segments
if len(w.Segments) == 0 {
return fmt.Errorf("workout %d: must have at least one segment", index)
}
if len(w.Segments) > 100 {
return fmt.Errorf("workout %d: too many segments (%d), maximum 100", index, len(w.Segments))
}
totalDuration := 0
for j, seg := range w.Segments {
if seg.Duration <= 0 {
return fmt.Errorf("workout %d, segment %d: invalid duration %d", index, j, seg.Duration)
}
// Validate power values are reasonable (0-300% FTP)
if seg.Power < 0 || seg.Power > 3.0 {
return fmt.Errorf("workout %d, segment %d: power %.2f out of range (0-3.0)", index, j, seg.Power)
}
if seg.PowerLow < 0 || seg.PowerLow > 3.0 {
return fmt.Errorf("workout %d, segment %d: power_low %.2f out of range (0-3.0)", index, j, seg.PowerLow)
}
if seg.PowerHigh < 0 || seg.PowerHigh > 3.0 {
return fmt.Errorf("workout %d, segment %d: power_high %.2f out of range (0-3.0)", index, j, seg.PowerHigh)
}
// Validate that power_low <= power_high (except for ramps/cooldowns where power descends)
// For warmup, cooldown, and ramp types, power can go from high to low
if seg.PowerLow > 0 && seg.PowerHigh > 0 && seg.PowerLow > seg.PowerHigh {
// Allow descending power for warmup, cooldown, and ramp segments
if seg.Type != "warmup" && seg.Type != "cooldown" && seg.Type != "ramp" {
return fmt.Errorf("workout %d, segment %d: power_low (%.2f) > power_high (%.2f)", index, j, seg.PowerLow, seg.PowerHigh)
}
}
// Validate segment type
validTypes := map[string]bool{
"warmup": true,
"cooldown": true,
"steadystate": true,
"interval": true,
"ramp": true,
"rest": true,
"freeride": true,
}
if !validTypes[seg.Type] {
return fmt.Errorf("workout %d, segment %d: invalid type %s", index, j, seg.Type)
}
totalDuration += seg.Duration
}
// Verify total duration matches sum of segments (within 5% tolerance)
tolerance := float64(w.Duration) * 0.05
diff := math.Abs(float64(totalDuration - w.Duration))
if diff > tolerance {
return fmt.Errorf("workout %d: duration mismatch (declared %d, segments total %d)",
index, w.Duration, totalDuration)
}
// Validate TSS is reasonable (0-500)
if w.EstimatedTSS < 0 || w.EstimatedTSS > 500 {
return fmt.Errorf("workout %d: unrealistic TSS %.1f (expected 0-500)", index, w.EstimatedTSS)
}
return nil
}
// validateGenerateRequest validates user input
func validateGenerateRequest(req GenerateRequest) error {
if req.PlanDuration < 1 || req.PlanDuration > 28 {
return fmt.Errorf("plan_duration must be between 1 and 28 days")
}
if req.WeeklyHours < 1 || req.WeeklyHours > 40 {
return fmt.Errorf("weekly_hours must be between 1 and 40")
}
validIntensities := map[string]bool{"easy": true, "moderate": true, "hard": true}
if !validIntensities[req.IntensityLevel] {
return fmt.Errorf("intensity_level must be 'easy', 'moderate', or 'hard'")
}
if len(req.FocusAreas) == 0 {
return fmt.Errorf("at least one focus area is required")
}
if len(req.FocusAreas) > 3 {
return fmt.Errorf("maximum 3 focus areas allowed")
}
validFocusAreas := map[string]bool{
"endurance": true,
"threshold": true,
"vo2max": true,
"recovery": true,
"sprint": true,
"sweet_spot": true,
}
for _, focus := range req.FocusAreas {
if !validFocusAreas[focus] {
return fmt.Errorf("invalid focus area: %s", focus)
}
}
// Validate start date format
if _, err := time.Parse("2006-01-02", req.StartDate); err != nil {
return fmt.Errorf("invalid start_date format, use YYYY-MM-DD")
}
return nil
}

View File

@@ -2,8 +2,12 @@ package auth
import (
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"rideaware/internal/config"
"rideaware/internal/user"
@@ -25,6 +29,7 @@ type SignupRequest struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TurnstileToken string `json:"turnstile_token"`
}
type LoginRequest struct {
@@ -39,6 +44,7 @@ type TokenResponse struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
@@ -55,6 +61,25 @@ func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
log.Printf("📝 Signup attempt for user: %s (email: %s)", req.Username, req.Email)
// Verify Turnstile CAPTCHA
turnstileSecret := os.Getenv("TURNSTILE_SECRET_KEY")
if turnstileSecret != "" {
if req.TurnstileToken == "" {
log.Printf("❌ Signup rejected: missing Turnstile token for %s", req.Username)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Please complete the verification check"})
return
}
if !verifyTurnstile(turnstileSecret, req.TurnstileToken, r.RemoteAddr) {
log.Printf("❌ Signup rejected: Turnstile verification failed for %s", req.Username)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "CAPTCHA verification failed. Please try again."})
return
}
}
newUser, err := h.userService.CreateUser(req.Username, req.Password, req.Email, req.FirstName, req.LastName)
if err != nil {
log.Printf("❌ Signup error: %v", err)
@@ -66,18 +91,19 @@ func (h *Handler) Signup(w http.ResponseWriter, r *http.Request) {
log.Printf("✅ User created: %s (ID: %d)", newUser.Username, newUser.ID)
accessToken, _ := config.GenerateAccessToken(newUser.ID, newUser.Email, newUser.Username)
refreshToken, _ := config.GenerateRefreshToken(newUser.ID, newUser.Email, newUser.Username)
accessToken, _ := config.GenerateAccessToken(newUser.ID, newUser.Email, newUser.Username, newUser.Role)
refreshToken, _ := config.GenerateRefreshToken(newUser.ID, newUser.Email, newUser.Username, newUser.Role)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: 900,
ExpiresIn: 86400, // 24 hours in seconds
UserID: newUser.ID,
Username: newUser.Username,
Email: newUser.Email,
Role: newUser.Role,
})
}
@@ -106,17 +132,18 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("✅ Login successful for user: %s (ID: %d)", user.Username, user.ID)
accessToken, _ := config.GenerateAccessToken(user.ID, user.Email, user.Username)
refreshToken, _ := config.GenerateRefreshToken(user.ID, user.Email, user.Username)
accessToken, _ := config.GenerateAccessToken(user.ID, user.Email, user.Username, user.Role)
refreshToken, _ := config.GenerateRefreshToken(user.ID, user.Email, user.Username, user.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: 900,
ExpiresIn: 86400, // 24 hours in seconds
UserID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
})
}
@@ -149,12 +176,12 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) {
log.Printf("✅ Refresh token valid for user ID: %d", claims.UserID)
// Generate new access token
newAccessToken, _ := config.GenerateAccessToken(claims.UserID, claims.Email, claims.Username)
newAccessToken, _ := config.GenerateAccessToken(claims.UserID, claims.Email, claims.Username, claims.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": newAccessToken,
"expires_in": 900,
"expires_in": 86400, // 24 hours in seconds
})
}
@@ -229,3 +256,38 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Logout successful"})
}
func verifyTurnstile(secret, token, remoteIP string) bool {
// Strip port from RemoteAddr if present
if idx := strings.LastIndex(remoteIP, ":"); idx != -1 {
remoteIP = remoteIP[:idx]
}
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify",
url.Values{
"secret": {secret},
"response": {token},
"remoteip": {remoteIP},
})
if err != nil {
log.Printf("❌ Turnstile verification request failed: %v", err)
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("❌ Turnstile verification read failed: %v", err)
return false
}
var result struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(body, &result); err != nil {
log.Printf("❌ Turnstile verification parse failed: %v", err)
return false
}
return result.Success
}

View File

@@ -20,8 +20,8 @@ var JWT *JWTConfig
func InitJWT() {
JWT = &JWTConfig{
SecretKey: os.Getenv("JWT_SECRET_KEY"),
AccessTokenDuration: 15 * time.Minute,
RefreshTokenDuration: 7 * 24 * time.Hour,
AccessTokenDuration: 24 * time.Hour, // 24 hours (was 15 minutes)
RefreshTokenDuration: 30 * 24 * time.Hour, // 30 days (was 7 days)
ResetTokenDuration: 1 * time.Hour,
}
@@ -34,15 +34,17 @@ type CustomClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
Role string `json:"role"`
TokenType string `json:"token_type"`
jwt.RegisteredClaims
}
func GenerateAccessToken(userID uint, email, username string) (string, error) {
func GenerateAccessToken(userID uint, email, username, role string) (string, error) {
claims := CustomClaims{
UserID: userID,
Email: email,
Username: username,
Role: role,
TokenType: "access",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.AccessTokenDuration)),
@@ -55,11 +57,12 @@ func GenerateAccessToken(userID uint, email, username string) (string, error) {
return token.SignedString([]byte(JWT.SecretKey))
}
func GenerateRefreshToken(userID uint, email, username string) (string, error) {
func GenerateRefreshToken(userID uint, email, username, role string) (string, error) {
claims := CustomClaims{
UserID: userID,
Email: email,
Username: username,
Role: role,
TokenType: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(JWT.RefreshTokenDuration)),

View File

@@ -152,9 +152,9 @@ func (s *Service) SendPasswordResetEmail(email, username, resetLink string) erro
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 20px; border-radius: 8px; }
.header { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 20px; border-radius: 8px; }
.content { padding: 20px; background: #f9f9f9; margin: 20px 0; border-radius: 8px; }
.button { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.button { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
</style>
</head>
@@ -192,9 +192,9 @@ func (s *Service) SendWelcomeEmail(email, username string) error {
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 20px; border-radius: 8px; }
.header { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 20px; border-radius: 8px; }
.content { padding: 20px; background: #f9f9f9; margin: 20px 0; border-radius: 8px; }
.button { background: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.button { background: linear-gradient(135deg, #1e4e9c 0%%25, #337cf2 100%%25); color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
</style>
</head>

194
internal/event/handler.go Normal file
View File

@@ -0,0 +1,194 @@
package event
import (
"encoding/json"
"net/http"
"strconv"
"time"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type Handler struct {
service *Service
}
func NewHandler() *Handler {
return &Handler{service: NewService()}
}
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
var req struct {
Name string `json:"name"`
EventDate string `json:"event_date"`
EventType string `json:"event_type"`
Distance float64 `json:"distance"`
Priority string `json:"priority"`
Location string `json:"location"`
Notes string `json:"notes"`
URL string `json:"url"`
}
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
}
eventDate, err := time.Parse("2006-01-02", req.EventDate)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event_date format (use YYYY-MM-DD)"})
return
}
event, err := h.service.CreateEvent(
claims.UserID, req.Name, eventDate, req.EventType,
req.Priority, req.Location, req.Notes, req.URL, req.Distance,
)
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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(event)
}
func (h *Handler) GetEvents(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
events, err := h.service.GetUserEvents(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 events"})
return
}
if events == nil {
events = []Event{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(events)
}
func (h *Handler) GetUpcomingEvents(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
events, err := h.service.GetUpcomingEvents(claims.UserID, 10)
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 events"})
return
}
if events == nil {
events = []Event{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(events)
}
func (h *Handler) UpdateEvent(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 event id"})
return
}
var req struct {
Name string `json:"name"`
EventDate string `json:"event_date"`
EventType string `json:"event_type"`
Distance float64 `json:"distance"`
Priority string `json:"priority"`
Location string `json:"location"`
Notes string `json:"notes"`
URL string `json:"url"`
}
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
}
var eventDate time.Time
if req.EventDate != "" {
eventDate, err = time.Parse("2006-01-02", req.EventDate)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid event_date format"})
return
}
}
event, err := h.service.UpdateEvent(
uint(id), claims.UserID, req.Name, eventDate, req.EventType,
req.Priority, req.Location, req.Notes, req.URL, req.Distance,
)
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(event)
}
func (h *Handler) DeleteEvent(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 event id"})
return
}
if err := h.service.DeleteEvent(uint(id), claims.UserID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to delete event"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "event deleted"})
}
func (h *Handler) GetEventTypes(w http.ResponseWriter, r *http.Request) {
types := []map[string]interface{}{
{"id": "race", "name": "Race", "icon": "\U0001F3C1", "color": "#FF1744"},
{"id": "gran_fondo", "name": "Gran Fondo", "icon": "\U0001F3C6", "color": "#FF6D00"},
{"id": "group_ride", "name": "Group Ride", "icon": "\U0001F6B4", "color": "#2979FF"},
{"id": "time_trial", "name": "Time Trial", "icon": "\u23F1\uFE0F", "color": "#AA00FF"},
{"id": "century", "name": "Century", "icon": "\U0001F5FA\uFE0F", "color": "#00C853"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(types)
}

18
internal/event/model.go Normal file
View File

@@ -0,0 +1,18 @@
package event
import "time"
type Event struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
EventDate time.Time `gorm:"not null;index" json:"event_date"`
EventType string `gorm:"not null" json:"event_type"`
Distance float64 `gorm:"default:0" json:"distance"`
Priority string `gorm:"default:'C'" json:"priority"`
Location string `gorm:"default:''" json:"location"`
Notes string `gorm:"default:''" json:"notes"`
URL string `gorm:"default:''" json:"url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,65 @@
package event
import (
"errors"
"time"
"rideaware/pkg/database"
"gorm.io/gorm"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) CreateEvent(event *Event) error {
return database.DB.Create(event).Error
}
func (r *Repository) GetEventByID(id, userID uint) (*Event, error) {
var event Event
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&event).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("event not found")
}
return nil, err
}
return &event, nil
}
func (r *Repository) GetUserEvents(userID uint) ([]Event, error) {
var events []Event
if err := database.DB.Where("user_id = ?", userID).Order("event_date ASC").Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
func (r *Repository) GetEventsByDateRange(userID uint, start, end time.Time) ([]Event, error) {
var events []Event
if err := database.DB.Where("user_id = ? AND event_date BETWEEN ? AND ?", userID, start, end).
Order("event_date ASC").Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
func (r *Repository) GetUpcomingEvents(userID uint, limit int) ([]Event, error) {
var events []Event
if err := database.DB.Where("user_id = ? AND event_date >= ?", userID, time.Now()).
Order("event_date ASC").Limit(limit).Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
func (r *Repository) UpdateEvent(event *Event) error {
return database.DB.Save(event).Error
}
func (r *Repository) DeleteEvent(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&Event{}).Error
}

105
internal/event/service.go Normal file
View File

@@ -0,0 +1,105 @@
package event
import (
"errors"
"time"
)
type Service struct {
repo *Repository
}
func NewService() *Service {
return &Service{repo: NewRepository()}
}
func (s *Service) CreateEvent(userID uint, name string, eventDate time.Time, eventType, priority, location, notes, url string, distance float64) (*Event, error) {
if name == "" {
return nil, errors.New("name is required")
}
if eventDate.IsZero() {
return nil, errors.New("event_date is required")
}
if !isValidEventType(eventType) {
return nil, errors.New("invalid event_type")
}
if !isValidPriority(priority) {
priority = "C"
}
event := &Event{
UserID: userID,
Name: name,
EventDate: eventDate,
EventType: eventType,
Distance: distance,
Priority: priority,
Location: location,
Notes: notes,
URL: url,
}
if err := s.repo.CreateEvent(event); err != nil {
return nil, err
}
return event, nil
}
func (s *Service) GetUserEvents(userID uint) ([]Event, error) {
return s.repo.GetUserEvents(userID)
}
func (s *Service) GetUpcomingEvents(userID uint, limit int) ([]Event, error) {
return s.repo.GetUpcomingEvents(userID, limit)
}
func (s *Service) UpdateEvent(id, userID uint, name string, eventDate time.Time, eventType, priority, location, notes, url string, distance float64) (*Event, error) {
event, err := s.repo.GetEventByID(id, userID)
if err != nil {
return nil, err
}
if name != "" {
event.Name = name
}
if !eventDate.IsZero() {
event.EventDate = eventDate
}
if eventType != "" {
if !isValidEventType(eventType) {
return nil, errors.New("invalid event_type")
}
event.EventType = eventType
}
if priority != "" {
if !isValidPriority(priority) {
return nil, errors.New("invalid priority")
}
event.Priority = priority
}
event.Location = location
event.Notes = notes
event.URL = url
event.Distance = distance
if err := s.repo.UpdateEvent(event); err != nil {
return nil, err
}
return event, nil
}
func (s *Service) DeleteEvent(id, userID uint) error {
return s.repo.DeleteEvent(id, userID)
}
func isValidEventType(t string) bool {
valid := map[string]bool{
"race": true, "gran_fondo": true, "group_ride": true,
"time_trial": true, "century": true,
}
return valid[t]
}
func isValidPriority(p string) bool {
return p == "A" || p == "B" || p == "C"
}

View File

@@ -29,17 +29,23 @@ func EncodeFITWorkout(w *workout.Workout, userFTP int) ([]byte, error) {
wktFile.FileId.
SetType(typedef.FileWorkout).
SetManufacturer(typedef.ManufacturerDevelopment).
SetProduct(1).
SetTimeCreated(time.Now())
SetProduct(0).
SetTimeCreated(time.Now()).
SetSerialNumber(uint32(time.Now().Unix()))
name := w.WorkoutData.Name
if name == "" {
name = w.Title
}
// Truncate name if too long (FIT spec supports up to 50 chars)
if len(name) > 50 {
name = name[:50]
}
wktFile.Workout = mesgdef.NewWorkout(nil).
SetWktName(name).
SetSport(typedef.SportCycling).
SetSubSport(typedef.SubSportGeneric).
SetNumValidSteps(uint16(len(w.WorkoutData.Segments)))
steps := make([]*mesgdef.WorkoutStep, 0, len(w.WorkoutData.Segments))
@@ -95,11 +101,20 @@ func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *me
case "freeride":
step.SetIntensity(typedef.IntensityActive)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Free Ride")
case "rest":
step.SetIntensity(typedef.IntensityRest)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Rest")
default:
step.SetIntensity(typedef.IntensityActive)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Active")
}
return step
@@ -110,6 +125,7 @@ func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *me
func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
if seg.PowerLow == 0 && seg.PowerHigh == 0 && seg.Power == 0 {
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
return
}
@@ -117,13 +133,34 @@ func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userF
step.SetTargetValue(0) // 0 = custom range
if seg.PowerLow != 0 || seg.PowerHigh != 0 {
low := uint32(float64(userFTP)*seg.PowerLow) + 1000
high := uint32(float64(userFTP)*seg.PowerHigh) + 1000
step.SetCustomTargetValueLow(low)
step.SetCustomTargetValueHigh(high)
lowWatts := uint32(float64(userFTP) * seg.PowerLow)
highWatts := uint32(float64(userFTP) * seg.PowerHigh)
// Ensure we have a valid range (low < high)
if lowWatts == highWatts {
// Add a small tolerance range (±5W) around the target
if lowWatts < 5 {
lowWatts = 1
highWatts = lowWatts + 5
} else {
lowWatts = lowWatts - 5
highWatts = highWatts + 5
}
}
step.SetCustomTargetValueLow(lowWatts + 1000)
step.SetCustomTargetValueHigh(highWatts + 1000)
} else if seg.Power != 0 {
watts := uint32(float64(userFTP) * seg.Power)
step.SetCustomTargetValueLow(watts - 10 + 1000)
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
lowOffset := uint32(10)
if watts < 10 {
lowOffset = watts - 1
if lowOffset == 0 {
lowOffset = 0
}
}
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
step.SetCustomTargetValueHigh(watts + 10 + 1000)
}
}
@@ -132,6 +169,7 @@ func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userF
func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
if seg.Power == 0 {
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
return
}
@@ -139,6 +177,14 @@ func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment,
step.SetTargetValue(0)
watts := uint32(float64(userFTP) * seg.Power)
step.SetCustomTargetValueLow(watts - 10 + 1000)
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
lowOffset := uint32(10)
if watts < 10 {
lowOffset = watts - 1
if lowOffset == 0 {
lowOffset = 0
}
}
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
step.SetCustomTargetValueHigh(watts + 10 + 1000)
}

210
internal/goal/handler.go Normal file
View File

@@ -0,0 +1,210 @@
package goal
import (
"encoding/json"
"net/http"
"strconv"
"time"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type Handler struct{}
func NewHandler() *Handler {
return &Handler{}
}
// CreateGoal POST /api/protected/goals
func (h *Handler) CreateGoal(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
var req struct {
Title string `json:"title"`
Description string `json:"description"`
GoalType string `json:"goal_type"`
TargetValue float64 `json:"target_value"`
TargetDate *time.Time `json:"target_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.TargetValue <= 0 {
writeError(w, http.StatusBadRequest, "target_value must be greater than 0")
return
}
// Validate goal type
gType := GoalType(req.GoalType)
switch gType {
case GoalDistance, GoalFrequency, GoalPower, GoalWeight, GoalCustom, "":
if gType == "" {
gType = GoalCustom
}
default:
writeError(w, http.StatusBadRequest, "invalid goal_type")
return
}
goal := &Goal{
UserID: claims.UserID,
Title: req.Title,
Description: req.Description,
GoalType: gType,
TargetValue: req.TargetValue,
TargetDate: req.TargetDate,
Status: StatusActive,
}
repo := NewRepository()
if err := repo.Create(goal); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create goal")
return
}
writeJSON(w, http.StatusCreated, goal)
}
// GetGoals GET /api/protected/goals?status=active
func (h *Handler) GetGoals(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
statusFilter := r.URL.Query().Get("status")
repo := NewRepository()
var goals []Goal
var err error
if statusFilter != "" {
goals, err = repo.ListByStatus(claims.UserID, GoalStatus(statusFilter))
} else {
goals, err = repo.List(claims.UserID)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch goals")
return
}
if goals == nil {
goals = []Goal{}
}
writeJSON(w, http.StatusOK, goals)
}
// UpdateGoal PUT /api/protected/goals?id=X
func (h *Handler) UpdateGoal(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 {
writeError(w, http.StatusBadRequest, "invalid goal id")
return
}
repo := NewRepository()
goal, err := repo.GetByID(uint(id), claims.UserID)
if err != nil {
writeError(w, http.StatusNotFound, "goal not found")
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
GoalType string `json:"goal_type"`
TargetValue *float64 `json:"target_value"`
CurrentValue *float64 `json:"current_value"`
TargetDate *time.Time `json:"target_date"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title != "" {
goal.Title = req.Title
}
if req.Description != "" {
goal.Description = req.Description
}
if req.GoalType != "" {
gType := GoalType(req.GoalType)
switch gType {
case GoalDistance, GoalFrequency, GoalPower, GoalWeight, GoalCustom:
goal.GoalType = gType
default:
writeError(w, http.StatusBadRequest, "invalid goal_type")
return
}
}
if req.TargetValue != nil {
goal.TargetValue = *req.TargetValue
}
if req.CurrentValue != nil {
goal.CurrentValue = *req.CurrentValue
}
if req.TargetDate != nil {
goal.TargetDate = req.TargetDate
}
if req.Status != "" {
switch GoalStatus(req.Status) {
case StatusActive, StatusCompleted, StatusArchived:
goal.Status = GoalStatus(req.Status)
default:
writeError(w, http.StatusBadRequest, "invalid status")
return
}
}
if err := repo.Update(goal); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update goal")
return
}
writeJSON(w, http.StatusOK, goal)
}
// DeleteGoal DELETE /api/protected/goals?id=X
func (h *Handler) DeleteGoal(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 {
writeError(w, http.StatusBadRequest, "invalid goal id")
return
}
repo := NewRepository()
if err := repo.Delete(uint(id), claims.UserID); err != nil {
writeError(w, http.StatusNotFound, "goal not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

42
internal/goal/model.go Normal file
View File

@@ -0,0 +1,42 @@
package goal
import "time"
// GoalType defines categories of goals users can set.
type GoalType string
const (
GoalDistance GoalType = "distance"
GoalFrequency GoalType = "frequency"
GoalPower GoalType = "power"
GoalWeight GoalType = "weight"
GoalCustom GoalType = "custom"
)
// GoalStatus tracks the lifecycle of a goal.
type GoalStatus string
const (
StatusActive GoalStatus = "active"
StatusCompleted GoalStatus = "completed"
StatusArchived GoalStatus = "archived"
)
type Goal 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"`
GoalType GoalType `gorm:"default:'custom'" json:"goal_type"`
TargetValue float64 `gorm:"not null;default:0" json:"target_value"`
CurrentValue float64 `gorm:"default:0" json:"current_value"`
TargetDate *time.Time `json:"target_date,omitempty"`
Status GoalStatus `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Goal) TableName() string {
return "goals"
}

View File

@@ -0,0 +1,49 @@
package goal
import (
"rideaware/pkg/database"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
func (r *Repository) Create(goal *Goal) error {
return database.DB.Create(goal).Error
}
func (r *Repository) GetByID(id, userID uint) (*Goal, error) {
var g Goal
err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&g).Error
if err != nil {
return nil, err
}
return &g, nil
}
func (r *Repository) List(userID uint) ([]Goal, error) {
var goals []Goal
err := database.DB.Where("user_id = ?", userID).
Order("created_at desc").
Find(&goals).Error
return goals, err
}
func (r *Repository) ListByStatus(userID uint, status GoalStatus) ([]Goal, error) {
var goals []Goal
err := database.DB.Where("user_id = ? AND status = ?", userID, status).
Order("created_at desc").
Find(&goals).Error
return goals, err
}
func (r *Repository) Update(goal *Goal) error {
return database.DB.Save(goal).Error
}
func (r *Repository) Delete(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&Goal{}).Error
}

View File

@@ -0,0 +1,308 @@
package integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"rideaware/internal/config"
"rideaware/internal/workout"
)
const intervalsBaseURL = "https://intervals.icu/api/v1"
const intervalsProvider = "intervals_icu"
type IntervalsClient struct {
oauthService *OAuthService
workoutRepo *workout.Repository
}
func NewIntervalsClient() *IntervalsClient {
return &IntervalsClient{
oauthService: NewOAuthService(),
workoutRepo: workout.NewRepository(),
}
}
// SaveApiKey stores the user's Intervals.icu API key encrypted in the oauth_connections table.
func (c *IntervalsClient) SaveApiKey(userID uint, apiKey string) error {
// Use a far-future expiry so GetValidToken never marks it expired
tokenResp := &TokenResponse{
AccessToken: apiKey,
ExpiresIn: 100 * 365 * 24 * 3600,
}
return c.oauthService.SaveConnection(userID, intervalsProvider, tokenResp)
}
// getApiKey retrieves and decrypts the stored API key for a user.
func (c *IntervalsClient) getApiKey(userID uint) (string, error) {
conn, err := c.oauthService.repo.GetConnection(userID, intervalsProvider)
if err != nil {
return "", fmt.Errorf("intervals.icu not connected")
}
if conn.Status != "active" {
return "", fmt.Errorf("intervals.icu connection is %s, please reconnect", conn.Status)
}
return Decrypt(conn.AccessToken, config.OAuth.EncryptionKey)
}
// intervalsRequest performs an authenticated request to the Intervals.icu API.
func (c *IntervalsClient) intervalsRequest(method, path string, apiKey string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, intervalsBaseURL+path, reqBody)
if err != nil {
return nil, err
}
req.SetBasicAuth("API_KEY", apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return http.DefaultClient.Do(req)
}
// ---- Workout push structures ----
type intervalsEventPayload struct {
StartDateLocal string `json:"start_date_local"`
Category string `json:"category"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
MovingTime int `json:"moving_time"`
WorkoutDoc *intervalsWorkoutDoc `json:"workout_doc,omitempty"`
}
type intervalsWorkoutDoc struct {
Description string `json:"description,omitempty"`
Duration int `json:"duration"`
Target string `json:"target"`
Steps []intervalsStep `json:"steps"`
}
type intervalsStep struct {
Duration int `json:"duration"`
Intensity string `json:"intensity"`
Warmup bool `json:"warmup,omitempty"`
Cooldown bool `json:"cooldown,omitempty"`
Power *intervalsPowerValue `json:"power,omitempty"`
Cadence *intervalsCadenceValue `json:"cadence,omitempty"`
}
type intervalsPowerValue struct {
Value float64 `json:"value,omitempty"`
Start float64 `json:"start,omitempty"`
End float64 `json:"end,omitempty"`
Units string `json:"units"`
}
type intervalsCadenceValue struct {
Value float64 `json:"value"`
Units string `json:"units"`
}
func segmentToStep(seg workout.WorkoutSegment) intervalsStep {
t := strings.ToLower(seg.Type)
step := intervalsStep{
Duration: seg.Duration,
}
switch t {
case "warmup", "warm_up":
step.Intensity = "warmup"
step.Warmup = true
case "cooldown", "cool_down":
step.Intensity = "cooldown"
step.Cooldown = true
case "recovery":
step.Intensity = "recovery"
case "interval":
step.Intensity = "interval"
default:
step.Intensity = "active"
}
// Power
low := seg.PowerLow
high := seg.PowerHigh
single := seg.Power
if single > 0 {
step.Power = &intervalsPowerValue{Value: single, Units: "%ftp"}
} else if low > 0 || high > 0 {
if low == high {
step.Power = &intervalsPowerValue{Value: low, Units: "%ftp"}
} else {
step.Power = &intervalsPowerValue{Start: low, End: high, Units: "%ftp"}
}
}
// Cadence
if seg.Cadence > 0 {
step.Cadence = &intervalsCadenceValue{Value: float64(seg.Cadence), Units: "rpm"}
}
return step
}
// PushWorkout pushes a planned workout to the user's Intervals.icu calendar.
func (c *IntervalsClient) PushWorkout(workoutID, userID uint) error {
apiKey, err := c.getApiKey(userID)
if err != nil {
return err
}
w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID)
if err != nil {
return fmt.Errorf("workout not found: %w", err)
}
// Build workout_doc from segments
var doc *intervalsWorkoutDoc
if len(w.WorkoutData.Segments) > 0 {
steps := make([]intervalsStep, 0, len(w.WorkoutData.Segments))
for _, seg := range w.WorkoutData.Segments {
steps = append(steps, segmentToStep(seg))
}
doc = &intervalsWorkoutDoc{
Description: w.Description,
Duration: w.Duration,
Target: "POWER",
Steps: steps,
}
}
payload := intervalsEventPayload{
StartDateLocal: w.ScheduledDate.Format("2006-01-02") + "T00:00:00",
Category: "WORKOUT",
Name: w.Title,
Description: w.Description,
Type: "Ride",
MovingTime: w.Duration,
WorkoutDoc: doc,
}
resp, err := c.intervalsRequest("POST", "/athlete/0/events", apiKey, payload)
if err != nil {
return fmt.Errorf("failed to push workout to Intervals.icu: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ---- Activity sync structures ----
type intervalsActivity struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
StartDateLocal string `json:"start_date_local"`
MovingTime int `json:"moving_time"`
Distance float64 `json:"distance"`
AverageWatts int `json:"average_watts"`
AverageHeartrate int `json:"average_heartrate"`
MaxWatts int `json:"max_watts"`
MaxHeartrate int `json:"max_heartrate"`
Calories int `json:"calories"`
TotalElevationGain int `json:"total_elevation_gain"`
}
// SyncActivities fetches recent completed activities from Intervals.icu and imports them.
// Returns the number of new activities imported.
func (c *IntervalsClient) SyncActivities(userID uint, days int) (int, error) {
apiKey, err := c.getApiKey(userID)
if err != nil {
return 0, err
}
if days <= 0 || days > 365 {
days = 30
}
oldest := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
newest := time.Now().Format("2006-01-02")
resp, err := c.intervalsRequest("GET",
fmt.Sprintf("/athlete/0/activities?oldest=%s&newest=%s", oldest, newest),
apiKey, nil)
if err != nil {
return 0, fmt.Errorf("failed to fetch activities from Intervals.icu: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("Intervals.icu API returned status %d: %s", resp.StatusCode, string(body))
}
var activities []intervalsActivity
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
return 0, fmt.Errorf("failed to parse Intervals.icu activities: %w", err)
}
imported := 0
for _, act := range activities {
// Parse date from start_date_local (format: "2026-01-15T08:30:00")
t, err := time.Parse("2006-01-02T15:04:05", act.StartDateLocal)
if err != nil {
// Try alternate format without time
t, err = time.Parse("2006-01-02", act.StartDateLocal[:10])
if err != nil {
continue
}
}
dateStr := t.Format("2006-01-02")
// Skip if a completed workout already exists on this date
existing, _ := c.workoutRepo.GetCompletedWorkoutOnDate(userID, dateStr)
if existing != nil {
continue
}
// Map activity type to RideAware type
workoutType := act.Type
if workoutType == "" {
workoutType = "Ride"
}
w := &workout.Workout{
UserID: userID,
Title: act.Name,
Type: workoutType,
Status: "completed",
ScheduledDate: t,
Duration: act.MovingTime,
Distance: act.Distance / 1000, // metres → km
AvgPower: act.AverageWatts,
AvgHR: act.AverageHeartrate,
MaxPower: act.MaxWatts,
MaxHR: act.MaxHeartrate,
CaloriesBurned: act.Calories,
ElevGain: act.TotalElevationGain,
}
if err := c.workoutRepo.CreateWorkout(w); err == nil {
imported++
}
}
return imported, nil
}

View File

@@ -0,0 +1,167 @@
package integration
import (
"encoding/json"
"log"
"net/http"
"strconv"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type IntervalsHandler struct {
client *IntervalsClient
oauthService *OAuthService
}
func NewIntervalsHandler() *IntervalsHandler {
return &IntervalsHandler{
client: NewIntervalsClient(),
oauthService: NewOAuthService(),
}
}
// SaveApiKey POST /api/protected/intervals/connect
func (h *IntervalsHandler) SaveApiKey(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 {
APIKey string `json:"api_key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.APIKey == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "api_key is required"})
return
}
if err := h.client.SaveApiKey(claims.UserID, req.APIKey); err != nil {
log.Printf("Intervals.icu save key error: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to save API key"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Intervals.icu connected"})
}
// ConnectionStatus GET /api/protected/intervals/status
func (h *IntervalsHandler) ConnectionStatus(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
}
status, err := h.oauthService.GetConnectionStatus(claims.UserID, intervalsProvider)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// Disconnect DELETE /api/protected/intervals/disconnect
func (h *IntervalsHandler) Disconnect(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
}
if err := h.oauthService.Disconnect(claims.UserID, intervalsProvider); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Intervals.icu disconnected"})
}
// PushWorkout POST /api/protected/workouts/push/intervals?id=X
func (h *IntervalsHandler) PushWorkout(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
}
idStr := r.URL.Query().Get("id")
if idStr == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "workout id is required"})
return
}
id, err := strconv.ParseUint(idStr, 10, 64)
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.client.PushWorkout(uint(id), claims.UserID); err != nil {
log.Printf("Intervals.icu push error: %v", err)
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")
json.NewEncoder(w).Encode(map[string]string{"message": "workout pushed to Intervals.icu"})
}
// SyncActivities POST /api/protected/intervals/sync?days=30
func (h *IntervalsHandler) SyncActivities(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
}
days := 30
if dStr := r.URL.Query().Get("days"); dStr != "" {
if d, err := strconv.Atoi(dStr); err == nil && d > 0 {
days = d
}
}
imported, err := h.client.SyncActivities(claims.UserID, days)
if err != nil {
log.Printf("Intervals.icu sync error: %v", err)
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")
json.NewEncoder(w).Encode(map[string]int{"imported": imported})
}

View File

@@ -17,6 +17,35 @@ func NewAuthMiddleware() *AuthMiddleware {
return &AuthMiddleware{}
}
// RequireRole returns middleware that checks for a specific user role.
// It must be used after ProtectedRoute (or any middleware that sets the user context).
func (am *AuthMiddleware) RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*config.CustomClaims)
if !ok || claims == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
})
return
}
if claims.Role != role {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{
"error": "insufficient permissions",
})
return
}
next.ServeHTTP(w, r)
})
}
}
func (am *AuthMiddleware) ProtectedRoute(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")

View File

@@ -0,0 +1,63 @@
package nutrition
import (
"encoding/json"
"net/http"
"rideaware/internal/config"
"rideaware/internal/middleware"
)
type Handler struct {
service *Service
}
func NewHandler() *Handler {
return &Handler{
service: NewService(),
}
}
// GetTargets GET /api/protected/nutrition/targets
func (h *Handler) GetTargets(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
targets, err := h.service.GetTargets(claims.UserID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to calculate targets")
return
}
respondJSON(w, http.StatusOK, targets)
}
// GetWeekly GET /api/protected/nutrition/weekly
func (h *Handler) GetWeekly(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
respondError(w, http.StatusUnauthorized, "unauthorized")
return
}
weekly, err := h.service.GetWeeklyNutrition(claims.UserID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to calculate weekly nutrition")
return
}
respondJSON(w, http.StatusOK, weekly)
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}

View File

@@ -0,0 +1,290 @@
package nutrition
import (
"math"
"time"
"rideaware/internal/stats"
"rideaware/internal/user"
"rideaware/internal/workout"
"rideaware/pkg/database"
)
type Service struct {
userRepo *user.Repository
statsRepo *stats.Repository
workoutRepo *workout.Repository
}
func NewService() *Service {
return &Service{
userRepo: user.NewRepository(),
statsRepo: stats.NewRepository(),
workoutRepo: workout.NewRepository(),
}
}
// NutritionTargets contains calculated daily nutrition targets
type NutritionTargets struct {
BMR int `json:"bmr"`
TDEE int `json:"tdee"`
DailyCalories int `json:"daily_calories"`
GoalAdjustment int `json:"goal_adjustment"`
Protein int `json:"protein_g"`
Carbs int `json:"carbs_g"`
Fat int `json:"fat_g"`
NutritionGoal string `json:"nutrition_goal"`
DietaryPref string `json:"dietary_preference"`
CurrentWeight float64 `json:"current_weight"`
TargetWeight float64 `json:"target_weight"`
IsConfigured bool `json:"is_configured"`
}
// DailyNutrition represents one day's nutrition data
type DailyNutrition struct {
Date string `json:"date"`
BaseCalories int `json:"base_calories"`
WorkoutCalories int `json:"workout_calories"`
TotalTarget int `json:"total_target"`
Protein int `json:"protein_g"`
Carbs int `json:"carbs_g"`
Fat int `json:"fat_g"`
IsTrainingDay bool `json:"is_training_day"`
WorkoutTitle string `json:"workout_title,omitempty"`
}
// WeeklyNutrition contains a week of daily nutrition data
type WeeklyNutrition struct {
Days []DailyNutrition `json:"days"`
AvgCalories int `json:"avg_calories"`
AvgProtein int `json:"avg_protein_g"`
AvgCarbs int `json:"avg_carbs_g"`
AvgFat int `json:"avg_fat_g"`
TotalWorkoutCal int `json:"total_workout_calories"`
}
// CalculateBMR uses Mifflin-St Jeor equation
func CalculateBMR(weight, height float64, age int, gender string) int {
if weight <= 0 || height <= 0 || age <= 0 {
return 0
}
bmr := 10.0*weight + 6.25*height - 5.0*float64(age)
if gender == "female" {
bmr -= 161
} else {
bmr += 5
}
return int(math.Round(bmr))
}
// CalculateTDEE applies activity multiplier to BMR
func CalculateTDEE(bmr int, activityLevel string) int {
multipliers := map[string]float64{
"sedentary": 1.2,
"lightly_active": 1.375,
"active": 1.55,
"very_active": 1.725,
}
mult, ok := multipliers[activityLevel]
if !ok {
mult = 1.375 // default to lightly active
}
return int(math.Round(float64(bmr) * mult))
}
// goalAdjustment returns calorie adjustment for the goal
func goalAdjustment(goal string) int {
switch goal {
case "weight_loss":
return -500
case "performance":
return 300
default: // maintenance
return 0
}
}
// macroSplit returns protein/carbs/fat percentages based on dietary preference
func macroSplit(pref string) (protPct, carbPct, fatPct float64) {
switch pref {
case "high_carb":
return 0.20, 0.60, 0.20
case "high_protein":
return 0.35, 0.40, 0.25
case "keto":
return 0.30, 0.10, 0.60
default: // balanced
return 0.25, 0.50, 0.25
}
}
// calculateMacros converts calories to grams of each macro
func calculateMacros(calories int, pref string) (protein, carbs, fat int) {
protPct, carbPct, fatPct := macroSplit(pref)
protein = int(math.Round(float64(calories) * protPct / 4.0)) // 4 cal/g
carbs = int(math.Round(float64(calories) * carbPct / 4.0)) // 4 cal/g
fat = int(math.Round(float64(calories) * fatPct / 9.0)) // 9 cal/g
return
}
// GetTargets calculates nutrition targets for a user
func (s *Service) GetTargets(userID uint) (*NutritionTargets, error) {
userObj, err := s.userRepo.GetUserByID(userID)
if err != nil {
return nil, err
}
profile := userObj.Profile
if profile == nil {
return &NutritionTargets{IsConfigured: false}, nil
}
// Check if nutrition is configured
if profile.Height <= 0 || profile.Age <= 0 || profile.Weight <= 0 || profile.Gender == "" {
return &NutritionTargets{
IsConfigured: false,
CurrentWeight: profile.Weight,
NutritionGoal: profile.NutritionGoal,
}, nil
}
bmr := CalculateBMR(profile.Weight, profile.Height, profile.Age, profile.Gender)
activityLevel := profile.ActivityLevel
if activityLevel == "" {
activityLevel = "lightly_active"
}
tdee := CalculateTDEE(bmr, activityLevel)
goal := profile.NutritionGoal
if goal == "" {
goal = "maintenance"
}
adj := goalAdjustment(goal)
dailyCal := tdee + adj
dietPref := profile.DietaryPref
if dietPref == "" {
dietPref = "balanced"
}
protein, carbs, fat := calculateMacros(dailyCal, dietPref)
return &NutritionTargets{
BMR: bmr,
TDEE: tdee,
DailyCalories: dailyCal,
GoalAdjustment: adj,
Protein: protein,
Carbs: carbs,
Fat: fat,
NutritionGoal: goal,
DietaryPref: dietPref,
CurrentWeight: profile.Weight,
TargetWeight: profile.TargetWeight,
IsConfigured: true,
}, nil
}
// GetWeeklyNutrition returns 7 days of nutrition data with workout adjustments
func (s *Service) GetWeeklyNutrition(userID uint) (*WeeklyNutrition, error) {
targets, err := s.GetTargets(userID)
if err != nil {
return nil, err
}
if !targets.IsConfigured {
return &WeeklyNutrition{Days: []DailyNutrition{}}, nil
}
// Get workouts for the next 7 days
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endDate := today.AddDate(0, 0, 7)
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, today, endDate)
if err != nil {
workouts = []workout.Workout{}
}
// Also get past 7 days for completed workout calories
pastStart := today.AddDate(0, 0, -7)
pastWorkouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, pastStart, today)
if err != nil {
pastWorkouts = []workout.Workout{}
}
allWorkouts := append(pastWorkouts, workouts...)
// Build workout map by date
workoutMap := make(map[string][]workout.Workout)
for _, w := range allWorkouts {
dateStr := w.ScheduledDate.Format("2006-01-02")
workoutMap[dateStr] = append(workoutMap[dateStr], w)
}
// Get user FTP for calorie estimation
var ftp int
database.DB.Table("user_profiles").Select("ftp").Where("user_id = ?", userID).Scan(&ftp)
days := make([]DailyNutrition, 0, 7)
totalWorkoutCal := 0
for i := 0; i < 7; i++ {
date := today.AddDate(0, 0, i)
dateStr := date.Format("2006-01-02")
dayWorkouts := workoutMap[dateStr]
workoutCal := 0
isTraining := false
title := ""
for _, w := range dayWorkouts {
if w.CaloriesBurned > 0 {
workoutCal += w.CaloriesBurned
} else if w.Duration > 0 && ftp > 0 && w.AvgPower > 0 {
// Estimate: ~4 cal per kJ, kJ = watts * seconds / 1000
kj := float64(w.AvgPower) * float64(w.Duration) / 1000.0
workoutCal += int(kj * 4.0)
} else if w.Duration > 0 {
// Rough estimate: ~8 cal/min for cycling
workoutCal += (w.Duration / 60) * 8
}
isTraining = true
if title == "" {
title = w.Title
}
}
totalTarget := targets.DailyCalories + workoutCal
protein, carbs, fat := calculateMacros(totalTarget, targets.DietaryPref)
days = append(days, DailyNutrition{
Date: dateStr,
BaseCalories: targets.DailyCalories,
WorkoutCalories: workoutCal,
TotalTarget: totalTarget,
Protein: protein,
Carbs: carbs,
Fat: fat,
IsTrainingDay: isTraining,
WorkoutTitle: title,
})
totalWorkoutCal += workoutCal
}
// Calculate averages
avgCal, avgProt, avgCarb, avgFat := 0, 0, 0, 0
for _, d := range days {
avgCal += d.TotalTarget
avgProt += d.Protein
avgCarb += d.Carbs
avgFat += d.Fat
}
return &WeeklyNutrition{
Days: days,
AvgCalories: avgCal / 7,
AvgProtein: avgProt / 7,
AvgCarbs: avgCarb / 7,
AvgFat: avgFat / 7,
TotalWorkoutCal: totalWorkoutCal,
}, nil
}

View File

@@ -110,6 +110,73 @@ func (h *Handler) GetMonthlyStats(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(stats)
}
// GetTrainingLoad GET /api/protected/stats/training-load?days=180
func (h *Handler) GetTrainingLoad(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
}
days := 180
if dStr := r.URL.Query().Get("days"); dStr != "" {
if parsed, err := strconv.Atoi(dStr); err == nil {
days = parsed
}
}
data, ftp, err := h.service.GetTrainingLoad(claims.UserID, days)
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 training load"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ftp": ftp,
"daily_tss": data,
})
}
// GetPowerHistory GET /api/protected/stats/power-history?days=365
func (h *Handler) GetPowerHistory(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
}
days := 365
if dStr := r.URL.Query().Get("days"); dStr != "" {
if parsed, err := strconv.Atoi(dStr); err == nil {
days = parsed
}
}
data, err := h.service.GetPowerHistory(claims.UserID, days)
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 power history"})
return
}
if data == nil {
data = []PowerPoint{}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
// GetPersonalBests GET /api/protected/stats/personal-bests
func (h *Handler) GetPersonalBests(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)

View File

@@ -43,6 +43,21 @@ type PeriodStats struct {
AvgHR float64 `json:"avg_hr"`
}
// DailyTSS holds the computed TSS for a single day.
type DailyTSS struct {
Date string `json:"date"`
TSS float64 `json:"tss"`
}
// PowerPoint holds power data for a single completed workout.
type PowerPoint struct {
Date string `json:"date"`
AvgPower int `json:"avg_power"`
MaxPower int `json:"max_power"`
Duration int `json:"duration"`
Title string `json:"title"`
}
// GetSummary returns overall ride statistics for completed workouts.
func (r *Repository) GetSummary(userID uint) (*Summary, error) {
var summary Summary
@@ -120,6 +135,65 @@ func (r *Repository) GetMonthlyStats(userID uint, months int) ([]PeriodStats, er
return stats, err
}
// GetDailyTSS returns daily TSS values for the last N days.
// TSS = (duration_seconds * (avg_power / FTP)^2 / 3600) * 100
// Simplified: (duration * avg_power^2) / (FTP^2 * 36)
func (r *Repository) GetDailyTSS(userID uint, ftp int, days int) ([]DailyTSS, error) {
if ftp <= 0 {
return []DailyTSS{}, nil
}
cutoff := time.Now().AddDate(0, 0, -days)
ftpFloat := float64(ftp)
var results []DailyTSS
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
COALESCE(SUM(
CASE WHEN avg_power > 0 AND duration > 0
THEN (duration::float8 * avg_power::float8 * avg_power::float8) / (? * ? * 36.0)
ELSE 0
END
), 0) as tss
`, ftpFloat, ftpFloat).
Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
Group("date").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []DailyTSS{}
}
return results, err
}
// GetPowerHistory returns power data points for completed workouts in the last N days.
func (r *Repository) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
cutoff := time.Now().AddDate(0, 0, -days)
var results []PowerPoint
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
avg_power,
max_power,
duration,
title
`).
Where("user_id = ? AND status = ? AND avg_power > 0 AND scheduled_date >= ?",
userID, "completed", cutoff).
Order("scheduled_date ASC").
Scan(&results).Error
if results == nil {
results = []PowerPoint{}
}
return results, err
}
// PersonalBest holds a single personal best record.
type PersonalBest struct {
Category string `json:"category"`

View File

@@ -1,5 +1,9 @@
package stats
import (
"rideaware/pkg/database"
)
type Service struct {
repo *Repository
}
@@ -31,3 +35,37 @@ func (s *Service) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error
func (s *Service) GetPersonalBests(userID uint) ([]PersonalBest, error) {
return s.repo.GetPersonalBests(userID)
}
// getUserFTP retrieves the user's FTP from the user_profiles table.
func (s *Service) getUserFTP(userID uint) int {
var ftp int
database.DB.Table("user_profiles").
Where("user_id = ?", userID).
Select("ftp").
Scan(&ftp)
return ftp
}
// GetTrainingLoad returns daily TSS data and the user's FTP.
func (s *Service) GetTrainingLoad(userID uint, days int) ([]DailyTSS, int, error) {
if days <= 0 || days > 730 {
days = 180
}
ftp := s.getUserFTP(userID)
data, err := s.repo.GetDailyTSS(userID, ftp, days)
if err != nil {
return nil, 0, err
}
return data, ftp, nil
}
// GetPowerHistory returns power data points for completed workouts.
func (s *Service) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
if days <= 0 || days > 730 {
days = 365
}
return s.repo.GetPowerHistory(userID, days)
}

View File

@@ -35,8 +35,6 @@ func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("DEBUG GetProfile: User ID=%d, Profile=%+v", user.ID, user.Profile)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GetProfileResponse{
User: user,
@@ -55,6 +53,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
MaxHR int `json:"max_hr"`
RestingHR int `json:"resting_hr"`
Weight float64 `json:"weight"`
Height float64 `json:"height"`
Age int `json:"age"`
Gender string `json:"gender"`
NutritionGoal string `json:"nutrition_goal"`
TargetWeight float64 `json:"target_weight"`
ActivityLevel string `json:"activity_level"`
DietaryPref string `json:"dietary_preference"`
Units string `json:"units"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -72,8 +78,6 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("DEBUG UpdateProfile: Before - Profile=%+v", user.Profile)
if user.Profile != nil {
user.Profile.FirstName = req.FirstName
user.Profile.LastName = req.LastName
@@ -82,11 +86,19 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user.Profile.MaxHR = req.MaxHR
user.Profile.RestingHR = req.RestingHR
user.Profile.Weight = req.Weight
log.Printf("DEBUG UpdateProfile: After - Profile=%+v", user.Profile)
user.Profile.Height = req.Height
user.Profile.Age = req.Age
user.Profile.Gender = req.Gender
user.Profile.NutritionGoal = req.NutritionGoal
user.Profile.TargetWeight = req.TargetWeight
user.Profile.ActivityLevel = req.ActivityLevel
user.Profile.DietaryPref = req.DietaryPref
if req.Units == "imperial" || req.Units == "metric" {
user.Profile.Units = req.Units
}
if err := h.service.UpdateUser(user); err != nil {
log.Printf("DEBUG UpdateProfile: Error updating - %v", err)
log.Printf("UpdateProfile: error saving - %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to update profile"})
@@ -95,7 +107,7 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user, err = h.service.GetUserByID(claims.UserID)
if err != nil {
log.Printf("DEBUG UpdateProfile: Error reloading - %v", err)
log.Printf("UpdateProfile: error reloading user - %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to load profile"})
@@ -103,8 +115,6 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
}
log.Printf("DEBUG UpdateProfile: Final - Profile=%+v", user.Profile)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GetProfileResponse{
User: user,

View File

@@ -13,6 +13,7 @@ type User struct {
Username string `gorm:"uniqueIndex;not null" json:"username"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `gorm:"not null" json:"-"`
Role string `gorm:"default:'athlete'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -33,6 +34,14 @@ type Profile struct {
MaxHR int `gorm:"default:0" json:"max_hr"`
FTP int `gorm:"default:0" json:"ftp"`
Weight float64 `gorm:"default:0" json:"weight"`
Height float64 `gorm:"default:0" json:"height"` // cm
Age int `gorm:"default:0" json:"age"`
Gender string `gorm:"default:''" json:"gender"` // male, female
NutritionGoal string `gorm:"default:''" json:"nutrition_goal"` // weight_loss, maintenance, performance
TargetWeight float64 `gorm:"default:0" json:"target_weight"` // kg
ActivityLevel string `gorm:"default:''" json:"activity_level"` // sedentary, lightly_active, active, very_active
DietaryPref string `gorm:"default:''" json:"dietary_preference"` // balanced, high_carb, high_protein, keto
Units string `gorm:"default:'metric'" json:"units"` // metric, imperial
TotalRides int `gorm:"default:0" json:"total_rides"`
TotalDistance float64 `gorm:"default:0" json:"total_distance"`
TotalTime int `gorm:"default:0" json:"total_time"`

View File

@@ -61,8 +61,6 @@ func (r *Repository) GetUserByID(id uint) (*User, error) {
user.Profile = &profile
}
log.Printf("DEBUG: Loaded user %d, profile ID=%d, profile=%+v", id, profile.ID, user.Profile)
return &user, nil
}

View File

@@ -27,15 +27,18 @@ func NewService() *Service {
}
func (s *Service) CreateUser(username, password, email, firstName, lastName string) (*User, error) {
if username == "" || password == "" {
return nil, errors.New("username and password are required")
if username == "" || password == "" || email == "" {
return nil, errors.New("username, password, and email are required")
}
// Username: 3-30 chars, alphanumeric + underscores/hyphens, must start with a letter
if !isValidUsername(username) {
return nil, errors.New("username must be 3-30 characters, start with a letter, and contain only letters, numbers, underscores, or hyphens")
}
if email != "" {
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
}
exists, err := s.repo.UserExists(username, email)
if err != nil {
@@ -158,6 +161,14 @@ func (s *Service) UpdateUser(user *User) error {
return s.repo.UpdateUser(user)
}
func isValidUsername(username string) bool {
if len(username) < 3 || len(username) > 30 {
return false
}
regex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{2,29}$`)
return regex.MatchString(username)
}
func isValidEmail(email string) bool {
regex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return regex.MatchString(email)

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"time"
"rideaware/internal/config"
@@ -43,6 +44,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
ScheduledDate string `json:"scheduled_date"`
Duration int `json:"duration"`
Notes string `json:"notes"`
Tags Tags `json:"tags"`
WorkoutData *WorkoutDataJSON `json:"workout_data"`
FileType string `json:"file_type"`
EquipmentID *uint `json:"equipment_id"`
@@ -105,6 +107,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
ScheduledDate: scheduledDate,
Duration: req.Duration,
Notes: req.Notes,
Tags: req.Tags,
FileType: req.FileType,
WorkoutData: *workoutData,
EquipmentID: req.EquipmentID,
@@ -127,11 +130,25 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(workout)
}
// GetWorkouts GET /api/protected/workouts
// GetWorkouts GET /api/protected/workouts?tags=tag1,tag2
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)
tagsParam := r.URL.Query().Get("tags")
var workouts []Workout
var err error
if tagsParam != "" {
tags := strings.Split(tagsParam, ",")
// Trim whitespace from each tag
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
}
workouts, err = h.service.GetUserWorkoutsByTags(claims.UserID, tags)
} else {
workouts, err = h.service.GetUserWorkouts(claims.UserID)
}
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
@@ -212,8 +229,10 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
MaxPower int `json:"max_power"`
MaxHR int `json:"max_hr"`
CaloriesBurned int `json:"calories_burned"`
RPE int `json:"rpe"`
Notes string `json:"notes"`
EquipmentID *uint `json:"equipment_id"`
Tags Tags `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -267,12 +286,18 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
if req.CaloriesBurned > 0 {
workout.CaloriesBurned = req.CaloriesBurned
}
if req.RPE > 0 && req.RPE <= 10 {
workout.RPE = req.RPE
}
if req.Notes != "" {
workout.Notes = req.Notes
}
if req.EquipmentID != nil {
workout.EquipmentID = req.EquipmentID
}
if req.Tags != nil {
workout.Tags = req.Tags
}
if err := h.service.repo.UpdateWorkout(workout); err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -309,6 +334,74 @@ func (h *Handler) DeleteWorkout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RemoveDuplicates POST /api/protected/workouts/remove-duplicates
func (h *Handler) RemoveDuplicates(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
removed, err := h.service.RemoveDuplicates(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 remove duplicates"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int64{"removed": removed})
}
// RescheduleWorkout PUT /api/protected/workouts/reschedule
func (h *Handler) RescheduleWorkout(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 {
ScheduledDate string `json:"scheduled_date"`
}
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
}
newDate, 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 date format, use YYYY-MM-DD"})
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
}
workout.ScheduledDate = newDate
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 reschedule workout"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(workout)
}
// GetWorkoutTypes GET /api/protected/workout-types
func (h *Handler) GetWorkoutTypes(w http.ResponseWriter, r *http.Request) {
types := []map[string]interface{}{

View File

@@ -3,6 +3,8 @@ package workout
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
)
@@ -26,11 +28,46 @@ type Workout struct {
EquipmentID *uint `gorm:"index" json:"equipment_id"`
FileURL string `gorm:"default:''" json:"file_url"`
WorkoutData WorkoutDataJSON `gorm:"type:jsonb" json:"workout_data,omitempty"`
RPE int `gorm:"default:0" json:"rpe"`
Notes string `json:"notes"`
Tags Tags `gorm:"type:jsonb;default:'[]'" json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Tags is a JSONB array of string tags for workouts
type Tags []string
// Scan implements the sql.Scanner interface for reading from the database
func (t *Tags) Scan(value interface{}) error {
if value == nil {
*t = Tags{}
return nil
}
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, t)
case string:
return json.Unmarshal([]byte(v), t)
default:
return fmt.Errorf("unsupported type for Tags: %T", value)
}
}
// Value implements the driver.Valuer interface for writing to the database
func (t Tags) Value() (driver.Value, error) {
if t == nil {
return json.Marshal([]string{})
}
return json.Marshal(t)
}
// String returns a comma-separated string representation
func (t Tags) String() string {
return strings.Join(t, ", ")
}
type WorkoutDataJSON struct {
Name string `json:"name"`
Author string `json:"author"`

View File

@@ -39,6 +39,17 @@ func (r *Repository) GetUserWorkouts(userID uint) ([]Workout, error) {
return workouts, nil
}
func (r *Repository) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
var workouts []Workout
// Use PostgreSQL jsonb ?| operator to check if the tags array contains any of the given tags
if err := database.DB.Where("user_id = ? AND tags ?| ?", userID, tags).
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).
@@ -64,6 +75,33 @@ func (r *Repository) DeleteWorkout(id, userID uint) error {
Delete(&Workout{}).Error
}
func (r *Repository) RemoveDuplicates(userID uint) (int64, error) {
// Find IDs to keep: the minimum ID for each (title, scheduled_date, duration) group
// Delete all other workouts that are duplicates
result := database.DB.Exec(`
DELETE FROM workouts
WHERE user_id = ? AND id NOT IN (
SELECT MIN(id)
FROM workouts
WHERE user_id = ?
GROUP BY title, scheduled_date, duration
)
`, userID, userID)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *Repository) GetCompletedWorkoutOnDate(userID uint, date string) (*Workout, error) {
var w Workout
if err := database.DB.Where("user_id = ? AND status = 'completed' AND DATE(scheduled_date) = ?", userID, date).
First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}
type EquipmentStat struct {
EquipmentID uint `json:"equipment_id"`
TotalRides int `json:"total_rides"`

View File

@@ -39,6 +39,13 @@ func (s *Service) GetUserWorkouts(userID uint) ([]Workout, error) {
return s.repo.GetUserWorkouts(userID)
}
func (s *Service) GetUserWorkoutsByTags(userID uint, tags []string) ([]Workout, error) {
if len(tags) == 0 {
return s.repo.GetUserWorkouts(userID)
}
return s.repo.GetUserWorkoutsByTags(userID, tags)
}
func (s *Service) GetWorkoutsByMonth(userID uint, year, month int) ([]Workout, error) {
return s.repo.GetWorkoutsByMonth(userID, year, month)
}
@@ -87,6 +94,10 @@ func (s *Service) DeleteWorkout(id, userID uint) error {
return s.repo.DeleteWorkout(id, userID)
}
func (s *Service) RemoveDuplicates(userID uint) (int64, error) {
return s.repo.RemoveDuplicates(userID)
}
func (s *Service) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
return s.repo.GetEquipmentStats(userID)
}