diff --git a/TODO.md b/TODO.md
index f210933..ac4d71d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -14,15 +14,15 @@
- [ ] **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
-- [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength)
-- [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad
+- [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
-- [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX)
+- [x] **Device Capture**: File upload (FIT/TCX/GPX activity import with metric extraction)
- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used
-- [ ] **Equipment Tracking**: Bike/components mileage, service reminders
+- [x] **Equipment Tracking**: Bike/components mileage auto-tracking, service reminders
## Advanced Analytics
- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends
@@ -55,10 +55,10 @@
- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles
## Integrations & Data
-- [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit
+- [~] **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**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP
+- [~] **Data Import/Export**: FIT/TCX/GPX activity import implemented; CSV/JSON export & bulk import pending
- [ ] **Public API & Webhooks**: For partners, coaches, clubs
## Notifications & Comms
@@ -130,18 +130,97 @@
---
-## Next Phase: Phase 2 - User Profiles & Stats Endpoints
+## Completed - Phase 2: User Profiles, Equipment & Workouts ✅
-### Planned Features
-- [ ] GET/PUT `/api/protected/profile` - Full profile management
-- [ ] POST/GET `/api/equipment` - Bike/gear management
-- [ ] POST/GET `/api/stats` - Ride statistics
-- [ ] GET `/api/zones` - Calculate training zones (auto from FTP/HR)
-- [ ] Equipment tracking (brand, model, weight, mileage)
-- [ ] Stats aggregation and trending
+### Profile & Equipment (completed earlier)
+- [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
-### After Phase 2: Phase 3 - OAuth Integration
-- [ ] Google OAuth 2.0
-- [ ] Strava API integration
+### 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] 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 ✅
+
+### Workout Export
+- [x] GET `/api/protected/workouts/export/fit` - FIT workout file export (Garmin-compatible)
+- [x] GET `/api/protected/workouts/export/zwo` - ZWO file export (Zwift-compatible)
+- [x] Segment-to-FIT mapping (warmup/steady/interval/cooldown/ramp/freeride)
+- [x] Power targets converted from %FTP to absolute watts for device display
+- [x] `github.com/muktihari/fit` library integration for FIT encoding
+
+### OAuth Infrastructure
+- [x] OAuthConnection model with AES-256-GCM token encryption
+- [x] OAuthState model for CSRF protection during OAuth flows
+- [x] Shared OAuth service (state management, PKCE, token exchange, auto-refresh)
+- [x] OAuth config loader from environment variables
+
+### Garmin Connect Integration
+- [x] GET `/api/protected/garmin/auth` - OAuth2 PKCE flow initiation
+- [x] GET `/api/garmin/callback` - OAuth callback handler
+- [x] POST `/api/protected/workouts/push/garmin` - Push workout to Garmin Connect
+- [x] GET `/api/protected/garmin/status` - Connection status check
+- [x] DELETE `/api/protected/garmin/disconnect` - Revoke connection
+
+### Wahoo Cloud API Integration
+- [x] GET `/api/protected/wahoo/auth` - OAuth2 flow initiation
+- [x] GET `/api/wahoo/callback` - OAuth callback handler
+- [x] POST `/api/protected/workouts/push/wahoo` - Push workout as Wahoo plan
+- [x] GET `/api/protected/wahoo/status` - Connection status check
+- [x] DELETE `/api/protected/wahoo/disconnect` - Revoke connection
+
+---
+
+## 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)
+- [x] FIT activity parser using `muktihari/fit` decoder (session-level metrics)
+- [x] TCX activity parser (lap aggregation, trackpoint elevation gain)
+- [x] GPX activity parser (Haversine distance, elevation gain, extension parsing)
+- [x] Extracts: duration, distance, avg/max power, avg/max HR, elevation gain, calories, cadence
+- [x] Can create new completed workout or update existing planned workout with actual data
+- [x] Supports optional equipment_id assignment on import
+
+### Equipment Mileage & Service Tracking
+- [x] Auto-increment equipment mileage when activities are imported with equipment assigned
+- [x] Total distance (km), total duration (seconds), total rides tracked per equipment
+- [x] Service interval configuration (distance-based and/or duration-based)
+- [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
+
+### OAuth Login
+- [ ] Google OAuth 2.0 (sign in with Google)
- [ ] Apple Sign-In
-- [ ] Garmin Connect
\ No newline at end of file
+- [ ] Strava OAuth (sign in + activity sync)
+
+### Platform Sync
+- [ ] Strava activity sync (import completed rides)
+- [ ] TrainingPeaks calendar sync
+- [ ] Intervals.icu integration
\ No newline at end of file
diff --git a/cmd/server/Untitled-1.dockerfile b/cmd/server/Untitled-1.dockerfile
new file mode 100644
index 0000000..82426bc
--- /dev/null
+++ b/cmd/server/Untitled-1.dockerfile
@@ -0,0 +1,195 @@
+#!/bin/bash
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Default values
+IMAGE_NAME="rideaware"
+IMAGE_TAG="latest"
+NO_CACHE=false
+RUN_CONTAINER=false
+CONTAINER_NAME="rideaware-api"
+HOST_PORT="5010"
+CONTAINER_PORT="5010"
+
+# Help function
+show_help() {
+ cat << EOF
+Usage: $0 [OPTIONS]
+
+OPTIONS:
+ -t, --tag TAG Image tag (default: latest)
+ -n, --name NAME Image name (default: rideaware)
+ -r, --run Run container after build
+ -c, --container NAME Container name when running (default: rideaware-api)
+ -p, --port PORT Host port mapping (default: 5010)
+ Format: HOST:CONTAINER or just HOST (uses same for container)
+ --no-cache Build without cache
+ -h, --help Show this help message
+
+EXAMPLES:
+ $0 # Build as rideaware:latest
+ $0 -t v1.0 # Build as rideaware:v1.0
+ $0 -t dev --run # Build and run on port 5010
+ $0 -t dev --run -p 5010 # Build and run on port 5010
+ $0 -t dev --run -p 5010:5010 # Map host 5010 to container 5000
+ $0 --no-cache -t prod # Build without cache as rideaware:prod
+
+EOF
+ exit 0
+}
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -t|--tag)
+ IMAGE_TAG="$2"
+ shift 2
+ ;;
+ -n|--name)
+ IMAGE_NAME="$2"
+ shift 2
+ ;;
+ -r|--run)
+ RUN_CONTAINER=true
+ shift
+ ;;
+ -c|--container)
+ CONTAINER_NAME="$2"
+ shift 2
+ ;;
+ -p|--port)
+ PORT_MAPPING="$2"
+ # Parse port mapping
+ if [[ $PORT_MAPPING == *":"* ]]; then
+ HOST_PORT="${PORT_MAPPING%%:*}"
+ CONTAINER_PORT="${PORT_MAPPING##*:}"
+ else
+ HOST_PORT="$PORT_MAPPING"
+ CONTAINER_PORT="$PORT_MAPPING"
+ fi
+ shift 2
+ ;;
+ --no-cache)
+ NO_CACHE=true
+ shift
+ ;;
+ -h|--help)
+ show_help
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ show_help
+ ;;
+ esac
+done
+
+FULL_IMAGE="$IMAGE_NAME:$IMAGE_TAG"
+BUILD_ARGS=""
+
+if [ "$NO_CACHE" = true ]; then
+ BUILD_ARGS="--no-cache"
+fi
+
+# Function to stop and remove container
+cleanup_container() {
+ local name=$1
+
+ if podman ps -a --format "{{.Names}}" | grep -q "^${name}\$"; then
+ echo -e "${YELLOW}Removing existing container: $name${NC}"
+
+ # Stop if running
+ if podman ps --format "{{.Names}}" | grep -q "^${name}\$"; then
+ echo " Stopping container..."
+ podman kill "$name" 2>/dev/null || true
+ fi
+
+ # Remove
+ echo " Removing container..."
+ if podman rm "$name" 2>/dev/null; then
+ echo -e "${GREEN} ✓ Container removed${NC}"
+ else
+ echo -e "${RED} ✗ Failed to remove container${NC}"
+ return 1
+ fi
+ fi
+ return 0
+}
+
+echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
+echo -e "${BLUE}║ Building Podman Image ║${NC}"
+echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
+echo -e "${YELLOW}Image: $FULL_IMAGE${NC}"
+echo ""
+
+if ! podman build $BUILD_ARGS -f docker/Dockerfile -t "$FULL_IMAGE" .; then
+ echo -e "${RED}✗ Build failed${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}✓ Image built successfully${NC}"
+echo ""
+
+# Show image info
+echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
+echo -e "${BLUE}║ Image Details ║${NC}"
+echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
+podman images "$IMAGE_NAME:$IMAGE_TAG" \
+ --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Created}}"
+echo ""
+
+if [ "$RUN_CONTAINER" = true ]; then
+ echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
+ echo -e "${BLUE}║ Starting Container ║${NC}"
+ echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
+
+ # Cleanup existing container FIRST (before checking port)
+ if ! cleanup_container "$CONTAINER_NAME"; then
+ echo -e "${RED}✗ Failed to clean up existing container${NC}"
+ exit 1
+ fi
+
+ echo ""
+ echo "Starting new container: $CONTAINER_NAME"
+ echo "Port mapping: $HOST_PORT:$CONTAINER_PORT"
+
+ if podman run -d \
+ --name "$CONTAINER_NAME" \
+ -e PORT="$CONTAINER_PORT" \
+ -p "$HOST_PORT:$CONTAINER_PORT" \
+ --env-file .env \
+ "$FULL_IMAGE"; then
+ echo -e "${GREEN}✓ Container running: $CONTAINER_NAME${NC}"
+ echo ""
+
+ # Wait for startup
+ sleep 2
+
+ echo -e "${YELLOW}Container logs:${NC}"
+ podman logs "$CONTAINER_NAME"
+ echo ""
+
+ echo -e "${GREEN}API available at: http://localhost:$HOST_PORT${NC}"
+ echo -e "${YELLOW}To view logs: podman logs -f $CONTAINER_NAME${NC}"
+ echo -e "${YELLOW}To stop: podman kill $CONTAINER_NAME${NC}"
+ echo -e "${YELLOW}To remove: podman rm $CONTAINER_NAME${NC}"
+ else
+ echo -e "${RED}✗ Failed to start container${NC}"
+ exit 1
+ fi
+else
+ echo -e "${YELLOW}To run the container:${NC}"
+ echo " podman run -d --name $CONTAINER_NAME -e PORT=$CONTAINER_PORT -p $HOST_PORT:$CONTAINER_PORT --env-file .env $FULL_IMAGE"
+ echo ""
+ echo -e "${YELLOW}Or use this script with --run:${NC}"
+ echo " $0 -t $IMAGE_TAG --run -p $HOST_PORT"
+fi
+
+echo ""
+echo -e "${GREEN}✓ Done!${NC}"
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 9d4221e..ed52529 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -10,10 +10,15 @@ import (
"github.com/go-chi/cors"
"github.com/joho/godotenv"
+ "rideaware/internal/activity"
"rideaware/internal/auth"
"rideaware/internal/config"
"rideaware/internal/equipment"
+ "rideaware/internal/export"
+ "rideaware/internal/integration"
"rideaware/internal/middleware"
+ "rideaware/internal/stats"
+ "rideaware/internal/templates"
"rideaware/internal/user"
"rideaware/internal/workout"
"rideaware/pkg/database"
@@ -34,6 +39,8 @@ func main() {
&user.Session{},
&equipment.Equipment{},
&workout.Workout{},
+ &integration.OAuthConnection{},
+ &integration.OAuthState{},
); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
@@ -41,6 +48,9 @@ func main() {
// Initialize JWT config
config.InitJWT()
+ // Initialize OAuth config
+ config.InitOAuth()
+
r := chi.NewRouter()
// Logging middleware
@@ -107,6 +117,12 @@ func setupRoutes(r *chi.Mux) {
r.Post("/api/password-reset/confirm", authHandler.ConfirmPasswordReset)
r.Post("/api/refresh-token", authHandler.RefreshToken)
+ // OAuth callbacks (public - called by provider redirects)
+ garminHandler := integration.NewGarminHandler()
+ wahooHandler := integration.NewWahooHandler()
+ r.Get("/api/garmin/callback", garminHandler.Callback)
+ r.Get("/api/wahoo/callback", wahooHandler.Callback)
+
// Protected routes
authMiddleware := middleware.NewAuthMiddleware()
r.Route("/api/protected", func(r chi.Router) {
@@ -124,6 +140,10 @@ func setupRoutes(r *chi.Mux) {
r.Put("/equipment", equipmentHandler.UpdateEquipment)
r.Delete("/equipment", equipmentHandler.DeleteEquipment)
+ // Equipment service tracking
+ r.Post("/equipment/service", equipmentHandler.RecordService)
+ r.Get("/equipment/service-status", equipmentHandler.GetServiceStatus)
+
// Training zones
r.Get("/zones", equipmentHandler.GetTrainingZones)
@@ -132,10 +152,45 @@ func setupRoutes(r *chi.Mux) {
r.Post("/workouts", workoutHandler.CreateWorkout)
r.Get("/workouts", workoutHandler.GetWorkouts)
r.Get("/workouts/month", workoutHandler.GetWorkoutsByMonth)
+ r.Get("/workouts/equipment-stats", workoutHandler.GetEquipmentStats)
r.Put("/workouts", workoutHandler.UpdateWorkout)
r.Delete("/workouts", workoutHandler.DeleteWorkout)
r.Get("/workout-types", workoutHandler.GetWorkoutTypes)
r.Post("/workouts/upload", workoutHandler.UploadWorkoutFile)
+
+ // Activity import (FIT/TCX/GPX)
+ activityHandler := activity.NewHandler()
+ r.Post("/workouts/import", activityHandler.ImportActivity)
+
+ // Workout export routes
+ exportHandler := export.NewHandler()
+ r.Get("/workouts/export/fit", exportHandler.ExportFIT)
+ r.Get("/workouts/export/zwo", exportHandler.ExportZWO)
+
+ // Garmin integration routes
+ r.Get("/garmin/auth", garminHandler.StartAuth)
+ r.Post("/workouts/push/garmin", garminHandler.PushWorkout)
+ r.Get("/garmin/status", garminHandler.ConnectionStatus)
+ r.Delete("/garmin/disconnect", garminHandler.Disconnect)
+
+ // Wahoo integration routes
+ r.Get("/wahoo/auth", wahooHandler.StartAuth)
+ r.Post("/workouts/push/wahoo", wahooHandler.PushWorkout)
+ r.Get("/wahoo/status", wahooHandler.ConnectionStatus)
+ r.Delete("/wahoo/disconnect", wahooHandler.Disconnect)
+
+ // 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)
+
+ // 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)
})
log.Println("✅ Routes registered successfully")
diff --git a/go.mod b/go.mod
index 13e56c0..38e0b4c 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1
- github.com/resend/resend-go/v2 v2.7.0
+ github.com/muktihari/fit v0.27.1
golang.org/x/crypto v0.17.0
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde
@@ -19,5 +19,5 @@ require (
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- golang.org/x/text v0.14.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
)
diff --git a/go.sum b/go.sum
index 9007410..23f0290 100644
--- a/go.sum
+++ b/go.sum
@@ -7,6 +7,8 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -19,19 +21,19 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/muktihari/fit v0.27.1 h1:s07/EPZ65uSaEuUeO4uzZZQ/9J5T2lnvOpTq0s9nqrQ=
+github.com/muktihari/fit v0.27.1/go.mod h1:IpJBARWj43cK7uFfDGqtRkKGxtWpg4N2m+wL5RI7/no=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/resend/resend-go/v2 v2.7.0 h1:yEze1zXRmcWVnCPXBy95bexkOTkP1ZyYnBIIJXgeNtI=
-github.com/resend/resend-go/v2 v2.7.0/go.mod h1:ihnxc7wPpSgans8RV8d8dIF4hYWVsqMK5KxXAr9LIos=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/activity/fit_parser.go b/internal/activity/fit_parser.go
new file mode 100644
index 0000000..1cc623d
--- /dev/null
+++ b/internal/activity/fit_parser.go
@@ -0,0 +1,94 @@
+package activity
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/muktihari/fit/decoder"
+ "github.com/muktihari/fit/profile/basetype"
+ "github.com/muktihari/fit/profile/mesgdef"
+ "github.com/muktihari/fit/profile/typedef"
+)
+
+// ParseFIT decodes a FIT activity file and extracts session-level metrics.
+func ParseFIT(data []byte) (*ParsedActivity, error) {
+ dec := decoder.New(bytes.NewReader(data))
+
+ if !dec.Next() {
+ return nil, fmt.Errorf("empty or invalid FIT file")
+ }
+
+ fit, err := dec.Decode()
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode FIT file: %w", err)
+ }
+
+ result := &ParsedActivity{}
+ foundSession := false
+
+ for i := range fit.Messages {
+ if fit.Messages[i].Num != typedef.MesgNumSession {
+ continue
+ }
+
+ foundSession = true
+ session := mesgdef.NewSession(&fit.Messages[i])
+
+ // total_elapsed_time: FIT stores as uint32 with scale 1000 (ms -> seconds)
+ if session.TotalElapsedTime != basetype.Uint32Invalid {
+ result.Duration = int(session.TotalElapsedTime / 1000)
+ }
+
+ // total_distance: FIT stores as uint32 with scale 100 (centimeters -> meters)
+ // Convert to km for our model
+ if session.TotalDistance != basetype.Uint32Invalid {
+ result.Distance = float64(session.TotalDistance) / 100.0 / 1000.0
+ }
+
+ // Power (no scale)
+ if session.AvgPower != basetype.Uint16Invalid {
+ result.AvgPower = int(session.AvgPower)
+ }
+ if session.MaxPower != basetype.Uint16Invalid {
+ result.MaxPower = int(session.MaxPower)
+ }
+
+ // Heart rate (no scale)
+ if session.AvgHeartRate != basetype.Uint8Invalid {
+ result.AvgHR = int(session.AvgHeartRate)
+ }
+ if session.MaxHeartRate != basetype.Uint8Invalid {
+ result.MaxHR = int(session.MaxHeartRate)
+ }
+
+ // Calories (no scale)
+ if session.TotalCalories != basetype.Uint16Invalid {
+ result.CaloriesBurned = int(session.TotalCalories)
+ }
+
+ // Elevation gain (no scale, meters)
+ if session.TotalAscent != basetype.Uint16Invalid {
+ result.ElevGain = int(session.TotalAscent)
+ }
+
+ // Cadence (no scale)
+ if session.AvgCadence != basetype.Uint8Invalid {
+ result.AvgCadence = int(session.AvgCadence)
+ }
+
+ // Start time
+ if session.StartTime.IsZero() {
+ result.StartTime = session.Timestamp
+ } else {
+ result.StartTime = session.StartTime
+ }
+
+ break // use first session
+ }
+
+ if !foundSession {
+ return nil, fmt.Errorf("no session data found in FIT file")
+ }
+
+ return result, nil
+}
diff --git a/internal/activity/gpx_parser.go b/internal/activity/gpx_parser.go
new file mode 100644
index 0000000..e03b960
--- /dev/null
+++ b/internal/activity/gpx_parser.go
@@ -0,0 +1,251 @@
+package activity
+
+import (
+ "encoding/xml"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GPX XML structures
+type gpxFile struct {
+ Metadata gpxMetadata `xml:"metadata"`
+ Tracks []gpxTrack `xml:"trk"`
+}
+
+type gpxMetadata struct {
+ Name string `xml:"name"`
+ Time string `xml:"time"`
+}
+
+type gpxTrack struct {
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ Segments []gpxTrackSeg `xml:"trkseg"`
+}
+
+type gpxTrackSeg struct {
+ Points []gpxTrackPoint `xml:"trkpt"`
+}
+
+type gpxTrackPoint struct {
+ Lat float64 `xml:"lat,attr"`
+ Lon float64 `xml:"lon,attr"`
+ Elevation float64 `xml:"ele"`
+ Time string `xml:"time"`
+ Extensions gpxTPExtensions `xml:"extensions"`
+}
+
+// gpxTPExtensions captures the raw inner XML of extensions for flexible parsing.
+type gpxTPExtensions struct {
+ InnerXML string `xml:",innerxml"`
+}
+
+// ParseGPX parses a GPX activity file, computing metrics from trackpoints.
+func ParseGPX(data []byte) (*ParsedActivity, error) {
+ var gpx gpxFile
+ if err := xml.Unmarshal(data, &gpx); err != nil {
+ return nil, fmt.Errorf("failed to parse GPX file: %w", err)
+ }
+
+ if len(gpx.Tracks) == 0 {
+ return nil, fmt.Errorf("no tracks found in GPX file")
+ }
+
+ result := &ParsedActivity{}
+
+ // Set title from track or metadata
+ if gpx.Tracks[0].Name != "" {
+ result.Title = gpx.Tracks[0].Name
+ } else if gpx.Metadata.Name != "" {
+ result.Title = gpx.Metadata.Name
+ }
+
+ // Collect all points across tracks and segments
+ var allPoints []gpxTrackPoint
+ for _, trk := range gpx.Tracks {
+ for _, seg := range trk.Segments {
+ allPoints = append(allPoints, seg.Points...)
+ }
+ }
+
+ if len(allPoints) == 0 {
+ return nil, fmt.Errorf("no trackpoints found in GPX file")
+ }
+
+ // Calculate distance using Haversine formula
+ var totalDistance float64
+ for i := 1; i < len(allPoints); i++ {
+ d := haversine(allPoints[i-1].Lat, allPoints[i-1].Lon, allPoints[i].Lat, allPoints[i].Lon)
+ totalDistance += d
+ }
+ result.Distance = totalDistance / 1000.0 // meters to km
+
+ // Calculate duration from timestamps
+ var firstTime, lastTime time.Time
+ for _, pt := range allPoints {
+ if pt.Time == "" {
+ continue
+ }
+ t, err := time.Parse(time.RFC3339, pt.Time)
+ if err != nil {
+ continue
+ }
+ if firstTime.IsZero() {
+ firstTime = t
+ }
+ lastTime = t
+ }
+ if !firstTime.IsZero() && !lastTime.IsZero() {
+ result.Duration = int(lastTime.Sub(firstTime).Seconds())
+ result.StartTime = firstTime
+ }
+
+ // Calculate elevation gain (sum of positive altitude deltas)
+ var elevGain float64
+ var prevEle float64
+ firstEle := true
+ for _, pt := range allPoints {
+ if pt.Elevation == 0 {
+ continue
+ }
+ if firstEle {
+ prevEle = pt.Elevation
+ firstEle = false
+ continue
+ }
+ delta := pt.Elevation - prevEle
+ if delta > 0 {
+ elevGain += delta
+ }
+ prevEle = pt.Elevation
+ }
+ result.ElevGain = int(math.Round(elevGain))
+
+ // Extract HR, power, cadence from extensions
+ var hrSum, powerSum, cadSum int
+ var hrCount, powerCount, cadCount int
+ var maxHR, maxPower int
+
+ for _, pt := range allPoints {
+ hr, power, cad := parseGPXExtensions(pt.Extensions.InnerXML)
+
+ if hr > 0 {
+ hrSum += hr
+ hrCount++
+ if hr > maxHR {
+ maxHR = hr
+ }
+ }
+ if power > 0 {
+ powerSum += power
+ powerCount++
+ if power > maxPower {
+ maxPower = power
+ }
+ }
+ if cad > 0 {
+ cadSum += cad
+ cadCount++
+ }
+ }
+
+ if hrCount > 0 {
+ result.AvgHR = hrSum / hrCount
+ }
+ result.MaxHR = maxHR
+
+ if powerCount > 0 {
+ result.AvgPower = powerSum / powerCount
+ }
+ result.MaxPower = maxPower
+
+ if cadCount > 0 {
+ result.AvgCadence = cadSum / cadCount
+ }
+
+ return result, nil
+}
+
+// parseGPXExtensions extracts HR, power, and cadence from extension XML.
+// Handles common namespaces: Garmin TrackPointExtension, ClueTrust, generic.
+func parseGPXExtensions(innerXML string) (hr, power, cadence int) {
+ if innerXML == "" {
+ return
+ }
+
+ // Parse the inner XML as a generic tree
+ type anyElement struct {
+ XMLName xml.Name
+ Value string `xml:",chardata"`
+ Children []anyElement `xml:",any"`
+ }
+
+ var elements []anyElement
+ wrapped := "" + innerXML + ""
+ var wrapper struct {
+ Children []anyElement `xml:",any"`
+ }
+ if err := xml.Unmarshal([]byte(wrapped), &wrapper); err != nil {
+ return
+ }
+ elements = wrapper.Children
+
+ // Walk the element tree looking for known field names
+ var walk func(elems []anyElement)
+ walk = func(elems []anyElement) {
+ for _, el := range elems {
+ local := strings.ToLower(el.XMLName.Local)
+ switch local {
+ case "hr":
+ if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
+ hr = v
+ }
+ // HR might be nested:
145
+ for _, child := range el.Children {
+ if strings.ToLower(child.XMLName.Local) == "value" {
+ if v, err := strconv.Atoi(strings.TrimSpace(child.Value)); err == nil && v > 0 {
+ hr = v
+ }
+ }
+ }
+ case "power", "watts":
+ if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
+ power = v
+ }
+ case "cad":
+ if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
+ cadence = v
+ }
+ }
+ // Recurse into children (e.g., TrackPointExtension wrapper)
+ if len(el.Children) > 0 {
+ walk(el.Children)
+ }
+ }
+ }
+
+ walk(elements)
+ return
+}
+
+// haversine calculates the distance in meters between two lat/lon points.
+func haversine(lat1, lon1, lat2, lon2 float64) float64 {
+ const earthRadius = 6371000.0 // meters
+
+ dLat := degToRad(lat2 - lat1)
+ dLon := degToRad(lon2 - lon1)
+
+ a := math.Sin(dLat/2)*math.Sin(dLat/2) +
+ math.Cos(degToRad(lat1))*math.Cos(degToRad(lat2))*
+ math.Sin(dLon/2)*math.Sin(dLon/2)
+ c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
+
+ return earthRadius * c
+}
+
+func degToRad(deg float64) float64 {
+ return deg * math.Pi / 180.0
+}
diff --git a/internal/activity/handler.go b/internal/activity/handler.go
new file mode 100644
index 0000000..d57e2bf
--- /dev/null
+++ b/internal/activity/handler.go
@@ -0,0 +1,103 @@
+package activity
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+
+ "rideaware/internal/config"
+ "rideaware/internal/equipment"
+ "rideaware/internal/middleware"
+)
+
+type Handler struct {
+ service *Service
+ equipmentSvc *equipment.Service
+}
+
+func NewHandler() *Handler {
+ return &Handler{
+ service: NewService(),
+ equipmentSvc: equipment.NewService(),
+ }
+}
+
+// ImportActivity POST /api/protected/workouts/import
+// Accepts multipart form with:
+// - file: activity file (FIT/TCX/GPX) - required
+// - workout_id: existing workout ID to update (optional)
+// - title: custom title (optional)
+// - equipment_id: equipment to associate (optional)
+// - notes: optional notes
+func (h *Handler) ImportActivity(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 := r.ParseMultipartForm(20 << 20); err != nil { // 20MB max
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "file too large or invalid form data"})
+ return
+ }
+
+ file, handler, err := r.FormFile("file")
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "no file provided"})
+ return
+ }
+ defer file.Close()
+
+ fileData := make([]byte, handler.Size)
+ if _, err := file.Read(fileData); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "failed to read file"})
+ return
+ }
+
+ opts := ImportOptions{
+ Title: r.FormValue("title"),
+ Notes: r.FormValue("notes"),
+ }
+
+ if idStr := r.FormValue("workout_id"); idStr != "" {
+ if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
+ opts.WorkoutID = uint(id)
+ }
+ }
+
+ if eqIDStr := r.FormValue("equipment_id"); eqIDStr != "" {
+ if eqID, err := strconv.ParseUint(eqIDStr, 10, 32); err == nil {
+ id := uint(eqID)
+ opts.EquipmentID = &id
+ }
+ }
+
+ workout, err := h.service.ImportActivity(claims.UserID, fileData, handler.Filename, opts)
+ if err != nil {
+ log.Printf("Activity import 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
+ }
+
+ // Auto-update equipment mileage if equipment is assigned
+ if workout.EquipmentID != nil && workout.Status == "completed" {
+ if err := h.equipmentSvc.IncrementMileage(*workout.EquipmentID, claims.UserID, workout.Distance, workout.Duration); err != nil {
+ log.Printf("Equipment mileage update error: %v", err)
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(workout)
+}
diff --git a/internal/activity/parser.go b/internal/activity/parser.go
new file mode 100644
index 0000000..d0b9295
--- /dev/null
+++ b/internal/activity/parser.go
@@ -0,0 +1,18 @@
+package activity
+
+import "time"
+
+// ParsedActivity holds the extracted metrics from an activity file (FIT/TCX/GPX).
+type ParsedActivity struct {
+ Title string
+ Duration int // seconds
+ Distance float64 // kilometers
+ ElevGain int // meters
+ AvgPower int // watts
+ MaxPower int // watts
+ AvgHR int // bpm
+ MaxHR int // bpm
+ CaloriesBurned int // kcal
+ AvgCadence int // rpm
+ StartTime time.Time // activity start time
+}
diff --git a/internal/activity/service.go b/internal/activity/service.go
new file mode 100644
index 0000000..a3534b9
--- /dev/null
+++ b/internal/activity/service.go
@@ -0,0 +1,126 @@
+package activity
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "rideaware/internal/workout"
+)
+
+type Service struct {
+ workoutRepo *workout.Repository
+}
+
+func NewService() *Service {
+ return &Service{
+ workoutRepo: workout.NewRepository(),
+ }
+}
+
+// ImportActivity parses an activity file and creates or updates a workout with the metrics.
+func (s *Service) ImportActivity(userID uint, fileData []byte, filename string, opts ImportOptions) (*workout.Workout, error) {
+ ext := strings.ToLower(filepath.Ext(filename))
+
+ var parsed *ParsedActivity
+ var err error
+
+ switch ext {
+ case ".fit":
+ parsed, err = ParseFIT(fileData)
+ case ".tcx":
+ parsed, err = ParseTCX(fileData)
+ case ".gpx":
+ parsed, err = ParseGPX(fileData)
+ default:
+ return nil, fmt.Errorf("unsupported file type: %s (supported: .fit, .tcx, .gpx)", ext)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse %s file: %w", ext, err)
+ }
+
+ // If updating an existing workout, apply metrics to it
+ if opts.WorkoutID > 0 {
+ return s.updateExistingWorkout(userID, opts.WorkoutID, parsed, opts)
+ }
+
+ // Create a new workout from parsed data
+ return s.createNewWorkout(userID, parsed, ext, opts)
+}
+
+func (s *Service) updateExistingWorkout(userID uint, workoutID uint, parsed *ParsedActivity, opts ImportOptions) (*workout.Workout, error) {
+ w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ w.Status = "completed"
+ w.Duration = parsed.Duration
+ w.Distance = parsed.Distance
+ w.ElevGain = parsed.ElevGain
+ w.AvgPower = parsed.AvgPower
+ w.MaxPower = parsed.MaxPower
+ w.AvgHR = parsed.AvgHR
+ w.MaxHR = parsed.MaxHR
+ w.CaloriesBurned = parsed.CaloriesBurned
+
+ if opts.EquipmentID != nil {
+ w.EquipmentID = opts.EquipmentID
+ }
+
+ if err := s.workoutRepo.UpdateWorkout(w); err != nil {
+ return nil, err
+ }
+
+ return w, nil
+}
+
+func (s *Service) createNewWorkout(userID uint, parsed *ParsedActivity, fileExt string, opts ImportOptions) (*workout.Workout, error) {
+ title := opts.Title
+ if title == "" && parsed.Title != "" {
+ title = parsed.Title
+ }
+ if title == "" {
+ title = "Imported Ride"
+ }
+
+ scheduledDate := parsed.StartTime
+ if scheduledDate.IsZero() {
+ scheduledDate = time.Now()
+ }
+
+ w := &workout.Workout{
+ UserID: userID,
+ Title: title,
+ Type: "ride",
+ Status: "completed",
+ ScheduledDate: scheduledDate,
+ Duration: parsed.Duration,
+ Distance: parsed.Distance,
+ ElevGain: parsed.ElevGain,
+ AvgPower: parsed.AvgPower,
+ MaxPower: parsed.MaxPower,
+ AvgHR: parsed.AvgHR,
+ MaxHR: parsed.MaxHR,
+ CaloriesBurned: parsed.CaloriesBurned,
+ FileType: strings.TrimPrefix(fileExt, "."),
+ EquipmentID: opts.EquipmentID,
+ Notes: opts.Notes,
+ }
+
+ if err := s.workoutRepo.CreateWorkout(w); err != nil {
+ return nil, err
+ }
+
+ return w, nil
+}
+
+// ImportOptions holds optional parameters for activity import.
+type ImportOptions struct {
+ WorkoutID uint // If set, update existing workout instead of creating new
+ Title string // Custom title override
+ EquipmentID *uint // Equipment to associate
+ Notes string // Optional notes
+}
diff --git a/internal/activity/tcx_parser.go b/internal/activity/tcx_parser.go
new file mode 100644
index 0000000..91e2dec
--- /dev/null
+++ b/internal/activity/tcx_parser.go
@@ -0,0 +1,181 @@
+package activity
+
+import (
+ "encoding/xml"
+ "fmt"
+ "math"
+ "time"
+)
+
+// TCX XML structures
+type tcxDatabase struct {
+ Activities struct {
+ Activity []tcxActivity `xml:"Activity"`
+ } `xml:"Activities"`
+}
+
+type tcxActivity struct {
+ Sport string `xml:"Sport,attr"`
+ ID string `xml:"Id"`
+ Laps []tcxLap `xml:"Lap"`
+}
+
+type tcxLap struct {
+ StartTime string `xml:"StartTime,attr"`
+ TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
+ DistanceMeters float64 `xml:"DistanceMeters"`
+ Calories int `xml:"Calories"`
+ AvgHR tcxHeartRate `xml:"AverageHeartRateBpm"`
+ MaxHR tcxHeartRate `xml:"MaximumHeartRateBpm"`
+ Cadence int `xml:"Cadence"`
+ Extensions tcxExtensions `xml:"Extensions"`
+ Track tcxTrack `xml:"Track"`
+}
+
+type tcxHeartRate struct {
+ Value int `xml:"Value"`
+}
+
+type tcxExtensions struct {
+ LX tcxLapExtension `xml:"LX"`
+}
+
+type tcxLapExtension struct {
+ AvgWatts int `xml:"AvgWatts"`
+ MaxWatts int `xml:"MaxWatts"`
+}
+
+type tcxTrack struct {
+ Trackpoints []tcxTrackpoint `xml:"Trackpoint"`
+}
+
+type tcxTrackpoint struct {
+ Time string `xml:"Time"`
+ AltitudeMeters float64 `xml:"AltitudeMeters"`
+ DistanceMeters float64 `xml:"DistanceMeters"`
+ HeartRateBpm tcxHeartRate `xml:"HeartRateBpm"`
+ Cadence int `xml:"Cadence"`
+ HasAltitude bool
+}
+
+// ParseTCX parses a TCX activity file and extracts aggregated metrics from laps.
+func ParseTCX(data []byte) (*ParsedActivity, error) {
+ var db tcxDatabase
+ if err := xml.Unmarshal(data, &db); err != nil {
+ return nil, fmt.Errorf("failed to parse TCX file: %w", err)
+ }
+
+ if len(db.Activities.Activity) == 0 {
+ return nil, fmt.Errorf("no activities found in TCX file")
+ }
+
+ act := db.Activities.Activity[0]
+ if len(act.Laps) == 0 {
+ return nil, fmt.Errorf("no laps found in TCX activity")
+ }
+
+ result := &ParsedActivity{}
+
+ var totalDuration float64
+ var totalDistance float64
+ var totalCalories int
+ var maxHR int
+ var maxPower int
+
+ // Weighted sums for averages
+ var hrDurationSum float64
+ var powerDurationSum float64
+ var cadDurationSum float64
+ var hrDuration float64
+ var powerDuration float64
+ var cadDuration float64
+
+ for _, lap := range act.Laps {
+ totalDuration += lap.TotalTimeSeconds
+ totalDistance += lap.DistanceMeters
+ totalCalories += lap.Calories
+
+ if lap.AvgHR.Value > 0 {
+ hrDurationSum += float64(lap.AvgHR.Value) * lap.TotalTimeSeconds
+ hrDuration += lap.TotalTimeSeconds
+ }
+ if lap.MaxHR.Value > maxHR {
+ maxHR = lap.MaxHR.Value
+ }
+
+ if lap.Extensions.LX.AvgWatts > 0 {
+ powerDurationSum += float64(lap.Extensions.LX.AvgWatts) * lap.TotalTimeSeconds
+ powerDuration += lap.TotalTimeSeconds
+ }
+ if lap.Extensions.LX.MaxWatts > maxPower {
+ maxPower = lap.Extensions.LX.MaxWatts
+ }
+
+ if lap.Cadence > 0 {
+ cadDurationSum += float64(lap.Cadence) * lap.TotalTimeSeconds
+ cadDuration += lap.TotalTimeSeconds
+ }
+ }
+
+ result.Duration = int(math.Round(totalDuration))
+ result.Distance = totalDistance / 1000.0 // meters to km
+ result.CaloriesBurned = totalCalories
+ result.MaxHR = maxHR
+ result.MaxPower = maxPower
+
+ if hrDuration > 0 {
+ result.AvgHR = int(math.Round(hrDurationSum / hrDuration))
+ }
+ if powerDuration > 0 {
+ result.AvgPower = int(math.Round(powerDurationSum / powerDuration))
+ }
+ if cadDuration > 0 {
+ result.AvgCadence = int(math.Round(cadDurationSum / cadDuration))
+ }
+
+ // Calculate elevation gain from trackpoints
+ result.ElevGain = calculateTCXElevGain(act.Laps)
+
+ // Parse start time from activity ID or first lap
+ if act.ID != "" {
+ if t, err := time.Parse(time.RFC3339, act.ID); err == nil {
+ result.StartTime = t
+ }
+ }
+ if result.StartTime.IsZero() && len(act.Laps) > 0 && act.Laps[0].StartTime != "" {
+ if t, err := time.Parse(time.RFC3339, act.Laps[0].StartTime); err == nil {
+ result.StartTime = t
+ }
+ }
+
+ return result, nil
+}
+
+// calculateTCXElevGain computes total ascent from trackpoint altitude data.
+func calculateTCXElevGain(laps []tcxLap) int {
+ var elevGain float64
+ var prevAlt float64
+ first := true
+
+ for _, lap := range laps {
+ for _, tp := range lap.Track.Trackpoints {
+ if tp.AltitudeMeters == 0 {
+ continue
+ }
+
+ if first {
+ prevAlt = tp.AltitudeMeters
+ first = false
+ continue
+ }
+
+ delta := tp.AltitudeMeters - prevAlt
+ if delta > 0 {
+ elevGain += delta
+ }
+ prevAlt = tp.AltitudeMeters
+ }
+ }
+
+ return int(math.Round(elevGain))
+}
diff --git a/internal/config/oauth.go b/internal/config/oauth.go
new file mode 100644
index 0000000..d2e3cf2
--- /dev/null
+++ b/internal/config/oauth.go
@@ -0,0 +1,46 @@
+package config
+
+import (
+ "log"
+ "os"
+)
+
+type OAuthProviderConfig struct {
+ ClientID string
+ ClientSecret string
+ RedirectURI string
+ AuthURL string
+ TokenURL string
+}
+
+type OAuthConfig struct {
+ EncryptionKey string
+ AppURL string
+ Garmin OAuthProviderConfig
+ Wahoo OAuthProviderConfig
+}
+
+var OAuth *OAuthConfig
+
+func InitOAuth() {
+ OAuth = &OAuthConfig{
+ EncryptionKey: os.Getenv("OAUTH_ENCRYPTION_KEY"),
+ AppURL: os.Getenv("APP_URL"),
+ Garmin: OAuthProviderConfig{
+ ClientID: os.Getenv("GARMIN_CLIENT_ID"),
+ ClientSecret: os.Getenv("GARMIN_CLIENT_SECRET"),
+ RedirectURI: os.Getenv("GARMIN_REDIRECT_URI"),
+ AuthURL: "https://apis.garmin.com/tools/oauth2/authorizeUser",
+ TokenURL: "https://diauth.garmin.com/di-oauth2-service/oauth/token",
+ },
+ Wahoo: OAuthProviderConfig{
+ ClientID: os.Getenv("WAHOO_CLIENT_ID"),
+ ClientSecret: os.Getenv("WAHOO_CLIENT_SECRET"),
+ RedirectURI: os.Getenv("WAHOO_REDIRECT_URI"),
+ AuthURL: "https://api.wahooligan.com/oauth/authorize",
+ TokenURL: "https://api.wahooligan.com/oauth/token",
+ },
+ }
+
+ log.Println("OAuth config initialized")
+}
diff --git a/internal/equipment/handler.go b/internal/equipment/handler.go
index 5c353f8..5cb8e3b 100644
--- a/internal/equipment/handler.go
+++ b/internal/equipment/handler.go
@@ -136,6 +136,56 @@ func (h *Handler) DeleteEquipment(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+// RecordService POST /api/protected/equipment/service?id=X
+func (h *Handler) RecordService(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 equipment id"})
+ return
+ }
+
+ eq, err := h.service.RecordService(uint(id), claims.UserID)
+ 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(eq)
+}
+
+// GetServiceStatus GET /api/protected/equipment/service-status?id=X
+func (h *Handler) GetServiceStatus(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 equipment id"})
+ return
+ }
+
+ status, err := h.service.GetServiceStatus(uint(id), claims.UserID)
+ 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(status)
+}
+
// GetTrainingZones GET /api/zones
func (h *Handler) GetTrainingZones(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
diff --git a/internal/equipment/model.go b/internal/equipment/model.go
index de86569..a6528cd 100644
--- a/internal/equipment/model.go
+++ b/internal/equipment/model.go
@@ -3,17 +3,58 @@ package equipment
import "time"
type Equipment struct {
- ID uint `gorm:"primaryKey" json:"id"`
- UserID uint `gorm:"not null;index" json:"user_id"`
- Name string `gorm:"not null" json:"name"`
- Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
- Brand string `gorm:"default:''" json:"brand"`
- Model string `gorm:"default:''" json:"model"`
- Weight float64 `gorm:"default:0" json:"weight"` // grams
- Notes string `gorm:"default:''" json:"notes"`
- Active bool `gorm:"default:true" json:"active"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"not null;index" json:"user_id"`
+ Name string `gorm:"not null" json:"name"`
+ Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
+ Brand string `gorm:"default:''" json:"brand"`
+ Model string `gorm:"default:''" json:"model"`
+ Weight float64 `gorm:"default:0" json:"weight"` // grams
+ Notes string `gorm:"default:''" json:"notes"`
+ Active bool `gorm:"default:true" json:"active"`
+ TotalDistance float64 `gorm:"default:0" json:"total_distance"` // km
+ TotalDuration int `gorm:"default:0" json:"total_duration"` // seconds
+ TotalRides int `gorm:"default:0" json:"total_rides"`
+ ServiceIntervalDistance float64 `gorm:"default:0" json:"service_interval_distance"` // km, 0 = no reminder
+ ServiceIntervalDuration int `gorm:"default:0" json:"service_interval_duration"` // hours, 0 = no reminder
+ LastServiceDate *time.Time `json:"last_service_date"`
+ DistanceSinceService float64 `gorm:"default:0" json:"distance_since_service"` // km since last service
+ DurationSinceService int `gorm:"default:0" json:"duration_since_service"` // seconds since last service
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// ServiceStatus indicates whether equipment needs servicing.
+type ServiceStatus struct {
+ NeedsService bool `json:"needs_service"`
+ DistanceDue bool `json:"distance_due"`
+ DurationDue bool `json:"duration_due"`
+ DistanceSinceService float64 `json:"distance_since_service"` // km
+ DurationSinceService int `json:"duration_since_service"` // hours
+ ServiceIntervalDist float64 `json:"service_interval_distance"`
+ ServiceIntervalDur int `json:"service_interval_duration"`
+}
+
+// GetServiceStatus checks if the equipment is due for service.
+func (e *Equipment) GetServiceStatus() ServiceStatus {
+ status := ServiceStatus{
+ DistanceSinceService: e.DistanceSinceService,
+ DurationSinceService: e.DurationSinceService / 3600,
+ ServiceIntervalDist: e.ServiceIntervalDistance,
+ ServiceIntervalDur: e.ServiceIntervalDuration,
+ }
+
+ if e.ServiceIntervalDistance > 0 && e.DistanceSinceService >= e.ServiceIntervalDistance {
+ status.DistanceDue = true
+ status.NeedsService = true
+ }
+
+ if e.ServiceIntervalDuration > 0 && e.DurationSinceService >= e.ServiceIntervalDuration*3600 {
+ status.DurationDue = true
+ status.NeedsService = true
+ }
+
+ return status
}
type TrainingZone struct {
diff --git a/internal/equipment/repoistory.go b/internal/equipment/repoistory.go
index 71cdb9f..ecd36b0 100644
--- a/internal/equipment/repoistory.go
+++ b/internal/equipment/repoistory.go
@@ -3,6 +3,8 @@ package equipment
import (
"errors"
"rideaware/pkg/database"
+ "time"
+
"gorm.io/gorm"
)
@@ -53,4 +55,29 @@ func (r *Repository) UpdateEquipment(equipment *Equipment) error {
func (r *Repository) DeleteEquipment(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).
Delete(&Equipment{}).Error
+}
+
+// IncrementMileage atomically adds distance and duration to equipment totals.
+func (r *Repository) IncrementMileage(id, userID uint, distance float64, duration int) error {
+ return database.DB.Model(&Equipment{}).
+ Where("id = ? AND user_id = ?", id, userID).
+ Updates(map[string]interface{}{
+ "total_distance": gorm.Expr("total_distance + ?", distance),
+ "total_duration": gorm.Expr("total_duration + ?", duration),
+ "total_rides": gorm.Expr("total_rides + 1"),
+ "distance_since_service": gorm.Expr("distance_since_service + ?", distance),
+ "duration_since_service": gorm.Expr("duration_since_service + ?", duration),
+ }).Error
+}
+
+// ResetServiceCounters resets the service tracking counters after servicing.
+func (r *Repository) ResetServiceCounters(id, userID uint) error {
+ now := time.Now()
+ return database.DB.Model(&Equipment{}).
+ Where("id = ? AND user_id = ?", id, userID).
+ Updates(map[string]interface{}{
+ "distance_since_service": 0,
+ "duration_since_service": 0,
+ "last_service_date": now,
+ }).Error
}
\ No newline at end of file
diff --git a/internal/equipment/service.go b/internal/equipment/service.go
index 86d6805..d454632 100644
--- a/internal/equipment/service.go
+++ b/internal/equipment/service.go
@@ -71,6 +71,12 @@ func (s *Service) UpdateEquipment(id, userID uint, updates map[string]interface{
if active, ok := updates["active"].(bool); ok {
equipment.Active = active
}
+ if v, ok := updates["service_interval_distance"].(float64); ok {
+ equipment.ServiceIntervalDistance = v
+ }
+ if v, ok := updates["service_interval_duration"].(float64); ok {
+ equipment.ServiceIntervalDuration = int(v)
+ }
if err := s.repo.UpdateEquipment(equipment); err != nil {
return nil, err
@@ -83,6 +89,29 @@ func (s *Service) DeleteEquipment(id, userID uint) error {
return s.repo.DeleteEquipment(id, userID)
}
+// IncrementMileage adds distance (km) and duration (seconds) to equipment totals.
+func (s *Service) IncrementMileage(equipmentID, userID uint, distance float64, duration int) error {
+ return s.repo.IncrementMileage(equipmentID, userID, distance, duration)
+}
+
+// RecordService resets the service counters and records the service date.
+func (s *Service) RecordService(equipmentID, userID uint) (*Equipment, error) {
+ if err := s.repo.ResetServiceCounters(equipmentID, userID); err != nil {
+ return nil, err
+ }
+ return s.repo.GetEquipmentByID(equipmentID, userID)
+}
+
+// GetServiceStatus returns the service status for a piece of equipment.
+func (s *Service) GetServiceStatus(equipmentID, userID uint) (*ServiceStatus, error) {
+ eq, err := s.repo.GetEquipmentByID(equipmentID, userID)
+ if err != nil {
+ return nil, err
+ }
+ status := eq.GetServiceStatus()
+ return &status, nil
+}
+
// Training Zones calculation
func (s *Service) CalculateHRZones(maxHR, restingHR int) *HRZones {
if maxHR <= 0 {
diff --git a/internal/export/fit_encoder.go b/internal/export/fit_encoder.go
new file mode 100644
index 0000000..c043dd9
--- /dev/null
+++ b/internal/export/fit_encoder.go
@@ -0,0 +1,144 @@
+package export
+
+import (
+ "bytes"
+ "fmt"
+ "time"
+
+ "github.com/muktihari/fit/encoder"
+ "github.com/muktihari/fit/profile/filedef"
+ "github.com/muktihari/fit/profile/mesgdef"
+ "github.com/muktihari/fit/profile/typedef"
+
+ "rideaware/internal/workout"
+)
+
+// EncodeFITWorkout generates a FIT workout file from a Workout's structured segments.
+// userFTP is the user's Functional Threshold Power in watts, used to convert
+// %FTP power targets into absolute watt values for the device.
+func EncodeFITWorkout(w *workout.Workout, userFTP int) ([]byte, error) {
+ if len(w.WorkoutData.Segments) == 0 {
+ return nil, fmt.Errorf("workout has no segments")
+ }
+ if userFTP <= 0 {
+ return nil, fmt.Errorf("user FTP must be greater than 0")
+ }
+
+ wktFile := filedef.NewWorkout()
+
+ wktFile.FileId.
+ SetType(typedef.FileWorkout).
+ SetManufacturer(typedef.ManufacturerDevelopment).
+ SetProduct(1).
+ SetTimeCreated(time.Now())
+
+ name := w.WorkoutData.Name
+ if name == "" {
+ name = w.Title
+ }
+
+ wktFile.Workout = mesgdef.NewWorkout(nil).
+ SetWktName(name).
+ SetSport(typedef.SportCycling).
+ SetNumValidSteps(uint16(len(w.WorkoutData.Segments)))
+
+ steps := make([]*mesgdef.WorkoutStep, 0, len(w.WorkoutData.Segments))
+ for i, seg := range w.WorkoutData.Segments {
+ step := segmentToFITStep(seg, uint16(i), userFTP)
+ steps = append(steps, step)
+ }
+ wktFile.WorkoutSteps = steps
+
+ fit := wktFile.ToFIT(nil)
+
+ var buf bytes.Buffer
+ enc := encoder.New(&buf)
+ if err := enc.Encode(&fit); err != nil {
+ return nil, fmt.Errorf("failed to encode FIT workout: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *mesgdef.WorkoutStep {
+ step := mesgdef.NewWorkoutStep(nil).
+ SetMessageIndex(typedef.MessageIndex(index)).
+ SetDurationType(typedef.WktStepDurationTime).
+ SetDurationValue(uint32(seg.Duration) * 1000) // seconds to milliseconds
+
+ switch seg.Type {
+ case "warmup":
+ step.SetIntensity(typedef.IntensityWarmup)
+ setPowerTarget(step, seg, userFTP)
+ step.SetWktStepName("Warm Up")
+
+ case "steadystate":
+ step.SetIntensity(typedef.IntensityActive)
+ setPowerTargetSteady(step, seg, userFTP)
+ step.SetWktStepName("Steady State")
+
+ case "interval":
+ step.SetIntensity(typedef.IntensityInterval)
+ setPowerTarget(step, seg, userFTP)
+ step.SetWktStepName("Interval")
+
+ case "cooldown":
+ step.SetIntensity(typedef.IntensityCooldown)
+ setPowerTarget(step, seg, userFTP)
+ step.SetWktStepName("Cool Down")
+
+ case "ramp":
+ step.SetIntensity(typedef.IntensityActive)
+ setPowerTarget(step, seg, userFTP)
+ step.SetWktStepName("Ramp")
+
+ case "freeride":
+ step.SetIntensity(typedef.IntensityActive)
+ step.SetTargetType(typedef.WktStepTargetOpen)
+ step.SetWktStepName("Free Ride")
+
+ default:
+ step.SetIntensity(typedef.IntensityActive)
+ step.SetTargetType(typedef.WktStepTargetOpen)
+ }
+
+ return step
+}
+
+// setPowerTarget sets a power range target using PowerLow/PowerHigh.
+// Values are converted from %FTP fractions to absolute watts with +1000 offset.
+func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
+ if seg.PowerLow == 0 && seg.PowerHigh == 0 && seg.Power == 0 {
+ step.SetTargetType(typedef.WktStepTargetOpen)
+ return
+ }
+
+ step.SetTargetType(typedef.WktStepTargetPower)
+ 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)
+ } else if seg.Power != 0 {
+ watts := uint32(float64(userFTP) * seg.Power)
+ step.SetCustomTargetValueLow(watts - 10 + 1000)
+ step.SetCustomTargetValueHigh(watts + 10 + 1000)
+ }
+}
+
+// setPowerTargetSteady sets a power target for steady state segments using the Power field.
+func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
+ if seg.Power == 0 {
+ step.SetTargetType(typedef.WktStepTargetOpen)
+ return
+ }
+
+ step.SetTargetType(typedef.WktStepTargetPower)
+ step.SetTargetValue(0)
+
+ watts := uint32(float64(userFTP) * seg.Power)
+ step.SetCustomTargetValueLow(watts - 10 + 1000)
+ step.SetCustomTargetValueHigh(watts + 10 + 1000)
+}
diff --git a/internal/export/handler.go b/internal/export/handler.go
new file mode 100644
index 0000000..f2cd781
--- /dev/null
+++ b/internal/export/handler.go
@@ -0,0 +1,106 @@
+package export
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+
+ "rideaware/internal/config"
+ "rideaware/internal/middleware"
+)
+
+type Handler struct {
+ service *Service
+}
+
+func NewHandler() *Handler {
+ return &Handler{
+ service: NewService(),
+ }
+}
+
+// ExportFIT GET /api/protected/workouts/export/fit?id=X
+func (h *Handler) ExportFIT(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
+ }
+
+ data, filename, err := h.service.ExportFIT(uint(id), claims.UserID)
+ if err != nil {
+ log.Printf("FIT export 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/octet-stream")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ w.WriteHeader(http.StatusOK)
+ w.Write(data)
+}
+
+// ExportZWO GET /api/protected/workouts/export/zwo?id=X
+func (h *Handler) ExportZWO(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
+ }
+
+ data, filename, err := h.service.ExportZWO(uint(id), claims.UserID)
+ if err != nil {
+ log.Printf("ZWO export 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/xml")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ w.WriteHeader(http.StatusOK)
+ w.Write(data)
+}
diff --git a/internal/export/service.go b/internal/export/service.go
new file mode 100644
index 0000000..d6d6c2f
--- /dev/null
+++ b/internal/export/service.go
@@ -0,0 +1,82 @@
+package export
+
+import (
+ "fmt"
+
+ "rideaware/internal/user"
+ "rideaware/internal/workout"
+)
+
+type Service struct {
+ workoutRepo *workout.Repository
+ userRepo *user.Repository
+}
+
+func NewService() *Service {
+ return &Service{
+ workoutRepo: workout.NewRepository(),
+ userRepo: user.NewRepository(),
+ }
+}
+
+func (s *Service) ExportFIT(workoutID, userID uint) ([]byte, string, error) {
+ w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
+ if err != nil {
+ return nil, "", fmt.Errorf("workout not found: %w", err)
+ }
+
+ u, err := s.userRepo.GetUserByID(userID)
+ if err != nil {
+ return nil, "", fmt.Errorf("user not found: %w", err)
+ }
+
+ ftp := 0
+ if u.Profile != nil {
+ ftp = u.Profile.FTP
+ }
+ if ftp <= 0 {
+ return nil, "", fmt.Errorf("FTP must be set in your profile before exporting FIT files")
+ }
+
+ data, err := EncodeFITWorkout(w, ftp)
+ if err != nil {
+ return nil, "", err
+ }
+
+ filename := sanitizeFilename(w.Title) + ".fit"
+ return data, filename, nil
+}
+
+func (s *Service) ExportZWO(workoutID, userID uint) ([]byte, string, error) {
+ w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
+ if err != nil {
+ return nil, "", fmt.Errorf("workout not found: %w", err)
+ }
+
+ data, err := GenerateZWO(w)
+ if err != nil {
+ return nil, "", err
+ }
+
+ filename := sanitizeFilename(w.Title) + ".zwo"
+ return data, filename, nil
+}
+
+func sanitizeFilename(name string) string {
+ if name == "" {
+ return "workout"
+ }
+ result := make([]byte, 0, len(name))
+ for _, c := range name {
+ switch {
+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
+ result = append(result, byte(c))
+ case c == ' ' || c == '-' || c == '_':
+ result = append(result, '_')
+ }
+ }
+ if len(result) == 0 {
+ return "workout"
+ }
+ return string(result)
+}
diff --git a/internal/export/zwo_generator.go b/internal/export/zwo_generator.go
new file mode 100644
index 0000000..518e11c
--- /dev/null
+++ b/internal/export/zwo_generator.go
@@ -0,0 +1,169 @@
+package export
+
+import (
+ "encoding/xml"
+ "fmt"
+
+ "rideaware/internal/workout"
+)
+
+// ZWO XML structures for marshalling (mirrors the parser types in workout/zwo_parser.go)
+
+type zwoWorkoutFile struct {
+ XMLName xml.Name `xml:"workout_file"`
+ Author string `xml:"author"`
+ Name string `xml:"name"`
+ Description string `xml:"description"`
+ SportType string `xml:"sportType"`
+ Workout zwoWorkout `xml:"workout"`
+}
+
+type zwoWorkout struct {
+ Steps []interface{}
+}
+
+type zwoWarmup struct {
+ XMLName xml.Name `xml:"Warmup"`
+ Duration int `xml:"Duration,attr"`
+ PowerLow float64 `xml:"PowerLow,attr"`
+ PowerHigh float64 `xml:"PowerHigh,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+type zwoSteadyState struct {
+ XMLName xml.Name `xml:"SteadyState"`
+ Duration int `xml:"Duration,attr"`
+ Power float64 `xml:"Power,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+type zwoCooldown struct {
+ XMLName xml.Name `xml:"Cooldown"`
+ Duration int `xml:"Duration,attr"`
+ PowerLow float64 `xml:"PowerLow,attr"`
+ PowerHigh float64 `xml:"PowerHigh,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+type zwoInterval struct {
+ XMLName xml.Name `xml:"IntervalsT"`
+ Duration int `xml:"Duration,attr"`
+ PowerLow float64 `xml:"PowerLow,attr"`
+ PowerHigh float64 `xml:"PowerHigh,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+type zwoRamp struct {
+ XMLName xml.Name `xml:"Ramp"`
+ Duration int `xml:"Duration,attr"`
+ PowerLow float64 `xml:"PowerLow,attr"`
+ PowerHigh float64 `xml:"PowerHigh,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+type zwoFreeRide struct {
+ XMLName xml.Name `xml:"FreeRide"`
+ Duration int `xml:"Duration,attr"`
+ Cadence int `xml:"Cadence,attr,omitempty"`
+}
+
+// MarshalXML implements custom XML marshalling for zwoWorkout to preserve segment ordering.
+func (w zwoWorkout) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
+ start.Name = xml.Name{Local: "workout"}
+ if err := e.EncodeToken(start); err != nil {
+ return err
+ }
+ for _, step := range w.Steps {
+ if err := e.Encode(step); err != nil {
+ return err
+ }
+ }
+ return e.EncodeToken(start.End())
+}
+
+// GenerateZWO creates a .zwo XML file from a workout's structured segments.
+// Power values are stored as %FTP fractions (0.0-2.0) and pass through directly.
+func GenerateZWO(w *workout.Workout) ([]byte, error) {
+ if len(w.WorkoutData.Segments) == 0 {
+ return nil, fmt.Errorf("workout has no segments")
+ }
+
+ name := w.WorkoutData.Name
+ if name == "" {
+ name = w.Title
+ }
+
+ author := w.WorkoutData.Author
+ if author == "" {
+ author = "RideAware"
+ }
+
+ zwo := zwoWorkoutFile{
+ Author: author,
+ Name: name,
+ Description: w.Description,
+ SportType: "bike",
+ }
+
+ steps := make([]interface{}, 0, len(w.WorkoutData.Segments))
+ for _, seg := range w.WorkoutData.Segments {
+ step := segmentToZWOStep(seg)
+ if step != nil {
+ steps = append(steps, step)
+ }
+ }
+ zwo.Workout = zwoWorkout{Steps: steps}
+
+ output, err := xml.MarshalIndent(zwo, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate ZWO XML: %w", err)
+ }
+
+ return append([]byte(xml.Header), output...), nil
+}
+
+func segmentToZWOStep(seg workout.WorkoutSegment) interface{} {
+ switch seg.Type {
+ case "warmup":
+ return zwoWarmup{
+ Duration: seg.Duration,
+ PowerLow: seg.PowerLow,
+ PowerHigh: seg.PowerHigh,
+ Cadence: seg.Cadence,
+ }
+ case "steadystate":
+ return zwoSteadyState{
+ Duration: seg.Duration,
+ Power: seg.Power,
+ Cadence: seg.Cadence,
+ }
+ case "cooldown":
+ return zwoCooldown{
+ Duration: seg.Duration,
+ PowerLow: seg.PowerLow,
+ PowerHigh: seg.PowerHigh,
+ Cadence: seg.Cadence,
+ }
+ case "interval":
+ return zwoInterval{
+ Duration: seg.Duration,
+ PowerLow: seg.PowerLow,
+ PowerHigh: seg.PowerHigh,
+ Cadence: seg.Cadence,
+ }
+ case "ramp":
+ return zwoRamp{
+ Duration: seg.Duration,
+ PowerLow: seg.PowerLow,
+ PowerHigh: seg.PowerHigh,
+ Cadence: seg.Cadence,
+ }
+ case "freeride":
+ return zwoFreeRide{
+ Duration: seg.Duration,
+ Cadence: seg.Cadence,
+ }
+ default:
+ return nil
+ }
+}
diff --git a/internal/integration/garmin_client.go b/internal/integration/garmin_client.go
new file mode 100644
index 0000000..fd79c51
--- /dev/null
+++ b/internal/integration/garmin_client.go
@@ -0,0 +1,140 @@
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "rideaware/internal/config"
+ "rideaware/internal/export"
+ "rideaware/internal/user"
+ "rideaware/internal/workout"
+)
+
+type GarminClient struct {
+ oauthService *OAuthService
+ workoutRepo *workout.Repository
+ userRepo *user.Repository
+}
+
+func NewGarminClient() *GarminClient {
+ return &GarminClient{
+ oauthService: NewOAuthService(),
+ workoutRepo: workout.NewRepository(),
+ userRepo: user.NewRepository(),
+ }
+}
+
+// BuildAuthURL constructs the Garmin OAuth2 PKCE authorization URL.
+func (c *GarminClient) BuildAuthURL(userID uint) (string, error) {
+ cfg := config.OAuth.Garmin
+ if cfg.ClientID == "" {
+ return "", fmt.Errorf("Garmin OAuth is not configured")
+ }
+
+ stateToken, _, codeChallenge, err := c.oauthService.GenerateStateWithPKCE(userID, "garmin")
+ if err != nil {
+ return "", err
+ }
+
+ params := url.Values{
+ "client_id": {cfg.ClientID},
+ "response_type": {"code"},
+ "redirect_uri": {cfg.RedirectURI},
+ "scope": {"TRAINING_API"},
+ "state": {stateToken},
+ "code_challenge": {codeChallenge},
+ "code_challenge_method": {"S256"},
+ }
+
+ return cfg.AuthURL + "?" + params.Encode(), nil
+}
+
+// HandleCallback exchanges the authorization code for tokens and stores them.
+func (c *GarminClient) HandleCallback(code, stateToken string) (uint, error) {
+ state, err := c.oauthService.ValidateState(stateToken)
+ if err != nil {
+ return 0, err
+ }
+
+ if state.Provider != "garmin" {
+ return 0, fmt.Errorf("invalid state provider: expected garmin, got %s", state.Provider)
+ }
+
+ cfg := config.OAuth.Garmin
+ params := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {cfg.RedirectURI},
+ "client_id": {cfg.ClientID},
+ "code_verifier": {state.CodeVerifier},
+ }
+
+ tokenResp, err := c.oauthService.ExchangeCode(cfg.TokenURL, params)
+ if err != nil {
+ return 0, fmt.Errorf("Garmin token exchange failed: %w", err)
+ }
+
+ if err := c.oauthService.SaveConnection(state.UserID, "garmin", tokenResp); err != nil {
+ return 0, err
+ }
+
+ return state.UserID, nil
+}
+
+// PushWorkout sends a structured workout to Garmin Connect via the Training API.
+// If the Training API is not yet available, returns an error with instructions to use FIT export.
+func (c *GarminClient) PushWorkout(workoutID, userID uint) error {
+ accessToken, err := c.oauthService.GetValidToken(userID, "garmin")
+ if err != nil {
+ return err
+ }
+
+ w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID)
+ if err != nil {
+ return fmt.Errorf("workout not found: %w", err)
+ }
+
+ u, err := c.userRepo.GetUserByID(userID)
+ if err != nil {
+ return fmt.Errorf("user not found: %w", err)
+ }
+
+ ftp := 0
+ if u.Profile != nil {
+ ftp = u.Profile.FTP
+ }
+ if ftp <= 0 {
+ return fmt.Errorf("FTP must be set in your profile before pushing workouts")
+ }
+
+ fitData, err := export.EncodeFITWorkout(w, ftp)
+ if err != nil {
+ return fmt.Errorf("failed to generate FIT workout: %w", err)
+ }
+
+ // Push FIT file to Garmin Training API
+ apiURL := "https://apis.garmin.com/training-api/workout"
+ req, err := http.NewRequest("POST", apiURL, bytes.NewReader(fitData))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to push workout to Garmin: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("Garmin API returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ return nil
+}
diff --git a/internal/integration/garmin_handler.go b/internal/integration/garmin_handler.go
new file mode 100644
index 0000000..231a167
--- /dev/null
+++ b/internal/integration/garmin_handler.go
@@ -0,0 +1,158 @@
+package integration
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+
+ "rideaware/internal/config"
+ "rideaware/internal/middleware"
+)
+
+type GarminHandler struct {
+ client *GarminClient
+ oauthService *OAuthService
+}
+
+func NewGarminHandler() *GarminHandler {
+ return &GarminHandler{
+ client: NewGarminClient(),
+ oauthService: NewOAuthService(),
+ }
+}
+
+// StartAuth GET /api/protected/garmin/auth - initiates Garmin OAuth2 PKCE flow
+func (h *GarminHandler) StartAuth(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
+ }
+
+ authURL, err := h.client.BuildAuthURL(claims.UserID)
+ if err != nil {
+ log.Printf("Garmin auth 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"auth_url": authURL})
+}
+
+// Callback GET /api/garmin/callback - handles Garmin OAuth callback (public endpoint)
+func (h *GarminHandler) Callback(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ state := r.URL.Query().Get("state")
+
+ if code == "" || state == "" {
+ errMsg := r.URL.Query().Get("error")
+ if errMsg == "" {
+ errMsg = "missing code or state parameter"
+ }
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?garmin=error&message="+errMsg, http.StatusFound)
+ return
+ }
+
+ _, err := h.client.HandleCallback(code, state)
+ if err != nil {
+ log.Printf("Garmin callback error: %v", err)
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?garmin=error&message=auth_failed", http.StatusFound)
+ return
+ }
+
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?garmin=connected", http.StatusFound)
+}
+
+// PushWorkout POST /api/protected/workouts/push/garmin?id=X - pushes workout to Garmin Connect
+func (h *GarminHandler) 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("Garmin 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "workout pushed to Garmin Connect"})
+}
+
+// ConnectionStatus GET /api/protected/garmin/status - check Garmin connection status
+func (h *GarminHandler) 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, "garmin")
+ 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(status)
+}
+
+// Disconnect DELETE /api/protected/garmin/disconnect - revoke Garmin connection
+func (h *GarminHandler) 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, "garmin"); 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "Garmin disconnected"})
+}
diff --git a/internal/integration/model.go b/internal/integration/model.go
new file mode 100644
index 0000000..55cbcbf
--- /dev/null
+++ b/internal/integration/model.go
@@ -0,0 +1,35 @@
+package integration
+
+import "time"
+
+type OAuthConnection struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"not null;uniqueIndex:idx_user_provider" json:"user_id"`
+ Provider string `gorm:"not null;uniqueIndex:idx_user_provider" json:"provider"` // "garmin", "wahoo"
+ AccessToken string `gorm:"not null" json:"-"`
+ RefreshToken string `gorm:"default:''" json:"-"`
+ TokenExpiresAt time.Time `json:"token_expires_at"`
+ ProviderUserID string `gorm:"default:''" json:"provider_user_id"`
+ Scopes string `gorm:"default:''" json:"scopes"`
+ Status string `gorm:"default:'active'" json:"status"` // "active", "revoked", "expired"
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (OAuthConnection) TableName() string {
+ return "oauth_connections"
+}
+
+type OAuthState struct {
+ ID uint `gorm:"primaryKey"`
+ State string `gorm:"uniqueIndex;not null"`
+ UserID uint `gorm:"not null"`
+ Provider string `gorm:"not null"` // "garmin", "wahoo"
+ CodeVerifier string `gorm:"default:''"` // for PKCE (Garmin)
+ ExpiresAt time.Time `gorm:"not null"`
+ CreatedAt time.Time
+}
+
+func (OAuthState) TableName() string {
+ return "oauth_states"
+}
diff --git a/internal/integration/oauth_service.go b/internal/integration/oauth_service.go
new file mode 100644
index 0000000..83859d6
--- /dev/null
+++ b/internal/integration/oauth_service.go
@@ -0,0 +1,325 @@
+package integration
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "rideaware/internal/config"
+)
+
+type OAuthService struct {
+ repo *Repository
+}
+
+func NewOAuthService() *OAuthService {
+ return &OAuthService{
+ repo: NewRepository(),
+ }
+}
+
+// GenerateState creates a cryptographically random state token for OAuth CSRF protection.
+func (s *OAuthService) GenerateState(userID uint, provider string) (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("failed to generate state: %w", err)
+ }
+ stateToken := base64.URLEncoding.EncodeToString(b)
+
+ state := &OAuthState{
+ State: stateToken,
+ UserID: userID,
+ Provider: provider,
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ }
+
+ if err := s.repo.CreateState(state); err != nil {
+ return "", err
+ }
+
+ return stateToken, nil
+}
+
+// GenerateStateWithPKCE creates a state token and PKCE code verifier/challenge for Garmin OAuth.
+func (s *OAuthService) GenerateStateWithPKCE(userID uint, provider string) (stateToken, codeVerifier, codeChallenge string, err error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", "", "", fmt.Errorf("failed to generate state: %w", err)
+ }
+ stateToken = base64.URLEncoding.EncodeToString(b)
+
+ // Generate code verifier (43-128 chars, base64url-safe)
+ verifierBytes := make([]byte, 32)
+ if _, err := rand.Read(verifierBytes); err != nil {
+ return "", "", "", fmt.Errorf("failed to generate code verifier: %w", err)
+ }
+ codeVerifier = base64.RawURLEncoding.EncodeToString(verifierBytes)
+
+ // code_challenge = base64url(SHA256(code_verifier))
+ h := sha256.Sum256([]byte(codeVerifier))
+ codeChallenge = base64.RawURLEncoding.EncodeToString(h[:])
+
+ state := &OAuthState{
+ State: stateToken,
+ UserID: userID,
+ Provider: provider,
+ CodeVerifier: codeVerifier,
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ }
+
+ if err := s.repo.CreateState(state); err != nil {
+ return "", "", "", err
+ }
+
+ return stateToken, codeVerifier, codeChallenge, nil
+}
+
+// ValidateState validates and consumes an OAuth state token.
+func (s *OAuthService) ValidateState(stateToken string) (*OAuthState, error) {
+ return s.repo.GetAndDeleteState(stateToken)
+}
+
+// TokenResponse is the standard OAuth2 token response.
+type TokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+ Scope string `json:"scope"`
+}
+
+// ExchangeCode exchanges an authorization code for tokens.
+func (s *OAuthService) ExchangeCode(tokenURL string, params url.Values) (*TokenResponse, error) {
+ resp, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(params.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("token exchange request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read token response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var tokenResp TokenResponse
+ if err := json.Unmarshal(body, &tokenResp); err != nil {
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
+ }
+
+ return &tokenResp, nil
+}
+
+// RefreshAccessToken refreshes an expired access token.
+func (s *OAuthService) RefreshAccessToken(tokenURL, clientID, clientSecret, refreshToken string) (*TokenResponse, error) {
+ params := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ "client_id": {clientID},
+ "client_secret": {clientSecret},
+ }
+
+ return s.ExchangeCode(tokenURL, params)
+}
+
+// SaveConnection encrypts tokens and stores the OAuth connection.
+func (s *OAuthService) SaveConnection(userID uint, provider string, tokenResp *TokenResponse) error {
+ encAccess, err := Encrypt(tokenResp.AccessToken, config.OAuth.EncryptionKey)
+ if err != nil {
+ return fmt.Errorf("failed to encrypt access token: %w", err)
+ }
+
+ encRefresh := ""
+ if tokenResp.RefreshToken != "" {
+ encRefresh, err = Encrypt(tokenResp.RefreshToken, config.OAuth.EncryptionKey)
+ if err != nil {
+ return fmt.Errorf("failed to encrypt refresh token: %w", err)
+ }
+ }
+
+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+
+ conn := &OAuthConnection{
+ UserID: userID,
+ Provider: provider,
+ AccessToken: encAccess,
+ RefreshToken: encRefresh,
+ TokenExpiresAt: expiresAt,
+ Scopes: tokenResp.Scope,
+ Status: "active",
+ }
+
+ return s.repo.UpsertConnection(conn)
+}
+
+// GetValidToken retrieves a connection and ensures the token is valid (refreshing if needed).
+func (s *OAuthService) GetValidToken(userID uint, provider string) (string, error) {
+ conn, err := s.repo.GetConnection(userID, provider)
+ if err != nil {
+ return "", err
+ }
+
+ if conn.Status != "active" {
+ return "", fmt.Errorf("%s connection is %s, please reconnect", provider, conn.Status)
+ }
+
+ accessToken, err := Decrypt(conn.AccessToken, config.OAuth.EncryptionKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to decrypt access token: %w", err)
+ }
+
+ // Token still valid (with 30s buffer)
+ if time.Now().Before(conn.TokenExpiresAt.Add(-30 * time.Second)) {
+ return accessToken, nil
+ }
+
+ // Token expired - try refresh
+ if conn.RefreshToken == "" {
+ conn.Status = "expired"
+ s.repo.UpdateConnection(conn)
+ return "", fmt.Errorf("%s token expired and no refresh token available, please reconnect", provider)
+ }
+
+ refreshToken, err := Decrypt(conn.RefreshToken, config.OAuth.EncryptionKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to decrypt refresh token: %w", err)
+ }
+
+ var providerConfig config.OAuthProviderConfig
+ switch provider {
+ case "garmin":
+ providerConfig = config.OAuth.Garmin
+ case "wahoo":
+ providerConfig = config.OAuth.Wahoo
+ default:
+ return "", errors.New("unknown provider")
+ }
+
+ tokenResp, err := s.RefreshAccessToken(providerConfig.TokenURL, providerConfig.ClientID, providerConfig.ClientSecret, refreshToken)
+ if err != nil {
+ conn.Status = "expired"
+ s.repo.UpdateConnection(conn)
+ return "", fmt.Errorf("%s token refresh failed, please reconnect: %w", provider, err)
+ }
+
+ // Save new tokens
+ if err := s.SaveConnection(userID, provider, tokenResp); err != nil {
+ return "", fmt.Errorf("failed to save refreshed tokens: %w", err)
+ }
+
+ return tokenResp.AccessToken, nil
+}
+
+// GetConnectionStatus returns the connection status for a user+provider.
+func (s *OAuthService) GetConnectionStatus(userID uint, provider string) (map[string]interface{}, error) {
+ conn, err := s.repo.GetConnection(userID, provider)
+ if err != nil {
+ return map[string]interface{}{
+ "connected": false,
+ "provider": provider,
+ }, nil
+ }
+
+ return map[string]interface{}{
+ "connected": conn.Status == "active",
+ "provider": conn.Provider,
+ "status": conn.Status,
+ "token_expires_at": conn.TokenExpiresAt,
+ "connected_at": conn.CreatedAt,
+ }, nil
+}
+
+// Disconnect removes an OAuth connection.
+func (s *OAuthService) Disconnect(userID uint, provider string) error {
+ return s.repo.DeleteConnection(userID, provider)
+}
+
+// Encrypt encrypts plaintext using AES-256-GCM with the given hex-encoded key.
+func Encrypt(plaintext, hexKey string) (string, error) {
+ if hexKey == "" {
+ // No encryption key configured - store as base64 (development mode)
+ return base64.StdEncoding.EncodeToString([]byte(plaintext)), nil
+ }
+
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return "", fmt.Errorf("invalid encryption key: %w", err)
+ }
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return "", err
+ }
+
+ ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// Decrypt decrypts ciphertext that was encrypted with Encrypt.
+func Decrypt(encoded, hexKey string) (string, error) {
+ if hexKey == "" {
+ // No encryption key - stored as plain base64
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", err
+ }
+ return string(decoded), nil
+ }
+
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return "", fmt.Errorf("invalid encryption key: %w", err)
+ }
+
+ ciphertext, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", err
+ }
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return "", errors.New("ciphertext too short")
+ }
+
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", err
+ }
+
+ return string(plaintext), nil
+}
diff --git a/internal/integration/repository.go b/internal/integration/repository.go
new file mode 100644
index 0000000..89c8d18
--- /dev/null
+++ b/internal/integration/repository.go
@@ -0,0 +1,93 @@
+package integration
+
+import (
+ "errors"
+ "rideaware/pkg/database"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type Repository struct{}
+
+func NewRepository() *Repository {
+ return &Repository{}
+}
+
+// UpsertConnection creates or updates an OAuth connection for a user+provider pair.
+func (r *Repository) UpsertConnection(conn *OAuthConnection) error {
+ var existing OAuthConnection
+ err := database.DB.Where("user_id = ? AND provider = ?", conn.UserID, conn.Provider).
+ First(&existing).Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return database.DB.Create(conn).Error
+ }
+ return err
+ }
+
+ existing.AccessToken = conn.AccessToken
+ existing.RefreshToken = conn.RefreshToken
+ existing.TokenExpiresAt = conn.TokenExpiresAt
+ existing.ProviderUserID = conn.ProviderUserID
+ existing.Scopes = conn.Scopes
+ existing.Status = "active"
+
+ conn.ID = existing.ID
+ return database.DB.Save(&existing).Error
+}
+
+// GetConnection retrieves an active OAuth connection for a user+provider pair.
+func (r *Repository) GetConnection(userID uint, provider string) (*OAuthConnection, error) {
+ var conn OAuthConnection
+ if err := database.DB.Where("user_id = ? AND provider = ?", userID, provider).
+ First(&conn).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("no " + provider + " connection found")
+ }
+ return nil, err
+ }
+ return &conn, nil
+}
+
+// UpdateConnection updates an existing OAuth connection.
+func (r *Repository) UpdateConnection(conn *OAuthConnection) error {
+ return database.DB.Save(conn).Error
+}
+
+// DeleteConnection removes an OAuth connection.
+func (r *Repository) DeleteConnection(userID uint, provider string) error {
+ return database.DB.Where("user_id = ? AND provider = ?", userID, provider).
+ Delete(&OAuthConnection{}).Error
+}
+
+// CreateState stores an OAuth state token for CSRF protection.
+func (r *Repository) CreateState(state *OAuthState) error {
+ return database.DB.Create(state).Error
+}
+
+// GetAndDeleteState retrieves and deletes an OAuth state token. Returns error if expired or not found.
+func (r *Repository) GetAndDeleteState(stateToken string) (*OAuthState, error) {
+ var state OAuthState
+ if err := database.DB.Where("state = ?", stateToken).First(&state).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("invalid or expired state token")
+ }
+ return nil, err
+ }
+
+ // Delete the state immediately (single-use)
+ database.DB.Delete(&state)
+
+ if time.Now().After(state.ExpiresAt) {
+ return nil, errors.New("state token has expired")
+ }
+
+ return &state, nil
+}
+
+// CleanupExpiredStates removes expired OAuth state tokens.
+func (r *Repository) CleanupExpiredStates() error {
+ return database.DB.Where("expires_at < ?", time.Now()).Delete(&OAuthState{}).Error
+}
diff --git a/internal/integration/wahoo_client.go b/internal/integration/wahoo_client.go
new file mode 100644
index 0000000..c5bde36
--- /dev/null
+++ b/internal/integration/wahoo_client.go
@@ -0,0 +1,336 @@
+package integration
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "rideaware/internal/config"
+ "rideaware/internal/user"
+ "rideaware/internal/workout"
+)
+
+type WahooClient struct {
+ oauthService *OAuthService
+ workoutRepo *workout.Repository
+ userRepo *user.Repository
+}
+
+func NewWahooClient() *WahooClient {
+ return &WahooClient{
+ oauthService: NewOAuthService(),
+ workoutRepo: workout.NewRepository(),
+ userRepo: user.NewRepository(),
+ }
+}
+
+// BuildAuthURL constructs the Wahoo OAuth2 authorization URL.
+func (c *WahooClient) BuildAuthURL(userID uint) (string, error) {
+ cfg := config.OAuth.Wahoo
+ if cfg.ClientID == "" {
+ return "", fmt.Errorf("Wahoo OAuth is not configured")
+ }
+
+ stateToken, err := c.oauthService.GenerateState(userID, "wahoo")
+ if err != nil {
+ return "", err
+ }
+
+ params := url.Values{
+ "client_id": {cfg.ClientID},
+ "response_type": {"code"},
+ "redirect_uri": {cfg.RedirectURI},
+ "scope": {"workouts_write plans_write user_read offline_data"},
+ "state": {stateToken},
+ }
+
+ return cfg.AuthURL + "?" + params.Encode(), nil
+}
+
+// HandleCallback exchanges the authorization code for tokens and stores them.
+func (c *WahooClient) HandleCallback(code, stateToken string) (uint, error) {
+ state, err := c.oauthService.ValidateState(stateToken)
+ if err != nil {
+ return 0, err
+ }
+
+ if state.Provider != "wahoo" {
+ return 0, fmt.Errorf("invalid state provider: expected wahoo, got %s", state.Provider)
+ }
+
+ cfg := config.OAuth.Wahoo
+ params := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {cfg.RedirectURI},
+ "client_id": {cfg.ClientID},
+ "client_secret": {cfg.ClientSecret},
+ }
+
+ tokenResp, err := c.oauthService.ExchangeCode(cfg.TokenURL, params)
+ if err != nil {
+ return 0, fmt.Errorf("Wahoo token exchange failed: %w", err)
+ }
+
+ if err := c.oauthService.SaveConnection(state.UserID, "wahoo", tokenResp); err != nil {
+ return 0, err
+ }
+
+ return state.UserID, nil
+}
+
+// Wahoo plan JSON structures
+
+type wahooPlanHeader struct {
+ Version string `json:"version"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ WorkoutTypeFamily string `json:"workout_type_family"`
+ WorkoutTypeLocation string `json:"workout_type_location"`
+ FTP int `json:"ftp"`
+ TotalDuration int `json:"total_duration"`
+}
+
+type wahooPlanTarget struct {
+ Type string `json:"type"`
+ Low float64 `json:"low"`
+ High float64 `json:"high"`
+}
+
+type wahooPlanInterval struct {
+ Name string `json:"name"`
+ Duration int `json:"duration"`
+ IntensityType string `json:"intensity_type"`
+ Targets []wahooPlanTarget `json:"targets"`
+}
+
+type wahooPlan struct {
+ Header wahooPlanHeader `json:"header"`
+ Intervals []wahooPlanInterval `json:"intervals"`
+}
+
+// PushWorkout sends a structured workout to Wahoo as a plan.
+func (c *WahooClient) PushWorkout(workoutID, userID uint) error {
+ accessToken, err := c.oauthService.GetValidToken(userID, "wahoo")
+ if err != nil {
+ return err
+ }
+
+ w, err := c.workoutRepo.GetWorkoutByID(workoutID, userID)
+ if err != nil {
+ return fmt.Errorf("workout not found: %w", err)
+ }
+
+ u, err := c.userRepo.GetUserByID(userID)
+ if err != nil {
+ return fmt.Errorf("user not found: %w", err)
+ }
+
+ ftp := 0
+ if u.Profile != nil {
+ ftp = u.Profile.FTP
+ }
+
+ plan := buildWahooPlan(w, ftp)
+
+ planJSON, err := json.Marshal(plan)
+ if err != nil {
+ return fmt.Errorf("failed to build Wahoo plan: %w", err)
+ }
+
+ // Step 1: Create plan via Wahoo API
+ planID, err := c.createWahooPlan(accessToken, planJSON, w.Title)
+ if err != nil {
+ return err
+ }
+
+ // Step 2: Create workout referencing the plan
+ if err := c.createWahooWorkout(accessToken, planID, w); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func buildWahooPlan(w *workout.Workout, ftp int) wahooPlan {
+ name := w.WorkoutData.Name
+ if name == "" {
+ name = w.Title
+ }
+
+ plan := wahooPlan{
+ Header: wahooPlanHeader{
+ Version: "1.0",
+ Name: name,
+ Description: w.Description,
+ WorkoutTypeFamily: "CYCLING",
+ WorkoutTypeLocation: "INDOOR",
+ FTP: ftp,
+ TotalDuration: w.WorkoutData.TotalDuration,
+ },
+ }
+
+ for _, seg := range w.WorkoutData.Segments {
+ interval := segmentToWahooInterval(seg)
+ plan.Intervals = append(plan.Intervals, interval)
+ }
+
+ return plan
+}
+
+func segmentToWahooInterval(seg workout.WorkoutSegment) wahooPlanInterval {
+ interval := wahooPlanInterval{
+ Duration: seg.Duration,
+ }
+
+ switch seg.Type {
+ case "warmup":
+ interval.Name = "Warm Up"
+ interval.IntensityType = "WARMUP"
+ if seg.PowerLow != 0 || seg.PowerHigh != 0 {
+ interval.Targets = []wahooPlanTarget{{
+ Type: "POWER_ZONE",
+ Low: seg.PowerLow,
+ High: seg.PowerHigh,
+ }}
+ }
+ case "steadystate":
+ interval.Name = "Steady State"
+ interval.IntensityType = "ACTIVE"
+ if seg.Power != 0 {
+ interval.Targets = []wahooPlanTarget{{
+ Type: "POWER_ZONE",
+ Low: seg.Power,
+ High: seg.Power,
+ }}
+ }
+ case "interval":
+ interval.Name = "Interval"
+ interval.IntensityType = "ACTIVE"
+ if seg.PowerLow != 0 || seg.PowerHigh != 0 {
+ interval.Targets = []wahooPlanTarget{{
+ Type: "POWER_ZONE",
+ Low: seg.PowerLow,
+ High: seg.PowerHigh,
+ }}
+ }
+ case "cooldown":
+ interval.Name = "Cool Down"
+ interval.IntensityType = "COOLDOWN"
+ if seg.PowerLow != 0 || seg.PowerHigh != 0 {
+ interval.Targets = []wahooPlanTarget{{
+ Type: "POWER_ZONE",
+ Low: seg.PowerLow,
+ High: seg.PowerHigh,
+ }}
+ }
+ case "ramp":
+ interval.Name = "Ramp"
+ interval.IntensityType = "ACTIVE"
+ if seg.PowerLow != 0 || seg.PowerHigh != 0 {
+ interval.Targets = []wahooPlanTarget{{
+ Type: "POWER_ZONE",
+ Low: seg.PowerLow,
+ High: seg.PowerHigh,
+ }}
+ }
+ case "freeride":
+ interval.Name = "Free Ride"
+ interval.IntensityType = "REST"
+ default:
+ interval.Name = seg.Type
+ interval.IntensityType = "ACTIVE"
+ }
+
+ return interval
+}
+
+func (c *WahooClient) createWahooPlan(accessToken string, planJSON []byte, title string) (string, error) {
+ apiURL := "https://api.wahooligan.com/v1/plans"
+
+ body := map[string]interface{}{
+ "plan": map[string]interface{}{
+ "name": title,
+ "file": planJSON,
+ },
+ }
+
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return "", err
+ }
+
+ req, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody))
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to create Wahoo plan: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return "", fmt.Errorf("Wahoo API returned status %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return "", fmt.Errorf("failed to parse Wahoo plan response: %w", err)
+ }
+
+ return result.ID, nil
+}
+
+func (c *WahooClient) createWahooWorkout(accessToken, planID string, w *workout.Workout) error {
+ apiURL := "https://api.wahooligan.com/v1/workouts"
+
+ body := map[string]interface{}{
+ "workout": map[string]interface{}{
+ "name": w.Title,
+ "plan_id": planID,
+ "workout_type": 0, // cycling
+ "scheduled_date": w.ScheduledDate.Format("2006-01-02"),
+ },
+ }
+
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to create Wahoo workout: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("Wahoo API returned status %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ return nil
+}
diff --git a/internal/integration/wahoo_handler.go b/internal/integration/wahoo_handler.go
new file mode 100644
index 0000000..61ba05d
--- /dev/null
+++ b/internal/integration/wahoo_handler.go
@@ -0,0 +1,158 @@
+package integration
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+
+ "rideaware/internal/config"
+ "rideaware/internal/middleware"
+)
+
+type WahooHandler struct {
+ client *WahooClient
+ oauthService *OAuthService
+}
+
+func NewWahooHandler() *WahooHandler {
+ return &WahooHandler{
+ client: NewWahooClient(),
+ oauthService: NewOAuthService(),
+ }
+}
+
+// StartAuth GET /api/protected/wahoo/auth - initiates Wahoo OAuth2 flow
+func (h *WahooHandler) StartAuth(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
+ }
+
+ authURL, err := h.client.BuildAuthURL(claims.UserID)
+ if err != nil {
+ log.Printf("Wahoo auth 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"auth_url": authURL})
+}
+
+// Callback GET /api/wahoo/callback - handles Wahoo OAuth callback (public endpoint)
+func (h *WahooHandler) Callback(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ state := r.URL.Query().Get("state")
+
+ if code == "" || state == "" {
+ errMsg := r.URL.Query().Get("error")
+ if errMsg == "" {
+ errMsg = "missing code or state parameter"
+ }
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?wahoo=error&message="+errMsg, http.StatusFound)
+ return
+ }
+
+ _, err := h.client.HandleCallback(code, state)
+ if err != nil {
+ log.Printf("Wahoo callback error: %v", err)
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?wahoo=error&message=auth_failed", http.StatusFound)
+ return
+ }
+
+ appURL := config.OAuth.AppURL
+ http.Redirect(w, r, appURL+"/settings?wahoo=connected", http.StatusFound)
+}
+
+// PushWorkout POST /api/protected/workouts/push/wahoo?id=X - pushes workout to Wahoo
+func (h *WahooHandler) 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("Wahoo 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "workout pushed to Wahoo"})
+}
+
+// ConnectionStatus GET /api/protected/wahoo/status - check Wahoo connection status
+func (h *WahooHandler) 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, "wahoo")
+ 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(status)
+}
+
+// Disconnect DELETE /api/protected/wahoo/disconnect - revoke Wahoo connection
+func (h *WahooHandler) 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, "wahoo"); 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")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "Wahoo disconnected"})
+}
diff --git a/internal/stats/handler.go b/internal/stats/handler.go
new file mode 100644
index 0000000..5de170c
--- /dev/null
+++ b/internal/stats/handler.go
@@ -0,0 +1,138 @@
+package stats
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "rideaware/internal/config"
+ "rideaware/internal/middleware"
+)
+
+type Handler struct {
+ service *Service
+}
+
+func NewHandler() *Handler {
+ return &Handler{
+ service: NewService(),
+ }
+}
+
+// GetSummary GET /api/protected/stats/summary
+func (h *Handler) GetSummary(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
+ }
+
+ summary, err := h.service.GetSummary(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 stats"})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(summary)
+}
+
+// GetWeeklyStats GET /api/protected/stats/weekly?weeks=12
+func (h *Handler) GetWeeklyStats(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
+ }
+
+ weeks := 12
+ if w_str := r.URL.Query().Get("weeks"); w_str != "" {
+ if parsed, err := strconv.Atoi(w_str); err == nil {
+ weeks = parsed
+ }
+ }
+
+ stats, err := h.service.GetWeeklyStats(claims.UserID, weeks)
+ 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 weekly stats"})
+ return
+ }
+
+ if stats == nil {
+ stats = []PeriodStats{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(stats)
+}
+
+// GetMonthlyStats GET /api/protected/stats/monthly?months=12
+func (h *Handler) GetMonthlyStats(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
+ }
+
+ months := 12
+ if m_str := r.URL.Query().Get("months"); m_str != "" {
+ if parsed, err := strconv.Atoi(m_str); err == nil {
+ months = parsed
+ }
+ }
+
+ stats, err := h.service.GetMonthlyStats(claims.UserID, months)
+ 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 monthly stats"})
+ return
+ }
+
+ if stats == nil {
+ stats = []PeriodStats{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(stats)
+}
+
+// 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)
+ if claims == nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
+ return
+ }
+
+ pbs, err := h.service.GetPersonalBests(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 personal bests"})
+ return
+ }
+
+ if pbs == nil {
+ pbs = []PersonalBest{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(pbs)
+}
diff --git a/internal/stats/repository.go b/internal/stats/repository.go
new file mode 100644
index 0000000..98a8b39
--- /dev/null
+++ b/internal/stats/repository.go
@@ -0,0 +1,222 @@
+package stats
+
+import (
+ "rideaware/internal/workout"
+ "rideaware/pkg/database"
+ "time"
+)
+
+type Repository struct{}
+
+func NewRepository() *Repository {
+ return &Repository{}
+}
+
+// Summary holds overall aggregated stats for a user.
+type Summary struct {
+ TotalRides int `json:"total_rides"`
+ TotalDistance float64 `json:"total_distance"`
+ TotalDuration int `json:"total_duration"`
+ TotalElevGain int `json:"total_elev_gain"`
+ TotalCalories int `json:"total_calories"`
+ AvgPower float64 `json:"avg_power"`
+ AvgHR float64 `json:"avg_hr"`
+ AvgDistance float64 `json:"avg_distance"`
+ AvgDuration float64 `json:"avg_duration"`
+ LongestRide int `json:"longest_ride"`
+ FarthestRide float64 `json:"farthest_ride"`
+ MaxPower int `json:"max_power"`
+ MaxHR int `json:"max_hr"`
+ MostElevGain int `json:"most_elev_gain"`
+}
+
+// PeriodStats holds aggregated stats for a time period.
+type PeriodStats struct {
+ Period string `json:"period"`
+ Year int `json:"year"`
+ Rides int `json:"rides"`
+ Distance float64 `json:"distance"`
+ Duration int `json:"duration"`
+ ElevGain int `json:"elev_gain"`
+ Calories int `json:"calories"`
+ AvgPower float64 `json:"avg_power"`
+ AvgHR float64 `json:"avg_hr"`
+}
+
+// GetSummary returns overall ride statistics for completed workouts.
+func (r *Repository) GetSummary(userID uint) (*Summary, error) {
+ var summary Summary
+
+ err := database.DB.Model(&workout.Workout{}).
+ Select(`
+ COUNT(*) as total_rides,
+ COALESCE(SUM(distance), 0) as total_distance,
+ COALESCE(SUM(duration), 0) as total_duration,
+ COALESCE(SUM(elev_gain), 0) as total_elev_gain,
+ COALESCE(SUM(calories_burned), 0) as total_calories,
+ COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
+ COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr,
+ COALESCE(AVG(distance), 0) as avg_distance,
+ COALESCE(AVG(duration), 0) as avg_duration,
+ COALESCE(MAX(duration), 0) as longest_ride,
+ COALESCE(MAX(distance), 0) as farthest_ride,
+ COALESCE(MAX(max_power), 0) as max_power,
+ COALESCE(MAX(max_hr), 0) as max_hr,
+ COALESCE(MAX(elev_gain), 0) as most_elev_gain
+ `).
+ Where("user_id = ? AND status = ?", userID, "completed").
+ Scan(&summary).Error
+
+ return &summary, err
+}
+
+// GetWeeklyStats returns weekly aggregated stats for the last N weeks.
+func (r *Repository) GetWeeklyStats(userID uint, weeks int) ([]PeriodStats, error) {
+ cutoff := time.Now().AddDate(0, 0, -weeks*7)
+
+ var stats []PeriodStats
+ err := database.DB.Model(&workout.Workout{}).
+ Select(`
+ TO_CHAR(scheduled_date, 'IYYY-IW') as period,
+ EXTRACT(ISOYEAR FROM scheduled_date)::int as year,
+ COUNT(*) as rides,
+ COALESCE(SUM(distance), 0) as distance,
+ COALESCE(SUM(duration), 0) as duration,
+ COALESCE(SUM(elev_gain), 0) as elev_gain,
+ COALESCE(SUM(calories_burned), 0) as calories,
+ COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
+ COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr
+ `).
+ Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
+ Group("period, year").
+ Order("year ASC, period ASC").
+ Scan(&stats).Error
+
+ return stats, err
+}
+
+// GetMonthlyStats returns monthly aggregated stats for the last N months.
+func (r *Repository) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error) {
+ cutoff := time.Now().AddDate(0, -months, 0)
+
+ var stats []PeriodStats
+ err := database.DB.Model(&workout.Workout{}).
+ Select(`
+ TO_CHAR(scheduled_date, 'YYYY-MM') as period,
+ EXTRACT(YEAR FROM scheduled_date)::int as year,
+ COUNT(*) as rides,
+ COALESCE(SUM(distance), 0) as distance,
+ COALESCE(SUM(duration), 0) as duration,
+ COALESCE(SUM(elev_gain), 0) as elev_gain,
+ COALESCE(SUM(calories_burned), 0) as calories,
+ COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
+ COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr
+ `).
+ Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
+ Group("period, year").
+ Order("year ASC, period ASC").
+ Scan(&stats).Error
+
+ return stats, err
+}
+
+// PersonalBest holds a single personal best record.
+type PersonalBest struct {
+ Category string `json:"category"`
+ Value interface{} `json:"value"`
+ Unit string `json:"unit"`
+ WorkoutID uint `json:"workout_id"`
+ Date time.Time `json:"date"`
+ Title string `json:"title"`
+}
+
+// GetPersonalBests returns personal best records across key metrics.
+func (r *Repository) GetPersonalBests(userID uint) ([]PersonalBest, error) {
+ var pbs []PersonalBest
+
+ type pbRow struct {
+ ID uint
+ Title string
+ ScheduledDate time.Time
+ MaxPower int
+ MaxHR int
+ Distance float64
+ Duration int
+ ElevGain int
+ AvgPower int
+ }
+
+ // Max Power
+ var maxPower pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND max_power > 0", userID, "completed").
+ Order("max_power DESC").Limit(1).
+ Scan(&maxPower).Error; err == nil && maxPower.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "max_power", Value: maxPower.MaxPower, Unit: "watts",
+ WorkoutID: maxPower.ID, Date: maxPower.ScheduledDate, Title: maxPower.Title,
+ })
+ }
+
+ // Max HR
+ var maxHR pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND max_hr > 0", userID, "completed").
+ Order("max_hr DESC").Limit(1).
+ Scan(&maxHR).Error; err == nil && maxHR.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "max_hr", Value: maxHR.MaxHR, Unit: "bpm",
+ WorkoutID: maxHR.ID, Date: maxHR.ScheduledDate, Title: maxHR.Title,
+ })
+ }
+
+ // Longest Ride (duration)
+ var longest pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND duration > 0", userID, "completed").
+ Order("duration DESC").Limit(1).
+ Scan(&longest).Error; err == nil && longest.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "longest_ride", Value: longest.Duration, Unit: "seconds",
+ WorkoutID: longest.ID, Date: longest.ScheduledDate, Title: longest.Title,
+ })
+ }
+
+ // Farthest Ride (distance)
+ var farthest pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND distance > 0", userID, "completed").
+ Order("distance DESC").Limit(1).
+ Scan(&farthest).Error; err == nil && farthest.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "farthest_ride", Value: farthest.Distance, Unit: "km",
+ WorkoutID: farthest.ID, Date: farthest.ScheduledDate, Title: farthest.Title,
+ })
+ }
+
+ // Most Elevation
+ var mostElev pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND elev_gain > 0", userID, "completed").
+ Order("elev_gain DESC").Limit(1).
+ Scan(&mostElev).Error; err == nil && mostElev.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "most_elevation", Value: mostElev.ElevGain, Unit: "meters",
+ WorkoutID: mostElev.ID, Date: mostElev.ScheduledDate, Title: mostElev.Title,
+ })
+ }
+
+ // Best Avg Power
+ var bestAvgPower pbRow
+ if err := database.DB.Model(&workout.Workout{}).
+ Where("user_id = ? AND status = ? AND avg_power > 0", userID, "completed").
+ Order("avg_power DESC").Limit(1).
+ Scan(&bestAvgPower).Error; err == nil && bestAvgPower.ID != 0 {
+ pbs = append(pbs, PersonalBest{
+ Category: "best_avg_power", Value: bestAvgPower.AvgPower, Unit: "watts",
+ WorkoutID: bestAvgPower.ID, Date: bestAvgPower.ScheduledDate, Title: bestAvgPower.Title,
+ })
+ }
+
+ return pbs, nil
+}
diff --git a/internal/stats/service.go b/internal/stats/service.go
new file mode 100644
index 0000000..39a9d49
--- /dev/null
+++ b/internal/stats/service.go
@@ -0,0 +1,33 @@
+package stats
+
+type Service struct {
+ repo *Repository
+}
+
+func NewService() *Service {
+ return &Service{
+ repo: NewRepository(),
+ }
+}
+
+func (s *Service) GetSummary(userID uint) (*Summary, error) {
+ return s.repo.GetSummary(userID)
+}
+
+func (s *Service) GetWeeklyStats(userID uint, weeks int) ([]PeriodStats, error) {
+ if weeks <= 0 || weeks > 52 {
+ weeks = 12
+ }
+ return s.repo.GetWeeklyStats(userID, weeks)
+}
+
+func (s *Service) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error) {
+ if months <= 0 || months > 24 {
+ months = 12
+ }
+ return s.repo.GetMonthlyStats(userID, months)
+}
+
+func (s *Service) GetPersonalBests(userID uint) ([]PersonalBest, error) {
+ return s.repo.GetPersonalBests(userID)
+}
diff --git a/internal/templates/handler.go b/internal/templates/handler.go
new file mode 100644
index 0000000..bdbdfba
--- /dev/null
+++ b/internal/templates/handler.go
@@ -0,0 +1,175 @@
+package templates
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+
+ "rideaware/internal/config"
+ "rideaware/internal/middleware"
+ "rideaware/internal/workout"
+)
+
+type Handler struct {
+ workoutRepo *workout.Repository
+}
+
+func NewHandler() *Handler {
+ return &Handler{
+ workoutRepo: workout.NewRepository(),
+ }
+}
+
+// TemplateSummary is a lighter view of a template for listing.
+type TemplateSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Type string `json:"type"`
+ Duration int `json:"duration"`
+ Difficulty string `json:"difficulty"`
+ Category string `json:"category"`
+ Segments int `json:"segments"`
+}
+
+// ListTemplates GET /api/protected/workout-templates
+func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
+ category := r.URL.Query().Get("category")
+
+ var source []Template
+ if category != "" {
+ source = GetByCategory(category)
+ } else {
+ source = All
+ }
+
+ summaries := make([]TemplateSummary, 0, len(source))
+ for _, t := range source {
+ summaries = append(summaries, TemplateSummary{
+ ID: t.ID,
+ Name: t.Name,
+ Description: t.Description,
+ Type: t.Type,
+ Duration: t.Duration,
+ Difficulty: t.Difficulty,
+ Category: t.Category,
+ Segments: len(t.Segments),
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(summaries)
+}
+
+// GetTemplate GET /api/protected/workout-templates/detail?id=X
+func (h *Handler) GetTemplate(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ if id == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "template id is required"})
+ return
+ }
+
+ t := GetByID(id)
+ if t == nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(t)
+}
+
+// CreateFromTemplate POST /api/protected/workouts/from-template
+func (h *Handler) CreateFromTemplate(w http.ResponseWriter, r *http.Request) {
+ claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
+ if claims == nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
+ return
+ }
+
+ var req struct {
+ TemplateID string `json:"template_id"`
+ ScheduledDate string `json:"scheduled_date"`
+ Title string `json:"title"`
+ Notes string `json:"notes"`
+ EquipmentID *uint `json:"equipment_id"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
+ return
+ }
+
+ if req.TemplateID == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "template_id is required"})
+ return
+ }
+
+ tmpl := GetByID(req.TemplateID)
+ if tmpl == nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
+ return
+ }
+
+ scheduledDate := time.Now()
+ if req.ScheduledDate != "" {
+ parsed, err := time.Parse("2006-01-02", req.ScheduledDate)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "invalid scheduled_date format, use YYYY-MM-DD"})
+ return
+ }
+ scheduledDate = parsed
+ }
+
+ title := req.Title
+ if title == "" {
+ title = tmpl.Name
+ }
+
+ newWorkout := &workout.Workout{
+ UserID: claims.UserID,
+ Title: title,
+ Description: tmpl.Description,
+ Type: tmpl.Type,
+ Status: "planned",
+ ScheduledDate: scheduledDate,
+ Duration: tmpl.Duration,
+ EquipmentID: req.EquipmentID,
+ Notes: req.Notes,
+ WorkoutData: workout.WorkoutDataJSON{
+ Name: tmpl.Name,
+ Author: "RideAware",
+ TotalDuration: tmpl.Duration,
+ Segments: tmpl.Segments,
+ },
+ }
+
+ if err := h.workoutRepo.CreateWorkout(newWorkout); err != nil {
+ log.Printf("Create from template error: %v", err)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "failed to create workout"})
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(newWorkout)
+}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
new file mode 100644
index 0000000..53c9f0c
--- /dev/null
+++ b/internal/templates/templates.go
@@ -0,0 +1,304 @@
+package templates
+
+import "rideaware/internal/workout"
+
+// Template represents a predefined workout template.
+type Template struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Type string `json:"type"`
+ Duration int `json:"duration"`
+ Difficulty string `json:"difficulty"` // "easy", "moderate", "hard", "very_hard"
+ Category string `json:"category"` // "endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"
+ Segments []workout.WorkoutSegment `json:"segments"`
+}
+
+// All returns the full list of workout templates.
+var All = []Template{
+ {
+ ID: "recovery-spin",
+ Name: "Recovery Spin",
+ Description: "Easy spin to promote blood flow and recovery. Keep it light and conversational.",
+ Type: "Recovery",
+ Duration: 1800, // 30 min
+ Difficulty: "easy",
+ Category: "recovery",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 300, PowerLow: 0.30, PowerHigh: 0.45},
+ {Type: "steadystate", Duration: 1200, Power: 0.45, Cadence: 90},
+ {Type: "cooldown", Duration: 300, PowerLow: 0.45, PowerHigh: 0.30},
+ },
+ },
+ {
+ ID: "endurance-60",
+ Name: "Endurance Base",
+ Description: "Steady zone 2 ride to build aerobic base. Maintain a comfortable, sustainable effort.",
+ Type: "Endurance",
+ Duration: 3600, // 60 min
+ Difficulty: "easy",
+ Category: "endurance",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
+ {Type: "steadystate", Duration: 2400, Power: 0.65, Cadence: 85},
+ {Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "endurance-90",
+ Name: "Long Endurance",
+ Description: "Extended zone 2 ride for deep aerobic adaptation. Pack snacks.",
+ Type: "Endurance",
+ Duration: 5400, // 90 min
+ Difficulty: "moderate",
+ Category: "endurance",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
+ {Type: "steadystate", Duration: 4200, Power: 0.68, Cadence: 85},
+ {Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "tempo-45",
+ Name: "Tempo Ride",
+ Description: "Sustained zone 3 effort. Comfortably hard - you can talk in short sentences.",
+ Type: "Tempo",
+ Duration: 2700, // 45 min
+ Difficulty: "moderate",
+ Category: "endurance",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.70},
+ {Type: "steadystate", Duration: 1200, Power: 0.78, Cadence: 90},
+ {Type: "steadystate", Duration: 300, Power: 0.55},
+ {Type: "steadystate", Duration: 300, Power: 0.78, Cadence: 90},
+ {Type: "cooldown", Duration: 300, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "sweet-spot-60",
+ Name: "Sweet Spot",
+ Description: "The most time-efficient training zone. 88-94% FTP intervals with short recovery.",
+ Type: "Threshold",
+ Duration: 3600, // 60 min
+ Difficulty: "moderate",
+ Category: "sweet_spot",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ {Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
+ {Type: "steadystate", Duration: 180, Power: 0.55},
+ {Type: "steadystate", Duration: 600, Power: 0.92, Cadence: 90},
+ {Type: "steadystate", Duration: 180, Power: 0.55},
+ {Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
+ {Type: "steadystate", Duration: 180, Power: 0.55},
+ {Type: "cooldown", Duration: 660, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "threshold-intervals",
+ Name: "Threshold Intervals",
+ Description: "Classic 2x20 at FTP. The gold standard for raising your threshold.",
+ Type: "Threshold",
+ Duration: 3600, // 60 min
+ Difficulty: "hard",
+ Category: "threshold",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ {Type: "steadystate", Duration: 300, Power: 0.80, Cadence: 90},
+ {Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
+ {Type: "steadystate", Duration: 300, Power: 0.50},
+ {Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
+ {Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "over-unders",
+ Name: "Over-Under Intervals",
+ Description: "Alternating between just below and just above FTP. Builds lactate tolerance.",
+ Type: "Threshold",
+ Duration: 3600, // 60 min
+ Difficulty: "hard",
+ Category: "threshold",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ // Set 1
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 300, Power: 0.50},
+ // Set 2
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 300, Power: 0.50},
+ // Set 3
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
+ {Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
+ {Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
+ },
+ },
+ {
+ ID: "vo2max-intervals",
+ Name: "VO2max Intervals",
+ Description: "5x3min at 115% FTP with equal rest. Expands your aerobic ceiling.",
+ Type: "VO2 Max",
+ Duration: 2700, // 45 min
+ Difficulty: "very_hard",
+ Category: "vo2max",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ {Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
+ {Type: "steadystate", Duration: 180, Power: 0.45},
+ {Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
+ {Type: "steadystate", Duration: 180, Power: 0.45},
+ {Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
+ {Type: "steadystate", Duration: 180, Power: 0.45},
+ {Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
+ {Type: "steadystate", Duration: 180, Power: 0.45},
+ {Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
+ {Type: "cooldown", Duration: 420, PowerLow: 0.55, PowerHigh: 0.35},
+ },
+ },
+ {
+ ID: "vo2max-short",
+ Name: "VO2max Short-Shorts",
+ Description: "30/30s at 120% FTP. Accumulate VO2max time in manageable chunks.",
+ Type: "VO2 Max",
+ Duration: 2400, // 40 min
+ Difficulty: "very_hard",
+ Category: "vo2max",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ // Set 1: 10x 30/30
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 300, Power: 0.40},
+ // Set 2: 10x 30/30
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "steadystate", Duration: 30, Power: 0.40},
+ {Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
+ {Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.35},
+ },
+ },
+ {
+ ID: "sprint-intervals",
+ Name: "Sprint Power",
+ Description: "Short maximal efforts to build neuromuscular power and sprint capacity.",
+ Type: "VO2 Max",
+ Duration: 2700, // 45 min
+ Difficulty: "very_hard",
+ Category: "sprint",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
+ {Type: "steadystate", Duration: 300, Power: 0.85, Cadence: 95},
+ {Type: "steadystate", Duration: 120, Power: 0.50},
+ // Sprints: 8x 15s all-out / 105s recovery
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "steadystate", Duration: 105, Power: 0.40},
+ {Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
+ {Type: "cooldown", Duration: 480, PowerLow: 0.55, PowerHigh: 0.35},
+ },
+ },
+ {
+ ID: "ramp-test",
+ Name: "FTP Ramp Test",
+ Description: "Progressive ramp to estimate your FTP. Ride until you can't hold the target.",
+ Type: "Threshold",
+ Duration: 1500, // ~25 min (most people fail around 19-22 min)
+ Difficulty: "very_hard",
+ Category: "threshold",
+ Segments: []workout.WorkoutSegment{
+ {Type: "warmup", Duration: 300, PowerLow: 0.40, PowerHigh: 0.46},
+ {Type: "ramp", Duration: 60, PowerLow: 0.46, PowerHigh: 0.52},
+ {Type: "ramp", Duration: 60, PowerLow: 0.52, PowerHigh: 0.58},
+ {Type: "ramp", Duration: 60, PowerLow: 0.58, PowerHigh: 0.64},
+ {Type: "ramp", Duration: 60, PowerLow: 0.64, PowerHigh: 0.70},
+ {Type: "ramp", Duration: 60, PowerLow: 0.70, PowerHigh: 0.76},
+ {Type: "ramp", Duration: 60, PowerLow: 0.76, PowerHigh: 0.82},
+ {Type: "ramp", Duration: 60, PowerLow: 0.82, PowerHigh: 0.88},
+ {Type: "ramp", Duration: 60, PowerLow: 0.88, PowerHigh: 0.94},
+ {Type: "ramp", Duration: 60, PowerLow: 0.94, PowerHigh: 1.00},
+ {Type: "ramp", Duration: 60, PowerLow: 1.00, PowerHigh: 1.06},
+ {Type: "ramp", Duration: 60, PowerLow: 1.06, PowerHigh: 1.12},
+ {Type: "ramp", Duration: 60, PowerLow: 1.12, PowerHigh: 1.18},
+ {Type: "ramp", Duration: 60, PowerLow: 1.18, PowerHigh: 1.24},
+ {Type: "ramp", Duration: 60, PowerLow: 1.24, PowerHigh: 1.30},
+ {Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.30},
+ },
+ },
+}
+
+// GetByID returns a template by its ID, or nil if not found.
+func GetByID(id string) *Template {
+ for i := range All {
+ if All[i].ID == id {
+ return &All[i]
+ }
+ }
+ return nil
+}
+
+// GetByCategory returns all templates matching a category.
+func GetByCategory(category string) []Template {
+ var result []Template
+ for _, t := range All {
+ if t.Category == category {
+ result = append(result, t)
+ }
+ }
+ return result
+}
diff --git a/internal/workout/handler.go b/internal/workout/handler.go
index a887261..e9649ea 100644
--- a/internal/workout/handler.go
+++ b/internal/workout/handler.go
@@ -45,6 +45,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
Notes string `json:"notes"`
WorkoutData *WorkoutDataJSON `json:"workout_data"`
FileType string `json:"file_type"`
+ EquipmentID *uint `json:"equipment_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -106,6 +107,7 @@ func (h *Handler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
Notes: req.Notes,
FileType: req.FileType,
WorkoutData: *workoutData,
+ EquipmentID: req.EquipmentID,
}
log.Printf("Creating workout: %+v", workout)
@@ -211,6 +213,7 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
MaxHR int `json:"max_hr"`
CaloriesBurned int `json:"calories_burned"`
Notes string `json:"notes"`
+ EquipmentID *uint `json:"equipment_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -267,6 +270,9 @@ func (h *Handler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
if req.Notes != "" {
workout.Notes = req.Notes
}
+ if req.EquipmentID != nil {
+ workout.EquipmentID = req.EquipmentID
+ }
if err := h.service.repo.UpdateWorkout(workout); err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -320,6 +326,26 @@ func (h *Handler) GetWorkoutTypes(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(types)
}
+// GetEquipmentStats GET /api/protected/workouts/equipment-stats
+func (h *Handler) GetEquipmentStats(w http.ResponseWriter, r *http.Request) {
+ claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
+
+ stats, err := h.service.GetEquipmentStats(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 equipment stats"})
+ return
+ }
+
+ if stats == nil {
+ stats = []EquipmentStat{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(stats)
+}
+
// UploadWorkoutFile POST /api/protected/workouts/upload
func (h *Handler) UploadWorkoutFile(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
diff --git a/internal/workout/model.go b/internal/workout/model.go
index bc7cc53..66dece9 100644
--- a/internal/workout/model.go
+++ b/internal/workout/model.go
@@ -23,6 +23,7 @@ type Workout struct {
MaxHR int `gorm:"default:0" json:"max_hr"`
CaloriesBurned int `gorm:"default:0" json:"calories_burned"`
FileType string `gorm:"default:''" json:"file_type"`
+ EquipmentID *uint `gorm:"index" json:"equipment_id"`
FileURL string `gorm:"default:''" json:"file_url"`
WorkoutData WorkoutDataJSON `gorm:"type:jsonb" json:"workout_data,omitempty"`
Notes string `json:"notes"`
diff --git a/internal/workout/repository.go b/internal/workout/repository.go
index 6672483..b3ef94f 100644
--- a/internal/workout/repository.go
+++ b/internal/workout/repository.go
@@ -62,4 +62,23 @@ func (r *Repository) UpdateWorkout(workout *Workout) error {
func (r *Repository) DeleteWorkout(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).
Delete(&Workout{}).Error
+}
+
+type EquipmentStat struct {
+ EquipmentID uint `json:"equipment_id"`
+ TotalRides int `json:"total_rides"`
+ TotalDistance float64 `json:"total_distance"`
+ TotalDuration int `json:"total_duration"`
+}
+
+func (r *Repository) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
+ var stats []EquipmentStat
+ if err := database.DB.Model(&Workout{}).
+ Select("equipment_id, COUNT(*) as total_rides, COALESCE(SUM(distance), 0) as total_distance, COALESCE(SUM(duration), 0) as total_duration").
+ Where("user_id = ? AND equipment_id IS NOT NULL", userID).
+ Group("equipment_id").
+ Scan(&stats).Error; err != nil {
+ return nil, err
+ }
+ return stats, nil
}
\ No newline at end of file
diff --git a/internal/workout/service.go b/internal/workout/service.go
index 9593fda..69b68bd 100644
--- a/internal/workout/service.go
+++ b/internal/workout/service.go
@@ -85,4 +85,8 @@ func (s *Service) UpdateWorkoutWithMetrics(id, userID uint, distance float64, av
func (s *Service) DeleteWorkout(id, userID uint) error {
return s.repo.DeleteWorkout(id, userID)
+}
+
+func (s *Service) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
+ return s.repo.GetEquipmentStats(userID)
}
\ No newline at end of file
diff --git a/scripts/build.sh b/scripts/build.sh
index 2aefd2b..2d29bc4 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -15,8 +15,8 @@ IMAGE_TAG="latest"
NO_CACHE=false
RUN_CONTAINER=false
CONTAINER_NAME="rideaware-api"
-HOST_PORT="5000"
-CONTAINER_PORT="5000"
+HOST_PORT="5010"
+CONTAINER_PORT="5010"
# Help function
show_help() {
@@ -192,4 +192,4 @@ else
fi
echo ""
-echo -e "${GREEN}✓ Done!${NC}"
\ No newline at end of file
+echo -e "${GREEN}✓ Done!${NC}"