aboutsummaryrefslogtreecommitdiff
path: root/web/preview.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/preview.go')
-rw-r--r--web/preview.go185
1 files changed, 185 insertions, 0 deletions
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})
+}