aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-06 12:38:16 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-06 12:38:16 +0200
commit8e0c55230ef59869bda1ca19ca8697b447d57698 (patch)
tree2d5941e95949c43a84326cc5d10db8e4fab72a64
parent28a7c9f281c16d1928b37dd1afdfe8399b7a03f8 (diff)
Preview: analyze a raw GPX or FIT file without persisting it
-rw-r--r--README.md2
-rw-r--r--web/frontend/ride-detail.ts28
-rw-r--r--web/preview.go185
-rw-r--r--web/rides.go22
-rw-r--r--web/server.go3
-rw-r--r--web/server_test.go104
-rw-r--r--web/static/style.css4
-rw-r--r--web/templates.templ31
-rw-r--r--web/views.go34
9 files changed, 385 insertions, 28 deletions
diff --git a/README.md b/README.md
index b957d90..3491395 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ Climb profile by 100m step.
**Analytics** - Plot speed vs difficulty, difficulty vs duration. Color points by type or season.
+**Preview** - Upload a raw GPX or FIT file at `/preview` to analyze it (climb detection, official-climb matching, mountain-pass crossings) without importing it; official climbs can be saved from a preview.
+
**Elevation fix** - Hover the profile to see `#index · km · m`, run `go run . elevation analyse <id|all>` to flag unrealistic grades (>50% over <30m) and `go run . elevation fix <id> --from <index|km> --to <index|km> [--dry-run]` (or `--auto`) to linearly interpolate elevations between anchors, rewriting FIT+GPX (`.bak` kept) and recomputing Cotacol.
## Getting started
diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts
index b8d2898..dcebbe4 100644
--- a/web/frontend/ride-detail.ts
+++ b/web/frontend/ride-detail.ts
@@ -146,6 +146,34 @@ export const mountRideDetail = (): void => {
});
if (climbItems.length > 0) updateClimbFocus(0, false);
else profileCanvas.redraw();
+ mountPreviewSave([...document.querySelectorAll<HTMLFormElement>("form[data-preview-form]")]);
+};
+
+// Preview ride forms save official climbs via fetch so the un-persisted preview stays on screen.
+const mountPreviewSave = (forms: HTMLFormElement[]): void => {
+ for (const form of forms) {
+ if (!form.dataset.previewForm) continue;
+ const item = form.closest<HTMLElement>("[data-climb-item]");
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const submitButton = form.querySelector<HTMLButtonElement>("button[type=submit]");
+ if (submitButton) submitButton.disabled = true;
+ let message: string;
+ try {
+ const response = await fetch(form.action, { method: "POST", body: new FormData(form) });
+ const payload = (await response.json()) as { ok?: boolean; name?: string; error?: string };
+ message =
+ response.ok && payload.ok && payload.name
+ ? `Official climb “${payload.name}” saved. Re-upload the file to see it matched.`
+ : `Saving the official climb failed${payload.error ? `: ${payload.error}` : "."}`;
+ } catch {
+ message = "Saving the official climb failed.";
+ }
+ const status = item?.querySelector<HTMLElement>(".climb-status");
+ if (status) status.textContent = message;
+ else window.alert(message);
+ });
+ }
};
mountRideDetail();
diff --git a/web/preview.go b/web/preview.go
new file mode 100644
index 0000000..2c36b71
--- /dev/null
+++ b/web/preview.go
@@ -0,0 +1,185 @@
+package web
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+const previewOfficialClimbURL = "/preview/official-climbs"
+
+type PreviewPageData struct {
+ Error string
+}
+
+func (s *Server) handlePreviewForm(w http.ResponseWriter, r *http.Request) {
+ kcore.RenderPage(r.Context(), PreviewPage(PreviewPageData{}), w)
+}
+
+func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
+ // ponytail: hard 32MB cap; raise if real-world GPX exports exceed it
+ r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
+ if err := r.ParseMultipartForm(8 << 20); err != nil {
+ slog.Info("Rejected preview upload", "error", err)
+ s.renderPreviewForm(w, r, "The upload was rejected. Please try a smaller file.")
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ s.renderPreviewForm(w, r, "A ride file is required.")
+ return
+ }
+ defer file.Close()
+ parser, err := rideParserFor(header.Filename)
+ if err != nil {
+ s.renderPreviewForm(w, r, err.Error())
+ return
+ }
+ parsed, err := parser.Parse(file)
+ if err != nil {
+ slog.Info("Failed to parse preview file", "filename", header.Filename, "error", err)
+ 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)
+ if err != nil {
+ slog.Warn("Failed to load nearby mountain passes for preview", "error", err)
+ }
+ officialClimbs, err := official_climb.List(s.db)
+ if err != nil {
+ slog.Warn("Failed to load official climbs for preview", "error", err)
+ }
+ view := buildRideDetailView(buildPreviewRideView(header.Filename, parsed), parsed, passes, officialClimbs, s.officialClimbMatchPolicy())
+ view.Preview = true
+ view.OfficialClimbFormURL = previewOfficialClimbURL
+ kcore.RenderPage(r.Context(), RideDetailPage(view), w)
+}
+
+func (s *Server) renderPreviewForm(w http.ResponseWriter, r *http.Request, message string) {
+ kcore.RenderPage(r.Context(), PreviewPage(PreviewPageData{Error: message}), w)
+}
+
+func rideParserFor(filename string) (ride.RideParser, error) {
+ switch strings.ToLower(filepath.Ext(filename)) {
+ case ".gpx":
+ return ride.GPXRideParser{}, nil
+ case ".fit":
+ return ride.FITRideParser{}, nil
+ default:
+ return nil, errors.New("unsupported file type: upload a .gpx or .fit file")
+ }
+}
+
+// buildPreviewRideView derives the ride summary metrics from the parsed route,
+// the same values ride.Save persists for imported rides.
+func buildPreviewRideView(filename string, parsed ride.Ride) RideView {
+ distanceM := parsed.Distances[len(parsed.Distances)-1]
+ elevationGainM := 0.0
+ for i := 1; i < len(parsed.Elevations); i++ {
+ if delta := parsed.Elevations[i] - parsed.Elevations[i-1]; delta > 0 {
+ elevationGainM += delta
+ }
+ }
+ movingTimeS := int64(ride.MovingTime(parsed) / time.Second)
+ averageSpeedMps := 0.0
+ if movingTimeS > 0 {
+ averageSpeedMps = distanceM / float64(movingTimeS)
+ }
+ timestamps := parsed.Timestamps()
+ var startDate time.Time
+ if len(timestamps) > 0 {
+ startDate = timestamps[0]
+ }
+ view := RideView{
+ Ride: ride.StoredRide{
+ Name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)),
+ StartDate: startDate,
+ DistanceM: distanceM,
+ TotalElevationGainM: elevationGainM,
+ AverageSpeedMps: averageSpeedMps,
+ },
+ Cotacol: "-",
+ CotacolPer100Km: "-",
+ MovingTime: formatDuration(movingTimeS),
+ }
+ if distanceM > 0 {
+ cotacol := climb.Cotacol(parsed.Distances, parsed.Elevations)
+ view.Cotacol = formatCotacol(cotacol)
+ view.CotacolPer100Km = formatCotacolPer100Km(cotacol, distanceM)
+ }
+ return view
+}
+
+func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ startIndex, err := parseRouteIndex(r.FormValue("start_index"))
+ if err != nil {
+ writePreviewSaveError(w, "a valid start route point is required")
+ return
+ }
+ endIndex, err := parseRouteIndex(r.FormValue("end_index"))
+ if err != nil {
+ writePreviewSaveError(w, "a valid end route point is required")
+ return
+ }
+ if startIndex >= endIndex {
+ writePreviewSaveError(w, "the official climb boundaries must be ordered route points")
+ return
+ }
+ startCoord, err := parseCoordForm(r, "start")
+ if err != nil {
+ writePreviewSaveError(w, "valid start coordinates are required")
+ return
+ }
+ endCoord, err := parseCoordForm(r, "end")
+ if err != nil {
+ writePreviewSaveError(w, "valid end coordinates are required")
+ return
+ }
+ created, err := official_climb.Create(s.db, official_climb.OfficialClimb{
+ Name: r.FormValue("name"),
+ StartCoord: startCoord,
+ EndCoord: endCoord,
+ })
+ if err != nil {
+ slog.Warn("Failed to create official climb from preview", "error", err)
+ writePreviewSaveError(w, err.Error())
+ return
+ }
+ slog.Info("Created official climb from preview", "official_climb_id", created.ID)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "name": created.Name})
+}
+
+func parseCoordForm(r *http.Request, prefix string) (coord geodist.Coord, err error) {
+ lat, err := strconv.ParseFloat(r.FormValue(prefix+"_lat"), 64)
+ if err != nil {
+ return coord, err
+ }
+ lon, err := strconv.ParseFloat(r.FormValue(prefix+"_lon"), 64)
+ if err != nil {
+ return coord, err
+ }
+ return geodist.Coord{Lat: lat, Lon: lon}, nil
+}
+
+func writePreviewSaveError(w http.ResponseWriter, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{"error": message})
+}
diff --git a/web/rides.go b/web/rides.go
index c945801..37340b8 100644
--- a/web/rides.go
+++ b/web/rides.go
@@ -86,15 +86,9 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Warn("Failed to load official climbs for ride detail", "ride_id", id, "error", err)
}
- matchPolicy := official_climb.DefaultMatchPolicy()
- appConfig, err := s.loadConfig()
- if err != nil {
- slog.Warn("Failed to load official climb matching configuration", "ride_id", id, "error", err)
- } else {
- matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM
- }
+ matchPolicy := s.officialClimbMatchPolicy()
slog.Info("Loaded ride detail", "ride_id", id)
- view := buildRideDetailView(item, parsed, passes, officialClimbs, matchPolicy)
+ view := buildRideDetailView(buildRideView(item), parsed, passes, officialClimbs, matchPolicy)
view.Notice = rideDetailNotice(r)
kcore.RenderPage(r.Context(), RideDetailPage(view), w)
}
@@ -186,6 +180,18 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques
http.Redirect(w, r, rideDetailURL(id)+"?official_climb=created", http.StatusSeeOther)
}
+// officialClimbMatchPolicy loads the configured endpoint radius, falling back to the default.
+func (s *Server) officialClimbMatchPolicy() official_climb.MatchPolicy {
+ matchPolicy := official_climb.DefaultMatchPolicy()
+ appConfig, err := s.loadConfig()
+ if err != nil {
+ slog.Warn("Failed to load official climb matching configuration", "error", err)
+ return matchPolicy
+ }
+ matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM
+ return matchPolicy
+}
+
func parseRouteIndex(value string) (int, error) {
index, err := strconv.Atoi(value)
if err != nil || index < 0 {
diff --git a/web/server.go b/web/server.go
index bde45ca..c588696 100644
--- a/web/server.go
+++ b/web/server.go
@@ -45,6 +45,9 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServer(http.FS(staticFiles)))
handleRoute(mux, "GET /", s.handleRides)
+ handleRoute(mux, "GET /preview", s.handlePreviewForm)
+ handleRoute(mux, "POST /preview", s.handlePreview)
+ 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)
diff --git a/web/server_test.go b/web/server_test.go
index a07b7d9..e90f69a 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -917,3 +917,107 @@ func TestHandlerSortsRidesByCotacol(t *testing.T) {
assert.Equal(t, http.StatusOK, response.Code)
assert.Less(t, strings.Index(body, "Low Cotacol"), strings.Index(body, "High Cotacol"))
}
+
+func previewUploadRequest(t *testing.T, filename string, content []byte) *http.Request {
+ t.Helper()
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("file", filename)
+ require.NoError(t, err)
+ _, err = part.Write(content)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+ request := httptest.NewRequest(http.MethodPost, "/preview", body)
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+ return request
+}
+
+func TestPreviewPageRendersUploadForm(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ request := httptest.NewRequest(http.MethodGet, "/preview", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, response.Body.String(), "Preview a ride file")
+ assert.Contains(t, response.Body.String(), `type="file"`)
+}
+
+func TestPreviewParsesGPXWithoutPersisting(t *testing.T) {
+ server, db := newWebTestServer(t)
+ request := previewUploadRequest(t, "morning-ride.gpx", []byte(officialClimbTrackGPX))
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusOK, response.Code)
+ body := response.Body.String()
+ assert.Contains(t, body, "ride-route")
+ assert.Contains(t, body, "ride-profile")
+ assert.Contains(t, body, "data-official-climb-form")
+ assert.NotContains(t, body, "Curation")
+ var count int
+ require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM rides").Scan(&count))
+ assert.Equal(t, 0, count, "preview must not persist the ride")
+}
+
+func TestPreviewShowsInlineErrorForUnsupportedFile(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ request := previewUploadRequest(t, "notes.txt", []byte("not a ride"))
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, response.Body.String(), "unsupported file type")
+}
+
+func TestPreviewShowsInlineErrorForInvalidRideFile(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ request := previewUploadRequest(t, "broken.gpx", []byte("not xml"))
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, response.Body.String(), "The file could not be parsed")
+}
+
+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"},
+ }
+ request := httptest.NewRequest(http.MethodPost, "/preview/official-climbs", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusOK, response.Code)
+ climbs, err := official_climb.List(db)
+ require.NoError(t, err)
+ require.Len(t, climbs, 1)
+ assert.Equal(t, "Col du Preview", climbs[0].Name)
+ 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")
+}
+
+func TestPreviewOfficialClimbRejectsUnorderedBoundaries(t *testing.T) {
+ server, db := newWebTestServer(t)
+ form := url.Values{
+ "name": {"Col du Chaos"},
+ "start_index": {"5"},
+ "end_index": {"2"},
+ "start_lat": {"43.0"},
+ "start_lon": {"5.0"},
+ "end_lat": {"43.01"},
+ "end_lon": {"5.01"},
+ }
+ request := httptest.NewRequest(http.MethodPost, "/preview/official-climbs", strings.NewReader(form.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ assert.Equal(t, http.StatusBadRequest, response.Code)
+ climbs, err := official_climb.List(db)
+ require.NoError(t, err)
+ assert.Empty(t, climbs)
+}
diff --git a/web/static/style.css b/web/static/style.css
index 61e482a..7965678 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -81,6 +81,10 @@ h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
label { display: grid; gap: .35rem; color: var(--color-muted); font-size: .85rem; font-weight: 650; }
input { border: 1px solid var(--color-input-border); border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: var(--color-surface); }
.form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }
+.preview-form { display: flex; align-items: end; gap: 1rem; flex-wrap: wrap; }
+
+/* +1px vertical padding matches the file input's border so button and input share the same height */
+.preview-form .button { padding: calc(.65rem + 1px) 1rem; }
.sync-progress { display: grid; gap: .7rem; margin-top: 1.25rem; }
.progress-heading { display: flex; justify-content: space-between; gap: 1rem; }
progress { width: 100%; height: .8rem; accent-color: var(--color-accent); }
diff --git a/web/templates.templ b/web/templates.templ
index 14f8d69..14e9aab 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="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav>
+ <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>
</header>
<main class="container">
@content
@@ -200,6 +200,25 @@ templ AnalyticsContent(data AnalyticsPageData) {
<script type="module" src="/static/analytics.js"></script>
}
+templ PreviewPage(data PreviewPageData) {
+ @Layout("Preview", PreviewContent(data))
+}
+
+templ PreviewContent(data PreviewPageData) {
+ <div class="toolbar">
+ <div><h1 class="ride-detail-title">Preview a ride file</h1><p class="lead">Analyze a GPX or FIT file without importing it: climbs, official climb matches and mountain-pass crossings.</p></div>
+ </div>
+ if data.Error != "" {
+ <div class="error">{ data.Error }</div>
+ }
+ <section class="panel">
+ <form method="post" action="/preview" enctype="multipart/form-data" class="preview-form">
+ <label>GPX or FIT file<input type="file" name="file" accept=".gpx,.fit" required/></label>
+ <button class="button" type="submit">Preview</button>
+ </form>
+ </section>
+}
+
templ RideDetailPage(data RideDetailView) {
@Layout("Ride detail", RideDetailContent(data))
}
@@ -233,7 +252,8 @@ templ RideDetailContent(data RideDetailView) {
<div><dt>Cotacol</dt><dd>{ data.Cotacol }</dd></div>
</dl>
</section>
- <section class="panel categories-panel">
+ if !data.Preview {
+ <section class="panel categories-panel">
<details class="categories-details" open?={ data.Ride.BikeType == "" && data.Ride.RideType == "" }>
<summary class="categories-summary">
<div class="categories-heading">
@@ -260,9 +280,10 @@ templ RideDetailContent(data RideDetailView) {
</div>
</fieldset>
<button class="button" type="submit">Save</button>
- </form>
- </details>
+ </form>
+ </details>
</section>
+ }
if data.HasRoute {
if len(data.Profile.OfficialClimbs()) > 0 {
<section class="panel official-climbs-panel">
@@ -317,7 +338,7 @@ templ RideDetailContent(data RideDetailView) {
</div>
<p class="climb-metrics" data-climb-metrics>{ climb.Category } · { formatProfileDistance(climb.DistanceKm) } at { formatSlope(climb.SlopePercent) } · Cotacol { formatCotacol(climb.Cotacol) }</p>
<p class="climb-status">Waiting for an official climb match.</p>
- <form class="official-climb-form" data-official-climb-form method="post" action={ templ.URL(officialClimbCreateURL(data.Ride.ID)) }>
+ <form class="official-climb-form" data-official-climb-form data-preview-form?={ data.Preview } method="post" action={ templ.URL(data.OfficialClimbFormURL) }>
<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"/>
diff --git a/web/views.go b/web/views.go
index 952b045..bfbfe09 100644
--- a/web/views.go
+++ b/web/views.go
@@ -68,14 +68,16 @@ func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint {
type RideDetailView struct {
RideView
- Route GeoJSONFeatureCollection
- Passes []RideMapPass
- HasRoute bool
- Profile RideProfile
- RouteError string
- ActionError string
- Notice string
- ExternalURL string
+ Route GeoJSONFeatureCollection
+ Passes []RideMapPass
+ HasRoute bool
+ Profile RideProfile
+ RouteError string
+ ActionError string
+ Notice string
+ ExternalURL string
+ Preview bool
+ OfficialClimbFormURL string
}
type RideMapPass struct {
@@ -289,17 +291,19 @@ func officialClimbCreateURL(id int64) string {
return fmt.Sprintf("/rides/%d/official-climbs", id)
}
-func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView {
+func buildRideDetailView(view RideView, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView {
coords := parsed.Coords
coordinates := make([][]float64, len(coords))
for i, coordinate := range coords {
coordinates[i] = []float64{coordinate.Lon, coordinate.Lat}
}
profile := buildRideProfile(parsed, passes, officialClimbs, matchPolicy)
- return RideDetailView{
- RideView: buildRideView(item),
- Passes: buildRideMapPasses(passes),
- ExternalURL: externalRideURL(item.ExternalID),
+ detail := RideDetailView{
+ RideView: view,
+ Passes: buildRideMapPasses(passes),
+ ExternalURL: externalRideURL(view.Ride.ExternalID),
+ OfficialClimbFormURL: officialClimbCreateURL(view.Ride.ID),
+ HasRoute: true,
Route: GeoJSONFeatureCollection{
Type: "FeatureCollection",
Features: []GeoJSONFeature{{
@@ -310,9 +314,9 @@ func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mounta
},
}},
},
- HasRoute: true,
- Profile: profile,
+ Profile: profile,
}
+ return detail
}
func externalRideURL(externalID string) string {