Get a GPX from Strava
Strava removes the timestamps when you export someone else's activity. Without them there is nothing to replay. This bookmark rebuilds the file with its times intact.
How to use it
- Show your bookmarks bar: ⌘ Shift B on a Mac, Ctrl Shift B on Windows.
- Drag the orange button onto it.
- Log in to Strava and open the first runner's activity.
The address should look like
strava.com/activities/1234567890. - Click the bookmark. A GPX file named after the athlete lands in your downloads.
- Do the same on the second runner's activity.
- Open the viewer and drop both files in.
What it does with your data
It runs in your browser, on the Strava page you are already looking at. It reads the same data Strava loads to draw its own charts, and saves it as a file. Nothing is sent to Dualtrail or anyone else. The viewer works the same way: your files never leave your computer.
One thing to know
Strava does not show what time of day someone else started. So every file starts at midnight on the activity's date, with the real elapsed times after that.
For two runners in the same race that is exactly right, because both start at zero. If they started in different waves, the replay still starts them together.
If it does not work
- "Could not read the activity streams"
- You are logged out, or the activity is private or for followers only.
- "This activity has no GPS track"
- It was recorded indoors or entered by hand.
- Nothing happens at all
- You are not on an activity page. Open the activity itself, not the athlete's profile or the feed.
The code
This is everything the bookmark runs.
Show the code
(async () => {
"use strict";
const say = (m) => console.log("[strava-gpx] " + m);
function toast(text, ok = true) {
let el = document.getElementById("dualtrail-toast");
if (!el) {
el = document.createElement("div");
el.id = "dualtrail-toast";
el.style.cssText = "position:fixed;z-index:2147483647;right:18px;bottom:18px;max-width:340px;padding:14px 18px;border-radius:12px;font:600 15px/1.45 system-ui,sans-serif;color:#fff;box-shadow:0 8px 30px rgba(0,0,0,.45);transition:opacity .3s;white-space:pre-line";
document.body.appendChild(el);
}
el.style.background = ok ? "#12161d" : "#5a1414";
el.style.border = `1px solid ${ok ? "#2f9e58" : "#c0392b"}`;
el.textContent = text;
el.style.opacity = "1";
clearTimeout(el._t);
el._t = setTimeout(() => {
el.style.opacity = "0";
}, 5e3);
}
const id = (location.pathname.match(/\/activities\/(\d+)/) || [])[1];
if (!id) {
alert("Open a Strava activity page first (strava.com/activities/\u2026), then run this.");
return;
}
const types = ["latlng", "time", "altitude", "heartrate", "cadence"];
const url = `/activities/${id}/streams?${types.map((t) => "stream_types[]=" + t).join("&")}`;
let s;
try {
const res = await fetch(url, { credentials: "same-origin", headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
s = await res.json();
} catch (e) {
alert("Could not read the activity streams (" + e.message + ").\nMake sure you are logged in and the activity is visible to you.");
return;
}
if (!s.latlng || !s.latlng.length) {
toast("This activity has no GPS track (indoor or manual entry?).", false);
return;
}
if (!s.time || s.time.length !== s.latlng.length) {
toast("No usable time stream here, so the GPX would have no timestamps.", false);
return;
}
const MONTHS = "january february march april may june july august september october november december janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre januar februar marz april mai juni juli august september oktober november dezember".split(" ");
function activityDate() {
const el = document.querySelector(".details time") || document.querySelector("time");
if (!el) return null;
const text = el.textContent.trim();
let d = new Date(text.replace(/^[^,]+,\s*/, ""));
if (!isNaN(d)) return { d, text };
const flat = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
const day = (flat.match(/\b(\d{1,2})\b/) || [])[1];
const year = (flat.match(/\b(20\d{2})\b/) || [])[1];
const mi = MONTHS.findIndex((m) => flat.includes(m));
if (day && year && mi >= 0) {
d = new Date(+year, mi % 12, +day);
if (!isNaN(d)) return { d, text };
}
return null;
}
const found = activityDate();
let startIsReal = !!found;
let start = found ? found.d : new Date((new Date()).setHours(0, 0, 0, 0));
if (found) {
say(`activity date: ${found.text} -> base ${start.toISOString()}`);
} else {
say("WARNING: could not read the activity date from this page.");
say("Using today's date. Elapsed times are still correct, but do not");
say("compare this file against one exported on a different day.");
}
const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]);
function athleteName() {
const nav = document.querySelector('a[href^="/athletes/"][href*="/training"]');
const me = nav ? (nav.getAttribute("href").match(/\/athletes\/(\d+)/) || [])[1] : null;
const seen = [];
for (const a of document.querySelectorAll('a[href^="/athletes/"]')) {
const aid = (a.getAttribute("href").match(/^\/athletes\/(\d+)$/) || [])[1];
if (!aid || aid === me) continue;
const t = a.textContent.replace(/\s+/g, " ").trim();
if (t.length > 2 && !/profile|training|find friends|create/i.test(t)) seen.push(t);
}
if (!seen.length) return null;
const full = seen.filter((t) => t.includes(" "));
return (full.length ? full : seen).sort((x, y) => y.length - x.length)[0];
}
const athlete = athleteName();
const title = (document.querySelector(".activity-name, h1.text-title1") || {}).textContent || "";
const name = athlete || title.trim() || `Strava ${id}`;
say(`athlete: ${athlete || "(not found, using the activity title)"}`);
const t0 = start.getTime();
const pts = [];
for (let i = 0; i < s.latlng.length; i++) {
const [lat, lon] = s.latlng[i];
if (lat == null || lon == null) continue;
const iso = new Date(t0 + s.time[i] * 1e3).toISOString().replace(/\.\d+Z$/, "Z");
const ele = s.altitude ? `<ele>${(+s.altitude[i]).toFixed(1)}</ele>` : "";
const hr = s.heartrate && s.heartrate[i] != null ? `<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>${s.heartrate[i]}</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>` : "";
pts.push(`<trkpt lat="${lat}" lon="${lon}">${ele}<time>${iso}</time>${hr}</trkpt>`);
}
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="dualtrail-strava-gpx"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
<metadata>
<name>${esc(name.trim())}</name>
<time>${new Date(t0).toISOString().replace(/\.\d+Z$/, "Z")}</time>
<link href="${location.origin}/activities/${id}"><text>Strava activity ${id}</text></link>
<desc>base-time: ${startIsReal ? "activity-date-midnight" : "TODAY-FALLBACK"}; time-of-day is not the real start, elapsed times are exact</desc>
</metadata>
<trk><name>${esc(name.trim())}</name><trkseg>
${pts.join("\n")}
</trkseg></trk>
</gpx>`;
function download() {
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${name.trim() || id}.gpx`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 5e3);
say(`saved ${a.download}`);
toast(`\u2713 ${a.download}
Saved to your downloads.`);
}
download();
say(`${pts.length} points, base ${start.toISOString()} (${startIsReal ? "activity date" : "FALLBACK"})`);
say(`streams present: ${types.filter((t) => s[t]).join(", ")}`);
})();