Skip to main content

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();
}

Be the first to reply!