diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-24 08:37:33 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-24 08:37:33 +0200 |
| commit | 90614736f2a0c556f31eca5361b3c032f5f309f6 (patch) | |
| tree | 81fda954dc2bff8ae8b0023b502343c455995272 /web | |
| parent | e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (diff) | |
Match official climbs against their recorded sample
An official climb matched on endpoint coordinates alone. On a loop ridden
the other way the route meets the climb's top before its bottom, so pairing
each start with the first end inside its lap window credited a 5.9 km
passage as the 850 m Chemin de la Tour Pey Blanc, and a 49 km one on
another ride.
Climbs now keep the sample of the segment they were created from: the
recorded length and elevation gain of the selected route points, captured
by the ride detail and preview handlers and stored NOT NULL. Matching takes
the whole route and drops any candidate outside ±50% of that sample on
either axis, and attempts are refreshed by upsert and prune so re-analyzing
a ride keeps the recorded-at stamp of the passages that survive.
The backfill leaves the web package: backfill.Run recomputes ride values
then official climb attempts as a single `go run . backfill` command
(climbs-backfill is gone), rideanalysis owns the attempt snapshot, and the
mountain-pass loading margin is shared. Applied to the stored rides it
stamps 78 samples, drops the four bogus attempts and leaves ride 800 with
its three Pont Rout passages.
Diffstat (limited to 'web')
| -rw-r--r-- | web/climb-backfill.go | 81 | ||||
| -rw-r--r-- | web/climbs.go | 10 | ||||
| -rw-r--r-- | web/climbs_test.go | 7 | ||||
| -rw-r--r-- | web/frontend/ride-detail-boundaries.ts | 26 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.test.ts | 16 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.ts | 32 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 5 | ||||
| -rw-r--r-- | web/official-climbs.go | 43 | ||||
| -rw-r--r-- | web/official-climbs_test.go | 28 | ||||
| -rw-r--r-- | web/preview.go | 25 | ||||
| -rw-r--r-- | web/rides.go | 7 | ||||
| -rw-r--r-- | web/server_test.go | 27 | ||||
| -rw-r--r-- | web/templates.templ | 8 |
13 files changed, 131 insertions, 184 deletions
diff --git a/web/climb-backfill.go b/web/climb-backfill.go deleted file mode 100644 index 13a8c02..0000000 --- a/web/climb-backfill.go +++ /dev/null @@ -1,81 +0,0 @@ -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 index 6859545..6416509 100644 --- a/web/climbs.go +++ b/web/climbs.go @@ -101,9 +101,7 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) { }), 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. +// buildClimbProfile computes the median normalized elevation profile from the linked rides, resampling every occurrence onto a shared 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 { @@ -127,7 +125,7 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a 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) { + for _, matched := range official_climb.MatchClimbs(parsed.Route, 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], @@ -152,9 +150,7 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a 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. +// medianRoute picks the recorded route of the attempt whose distance is the median: full precision, no resampling, robust to one drifted ride. func medianRoute(candidates []routeCandidate) []geodist.Coord { if len(candidates) == 0 { return nil diff --git a/web/climbs_test.go b/web/climbs_test.go index fbaf527..3b30c07 100644 --- a/web/climbs_test.go +++ b/web/climbs_test.go @@ -67,7 +67,7 @@ func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { storeRideFixtureFile(t, db, rideID) parsed, _ := testRideForClimb(t) analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := attemptsFromAnalysis(rideID, analysis, parsed) + attempts := rideanalysis.Attempts(rideID, analysis, parsed) profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) @@ -84,12 +84,11 @@ func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { 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) + attempts := rideanalysis.Attempts(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. + // Full recorded precision: every matched segment point is present, not a fixed 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) diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts index a9bb8e1..f1d80e7 100644 --- a/web/frontend/ride-detail-boundaries.ts +++ b/web/frontend/ride-detail-boundaries.ts @@ -1,4 +1,4 @@ -import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js"; +import { climbMetrics, climbSample, formatDistance, formatElevation } from "./ride-detail-logic.js"; import { requireElement } from "./dom.js"; import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js"; @@ -104,11 +104,13 @@ export class RideBoundaryController { const item = form.closest<HTMLElement>("[data-climb-item]"); if (!item) return; const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); - const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]); - if (!metrics) { + const bounds = this.climbBounds[climbIndex]; + const metrics = climbMetrics(this.points, bounds); + if (!bounds || !metrics) { preview.textContent = "Choose an end point after the start point."; return; } + this.syncSnapshot(form, bounds); const summary = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary"); const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "climb metrics"); summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`; @@ -116,6 +118,24 @@ export class RideBoundaryController { preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; } + // syncSnapshot fills the fields the preview save posts: the preview ride is never persisted. + syncSnapshot(form: HTMLFormElement, bounds: ClimbBounds): void { + const sample = climbSample(this.points, bounds); + if (!sample) return; + const snapshot: Array<[string, number]> = [ + ["start_lat", this.points[bounds.startIndex].latitude], + ["start_lon", this.points[bounds.startIndex].longitude], + ["end_lat", this.points[bounds.endIndex].latitude], + ["end_lon", this.points[bounds.endIndex].longitude], + ["sample_distance_m", sample.distanceM], + ["sample_elevation_gain_m", sample.elevationGainM], + ]; + for (const [name, value] of snapshot) { + const input = form.querySelector<HTMLInputElement>(`input[name="${name}"]`); + if (input) input.value = String(value); + } + } + boundaryLabel(index: number): string { const point = this.points[index]; const idx = point.index ?? index; diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts index 471eb49..5104516 100644 --- a/web/frontend/ride-detail-logic.test.ts +++ b/web/frontend/ride-detail-logic.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { categoryForCotacol, climbMetrics, + climbSample, cotacolForClimb, elevationAtDistance, formatClimbLabel, @@ -41,6 +42,21 @@ test("calculates climb metrics with Cotacol", () => { assert.equal(metrics.category, categoryForCotacol(metrics.cotacol)); }); +test("computes the recorded sample of a climb segment", () => { + assert.deepEqual(climbSample(points, { startIndex: 0, endIndex: 3 }), { distanceM: 250, elevationGainM: 20 }); + assert.equal(climbSample(points, { startIndex: 3, endIndex: 0 }), null); +}); + +test("climb sample counts only upward steps", () => { + const rolling = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.1, elevationM: 130 }, + { distanceKm: 0.2, elevationM: 110 }, + { distanceKm: 0.3, elevationM: 140 }, + ]; + assert.deepEqual(climbSample(rolling, { startIndex: 0, endIndex: 3 }), { distanceM: 300, elevationGainM: 60 }); +}); + test("formats profile climb labels with category", () => { assert.equal(formatClimbLabel({ name: "", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Cat 4 45.9"); assert.equal(formatClimbLabel({ name: "Col de Test", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Col de Test"); diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts index b4d4dba..e7fc822 100644 --- a/web/frontend/ride-detail-logic.ts +++ b/web/frontend/ride-detail-logic.ts @@ -1,4 +1,4 @@ -import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js"; +import type { ClimbBounds, ClimbMetrics, ClimbSample, ProfilePoint } from "./types.js"; export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); @@ -71,15 +71,13 @@ export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endI return cotacol; }; +const boundsAreValid = (points: ProfilePoint[], bounds: ClimbBounds | undefined): bounds is ClimbBounds => { + if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex)) return false; + return bounds.startIndex >= 0 && bounds.endIndex < points.length && bounds.startIndex < bounds.endIndex; +}; + export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => { - if ( - !bounds || - !Number.isInteger(bounds.startIndex) || - !Number.isInteger(bounds.endIndex) || - bounds.startIndex < 0 || - bounds.endIndex >= points.length || - bounds.startIndex >= bounds.endIndex - ) { + if (!boundsAreValid(points, bounds)) { return null; } const start = points[bounds.startIndex]; @@ -98,3 +96,19 @@ export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undef category: categoryForCotacol(cotacol), }; }; + +// climbSample returns the segment's recorded length and upward-only gain, as the server computes it for an official climb sample. +export const climbSample = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbSample | null => { + if (!boundsAreValid(points, bounds)) { + return null; + } + const sample: ClimbSample = { + distanceM: (points[bounds.endIndex].distanceKm - points[bounds.startIndex].distanceKm) * 1000, + elevationGainM: 0, + }; + for (let index = bounds.startIndex + 1; index <= bounds.endIndex; index++) { + const gain = points[index].elevationM - points[index - 1].elevationM; + if (gain > 0) sample.elevationGainM += gain; + } + return sample; +}; diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index 87be024..74e0765 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -81,6 +81,11 @@ export interface ClimbMetrics { category: string; } +export interface ClimbSample { + distanceM: number; + elevationGainM: number; +} + export interface OfficialProfileColors { downhill: string; "0-3": string; diff --git a/web/official-climbs.go b/web/official-climbs.go index ce9d7cc..b312bb5 100644 --- a/web/official-climbs.go +++ b/web/official-climbs.go @@ -9,51 +9,14 @@ import ( "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. +// analysisFor runs the same pipeline as the ride profile, so recorded attempts and displayed climbs 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. +// recordOfficialClimbAttempts refreshes the ride's official-climb attempts; failures are logged, never fatal. func (s *Server) recordOfficialClimbAttempts(rideID int64, parsed ride.Ride, analysis rideanalysis.Result) { - attempts := attemptsFromAnalysis(rideID, analysis, parsed) + attempts := rideanalysis.Attempts(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 index ba71f19..8fab49c 100644 --- a/web/official-climbs_test.go +++ b/web/official-climbs_test.go @@ -18,7 +18,7 @@ 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]} + return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], Sample: parsed.Sample(0, 1)} } func insertTestRide(t *testing.T, db *sql.DB) int64 { @@ -33,32 +33,6 @@ func insertTestRide(t *testing.T, db *sql.DB) int64 { 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) diff --git a/web/preview.go b/web/preview.go index 2c36b71..88978b8 100644 --- a/web/preview.go +++ b/web/preview.go @@ -15,6 +15,7 @@ import ( "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" ) @@ -53,7 +54,7 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { s.renderPreviewForm(w, r, "The file could not be parsed as a ride (GPX without elevation or an unsupported format?).") return } - passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideanalysis.PassMarginM) if err != nil { slog.Warn("Failed to load nearby mountain passes for preview", "error", err) } @@ -82,8 +83,7 @@ func rideParserFor(filename string) (ride.RideParser, error) { } } -// buildPreviewRideView derives the ride summary metrics from the parsed route, -// the same values ride.Save persists for imported rides. +// buildPreviewRideView derives the ride summary metrics from the parsed route, the same values ride.Save persists. func buildPreviewRideView(filename string, parsed ride.Ride) RideView { distanceM := parsed.Distances[len(parsed.Distances)-1] elevationGainM := 0.0 @@ -151,10 +151,16 @@ func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Reque writePreviewSaveError(w, "valid end coordinates are required") return } + sample, err := parseSampleForm(r) + if err != nil { + writePreviewSaveError(w, "a valid recorded sample is required") + return + } created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ Name: r.FormValue("name"), StartCoord: startCoord, EndCoord: endCoord, + Sample: sample, }) if err != nil { slog.Warn("Failed to create official climb from preview", "error", err) @@ -166,6 +172,19 @@ func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Reque _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "name": created.Name}) } +// parseSampleForm reads the sample the preview client computed for the selected boundaries. +func parseSampleForm(r *http.Request) (climb.SegmentSample, error) { + distanceM, err := strconv.ParseFloat(r.FormValue("sample_distance_m"), 64) + if err != nil { + return climb.SegmentSample{}, err + } + elevationGainM, err := strconv.ParseFloat(r.FormValue("sample_elevation_gain_m"), 64) + if err != nil { + return climb.SegmentSample{}, err + } + return climb.SegmentSample{DistanceM: distanceM, ElevationGainM: elevationGainM}, nil +} + func parseCoordForm(r *http.Request, prefix string) (coord geodist.Coord, err error) { lat, err := strconv.ParseFloat(r.FormValue(prefix+"_lat"), 64) if err != nil { diff --git a/web/rides.go b/web/rides.go index 10fbb75..1eea98e 100644 --- a/web/rides.go +++ b/web/rides.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -78,7 +79,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { }), w) return } - passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideanalysis.PassMarginM) if err != nil { slog.Warn("Failed to load nearby mountain passes for ride detail", "ride_id", id, "error", err) } @@ -167,10 +168,12 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.Error(w, "the official climb boundaries must be ordered route points", http.StatusBadRequest) return } + sample := parsed.Sample(startIndex, endIndex) created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ Name: r.FormValue("name"), StartCoord: bounds[startIndex], EndCoord: bounds[endIndex], + Sample: sample, }) if err != nil { slog.Warn("Failed to create official climb", "ride_id", id, "error", err) @@ -209,8 +212,6 @@ func (s *Server) listRides(rideSort RideSort) ([]ride.StoredRide, error) { return ride.ListSorted(s.db, column, rideSort.Descending) } -const rideDetailMountainPassMarginM = 10_000 - func buildRideViews(items []ride.StoredRide) []RideView { views := make([]RideView, 0, len(items)) for _, item := range items { diff --git a/web/server_test.go b/web/server_test.go index e90f69a..440ac5f 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -244,6 +244,7 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) { Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], + Sample: parsed.Sample(0, 1), }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -261,6 +262,7 @@ func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) { Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[2], + Sample: parsed.Sample(0, 2), }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -314,6 +316,9 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { assert.Equal(t, "Col de Test", climbs[0].Name) assert.Equal(t, geodist.Coord{Lat: 43.0, Lon: 5.0}, climbs[0].StartCoord) assert.Equal(t, geodist.Coord{Lat: 43.005, Lon: 5.005}, climbs[0].EndCoord) + require.NotNil(t, climbs[0].Sample) + assert.Equal(t, 200.0, climbs[0].Sample.ElevationGainM) + assert.InDelta(t, 689, climbs[0].Sample.DistanceM, 2) getRequest = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), nil) getResponse = httptest.NewRecorder() @@ -952,6 +957,9 @@ func TestPreviewParsesGPXWithoutPersisting(t *testing.T) { assert.Contains(t, body, "ride-route") assert.Contains(t, body, "ride-profile") assert.Contains(t, body, "data-official-climb-form") + assert.Contains(t, body, `name="sample_distance_m"`) + assert.Contains(t, body, `name="sample_elevation_gain_m"`) + assert.Contains(t, body, `name="start_lat"`) assert.NotContains(t, body, "Curation") var count int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM rides").Scan(&count)) @@ -979,13 +987,15 @@ func TestPreviewShowsInlineErrorForInvalidRideFile(t *testing.T) { func TestPreviewCreatesOfficialClimbWithoutPersistingRide(t *testing.T) { server, db := newWebTestServer(t) form := url.Values{ - "name": {"Col du Preview"}, - "start_index": {"0"}, - "end_index": {"2"}, - "start_lat": {"43.0"}, - "start_lon": {"5.0"}, - "end_lat": {"43.01"}, - "end_lon": {"5.01"}, + "name": {"Col du Preview"}, + "start_index": {"0"}, + "end_index": {"2"}, + "start_lat": {"43.0"}, + "start_lon": {"5.0"}, + "end_lat": {"43.01"}, + "end_lon": {"5.01"}, + "sample_distance_m": {"1400"}, + "sample_elevation_gain_m": {"200"}, } request := httptest.NewRequest(http.MethodPost, "/preview/official-climbs", strings.NewReader(form.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -996,6 +1006,9 @@ func TestPreviewCreatesOfficialClimbWithoutPersistingRide(t *testing.T) { require.NoError(t, err) require.Len(t, climbs, 1) assert.Equal(t, "Col du Preview", climbs[0].Name) + require.NotNil(t, climbs[0].Sample) + assert.Equal(t, 1400.0, climbs[0].Sample.DistanceM) + assert.Equal(t, 200.0, climbs[0].Sample.ElevationGainM) var count int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM rides").Scan(&count)) assert.Equal(t, 0, count, "preview climb creation must not persist a ride") diff --git a/web/templates.templ b/web/templates.templ index d41dd39..aceeeee 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -342,6 +342,14 @@ templ RideDetailContent(data RideDetailView) { <label>Name<input type="text" name="name" required placeholder="Official climb name"/></label> <input type="hidden" name="start_index" value={ formatRouteIndex(climb.StartIndex) } data-boundary-input="start"/> <input type="hidden" name="end_index" value={ formatRouteIndex(climb.EndIndex) } data-boundary-input="end"/> + if data.Preview { + <input type="hidden" name="start_lat" value=""/> + <input type="hidden" name="start_lon" value=""/> + <input type="hidden" name="end_lat" value=""/> + <input type="hidden" name="end_lon" value=""/> + <input type="hidden" name="sample_distance_m" value=""/> + <input type="hidden" name="sample_elevation_gain_m" value=""/> + } <div class="boundary-controls"> <div><strong>Start</strong><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="profile" aria-label="Select on profile for start">Profile</button><button class="button secondary" type="button" data-boundary-button="start" data-boundary-source="map" aria-label="Select on map for start">Map</button><output data-boundary-output="start">{ formatProfileDistance(climb.StartKm) }</output></div> <div><strong>End</strong><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="profile" aria-label="Select on profile for end">Profile</button><button class="button secondary" type="button" data-boundary-button="end" data-boundary-source="map" aria-label="Select on map for end">Map</button><output data-boundary-output="end">{ formatProfileDistance(climb.EndKm) }</output></div> |
