I am trying to make a personal widget that shows my latest activity in the way that strava does in the feed. I am almost there now but I just can’t get the map to show the route line and I believe this is an issue with the polyline summary. Below is the code that I an using in scriptable, any ideas where I am going wrong?
// -------------------------
// CONFIGURATION
// -------------------------
const clientId = “My Client ID";
const clientSecret = “My Client Secret";
const refreshToken = “My Refresh Token";
const googleApiKey = “My Google API"; // <-- paste your key here
const athleteName = "Jess Langdon";
const profilePicUrl = “My Profile Pic URL";
const typeIcons = {
Run: "https://img.icons8.com/ios-filled/50/808080/running.png",
Ride: "https://img.icons8.com/ios-filled/50/808080/cycling-road.png",
Swim: "https://img.icons8.com/ios-filled/50/808080/swimming.png",
Hike: "https://img.icons8.com/ios-filled/50/808080/trekking.png",
Workout: "https://img.icons8.com/ios-filled/50/808080/fitness.png",
"High Intensity Interval Training": "https://img.icons8.com/ios-filled/50/808080/interval-training.png",
"Strength Training": "https://img.icons8.com/ios-filled/50/808080/dumbbell.png",
"Weight Training": "https://img.icons8.com/ios-filled/50/808080/dumbbell.png"
};
// -------------------------
// STRAVA API
// -------------------------
async function getAccessToken() {
const req = new Request("https://www.strava.com/oauth/token");
req.method = "POST";
req.headers = { "Content-Type": "application/x-www-form-urlencoded" };
req.body =
`client_id=${clientId}&client_secret=${clientSecret}` +
`&refresh_token=${refreshToken}&grant_type=refresh_token`;
const res = await req.loadJSON();
return res.access_token;
}
async function getLatestActivity(token) {
const req = new Request("https://www.strava.com/api/v3/athlete/activities?per_page=1");
req.headers = { Authorization: `Bearer ${token}` };
const res = await req.loadJSON();
if (!Array.isArray(res) || res.length === 0) return null;
const detailReq = new Request(`https://www.strava.com/api/v3/activities/${res[0].id}`);
detailReq.headers = { Authorization: `Bearer ${token}` };
return await detailReq.loadJSON();
}
async function getImage(url) {
if (!url) return null;
const req = new Request(url);
return await req.loadImage();
}
// -------------------------
// MAP (Google Static Maps)
// -------------------------
function decodePolyline(encoded) {
let points = [], index = 0, lat = 0, lng = 0;
while (index < encoded.length) {
let b, shift = 0, result = 0;
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
shift = 0; result = 0;
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
lng += (result & 1) ? ~(result >> 1) : (result >> 1);
points.push([lat * 1e-5, lng * 1e-5]);
}
return points;
}
async function getMapImage(polyline, width = 600, height = 300) {
if (!polyline || !googleApiKey) return null;
const coords = decodePolyline(polyline);
if (coords.length < 2) return null;
const lats = coords.map(c => c[0]);
const lngs = coords.map(c => c[1]);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
const url =
`https://maps.googleapis.com/maps/api/staticmap?size=${width}x${height}` +
`&path=color:0xfc4c02ff|weight:4|enc:${encodeURIComponent(polyline)}` +
`&visible=${minLat},${minLng}|${maxLat},${maxLng}` +
`&key=${googleApiKey}`;
console.log("Map URL:", url);
const req = new Request(url);
return await req.loadImage();
}
function formatDateLocal(str) {
const [d, t] = str.split("T");
const [y, m, day] = d.split("-").map(Number);
const [h, min] = t.split(":").map(Number);
const date = new Date(y, m - 1, day, h, min);
return date.toLocaleString(undefined, {
weekday: "short", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", hour12: false
});
}
// -------------------------
// WIDGET
// -------------------------
async function createWidget(activity) {
const w = new ListWidget();
w.backgroundColor = Color.white();
if (!activity) {
const t = w.addText("No recent activity found");
t.font = Font.boldSystemFont(16);
t.textColor = Color.black();
t.centerAlignText();
return w;
}
const header = w.addStack();
header.layoutHorizontally();
header.spacing = 8;
const imgContainer = header.addStack();
imgContainer.size = new Size(40, 40);
imgContainer.cornerRadius = 20;
imgContainer.clipsToBounds = true;
const profile = await getImage(profilePicUrl);
if (profile) imgContainer.addImage(profile).applyFittingContentMode();
const info = header.addStack();
info.layoutVertically();
info.spacing = 2;
const name = info.addText(athleteName);
name.font = Font.boldSystemFont(14);
name.textColor = Color.black();
const details = info.addStack();
details.layoutHorizontally();
details.spacing = 4;
const iconUrl = typeIcons[activity.type];
if (iconUrl) {
const iconImg = await getImage(iconUrl);
if (iconImg) {
const img = details.addImage(iconImg);
img.imageSize = new Size(12, 12);
img.tintColor = Color.gray();
}
}
const loc =
[activity.location_city, activity.location_state, activity.location_country]
.filter(Boolean).join(", ") || "Unknown location";
const dateLoc = details.addText(`${formatDateLocal(activity.start_date_local)} · ${loc}`);
dateLoc.font = Font.systemFont(11);
dateLoc.textColor = Color.gray();
w.addSpacer(12);
const title = w.addText(activity.name || "Unnamed Activity");
title.font = Font.boldSystemFont(16);
title.textColor = Color.black();
title.lineLimit = 2;
w.addSpacer(6);
const description = activity.description && activity.description.trim() !== ""
? activity.description
: "No activity description.";
const desc = w.addText(description);
desc.font = Font.systemFont(12);
desc.textColor = new Color("#686765");
desc.lineLimit = 2;
w.addSpacer(10);
const stats = w.addStack();
stats.layoutHorizontally();
stats.spacing = 30;
function addStat(label, val) {
const col = stats.addStack();
col.layoutVertically();
const l = col.addText(label); l.font = Font.systemFont(10); l.textColor = Color.gray();
const v = col.addText(val); v.font = Font.boldSystemFont(14); v.textColor = Color.black();
}
const distance = (activity.distance / 1000).toFixed(2) + " km";
const time = `${Math.floor(activity.moving_time / 60)}m ${activity.moving_time % 60}s`;
const calories = activity.calories ? Math.round(activity.calories) + " cal" : "-";
const pace = activity.moving_time && activity.distance
? (activity.moving_time / 60 / (activity.distance / 1609)).toFixed(2) + " /mi"
: "-";
const running = ["Run", "Hike"];
const cycling = ["Ride", "Swim"];
const workouts = ["Workout", "High Intensity Interval Training", "Strength Training", "Weight Training"];
if (running.includes(activity.type)) {
addStat("Distance", distance);
addStat("Pace", pace);
addStat("Time", time);
} else if (cycling.includes(activity.type)) {
addStat("Distance", distance);
addStat("Time", time);
addStat("Calories", calories);
} else if (workouts.includes(activity.type)) {
addStat("Time", time);
addStat("Calories", calories);
} else {
addStat("Distance", distance);
addStat("Time", time);
}
w.addSpacer(8);
const polyline = activity.map?.polyline || activity.map?.summary_polyline;
// Add this debug log:
console.log("Polyline length:", polyline ? polyline.length : "none");
const mapImg = polyline ? await getMapImage(polyline, 600, 300) : null;
if (mapImg) {
const img = w.addImage(mapImg);
img.cornerRadius = 8;
img.centerAlignImage();
} else {
const ph = w.addStack();
ph.backgroundColor = new Color("#eeeeee");
ph.size = new Size(308, 160);
ph.cornerRadius = 8;
}
return w;
}
// -------------------------
// MAIN
// -------------------------
try {
const token = await getAccessToken();
console.log("Access token:", token);
const activity = await getLatestActivity(token);
console.log("Latest activity JSON:", JSON.stringify(activity));
const widget = await createWidget(activity);
if (activity) widget.url = `strava://activities/${activity.id}`;
if (config.runsInWidget)
widget.refreshAfterDate = new Date(Date.now() + 15 * 60 * 1000);
Script.setWidget(widget);
Script.complete();
if (!config.runsInWidget)
await widget.presentLarge();
} catch (e) {
console.error("Error:", e);
const w = new ListWidget();
w.addText("Error fetching activity");
Script.setWidget(w);
Script.complete();
await w.presentLarge();
}