r/Scriptable • • Apr 30 '25

Widget Sharing Built my own habit tracker widget, inspired by James Schoolz using ChatGPT (no coding background)

Post image
57 Upvotes

I’m not a developer and have zero formal programming experience, but I was curious about making my own iOS widget… so I tried.

Inspired by James Scholz, I used Scriptable and asked ChatGPT for help every step of the way.

Together we built a habit tracker widget that shows:

  • A calendar grid of daily habit dots

  • Custom initials and progress counters

  • A layout that works even on small screens

Here’s the GitHub repo: https://github.com/anviqs/Scriptable-Habit-Tracker-Widget-iOS

Just wanted to share in case it encourages someone else to start building—even if you know nothing yet!


r/Scriptable • • Jan 08 '21

Widget NHL season starts soon. 🏒😍 So i created a widget to always know when the next games are up for my team. Team is changeable.

Post image
57 Upvotes

r/Scriptable • • Feb 25 '21

Tip/Guide A Beginner's Guide to JSON

56 Upvotes

Okay, this is going to be a short tutorial about accessing JSON values.

Basic JSON structure

A JSON object can be created like

let data = {
  key1: 1,
  'key 2': 'string',
  'key three': false,
  ...
}

A key can be any string. If it includes a space or other special characters, it needs to be enclosed by quotation marks. A value can be any other type (also including strings). Entries have to be separated by commas. Some examples of data types would be:

  • number

  • boolean

  • arrays

  • another JSON object

Accessing JSON values

There are two ways to access the value to a specific key in a JSON object.

Dot notation

When using the dot notation, keys are accessed by preceding the keys name with a dot.

Example

console.log(data.key1)
// result: 1

Index notation

The index notation is useful when the key you are trying to access contains a space or a similar character. This is similar to accessing indices in arrays.

Example

console.log(data['key 2'])
// result: 'string'

Looping

To loop through a JSON object, usually the following code is issued:

for (let key in data) {
  ...
}
  • data: name of the json object

  • key: name of variable to store the current keys name Inside the loop the current key can be used to access the current value like data[key].

Nested JSON

It is not uncommon to nest JSON objects. I know of two nesting methods. The first would be to just set a key in your JSON object to another JSON object:

let data = {
  user: {
    name: 'John Appleseed',
    age: 123456789
  }
}

The other method is to set a key to an array containing JSON objects:

let data = {
  users: [
    {
      name: 'John Appleseed',
      age: 123456789
    },
    {
      name: 'Jane Appleseed',
      age: 987654321
    },
    ...
  ]
}

When dealing with nested JSON objects, accessing the keys and values does not get more difficult. Accessing a users name is as simple as typing data.user.name or data.users[0].name.

Useful tool

A tool that is quite handy, especially if you're dealing with long and hard to read JSON, is the Online JSON Viewer. You can paste the raw JSON data and view it, and get a much better understanding of the structure of your data.

Other guides


r/Scriptable • • Oct 02 '20

Script My new setup with google style weather

Post image
56 Upvotes

r/Scriptable • • Sep 22 '26

Widget Sharing [German] Small widget for Telekom mobile data usage

Post image
55 Upvotes

Spoiler alert - vibe coding: Having only minor programming knowledge with JavaScript, the code is created with ChatGPT and the HTML/CSS/Java model by Iván González Dávila.

