diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 46 | ||||
| -rw-r--r-- | config/env.go (renamed from env.go) | 30 | ||||
| -rw-r--r-- | db/migrations/20260804000000_rides.sql | 19 | ||||
| -rw-r--r-- | db/schema.sql | 18 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | main.go | 163 | ||||
| -rw-r--r-- | ride/parser_test.go | 46 | ||||
| -rw-r--r-- | ride/ride.go | 30 | ||||
| -rw-r--r-- | rides/rides.go | 104 | ||||
| -rw-r--r-- | rides/rides_test.go | 104 | ||||
| -rw-r--r-- | strava/strava.go | 7 | ||||
| -rw-r--r-- | strava/sync.go | 60 | ||||
| -rw-r--r-- | strava/sync_test.go | 31 | ||||
| -rw-r--r-- | web/server.go | 356 | ||||
| -rw-r--r-- | web/server_test.go | 133 | ||||
| -rw-r--r-- | web/templates.templ | 106 | ||||
| -rw-r--r-- | web/templates_templ.go | 381 | ||||
| -rw-r--r-- | web/views.go | 52 |
19 files changed, 1496 insertions, 194 deletions
@@ -2,7 +2,7 @@ .strava-last-sync *.db *.png -rides/ +rides/*.gpx debug_department_*.csv france-latest.osm.pbf biking_home @@ -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 @@ -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'); @@ -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 @@ -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) +} |