aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/climb-backfill.go81
-rw-r--r--web/climbs.go10
-rw-r--r--web/climbs_test.go7
-rw-r--r--web/frontend/ride-detail-boundaries.ts26
-rw-r--r--web/frontend/ride-detail-logic.test.ts16
-rw-r--r--web/frontend/ride-detail-logic.ts32
-rw-r--r--web/frontend/types.d.ts5
-rw-r--r--web/official-climbs.go43
-rw-r--r--web/official-climbs_test.go28
-rw-r--r--web/preview.go25
-rw-r--r--web/rides.go7
-rw-r--r--web/server_test.go27
-rw-r--r--web/templates.templ8
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>