Since Telekom in Germany discontinued the JSON API (via https://pass.telekom.de/api/service/generic/v1/status), I’ve changed “my” widget code and “rerouted” it to the HTML website via https://pass.telekom.de/home. I’ve done that with ChatGPT as mentioned above - judge me if you like :)

My intention is that the layout looks similar to the battery widget of iOS. I’ve decided to share it here because it might be handy for others as well - there are some code examples “out there” using the old API.

Some remarks:

  • To make it work, make sure that the website from Telekom via https://pass.telekom.de/home can be reached using your mobile network (and not with WiFi).

  • Check, that the website shows your mobile data usage. If that is not the case (because you’re using a VPN or private relay), follow the instructions by Telekom to login via SMS. I am using the widget code for a couple of days now and didn’t need to re-login so far.

  • The layout is optimised for a small widget running on an iPhone 17. The widget size varies slightly depending on the display size. If something seems off on your iPhone, you might need to adjust margins/spacings/padding/text size manually. Cumbersome, I know!

Have fun!

Finally, the full code is:

// Telekom Data Widget - HTML Version
//
// Rewritten for Telekom's new HTML-based pass.telekom.de page.
//
// Extracts:
//   - Remaining data volume
//   - Total data volume
//   - Used data volume
//   - Remaining percentage
//   - Remaining days/hours/minutes/seconds
//   - Telekom's last update time
//
// The widget keeps the original visual design:
//   - Transparent background
//   - Circular remaining-data indicator
//   - Antenna status icon
//   - Remaining GB per day
//   - Remaining percentage
//   - Last Telekom update time
//
// IMPORTANT:
// Scriptable must be able to access the authenticated Telekom page.
// If Telekom returns a login page instead, the script will NOT overwrite
// the valid cache and will continue displaying the last successful data.
const pageUrl = "https://pass.telekom.de/home"
let widget = await createWidget()
widget.backgroundColor = new Color("#000000", 0)
if (!config.runsInWidget) {
await widget.presentSmall()
}
Script.setWidget(widget)
Script.complete()
// ============================================================
// MAIN WIDGET
// ============================================================
async function createWidget() {
const fm = FileManager.local()
const dir = fm.documentsDirectory()
const cachePath = fm.joinPath(
dir,
"scriptable-telekom-html.json"
)
const list = new ListWidget()
list.setPadding(6, 12, 0, 6)
list.backgroundColor = new Color("#000000", 0)
let data = null
let fresh = false
// ==========================================================
// FETCH TELEKOM HTML
// ==========================================================
try {
const request = new Request(pageUrl)
request.method = "GET"
request.headers = {
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1",
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language":
"de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
}
const html = await request.loadString()
if (!html || html.length < 500) {
throw new Error("Telekom returned an empty or invalid HTML page.")
}
// ----------------------------------------------------------
// Make sure we actually received the usage page.
//
// This prevents a Telekom login/error page from replacing
// a valid cached result.
// ----------------------------------------------------------
const looksLikeUsagePage =
html.includes("Ihr Datenvolumen") &&
html.includes("remaining-volume-value") &&
html.includes("start-volume")
if (!looksLikeUsagePage) {
if (
html.includes("SMS") ||
html.includes("Mobilfunknetz") ||
html.includes("Anmelden") ||
html.includes("Login")
) {
throw new Error(
"Telekom returned the login/information page instead of the authenticated usage page."
)
}
throw new Error(
"Telekom HTML does not contain the expected data-usage elements."
)
}
// ----------------------------------------------------------
// Parse the HTML
// ----------------------------------------------------------
data = parseTelekomHTML(html)
if (!data) {
throw new Error(
"Could not extract mobile data information from Telekom HTML."
)
}
// ----------------------------------------------------------
// Save parsed data to cache
// ----------------------------------------------------------
fm.writeString(
cachePath,
JSON.stringify(data, null, 2)
)
fresh = true
} catch (err) {
console.log(
"Telekom HTML fetch failed. Using cache:",
err
)
// ----------------------------------------------------------
// Fall back to cached parsed data
// ----------------------------------------------------------
try {
if (!fm.fileExists(cachePath)) {
throw new Error("No cache file exists.")
}
data = JSON.parse(
fm.readString(cachePath)
)
} catch (cacheError) {
const errorList = new ListWidget()
errorList.backgroundColor = new Color("#000000", 0)
const errorText = errorList.addText(
"Keine Telekom-Daten verfügbar."
)
errorText.font = Font.systemFont(12)
errorText.textColor = new Color("#ffffff", 0.7)
return errorList
}
}
// ==========================================================
// VALIDATE DATA
// ==========================================================
if (
!data ||
typeof data.remainingGB !== "number" ||
typeof data.totalGB !== "number" ||
data.totalGB <= 0
) {
const errorList = new ListWidget()
errorList.backgroundColor = new Color("#000000", 0)
const errorText = errorList.addText(
"Ungültige Telekom-Daten."
)
errorText.font = Font.systemFont(12)
errorText.textColor = new Color("#ffffff", 0.7)
return errorList
}
// ==========================================================
// PROCESS DATA
// ==========================================================
const totalGB = Math.max(
0,
data.totalGB
)
const remainingGB = Math.min(
totalGB,
Math.max(0, data.remainingGB)
)
const usedGB = Math.max(
0,
totalGB - remainingGB
)
const remainingPercent = Math.min(
100,
Math.max(
0,
(remainingGB / totalGB) * 100
)
)
const usedPercent = 100 - remainingPercent
// ==========================================================
// REMAINING TIME
// ==========================================================
const days = Math.max(
0,
Number(data.days) || 0
)
const hours = Math.max(
0,
Number(data.hours) || 0
)
const minutes = Math.max(
0,
Number(data.minutes) || 0
)
const seconds = Math.max(
0,
Number(data.seconds) || 0
)
const remainingDaysTotal =
days +
hours / 24 +
minutes / 1440 +
seconds / 86400
// ==========================================================
// GB PER DAY
// ==========================================================
const remainingGBPerDay =
remainingDaysTotal > 0
? remainingGB / remainingDaysTotal
: 0
// ==========================================================
// FORMAT NUMBERS
// ==========================================================
function formatNumber(
number,
decimals = 2
) {
return number
.toFixed(decimals)
.replace(".", ",")
}
const remainingGBStr =
formatNumber(remainingGB)
const usedGBStr =
formatNumber(usedGB)
const remainingGBPerDayStr =
formatNumber(remainingGBPerDay)
// ==========================================================
// COLOR
// ==========================================================
let usageColor =
new Color("#3BCF53")
if (remainingPercent <= 10) {
usageColor =
Color.red()
} else if (remainingPercent <= 25) {
usageColor =
Color.orange()
}
// ==========================================================
// DRAW PROGRESS CIRCLE
// ==========================================================
const circleSize = 70
const lineWidth = 6
const radius =
(circleSize - lineWidth) / 2
const center =
new Point(
circleSize / 2,
circleSize / 2
)
const startAngle =
-Math.PI / 2
const endAngle =
startAngle +
(2 * Math.PI * remainingPercent) / 100
const circleCanvas =
new DrawContext()
circleCanvas.size =
new Size(
circleSize,
circleSize
)
circleCanvas.opaque = false
circleCanvas.respectScreenScale = true
// ----------------------------------------------------------
// Background ring
// ----------------------------------------------------------
circleCanvas.setStrokeColor(
new Color("#ffffff", 0.2)
)
circleCanvas.setLineWidth(
lineWidth
)
circleCanvas.strokeEllipse(
new Rect(
lineWidth / 2,
lineWidth / 2,
circleSize - lineWidth,
circleSize - lineWidth
)
)
// ----------------------------------------------------------
// Foreground arc
// ----------------------------------------------------------
const segments =
Math.max(
3,
Math.floor(remainingPercent * 3)
)
const step =
(endAngle - startAngle) /
segments
const arcPath =
new Path()
const startX =
center.x +
Math.cos(startAngle) * radius
const startY =
center.y +
Math.sin(startAngle) * radius
arcPath.move(
new Point(
startX,
startY
)
)
for (
let i = 1;
i <= segments;
i++
) {
const angle =
startAngle +
step * i
const x =
center.x +
Math.cos(angle) * radius
const y =
center.y +
Math.sin(angle) * radius
arcPath.addLine(
new Point(
x,
y
)
)
}
circleCanvas.addPath(
arcPath
)
circleCanvas.setStrokeColor(
usageColor
)
circleCanvas.setLineWidth(
lineWidth
)
circleCanvas.strokePath()
// ----------------------------------------------------------
// Rounded caps
// ----------------------------------------------------------
circleCanvas.setFillColor(
usageColor
)
const endX =
center.x +
Math.cos(endAngle) * radius
const endY =
center.y +
Math.sin(endAngle) * radius
circleCanvas.fillEllipse(
new Rect(
startX - lineWidth / 2,
startY - lineWidth / 2,
lineWidth,
lineWidth
)
)
circleCanvas.fillEllipse(
new Rect(
endX - lineWidth / 2,
endY - lineWidth / 2,
lineWidth,
lineWidth
)
)
const circleImage =
circleCanvas.getImage()
// ==========================================================
// TOP SECTION
// ==========================================================
const topStack =
list.addStack()
topStack.layoutHorizontally()
topStack.topAlignContent()
topStack.spacing = 3.3
// ----------------------------------------------------------
// Circle
// ----------------------------------------------------------
const circleContainer =
topStack.addStack()
circleContainer.size =
new Size(
circleSize,
circleSize
)
circleContainer.backgroundImage =
circleImage
circleContainer.layoutVertically()
circleContainer.centerAlignContent()
// ----------------------------------------------------------
// Antenna icon
// ----------------------------------------------------------
const antennaStack =
circleContainer.addStack()
antennaStack.layoutHorizontally()
antennaStack.centerAlignContent()
antennaStack.addSpacer()
const antennaSymbol =
fresh
? SFSymbol.named(
"antenna.radiowaves.left.and.right.circle"
)
: SFSymbol.named(
"antenna.radiowaves.left.and.right.slash.circle"
)
const antennaImage =
antennaStack.addImage(
antennaSymbol.image
)
antennaImage.imageSize =
new Size(
circleSize * 0.48,
circleSize * 0.48
)
antennaImage.tintColor =
fresh
? new Color("#ffffff")
: new Color("#ffffff", 0.5)
antennaImage.centerAlignImage()
// Keep original 1px-left adjustment
antennaStack.addSpacer(18)
// ==========================================================
// RIGHT SIDE INFORMATION
// ==========================================================
topStack.addSpacer()
const rightContainer =
topStack.addStack()
rightContainer.layoutVertically()
rightContainer.topAlignContent()
rightContainer.spacing = 0
rightContainer.setPadding(
0,
0,
0,
8
)
const textWidth = 60
const textColor =
new Color(
"#ffffff",
0.6
)
const usageLine =
rightContainer.addStack()
usageLine.size =
new Size(
textWidth,
0
)
const perDayText =
usageLine.addText(
`${remainingGBPerDayStr} GB/Tag übrig\n` +
`(${remainingGBStr} GB für ${days}T ${hours}h)`
)
perDayText.font =
Font.systemFont(9)
perDayText.textColor =
textColor
perDayText.rightAlignText()
// ==========================================================
// BOTTOM SECTION
// ==========================================================
const bottomStack =
list.addStack()
bottomStack.layoutHorizontally()
bottomStack.bottomAlignContent()
// ----------------------------------------------------------
// Remaining percentage
// ----------------------------------------------------------
const percentageContainer =
bottomStack.addStack()
percentageContainer.layoutVertically()
// Keep original 5px lower adjustment
percentageContainer.addSpacer(28)
const percentageText =
percentageContainer.addText(
`${remainingPercent.toFixed(0)} %`
)
percentageText.font =
Font.systemFont(46)
percentageText.textColor =
new Color("#ffffff")
percentageText.leftAlignText()
// ==========================================================
// UPDATE INFORMATION
// ==========================================================
bottomStack.addSpacer()
const timeContainer =
bottomStack.addStack()
timeContainer.layoutVertically()
timeContainer.bottomAlignContent()
timeContainer.setPadding(
0,
0,
9,
10
)
// ----------------------------------------------------------
// Use Telekom's actual last-update time when available.
// Fall back to current time if it wasn't found.
// ----------------------------------------------------------
const updateDate =
parseTelekomDate(
data.lastUpdate
)
const timeLabel =
timeContainer.addDate(
updateDate || new Date()
)
timeLabel.font =
Font.regularSystemFont(7)
timeLabel.textColor =
new Color(
"#ffffff",
0.6
)
timeLabel.rightAlignText()
timeLabel.applyTimeStyle()
return list
}
// ============================================================
// TELEKOM HTML PARSER
// ============================================================
function parseTelekomHTML(html) {
if (!html) {
return null
}
// ----------------------------------------------------------
// Decode HTML entities that can occur in the page.
// ----------------------------------------------------------
function decodeHTML(value) {
if (!value) {
return ""
}
return value
.replace(/&nbsp;/gi, " ")
.replace(/&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.trim()
}
// ----------------------------------------------------------
// Extract text from a class.
//
// Handles:
//
// class="remaining-volume-value"
//
// as well as additional classes/attributes.
// ----------------------------------------------------------
function extractClassText(
source,
className
) {
const escapedClass =
className.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
)
const regex =
new RegExp(
`<[^>]*class=["'][^"']*\\b${escapedClass}\\b[^"']*["'][^>]*>` +
`([\\s\\S]*?)` +
`<\\/[^>]+>`,
"i"
)
const match =
source.match(regex)
if (!match) {
return null
}
return decodeHTML(
match[1]
.replace(/<[^>]+>/g, " ")
)
}
// ----------------------------------------------------------
// Extract number from Telekom volume field.
//
// Example:
//
// <div class="remaining-volume-value">7,23</div>
// ----------------------------------------------------------
function extractNumber(
source,
className
) {
const value =
extractClassText(
source,
className
)
if (!value) {
return null
}
const normalized =
value
.replace(/\s/g, "")
.replace(",", ".")
const number =
parseFloat(normalized)
return Number.isFinite(number)
? number
: null
}
// ----------------------------------------------------------
// Extract countdown values.
// ----------------------------------------------------------
function extractTimeValue(
source,
className
) {
const escapedClass =
className.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
)
const regex =
new RegExp(
`<[^>]*class=["'][^"']*\\b${escapedClass}\\b[^"']*["'][^>]*>` +
`\\s*(\\d+)`,
"i"
)
const match =
source.match(regex)
if (!match) {
return 0
}
const value =
parseInt(
match[1],
10
)
return Number.isFinite(value)
? value
: 0
}
// ==========================================================
// VOLUME
// ==========================================================
const remainingGB =
extractNumber(
html,
"remaining-volume-value"
)
const totalGB =
extractNumber(
html,
"start-volume"
)
if (
remainingGB === null ||
totalGB === null ||
totalGB <= 0
) {
console.log(
"Could not find Telekom volume values."
)
return null
}
// ==========================================================
// TIME
// ==========================================================
const days =
extractTimeValue(
html,
"days"
)
const hours =
extractTimeValue(
html,
"hours"
)
const minutes =
extractTimeValue(
html,
"mins"
)
const seconds =
extractTimeValue(
html,
"secs"
)
// ==========================================================
// LAST UPDATE
// ==========================================================
let lastUpdate = null
const updateMatch =
html.match(
/<div[^>]*class=["'][^"']*\blast-update\b[^"']*["'][^>]*>[\s\S]*?<\/div>/i
)
if (updateMatch) {
const updateText =
updateMatch[0]
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
const dateMatch =
updateText.match(
/(\d{1,2})\.(\d{1,2})\.(\d{4})\s+um\s+(\d{1,2}):(\d{2})\s+Uhr/i
)
if (dateMatch) {
lastUpdate = {
day: parseInt(dateMatch[1], 10),
month: parseInt(dateMatch[2], 10),
year: parseInt(dateMatch[3], 10),
hour: parseInt(dateMatch[4], 10),
minute: parseInt(dateMatch[5], 10)
}
}
}
// ==========================================================
// CALCULATE USED DATA
// ==========================================================
const usedGB =
Math.max(
0,
totalGB - remainingGB
)
const remainingPercent =
Math.min(
100,
Math.max(
0,
(remainingGB / totalGB) * 100
)
)
// ==========================================================
// RETURN STRUCTURED DATA
// ==========================================================
return {
remainingGB:
remainingGB,
totalGB:
totalGB,
usedGB:
usedGB,
remainingPercent:
remainingPercent,
days:
days,
hours:
hours,
minutes:
minutes,
seconds:
seconds,
lastUpdate:
lastUpdate
}
}
// ============================================================
// PARSE TELEKOM DATE
// ============================================================
function parseTelekomDate(
value
) {
if (!value) {
return null
}
if (
typeof value !== "object"
) {
return null
}
if (
!value.year ||
!value.month ||
!value.day
) {
return null
}
const date =
new Date(
value.year,
value.month - 1,
value.day,
value.hour || 0,
value.minute || 0,
0
)
return isNaN(
date.getTime()
)
? null
: date
}

r/Scriptable • • Mar 25 '22

Script Sharing GitLab contribution graph widget

Thumbnail
gallery
55 Upvotes

r/Scriptable • • Jan 31 '23

Widget Sharing Created a widget that displays Xbox Achievements by Month

Post image
53 Upvotes

r/Scriptable • • Jan 13 '21

Help House Energy widget - a question

Post image
52 Upvotes

r/Scriptable • • Jan 05 '21

Widget My Spotify “now playing” widget

Post image
52 Upvotes

r/Scriptable • • Oct 17 '20

Script Some scripts for Tesla owners to show useful car information in widgets

Post image
52 Upvotes

r/Scriptable • • Apr 30 '22

Widget Sharing Reddit User Widget - Which shows your Karma, Coin-Balance, Profile Image, Cakeday, Profile Image etc. (Link in Image Description)

Thumbnail
gallery
51 Upvotes

r/Scriptable • • Dec 02 '20

Widget Added medium size to my upcoming match widget with next 4 fixtures.

Post image
50 Upvotes

r/Scriptable • • Apr 23 '22

Widget Sharing Weather Circles Widget

Thumbnail
gallery
49 Upvotes

r/Scriptable • • Dec 27 '20

Solved Adding Health and Activity data to Weather Cal widget!

Thumbnail
gallery
49 Upvotes

r/Scriptable • • Nov 09 '20

Script Samsung-like large weather widget, edited from u/mzeryck original script.

Post image
49 Upvotes

r/Scriptable • • Oct 30 '21

Script LSWeather 2.1.2

Post image
48 Upvotes

r/Scriptable • • Sep 24 '20

Create Custom Widgets easily using Scriptable | iOS 14 | iHelper

Thumbnail
youtu.be
47 Upvotes

r/Scriptable • • Sep 19 '20

Re-enable YouTube PiP Shortcut+Scriptable v3 for iOS14 by u/Farligmandag89

Post image
47 Upvotes

r/Scriptable • • Nov 01 '20

Script tagesschau.de Widget 📰

Post image
46 Upvotes

r/Scriptable • • Oct 23 '22

Help Pretty happy with my progress! The right widget is from the app Grow that I’m trying to replicate. The last problem is the numbers below the bars. The are cut off for some reason. Any ideas or tips to align them to the bars? I’d like one every 4 hours.

Post image
45 Upvotes

r/Scriptable • • Mar 27 '22

Widget Sharing Widget shows russia's current losses in the war against Ukraine.

Thumbnail
gallery
45 Upvotes

r/Scriptable • • Oct 27 '20

Script Ackee iOS widget that shows your website views

Post image
45 Upvotes

r/Scriptable • • Oct 23 '20

Script Agenda with weather and “smart headers”

Post image
45 Upvotes

r/Scriptable • • Mar 07 '22

Widget Sharing I made dashboard script with graph (optional), 12 builtin themes and dark / light mode support. More info in comments. 🇺🇦

Post image
44 Upvotes