summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 11:21:55 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 11:21:55 +0200
commitbef31e1d35fe88f2698cf0e3191f26a79623f66f (patch)
tree8d8e10b2a58fc4ca557f0312cfda60adfc308c30
parent69f317d8cb51b911732712a9fcc8ac49563326c6 (diff)
feat: Add web ride library with Strava sync
-rw-r--r--.gitignore2
-rw-r--r--README.md46
-rw-r--r--config/env.go (renamed from env.go)30
-rw-r--r--db/migrations/20260804000000_rides.sql19
-rw-r--r--db/schema.sql18
-rw-r--r--go.mod2
-rw-r--r--main.go163
-rw-r--r--ride/parser_test.go46
-rw-r--r--ride/ride.go30
-rw-r--r--rides/rides.go104
-rw-r--r--rides/rides_test.go104
-rw-r--r--strava/strava.go7
-rw-r--r--strava/sync.go60
-rw-r--r--strava/sync_test.go31
-rw-r--r--web/server.go356
-rw-r--r--web/server_test.go133
-rw-r--r--web/templates.templ106
-rw-r--r--web/templates_templ.go381
-rw-r--r--web/views.go52
19 files changed, 1496 insertions, 194 deletions
diff --git a/.gitignore b/.gitignore
index c96fd10..b9d0e86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,7 @@
.strava-last-sync
*.db
*.png
-rides/
+rides/*.gpx
debug_department_*.csv
france-latest.osm.pbf
biking_home
diff --git a/README.md b/README.md
index 06af5d3..f832251 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **Mountain pass download** — imports French mountain passes from centcols.org into a SQLite database
- **Pass crossing detection** — enriches passes with OSM coordinates, flags which passes a ride crosses, and names each climb after the pass it tops (e.g. "Col de Castellaras")
- **Plots** — renders elevation and score-profile charts as PNG
+- **Web ride library** — starts a local web server by default, imports Strava rides over a date range, stores metadata in SQLite, and keeps their GPX files on disk
## Getting started
@@ -21,6 +22,9 @@ go build -o biking_home .
## Usage
```bash
+# Start the web server on http://localhost:8080
+./biking_home
+
# Download French mountain passes into biking_home.db (SQLite)
./biking_home -download
@@ -59,14 +63,54 @@ go build -o biking_home .
| `-demo` | Run the climb-similarity demo | `false` |
| `-cpuprofile` | Write a CPU profile to file | `""` |
+## Strava API
+
+The Strava v3 API (OAuth2) is the platform's public access point; scopes gate each resource (`read`, `activity:read_all`, `activity:write`, `push:subscriptions`). It offers:
+
+- **Activities** — list and fetch ride detail, plus data streams (lat/lng, altitude, time, distance, heart rate, cadence, power) to rebuild GPX or compute metrics
+- **Segments** — explore segments, match segment efforts, pull personal records
+- **Uploads** — create activities by pushing FIT, GPX or TCX files
+- **Webhooks** — push subscriptions notify a callback URL on activity create/delete, enabling real-time sync
+- **Extras** — clubs, routes, gear, photos, kudos
+
+Standard apps are rate-limited to 100 calls per 15 minutes and 1,000 per day.
+
## Architecture
- Go 1.23; SQLite via `mattn/go-sqlite3`; charts via `gonum.org/v1/plot`
- `ride` — GPX parsing, climb detection, difficulty scores (KOM + Cotacol), similarity index
- `mountain_pass` — centcols.org department CSV download into SQLite, with disk caching and retries
- `osmpass` — OSM PBF extraction (`mountain_pass=yes` nodes) and pass coordinate enrichment
+- `strava` — OAuth2 client returning activity metadata and GPX data
+- `rides` — SQLite persistence for imported ride metadata
+- `config` — `.env` configuration persistence
+- `web` — HTTP server, OAuth callback, sync orchestration, and templ pages
- **Notable choices** — the difficulty score follows the Cotacol method: the ride is split into fixed 100 m segments and each scores `distance_km × slope²`, so steep sections weigh exponentially more than long flat ones
+```mermaid
+flowchart TB
+ %% Arrow X --> Y means: X depends on Y
+
+ main["main (CLI + server: main.go, chart.go)"]
+
+ ride["ride (GPX parsing, climbs, Cotacol)"]
+ mpass["mountain_pass (centcols, crossings)"]
+ osmpass["osmpass (OSM PBF, enrichment)"]
+ strava["strava (OAuth, List, Get)"]
+ rides["rides (SQLite persistence)"]
+ config["config (.env)"]
+ web["web (HTTP + templ)"]
+
+ main --> ride
+ main --> mpass
+ main --> osmpass
+ main --> web
+ mpass --> ride
+ web --> strava
+ web --> rides
+ web --> config
+```
+
## Development
```bash
@@ -77,12 +121,10 @@ go test ./...
## TODO
- Compute estimated power
-- Export data from Strava / Garmin (GPX, TCX, FIT)
- Plot speed and slope per segment, colored by heart rate
- Persist the chosen climb variant across activities
- Handle historical data
- Blog with pictures and markdown
-- Store GPX files
## Resources
diff --git a/env.go b/config/env.go
index e1ec826..b9d9f76 100644
--- a/env.go
+++ b/config/env.go
@@ -1,18 +1,17 @@
-package main
+package config
import (
+ "fmt"
"os"
"strings"
-
- "github.com/martinlehoux/kagamigo/kcore"
)
-func loadEnv() map[string]string {
- values := map[string]string{}
- data, err := os.ReadFile(".env")
+func LoadEnv(filename string) (map[string]string, error) {
+ data, err := os.ReadFile(filename)
if err != nil {
- return values
+ return nil, err
}
+ values := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -23,15 +22,16 @@ func loadEnv() map[string]string {
values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
}
}
- return values
+ return values, nil
}
-func updateEnv(updates map[string]string) {
- data, err := os.ReadFile(".env")
- kcore.Expect(err, "failed to read .env")
+func UpdateEnv(filename string, updates map[string]string) error {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return err
+ }
lines := strings.Split(string(data), "\n")
for key, value := range updates {
- key = strings.TrimSpace(key)
found := false
for i, line := range lines {
existingKey, _, cut := strings.Cut(strings.TrimSpace(line), "=")
@@ -45,6 +45,8 @@ func updateEnv(updates map[string]string) {
lines = append(lines, key+"="+value)
}
}
- err = os.WriteFile(".env", []byte(strings.Join(lines, "\n")+"\n"), 0o600)
- kcore.Expect(err, "failed to write .env")
+ if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
+ return fmt.Errorf("write env file: %w", err)
+ }
+ return nil
}
diff --git a/db/migrations/20260804000000_rides.sql b/db/migrations/20260804000000_rides.sql
new file mode 100644
index 0000000..4f25c64
--- /dev/null
+++ b/db/migrations/20260804000000_rides.sql
@@ -0,0 +1,19 @@
+-- migrate:up
+create table rides (
+ id integer primary key,
+ external_id text unique not null,
+ gpx_path text not null,
+ name text not null,
+ type text not null,
+ start_date text not null,
+ distance_m real not null,
+ moving_time_s integer not null,
+ elapsed_time_s integer not null,
+ total_elevation_gain_m real not null,
+ average_speed_mps real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+
+-- migrate:down
+drop table rides;
diff --git a/db/schema.sql b/db/schema.sql
index 398bfea..6a22bc5 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -15,7 +15,23 @@ CREATE TABLE osm_passes (
latitude real not null,
longitude real not null
);
+CREATE TABLE rides (
+ id integer primary key,
+ external_id text unique not null,
+ gpx_path text not null,
+ name text not null,
+ type text not null,
+ start_date text not null,
+ distance_m real not null,
+ moving_time_s integer not null,
+ elapsed_time_s integer not null,
+ total_elevation_gain_m real not null,
+ average_speed_mps real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
-- Dbmate schema migrations
INSERT INTO "schema_migrations" (version) VALUES
('20250802140659'),
- ('20260802090000');
+ ('20260802090000'),
+ ('20260804000000');
diff --git a/go.mod b/go.mod
index 99cc6ec..2d4c4d4 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.23.0
toolchain go1.23.8
require (
+ github.com/a-h/templ v0.2.747
github.com/bradleyjkemp/cupaloy v1.3.0
github.com/jftuga/geodist v1.0.0
github.com/martinlehoux/kagamigo v0.4.1
@@ -25,7 +26,6 @@ require (
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/ClickHouse/clickhouse-go v1.5.4 // indirect
github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985 // indirect
- github.com/a-h/templ v0.2.747 // indirect
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect
github.com/amacneil/dbmate v1.16.2 // indirect
github.com/campoy/embedmd v1.0.0 // indirect
diff --git a/main.go b/main.go
index 89f4098..072941f 100644
--- a/main.go
+++ b/main.go
@@ -3,22 +3,18 @@ package main
import (
"context"
"database/sql"
- "errors"
"flag"
- "fmt"
"log/slog"
- "net/http"
"os"
"path/filepath"
"runtime/pprof"
- "strconv"
"strings"
"time"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/osmpass"
"github.com/martinlehoux/biking_home/ride"
- "github.com/martinlehoux/biking_home/strava"
+ "github.com/martinlehoux/biking_home/web"
"github.com/martinlehoux/kagamigo/kcore"
_ "github.com/mattn/go-sqlite3"
)
@@ -32,9 +28,6 @@ var (
enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data")
demo = flag.Bool("demo", false, "run the climb similarity demo")
chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file")
- stravaLogin = flag.Bool("strava-login", false, "authorize the Strava API and store tokens in .env")
- stravaSync = flag.Bool("strava-sync", false, "download Strava rides as GPX files into rides/")
- stravaSince = flag.Int("strava-since", 30, "days of history to fetch on the first sync (before any .strava-last-sync)")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
parser = ride.GPXRideParser{}
)
@@ -62,159 +55,17 @@ func main() {
kcore.Expect(err, "failed to enrich mountain passes")
case *demo:
runDemo(db)
- case *stravaLogin:
- runStravaLogin()
- case *stravaSync:
- runStravaSync()
case *chartFile != "":
runChart(db, *chartFile)
+ default:
+ runServer(db)
}
}
-func runStravaLogin() {
- clientID, clientSecret, _ := stravaCredentials(loadEnv())
- if clientID == "" || clientSecret == "" {
- kcore.Expect(errors.New("missing STRAVA_CLIENT_ID / STRAVA_CLIENT_SECRET"), "set both in .env")
- }
- redirectURI := fmt.Sprintf("http://localhost:%d/callback", strava.LoginPort)
- codeCh := make(chan string, 1)
- serverErrCh := make(chan error, 1)
- mux := http.NewServeMux()
- mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
- code := r.URL.Query().Get("code")
- if code == "" {
- serverErrCh <- fmt.Errorf("no code in callback: %s", r.URL.RawQuery)
- http.Error(w, "Authorization failed", http.StatusBadRequest)
- return
- }
- codeCh <- code
- fmt.Fprint(w, "Authorization successful — you can close this tab.")
- })
- server := &http.Server{Addr: fmt.Sprintf(":%d", strava.LoginPort), Handler: mux}
- go func() {
- if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
- serverErrCh <- err
- }
- }()
- defer server.Close()
-
- slog.Info("Open the authorization URL in your browser", "url", strava.AuthorizeURL(clientID, redirectURI))
- var token strava.Token
- select {
- case code := <-codeCh:
- var err error
- token, err = strava.ExchangeCode(clientID, clientSecret, code, redirectURI)
- kcore.Expect(err, "failed to exchange authorization code")
- slog.Info("Login successful, tokens stored in .env")
- case err := <-serverErrCh:
- kcore.Expect(err, "authorization server failed")
- case <-time.After(5 * time.Minute):
- kcore.Expect(errors.New("timeout"), "no authorization code received")
- }
-
- validateStrava(clientID, clientSecret, token)
-}
-
-func stravaCredentials(env map[string]string) (clientID, clientSecret string, token strava.Token) {
- expiresAt, _ := strconv.ParseInt(env["STRAVA_EXPIRES_AT"], 10, 64)
- return env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], strava.Token{
- AccessToken: env["STRAVA_ACCESS_TOKEN"],
- RefreshToken: env["STRAVA_REFRESH_TOKEN"],
- ExpiresAt: time.Unix(expiresAt, 0),
- }
-}
-
-func validateStrava(clientID, clientSecret string, token strava.Token) {
- client := strava.NewClient(clientID, clientSecret, token)
- if refreshed, err := client.RefreshIfNeeded(); err != nil {
- kcore.Expect(err, "failed to refresh access token")
- } else if refreshed {
- slog.Info("Access token refreshed")
- }
- storeStravaTokens(client.Tokens())
- var athlete struct {
- Firstname string `json:"firstname"`
- Lastname string `json:"lastname"`
- }
- err := client.GetJSON("/athlete", &athlete)
- kcore.Expect(err, "failed to validate token against /athlete")
- slog.Info("Authenticated as", "athlete", athlete.Firstname+" "+athlete.Lastname)
-}
-
-func runStravaSync() {
- clientID, clientSecret, token := stravaCredentials(loadEnv())
- if token.AccessToken == "" || token.RefreshToken == "" {
- kcore.Expect(errors.New("missing STRAVA tokens"), "run -strava-login first")
- }
- client := strava.NewClient(clientID, clientSecret, token)
- if refreshed, err := client.RefreshIfNeeded(); err != nil {
- kcore.Expect(err, "failed to refresh access token")
- } else if refreshed {
- storeStravaTokens(client.Tokens())
- slog.Info("Access token refreshed")
- }
-
- after := lastStravaSync()
- if after.IsZero() {
- after = time.Now().AddDate(0, 0, -*stravaSince)
- }
- slog.Info("Syncing Strava rides", "since", after.Format("2006-01-02"))
- activities, err := client.ListActivities(after, "Ride")
- kcore.Expect(err, "failed to list Strava activities")
- slog.Info("Rides found", "count", len(activities))
-
- kcore.Expect(os.MkdirAll("rides", 0o755), "failed to create rides/ directory")
- downloaded := 0
- for _, activity := range activities {
- file := filepath.Join("rides", fmt.Sprintf("activity_%d.gpx", activity.ID))
- if _, err := os.Stat(file); err == nil {
- continue
- }
- latlng, altitude, seconds, err := client.ActivityStreams(activity.ID)
- if err != nil {
- slog.Warn("Failed to fetch streams, skipping", "activity", activity.ID, "error", err)
- continue
- }
- if len(latlng) == 0 || len(altitude) == 0 {
- slog.Warn("Missing latlng or altitude stream, skipping", "activity", activity.ID, "name", activity.Name)
- continue
- }
- err = strava.WriteActivityGPX(file, activity, latlng, altitude, seconds)
- if err != nil {
- slog.Warn("Failed to write GPX, skipping", "activity", activity.ID, "error", err)
- continue
- }
- downloaded++
- slog.Info("Downloaded", "activity", activity.ID, "name", activity.Name, "points", len(latlng))
- }
- updateLastStravaSync(time.Now())
- slog.Info("Sync complete", "downloaded", downloaded, "seen", len(activities))
-}
-
-const lastSyncFile = ".strava-last-sync"
-
-func lastStravaSync() time.Time {
- data, err := os.ReadFile(lastSyncFile)
- if err != nil {
- return time.Time{}
- }
- unix, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
- if err != nil {
- return time.Time{}
- }
- return time.Unix(unix, 0)
-}
-
-func updateLastStravaSync(t time.Time) {
- kcore.Expect(os.WriteFile(lastSyncFile, []byte(fmt.Sprintf("%d\n", t.Unix())), 0o644), "failed to write last sync marker")
-}
-
-func storeStravaTokens(token strava.Token) {
- updateEnv(map[string]string{
- "STRAVA_ACCESS_TOKEN": token.AccessToken,
- "STRAVA_REFRESH_TOKEN": token.RefreshToken,
- "STRAVA_EXPIRES_AT": fmt.Sprintf("%d", token.ExpiresAt.Unix()),
- })
+func runServer(db *sql.DB) {
+ server := web.NewServer(db, ".env", "rides", "http://localhost:8080")
+ slog.Info("Starting web server", "address", "http://localhost:8080")
+ kcore.Expect(server.ListenAndServe(":8080"), "web server stopped")
}
func runChart(db *sql.DB, filename string) {
diff --git a/ride/parser_test.go b/ride/parser_test.go
new file mode 100644
index 0000000..db4907d
--- /dev/null
+++ b/ride/parser_test.go
@@ -0,0 +1,46 @@
+package ride_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const parserTestGPXPrefix = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg>`
+const parserTestGPXSuffix = `</trkseg></trk></gpx>`
+
+func TestGPXRideParserSkipsStationaryPoints(t *testing.T) {
+ data := parserTestGPXPrefix +
+ `<trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt>` +
+ `<trkpt lat="43.0" lon="5.0"><ele>101</ele></trkpt>` +
+ `<trkpt lat="43.0" lon="5.0"><ele>102</ele></trkpt>` +
+ `<trkpt lat="43.001" lon="5.001"><ele>103</ele></trkpt>` +
+ parserTestGPXSuffix
+
+ parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
+ require.NoError(t, err)
+ assert.Len(t, parsed.Points(), 2)
+ assert.Greater(t, parsed.Points()[1].DistanceM, 0.0)
+}
+
+func TestGPXRideParserRejectsStationaryRide(t *testing.T) {
+ data := parserTestGPXPrefix +
+ `<trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt>` +
+ `<trkpt lat="43.0" lon="5.0"><ele>101</ele></trkpt>` +
+ parserTestGPXSuffix
+
+ _, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
+ require.Error(t, err)
+ assert.Equal(t, "zero distance", err.Error())
+}
+
+func TestGPXRideParserRejectsEmptyTrack(t *testing.T) {
+ data := parserTestGPXPrefix + parserTestGPXSuffix
+
+ _, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
+ require.Error(t, err)
+ assert.Equal(t, "ride has no track points", err.Error())
+}
diff --git a/ride/ride.go b/ride/ride.go
index df5560b..9ba644e 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -55,20 +55,34 @@ func (p GPXRideParser) Parse(reader io.Reader) (Ride, error) {
if err != nil {
return Ride{}, err
}
+ if len(content.Tracks) == 0 || len(content.Tracks[0].Segments) == 0 {
+ return Ride{}, errors.New("ride has no track segment")
+ }
segment := content.Tracks[0].Segments[0]
- points := make([]Point, len(segment.Points))
+ if len(segment.Points) == 0 {
+ return Ride{}, errors.New("ride has no track points")
+ }
+ points := make([]Point, 0, len(segment.Points))
distance := 0.0
- for i, p := range segment.Points {
- if i != 0 {
- distance += p.Distance2D(&segment.Points[i-1])
- }
- if i != 0 && distance == 0 {
- return Ride{}, errors.New("zero distance")
+ previous := segment.Points[0]
+ if previous.Elevation.Null() {
+ return Ride{}, errors.New("points without elevation")
+ }
+ points = append(points, Point{DistanceM: 0, ElevationM: previous.Elevation.Value(), Coord: geodist.Coord{Lat: previous.Latitude, Lon: previous.Longitude}, Timestamp: previous.Timestamp})
+ for i := 1; i < len(segment.Points); i++ {
+ p := segment.Points[i]
+ distance += p.Distance2D(&previous)
+ previous = p
+ if distance == 0 {
+ continue
}
if p.Elevation.Null() {
return Ride{}, errors.New("points without elevation")
}
- points[i] = Point{DistanceM: distance, ElevationM: p.Elevation.Value(), Coord: geodist.Coord{Lat: p.Latitude, Lon: p.Longitude}, Timestamp: p.Timestamp}
+ points = append(points, Point{DistanceM: distance, ElevationM: p.Elevation.Value(), Coord: geodist.Coord{Lat: p.Latitude, Lon: p.Longitude}, Timestamp: p.Timestamp})
+ }
+ if len(points) < 2 {
+ return Ride{}, errors.New("zero distance")
}
ride := Ride{points}
ride.check()
diff --git a/rides/rides.go b/rides/rides.go
new file mode 100644
index 0000000..1958d20
--- /dev/null
+++ b/rides/rides.go
@@ -0,0 +1,104 @@
+package rides
+
+import (
+ "database/sql"
+ "time"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+type Ride struct {
+ ID int64
+ ExternalID string
+ GPXPath string
+ Name string
+ Type string
+ StartDate time.Time
+ DistanceM float64
+ MovingTimeS int64
+ ElapsedTimeS int64
+ TotalElevationGainM float64
+ AverageSpeedMps float64
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at"
+
+func Save(db *sql.DB, r Ride) error {
+ _, err := db.Exec(`
+ INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(external_id) DO UPDATE SET
+ gpx_path = excluded.gpx_path,
+ name = excluded.name,
+ type = excluded.type,
+ start_date = excluded.start_date,
+ distance_m = excluded.distance_m,
+ moving_time_s = excluded.moving_time_s,
+ elapsed_time_s = excluded.elapsed_time_s,
+ total_elevation_gain_m = excluded.total_elevation_gain_m,
+ average_speed_mps = excluded.average_speed_mps,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.MovingTimeS, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps)
+ return err
+}
+
+func List(db *sql.DB) ([]Ride, error) {
+ rows, err := db.Query("SELECT " + columns + " FROM rides ORDER BY start_date DESC")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var rides []Ride
+ for rows.Next() {
+ ride, err := scanRide(rows)
+ if err != nil {
+ return nil, err
+ }
+ rides = append(rides, ride)
+ }
+ return rides, rows.Err()
+}
+
+func GetByExternalID(db *sql.DB, externalID string) (Ride, bool, error) {
+ row := db.QueryRow("SELECT "+columns+" FROM rides WHERE external_id = ?", externalID)
+ ride, err := scanRide(row)
+ if err == sql.ErrNoRows {
+ return Ride{}, false, nil
+ }
+ if err != nil {
+ return Ride{}, false, err
+ }
+ return ride, true, nil
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanRide(s scanner) (Ride, error) {
+ var (
+ ride Ride
+ startDate string
+ createdAt string
+ updatedAt string
+ )
+ err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.MovingTimeS, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &createdAt, &updatedAt)
+ if err != nil {
+ return Ride{}, err
+ }
+ ride.StartDate, err = time.Parse(time.RFC3339, startDate)
+ if err != nil {
+ return Ride{}, kcore.Wrap(err, "invalid start_date in rides row")
+ }
+ ride.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
+ if err != nil {
+ return Ride{}, kcore.Wrap(err, "invalid created_at in rides row")
+ }
+ ride.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
+ if err != nil {
+ return Ride{}, kcore.Wrap(err, "invalid updated_at in rides row")
+ }
+ return ride, nil
+}
diff --git a/rides/rides_test.go b/rides/rides_test.go
new file mode 100644
index 0000000..1ba23f9
--- /dev/null
+++ b/rides/rides_test.go
@@ -0,0 +1,104 @@
+package rides
+
+import (
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", ":memory:")
+ require.NoError(t, err)
+ t.Cleanup(func() { db.Close() })
+ _, err = db.Exec(`
+ create table rides (
+ id integer primary key,
+ external_id text unique not null,
+ gpx_path text not null,
+ name text not null,
+ type text not null,
+ start_date text not null,
+ distance_m real not null,
+ moving_time_s integer not null,
+ elapsed_time_s integer not null,
+ total_elevation_gain_m real not null,
+ average_speed_mps real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ )
+ `)
+ require.NoError(t, err)
+ return db
+}
+
+func sampleRide() Ride {
+ return Ride{
+ ExternalID: "strava:1234",
+ GPXPath: "rides/activity_1234.gpx",
+ Name: "Morning Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC),
+ DistanceM: 42_195,
+ MovingTimeS: 7_200,
+ ElapsedTimeS: 7_800,
+ TotalElevationGainM: 850,
+ AverageSpeedMps: 5.86,
+ }
+}
+
+func TestUpsertAndGet(t *testing.T) {
+ db := newTestDB(t)
+ err := Save(db, sampleRide())
+ require.NoError(t, err)
+
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, "Morning Ride", got.Name)
+ assert.Equal(t, sampleRide().StartDate, got.StartDate)
+ assert.Equal(t, 42_195.0, got.DistanceM)
+ assert.Equal(t, "rides/activity_1234.gpx", got.GPXPath)
+
+ _, ok, err = GetByExternalID(db, "strava:9999")
+ require.NoError(t, err)
+ assert.False(t, ok)
+}
+
+func TestUpsertUpdatesExisting(t *testing.T) {
+ db := newTestDB(t)
+ ride := sampleRide()
+ require.NoError(t, Save(db, ride))
+ ride.Name = "Renamed Ride"
+ ride.DistanceM = 50_000
+ require.NoError(t, Save(db, ride))
+
+ got, ok, err := GetByExternalID(db, "strava:1234")
+ require.NoError(t, err)
+ require.True(t, ok)
+ assert.Equal(t, "Renamed Ride", got.Name)
+ assert.Equal(t, 50_000.0, got.DistanceM)
+}
+
+func TestList(t *testing.T) {
+ db := newTestDB(t)
+ first := sampleRide()
+ first.StartDate = time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC)
+ second := sampleRide()
+ second.ExternalID = "strava:5678"
+ second.Name = "Evening Ride"
+ require.NoError(t, Save(db, first))
+ require.NoError(t, Save(db, second))
+
+ rides, err := List(db)
+ require.NoError(t, err)
+ require.Len(t, rides, 2)
+ assert.Equal(t, "strava:5678", rides[0].ExternalID)
+ assert.Equal(t, "strava:1234", rides[1].ExternalID)
+ kcore.Assert(len(rides) == 2, "two rides")
+}
diff --git a/strava/strava.go b/strava/strava.go
index d394791..c7a84d0 100644
--- a/strava/strava.go
+++ b/strava/strava.go
@@ -30,12 +30,19 @@ type Token struct {
}
func AuthorizeURL(clientID, redirectURI string) string {
+ return AuthorizeURLWithState(clientID, redirectURI, "")
+}
+
+func AuthorizeURLWithState(clientID, redirectURI, state string) string {
q := url.Values{}
q.Set("client_id", clientID)
q.Set("response_type", "code")
q.Set("redirect_uri", redirectURI)
q.Set("approval_prompt", "auto")
q.Set("scope", "activity:read_all")
+ if state != "" {
+ q.Set("state", state)
+ }
return AuthURL + "?" + q.Encode()
}
diff --git a/strava/sync.go b/strava/sync.go
index f104585..2044f28 100644
--- a/strava/sync.go
+++ b/strava/sync.go
@@ -12,14 +12,23 @@ import (
)
type Activity struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- SportType string `json:"sport_type"`
- StartDate time.Time `json:"start_date"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ SportType string `json:"sport_type"`
+ StartDate time.Time `json:"start_date"`
+ DistanceM float64 `json:"distance"`
+ MovingTimeS int64 `json:"moving_time"`
+ ElapsedTimeS int64 `json:"elapsed_time"`
+ TotalElevationGainM float64 `json:"total_elevation_gain"`
+ AverageSpeedMps float64 `json:"average_speed"`
}
-func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) {
+func (c *Client) List(from, to time.Time) ([]Activity, error) {
+ return c.list(from, to, "Ride")
+}
+
+func (c *Client) list(from, to time.Time, types ...string) ([]Activity, error) {
allowed := map[string]bool{}
for _, t := range types {
allowed[t] = true
@@ -29,7 +38,12 @@ func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, e
q := url.Values{}
q.Set("per_page", "200")
q.Set("page", strconv.Itoa(page))
- q.Set("after", strconv.FormatInt(after.Unix(), 10))
+ if !from.IsZero() {
+ q.Set("after", strconv.FormatInt(from.Unix(), 10))
+ }
+ if !to.IsZero() {
+ q.Set("before", strconv.FormatInt(to.Unix(), 10))
+ }
var batch []Activity
err := c.GetJSON("/athlete/activities?"+q.Encode(), &batch)
if err != nil {
@@ -50,6 +64,26 @@ func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, e
return all, nil
}
+func (c *Client) ListActivities(after time.Time, types ...string) ([]Activity, error) {
+ return c.list(after, time.Time{}, types...)
+}
+
+func (c *Client) Get(id int64) (Activity, []byte, error) {
+ var activity Activity
+ if err := c.GetJSON(fmt.Sprintf("/activities/%d", id), &activity); err != nil {
+ return Activity{}, nil, kcore.Wrap(err, "failed to get Strava activity")
+ }
+ latlng, altitude, seconds, err := c.ActivityStreams(id)
+ if err != nil {
+ return Activity{}, nil, err
+ }
+ gpxData, err := ActivityGPX(activity, latlng, altitude, seconds)
+ if err != nil {
+ return Activity{}, nil, err
+ }
+ return activity, gpxData, nil
+}
+
func (c *Client) ActivityStreams(id int64) (latlng [][2]float64, altitude []float64, seconds []float64, err error) {
path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time&key_by_type=true", id)
var streams struct {
@@ -79,7 +113,7 @@ func (c *Client) ActivityStreams(id int64) (latlng [][2]float64, altitude []floa
return latlng, altitude, seconds, nil
}
-func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error {
+func ActivityGPX(activity Activity, latlng [][2]float64, altitude, seconds []float64) ([]byte, error) {
points := make([]gpx.GPXPoint, len(latlng))
for i := range latlng {
point := gpx.GPXPoint{}
@@ -104,7 +138,15 @@ func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altit
}
xmlData, err := doc.ToXml(gpx.ToXmlParams{})
if err != nil {
- return kcore.Wrap(err, "failed to build GPX XML")
+ return nil, kcore.Wrap(err, "failed to build GPX XML")
+ }
+ return xmlData, nil
+}
+
+func WriteActivityGPX(file string, activity Activity, latlng [][2]float64, altitude, seconds []float64) error {
+ xmlData, err := ActivityGPX(activity, latlng, altitude, seconds)
+ if err != nil {
+ return err
}
return os.WriteFile(file, xmlData, 0o644)
}
diff --git a/strava/sync_test.go b/strava/sync_test.go
index b74d1ab..1ddf439 100644
--- a/strava/sync_test.go
+++ b/strava/sync_test.go
@@ -27,7 +27,7 @@ func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
return client
}
-func TestListActivities(t *testing.T) {
+func TestList(t *testing.T) {
calls := 0
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
@@ -36,6 +36,7 @@ func TestListActivities(t *testing.T) {
assert.Equal(t, "200", q.Get("per_page"))
assert.Equal(t, fmt.Sprint(calls), q.Get("page"))
assert.Equal(t, "1600000000", q.Get("after"))
+ assert.Equal(t, "1700000000", q.Get("before"))
w.Header().Set("Content-Type", "application/json")
body := `[{"id":1,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z"},{"id":2,"name":"Run","type":"Run","sport_type":"Run","start_date":"2026-01-01T11:00:00Z"}]`
if calls == 2 {
@@ -45,7 +46,7 @@ func TestListActivities(t *testing.T) {
require.NoError(t, err)
})
- activities, err := client.ListActivities(time.Unix(1600000000, 0), "Ride")
+ activities, err := client.List(time.Unix(1600000000, 0), time.Unix(1700000000, 0))
require.NoError(t, err)
require.Len(t, activities, 1)
assert.Equal(t, int64(1), activities[0].ID)
@@ -53,6 +54,32 @@ func TestListActivities(t *testing.T) {
assert.Equal(t, time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), activities[0].StartDate)
}
+func TestGet(t *testing.T) {
+ client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/activities/42":
+ _, err := w.Write([]byte(`{"id":42,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z","distance":42195,"moving_time":7200,"elapsed_time":7800,"total_elevation_gain":850,"average_speed":5.86}`))
+ require.NoError(t, err)
+ case "/activities/42/streams":
+ _, err := w.Write([]byte(`{"latlng":{"data":[[43.0,5.0],[43.1,5.1]]},"altitude":{"data":[100,120]},"time":{"data":[0,10]}}`))
+ require.NoError(t, err)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+
+ activity, gpxData, err := client.Get(42)
+ require.NoError(t, err)
+ assert.Equal(t, int64(42), activity.ID)
+ assert.Equal(t, 42195.0, activity.DistanceM)
+ assert.Equal(t, int64(7200), activity.MovingTimeS)
+ assert.NotEmpty(t, gpxData)
+ parsed, err := gpx.ParseBytes(gpxData)
+ require.NoError(t, err)
+ require.Len(t, parsed.Tracks[0].Segments[0].Points, 2)
+}
+
func TestActivityStreams(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/activities/42/streams", r.URL.Path)
diff --git a/web/server.go b/web/server.go
new file mode 100644
index 0000000..e394eec
--- /dev/null
+++ b/web/server.go
@@ -0,0 +1,356 @@
+package web
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/martinlehoux/biking_home/config"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rides"
+ "github.com/martinlehoux/biking_home/strava"
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+const dateFormat = "2006-01-02"
+
+type Server struct {
+ db *sql.DB
+ envPath string
+ gpxDir string
+ baseURL string
+
+ oauthMu sync.Mutex
+ oauthState string
+ returnToURL string
+}
+
+func NewServer(db *sql.DB, envPath, gpxDir, baseURL string) *Server {
+ return &Server{db: db, envPath: envPath, gpxDir: gpxDir, baseURL: strings.TrimRight(baseURL, "/")}
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", s.handleRides)
+ mux.HandleFunc("GET /sync", s.handleSyncForm)
+ mux.HandleFunc("POST /sync", s.handleSync)
+ mux.HandleFunc("GET /strava/login", s.handleStravaLogin)
+ mux.HandleFunc("GET /strava/callback", s.handleStravaCallback)
+ return kcore.RecoverMiddleware(mux)
+}
+
+func (s *Server) ListenAndServe(addr string) error {
+ return http.ListenAndServe(addr, s.Handler())
+}
+
+func (s *Server) handleRides(w http.ResponseWriter, r *http.Request) {
+ items, err := rides.List(s.db)
+ if err != nil {
+ http.Error(w, "failed to load rides", http.StatusInternalServerError)
+ return
+ }
+ kcore.RenderPage(r.Context(), RidesPage(buildRideViews(items)), w)
+}
+
+const minimumDisplayedDistanceM = 10_000
+
+func buildRideViews(items []rides.Ride) []RideView {
+ parser := ride.GPXRideParser{}
+ views := make([]RideView, 0, len(items))
+ for _, item := range items {
+ if item.DistanceM < minimumDisplayedDistanceM {
+ continue
+ }
+ view := RideView{Ride: item, Cotacol: "-"}
+ parsed, err := ride.ParseFile(parser, item.GPXPath)
+ if err != nil {
+ slog.Warn("Failed to compute Cotacol", "ride", item.ExternalID, "file", item.GPXPath, "error", err)
+ } else {
+ score := parsed.DifficultyScore()
+ view.Cotacol = formatCotacol(score)
+ view.CotacolPer100Km = formatCotacolPer100Km(score, item.DistanceM)
+ }
+ views = append(views, view)
+ }
+ return views
+}
+
+func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) {
+ data := SyncPageData{
+ From: queryOrDefault(r, "from", time.Now().AddDate(0, 0, -30).Format(dateFormat)),
+ To: queryOrDefault(r, "to", time.Now().Format(dateFormat)),
+ Notice: syncNotice(r),
+ HasAuth: s.hasStravaToken(),
+ }
+ if _, _, err := s.stravaClient(); err != nil {
+ data.Error = err.Error()
+ }
+ kcore.RenderPage(r.Context(), SyncPage(data), w)
+}
+
+func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ from, to, err := parseDateRange(r.FormValue("from"), r.FormValue("to"))
+ if err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ }
+ client, authorized, err := s.stravaClient()
+ if err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ }
+ if !authorized {
+ query := url.Values{}
+ query.Set("return_to", "/sync?from="+r.FormValue("from")+"&to="+r.FormValue("to"))
+ http.Redirect(w, r, "/strava/login?"+query.Encode(), http.StatusFound)
+ return
+ }
+
+ if refreshed, err := client.RefreshIfNeeded(); err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ } else if refreshed {
+ if err := s.saveStravaToken(client); err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ }
+ }
+ imported, skipped, err := s.syncRides(client, from, to)
+ if err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ }
+ query := url.Values{}
+ query.Set("from", r.FormValue("from"))
+ query.Set("to", r.FormValue("to"))
+ query.Set("imported", strconv.Itoa(imported))
+ query.Set("skipped", strconv.Itoa(skipped))
+ http.Redirect(w, r, "/sync?"+query.Encode(), http.StatusSeeOther)
+}
+
+func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) {
+ env, err := config.LoadEnv(s.envPath)
+ if err != nil {
+ http.Error(w, "failed to load configuration", http.StatusInternalServerError)
+ return
+ }
+ clientID := env["STRAVA_CLIENT_ID"]
+ clientSecret := env["STRAVA_CLIENT_SECRET"]
+ if clientID == "" || clientSecret == "" {
+ http.Error(w, "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required", http.StatusInternalServerError)
+ return
+ }
+ state, err := newOAuthState()
+ if err != nil {
+ http.Error(w, "failed to start OAuth flow", http.StatusInternalServerError)
+ return
+ }
+ returnTo := safeReturnTo(r.URL.Query().Get("return_to"))
+ s.oauthMu.Lock()
+ s.oauthState = state
+ s.returnToURL = returnTo
+ s.oauthMu.Unlock()
+ redirectURI := s.baseURL + "/strava/callback"
+ http.Redirect(w, r, strava.AuthorizeURLWithState(clientID, redirectURI, state), http.StatusFound)
+}
+
+func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) {
+ if callbackError := r.URL.Query().Get("error"); callbackError != "" {
+ http.Error(w, "Strava authorization was denied: "+callbackError, http.StatusBadRequest)
+ return
+ }
+ returnTo, ok := s.consumeOAuthState(r.URL.Query().Get("state"))
+ if !ok {
+ http.Error(w, "invalid OAuth state", http.StatusBadRequest)
+ return
+ }
+ code := r.URL.Query().Get("code")
+ if code == "" {
+ http.Error(w, "missing authorization code", http.StatusBadRequest)
+ return
+ }
+ env, err := config.LoadEnv(s.envPath)
+ if err != nil {
+ http.Error(w, "failed to load configuration", http.StatusInternalServerError)
+ return
+ }
+ redirectURI := s.baseURL + "/strava/callback"
+ token, err := strava.ExchangeCode(env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], code, redirectURI)
+ if err != nil {
+ http.Error(w, "failed to exchange Strava authorization code", http.StatusBadGateway)
+ return
+ }
+ if err := config.UpdateEnv(s.envPath, map[string]string{
+ "STRAVA_ACCESS_TOKEN": token.AccessToken,
+ "STRAVA_REFRESH_TOKEN": token.RefreshToken,
+ "STRAVA_EXPIRES_AT": strconv.FormatInt(token.ExpiresAt.Unix(), 10),
+ }); err != nil {
+ http.Error(w, "failed to store Strava token", http.StatusInternalServerError)
+ return
+ }
+ slog.Info("Strava authorization completed")
+ http.Redirect(w, r, returnTo, http.StatusSeeOther)
+}
+
+func (s *Server) syncRides(client *strava.Client, from, to time.Time) (imported, skipped int, err error) {
+ activities, err := client.List(from, to)
+ if err != nil {
+ return 0, 0, err
+ }
+ if err := os.MkdirAll(s.gpxDir, 0o755); err != nil {
+ return 0, 0, fmt.Errorf("create GPX directory: %w", err)
+ }
+ for _, summary := range activities {
+ externalID := fmt.Sprintf("strava:%d", summary.ID)
+ _, exists, err := rides.GetByExternalID(s.db, externalID)
+ if err != nil {
+ return imported, skipped, err
+ }
+ if exists {
+ skipped++
+ continue
+ }
+ activity, gpxData, err := client.Get(summary.ID)
+ if err != nil {
+ return imported, skipped, err
+ }
+ gpxPath := filepath.Join(s.gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
+ if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil {
+ return imported, skipped, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err)
+ }
+ activityType := activity.SportType
+ if activityType == "" {
+ activityType = activity.Type
+ }
+ if err := rides.Save(s.db, rides.Ride{
+ ExternalID: externalID,
+ GPXPath: gpxPath,
+ Name: activity.Name,
+ Type: activityType,
+ StartDate: activity.StartDate,
+ DistanceM: activity.DistanceM,
+ MovingTimeS: activity.MovingTimeS,
+ ElapsedTimeS: activity.ElapsedTimeS,
+ TotalElevationGainM: activity.TotalElevationGainM,
+ AverageSpeedMps: activity.AverageSpeedMps,
+ }); err != nil {
+ return imported, skipped, fmt.Errorf("save activity %d: %w", activity.ID, err)
+ }
+ imported++
+ slog.Info("Imported Strava ride", "activity", activity.ID, "name", activity.Name)
+ }
+ return imported, skipped, nil
+}
+
+func (s *Server) stravaClient() (*strava.Client, bool, error) {
+ env, err := config.LoadEnv(s.envPath)
+ if err != nil {
+ return nil, false, fmt.Errorf("load configuration: %w", err)
+ }
+ if env["STRAVA_CLIENT_ID"] == "" || env["STRAVA_CLIENT_SECRET"] == "" {
+ return nil, false, errors.New("STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required")
+ }
+ if env["STRAVA_ACCESS_TOKEN"] == "" || env["STRAVA_REFRESH_TOKEN"] == "" {
+ return nil, false, nil
+ }
+ var expiresAt time.Time
+ if unix, err := strconv.ParseInt(env["STRAVA_EXPIRES_AT"], 10, 64); err == nil && unix != 0 {
+ expiresAt = time.Unix(unix, 0)
+ }
+ return strava.NewClient(env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], strava.Token{
+ AccessToken: env["STRAVA_ACCESS_TOKEN"],
+ RefreshToken: env["STRAVA_REFRESH_TOKEN"],
+ ExpiresAt: expiresAt,
+ }), true, nil
+}
+
+func (s *Server) saveStravaToken(client *strava.Client) error {
+ token := client.Tokens()
+ return config.UpdateEnv(s.envPath, map[string]string{
+ "STRAVA_ACCESS_TOKEN": token.AccessToken,
+ "STRAVA_REFRESH_TOKEN": token.RefreshToken,
+ "STRAVA_EXPIRES_AT": strconv.FormatInt(token.ExpiresAt.Unix(), 10),
+ })
+}
+
+func (s *Server) hasStravaToken() bool {
+ _, authorized, err := s.stravaClient()
+ return err == nil && authorized
+}
+
+func (s *Server) consumeOAuthState(state string) (string, bool) {
+ s.oauthMu.Lock()
+ defer s.oauthMu.Unlock()
+ if state == "" || state != s.oauthState {
+ return "", false
+ }
+ returnTo := s.returnToURL
+ s.oauthState = ""
+ s.returnToURL = ""
+ return returnTo, true
+}
+
+func (s *Server) renderSyncError(w http.ResponseWriter, r *http.Request, from, to string, err error) {
+ w.WriteHeader(http.StatusBadRequest)
+ kcore.RenderPage(r.Context(), SyncPage(SyncPageData{From: from, To: to, Error: err.Error(), HasAuth: s.hasStravaToken()}), w)
+}
+
+func newOAuthState() (string, error) {
+ data := make([]byte, 32)
+ if _, err := rand.Read(data); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(data), nil
+}
+
+func parseDateRange(from, to string) (time.Time, time.Time, error) {
+ start, err := time.Parse(dateFormat, from)
+ if err != nil {
+ return time.Time{}, time.Time{}, errors.New("a valid start date is required")
+ }
+ end, err := time.Parse(dateFormat, to)
+ if err != nil {
+ return time.Time{}, time.Time{}, errors.New("a valid end date is required")
+ }
+ if end.Before(start) {
+ return time.Time{}, time.Time{}, errors.New("the end date must not be before the start date")
+ }
+ return start.UTC(), end.AddDate(0, 0, 1).UTC(), nil
+}
+
+func queryOrDefault(r *http.Request, key, fallback string) string {
+ if value := r.URL.Query().Get(key); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func syncNotice(r *http.Request) string {
+ imported := r.URL.Query().Get("imported")
+ if imported == "" {
+ return ""
+ }
+ return fmt.Sprintf("Sync complete: %s imported, %s already stored.", imported, r.URL.Query().Get("skipped"))
+}
+
+func safeReturnTo(value string) string {
+ if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
+ return "/sync"
+ }
+ return value
+}
diff --git a/web/server_test.go b/web/server_test.go
new file mode 100644
index 0000000..8d3f653
--- /dev/null
+++ b/web/server_test.go
@@ -0,0 +1,133 @@
+package web
+
+import (
+ "database/sql"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/martinlehoux/biking_home/rides"
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
+ t.Helper()
+ db, err := sql.Open("sqlite3", ":memory:")
+ require.NoError(t, err)
+ t.Cleanup(func() { db.Close() })
+ _, err = db.Exec(`
+ create table rides (
+ id integer primary key,
+ external_id text unique not null,
+ gpx_path text not null,
+ name text not null,
+ type text not null,
+ start_date text not null,
+ distance_m real not null,
+ moving_time_s integer not null,
+ elapsed_time_s integer not null,
+ total_elevation_gain_m real not null,
+ average_speed_mps real not null,
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ )
+ `)
+ require.NoError(t, err)
+ envPath := filepath.Join(t.TempDir(), ".env")
+ require.NoError(t, os.WriteFile(envPath, []byte("STRAVA_CLIENT_ID=123\nSTRAVA_CLIENT_SECRET=secret\n"), 0o600))
+ return NewServer(db, envPath, t.TempDir(), "http://localhost:8080"), db
+}
+
+func TestHandlerRendersRidesPage(t *testing.T) {
+ server, db := newWebTestServer(t)
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:1234",
+ GPXPath: "missing.gpx",
+ Name: "Long Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
+ DistanceM: 20_000,
+ }))
+ require.NoError(t, rides.Save(db, rides.Ride{
+ ExternalID: "strava:5678",
+ GPXPath: "short.gpx",
+ Name: "Short Ride",
+ Type: "Ride",
+ StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC),
+ DistanceM: 9_999,
+ }))
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, req)
+
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, response.Body.String(), "All rides")
+ assert.Contains(t, response.Body.String(), "Long Ride")
+ assert.NotContains(t, response.Body.String(), "Short Ride")
+ assert.Contains(t, response.Body.String(), "Cotacol")
+ assert.Contains(t, response.Body.String(), "Cotacol / 100 km")
+}
+
+func TestSyncPageRequestsAuthorization(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/sync", nil)
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, req)
+
+ assert.Equal(t, http.StatusOK, response.Code)
+ assert.Contains(t, response.Body.String(), "Authorize Strava")
+}
+
+func TestSyncRedirectsToOAuthWhenUnauthenticated(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ form := url.Values{"from": {"2026-08-01"}, "to": {"2026-08-04"}}
+ req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, req)
+
+ assert.Equal(t, http.StatusFound, response.Code)
+ location, err := url.Parse(response.Header().Get("Location"))
+ require.NoError(t, err)
+ assert.Equal(t, "/strava/login", location.Path)
+ assert.Equal(t, "/sync?from=2026-08-01&to=2026-08-04", location.Query().Get("return_to"))
+}
+
+func TestStravaLoginRedirectsToAuthorize(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/strava/login?return_to=/sync", nil)
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, req)
+
+ assert.Equal(t, http.StatusFound, response.Code)
+ location, err := url.Parse(response.Header().Get("Location"))
+ require.NoError(t, err)
+ assert.Equal(t, "www.strava.com", location.Host)
+ assert.Equal(t, "/oauth/authorize", location.Path)
+ assert.Equal(t, "activity:read_all", location.Query().Get("scope"))
+ assert.Equal(t, "http://localhost:8080/strava/callback", location.Query().Get("redirect_uri"))
+ assert.NotEmpty(t, location.Query().Get("state"))
+}
+
+func TestParseDateRangeMakesEndInclusive(t *testing.T) {
+ from, to, err := parseDateRange("2026-08-01", "2026-08-04")
+ require.NoError(t, err)
+ assert.Equal(t, "2026-08-01T00:00:00Z", from.Format("2006-01-02T15:04:05Z07:00"))
+ assert.Equal(t, "2026-08-05T00:00:00Z", to.Format("2006-01-02T15:04:05Z07:00"))
+}
+
+func TestFormatCotacolPer100Km(t *testing.T) {
+ assert.Equal(t, "20.0", formatCotacolPer100Km(2, 10_000))
+ assert.Equal(t, "-", formatCotacolPer100Km(2, 0))
+}
diff --git a/web/templates.templ b/web/templates.templ
new file mode 100644
index 0000000..d0b1c0c
--- /dev/null
+++ b/web/templates.templ
@@ -0,0 +1,106 @@
+package web
+
+templ Layout(title string, content templ.Component) {
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
+ <title>{ title } · biking_home</title>
+ <style>
+ :root { color-scheme: light; font-family: system-ui, sans-serif; background: #f5f3ee; color: #1d2a22; }
+ * { box-sizing: border-box; }
+ body { margin: 0; }
+ a { color: #18794e; }
+ .site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(1rem, calc((100vw - 70rem) / 2)); background: #173b2a; color: #fff; }
+ .site-header a { color: #fff; text-decoration: none; }
+ .brand { font-weight: 750; letter-spacing: .02em; }
+ .nav { display: flex; gap: 1rem; font-size: .95rem; }
+ .container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
+ .eyebrow { margin: 0 0 .4rem; color: #18794e; font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
+ h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
+ .lead { max-width: 42rem; color: #5b675f; }
+ .panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid #d9ded8; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 2.5rem #173b2a0d; }
+ .toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
+ .button { display: inline-block; border: 0; border-radius: .65rem; padding: .7rem 1rem; background: #d96932; color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
+ .button.secondary { background: #e8eee9; color: #173b2a; }
+ label { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; }
+ input { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; }
+ .form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }
+ .notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; }
+ .error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; }
+ table { width: 100%; border-collapse: collapse; }
+ th, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }
+ th { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
+ .numeric { text-align: right; white-space: nowrap; }
+ .empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }
+ @media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
+ </style>
+ </head>
+ <body>
+ <header class="site-header">
+ <a class="brand" href="/">biking_home</a>
+ <nav class="nav"><a href="/">Rides</a><a href="/sync">Sync Strava</a></nav>
+ </header>
+ <main class="container">
+ @content
+ </main>
+ </body>
+ </html>
+}
+
+templ RidesPage(items []RideView) {
+ @Layout("Rides", RidesContent(items))
+}
+
+templ RidesContent(items []RideView) {
+ <p class="eyebrow">Ride library</p>
+ <div class="toolbar">
+ <div><h1>All rides</h1><p class="lead">Your imported rides, ready for climb analysis.</p></div>
+ <a class="button" href="/sync">Sync Strava</a>
+ </div>
+ <section class="panel">
+ if len(items) == 0 {
+ <div class="empty">No rides stored yet. <a href="/sync">Import your first Strava rides.</a></div>
+ } else {
+ <table>
+ <thead><tr><th>Ride</th><th>Started</th><th class="numeric">Distance</th><th class="numeric">Moving time</th><th class="numeric">Elevation</th><th class="numeric">Cotacol</th><th class="numeric">Cotacol / 100 km</th></tr></thead>
+ <tbody>
+ for _, item := range items {
+ <tr><td><strong>{ item.Name }</strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ formatDuration(item.MovingTimeS) }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr>
+ }
+ </tbody>
+ </table>
+ }
+ </section>
+}
+
+templ SyncPage(data SyncPageData) {
+ @Layout("Sync Strava", SyncContent(data))
+}
+
+templ SyncContent(data SyncPageData) {
+ <p class="eyebrow">Data import</p>
+ <h1>Sync Strava rides</h1>
+ <p class="lead">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>
+ if data.Notice != "" {
+ <div class="notice">{ data.Notice }</div>
+ }
+ if data.Error != "" {
+ <div class="error">{ data.Error }</div>
+ }
+ <section class="panel">
+ if !data.HasAuth {
+ <p>Strava authorization is required before the first sync.</p>
+ <a class="button secondary" href={ templ.URL("/strava/login?return_to=/sync") }>Authorize Strava</a>
+ } else {
+ <form method="post" action="/sync">
+ <div class="form-row">
+ <label>From<input type="date" name="from" value={ data.From } required/></label>
+ <label>To<input type="date" name="to" value={ data.To } required/></label>
+ <button class="button" type="submit">Sync rides</button>
+ </div>
+ </form>
+ }
+ </section>
+}
diff --git a/web/templates_templ.go b/web/templates_templ.go
new file mode 100644
index 0000000..8ab1d83
--- /dev/null
+++ b/web/templates_templ.go
@@ -0,0 +1,381 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.747
+package web
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Layout(title string, content templ.Component) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 9, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" · biking_home</title><style>\n\t\t\t\t:root { color-scheme: light; font-family: system-ui, sans-serif; background: #f5f3ee; color: #1d2a22; }\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\tbody { margin: 0; }\n\t\t\t\ta { color: #18794e; }\n\t\t\t\t.site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(1rem, calc((100vw - 70rem) / 2)); background: #173b2a; color: #fff; }\n\t\t\t\t.site-header a { color: #fff; text-decoration: none; }\n\t\t\t\t.brand { font-weight: 750; letter-spacing: .02em; }\n\t\t\t\t.nav { display: flex; gap: 1rem; font-size: .95rem; }\n\t\t\t\t.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }\n\t\t\t\t.eyebrow { margin: 0 0 .4rem; color: #18794e; font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n\t\t\t\th1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }\n\t\t\t\t.lead { max-width: 42rem; color: #5b675f; }\n\t\t\t\t.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid #d9ded8; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 2.5rem #173b2a0d; }\n\t\t\t\t.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }\n\t\t\t\t.button { display: inline-block; border: 0; border-radius: .65rem; padding: .7rem 1rem; background: #d96932; color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }\n\t\t\t\t.button.secondary { background: #e8eee9; color: #173b2a; }\n\t\t\t\tlabel { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; }\n\t\t\t\tinput { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; }\n\t\t\t\t.form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }\n\t\t\t\t.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; }\n\t\t\t\t.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; }\n\t\t\t\ttable { width: 100%; border-collapse: collapse; }\n\t\t\t\tth, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }\n\t\t\t\tth { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }\n\t\t\t\t.numeric { text-align: right; white-space: nowrap; }\n\t\t\t\t.empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }\n\t\t\t\t@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }\n\t\t\t</style></head><body><header class=\"site-header\"><a class=\"brand\" href=\"/\">biking_home</a><nav class=\"nav\"><a href=\"/\">Rides</a><a href=\"/sync\">Sync Strava</a></nav></header><main class=\"container\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</main></body></html>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func RidesPage(items []RideView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var3 == nil {
+ templ_7745c5c3_Var3 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = Layout("Rides", RidesContent(items)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func RidesContent(items []RideView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Ride library</p><div class=\"toolbar\"><div><h1>All rides</h1><p class=\"lead\">Your imported rides, ready for climb analysis.</p></div><a class=\"button\" href=\"/sync\">Sync Strava</a></div><section class=\"panel\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(items) == 0 {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"empty\">No rides stored yet. <a href=\"/sync\">Import your first Strava rides.</a></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<table><thead><tr><th>Ride</th><th>Started</th><th class=\"numeric\">Distance</th><th class=\"numeric\">Moving time</th><th class=\"numeric\">Elevation</th><th class=\"numeric\">Cotacol</th><th class=\"numeric\">Cotacol / 100 km</th></tr></thead> <tbody>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range items {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><td><strong>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</strong><br><small>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Type)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 67}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</small></td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(formatRideDate(item.StartDate))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td class=\"numeric\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatDistance(item.DistanceM))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 177}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td class=\"numeric\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDuration(item.MovingTimeS))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 238}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td class=\"numeric\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatElevation(item.TotalElevationGainM))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 308}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td class=\"numeric\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Cotacol)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 349}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td class=\"numeric\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.CotacolPer100Km)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 70, Col: 398}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</tbody></table>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func SyncPage(data SyncPageData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var13 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var13 == nil {
+ templ_7745c5c3_Var13 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = Layout("Sync Strava", SyncContent(data)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+func SyncContent(data SyncPageData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var14 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var14 == nil {
+ templ_7745c5c3_Var14 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p class=\"eyebrow\">Data import</p><h1>Sync Strava rides</h1><p class=\"lead\">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Notice != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"notice\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 87, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Error != "" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"error\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 90, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"panel\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !data.HasAuth {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>Strava authorization is required before the first sync.</p><a class=\"button secondary\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 templ.SafeURL = templ.URL("/strava/login?return_to=/sync")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var17)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">Authorize Strava</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form method=\"post\" action=\"/sync\"><div class=\"form-row\"><label>From<input type=\"date\" name=\"from\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(data.From)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 99, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" required></label> <label>To<input type=\"date\" name=\"to\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(data.To)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 100, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" required></label> <button class=\"button\" type=\"submit\">Sync rides</button></div></form>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
diff --git a/web/views.go b/web/views.go
new file mode 100644
index 0000000..29f3c4f
--- /dev/null
+++ b/web/views.go
@@ -0,0 +1,52 @@
+package web
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/martinlehoux/biking_home/rides"
+)
+
+type RideView struct {
+ rides.Ride
+ Cotacol string
+ CotacolPer100Km string
+}
+
+type SyncPageData struct {
+ From string
+ To string
+ Error string
+ Notice string
+ HasAuth bool
+}
+
+func formatRideDate(value time.Time) string {
+ return value.Local().Format("02 Jan 2006, 15:04")
+}
+
+func formatDistance(meters float64) string {
+ return fmt.Sprintf("%.1f km", meters/1000)
+}
+
+func formatDuration(seconds int64) string {
+ hours := seconds / 3600
+ minutes := (seconds % 3600) / 60
+ return fmt.Sprintf("%dh %02dm", hours, minutes)
+}
+
+func formatElevation(meters float64) string {
+ return fmt.Sprintf("%.0f m", meters)
+}
+
+func formatCotacol(score float64) string {
+ return fmt.Sprintf("%.1f", score)
+}
+
+func formatCotacolPer100Km(score, distanceM float64) string {
+ distanceKm := distanceM / 1000
+ if distanceKm <= 0 {
+ return "-"
+ }
+ return fmt.Sprintf("%.1f", score*100/distanceKm)
+}