package web import ( "fmt" "net/url" "time" "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/rideanalysis" "github.com/martinlehoux/biking_home/strava" ) type RideView struct { Ride ride.StoredRide Cotacol string CotacolPer100Km string MovingTime string } type RidesPageData struct { Items []RideView Headers []RideSortHeader Filters RideFilterView Sort RideSort } type AnalyticsPageData struct { Points []AnalyticsPoint Filter RideFilterView } type AnalyticsPoint struct { ID int64 `json:"id"` Name string `json:"name"` URL string `json:"url"` SpeedKmh float64 `json:"speedKmh"` CotacolPer100Km float64 `json:"cotacolPer100Km"` MovingHours float64 `json:"movingHours"` RideType string `json:"rideType"` BikeType string `json:"bikeType"` Year int `json:"year"` } func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint { points := make([]AnalyticsPoint, 0, len(items)) for _, item := range items { cotacol, ready := item.CotacolScore() if !ready || item.CotacolAlgorithmVersion() != climb.CotacolAlgorithmVersion || item.DistanceM <= 0 { continue } points = append(points, AnalyticsPoint{ ID: item.ID, Name: item.Name, URL: rideDetailURL(item.ID), SpeedKmh: item.AverageSpeedMps * 3.6, CotacolPer100Km: cotacol * 100 / (item.DistanceM / 1000), MovingHours: float64(item.EffectiveMovingTimeS()) / 3600, RideType: string(item.RideType), BikeType: string(item.BikeType), Year: item.StartDate.Year(), }) } return points } type RideDetailView struct { RideView Route GeoJSONFeatureCollection Passes []RideMapPass HasRoute bool Profile RideProfile RouteError string ActionError string Notice string } type RideMapPass struct { Name string `json:"name"` ElevationM int `json:"elevationM"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Category string `json:"category"` } type RideProfile struct { Points []RideProfilePoint `json:"points"` Climbs []RideProfileClimb `json:"climbs"` Crossings []RideProfileCrossing `json:"crossings"` } func (profile RideProfile) OfficialClimbs() []RideProfileClimb { climbs := make([]RideProfileClimb, 0) for _, climb := range profile.Climbs { if climb.OfficialClimbID > 0 { climbs = append(climbs, climb) } } return climbs } func (profile RideProfile) UnmatchedClimbs() []RideProfileClimb { climbs := make([]RideProfileClimb, 0) for _, climb := range profile.Climbs { if climb.OfficialClimbID == 0 { climbs = append(climbs, climb) } } return climbs } type RideProfilePoint struct { DistanceKm float64 `json:"distanceKm"` ElevationM float64 `json:"elevationM"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` } type RideProfileClimb struct { Index int `json:"-"` StartKm float64 `json:"startKm"` EndKm float64 `json:"endKm"` TopKm float64 `json:"topKm"` TopElevationM float64 `json:"topElevationM"` Name string `json:"name"` Category string `json:"category"` DistanceKm float64 `json:"distanceKm"` SlopePercent float64 `json:"slopePercent"` Cotacol float64 `json:"cotacol"` OfficialClimbID int64 `json:"officialClimbId,omitempty"` OfficialName string `json:"officialName,omitempty"` StartIndex int `json:"startIndex"` EndIndex int `json:"endIndex"` } type RideProfileCrossing struct { DistanceKm float64 `json:"distanceKm"` PassElevation float64 `json:"passElevationM"` RideElevation float64 `json:"rideElevationM"` DistanceToM float64 `json:"distanceToM"` ElevationDiff float64 `json:"elevationDiffM"` Name string `json:"name"` } type GeoJSONFeatureCollection struct { Type string `json:"type"` Features []GeoJSONFeature `json:"features"` } type GeoJSONFeature struct { Type string `json:"type"` Geometry GeoJSONGeometry `json:"geometry"` Properties map[string]any `json:"properties,omitempty"` } type GeoJSONGeometry struct { Type string `json:"type"` Coordinates [][]float64 `json:"coordinates"` } type RideSort struct { Column string Descending bool } type RideSortHeader struct { Label string Class string URL string AriaSort string Indicator string } const ( rideSortName = "name" rideSortStarted = "started" rideSortDistance = "distance" rideSortMovingTime = "moving_time" rideSortSpeed = "speed" rideSortElevation = "elevation" rideSortCotacol = "cotacol" rideSortCotacolKm = "cotacol_100km" ) var rideSortColumns = map[string]bool{ rideSortName: true, rideSortStarted: true, rideSortDistance: true, rideSortMovingTime: true, rideSortSpeed: true, rideSortElevation: true, rideSortCotacol: true, rideSortCotacolKm: true, } func parseRideSort(query url.Values) RideSort { column := query.Get("sort") if !rideSortColumns[column] { column = rideSortStarted } direction := query.Get("dir") return RideSort{Column: column, Descending: direction != "asc"} } func (s RideSort) databaseColumn() (ride.SortColumn, bool) { columns := map[string]ride.SortColumn{ rideSortName: ride.SortName, rideSortStarted: ride.SortStartDate, rideSortDistance: ride.SortDistance, rideSortMovingTime: ride.SortMovingTime, rideSortSpeed: ride.SortSpeed, rideSortElevation: ride.SortElevation, rideSortCotacol: ride.SortCotacol, rideSortCotacolKm: ride.SortCotacolKm, } column, found := columns[s.Column] return column, found } func rideSortHeaders(current RideSort) []RideSortHeader { return rideSortHeadersWithFilters(current, RideFilters{}) } func rideSortHeadersWithFilters(current RideSort, filters RideFilters) []RideSortHeader { columns := []struct { key string label string class string }{ {key: rideSortName, label: "Ride"}, {key: rideSortStarted, label: "Started"}, {key: rideSortDistance, label: "Distance", class: "numeric"}, {key: rideSortMovingTime, label: "Moving time", class: "numeric"}, {key: rideSortSpeed, label: "Speed", class: "numeric"}, {key: rideSortElevation, label: "Elevation", class: "numeric"}, {key: rideSortCotacol, label: "Cotacol", class: "numeric"}, {key: rideSortCotacolKm, label: "Cotacol / 100 km", class: "numeric"}, } headers := make([]RideSortHeader, 0, len(columns)) for _, column := range columns { descending := false active := current.Column == column.key if active { descending = !current.Descending } query := rideFilterQuery(filters) query.Set("sort", column.key) query.Set("dir", rideSortDirection(RideSort{Column: column.key, Descending: descending})) headerClass := column.class ariaSort := "none" indicator := "" if active { headerClass += " active" if current.Descending { ariaSort = "descending" indicator = "↓" } else { ariaSort = "ascending" indicator = "↑" } } headers = append(headers, RideSortHeader{ Label: column.label, Class: headerClass, URL: "/?" + query.Encode(), AriaSort: ariaSort, Indicator: indicator, }) } return headers } func rideSortDirection(sort RideSort) string { if sort.Descending { return "desc" } return "asc" } func rideDetailURL(id int64) string { return fmt.Sprintf("/rides/%d", id) } func officialClimbCreateURL(id int64) string { return fmt.Sprintf("/rides/%d/official-climbs", id) } func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView { coords := parsed.Coords coordinates := make([][]float64, len(coords)) for i, coordinate := range coords { coordinates[i] = []float64{coordinate.Lon, coordinate.Lat} } profile := buildRideProfile(parsed, passes, officialClimbs, matchPolicy) return RideDetailView{ RideView: buildRideView(item), Passes: buildRideMapPasses(passes), Route: GeoJSONFeatureCollection{ Type: "FeatureCollection", Features: []GeoJSONFeature{{ Type: "Feature", Geometry: GeoJSONGeometry{ Type: "LineString", Coordinates: coordinates, }, }}, }, HasRoute: true, Profile: profile, } } func buildRideMapPasses(passes []mountain_pass.MountainPass) []RideMapPass { mapPasses := make([]RideMapPass, 0, len(passes)) for _, mountainPass := range passes { if mountainPass.Coord == nil { continue } mapPasses = append(mapPasses, RideMapPass{ Name: mountainPass.Name, ElevationM: mountainPass.Elevation, Latitude: mountainPass.Coord.Lat, Longitude: mountainPass.Coord.Lon, Category: mountainPass.Category, }) } return mapPasses } func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile { coords := parsed.Coords distances := parsed.Distances elevations := parsed.Elevations profile := RideProfile{ Points: make([]RideProfilePoint, len(coords)), Climbs: make([]RideProfileClimb, 0), Crossings: make([]RideProfileCrossing, 0), } for i, coordinate := range coords { profile.Points[i] = RideProfilePoint{ DistanceKm: distances[i] / 1000, ElevationM: elevations[i], Latitude: coordinate.Lat, Longitude: coordinate.Lon, } } analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) for _, analyzedClimb := range analysis.Climbs { segment := analyzedClimb.Segment topIndex, topElevationM := segment.Top() climb := RideProfileClimb{ Index: len(profile.Climbs), StartKm: segment.Distances[0] / 1000, EndKm: segment.Distances[len(segment.Distances)-1] / 1000, TopKm: segment.Distances[topIndex] / 1000, TopElevationM: topElevationM, Name: analyzedClimb.Name, Category: analyzedClimb.Category, DistanceKm: analyzedClimb.DistanceM / 1000, SlopePercent: analyzedClimb.SlopePercent, Cotacol: analyzedClimb.Cotacol, OfficialClimbID: analyzedClimb.OfficialClimbID, OfficialName: analyzedClimb.OfficialName, StartIndex: analyzedClimb.Interval.Start, EndIndex: analyzedClimb.Interval.End, } profile.Climbs = append(profile.Climbs, climb) } for _, crossing := range analysis.Crossings { profile.Crossings = append(profile.Crossings, RideProfileCrossing{ DistanceKm: crossing.RideDistanceM / 1000, PassElevation: float64(crossing.Pass.Elevation), RideElevation: crossing.RideElevation, DistanceToM: crossing.DistanceToM, ElevationDiff: crossing.ElevationDiff, Name: crossing.Pass.Name, }) } return profile } type SyncPageData struct { From string To string Error string Notice string HasAuth bool } type RateLimitSnapshot struct { Read strava.RateLimit `json:"read"` Overall strava.RateLimit `json:"overall"` } type SyncProgress struct { Total int `json:"total"` Completed int `json:"completed"` Imported int `json:"imported"` Skipped int `json:"skipped"` Updated int `json:"updated"` RateLimit *RateLimitSnapshot `json:"rateLimit,omitempty"` } func formatRideDate(value time.Time) string { return value.Local().Format("02 Jan 2006, 15:04") } func formatDistance(meters float64) string { return fmt.Sprintf("%.1f km", meters/1000) } func formatSpeed(metersPerSecond float64) string { return fmt.Sprintf("%.1f km/h", metersPerSecond*3.6) } func formatDuration(seconds int64) string { hours := seconds / 3600 minutes := (seconds % 3600) / 60 return fmt.Sprintf("%dh %02dm", hours, minutes) } func formatElevation(meters float64) string { return fmt.Sprintf("%.0f m", meters) } func formatCotacol(cotacol float64) string { return fmt.Sprintf("%.1f", cotacol) } func formatCotacolPer100Km(cotacol, distanceM float64) string { distanceKm := distanceM / 1000 if distanceKm <= 0 { return "-" } return fmt.Sprintf("%.1f", cotacol*100/distanceKm) } func formatRouteIndex(index int) string { return fmt.Sprintf("%d", index) } func formatClimbNumber(index int) string { return fmt.Sprintf("%d", index+1) } func formatClimbCount(count int) string { return fmt.Sprintf("%d", count) } func formatRideCount(count int) string { return fmt.Sprintf("%d", count) } func formatProfileDistance(distanceKm float64) string { return fmt.Sprintf("%.1f km", distanceKm) } func formatSlope(slopePercent float64) string { return fmt.Sprintf("%.1f%%", slopePercent) } func officialProfileLabel(name string) string { return "Elevation profile for " + name } func rideDetailColumnsClass(unmatchedClimbCount int) string { if unmatchedClimbCount == 0 { return "ride-detail-columns ride-detail-columns-single" } return "ride-detail-columns" }