diff options
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> |
