feat: extend equipment and workout models with service tracking

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

115
TODO.md
View File

@@ -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

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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

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

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

View 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)
}

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

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

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

View File

@@ -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)

View File

@@ -3,17 +3,58 @@ package equipment
import "time"
type Equipment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
Brand string `gorm:"default:''" json:"brand"`
Model string `gorm:"default:''" json:"model"`
Weight float64 `gorm:"default:0" json:"weight"` // grams
Notes string `gorm:"default:''" json:"notes"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
Brand string `gorm:"default:''" json:"brand"`
Model string `gorm:"default:''" json:"model"`
Weight float64 `gorm:"default:0" json:"weight"` // grams
Notes string `gorm:"default:''" json:"notes"`
Active bool `gorm:"default:true" json:"active"`
TotalDistance float64 `gorm:"default:0" json:"total_distance"` // km
TotalDuration int `gorm:"default:0" json:"total_duration"` // seconds
TotalRides int `gorm:"default:0" json:"total_rides"`
ServiceIntervalDistance float64 `gorm:"default:0" json:"service_interval_distance"` // km, 0 = no reminder
ServiceIntervalDuration int `gorm:"default:0" json:"service_interval_duration"` // hours, 0 = no reminder
LastServiceDate *time.Time `json:"last_service_date"`
DistanceSinceService float64 `gorm:"default:0" json:"distance_since_service"` // km since last service
DurationSinceService int `gorm:"default:0" json:"duration_since_service"` // seconds since last service
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServiceStatus indicates whether equipment needs servicing.
type ServiceStatus struct {
NeedsService bool `json:"needs_service"`
DistanceDue bool `json:"distance_due"`
DurationDue bool `json:"duration_due"`
DistanceSinceService float64 `json:"distance_since_service"` // km
DurationSinceService int `json:"duration_since_service"` // hours
ServiceIntervalDist float64 `json:"service_interval_distance"`
ServiceIntervalDur int `json:"service_interval_duration"`
}
// GetServiceStatus checks if the equipment is due for service.
func (e *Equipment) GetServiceStatus() ServiceStatus {
status := ServiceStatus{
DistanceSinceService: e.DistanceSinceService,
DurationSinceService: e.DurationSinceService / 3600,
ServiceIntervalDist: e.ServiceIntervalDistance,
ServiceIntervalDur: e.ServiceIntervalDuration,
}
if e.ServiceIntervalDistance > 0 && e.DistanceSinceService >= e.ServiceIntervalDistance {
status.DistanceDue = true
status.NeedsService = true
}
if e.ServiceIntervalDuration > 0 && e.DurationSinceService >= e.ServiceIntervalDuration*3600 {
status.DurationDue = true
status.NeedsService = true
}
return status
}
type TrainingZone struct {

View File

@@ -3,6 +3,8 @@ package equipment
import (
"errors"
"rideaware/pkg/database"
"time"
"gorm.io/gorm"
)
@@ -53,4 +55,29 @@ func (r *Repository) UpdateEquipment(equipment *Equipment) error {
func (r *Repository) DeleteEquipment(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).
Delete(&Equipment{}).Error
}
// IncrementMileage atomically adds distance and duration to equipment totals.
func (r *Repository) IncrementMileage(id, userID uint, distance float64, duration int) error {
return database.DB.Model(&Equipment{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"total_distance": gorm.Expr("total_distance + ?", distance),
"total_duration": gorm.Expr("total_duration + ?", duration),
"total_rides": gorm.Expr("total_rides + 1"),
"distance_since_service": gorm.Expr("distance_since_service + ?", distance),
"duration_since_service": gorm.Expr("duration_since_service + ?", duration),
}).Error
}
// ResetServiceCounters resets the service tracking counters after servicing.
func (r *Repository) ResetServiceCounters(id, userID uint) error {
now := time.Now()
return database.DB.Model(&Equipment{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"distance_since_service": 0,
"duration_since_service": 0,
"last_service_date": now,
}).Error
}

View File

@@ -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 {

View 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
View 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)
}

View 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)
}

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

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

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

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

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

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

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

View 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
View 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)
}

View 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
View 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)
}

View File

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

View File

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

View File

@@ -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)

View File

@@ -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"`

View File

@@ -62,4 +62,23 @@ func (r *Repository) UpdateWorkout(workout *Workout) error {
func (r *Repository) DeleteWorkout(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).
Delete(&Workout{}).Error
}
type EquipmentStat struct {
EquipmentID uint `json:"equipment_id"`
TotalRides int `json:"total_rides"`
TotalDistance float64 `json:"total_distance"`
TotalDuration int `json:"total_duration"`
}
func (r *Repository) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
var stats []EquipmentStat
if err := database.DB.Model(&Workout{}).
Select("equipment_id, COUNT(*) as total_rides, COALESCE(SUM(distance), 0) as total_distance, COALESCE(SUM(duration), 0) as total_duration").
Where("user_id = ? AND equipment_id IS NOT NULL", userID).
Group("equipment_id").
Scan(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}

View File

@@ -85,4 +85,8 @@ func (s *Service) UpdateWorkoutWithMetrics(id, userID uint, distance float64, av
func (s *Service) DeleteWorkout(id, userID uint) error {
return s.repo.DeleteWorkout(id, userID)
}
func (s *Service) GetEquipmentStats(userID uint) ([]EquipmentStat, error) {
return s.repo.GetEquipmentStats(userID)
}

View File

@@ -15,8 +15,8 @@ IMAGE_TAG="latest"
NO_CACHE=false
RUN_CONTAINER=false
CONTAINER_NAME="rideaware-api"
HOST_PORT="5000"
CONTAINER_PORT="5000"
HOST_PORT="5010"
CONTAINER_PORT="5010"
# Help function
show_help() {
@@ -192,4 +192,4 @@ else
fi
echo ""
echo -e "${GREEN}✓ Done!${NC}"
echo -e "${GREEN}✓ Done!${NC}"