diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 13:19:22 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 13:19:22 +0200 |
| commit | be78280c669a04c112afaf1801f22ed2eec7c55a (patch) | |
| tree | 0d630af1d0502aae12b1a8cf4ee609a9cba9e4e3 /web | |
| parent | e7bd55d20974f4ee5b892cd2e81c62b5c5408920 (diff) | |
refactor(web): establish JavaScript module tooling
Diffstat (limited to 'web')
| -rw-r--r-- | web/server_test.go | 27 | ||||
| -rw-r--r-- | web/static/official-climb-profile-logic.js | 90 | ||||
| -rw-r--r-- | web/static/official-climb-profile.js | 261 | ||||
| -rw-r--r-- | web/static/ride-detail.js | 137 | ||||
| -rw-r--r-- | web/static/sync.js | 103 | ||||
| -rw-r--r-- | web/static_test/official-climb-profile-logic.test.js | 10 | ||||
| -rw-r--r-- | web/templates.templ | 104 | ||||
| -rw-r--r-- | web/templates_templ.go | 12 | ||||
| -rw-r--r-- | web/types/global.d.ts | 3 |
9 files changed, 419 insertions, 328 deletions
diff --git a/web/server_test.go b/web/server_test.go index a4ee0b7..654d6d5 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -134,10 +134,10 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `class="ride-detail-columns`) assert.NotContains(t, body, "Climbs to match") assert.Contains(t, body, `class="ride-detail-main"`) - assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script>`) - assert.Contains(t, body, `<script src="/static/official-climb-profile-logic.js" defer></script>`) - assert.Contains(t, body, `<script src="/static/official-climb-profile.js" defer></script>`) - assert.Contains(t, body, `<script src="/static/ride-detail.js" defer></script>`) + assert.Contains(t, body, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>`) + assert.Contains(t, body, `<script type="module" src="/static/ride-detail.js"></script>`) + assert.NotContains(t, body, `<script src="/static/official-climb-profile-logic.js"`) + assert.NotContains(t, body, `<script src="/static/official-climb-profile.js"`) staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil) staticResponse := httptest.NewRecorder() @@ -168,6 +168,14 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Equal(t, http.StatusOK, logicStaticResponse.Code) assert.Equal(t, "text/javascript; charset=utf-8", logicStaticResponse.Header().Get("Content-Type")) assert.NotEmpty(t, logicStaticResponse.Body.String()) + + syncStaticRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil) + syncStaticResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(syncStaticResponse, syncStaticRequest) + + assert.Equal(t, http.StatusOK, syncStaticResponse.Code) + assert.Equal(t, "text/javascript; charset=utf-8", syncStaticResponse.Header().Get("Content-Type")) + assert.NotEmpty(t, syncStaticResponse.Body.String()) } func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) { @@ -386,7 +394,16 @@ func TestSyncPageIncludesProgressUIWhenAuthorized(t *testing.T) { assert.Contains(t, body, `id="sync-form"`) assert.Contains(t, body, `id="sync-progress"`) assert.Contains(t, body, `<progress id="sync-progress-bar"`) - assert.Contains(t, body, `text/event-stream`) + assert.Contains(t, body, `<script type="module" src="/static/sync.js"></script>`) + assert.NotContains(t, body, `const consumeEvents = async`) + + syncRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil) + syncResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(syncResponse, syncRequest) + + assert.Equal(t, http.StatusOK, syncResponse.Code) + assert.Equal(t, "text/javascript; charset=utf-8", syncResponse.Header().Get("Content-Type")) + assert.Contains(t, syncResponse.Body.String(), `text/event-stream`) } func TestSyncRedirectsToOAuthWhenUnauthenticated(t *testing.T) { diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js index c84960b..87dcef8 100644 --- a/web/static/official-climb-profile-logic.js +++ b/web/static/official-climb-profile-logic.js @@ -1,47 +1,45 @@ -(() => { - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const profileStepSizesM = [100, 200, 500, 1000]; - const displayStepForLength = (lengthM) => profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; - const profileBandForSlope = (slopePercent) => { - if (slopePercent < 0) return "downhill"; - if (slopePercent < 3) return "0-3"; - if (slopePercent < 6) return "3-6"; - if (slopePercent < 9) return "6-9"; - if (slopePercent < 12) return "9-12"; - return "12-plus"; +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const profileStepSizesM = [100, 200, 500, 1000]; +const displayStepForLength = (lengthM) => + profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; +const profileBandForSlope = (slopePercent) => { + if (slopePercent < 0) return "downhill"; + if (slopePercent < 3) return "0-3"; + if (slopePercent < 6) return "3-6"; + if (slopePercent < 9) return "6-9"; + if (slopePercent < 12) return "9-12"; + return "12-plus"; +}; +const officialProfileSections = (points, startIndex, endIndex, stepM) => { + const startDistanceM = points[startIndex].distanceKm * 1000; + const endDistanceM = points[endIndex].distanceKm * 1000; + const sections = []; + let pointIndex = startIndex; + const elevationAtDistance = (distanceM) => { + while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; + const nextIndex = Math.min(pointIndex + 1, endIndex); + const first = points[pointIndex]; + const next = points[nextIndex]; + const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; + if (distanceSpan <= 0) return first.elevationM; + const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; + return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); }; - const officialProfileSections = (points, startIndex, endIndex, stepM) => { - const startDistanceM = points[startIndex].distanceKm * 1000; - const endDistanceM = points[endIndex].distanceKm * 1000; - const sections = []; - let pointIndex = startIndex; - const elevationAtDistance = (distanceM) => { - while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++; - const nextIndex = Math.min(pointIndex + 1, endIndex); - const first = points[pointIndex]; - const next = points[nextIndex]; - const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000; - if (distanceSpan <= 0) return first.elevationM; - const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan; - return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM); - }; - for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { - const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); - const startElevation = elevationAtDistance(sectionStartM); - const endElevation = elevationAtDistance(sectionEndM); - const slopePercent = (endElevation - startElevation) / (sectionEndM - sectionStartM) * 100; - sections.push({ - startDistanceKm: sectionStartM / 1000, - endDistanceKm: sectionEndM / 1000, - startElevation, - endElevation, - slopePercent, - band: profileBandForSlope(slopePercent), - }); - } - return sections; - }; - const api = { displayStepForLength, profileBandForSlope, officialProfileSections }; - if (typeof module !== "undefined" && module.exports) module.exports = api; - else window.OfficialClimbProfileLogic = api; -})(); + for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) { + const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM); + const startElevation = elevationAtDistance(sectionStartM); + const endElevation = elevationAtDistance(sectionEndM); + const slopePercent = ((endElevation - startElevation) / (sectionEndM - sectionStartM)) * 100; + sections.push({ + startDistanceKm: sectionStartM / 1000, + endDistanceKm: sectionEndM / 1000, + startElevation, + endElevation, + slopePercent, + band: profileBandForSlope(slopePercent), + }); + } + return sections; +}; + +export { displayStepForLength, profileBandForSlope, officialProfileSections }; diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js index 603edfe..3f0a229 100644 --- a/web/static/official-climb-profile.js +++ b/web/static/official-climb-profile.js @@ -1,6 +1,24 @@ -(() => { - const createOfficialClimbProfileController = (points) => { - const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic; +import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; + +export class OfficialClimbProfileController { + constructor(points) { + this.points = points; + this.colors = this.resolveColors(); + this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => /** @type {HTMLDetailsElement} */ (card)); + for (const card of this.cards) { + card.addEventListener("toggle", () => { + if (!card.open) return; + for (const other of this.cards) { + if (other !== card) other.open = false; + } + /** @type {HTMLCanvasElement | null} */ + const profileCanvas = card.querySelector("[data-official-profile]"); + if (profileCanvas) this.drawOfficialProfile(profileCanvas); + }); + } + } + + resolveColors() { const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); @@ -21,127 +39,128 @@ accent: resolveColor("--color-accent"), }; colorProbe.remove(); - const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; - const drawOfficialProfile = (profileCanvas) => { - const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10); - const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10); - if (!Number.isInteger(startIndex) || !Number.isInteger(endIndex) || startIndex < 0 || endIndex >= points.length || startIndex >= endIndex) return; - const rect = profileCanvas.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - const context = profileCanvas.getContext("2d"); - if (!context) return; - const ratio = window.devicePixelRatio || 1; - profileCanvas.width = Math.floor(rect.width * ratio); - profileCanvas.height = Math.floor(rect.height * ratio); - context.setTransform(ratio, 0, 0, ratio, 0, 0); - const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 }; - const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; - const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); - if (sections.length === 0) return; - let minElevation = sections[0].startElevation; - let maxElevation = minElevation; - for (const section of sections) { - minElevation = Math.min(minElevation, section.startElevation, section.endElevation); - maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); - } - const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); - minElevation -= elevationPadding; - maxElevation += elevationPadding; - const minDistance = sections[0].startDistanceKm; - const maxDistance = sections[sections.length - 1].endDistanceKm; - const distanceSpan = Math.max(maxDistance - minDistance, 0.1); - const elevationSpan = Math.max(maxElevation - minElevation, 1); - const xForDistance = (distanceKm) => plot.left + (distanceKm - minDistance) / distanceSpan * (plot.right - plot.left); - const yForElevation = (elevationM) => plot.bottom - (elevationM - minElevation) / elevationSpan * (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.strokeStyle = colors.grid; - context.lineWidth = 1; + return colors; + } + + drawOfficialProfile(profileCanvas) { + const { points, colors } = this; + const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10); + const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10); + if ( + !Number.isInteger(startIndex) || + !Number.isInteger(endIndex) || + startIndex < 0 || + endIndex >= points.length || + startIndex >= endIndex + ) + return; + const rect = profileCanvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const context = profileCanvas.getContext("2d"); + if (!context) return; + const ratio = window.devicePixelRatio || 1; + profileCanvas.width = Math.floor(rect.width * ratio); + profileCanvas.height = Math.floor(rect.height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 }; + const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; + const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); + if (sections.length === 0) return; + let minElevation = sections[0].startElevation; + let maxElevation = minElevation; + for (const section of sections) { + minElevation = Math.min(minElevation, section.startElevation, section.endElevation); + maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation); + } + const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8); + minElevation -= elevationPadding; + maxElevation += elevationPadding; + const minDistance = sections[0].startDistanceKm; + const maxDistance = sections[sections.length - 1].endDistanceKm; + const distanceSpan = Math.max(maxDistance - minDistance, 0.1); + const elevationSpan = Math.max(maxElevation - minElevation, 1); + const xForDistance = (distanceKm) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left); + const yForElevation = (elevationM) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (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.strokeStyle = colors.grid; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(plot.left, plot.bottom); + context.lineTo(plot.right, plot.bottom); + context.stroke(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); context.beginPath(); - context.moveTo(plot.left, plot.bottom); - context.lineTo(plot.right, plot.bottom); - context.stroke(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - context.beginPath(); - context.moveTo(startX, plot.bottom); - context.lineTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.lineTo(endX, plot.bottom); - context.closePath(); - context.globalAlpha = 0.25; - context.fillStyle = colors[section.band]; - context.fill(); - context.globalAlpha = 1; - context.beginPath(); - context.moveTo(startX, yForElevation(section.startElevation)); - context.lineTo(endX, yForElevation(section.endElevation)); - context.strokeStyle = colors[section.band]; - context.lineWidth = 2.5; - context.stroke(); - } - context.font = "9px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "bottom"; - context.textAlign = "center"; - context.save(); + context.moveTo(startX, plot.bottom); + context.lineTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.lineTo(endX, plot.bottom); + context.closePath(); + context.globalAlpha = 0.25; + context.fillStyle = colors[section.band]; + context.fill(); + context.globalAlpha = 1; context.beginPath(); - context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); - context.clip(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - const label = `${section.slopePercent.toFixed(1)}%`; - const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4); - context.fillText(label, (startX + endX) / 2, labelY); + context.moveTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.strokeStyle = colors[section.band]; + context.lineWidth = 2.5; + context.stroke(); + } + context.font = "9px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "bottom"; + context.textAlign = "center"; + context.save(); + context.beginPath(); + context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); + context.clip(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); + const label = `${section.slopePercent.toFixed(1)}%`; + const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4); + context.fillText(label, (startX + endX) / 2, labelY); + } + context.restore(); + let topElevation = sections[0].startElevation; + let topDistance = sections[0].startDistanceKm; + for (const section of sections) { + if (section.startElevation > topElevation) { + topElevation = section.startElevation; + topDistance = section.startDistanceKm; } - context.restore(); - let topElevation = sections[0].startElevation; - let topDistance = sections[0].startDistanceKm; - for (const section of sections) { - if (section.startElevation > topElevation) { - topElevation = section.startElevation; - topDistance = section.startDistanceKm; - } - if (section.endElevation > topElevation) { - topElevation = section.endElevation; - topDistance = section.endDistanceKm; - } + if (section.endElevation > topElevation) { + topElevation = section.endElevation; + topDistance = section.endDistanceKm; } - context.fillStyle = colors.accent; - context.beginPath(); - context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); - context.fill(); - context.font = "11px system-ui, sans-serif"; - context.fillStyle = colors.subtle; - context.textBaseline = "top"; - context.textAlign = "left"; - context.fillText(formatDistance(0), plot.left, rect.height - 16); - context.textAlign = "right"; - context.fillText(formatDistance(maxDistance - minDistance), plot.right, rect.height - 16); - }; - const cards = [...document.querySelectorAll("[data-official-climb-card]")]; - for (const card of cards) { - card.addEventListener("toggle", () => { - if (!card.open) return; - for (const other of cards) { - if (other !== card) other.open = false; - } - const profileCanvas = card.querySelector("[data-official-profile]"); - if (profileCanvas) drawOfficialProfile(profileCanvas); - }); } - return { - redrawOpen: () => { - for (const profileCanvas of document.querySelectorAll("[data-official-profile]")) { - const card = profileCanvas.closest("[data-official-climb-card]"); - if (card?.open) drawOfficialProfile(profileCanvas); - } - }, - }; - }; + context.fillStyle = colors.accent; + context.beginPath(); + context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI); + context.fill(); + context.font = "11px system-ui, sans-serif"; + context.fillStyle = colors.subtle; + context.textBaseline = "top"; + context.textAlign = "left"; + context.fillText(this.formatDistance(0), plot.left, rect.height - 16); + context.textAlign = "right"; + context.fillText(this.formatDistance(maxDistance - minDistance), plot.right, rect.height - 16); + } - window.createOfficialClimbProfileController = createOfficialClimbProfileController; -})(); + formatDistance(distance) { + return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + } + + redrawOpen() { + /** @type {NodeListOf<HTMLCanvasElement>} */ + const profileCanvases = document.querySelectorAll("[data-official-profile]"); + for (const profileCanvas of profileCanvases) { + const card = /** @type {HTMLDetailsElement | null} */ (profileCanvas.closest("[data-official-climb-card]")); + if (card?.open) this.drawOfficialProfile(profileCanvas); + } + } +} diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js index 70878a1..75cb54a 100644 --- a/web/static/ride-detail.js +++ b/web/static/ride-detail.js @@ -1,10 +1,15 @@ -(() => { +import { OfficialClimbProfileController } from "./official-climb-profile.js"; + +export const mountRideDetail = () => { const mapElement = document.getElementById("ride-map"); - const routeElement = document.getElementById("ride-route"); - const profileElement = document.getElementById("ride-profile"); - const canvas = document.getElementById("ride-profile-chart"); + const routeElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-route")); + const profileElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-profile")); + const canvas = /** @type {HTMLCanvasElement | null} */ (document.getElementById("ride-profile-chart")); + /** @type {HTMLElement | null} */ const hoverOutput = document.getElementById("ride-profile-hover"); if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return; + const leaflet = window.L; + if (!leaflet) return; const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); @@ -30,15 +35,17 @@ colorProbe.remove(); const route = JSON.parse(routeElement.textContent); const profile = JSON.parse(profileElement.textContent); - const map = L.map(mapElement); - const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + const map = leaflet.map(mapElement); + const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', }); tiles.addTo(map); - const routeLayer = L.geoJSON(route, { - style: { color: colors.accent, weight: 4, opacity: 0.9 }, - }).addTo(map); + const routeLayer = leaflet + .geoJSON(route, { + style: { color: colors.accent, weight: 4, opacity: 0.9 }, + }) + .addTo(map); const bounds = routeLayer.getBounds(); if (bounds.isValid()) { map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); @@ -46,29 +53,52 @@ const points = profile.points || []; if (points.length === 0) return; - const climbItems = [...document.querySelectorAll("[data-climb-item]")]; + const climbItems = [...document.querySelectorAll("[data-climb-item]")].map((item) => /** @type {HTMLElement} */ (item)); const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex, 10)); + /** @type {HTMLButtonElement | null} */ const previousClimbButton = document.querySelector("[data-climb-previous]"); + /** @type {HTMLButtonElement | null} */ const nextClimbButton = document.querySelector("[data-climb-next]"); + /** @type {HTMLElement | null} */ const climbPosition = document.querySelector("[data-climb-position]"); + /** @type {(import("leaflet").Polyline | null)[]} */ const climbLayers = []; for (const climb of profile.climbs || []) { - if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) { + if ( + !Number.isInteger(climb.startIndex) || + !Number.isInteger(climb.endIndex) || + climb.startIndex < 0 || + climb.endIndex >= points.length || + climb.startIndex >= climb.endIndex + ) { climbLayers.push(null); continue; } const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]); - climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map)); + climbLayers.push( + leaflet + .polyline(coordinates, { + color: colors.climbRoute, + weight: 7, + opacity: 0.65, + lineCap: "round", + lineJoin: "round", + interactive: false, + }) + .addTo(map), + ); } - const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], { - color: colors.forest, - fillColor: colors.accent, - fillOpacity: 0, - opacity: 0, - radius: 7, - weight: 3, - interactive: false, - }).addTo(map); + const routeCursor = leaflet + .circleMarker([points[0].latitude, points[0].longitude], { + color: colors.forest, + fillColor: colors.accent, + fillOpacity: 0, + opacity: 0, + radius: 7, + weight: 3, + interactive: false, + }) + .addTo(map); const context = canvas.getContext("2d"); if (!context) return; @@ -96,14 +126,14 @@ const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; const formatElevation = (elevation) => `${Math.round(elevation)} m`; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const officialProfileController = window.createOfficialClimbProfileController(points); + const officialProfileController = new OfficialClimbProfileController(points); const xForDistance = (distance) => { const span = Math.max(maxDistance - minDistance, 1); - return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left); + return state.plot.left + ((distance - minDistance) / span) * (state.plot.right - state.plot.left); }; const yForElevation = (elevation) => { const span = Math.max(maxElevation - minElevation, 1); - return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top); + return state.plot.bottom - ((elevation - minElevation) / span) * (state.plot.bottom - state.plot.top); }; const nearestPointIndex = (distance) => { let low = 0; @@ -122,7 +152,7 @@ let nearestDistance = Infinity; for (let index = 0; index < points.length; index++) { const point = points[index]; - const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); + const distance = leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); if (distance < nearestDistance) { nearestIndex = index; nearestDistance = distance; @@ -130,7 +160,7 @@ } return nearestIndex; }; - const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")]; + const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form)); let activeBoundary = null; const boundaryLabel = (index) => { const point = points[index]; @@ -164,20 +194,37 @@ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++; const endElevation = elevationAtDistance(pointIndex, segmentEndM); const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM); - if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2; + if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2; } return score; }; const climbMetrics = (index) => { const bounds = state.climbBounds[index]; - if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null; + if ( + !bounds || + !Number.isInteger(bounds.startIndex) || + !Number.isInteger(bounds.endIndex) || + bounds.startIndex < 0 || + bounds.endIndex >= points.length || + bounds.startIndex >= bounds.endIndex + ) + return null; const start = points[bounds.startIndex]; const end = points[bounds.endIndex]; const distanceKm = end.distanceKm - start.distanceKm; const elevationGain = end.elevationM - start.elevationM; const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0; - const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0; - return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) }; + const score = distanceKm > 0 ? ((Math.abs(elevationGain) * elevationGain) / (distanceKm * 1000)) * 10 : 0; + return { + start, + end, + distanceKm, + elevationGain, + slope, + score, + cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), + category: categoryForScore(score), + }; }; const clearBoundarySelection = () => { activeBoundary = null; @@ -187,6 +234,7 @@ }; const updateBoundaryPreview = (form) => { const preview = form.querySelector("[data-boundary-preview]"); + /** @type {HTMLElement} */ const item = form.closest("[data-climb-item]"); const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); const metrics = climbMetrics(climbIndex); @@ -216,10 +264,11 @@ const chooseBoundary = (index) => { if (!activeBoundary) return; const { form, target } = activeBoundary; + /** @type {HTMLElement} */ const item = form.closest("[data-climb-item]"); const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); - const input = form.querySelector(`[data-boundary-input="${target}"]`); - const output = form.querySelector(`[data-boundary-output="${target}"]`); + const input = /** @type {HTMLInputElement} */ (form.querySelector(`[data-boundary-input="${target}"]`)); + const output = /** @type {HTMLOutputElement} */ (form.querySelector(`[data-boundary-output="${target}"]`)); input.value = index; output.textContent = boundaryLabel(index); state.climbBounds[climbIndex][`${target}Index`] = index; @@ -229,14 +278,16 @@ showPoint(index); }; for (const form of boundaryForms) { + /** @type {HTMLElement} */ const item = form.closest("[data-climb-item]"); const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); state.climbBounds[climbIndex] = { - startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10), - endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10), + startIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="start"]')).value, 10), + endIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="end"]')).value, 10), }; updateBoundaryPreview(form); - for (const button of form.querySelectorAll("[data-boundary-button]")) { + const boundaryButtons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button)); + for (const button of boundaryButtons) { button.addEventListener("click", () => { clearBoundarySelection(); activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource }; @@ -340,7 +391,7 @@ context.textAlign = "center"; context.textBaseline = "top"; for (let step = 0; step <= 4; step++) { - const distance = minDistance + step / 4 * (maxDistance - minDistance); + const distance = minDistance + (step / 4) * (maxDistance - minDistance); context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10); } @@ -381,7 +432,7 @@ const climbBounds = state.climbBounds[index]; if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return; const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1); - const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude])); + const mapBounds = leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude])); if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 }); }; const updateClimbFocus = (index, zoom) => { @@ -415,21 +466,21 @@ clearHover(); return; } - const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance); + const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance); showPoint(nearestPointIndex(distance)); }); canvas.addEventListener("pointerleave", clearHover); canvas.addEventListener("pointercancel", clearHover); canvas.addEventListener("click", (event) => { - if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return; + if (activeBoundary?.source !== "profile" || !state.plot) return; const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; if (x < state.plot.left || x > state.plot.right) return; - const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance); + const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance); chooseBoundary(nearestPointIndex(distance)); }); map.on("click", (event) => { - if (!activeBoundary || activeBoundary.source !== "map") return; + if (activeBoundary?.source !== "map") return; chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng)); }); canvas.addEventListener("keydown", (event) => { @@ -445,4 +496,6 @@ }); if (climbItems.length > 0) updateClimbFocus(0, false); else draw(); -})(); +}; + +mountRideDetail(); diff --git a/web/static/sync.js b/web/static/sync.js new file mode 100644 index 0000000..3cf6cca --- /dev/null +++ b/web/static/sync.js @@ -0,0 +1,103 @@ +export const mountSync = () => { + const form = /** @type {HTMLFormElement | null} */ (document.getElementById("sync-form")); + if (!form) return; + /** @type {HTMLButtonElement} */ + const button = form.querySelector("button[type=submit]"); + /** @type {HTMLElement} */ + const progressPanel = document.getElementById("sync-progress"); + const progressBar = /** @type {HTMLProgressElement} */ (document.getElementById("sync-progress-bar")); + /** @type {HTMLElement} */ + const progressCount = document.getElementById("sync-progress-count"); + /** @type {HTMLElement} */ + const progressDetails = document.getElementById("sync-progress-details"); + /** @type {HTMLElement} */ + const notice = document.getElementById("sync-notice"); + /** @type {HTMLElement} */ + const error = document.getElementById("sync-error"); + + const showError = (message) => { + error.textContent = message; + error.hidden = false; + }; + const updateProgress = (progress) => { + progressPanel.hidden = false; + progressBar.max = Math.max(progress.total, 1); + progressBar.value = progress.completed; + progressCount.textContent = `${progress.completed} / ${progress.total}`; + progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`; + }; + const handleEvent = (eventName, data) => { + if (eventName === "progress") { + updateProgress(data); + return false; + } + if (eventName === "complete") { + updateProgress(data); + notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`; + notice.hidden = false; + return true; + } + if (eventName === "error") { + if (data.progress) updateProgress(data.progress); + throw new Error(data.message || "Strava sync failed"); + } + return false; + }; + const consumeEvents = async (response) => { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + const consumeBlock = (block) => { + let eventName = "message"; + let data = ""; + for (const line of block.split(/\r?\n/)) { + if (line.startsWith("event: ")) eventName = line.slice(7); + if (line.startsWith("data: ")) data += line.slice(6); + } + if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed; + }; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = blocks.pop(); + for (const block of blocks) consumeBlock(block); + if (done) break; + } + if (buffer.trim()) consumeBlock(buffer); + if (!completed) throw new Error("Strava sync ended before completion"); + }; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + button.disabled = true; + button.textContent = "Syncing..."; + notice.hidden = true; + error.hidden = true; + progressPanel.hidden = false; + progressCount.textContent = "0 / 0"; + progressDetails.textContent = "Preparing import..."; + try { + const response = await fetch(form.action, { + method: "POST", + body: new FormData(form), + headers: { Accept: "text/event-stream" }, + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`); + if (!response.body) throw new Error("The browser does not support streaming responses"); + await consumeEvents(response); + } catch (caught) { + showError(caught instanceof Error ? caught.message : "Strava sync failed"); + } finally { + button.disabled = false; + button.textContent = "Sync rides"; + } + }); +}; + +mountSync(); diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js index f663a65..9976c66 100644 --- a/web/static_test/official-climb-profile-logic.test.js +++ b/web/static_test/official-climb-profile-logic.test.js @@ -1,10 +1,6 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const { - displayStepForLength, - profileBandForSlope, - officialProfileSections, -} = require("../static/official-climb-profile-logic.js"); +import test from "node:test"; +import assert from "node:assert/strict"; +import { displayStepForLength, profileBandForSlope, officialProfileSections } from "../static/official-climb-profile-logic.js"; test("selects a display step that keeps long profiles readable", () => { assert.equal(displayStepForLength(2_400), 100); diff --git a/web/templates.templ b/web/templates.templ index fe5c363..68327a1 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -170,10 +170,8 @@ templ RideDetailContent(data RideDetailView) { if data.HasRoute { @templ.JSONScript("ride-route", data.Route) @templ.JSONScript("ride-profile", data.Profile) - <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" defer></script> - <script src="/static/official-climb-profile-logic.js" defer></script> - <script src="/static/official-climb-profile.js" defer></script> - <script src="/static/ride-detail.js" defer></script> + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> + <script type="module" src="/static/ride-detail.js"></script> } } @@ -214,101 +212,5 @@ templ SyncContent(data SyncPageData) { </div> } </section> - <script> - (() => { - const form = document.getElementById("sync-form"); - if (!form) return; - const button = form.querySelector("button[type=submit]"); - const progressPanel = document.getElementById("sync-progress"); - const progressBar = document.getElementById("sync-progress-bar"); - const progressCount = document.getElementById("sync-progress-count"); - const progressDetails = document.getElementById("sync-progress-details"); - const notice = document.getElementById("sync-notice"); - const error = document.getElementById("sync-error"); - - const showError = (message) => { - error.textContent = message; - error.hidden = false; - }; - const updateProgress = (progress) => { - progressPanel.hidden = false; - progressBar.max = Math.max(progress.total, 1); - progressBar.value = progress.completed; - progressCount.textContent = `${progress.completed} / ${progress.total}`; - progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`; - }; - const handleEvent = (eventName, data) => { - if (eventName === "progress") { - updateProgress(data); - return false; - } - if (eventName === "complete") { - updateProgress(data); - notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`; - notice.hidden = false; - return true; - } - if (eventName === "error") { - if (data.progress) updateProgress(data.progress); - throw new Error(data.message || "Strava sync failed"); - } - return false; - }; - const consumeEvents = async (response) => { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let completed = false; - const consumeBlock = (block) => { - let eventName = "message"; - let data = ""; - for (const line of block.split(/\r?\n/)) { - if (line.startsWith("event: ")) eventName = line.slice(7); - if (line.startsWith("data: ")) data += line.slice(6); - } - if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed; - }; - while (true) { - const { value, done } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const blocks = buffer.split(/\r?\n\r?\n/); - buffer = blocks.pop(); - for (const block of blocks) consumeBlock(block); - if (done) break; - } - if (buffer.trim()) consumeBlock(buffer); - if (!completed) throw new Error("Strava sync ended before completion"); - }; - - form.addEventListener("submit", async (event) => { - event.preventDefault(); - button.disabled = true; - button.textContent = "Syncing..."; - notice.hidden = true; - error.hidden = true; - progressPanel.hidden = false; - progressCount.textContent = "0 / 0"; - progressDetails.textContent = "Preparing import..."; - try { - const response = await fetch(form.action, { - method: "POST", - body: new FormData(form), - headers: { Accept: "text/event-stream" }, - }); - if (response.redirected) { - window.location.assign(response.url); - return; - } - if (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`); - if (!response.body) throw new Error("The browser does not support streaming responses"); - await consumeEvents(response); - } catch (caught) { - showError(caught instanceof Error ? caught.message : "Strava sync failed"); - } finally { - button.disabled = false; - button.textContent = "Sync rides"; - } - }); - })(); - </script> + <script type="module" src="/static/sync.js"></script> } diff --git a/web/templates_templ.go b/web/templates_templ.go index eee38dd..f7a89d8 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -912,7 +912,7 @@ func RideDetailContent(data RideDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\" defer></script> <script src=\"/static/official-climb-profile-logic.js\" defer></script> <script src=\"/static/official-climb-profile.js\" defer></script> <script src=\"/static/ride-detail.js\" defer></script>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script> <script type=\"module\" src=\"/static/ride-detail.js\"></script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -983,7 +983,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var59 string templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(data.Notice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 189, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 187, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1007,7 +1007,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var60 string templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 194, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 192, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { @@ -1049,7 +1049,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var62 string templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 205, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 203, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { @@ -1062,7 +1062,7 @@ func SyncContent(data SyncPageData) templ.Component { var templ_7745c5c3_Var63 string templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 206, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates.templ`, Line: 204, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { @@ -1073,7 +1073,7 @@ func SyncContent(data SyncPageData) templ.Component { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script type=\"module\" src=\"/static/sync.js\"></script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/types/global.d.ts b/web/types/global.d.ts new file mode 100644 index 0000000..2f4a39b --- /dev/null +++ b/web/types/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + L?: typeof import("leaflet"); +} |