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
I created some widgets using the app Scriptable on iOS. Let me know what you guys think! Here are the ones displayed:
Directions will be in the replies
Here are some instructions on how to get this widget setup:
Couple of limitations:
Very cool. Thanks!
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 …
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()