aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-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
8 files changed, 330 insertions, 20 deletions
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"