From 8e0c55230ef59869bda1ca19ca8697b447d57698 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 6 Sep 2026 12:38:16 +0200 Subject: Preview: analyze a raw GPX or FIT file without persisting it --- README.md | 2 + web/frontend/ride-detail.ts | 28 +++++++ web/preview.go | 185 ++++++++++++++++++++++++++++++++++++++++++++ web/rides.go | 22 ++++-- web/server.go | 3 + web/server_test.go | 104 +++++++++++++++++++++++++ web/static/style.css | 4 + web/templates.templ | 31 ++++++-- web/views.go | 34 ++++---- 9 files changed, 385 insertions(+), 28 deletions(-) create mode 100644 web/preview.go 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 ` to flag unrealistic grades (>50% over <30m) and `go run . elevation fix --from --to [--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("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("[data-climb-item]"); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitButton = form.querySelector("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(".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) {
@content @@ -200,6 +200,25 @@ templ AnalyticsContent(data AnalyticsPageData) { } +templ PreviewPage(data PreviewPageData) { + @Layout("Preview", PreviewContent(data)) +} + +templ PreviewContent(data PreviewPageData) { +
+

Preview a ride file

Analyze a GPX or FIT file without importing it: climbs, official climb matches and mountain-pass crossings.

+
+ if data.Error != "" { +
{ data.Error }
+ } +
+
+ + +
+
+} + templ RideDetailPage(data RideDetailView) { @Layout("Ride detail", RideDetailContent(data)) } @@ -233,7 +252,8 @@ templ RideDetailContent(data RideDetailView) {
Cotacol
{ data.Cotacol }
-
+ if !data.Preview { +
@@ -260,9 +280,10 @@ templ RideDetailContent(data RideDetailView) {
- -
+ +
+ } if data.HasRoute { if len(data.Profile.OfficialClimbs()) > 0 {
@@ -317,7 +338,7 @@ templ RideDetailContent(data RideDetailView) {

{ climb.Category } · { formatProfileDistance(climb.DistanceKm) } at { formatSlope(climb.SlopePercent) } · Cotacol { formatCotacol(climb.Cotacol) }

Waiting for an official climb match.

-
+ 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 { -- cgit v1.3