I created some custom iOS Widgets (that anyone can use)

I created some widgets using the app Scriptable on iOS. Let me know what you guys think! Here are the ones displayed:

  • Upcoming workout
  • Workout completion percentage
  • FTP, W/KG

Directions will be in the replies

5 Likes

Here are some instructions on how to get this widget setup:

  1. Download Scriptable
  2. Download the widget you want on iPhone
  3. Save in the Files app
  4. Open with Scriptable
  5. Edit file on line 4 to include your username rather than mine
  6. Add a scriptable widget to your homescreen, and select the correct one
  7. :tada:

Couple of limitations:

  • Your profile needs to be public
  • It tracks completion of workouts since the beginning of the year
3 Likes

Very cool. Thanks!

1 Like

Hello I just gave it a try, dowloaded the scripts, updated with my username and my profile is public. But then as it executes, it throws up some errors (seems related to the format of the data received) and thus is unable to create the widget itself. Can anyone still confirm this works? Thanks.

I’ve been having the same issue. They’re fantastic widgets and I have them up all the time but sadly no longer functioning. I don’t know if @quinnsprouse is still on the forum …

1 Like

This looks pretty cool! Would it be possible to make something like this on Android?

Hi,

I created a similar widget for the next training, which supports the new API of Trainerroad.

Just go to manually add the calendar on Trainerroad and copy the URL and replace it with the one in the script. Then copy the script in a new Scriptable script and use it in a widget.

Here is the script:

// TrainerRoad – Next Cycling Workout Widget

// ================= CONFIG =================

const TR_ICAL_URL = “https://api.trainerroad.com/v1/calendar/ics/xxxxxxxxxxxxxxxxxxxxxxxxxx” // <===== Enter my personal URL Here !!!

// ================= COLORS =================

const bgColor = new Color(“#1E1E1E”)

const cardColor = new Color(“#2A2A2A”)

const textColor = Color.white()

const accentColor = new Color(“#FF4B00”)

const secondaryColor = new Color(“#9AA0A6”)

// ================= MAIN =================

async function createWidget() {

const widget = new ListWidget()

widget.backgroundColor = bgColor

widget.setPadding(16, 16, 16, 16)

try {

const workout = await fetchNextWorkout()

if (!workout) {

  widget.addText("No upcoming workouts")

  return widget

}

// Header

const header = widget.addText(

  \`Nächstes Training · ${formatDate(workout.date)}\`

)

header.font = Font.systemFont(12)

header.textColor = secondaryColor

widget.addSpacer(10)

// Card

const card = widget.addStack()

card.backgroundColor = cardColor

card.cornerRadius = 12

card.setPadding(12, 12, 12, 12)

card.layoutVertically()

// Title

const title = card.addText(workout.title)

title.font = Font.boldSystemFont(16)

title.textColor = textColor

title.lineLimit = 1

// Type

if (workout.type) {

  const type = card.addText(workout.type)

  type.font = Font.systemFont(13)

  type.textColor = secondaryColor

}

card.addSpacer(8)

// Stats row

const stats = card.addStack()

stats.layoutHorizontally()

addStat(stats, workout.duration, "DAUER")

stats.addSpacer()

addStat(stats, workout.tss ?? "–", "TSS")

stats.addSpacer()

addStat(stats, workout.if ?? "–", "IF")

} catch (e) {

const err = widget.addText("TrainerRoad Error")

err.textColor = Color.red()

widget.addText(e.message)

}

return widget

}

// ================= DATA =================

async function fetchNextWorkout() {

const req = new Request(TR_ICAL_URL)

const ics = await req.loadString()

const events = ics.split(“BEGIN:VEVENT”).slice(1)

const today = startOfToday()

for (const e of events) {

const summary = getLine(e, "SUMMARY")

const desc = getLine(e, "DESCRIPTION")

const dtStart = getLine(e, "DTSTART")

if (!summary || !dtStart) continue

const date = parseICSDate(dtStart)

if (!date || date < today) continue

return {

  date,

  title: cleanSummary(summary),

  duration: extractDuration(summary),

  type: extractType(desc),

  tss: extractTSS(desc),

  if: extractIF(desc)

}

}

return null

}

// ================= UI HELPERS =================

function addStat(stack, value, label) {

const col = stack.addStack()

col.layoutVertically()

const v = col.addText(String(value))

v.font = Font.boldSystemFont(14)

v.textColor = textColor

const l = col.addText(label)

l.font = Font.systemFont(9)

l.textColor = secondaryColor

}

// ================= PARSING =================

function getLine(block, key) {

const m = block.match(new RegExp(`${key}[^:]*:(.+)`))

return m ? m[1].replace(/\\n/g, " ").trim() : null

}

function parseICSDate(v) {

if (/^\d{8}$/.test(v)) {

return new Date(v.slice(0,4), v.slice(4,6)-1, v.slice(6,8))

}

if (/^\d{8}T\d{6}/.test(v)) {

return new Date(

  v.slice(0,4), v.slice(4,6)-1, v.slice(6,8),

  v.slice(9,11), v.slice(11,13)

)

}

return null

}

function extractDuration(s) {

const m = s.match(/^(\d+:\d{2})/)

return m ? m[1] : “–”

}

function cleanSummary(s) {

return s.replace(/^\d+:\d{2}\s*-\s*/, “”)

}

function extractType(desc, title) {

if (!desc) return null

const safeTitle = title ? title.toLowerCase() : null

const lines = desc

.split(/\\n|\\\\n/)

.map(l => l.trim())

.filter(l => l.length > 0)

for (const line of lines) {

const lower = line.toLowerCase()

// ignorieren

if (safeTitle && lower === safeTitle) continue

if (lower.includes("tss")) continue

if (lower.includes("if")) continue

if (lower.includes("intensity")) continue

if (lower.includes("duration")) continue

// Treffer → Trainings-Typ

return line

}

return null

}

function extractTSS(d) {

const m = d?.match(/TSS[:\s]+(\d+)/i)

return m ? m[1] : null

}

function extractIF(d) {

const m = d?.match(/IF[:\s]+([\d.]+)/i)

return m ? m[1] : null

}

// ================= DATE =================

function formatDate(date) {
const today = startOfToday()

const diffDays =
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)

if (diffDays === 0) {
return “Heute”
}

return date.toLocaleDateString(“de-DE”, {
weekday: “long”,
day: “numeric”,
month: “long”
})
}

function startOfToday() {

const d = new Date()

d.setHours(0,0,0,0)

return d

}

// ================= RUN =================

const widget = await createWidget()

if (config.runsInWidget) Script.setWidget(widget)

else widget.presentMedium()

Script.complete()

2 Likes