feat: extend equipment and workout models with service tracking
This commit is contained in:
115
TODO.md
115
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
|
||||
- [ ] Strava OAuth (sign in + activity sync)
|
||||
|
||||
### Platform Sync
|
||||
- [ ] Strava activity sync (import completed rides)
|
||||
- [ ] TrainingPeaks calendar sync
|
||||
- [ ] Intervals.icu integration
|
||||
195
cmd/server/Untitled-1.dockerfile
Normal file
195
cmd/server/Untitled-1.dockerfile
Normal file
@@ -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}"
|
||||
@@ -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")
|
||||
|
||||
4
go.mod
4
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
|
||||
)
|
||||
|
||||
14
go.sum
14
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=
|
||||
|
||||
94
internal/activity/fit_parser.go
Normal file
94
internal/activity/fit_parser.go
Normal file
@@ -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
|
||||
}
|
||||
251
internal/activity/gpx_parser.go
Normal file
251
internal/activity/gpx_parser.go
Normal file
@@ -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 := "<ext>" + innerXML + "</ext>"
|
||||
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: <hr><Value>145</Value></hr>
|
||||
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
|
||||
}
|
||||
103
internal/activity/handler.go
Normal file
103
internal/activity/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
18
internal/activity/parser.go
Normal file
18
internal/activity/parser.go
Normal file
@@ -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
|
||||
}
|
||||
126
internal/activity/service.go
Normal file
126
internal/activity/service.go
Normal file
@@ -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
|
||||
}
|
||||
181
internal/activity/tcx_parser.go
Normal file
181
internal/activity/tcx_parser.go
Normal file
@@ -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))
|
||||
}
|
||||
46
internal/config/oauth.go
Normal file
46
internal/config/oauth.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -12,10 +12,51 @@ type Equipment struct {
|
||||
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 {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -3,6 +3,8 @@ package equipment
|
||||
import (
|
||||
"errors"
|
||||
"rideaware/pkg/database"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -54,3 +56,28 @@ 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
144
internal/export/fit_encoder.go
Normal file
144
internal/export/fit_encoder.go
Normal file
@@ -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)
|
||||
}
|
||||
106
internal/export/handler.go
Normal file
106
internal/export/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
82
internal/export/service.go
Normal file
82
internal/export/service.go
Normal file
@@ -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)
|
||||
}
|
||||
169
internal/export/zwo_generator.go
Normal file
169
internal/export/zwo_generator.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
140
internal/integration/garmin_client.go
Normal file
140
internal/integration/garmin_client.go
Normal file
@@ -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
|
||||
}
|
||||
158
internal/integration/garmin_handler.go
Normal file
158
internal/integration/garmin_handler.go
Normal file
@@ -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"})
|
||||
}
|
||||
35
internal/integration/model.go
Normal file
35
internal/integration/model.go
Normal file
@@ -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"
|
||||
}
|
||||
325
internal/integration/oauth_service.go
Normal file
325
internal/integration/oauth_service.go
Normal file
@@ -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
|
||||
}
|
||||
93
internal/integration/repository.go
Normal file
93
internal/integration/repository.go
Normal file
@@ -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
|
||||
}
|
||||
336
internal/integration/wahoo_client.go
Normal file
336
internal/integration/wahoo_client.go
Normal file
@@ -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
|
||||
}
|
||||
158
internal/integration/wahoo_handler.go
Normal file
158
internal/integration/wahoo_handler.go
Normal file
@@ -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"})
|
||||
}
|
||||
138
internal/stats/handler.go
Normal file
138
internal/stats/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
222
internal/stats/repository.go
Normal file
222
internal/stats/repository.go
Normal file
@@ -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
|
||||
}
|
||||
33
internal/stats/service.go
Normal file
33
internal/stats/service.go
Normal file
@@ -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)
|
||||
}
|
||||
175
internal/templates/handler.go
Normal file
175
internal/templates/handler.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
workoutRepo *workout.Repository
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
workoutRepo: workout.NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateSummary is a lighter view of a template for listing.
|
||||
type TemplateSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Category string `json:"category"`
|
||||
Segments int `json:"segments"`
|
||||
}
|
||||
|
||||
// ListTemplates GET /api/protected/workout-templates
|
||||
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
var source []Template
|
||||
if category != "" {
|
||||
source = GetByCategory(category)
|
||||
} else {
|
||||
source = All
|
||||
}
|
||||
|
||||
summaries := make([]TemplateSummary, 0, len(source))
|
||||
for _, t := range source {
|
||||
summaries = append(summaries, TemplateSummary{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Type: t.Type,
|
||||
Duration: t.Duration,
|
||||
Difficulty: t.Difficulty,
|
||||
Category: t.Category,
|
||||
Segments: len(t.Segments),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(summaries)
|
||||
}
|
||||
|
||||
// GetTemplate GET /api/protected/workout-templates/detail?id=X
|
||||
func (h *Handler) GetTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "template id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
t := GetByID(id)
|
||||
if t == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(t)
|
||||
}
|
||||
|
||||
// CreateFromTemplate POST /api/protected/workouts/from-template
|
||||
func (h *Handler) CreateFromTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
ScheduledDate string `json:"scheduled_date"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
EquipmentID *uint `json:"equipment_id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.TemplateID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "template_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := GetByID(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
scheduledDate := time.Now()
|
||||
if req.ScheduledDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", req.ScheduledDate)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid scheduled_date format, use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
scheduledDate = parsed
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = tmpl.Name
|
||||
}
|
||||
|
||||
newWorkout := &workout.Workout{
|
||||
UserID: claims.UserID,
|
||||
Title: title,
|
||||
Description: tmpl.Description,
|
||||
Type: tmpl.Type,
|
||||
Status: "planned",
|
||||
ScheduledDate: scheduledDate,
|
||||
Duration: tmpl.Duration,
|
||||
EquipmentID: req.EquipmentID,
|
||||
Notes: req.Notes,
|
||||
WorkoutData: workout.WorkoutDataJSON{
|
||||
Name: tmpl.Name,
|
||||
Author: "RideAware",
|
||||
TotalDuration: tmpl.Duration,
|
||||
Segments: tmpl.Segments,
|
||||
},
|
||||
}
|
||||
|
||||
if err := h.workoutRepo.CreateWorkout(newWorkout); err != nil {
|
||||
log.Printf("Create from template error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create workout"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(newWorkout)
|
||||
}
|
||||
304
internal/templates/templates.go
Normal file
304
internal/templates/templates.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package templates
|
||||
|
||||
import "rideaware/internal/workout"
|
||||
|
||||
// Template represents a predefined workout template.
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration"`
|
||||
Difficulty string `json:"difficulty"` // "easy", "moderate", "hard", "very_hard"
|
||||
Category string `json:"category"` // "endurance", "threshold", "vo2max", "recovery", "sprint", "sweet_spot"
|
||||
Segments []workout.WorkoutSegment `json:"segments"`
|
||||
}
|
||||
|
||||
// All returns the full list of workout templates.
|
||||
var All = []Template{
|
||||
{
|
||||
ID: "recovery-spin",
|
||||
Name: "Recovery Spin",
|
||||
Description: "Easy spin to promote blood flow and recovery. Keep it light and conversational.",
|
||||
Type: "Recovery",
|
||||
Duration: 1800, // 30 min
|
||||
Difficulty: "easy",
|
||||
Category: "recovery",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 300, PowerLow: 0.30, PowerHigh: 0.45},
|
||||
{Type: "steadystate", Duration: 1200, Power: 0.45, Cadence: 90},
|
||||
{Type: "cooldown", Duration: 300, PowerLow: 0.45, PowerHigh: 0.30},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "endurance-60",
|
||||
Name: "Endurance Base",
|
||||
Description: "Steady zone 2 ride to build aerobic base. Maintain a comfortable, sustainable effort.",
|
||||
Type: "Endurance",
|
||||
Duration: 3600, // 60 min
|
||||
Difficulty: "easy",
|
||||
Category: "endurance",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
|
||||
{Type: "steadystate", Duration: 2400, Power: 0.65, Cadence: 85},
|
||||
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "endurance-90",
|
||||
Name: "Long Endurance",
|
||||
Description: "Extended zone 2 ride for deep aerobic adaptation. Pack snacks.",
|
||||
Type: "Endurance",
|
||||
Duration: 5400, // 90 min
|
||||
Difficulty: "moderate",
|
||||
Category: "endurance",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.65},
|
||||
{Type: "steadystate", Duration: 4200, Power: 0.68, Cadence: 85},
|
||||
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "tempo-45",
|
||||
Name: "Tempo Ride",
|
||||
Description: "Sustained zone 3 effort. Comfortably hard - you can talk in short sentences.",
|
||||
Type: "Tempo",
|
||||
Duration: 2700, // 45 min
|
||||
Difficulty: "moderate",
|
||||
Category: "endurance",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.70},
|
||||
{Type: "steadystate", Duration: 1200, Power: 0.78, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.55},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.78, Cadence: 90},
|
||||
{Type: "cooldown", Duration: 300, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "sweet-spot-60",
|
||||
Name: "Sweet Spot",
|
||||
Description: "The most time-efficient training zone. 88-94% FTP intervals with short recovery.",
|
||||
Type: "Threshold",
|
||||
Duration: 3600, // 60 min
|
||||
Difficulty: "moderate",
|
||||
Category: "sweet_spot",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
{Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.55},
|
||||
{Type: "steadystate", Duration: 600, Power: 0.92, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.55},
|
||||
{Type: "steadystate", Duration: 600, Power: 0.90, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.55},
|
||||
{Type: "cooldown", Duration: 660, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "threshold-intervals",
|
||||
Name: "Threshold Intervals",
|
||||
Description: "Classic 2x20 at FTP. The gold standard for raising your threshold.",
|
||||
Type: "Threshold",
|
||||
Duration: 3600, // 60 min
|
||||
Difficulty: "hard",
|
||||
Category: "threshold",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.80, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.50},
|
||||
{Type: "steadystate", Duration: 1200, Power: 1.00, Cadence: 95},
|
||||
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "over-unders",
|
||||
Name: "Over-Under Intervals",
|
||||
Description: "Alternating between just below and just above FTP. Builds lactate tolerance.",
|
||||
Type: "Threshold",
|
||||
Duration: 3600, // 60 min
|
||||
Difficulty: "hard",
|
||||
Category: "threshold",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
// Set 1
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.50},
|
||||
// Set 2
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.50},
|
||||
// Set 3
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.95, Cadence: 90},
|
||||
{Type: "steadystate", Duration: 60, Power: 1.05, Cadence: 95},
|
||||
{Type: "cooldown", Duration: 600, PowerLow: 0.65, PowerHigh: 0.40},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "vo2max-intervals",
|
||||
Name: "VO2max Intervals",
|
||||
Description: "5x3min at 115% FTP with equal rest. Expands your aerobic ceiling.",
|
||||
Type: "VO2 Max",
|
||||
Duration: 2700, // 45 min
|
||||
Difficulty: "very_hard",
|
||||
Category: "vo2max",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.45},
|
||||
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.45},
|
||||
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.45},
|
||||
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
|
||||
{Type: "steadystate", Duration: 180, Power: 0.45},
|
||||
{Type: "steadystate", Duration: 180, Power: 1.15, Cadence: 100},
|
||||
{Type: "cooldown", Duration: 420, PowerLow: 0.55, PowerHigh: 0.35},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "vo2max-short",
|
||||
Name: "VO2max Short-Shorts",
|
||||
Description: "30/30s at 120% FTP. Accumulate VO2max time in manageable chunks.",
|
||||
Type: "VO2 Max",
|
||||
Duration: 2400, // 40 min
|
||||
Difficulty: "very_hard",
|
||||
Category: "vo2max",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
// Set 1: 10x 30/30
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.40},
|
||||
// Set 2: 10x 30/30
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "steadystate", Duration: 30, Power: 0.40},
|
||||
{Type: "interval", Duration: 30, PowerLow: 1.20, PowerHigh: 1.20},
|
||||
{Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.35},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "sprint-intervals",
|
||||
Name: "Sprint Power",
|
||||
Description: "Short maximal efforts to build neuromuscular power and sprint capacity.",
|
||||
Type: "VO2 Max",
|
||||
Duration: 2700, // 45 min
|
||||
Difficulty: "very_hard",
|
||||
Category: "sprint",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 600, PowerLow: 0.40, PowerHigh: 0.75},
|
||||
{Type: "steadystate", Duration: 300, Power: 0.85, Cadence: 95},
|
||||
{Type: "steadystate", Duration: 120, Power: 0.50},
|
||||
// Sprints: 8x 15s all-out / 105s recovery
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "steadystate", Duration: 105, Power: 0.40},
|
||||
{Type: "interval", Duration: 15, PowerLow: 1.50, PowerHigh: 2.00},
|
||||
{Type: "cooldown", Duration: 480, PowerLow: 0.55, PowerHigh: 0.35},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ramp-test",
|
||||
Name: "FTP Ramp Test",
|
||||
Description: "Progressive ramp to estimate your FTP. Ride until you can't hold the target.",
|
||||
Type: "Threshold",
|
||||
Duration: 1500, // ~25 min (most people fail around 19-22 min)
|
||||
Difficulty: "very_hard",
|
||||
Category: "threshold",
|
||||
Segments: []workout.WorkoutSegment{
|
||||
{Type: "warmup", Duration: 300, PowerLow: 0.40, PowerHigh: 0.46},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.46, PowerHigh: 0.52},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.52, PowerHigh: 0.58},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.58, PowerHigh: 0.64},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.64, PowerHigh: 0.70},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.70, PowerHigh: 0.76},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.76, PowerHigh: 0.82},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.82, PowerHigh: 0.88},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.88, PowerHigh: 0.94},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 0.94, PowerHigh: 1.00},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 1.00, PowerHigh: 1.06},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 1.06, PowerHigh: 1.12},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 1.12, PowerHigh: 1.18},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 1.18, PowerHigh: 1.24},
|
||||
{Type: "ramp", Duration: 60, PowerLow: 1.24, PowerHigh: 1.30},
|
||||
{Type: "cooldown", Duration: 300, PowerLow: 0.50, PowerHigh: 0.30},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetByID returns a template by its ID, or nil if not found.
|
||||
func GetByID(id string) *Template {
|
||||
for i := range All {
|
||||
if All[i].ID == id {
|
||||
return &All[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByCategory returns all templates matching a category.
|
||||
func GetByCategory(category string) []Template {
|
||||
var result []Template
|
||||
for _, t := range All {
|
||||
if t.Category == category {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -63,3 +63,22 @@ 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
|
||||
}
|
||||
@@ -86,3 +86,7 @@ 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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user