Deploy Stride with encrypted manual and scheduled Garmin sync
This commit is contained in:
+37
-4
@@ -1,4 +1,36 @@
|
||||
"use strict";
|
||||
const shareButton = document.getElementById("copy-run-link");
|
||||
if (shareButton) shareButton.addEventListener("click", async () => {
|
||||
const status = document.getElementById("share-status");
|
||||
const fallback = document.getElementById("share-fallback");
|
||||
const input = document.getElementById("share-link-value");
|
||||
shareButton.disabled = true;
|
||||
fallback.hidden = true;
|
||||
status.textContent = "Creating share link…";
|
||||
try {
|
||||
const response = await fetch(shareButton.dataset.url, {
|
||||
method: "POST", headers: {"X-CSRFToken": shareButton.dataset.csrf},
|
||||
});
|
||||
if (!response.ok || response.redirected) throw new Error("Share unavailable");
|
||||
const data = await response.json();
|
||||
input.value = data.url;
|
||||
document.getElementById("revoke-share").hidden = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.url);
|
||||
status.textContent = "Link copied! Anyone with the link can view this run.";
|
||||
} catch {
|
||||
// Clipboard permissions and non-HTTPS hosts can prevent automatic copying.
|
||||
fallback.hidden = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
status.textContent = "Link ready. Copy it below to share this run without sign-in.";
|
||||
}
|
||||
} catch {
|
||||
status.textContent = "Couldn’t create a share link. Please try again.";
|
||||
} finally {
|
||||
shareButton.disabled = false;
|
||||
}
|
||||
});
|
||||
const valid = v => typeof v === "number" && Number.isFinite(v);
|
||||
const paceLabel = v => {const s = Math.round(v); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;};
|
||||
async function showActivity() {
|
||||
@@ -67,6 +99,7 @@ async function showActivity() {
|
||||
document.getElementById("fit-route").disabled = true;
|
||||
}
|
||||
if (!window.Chart) return;
|
||||
Chart.defaults.color = "#a0aaac";
|
||||
const line = (label,key,colour,unit,axis="y") => ({label,key,borderColor:colour,unit,yAxisID:axis});
|
||||
const chart = (id, series, reverse=false) => {
|
||||
const canvas = document.getElementById(id);
|
||||
@@ -74,12 +107,12 @@ async function showActivity() {
|
||||
const message=document.createElement("p"); message.className="chart-empty";message.textContent="Not recorded by your watch for this activity.";canvas.replaceWith(message);return;
|
||||
}
|
||||
const datasets = series.map(s=>({...s,data:data.chart.filter(r=>valid(r.distance)).map(r=>({x:r.distance/1000,y:valid(r[s.key])?r[s.key]:null})),borderWidth:1.6,pointRadius:0,pointHitRadius:8,tension:.15,spanGaps:false}));
|
||||
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#edf0e9",drawOnChartArea:!right},border:{display:false},ticks:{color:"#7d847b",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
|
||||
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#7d847b",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
|
||||
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#30383b",drawOnChartArea:!right},border:{display:false},ticks:{color:"#a0aaac",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
|
||||
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#a0aaac",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
|
||||
};
|
||||
chart("pace-chart",[line("Pace","pace","#38795b","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
|
||||
chart("pace-chart",[line("Pace","pace","#ff9268","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
|
||||
chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]);
|
||||
chart("elevation-chart",[line("Elevation","elevation","#668b47","m"),line("Gradient","grade","#ba965a","%","y2")]);
|
||||
chart("elevation-chart",[line("Elevation","elevation","#a6c896","m"),line("Gradient","grade","#ba965a","%","y2")]);
|
||||
chart("cadence-chart",[line("Cadence","cadence","#5a83ab","spm"),line("Step length","stride_length","#9b83b3","m","y2")]);
|
||||
chart("power-chart",[line("Power","power","#b48a38","W")]);
|
||||
chart("dynamics-chart",[line("Vertical oscillation","vertical_oscillation","#699f96","cm"),line("Ground contact","ground_contact","#a57b9c","ms","y2")]);
|
||||
|
||||
+65
-2
@@ -4,7 +4,70 @@ if (volume && window.Chart) {
|
||||
const data = JSON.parse(volume.textContent);
|
||||
new Chart(document.getElementById("volume-chart"), {
|
||||
type: "bar",
|
||||
data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#294f3a" : "#dbe6bf"), hoverBackgroundColor: "#87a364", borderRadius: 5, maxBarThickness: 34}]},
|
||||
options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#f0f2eb"}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}}}
|
||||
data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#ff9268" : "#586367"), hoverBackgroundColor: "#ffb08b", borderRadius: 2, maxBarThickness: 34}]},
|
||||
options: {animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#a0aaac"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#30383b"}, ticks: {font: {size: 10}, color: "#a0aaac", callback: v => `${v} km`}}}}
|
||||
});
|
||||
}
|
||||
|
||||
async function showDashboardRoute() {
|
||||
const container = document.getElementById("dashboard-route-map");
|
||||
if (!container?.dataset.url) return;
|
||||
const status = document.getElementById("dashboard-route-status");
|
||||
const toggle = document.getElementById("toggle-route-replay");
|
||||
try {
|
||||
const response = await fetch(container.dataset.url);
|
||||
if (!response.ok) throw new Error("Route unavailable");
|
||||
const data = await response.json();
|
||||
const segments = data.segments.filter(segment => segment.length);
|
||||
if (!segments.length) { status.textContent = "No GPS route recorded for this run."; return; }
|
||||
const map = L.map(container, {scrollWheelZoom: false});
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
const route = L.polyline(segments, {color: "#ff9268", weight: 4, opacity: .85}).addTo(map);
|
||||
const fit = () => map.fitBounds(route.getBounds(), {padding: [28, 28], maxZoom: 16});
|
||||
fit();
|
||||
new ResizeObserver(() => { map.invalidateSize(); fit(); }).observe(container);
|
||||
const first = segments[0][0], last = segments.at(-1).at(-1);
|
||||
L.circleMarker(first, {radius: 6, color: "#fff", fillColor: "#70b992", fillOpacity: 1}).bindTooltip("Start").addTo(map);
|
||||
L.circleMarker(last, {radius: 6, color: "#fff", fillColor: "#ff9268", fillOpacity: 1}).bindTooltip("Finish").addTo(map);
|
||||
const runner = L.marker(first, {interactive: false, icon: L.divIcon({
|
||||
className: "dashboard-runner", html: '<span></span>', iconSize: [20,20], iconAnchor: [10,10],
|
||||
})}).addTo(map);
|
||||
// Measure each recorded segment separately so GPS gaps are never drawn across.
|
||||
let length = 0;
|
||||
const edges = [];
|
||||
for (const segment of segments) for (let i = 1; i < segment.length; i++) {
|
||||
const a = L.latLng(segment[i-1]), b = L.latLng(segment[i]), distance = a.distanceTo(b);
|
||||
if (distance > 0) { edges.push({a, b, start: length, end: length + distance}); length += distance; }
|
||||
}
|
||||
if (!length) { status.textContent = "Recorded GPS location. No movement to replay."; return; }
|
||||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let paused = preference.matches, elapsed = 0, previous = null, frame = null;
|
||||
const tick = now => {
|
||||
if (paused || document.hidden) { previous = null; frame = null; return; }
|
||||
if (previous !== null) elapsed += now - previous;
|
||||
previous = now;
|
||||
const distance = ((elapsed % 20000) / 20000) * length;
|
||||
const edge = edges.find(edge => edge.end >= distance) || edges.at(-1);
|
||||
const fraction = (distance - edge.start) / (edge.end - edge.start);
|
||||
runner.setLatLng([edge.a.lat + (edge.b.lat-edge.a.lat)*fraction, edge.a.lng + (edge.b.lng-edge.a.lng)*fraction]);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const update = () => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = null; previous = null;
|
||||
toggle.textContent = paused ? "Play replay" : "Pause replay";
|
||||
status.textContent = paused ? "Replay paused. Explore the recorded route." : "Following the recorded route · 20-second replay";
|
||||
if (!paused && !document.hidden) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
toggle.hidden = false;
|
||||
toggle.onclick = () => { paused = !paused; update(); };
|
||||
preference.addEventListener("change", event => { paused = event.matches; update(); });
|
||||
document.addEventListener("visibilitychange", update);
|
||||
update();
|
||||
} catch {
|
||||
status.textContent = "Couldn’t load the route. Refresh to try again.";
|
||||
}
|
||||
}
|
||||
showDashboardRoute();
|
||||
|
||||
+19
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user