aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-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
4 files changed, 221 insertions, 0 deletions
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;