aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db/migrations/20260925061236_ride_official_climb_attempts_average_power.sql6
-rw-r--r--db/schema.sql5
-rw-r--r--official_climb/attempt.go36
-rw-r--r--official_climb/attempt_test.go9
-rw-r--r--rideanalysis/attempts.go3
-rw-r--r--rideanalysis/attempts_test.go14
-rw-r--r--web/climbs.go70
-rw-r--r--web/climbs_test.go37
-rw-r--r--web/frontend/climb-attempts-logic.test.ts35
-rw-r--r--web/frontend/climb-attempts-logic.ts30
-rw-r--r--web/frontend/climb-attempts.ts148
-rw-r--r--web/frontend/types.d.ts8
-rw-r--r--web/templates.templ10
-rw-r--r--web/views.go12
14 files changed, 391 insertions, 32 deletions
diff --git a/db/migrations/20260925061236_ride_official_climb_attempts_average_power.sql b/db/migrations/20260925061236_ride_official_climb_attempts_average_power.sql
new file mode 100644
index 0000000..e0c4bf1
--- /dev/null
+++ b/db/migrations/20260925061236_ride_official_climb_attempts_average_power.sql
@@ -0,0 +1,6 @@
+-- Average power over the attempt interval; null for rides without a power meter.
+-- migrate:up
+ALTER TABLE ride_official_climb_attempts ADD COLUMN average_power_w real;
+
+-- migrate:down
+ALTER TABLE ride_official_climb_attempts DROP COLUMN average_power_w;
diff --git a/db/schema.sql b/db/schema.sql
index 48bd02f..8995098 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -40,7 +40,7 @@ CREATE TABLE ride_official_climb_attempts (
cotacol_algo_version text not null,
started_at text not null,
ended_at text not null,
- created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), average_power_w real,
primary key (ride_id, climb_id, occurrence)
);
CREATE TABLE IF NOT EXISTS "official_climbs" (
@@ -71,4 +71,5 @@ INSERT INTO "schema_migrations" (version) VALUES
('20260831000000'),
('20260913000000'),
('20260924000000'),
- ('20260925000000');
+ ('20260925000000'),
+ ('20260925061236');
diff --git a/official_climb/attempt.go b/official_climb/attempt.go
index c340fea..1164e01 100644
--- a/official_climb/attempt.go
+++ b/official_climb/attempt.go
@@ -9,7 +9,7 @@ import (
"github.com/martinlehoux/biking_home/climb"
)
-// Attempt is one recorded passage of an official climb in a ride, keyed by (rideID, climbID, occurrence); stats are snapshotted so listings never re-parse ride files.
+// Attempt is one recorded passage of an official climb in a ride, keyed by (rideID, climbID, occurrence); stats are snapshotted so listings never re-parse ride files. AveragePowerW is nil for rides without a power meter.
type Attempt struct {
RideID int64
ClimbID int64
@@ -18,13 +18,14 @@ type Attempt struct {
ElevationGainM float64
SlopePercent float64
Cotacol float64
+ AveragePowerW *float64
CotacolAlgoVersion string
StartedAt time.Time
EndedAt time.Time
CreatedAt time.Time
}
-const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at"
+const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.average_power_w, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at"
// AttemptsForClimb returns every recorded attempt of one official climb, oldest ride first.
func AttemptsForClimb(db *sql.DB, climbID int64) ([]Attempt, error) {
@@ -78,17 +79,18 @@ func ReplaceRideAttempts(db *sql.DB, rideID int64, attempts []Attempt) error {
keys = append(keys, "(?, ?)")
args = append(args, attempt.ClimbID, attempt.Occurrence)
if _, err := tx.Exec(`
- INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, cotacol_algo_version, started_at, ended_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, average_power_w, cotacol_algo_version, started_at, ended_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ride_id, climb_id, occurrence) DO UPDATE SET
distance_m = excluded.distance_m,
elevation_gain_m = excluded.elevation_gain_m,
slope_percent = excluded.slope_percent,
cotacol = excluded.cotacol,
+ average_power_w = excluded.average_power_w,
cotacol_algo_version = excluded.cotacol_algo_version,
started_at = excluded.started_at,
ended_at = excluded.ended_at
- `, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.CotacolAlgoVersion,
+ `, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.AveragePowerW, attempt.CotacolAlgoVersion,
attempt.StartedAt.UTC().Format(time.RFC3339), attempt.EndedAt.UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("insert ride attempt: %w", err)
}
@@ -113,12 +115,16 @@ type attemptScanner interface {
func scanAttempt(s attemptScanner) (Attempt, error) {
var (
attempt Attempt
+ averagePower sql.NullFloat64
startedAt, endedAt string
createdAt sql.NullString
)
- if err := s.Scan(&attempt.RideID, &attempt.ClimbID, &attempt.Occurrence, &attempt.DistanceM, &attempt.ElevationGainM, &attempt.SlopePercent, &attempt.Cotacol, &attempt.CotacolAlgoVersion, &startedAt, &endedAt, &createdAt); err != nil {
+ if err := s.Scan(&attempt.RideID, &attempt.ClimbID, &attempt.Occurrence, &attempt.DistanceM, &attempt.ElevationGainM, &attempt.SlopePercent, &attempt.Cotacol, &averagePower, &attempt.CotacolAlgoVersion, &startedAt, &endedAt, &createdAt); err != nil {
return Attempt{}, err
}
+ if averagePower.Valid {
+ attempt.AveragePowerW = &averagePower.Float64
+ }
var err error
if attempt.StartedAt, err = time.Parse(time.RFC3339, startedAt); err != nil {
return Attempt{}, fmt.Errorf("invalid ride_official_climb_attempts.started_at: %w", err)
@@ -145,8 +151,8 @@ type AttemptStats struct {
EndIndex int
}
-// AttemptFromStats snapshots one analyzed climb occurrence; timestamps come from the ride points bounding the climb interval.
-func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time) Attempt {
+// AttemptFromStats snapshots one analyzed climb occurrence; timestamps come from the ride points bounding the climb interval, and AveragePowerW is the mean of the power samples inside it (nil when the ride recorded no power).
+func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time, powers []float64) Attempt {
startedAt, endedAt := time.Time{}, time.Time{}
if stats.StartIndex < len(timestamps) {
startedAt = timestamps[stats.StartIndex]
@@ -162,8 +168,22 @@ func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestam
ElevationGainM: stats.ElevationGainM,
SlopePercent: stats.SlopePercent,
Cotacol: stats.Cotacol,
+ AveragePowerW: meanPower(powers, stats.StartIndex, stats.EndIndex),
CotacolAlgoVersion: climb.CotacolAlgorithmVersion,
StartedAt: startedAt,
EndedAt: endedAt,
}
}
+
+// meanPower averages the power samples inside the climb interval (bounds included); it is nil when the interval does not fit the column.
+func meanPower(powers []float64, start, end int) *float64 {
+ if start < 0 || end >= len(powers) || start > end {
+ return nil
+ }
+ sum := 0.0
+ for _, power := range powers[start : end+1] {
+ sum += power
+ }
+ mean := sum / float64(end-start+1)
+ return &mean
+}
diff --git a/official_climb/attempt_test.go b/official_climb/attempt_test.go
index 8913807..e9d8b11 100644
--- a/official_climb/attempt_test.go
+++ b/official_climb/attempt_test.go
@@ -47,6 +47,7 @@ func withStartDate(startDate string) func(*rideInsert) {
}
func attemptFixture(rideID, climbID int64, occurrence int) official_climb.Attempt {
+ averagePowerW := 235.5
return official_climb.Attempt{
RideID: rideID,
ClimbID: climbID,
@@ -55,6 +56,7 @@ func attemptFixture(rideID, climbID int64, occurrence int) official_climb.Attemp
ElevationGainM: 210,
SlopePercent: 6.5,
Cotacol: 84.2,
+ AveragePowerW: &averagePowerW,
CotacolAlgoVersion: "v1",
StartedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC),
EndedAt: time.Date(2026, 9, 1, 10, 18, 30, 0, time.UTC),
@@ -66,10 +68,12 @@ func TestReplaceRideAttemptsRoundTrip(t *testing.T) {
created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()})
require.NoError(t, err)
rideID := insertRide(t, db)
+ withoutPower := attemptFixture(rideID, created.ID, 1)
+ withoutPower.AveragePowerW = nil
require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, []official_climb.Attempt{
attemptFixture(rideID, created.ID, 0),
- attemptFixture(rideID, created.ID, 1),
+ withoutPower,
}))
attempts, err := official_climb.AttemptsForClimb(db, created.ID)
@@ -80,6 +84,9 @@ func TestReplaceRideAttemptsRoundTrip(t *testing.T) {
assert.Equal(t, 3200.0, attempts[0].DistanceM)
assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion)
assert.Equal(t, time.Date(2026, 9, 1, 10, 18, 30, 0, time.UTC), attempts[0].EndedAt)
+ require.NotNil(t, attempts[0].AveragePowerW)
+ assert.InDelta(t, 235.5, *attempts[0].AveragePowerW, 0.01)
+ assert.Nil(t, attempts[1].AveragePowerW, "a missing power meter must round-trip as null")
rideAttempts, err := official_climb.AttemptsForRide(db, rideID)
require.NoError(t, err)
diff --git a/rideanalysis/attempts.go b/rideanalysis/attempts.go
index faf02e9..0a89895 100644
--- a/rideanalysis/attempts.go
+++ b/rideanalysis/attempts.go
@@ -9,6 +9,7 @@ import (
func Attempts(rideID int64, analysis Result, parsed ride.Ride) []official_climb.Attempt {
occurrences := make(map[int64]int)
attempts := make([]official_climb.Attempt, 0)
+ powers, _ := parsed.Powers()
for _, analyzed := range analysis.Climbs {
if analyzed.OfficialClimbID == 0 {
continue
@@ -23,7 +24,7 @@ func Attempts(rideID int64, analysis Result, parsed ride.Ride) []official_climb.
Cotacol: analyzed.Cotacol,
StartIndex: analyzed.Interval.Start,
EndIndex: analyzed.Interval.End,
- }, parsed.Timestamps()))
+ }, parsed.Timestamps(), powers))
}
return attempts
}
diff --git a/rideanalysis/attempts_test.go b/rideanalysis/attempts_test.go
index 6fab8e1..b5b1271 100644
--- a/rideanalysis/attempts_test.go
+++ b/rideanalysis/attempts_test.go
@@ -36,6 +36,20 @@ func TestAttemptsSnapshotsOfficialClimbs(t *testing.T) {
assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion)
assert.Equal(t, attemptRideStart, attempts[0].StartedAt.UTC())
assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps")
+ assert.Nil(t, attempts[0].AveragePowerW, "rides without a power meter record no average power")
+}
+
+func TestAttemptsSnapshotsAveragePower(t *testing.T) {
+ parsed := ridetest.MustRide("0.5km at 10% w=200 | 0.5km at 10% w=400 | 1km at -5% w=100", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(500), ridetest.StartTime(attemptRideStart))
+ official := official_climb.OfficialClimb{ID: 42, Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[2], Sample: parsed.Sample(0, 2)}
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
+
+ attempts := rideanalysis.Attempts(7, analysis, parsed)
+ require.Len(t, attempts, 1)
+ assert.InDelta(t, 1000, attempts[0].DistanceM, 1)
+ // Mean of the power samples inside the climb interval: 200, 200, 400 W.
+ require.NotNil(t, attempts[0].AveragePowerW)
+ assert.InDelta(t, 800.0/3.0, *attempts[0].AveragePowerW, 0.01)
}
func TestAttemptsSkipsUnmatchedClimbs(t *testing.T) {
diff --git a/web/climbs.go b/web/climbs.go
index 6416509..5cd6db9 100644
--- a/web/climbs.go
+++ b/web/climbs.go
@@ -38,6 +38,18 @@ type ClimbAttemptView struct {
Duration string
Distance string
Cotacol string
+ // Numeric chart data; AveragePowerW is nil for rides without a power meter.
+ DurationS float64
+ AveragePowerW *float64
+}
+
+// ClimbAttemptPoint is one dot on the attempts chart: attempt duration against average power.
+type ClimbAttemptPoint struct {
+ DurationS float64 `json:"durationS"`
+ AveragePowerW float64 `json:"averagePowerW"`
+ Date string `json:"date"`
+ RideName string `json:"rideName"`
+ URL string `json:"url"`
}
type ClimbStatsView struct {
@@ -51,11 +63,12 @@ type ClimbStatsView struct {
}
type ClimbDetailPageData struct {
- Climb official_climb.OfficialClimb
- Stats ClimbStatsView
- Attempts []ClimbAttemptView
- Profile ClimbProfileData
- Endpoints ClimbMapEndpoints
+ Climb official_climb.OfficialClimb
+ Stats ClimbStatsView
+ Attempts []ClimbAttemptView
+ AttemptPoints []ClimbAttemptPoint
+ Profile ClimbProfileData
+ Endpoints ClimbMapEndpoints
}
type ClimbMapEndpoints struct {
@@ -88,11 +101,13 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) {
}
aggregate := official_climb.AggregateAttempts(attempts)
profile := s.buildClimbProfile(climb, aggregate, attempts)
+ views := buildClimbAttemptViews(s.db, attempts)
kcore.RenderPage(r.Context(), ClimbDetailPage(ClimbDetailPageData{
- Climb: climb,
- Stats: buildClimbStatsView(aggregate),
- Attempts: buildClimbAttemptViews(s.db, attempts),
- Profile: profile,
+ Climb: climb,
+ Stats: buildClimbStatsView(aggregate),
+ Attempts: views,
+ AttemptPoints: climbAttemptPoints(views),
+ Profile: profile,
Endpoints: ClimbMapEndpoints{
Start: []float64{climb.StartCoord.Lat, climb.StartCoord.Lon},
End: []float64{climb.EndCoord.Lat, climb.EndCoord.Lon},
@@ -126,11 +141,10 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a
continue
}
for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) {
- segment := climb.Climb{Route: climb.Route{
+ segment := climb.Climb{
Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1],
Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1],
- Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1],
- }}
+ Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1]}
if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 {
elevationSamples = append(elevationSamples, sample)
}
@@ -199,17 +213,37 @@ func buildClimbAttemptViews(db *sql.DB, attempts []official_climb.Attempt) []Cli
names[attempt.RideID] = name
}
views[i] = ClimbAttemptView{
- RideID: attempt.RideID,
- RideName: name,
- Date: formatRideDate(attempt.StartedAt),
- Duration: formatDuration(int64(attempt.EndedAt.Sub(attempt.StartedAt).Seconds())),
- Distance: formatDistance(attempt.DistanceM),
- Cotacol: formatCotacol(attempt.Cotacol),
+ RideID: attempt.RideID,
+ RideName: name,
+ Date: formatRideDate(attempt.StartedAt),
+ Duration: formatDuration(int64(attempt.EndedAt.Sub(attempt.StartedAt).Seconds())),
+ Distance: formatDistance(attempt.DistanceM),
+ Cotacol: formatCotacol(attempt.Cotacol),
+ DurationS: attempt.EndedAt.Sub(attempt.StartedAt).Seconds(),
+ AveragePowerW: attempt.AveragePowerW,
}
}
return views
}
+// climbAttemptPoints maps attempts to chart dots; an attempt without power data carries no dot.
+func climbAttemptPoints(views []ClimbAttemptView) []ClimbAttemptPoint {
+ points := make([]ClimbAttemptPoint, 0, len(views))
+ for _, view := range views {
+ if view.AveragePowerW == nil {
+ continue
+ }
+ points = append(points, ClimbAttemptPoint{
+ DurationS: view.DurationS,
+ AveragePowerW: *view.AveragePowerW,
+ Date: view.Date,
+ RideName: view.RideName,
+ URL: rideDetailURL(view.RideID),
+ })
+ }
+ return points
+}
+
func climbsListURL() string {
return "/climbs"
}
diff --git a/web/climbs_test.go b/web/climbs_test.go
index 3b30c07..d79f1d8 100644
--- a/web/climbs_test.go
+++ b/web/climbs_test.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
+ "github.com/martinlehoux/biking_home/internal/ridetest"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rideanalysis"
@@ -49,6 +50,42 @@ func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) {
assert.Contains(t, body, "Col de Test")
assert.Contains(t, body, "climb-profile-data")
assert.Contains(t, body, "Test Ride")
+ // A ride without a power meter renders a dash, not a fabricated value.
+ assert.Contains(t, body, `<td class="numeric">-</td>`)
+}
+
+func TestHandleClimbRendersAttemptPoints(t *testing.T) {
+ server, db := newWebTestServer(t)
+ parsed := ridetest.MustRide("1km at 14.7% w=250 | 1km at -6.7% w=150", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(testRideStart))
+ created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], Sample: parsed.Sample(0, 1)})
+ require.NoError(t, err)
+ rideID := insertTestRide(t, db)
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy())
+ server.recordOfficialClimbAttempts(rideID, parsed, analysis)
+
+ recorder := httptest.NewRecorder()
+ server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, climbDetailURL(created.ID), nil))
+ require.Equal(t, http.StatusOK, recorder.Code)
+ body := recorder.Body.String()
+ assert.Contains(t, body, `id="climb-attempt-points"`)
+ // Mean of the climb interval power samples: 250, 250 W.
+ assert.Contains(t, body, `"averagePowerW":250`)
+ assert.Contains(t, body, `"url":"/rides/`)
+ assert.Contains(t, body, "Avg power")
+ assert.Contains(t, body, "250 W")
+}
+
+func TestClimbAttemptPointsSkipAttemptsWithoutPower(t *testing.T) {
+ power := 235.5
+ views := []ClimbAttemptView{
+ {RideID: 7, RideName: "Powered", Date: "01 Sep 2026, 10:00", DurationS: 1110, AveragePowerW: &power},
+ {RideID: 8, RideName: "No meter", Date: "02 Sep 2026, 10:00", DurationS: 1200},
+ }
+
+ points := climbAttemptPoints(views)
+
+ require.Len(t, points, 1)
+ assert.Equal(t, ClimbAttemptPoint{DurationS: 1110, AveragePowerW: 235.5, Date: "01 Sep 2026, 10:00", RideName: "Powered", URL: "/rides/7"}, points[0])
}
func TestHandleClimbNotFound(t *testing.T) {
diff --git a/web/frontend/climb-attempts-logic.test.ts b/web/frontend/climb-attempts-logic.test.ts
new file mode 100644
index 0000000..6ce7353
--- /dev/null
+++ b/web/frontend/climb-attempts-logic.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { attemptsDomain, formatAttemptDuration, formatPower } from "./climb-attempts-logic.ts";
+import type { ClimbAttemptPoint } from "./types.js";
+
+const makePoint = (durationS: number, averagePowerW: number): ClimbAttemptPoint => ({
+ durationS,
+ averagePowerW,
+ date: "01 Sep 2026, 10:00",
+ rideName: "ride",
+ url: "/rides/1",
+});
+
+test("formats attempt durations as mm:ss and h:mm:ss", () => {
+ assert.equal(formatAttemptDuration(0), "0:00");
+ assert.equal(formatAttemptDuration(1110), "18:30");
+ assert.equal(formatAttemptDuration(3723), "1:02:03");
+ assert.equal(formatAttemptDuration(-5), "0:00");
+});
+
+test("formats power in whole watts", () => {
+ assert.equal(formatPower(235.5), "236 W");
+ assert.equal(formatPower(200), "200 W");
+});
+
+test("pads zero-based axis domains above the data", () => {
+ const domain = attemptsDomain([makePoint(100, 250), makePoint(200, 150)]);
+ assert.equal(domain.maxDurationS, 216);
+ assert.equal(domain.maxPowerW, 270);
+});
+
+test("keeps a drawable domain when data is empty or zero", () => {
+ assert.deepEqual(attemptsDomain([]), { maxDurationS: 1, maxPowerW: 1 });
+ assert.deepEqual(attemptsDomain([makePoint(0, 0)]), { maxDurationS: 1, maxPowerW: 1 });
+});
diff --git a/web/frontend/climb-attempts-logic.ts b/web/frontend/climb-attempts-logic.ts
new file mode 100644
index 0000000..868545b
--- /dev/null
+++ b/web/frontend/climb-attempts-logic.ts
@@ -0,0 +1,30 @@
+import type { ClimbAttemptPoint } from "./types.js";
+
+// formatAttemptDuration renders an attempt duration as "mm:ss", or "h:mm:ss" past an hour.
+export const formatAttemptDuration = (durationS: number): string => {
+ const total = Math.max(0, Math.round(durationS));
+ const seconds = total % 60;
+ const minutes = Math.floor(total / 60) % 60;
+ const hours = Math.floor(total / 3600);
+ const two = (value: number): string => String(value).padStart(2, "0");
+ return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`;
+};
+
+// formatPower renders an average power in whole watts.
+export const formatPower = (powerW: number): string => `${Math.round(powerW)} W`;
+
+export interface AttemptsDomain {
+ maxDurationS: number;
+ maxPowerW: number;
+}
+
+// attemptsDomain returns zero-based axis bounds padded above the data; empty or degenerate data still yields a drawable unit domain.
+export const attemptsDomain = (points: ClimbAttemptPoint[]): AttemptsDomain => {
+ let maxDurationS = 0;
+ let maxPowerW = 0;
+ for (const point of points) {
+ maxDurationS = Math.max(maxDurationS, point.durationS);
+ maxPowerW = Math.max(maxPowerW, point.averagePowerW);
+ }
+ return { maxDurationS: maxDurationS * 1.08 || 1, maxPowerW: maxPowerW * 1.08 || 1 };
+};
diff --git a/web/frontend/climb-attempts.ts b/web/frontend/climb-attempts.ts
new file mode 100644
index 0000000..e29dffc
--- /dev/null
+++ b/web/frontend/climb-attempts.ts
@@ -0,0 +1,148 @@
+import { ticks } from "./analytics-logic.js";
+import { attemptsDomain, formatAttemptDuration, formatPower } from "./climb-attempts-logic.js";
+import { resolveOfficialProfileColors } from "./official-climb-profile.js";
+import type { ClimbAttemptPoint, OfficialProfileColors } from "./types.js";
+
+interface Plot {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+interface AttemptsChartState {
+ canvas: HTMLCanvasElement;
+ points: ClimbAttemptPoint[];
+ colors: OfficialProfileColors;
+ output: HTMLOutputElement;
+ hoveredIndex: number;
+}
+
+const POINT_RADIUS = 4;
+const HOVER_RADIUS = 7;
+const HIT_RADIUS = 12;
+
+const draw = (state: AttemptsChartState): void => {
+ const { canvas, points, colors } = state;
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const context = canvas.getContext("2d");
+ if (!context) return;
+ const ratio = window.devicePixelRatio || 1;
+ canvas.width = Math.floor(rect.width * ratio);
+ canvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot: Plot = { left: 44, right: rect.width - 16, top: 16, bottom: rect.height - 30 };
+ const domain = attemptsDomain(points);
+ const xForDuration = (durationS: number): number => plot.left + (durationS / domain.maxDurationS) * (plot.right - plot.left);
+ const yForPower = (powerW: number): number => plot.bottom - (powerW / domain.maxPowerW) * (plot.bottom - plot.top);
+ context.clearRect(0, 0, rect.width, rect.height);
+ context.fillStyle = colors.plotSurface;
+ context.fillRect(0, 0, rect.width, rect.height);
+ context.font = "11px system-ui, sans-serif";
+ context.lineWidth = 1;
+ // Duration ticks step in minutes so labels stay round.
+ for (const tick of ticks(0, domain.maxDurationS / 60)) {
+ const x = xForDuration(tick * 60);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.fillStyle = colors.subtle;
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ context.fillText(formatAttemptDuration(tick * 60), x, plot.bottom + 8);
+ }
+ for (const tick of ticks(0, domain.maxPowerW)) {
+ const y = yForPower(tick);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(plot.left, y);
+ context.lineTo(plot.right, y);
+ context.stroke();
+ context.fillStyle = colors.subtle;
+ context.textAlign = "right";
+ context.textBaseline = "middle";
+ context.fillText(formatPower(tick), plot.left - 8, y);
+ }
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(xForDuration(point.durationS), yForPower(point.averagePowerW), POINT_RADIUS, 0, 2 * Math.PI);
+ context.fill();
+ if (index === state.hoveredIndex) {
+ context.strokeStyle = colors.accent;
+ context.lineWidth = 2;
+ context.beginPath();
+ context.arc(xForDuration(point.durationS), yForPower(point.averagePowerW), HOVER_RADIUS, 0, 2 * Math.PI);
+ context.stroke();
+ }
+ }
+};
+
+const nearestPoint = (state: AttemptsChartState, x: number, y: number): number => {
+ const { canvas, points } = state;
+ const rect = canvas.getBoundingClientRect();
+ const plot: Plot = { left: 44, right: rect.width - 16, top: 16, bottom: rect.height - 30 };
+ const domain = attemptsDomain(points);
+ let nearest = -1;
+ let best = Infinity;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ const dx = plot.left + (point.durationS / domain.maxDurationS) * (plot.right - plot.left) - x;
+ const dy = plot.bottom - (point.averagePowerW / domain.maxPowerW) * (plot.bottom - plot.top) - y;
+ const distance = Math.hypot(dx, dy);
+ if (distance < best) {
+ best = distance;
+ nearest = index;
+ }
+ }
+ return best <= HIT_RADIUS ? nearest : -1;
+};
+
+const setHovered = (state: AttemptsChartState, index: number): void => {
+ if (index === state.hoveredIndex) return;
+ state.hoveredIndex = index;
+ if (index < 0) {
+ state.output.textContent = "Hover a point to inspect an attempt.";
+ } else {
+ const point = state.points[index];
+ state.output.textContent = `${point.date} · ${point.rideName} · ${formatAttemptDuration(point.durationS)} · ${formatPower(point.averagePowerW)} · click for details`;
+ }
+ draw(state);
+};
+
+const bindEvents = (state: AttemptsChartState): void => {
+ state.canvas.addEventListener("pointermove", (event: PointerEvent) => {
+ const rect = state.canvas.getBoundingClientRect();
+ const index = nearestPoint(state, event.clientX - rect.left, event.clientY - rect.top);
+ state.canvas.style.cursor = index >= 0 ? "pointer" : "default";
+ setHovered(state, index);
+ });
+ state.canvas.addEventListener("pointerleave", () => {
+ state.canvas.style.cursor = "default";
+ setHovered(state, -1);
+ });
+ state.canvas.addEventListener("pointercancel", () => setHovered(state, -1));
+ state.canvas.addEventListener("click", () => {
+ if (state.hoveredIndex < 0) return;
+ window.open(state.points[state.hoveredIndex].url, "_blank", "noopener");
+ });
+};
+
+const init = (): void => {
+ const payloadElement = document.getElementById("climb-attempt-points");
+ const canvas = document.querySelector<HTMLCanvasElement>("[data-climb-attempts-chart]");
+ const output = document.querySelector<HTMLOutputElement>("[data-attempts-hover]");
+ if (!payloadElement || !canvas || !output) return;
+ const points = JSON.parse(payloadElement.textContent ?? "[]") as ClimbAttemptPoint[];
+ if (points.length === 0) return;
+ const state: AttemptsChartState = { canvas, points, colors: resolveOfficialProfileColors(), output, hoveredIndex: -1 };
+ draw(state);
+ bindEvents(state);
+ window.addEventListener("resize", () => draw(state));
+};
+
+init();
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 74e0765..a933d7d 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -134,6 +134,14 @@ export interface AnalyticsPoint {
year: number;
}
+export interface ClimbAttemptPoint {
+ durationS: number;
+ averagePowerW: number;
+ date: string;
+ rideName: string;
+ url: string;
+}
+
export interface RateLimit {
limit15: number;
usage15: number;
diff --git a/web/templates.templ b/web/templates.templ
index aceeeee..6a0109f 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -563,19 +563,25 @@ templ ClimbDetailContent(data ClimbDetailPageData) {
if len(data.Attempts) == 0 {
<div class="empty">No attempts recorded yet. Open a ride containing this climb to record one.</div>
} else {
+ if len(data.AttemptPoints) > 0 {
+ <div class="analytics-chart"><canvas id="climb-attempts-chart" data-climb-attempts-chart tabindex="0" role="img" aria-label={ attemptsChartLabel(data.Climb.Name) }></canvas></div>
+ <output class="analytics-hover" data-attempts-hover aria-live="polite">Hover a point to inspect an attempt.</output>
+ }
<table>
- <thead><tr><th>Date</th><th>Ride</th><th class="numeric">Time</th><th class="numeric">Distance</th><th class="numeric">Cotacol</th></tr></thead>
+ <thead><tr><th>Date</th><th>Ride</th><th class="numeric">Time</th><th class="numeric">Avg power</th><th class="numeric">Distance</th><th class="numeric">Cotacol</th></tr></thead>
<tbody>
for _, attempt := range data.Attempts {
- <tr><td>{ attempt.Date }</td><td><a href={ templ.URL(rideDetailURL(attempt.RideID)) }>{ attempt.RideName }</a></td><td class="numeric">{ attempt.Duration }</td><td class="numeric">{ attempt.Distance }</td><td class="numeric">{ attempt.Cotacol }</td></tr>
+ <tr><td>{ attempt.Date }</td><td><a href={ templ.URL(rideDetailURL(attempt.RideID)) }>{ attempt.RideName }</a></td><td class="numeric">{ attempt.Duration }</td><td class="numeric">{ formatPower(attempt.AveragePowerW) }</td><td class="numeric">{ attempt.Distance }</td><td class="numeric">{ attempt.Cotacol }</td></tr>
}
</tbody>
</table>
}
</section>
@templ.JSONScript("climb-profile-data", data.Profile)
+ @templ.JSONScript("climb-attempt-points", data.AttemptPoints)
@templ.JSONScript("climb-endpoints", data.Endpoints)
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="/static/climb-profile.js"></script>
+ <script type="module" src="/static/climb-attempts.js"></script>
<script type="module" src="/static/climb-map.js"></script>
}
diff --git a/web/views.go b/web/views.go
index 5dfc0e6..af3dbc2 100644
--- a/web/views.go
+++ b/web/views.go
@@ -448,6 +448,14 @@ func formatElevation(meters float64) string {
return fmt.Sprintf("%.0f m", meters)
}
+// formatPower renders an attempt's average power; a missing power meter shows "-".
+func formatPower(watts *float64) string {
+ if watts == nil {
+ return "-"
+ }
+ return fmt.Sprintf("%.0f W", *watts)
+}
+
func formatCotacol(cotacol float64) string {
return fmt.Sprintf("%.1f", cotacol)
}
@@ -488,6 +496,10 @@ func officialProfileLabel(name string) string {
return "Elevation profile for " + name
}
+func attemptsChartLabel(name string) string {
+ return "Power vs time of recorded attempts at " + name
+}
+
func rideDetailColumnsClass(unmatchedClimbCount int) string {
if unmatchedClimbCount == 0 {
return "ride-detail-columns ride-detail-columns-single"