diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/climb-backfill.go | 81 | ||||
| -rw-r--r-- | web/climbs.go | 398 | ||||
| -rw-r--r-- | web/climbs_test.go | 97 | ||||
| -rw-r--r-- | web/frontend/climb-map.ts | 65 | ||||
| -rw-r--r-- | web/frontend/climb-profile.ts | 110 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 413 | ||||
| -rw-r--r-- | web/official-climbs.go | 60 | ||||
| -rw-r--r-- | web/official-climbs_test.go | 81 | ||||
| -rw-r--r-- | web/rides.go | 1 | ||||
| -rw-r--r-- | web/server.go | 4 | ||||
| -rw-r--r-- | web/templates.templ | 88 | ||||
| -rw-r--r-- | web/views.go | 6 |
12 files changed, 1215 insertions, 189 deletions
diff --git a/web/climb-backfill.go b/web/climb-backfill.go new file mode 100644 index 0000000..13a8c02 --- /dev/null +++ b/web/climb-backfill.go @@ -0,0 +1,81 @@ +package web + +import ( + "database/sql" + "log/slog" + + "github.com/martinlehoux/biking_home/config" + "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/kagamigo/kcore" + "github.com/schollz/progressbar/v3" +) + +// BackfillOfficialClimbAttempts parses every route-bearing ride, matches its +// official climbs, and persists the resulting attempts. An optional reporter +// is invoked with progress for CLI rendering. +func BackfillOfficialClimbAttempts(db *sql.DB, configPath string, reporters ...func(current, total int)) (int, error) { + appConfig, err := config.Load(configPath) + if err != nil { + return 0, err + } + matchPolicy := official_climb.DefaultMatchPolicy() + matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM + items, err := ride.List(db) + if err != nil { + return 0, err + } + officialClimbs, err := official_climb.List(db) + if err != nil { + return 0, err + } + var bar *progressbar.ProgressBar + report := func(int, int) {} + if len(reporters) > 0 { + report = reporters[0] + } + backfilled := 0 + for i, item := range items { + report(i+1, len(items)) + if item.FitPath == "" && item.GPXPath == "" { + continue + } + parsed, err := ride.ParseStoredRide(item) + if err != nil { + slog.Warn("Failed to parse ride for climb backfill", "ride_id", item.ID, "error", err) + continue + } + passes, err := mountain_pass.LoadMountainPassesAroundRide(db, parsed.Coords, rideDetailMountainPassMarginM) + if err != nil { + slog.Warn("Failed to load passes for climb backfill", "ride_id", item.ID, "error", err) + } + analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) + attempts := attemptsFromAnalysis(item.ID, analysis, parsed) + if err := official_climb.ReplaceRideAttempts(db, item.ID, attempts); err != nil { + slog.Warn("Failed to record climb attempts", "ride_id", item.ID, "error", err) + continue + } + backfilled++ + } + if bar != nil { + bar.Finish() + } + return backfilled, nil +} + +// BackfillOfficialClimbAttemptsCmd runs the climb attempt backfill from the CLI. +func BackfillOfficialClimbAttemptsCmd(db *sql.DB, configPath string) { + var bar *progressbar.ProgressBar + count, err := BackfillOfficialClimbAttempts(db, configPath, func(current, total int) { + if bar == nil && mountain_pass.ProgressBarEnabled() { + bar = progressbar.Default(int64(total), "Backfilling climb attempts") + } + if bar != nil { + bar.Set(current) + } + }) + kcore.Expect(err, "failed to backfill climb attempts") + slog.Info("Backfilled climb attempts", "rides", count) +} diff --git a/web/climbs.go b/web/climbs.go new file mode 100644 index 0000000..6859545 --- /dev/null +++ b/web/climbs.go @@ -0,0 +1,398 @@ +package web + +import ( + "database/sql" + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" + "log/slog" + "math" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/kagamigo/kcore" +) + +func climbDetailURL(id int64) string { + return "/climbs/" + strconv.FormatInt(id, 10) +} + +type routeCandidate struct { + coords []geodist.Coord + distance float64 +} + +type ClimbProfileData struct { + DistanceKm float64 `json:"distanceKm"` + Elevations []float64 `json:"elevations"` + Route [][]float64 `json:"route,omitempty"` +} +type ClimbAttemptView struct { + RideID int64 + RideName string + Date string + Duration string + Distance string + Cotacol string +} + +type ClimbStatsView struct { + Attempts string + Distance string + Slope string + Cotacol string + Category string + BestTime string + ElevationG string +} + +type ClimbDetailPageData struct { + Climb official_climb.OfficialClimb + Stats ClimbStatsView + Attempts []ClimbAttemptView + Profile ClimbProfileData + Endpoints ClimbMapEndpoints +} + +type ClimbMapEndpoints struct { + Start []float64 `json:"start"` + End []float64 `json:"end"` + Name string `json:"name"` +} + +func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + climb, found, err := official_climb.GetByID(s.db, id) + if err != nil { + slog.Error("Failed to load official climb", "climb_id", id, "error", err) + http.Error(w, "failed to load climb", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + attempts, err := official_climb.AttemptsForClimb(s.db, id) + if err != nil { + slog.Error("Failed to load climb attempts", "climb_id", id, "error", err) + http.Error(w, "failed to load climb attempts", http.StatusInternalServerError) + return + } + aggregate := official_climb.AggregateAttempts(attempts) + profile := s.buildClimbProfile(climb, aggregate, attempts) + kcore.RenderPage(r.Context(), ClimbDetailPage(ClimbDetailPageData{ + Climb: climb, + Stats: buildClimbStatsView(aggregate), + Attempts: buildClimbAttemptViews(s.db, attempts), + Profile: profile, + Endpoints: ClimbMapEndpoints{ + Start: []float64{climb.StartCoord.Lat, climb.StartCoord.Lon}, + End: []float64{climb.EndCoord.Lat, climb.EndCoord.Lon}, + Name: climb.Name, + }, + }), w) +} + +// buildClimbProfile computes the median normalized elevation profile from the +// linked rides: each ride is parsed, its climb occurrences matched, and every +// occurrence resampled onto the shared normalized axis. +func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData { + profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000} + if len(attempts) == 0 { + return profile + } + matchPolicy := s.officialClimbMatchPolicy() + elevationSamples := make([][]float64, 0, len(attempts)) + routeCandidates := make([]routeCandidate, 0, len(attempts)) + seen := make(map[int64]bool) + for _, attempt := range attempts { + if seen[attempt.RideID] { + continue + } + seen[attempt.RideID] = true + item, found, err := ride.GetByID(s.db, attempt.RideID) + if err != nil || !found { + continue + } + parsed, err := ride.ParseStoredRide(item) + if err != nil { + slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err) + continue + } + for _, matched := range official_climb.MatchClimbs(parsed.Coords, officialClimb, matchPolicy.EndpointRadiusM) { + segment := climb.Climb{Route: climb.Route{ + Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1], + Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1], + Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1], + }} + if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 { + elevationSamples = append(elevationSamples, sample) + } + routeCandidates = append(routeCandidates, routeCandidate{ + coords: segment.Coords, + distance: segment.Distances[len(segment.Distances)-1] - segment.Distances[0], + }) + } + } + profile.Elevations = official_climb.MedianProfile(elevationSamples) + if route := medianRoute(routeCandidates); len(route) > 1 { + profile.Route = make([][]float64, len(route)) + for i, coord := range route { + profile.Route[i] = []float64{coord.Lon, coord.Lat} + } + } + return profile +} + +// medianRoute picks the recorded route of the attempt whose distance is the +// median across occurrences: full recorded precision, no resampling, and +// robust to one GPS-drifted ride. +func medianRoute(candidates []routeCandidate) []geodist.Coord { + if len(candidates) == 0 { + return nil + } + distances := make([]float64, len(candidates)) + for i, candidate := range candidates { + distances[i] = candidate.distance + } + median := official_climb.Median(distances) + best := candidates[0] + bestDiff := math.Abs(best.distance - median) + for _, candidate := range candidates[1:] { + if diff := math.Abs(candidate.distance - median); diff < bestDiff { + best = candidate + bestDiff = diff + } + } + return best.coords +} + +func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView { + stats := ClimbStatsView{ + Attempts: strconv.Itoa(aggregate.Attempts), + Distance: formatDistance(aggregate.DistanceM), + Slope: formatSlope(aggregate.SlopePercent), + Cotacol: formatCotacol(aggregate.Cotacol), + Category: aggregate.Category.String(), + ElevationG: formatElevation(aggregate.ElevationGainM), + BestTime: "-", + } + if aggregate.HasBest { + stats.BestTime = formatDuration(int64(aggregate.BestDuration.Seconds())) + } + return stats +} + +func buildClimbAttemptViews(db *sql.DB, attempts []official_climb.Attempt) []ClimbAttemptView { + names := make(map[int64]string) + views := make([]ClimbAttemptView, len(attempts)) + for i, attempt := range attempts { + name, known := names[attempt.RideID] + if !known { + if item, found, err := ride.GetByID(db, attempt.RideID); err == nil && found { + name = item.Name + } + names[attempt.RideID] = name + } + views[i] = ClimbAttemptView{ + RideID: attempt.RideID, + RideName: name, + Date: formatRideDate(attempt.StartedAt), + Duration: formatDuration(int64(attempt.EndedAt.Sub(attempt.StartedAt).Seconds())), + Distance: formatDistance(attempt.DistanceM), + Cotacol: formatCotacol(attempt.Cotacol), + } + } + return views +} + +func climbsListURL() string { + return "/climbs" +} + +type ClimbSort struct { + Column string + Descending bool +} + +const ( + climbSortName = "name" + climbSortCategory = "category" + climbSortAttempts = "attempts" + climbSortDistance = "distance" + climbSortSlope = "slope" + climbSortCotacol = "cotacol" + climbSortBestTime = "best_time" +) + +var climbSortColumns = map[string]bool{ + climbSortName: true, + climbSortCategory: true, + climbSortAttempts: true, + climbSortDistance: true, + climbSortSlope: true, + climbSortCotacol: true, + climbSortBestTime: true, +} + +func parseClimbSort(query url.Values) ClimbSort { + column := query.Get("sort") + if !climbSortColumns[column] { + column = climbSortCotacol + } + direction := query.Get("dir") + return ClimbSort{Column: column, Descending: direction != "asc"} +} + +func sortListedClimbs(climbs []official_climb.ListedClimb, sort ClimbSort) []official_climb.ListedClimb { + ordered := make([]official_climb.ListedClimb, len(climbs)) + copy(ordered, climbs) + slices.SortStableFunc(ordered, func(a, b official_climb.ListedClimb) int { + var cmp int + switch sort.Column { + case climbSortName: + cmp = strings.Compare(a.Name, b.Name) + case climbSortCategory: + cmp = int(climb.CategoryOf(a.Cotacol)) - int(climb.CategoryOf(b.Cotacol)) + case climbSortAttempts: + cmp = a.Attempts - b.Attempts + case climbSortDistance: + cmp = compareFloat(a.DistanceM, b.DistanceM) + case climbSortSlope: + cmp = compareFloat(a.SlopePercent, b.SlopePercent) + case climbSortBestTime: + cmp = compareFloat(bestTimeOrInfinity(a), bestTimeOrInfinity(b)) + default: + cmp = compareFloat(a.Cotacol, b.Cotacol) + } + if sort.Descending { + return -cmp + } + return cmp + }) + return ordered +} + +func bestTimeOrInfinity(climb official_climb.ListedClimb) float64 { + if !climb.HasBest { + return math.Inf(1) + } + return climb.BestDurationS +} + +func compareFloat(a, b float64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +type ClimbListView struct { + Climb official_climb.ListedClimb + Category string + Distance string + Slope string + Cotacol string + BestTime string + URL string +} + +type ClimbsPageData struct { + Items []ClimbListView + Headers []RideSortHeader +} + +func buildClimbListViews(climbs []official_climb.ListedClimb) []ClimbListView { + views := make([]ClimbListView, len(climbs)) + for i, listed := range climbs { + bestTime := "-" + if listed.HasBest { + bestTime = formatDuration(int64(listed.BestDurationS)) + } + views[i] = ClimbListView{ + Climb: listed, + Category: listed.Category(), + Distance: formatDistance(listed.DistanceM), + Slope: formatSlope(listed.SlopePercent), + Cotacol: formatCotacol(listed.Cotacol), + BestTime: bestTime, + URL: climbDetailURL(listed.ID), + } + } + return views +} + +func (s *Server) handleClimbs(w http.ResponseWriter, r *http.Request) { + sort := parseClimbSort(r.URL.Query()) + climbs, err := official_climb.ListWithAggregates(s.db) + if err != nil { + slog.Error("Failed to load climbs", "error", err) + http.Error(w, "failed to load climbs", http.StatusInternalServerError) + return + } + climbs = sortListedClimbs(climbs, sort) + kcore.RenderPage(r.Context(), ClimbsPage(ClimbsPageData{ + Items: buildClimbListViews(climbs), + Headers: climbSortHeaders(sort), + }), w) +} + +func climbSortHeaders(current ClimbSort) []RideSortHeader { + columns := []struct { + key string + label string + class string + }{ + {key: climbSortName, label: "Climb"}, + {key: climbSortCategory, label: "Category"}, + {key: climbSortAttempts, label: "Attempts", class: "numeric"}, + {key: climbSortDistance, label: "Distance", class: "numeric"}, + {key: climbSortSlope, label: "Avg slope", class: "numeric"}, + {key: climbSortCotacol, label: "Cotacol", class: "numeric"}, + {key: climbSortBestTime, label: "Best time", 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 := url.Values{} + 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: climbsListURL() + "?" + query.Encode(), + AriaSort: ariaSort, + Indicator: indicator, + }) + } + return headers +} diff --git a/web/climbs_test.go b/web/climbs_test.go new file mode 100644 index 0000000..fbaf527 --- /dev/null +++ b/web/climbs_test.go @@ -0,0 +1,97 @@ +package web + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func recordFixtureAttempts(t *testing.T, server *Server, rideID int64, climb official_climb.OfficialClimb) { + t.Helper() + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{climb}, official_climb.DefaultMatchPolicy()) + server.recordOfficialClimbAttempts(rideID, parsed, analysis) +} +func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) { + t.Helper() + parsed, _ := testRideForClimb(t) + fitPath := filepath.Join(t.TempDir(), "ride.fit") + fitFile, err := os.Create(fitPath) + require.NoError(t, err) + require.NoError(t, ride.EncodeFIT(parsed, fitFile)) + require.NoError(t, fitFile.Close()) + _, err = db.Exec("UPDATE rides SET fit_path = ? WHERE id = ?", fitPath, rideID) + require.NoError(t, err) +} + +func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + recordFixtureAttempts(t, server, rideID, created) + + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, climbDetailURL(created.ID), nil)) + require.Equal(t, http.StatusOK, recorder.Code) + body := recorder.Body.String() + assert.Contains(t, body, "Col de Test") + assert.Contains(t, body, "climb-profile-data") + assert.Contains(t, body, "Test Ride") +} + +func TestHandleClimbNotFound(t *testing.T) { + server, _ := newWebTestServer(t) + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/climbs/999", nil)) + assert.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { + server, db := newWebTestServer(t) + _, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) + require.Len(t, profile.Elevations, 50) + assert.InDelta(t, 300, profile.Elevations[0], 1) + assert.InDelta(t, 447, profile.Elevations[49], 2) +} + +func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { + server, db := newWebTestServer(t) + parsed, climbFixture := testRideForClimb(t) + created, err := official_climb.Create(db, climbFixture) + require.NoError(t, err) + rideID := insertTestRide(t, db) + storeRideFixtureFile(t, db, rideID) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + + profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) + require.NotEmpty(t, profile.Route) + // Full recorded precision: every matched segment point is present, not a + // fixed 50-step resample. + assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) + assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) + assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1][1], 1e-6) +} diff --git a/web/frontend/climb-map.ts b/web/frontend/climb-map.ts new file mode 100644 index 0000000..909614d --- /dev/null +++ b/web/frontend/climb-map.ts @@ -0,0 +1,65 @@ +import type { Map as LeafletMap } from "leaflet"; + +interface ClimbMapPayload { + distanceKm: number; + elevations: number[]; + route?: [number, number][]; +} + +interface ClimbMapEndpoints { + start: [number, number]; + end: [number, number]; + name: string; +} + +const mountClimbMap = (): void => { + const element = document.getElementById("climb-map"); + const endpointsElement = document.getElementById("climb-endpoints"); + const profileElement = document.getElementById("climb-profile-data"); + const leaflet = window.L; + if (!element || !endpointsElement || !profileElement || !leaflet) return; + const endpoints = JSON.parse(endpointsElement.textContent ?? "null") as ClimbMapEndpoints | null; + const profile = JSON.parse(profileElement.textContent ?? "null") as ClimbMapPayload | null; + if (!endpoints) return; + const route = (profile?.route ?? []).map(([lon, lat]) => [lat, lon] as [number, number]); + const line: [number, number][] = route.length > 1 ? route : [endpoints.start, endpoints.end]; + + const map: LeafletMap = leaflet.map(element); + leaflet + .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }) + .addTo(map); + + const startIcon = leaflet.divIcon({ + className: "ride-map-climb-start-icon", + html: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="currentColor"/></svg>', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + const endIcon = leaflet.divIcon({ + className: "ride-map-climb-end-icon", + html: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3v18" stroke="currentColor" stroke-width="2"/><path d="M6 4h13v8H6Z" fill="currentColor"/><path d="M6 4h3.25v4H6ZM12.5 4h3.25v4H12.5ZM9.25 8h3.25v4H9.25ZM15.75 8H19v4h-3.25Z" fill="var(--color-forest)"/></svg>', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + leaflet + .polyline(line, { + color: "var(--color-climb-route)", + weight: 4, + opacity: 0.9, + }) + .addTo(map); + leaflet + .marker(endpoints.start, { icon: startIcon, alt: `${endpoints.name} start` }) + .addTo(map) + .bindTooltip("Start"); + leaflet + .marker(endpoints.end, { icon: endIcon, alt: `${endpoints.name} end` }) + .addTo(map) + .bindTooltip("End"); + map.fitBounds(leaflet.latLngBounds(line), { padding: [40, 40], maxZoom: 16 }); +}; + +mountClimbMap(); diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts new file mode 100644 index 0000000..8910349 --- /dev/null +++ b/web/frontend/climb-profile.ts @@ -0,0 +1,110 @@ +import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js"; +import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; +import type { ProfilePoint } from "./types.js"; + +interface ClimbProfilePayload { + distanceKm: number; + elevations: number[]; +} + +interface ClimbProfileState { + canvas: HTMLCanvasElement; + payload: ClimbProfilePayload; + sections: OfficialProfileSection[]; + hoverOutput: HTMLOutputElement; +} + +const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] => + payload.elevations.map((elevationM, index) => ({ + distanceKm: (payload.distanceKm * index) / (payload.elevations.length - 1), + elevationM, + })); + +const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => { + const points = climbProfilePoints(payload); + if (points.length < 2) return []; + return officialProfileSections(points, 0, points.length - 1, displayStepForLength(payload.distanceKm * 1000)); +}; + +const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => { + for (const section of sections) { + if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) { + const span = section.endDistanceKm - section.startDistanceKm; + const fraction = span > 0 ? (distanceKm - section.startDistanceKm) / span : 0; + return section.startElevation + fraction * (section.endElevation - section.startElevation); + } + } + return null; +}; + +const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => { + const colors = resolveOfficialProfileColors(); + const rect = state.canvas.getBoundingClientRect(); + const context = state.canvas.getContext("2d"); + if (!context) return; + const plot = officialProfilePlot(state.sections, rect.width, rect.height); + const elevationSpan = Math.max(plot.maxElevation - plot.minElevation, 1); + const x = plot.left + ((distanceKm - plot.minDistanceKm) / plot.distanceSpan) * (plot.right - plot.left); + const elevation = elevationAtDistance(state.sections, distanceKm); + if (elevation === null) return; + const y = plot.bottom - ((elevation - plot.minElevation) / elevationSpan) * (plot.bottom - plot.top); + context.strokeStyle = colors.subtle; + context.lineWidth = 1; + context.setLineDash([3, 3]); + context.beginPath(); + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); + context.setLineDash([]); + context.fillStyle = colors.accent; + context.beginPath(); + context.arc(x, y, 5, 0, 2 * Math.PI); + context.fill(); + state.hoverOutput.textContent = `${distanceKm.toFixed(2)} km · ${Math.round(elevation)} m`; +}; + +const clearHover = (state: ClimbProfileState): void => { + drawClimbProfile(state.canvas, state.payload, state.sections); + state.hoverOutput.textContent = ""; +}; + +const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => { + const profileSections = sections ?? climbProfileSections(payload); + if (profileSections.length === 0) return; + drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors()); +}; + +const bindHover = (state: ClimbProfileState): void => { + state.canvas.addEventListener("pointermove", (event: PointerEvent) => { + const rect = state.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const plot = officialProfilePlot(state.sections, rect.width, rect.height); + if (x < plot.left || x > plot.right) { + clearHover(state); + return; + } + const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan; + drawClimbProfile(state.canvas, state.payload, state.sections); + drawHoverMarker(state, distanceKm); + }); + state.canvas.addEventListener("pointerleave", () => clearHover(state)); + state.canvas.addEventListener("pointercancel", () => clearHover(state)); +}; + +const init = (): void => { + const payloadElement = document.getElementById("climb-profile-data"); + const canvas = document.querySelector<HTMLCanvasElement>("[data-climb-profile]"); + if (!payloadElement || !canvas) return; + const payload = JSON.parse(payloadElement.textContent ?? "null") as ClimbProfilePayload | null; + if (!payload || payload.elevations.length < 2) return; + const sections = climbProfileSections(payload); + if (sections.length === 0) return; + const hoverOutput = document.querySelector<HTMLOutputElement>("[data-climb-profile-hover]"); + if (!hoverOutput) return; + const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; + drawClimbProfile(canvas, payload, sections); + bindHover(state); +}; + +init(); diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts index bc851f5..ac4d591 100644 --- a/web/frontend/official-climb-profile.ts +++ b/web/frontend/official-climb-profile.ts @@ -1,6 +1,6 @@ import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; - export class OfficialClimbProfileController { readonly points: RideProfilePoint[]; readonly colors: OfficialProfileColors; @@ -12,43 +12,19 @@ export class OfficialClimbProfileController { this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement); for (const card of this.cards) { card.addEventListener("toggle", () => { - if (!card.open) return; - for (const other of this.cards) { - if (other !== card) other.open = false; + if (card.open) { + this.redrawOpen(); } - const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]"); - if (profileCanvas) this.drawOfficialProfile(profileCanvas); }); } } resolveColors(): OfficialProfileColors { - const colorProbe = document.createElement("span"); - colorProbe.hidden = true; - document.body.append(colorProbe); - const resolveColor = (name: string): string => { - colorProbe.style.color = `var(${name})`; - return getComputedStyle(colorProbe).color; - }; - const colors = { - downhill: resolveColor("--color-profile-downhill"), - "0-3": resolveColor("--color-profile-0-3"), - "3-6": resolveColor("--color-profile-3-6"), - "6-9": resolveColor("--color-profile-6-9"), - "9-plus": resolveColor("--color-profile-9-plus"), - plotSurface: resolveColor("--color-plot-surface"), - grid: resolveColor("--color-plot-grid"), - xGrid: resolveColor("--color-profile-grid-x"), - yGrid: resolveColor("--color-profile-grid-y"), - subtle: resolveColor("--color-subtle"), - accent: resolveColor("--color-accent"), - }; - colorProbe.remove(); - return colors; + return resolveOfficialProfileColors(); } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { - const { points, colors } = this; + const { points } = this; const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); if ( @@ -59,175 +35,240 @@ export class OfficialClimbProfileController { startIndex >= endIndex ) return; - const rect = profileCanvas.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - const context = profileCanvas.getContext("2d"); - if (!context) return; - const ratio = window.devicePixelRatio || 1; - profileCanvas.width = Math.floor(rect.width * ratio); - profileCanvas.height = Math.floor(rect.height * ratio); - context.setTransform(ratio, 0, 0, ratio, 0, 0); - const plot = { left: 10, right: Math.max(11, rect.width - 44), top: 16, bottom: rect.height - 34 }; const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); if (sections.length === 0) return; - let minElevation = sections[0].startElevation; - let maxElevation = minElevation; - for (const section of sections) { - minElevation = Math.min(minElevation, section.startElevation, section.endElevation); - maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); - } - const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); - minElevation -= elevationPadding; - maxElevation += elevationPadding; - const minDistance = sections[0].startDistanceKm; - const maxDistance = sections[sections.length - 1].endDistanceKm; - const distanceSpan = Math.max(maxDistance - minDistance, 0.1); - const elevationSpan = Math.max(maxElevation - minElevation, 1); - const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left); - const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top); - context.clearRect(0, 0, rect.width, rect.height); - context.fillStyle = colors.plotSurface; - context.fillRect(0, 0, rect.width, rect.height); - context.font = "9px system-ui, sans-serif"; - context.lineWidth = 1; - context.setLineDash([4, 4]); - context.strokeStyle = colors.yGrid; - const yGridElevations: number[] = []; - for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) { - yGridElevations.push(elevationM); - } - context.save(); - context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.left, yForElevation(sections[0].startElevation)); - for (const section of sections) { - context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); - } - context.lineTo(plot.right, plot.bottom); - context.closePath(); - context.clip(); - for (const elevationM of yGridElevations) { - const y = yForElevation(elevationM); - context.beginPath(); - context.moveTo(plot.left, y); - context.lineTo(plot.right, y); - context.stroke(); - } - context.restore(); - context.fillStyle = colors.yGrid; - context.textAlign = "left"; - context.textBaseline = "middle"; - for (const elevationM of yGridElevations) { - context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM)); + drawOfficialSections(profileCanvas, sections, this.colors); + } + + redrawOpen() { + const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); + if (profileCanvases.length === 0) return; + for (const profileCanvas of profileCanvases) { + if (profileCanvas.closest("details")?.open) { + this.drawOfficialProfile(profileCanvas); + } } - context.setLineDash([]); - context.strokeStyle = colors.grid; + } +} + +export function resolveOfficialProfileColors(): OfficialProfileColors { + const colorProbe = document.createElement("span"); + colorProbe.hidden = true; + document.body.append(colorProbe); + const resolveColor = (name: string): string => { + colorProbe.style.color = `var(${name})`; + return getComputedStyle(colorProbe).color; + }; + const colors = { + downhill: resolveColor("--color-profile-downhill"), + "0-3": resolveColor("--color-profile-0-3"), + "3-6": resolveColor("--color-profile-3-6"), + "6-9": resolveColor("--color-profile-6-9"), + "9-plus": resolveColor("--color-profile-9-plus"), + plotSurface: resolveColor("--color-plot-surface"), + grid: resolveColor("--color-plot-grid"), + xGrid: resolveColor("--color-profile-grid-x"), + yGrid: resolveColor("--color-profile-grid-y"), + subtle: resolveColor("--color-subtle"), + accent: resolveColor("--color-accent"), + }; + colorProbe.remove(); + return colors; +} + +export interface OfficialProfilePlot { + left: number; + right: number; + top: number; + bottom: number; + minElevation: number; + maxElevation: number; + minDistanceKm: number; + maxDistanceKm: number; + distanceSpan: number; +} + +export const officialProfilePlot = (sections: OfficialProfileSection[], width: number, height: number): OfficialProfilePlot => { + let minElevation = sections[0].startElevation; + let maxElevation = minElevation; + for (const section of sections) { + minElevation = Math.min(minElevation, section.startElevation, section.endElevation); + maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); + } + const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); + minElevation -= elevationPadding; + maxElevation += elevationPadding; + const minDistanceKm = sections[0].startDistanceKm; + const maxDistanceKm = sections[sections.length - 1].endDistanceKm; + return { + left: 10, + right: Math.max(11, width - 44), + top: 16, + bottom: height - 34, + minElevation, + maxElevation, + minDistanceKm, + maxDistanceKm, + distanceSpan: Math.max(maxDistanceKm - minDistanceKm, 0.1), + }; +}; + +// drawOfficialSections renders precomputed profile sections onto a canvas. +// Shared by the ride detail official-climb cards and the climb detail page. +export function drawOfficialSections( + profileCanvas: HTMLCanvasElement, + sections: OfficialProfileSection[], + colors: OfficialProfileColors, +): void { + const rect = profileCanvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const context = profileCanvas.getContext("2d"); + if (!context) return; + const ratio = window.devicePixelRatio || 1; + profileCanvas.width = Math.floor(rect.width * ratio); + profileCanvas.height = Math.floor(rect.height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + const plot = officialProfilePlot(sections, rect.width, rect.height); + const minElevation = plot.minElevation; + const maxElevation = plot.maxElevation; + const minDistance = plot.minDistanceKm; + const maxDistance = plot.maxDistanceKm; + const elevationSpan = Math.max(maxElevation - minElevation, 1); + const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / plot.distanceSpan) * (plot.right - plot.left); + const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top); + context.clearRect(0, 0, rect.width, rect.height); + context.fillStyle = colors.plotSurface; + context.fillRect(0, 0, rect.width, rect.height); + context.font = "9px system-ui, sans-serif"; + context.lineWidth = 1; + context.setLineDash([4, 4]); + context.strokeStyle = colors.yGrid; + const yGridElevations: number[] = []; + for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) { + yGridElevations.push(elevationM); + } + context.save(); + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.left, yForElevation(sections[0].startElevation)); + for (const section of sections) { + context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); + } + context.lineTo(plot.right, plot.bottom); + context.closePath(); + context.clip(); + for (const elevationM of yGridElevations) { + const y = yForElevation(elevationM); context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.right, plot.bottom); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); context.stroke(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - context.beginPath(); - context.moveTo(startX, plot.bottom); - context.lineTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.lineTo(endX, plot.bottom); - context.closePath(); - context.globalAlpha = 0.25; - context.fillStyle = colors[section.band]; - context.fill(); - context.globalAlpha = 1; - context.beginPath(); - context.moveTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.strokeStyle = colors[section.band]; - context.lineWidth = 2.5; - context.stroke(); - } - context.save(); + } + context.restore(); + context.fillStyle = colors.yGrid; + context.textAlign = "left"; + context.textBaseline = "middle"; + for (const elevationM of yGridElevations) { + context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM)); + } + context.setLineDash([]); + context.strokeStyle = colors.grid; + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.right, plot.bottom); + context.stroke(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.left, yForElevation(sections[0].startElevation)); - for (const section of sections) { - context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); - } - context.lineTo(plot.right, plot.bottom); + context.moveTo(startX, plot.bottom); + context.lineTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.lineTo(endX, plot.bottom); context.closePath(); - context.clip(); - context.strokeStyle = colors.xGrid; - context.lineWidth = 1; - context.setLineDash([4, 4]); - for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; - const x = xForDistance(distanceKm); - context.beginPath(); - context.moveTo(x, plot.top); - context.lineTo(x, plot.bottom); - context.stroke(); - } - context.restore(); - context.setLineDash([]); - context.font = "9px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "bottom"; - context.textAlign = "center"; - context.save(); + context.globalAlpha = 0.25; + context.fillStyle = colors[section.band]; + context.fill(); + context.globalAlpha = 1; context.beginPath(); - context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); - context.clip(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - const label = `${section.slopePercent.toFixed(1)}%`; - context.fillText(label, (startX + endX) / 2, plot.bottom - 4); - } - context.restore(); - let topElevation = sections[0].startElevation; - let topDistance = sections[0].startDistanceKm; - for (const section of sections) { - if (section.startElevation > topElevation) { - topElevation = section.startElevation; - topDistance = section.startDistanceKm; - } - if (section.endElevation > topElevation) { - topElevation = section.endElevation; - topDistance = section.endDistanceKm; - } - } - context.fillStyle = colors.accent; + context.moveTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.strokeStyle = colors[section.band]; + context.lineWidth = 2.5; + context.stroke(); + } + context.save(); + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.left, yForElevation(sections[0].startElevation)); + for (const section of sections) { + context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation)); + } + context.lineTo(plot.right, plot.bottom); + context.closePath(); + context.clip(); + context.strokeStyle = colors.xGrid; + context.lineWidth = 1; + context.setLineDash([4, 4]); + for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; + const x = xForDistance(distanceKm); context.beginPath(); - context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); - context.fill(); - context.font = "9px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "top"; - context.strokeStyle = colors.grid; - context.lineWidth = 1; - for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = - sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; - const x = xForDistance(minDistance + distanceKm); - context.beginPath(); - context.moveTo(x, plot.bottom); - context.lineTo(x, plot.bottom + 3); - context.stroke(); - context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; - context.fillText(this.formatDistance(distanceKm), x, plot.bottom + 5); - } + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); } - - formatDistance(distance: number): string { - return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + context.restore(); + context.setLineDash([]); + context.font = "9px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "bottom"; + context.textAlign = "center"; + context.save(); + context.beginPath(); + context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); + context.clip(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); + const label = `${section.slopePercent.toFixed(1)}%`; + context.fillText(label, (startX + endX) / 2, plot.bottom - 4); } - - redrawOpen() { - const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); - for (const profileCanvas of profileCanvases) { - const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]"); - if (card?.open) this.drawOfficialProfile(profileCanvas); + context.restore(); + let topElevation = sections[0].startElevation; + let topDistance = sections[0].startDistanceKm; + for (const section of sections) { + if (section.startElevation > topElevation) { + topElevation = section.startElevation; + topDistance = section.startDistanceKm; } + if (section.endElevation > topElevation) { + topElevation = section.endElevation; + topDistance = section.endDistanceKm; + } + } + context.fillStyle = colors.accent; + context.beginPath(); + context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); + context.fill(); + context.font = "9px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "top"; + context.strokeStyle = colors.grid; + context.lineWidth = 1; + for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; + const x = xForDistance(minDistance + distanceKm); + context.beginPath(); + context.moveTo(x, plot.bottom); + context.lineTo(x, plot.bottom + 3); + context.stroke(); + context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; + context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5); } } + +function formatClimbDistance(distance: number): string { + return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; +} diff --git a/web/official-climbs.go b/web/official-climbs.go new file mode 100644 index 0000000..ce9d7cc --- /dev/null +++ b/web/official-climbs.go @@ -0,0 +1,60 @@ +package web + +import ( + "log/slog" + + "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" +) + +// analysisFor runs the ride analysis pipeline the same way the ride profile +// does, so recorded attempts and displayed climbs always agree. +func analysisFor(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) rideanalysis.Result { + return rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) +} + +// attemptsFromAnalysis extracts one attempt per official-climb occurrence. +// Occurrences are numbered per climb in route order (0, 1, … for laps). +func attemptsFromAnalysis(rideID int64, analysis rideanalysis.Result, parsed ride.Ride) []official_climb.Attempt { + occurrences := make(map[int64]int) + attempts := make([]official_climb.Attempt, 0) + for _, analyzed := range analysis.Climbs { + if analyzed.OfficialClimbID == 0 { + continue + } + occurrence := occurrences[analyzed.OfficialClimbID] + occurrences[analyzed.OfficialClimbID] = occurrence + 1 + attempts = append(attempts, official_climb.AttemptFromStats(rideID, occurrence, official_climb.AttemptStats{ + OfficialClimbID: analyzed.OfficialClimbID, + DistanceM: analyzed.DistanceM, + ElevationGainM: elevationGain(analyzed), + SlopePercent: analyzed.SlopePercent, + Cotacol: analyzed.Cotacol, + StartIndex: analyzed.Interval.Start, + EndIndex: analyzed.Interval.End, + }, parsed.Timestamps())) + } + return attempts +} + +func elevationGain(analyzed rideanalysis.AnalyzedClimb) float64 { + gain := 0.0 + for i := 1; i < len(analyzed.Segment.Elevations); i++ { + if diff := analyzed.Segment.Elevations[i] - analyzed.Segment.Elevations[i-1]; diff > 0 { + gain += diff + } + } + return gain +} + +// recordOfficialClimbAttempts persists the ride's official-climb attempts, +// replacing any previously recorded ones. Failures are logged, never fatal: +// attempts are an optimization over on-the-fly matching. +func (s *Server) recordOfficialClimbAttempts(rideID int64, parsed ride.Ride, analysis rideanalysis.Result) { + attempts := attemptsFromAnalysis(rideID, analysis, parsed) + if err := official_climb.ReplaceRideAttempts(s.db, rideID, attempts); err != nil { + slog.Warn("Failed to record official climb attempts", "ride_id", rideID, "error", err) + } +} diff --git a/web/official-climbs_test.go b/web/official-climbs_test.go new file mode 100644 index 0000000..ba71f19 --- /dev/null +++ b/web/official-climbs_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "database/sql" + "github.com/martinlehoux/biking_home/internal/ridetest" + "testing" + "time" + + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testRideStart = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + +func testRideForClimb(t *testing.T) (ride.Ride, official_climb.OfficialClimb) { + t.Helper() + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(testRideStart)) + return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1]} +} + +func insertTestRide(t *testing.T, db *sql.DB) int64 { + t.Helper() + result, err := db.Exec(` + INSERT INTO rides (external_id, gpx_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "strava:climb-test", "missing.gpx", "Test Ride", "Ride", "2026-09-01T08:00:00Z", 20_000, 0, 0, 0, 0) + require.NoError(t, err) + id, err := result.LastInsertId() + require.NoError(t, err) + return id +} + +func TestAttemptsFromAnalysisSnapshotsOfficialClimbs(t *testing.T) { + parsed, climb := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{{ID: 42, Name: climb.Name, StartCoord: climb.StartCoord, EndCoord: climb.EndCoord}}, official_climb.DefaultMatchPolicy()) + + attempts := attemptsFromAnalysis(7, analysis, parsed) + require.Len(t, attempts, 1) + assert.Equal(t, int64(7), attempts[0].RideID) + assert.Equal(t, int64(42), attempts[0].ClimbID) + assert.Equal(t, 0, attempts[0].Occurrence) + assert.InDelta(t, 1000, attempts[0].DistanceM, 1) + assert.InDelta(t, 147, attempts[0].ElevationGainM, 1) + assert.InDelta(t, 14.7, attempts[0].SlopePercent, 0.1) + assert.Positive(t, attempts[0].Cotacol) + assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion) + assert.Equal(t, testRideStart, attempts[0].StartedAt.UTC()) + assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps") +} + +func TestAttemptsFromAnalysisSkipsUnmatchedClimbs(t *testing.T) { + parsed, _ := testRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, nil, official_climb.DefaultMatchPolicy()) + + attempts := attemptsFromAnalysis(7, analysis, parsed) + assert.Empty(t, attempts) +} + +func TestRecordOfficialClimbAttemptsPersistsAndReplaces(t *testing.T) { + server, db := newWebTestServer(t) + rideID := insertTestRide(t, db) + parsed, climb := testRideForClimb(t) + created, err := official_climb.Create(db, climb) + require.NoError(t, err) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) + + server.recordOfficialClimbAttempts(rideID, parsed, analysis) + attempts, err := official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 1) + assert.Equal(t, rideID, attempts[0].RideID) + + // Re-recording replaces, never duplicates. + server.recordOfficialClimbAttempts(rideID, parsed, analysis) + attempts, err = official_climb.AttemptsForClimb(db, created.ID) + require.NoError(t, err) + require.Len(t, attempts, 1) +} diff --git a/web/rides.go b/web/rides.go index 37340b8..10fbb75 100644 --- a/web/rides.go +++ b/web/rides.go @@ -87,6 +87,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { slog.Warn("Failed to load official climbs for ride detail", "ride_id", id, "error", err) } matchPolicy := s.officialClimbMatchPolicy() + s.recordOfficialClimbAttempts(id, parsed, analysisFor(parsed, passes, officialClimbs, matchPolicy)) slog.Info("Loaded ride detail", "ride_id", id) view := buildRideDetailView(buildRideView(item), parsed, passes, officialClimbs, matchPolicy) view.Notice = rideDetailNotice(r) diff --git a/web/server.go b/web/server.go index c588696..fc0e526 100644 --- a/web/server.go +++ b/web/server.go @@ -50,8 +50,10 @@ func (s *Server) Handler() http.Handler { handleRoute(mux, "POST /preview/official-climbs", s.handlePreviewOfficialClimb) handleRoute(mux, "GET /rides/{id}", s.handleRide) handleRoute(mux, "GET /analytics", s.handleAnalytics) - handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) + handleRoute(mux, "GET /climbs", s.handleClimbs) + handleRoute(mux, "GET /climbs/{id}", s.handleClimb) handleRoute(mux, "POST /rides/{id}/categories", s.handleUpdateRideCategories) + handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) handleRoute(mux, "GET /data-quality", s.handleDataQuality) handleRoute(mux, "POST /data-quality/categories", s.handleBulkUpdateCategories) handleRoute(mux, "GET /sync", s.handleSyncForm) diff --git a/web/templates.templ b/web/templates.templ index 14e9aab..d41dd39 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -15,7 +15,7 @@ templ Layout(title string, content templ.Component) { <body> <header class="site-header"> <a class="brand" href="/">biking_home</a> - <nav class="nav"><a href="/">Rides</a><a href="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav> + <nav class="nav"><a href="/">Rides</a><a href="/climbs">Climbs</a><a href="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav> </header> <main class="container"> @content @@ -485,3 +485,89 @@ templ DataQualityContent(data DataQualityPageData) { } </section> } +templ ClimbsPage(data ClimbsPageData) { + @Layout("Climbs", ClimbsContent(data)) +} + +templ ClimbsContent(data ClimbsPageData) { + <p class="eyebrow">Official climbs</p> + <section class="panel"> + if len(data.Items) == 0 { + <div class="empty">No official climbs yet. Create one from a ride detail page.</div> + } else { + <table> + <thead><tr> + for _, header := range data.Headers { + <th class={ header.Class } scope="col" aria-sort={ header.AriaSort }><a class="sort-link" href={ templ.URL(header.URL) }>{ header.Label }<span class="sort-indicator" aria-hidden="true">{ header.Indicator }</span></a></th> + } + </tr></thead> + <tbody> + for _, item := range data.Items { + <tr><td><strong><a href={ templ.URL(item.URL) }>{ item.Climb.Name }</a></strong></td><td>{ item.Category }</td><td class="numeric">{ formatClimbCount(item.Climb.Attempts) }</td><td class="numeric">{ item.Distance }</td><td class="numeric">{ item.Slope }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.BestTime }</td></tr> + } + </tbody> + </table> + } + </section> +} + +templ ClimbDetailPage(data ClimbDetailPageData) { + @Layout("Climb", ClimbDetailContent(data)) +} + +templ ClimbDetailContent(data ClimbDetailPageData) { + <div class="toolbar"> + <div><h1 class="ride-detail-title">{ data.Climb.Name }</h1><p class="lead">Official climb · { data.Stats.Category }</p></div> + <div class="toolbar-actions"><a class="button secondary" href="/climbs">Back to climbs</a></div> + </div> + <section class="panel summary-panel"> + <dl> + <div><dt>Attempts</dt><dd>{ data.Stats.Attempts }</dd></div> + <div><dt>Distance</dt><dd>{ data.Stats.Distance }</dd></div> + <div><dt>Elevation</dt><dd>{ data.Stats.ElevationG }</dd></div> + <div><dt>Avg slope</dt><dd>{ data.Stats.Slope }</dd></div> + <div><dt>Cotacol</dt><dd>{ data.Stats.Cotacol }</dd></div> + <div><dt>Best time</dt><dd>{ data.Stats.BestTime }</dd></div> + </dl> + </section> + <section class="panel map-panel"> + <div id="climb-map" class="ride-map" role="img" aria-label="Official climb map"></div> + </section> + if len(data.Profile.Elevations) > 0 { + <section class="panel"> + <div class="section-heading"><div><p class="eyebrow">Profile</p><h2>Median profile</h2></div></div> + <div class="profile-chart"><canvas id="climb-profile" data-climb-profile tabindex="0" role="img" aria-label={ officialProfileLabel(data.Climb.Name) }></canvas></div> + <p class="profile-hover" data-climb-profile-hover aria-live="polite">Hover or focus the profile to inspect the climb at a distance.</p> + <div class="official-climb-profile-legend" role="list" aria-label="Average slope color scale"> + <span role="listitem"><i class="profile-band-swatch profile-band-downhill" aria-hidden="true"></i><=0%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-0-3" aria-hidden="true"></i>0–3%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-3-6" aria-hidden="true"></i>3–6%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-6-9" aria-hidden="true"></i>6–9%</span> + <span role="listitem"><i class="profile-band-swatch profile-band-9-plus" aria-hidden="true"></i>9%+</span> + </div> + </section> + } + <section class="panel"> + <div class="section-heading"> + <div><p class="eyebrow">History</p><h2>Attempts</h2></div> + <span class="section-count">{ formatClimbCount(len(data.Attempts)) }</span> + </div> + if len(data.Attempts) == 0 { + <div class="empty">No attempts recorded yet. Open a ride containing this climb to record one.</div> + } else { + <table> + <thead><tr><th>Date</th><th>Ride</th><th class="numeric">Time</th><th class="numeric">Distance</th><th class="numeric">Cotacol</th></tr></thead> + <tbody> + for _, attempt := range data.Attempts { + <tr><td>{ attempt.Date }</td><td><a href={ templ.URL(rideDetailURL(attempt.RideID)) }>{ attempt.RideName }</a></td><td class="numeric">{ attempt.Duration }</td><td class="numeric">{ attempt.Distance }</td><td class="numeric">{ attempt.Cotacol }</td></tr> + } + </tbody> + </table> + } + </section> + @templ.JSONScript("climb-profile-data", data.Profile) + @templ.JSONScript("climb-endpoints", data.Endpoints) + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> + <script type="module" src="/static/climb-profile.js"></script> + <script type="module" src="/static/climb-map.js"></script> +} diff --git a/web/views.go b/web/views.go index bfbfe09..5dfc0e6 100644 --- a/web/views.go +++ b/web/views.go @@ -437,7 +437,11 @@ func formatSpeed(metersPerSecond float64) string { func formatDuration(seconds int64) string { hours := seconds / 3600 minutes := (seconds % 3600) / 60 - return fmt.Sprintf("%dh %02dm", hours, minutes) + secondsOnly := seconds % 60 + if hours > 0 { + return fmt.Sprintf("%dh %02dm %02ds", hours, minutes, secondsOnly) + } + return fmt.Sprintf("%dm %02ds", minutes, secondsOnly) } func formatElevation(meters float64) string { |
