diff options
| -rw-r--r-- | db/migrations/20260925061236_ride_official_climb_attempts_average_power.sql | 6 | ||||
| -rw-r--r-- | db/schema.sql | 5 | ||||
| -rw-r--r-- | official_climb/attempt.go | 36 | ||||
| -rw-r--r-- | official_climb/attempt_test.go | 9 | ||||
| -rw-r--r-- | rideanalysis/attempts.go | 3 | ||||
| -rw-r--r-- | rideanalysis/attempts_test.go | 14 | ||||
| -rw-r--r-- | web/climbs.go | 70 | ||||
| -rw-r--r-- | web/climbs_test.go | 37 | ||||
| -rw-r--r-- | web/frontend/climb-attempts-logic.test.ts | 35 | ||||
| -rw-r--r-- | web/frontend/climb-attempts-logic.ts | 30 | ||||
| -rw-r--r-- | web/frontend/climb-attempts.ts | 148 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 8 | ||||
| -rw-r--r-- | web/templates.templ | 10 | ||||
| -rw-r--r-- | web/views.go | 12 |
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" |
