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}"