Last active
March 10, 2025 21:50
-
-
Save adriaandotcom/75b92d97195be1e68021ed041c702c92 to your computer and use it in GitHub Desktop.
Scriptable iOS MRR Gauge on Lockscreen
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const title = "MRR"; | |
const titleFontSize = 12; | |
// const metric = "mrr_no_currency"; | |
const metric = "mrr"; | |
const metricFontSize = 11; | |
const wait = ms => new Promise(resolve => Timer.schedule(ms, false, resolve)); | |
const loadSAData = async () => { | |
const url = "https://dashboard.simpleanalytics.com/open.json"; | |
const fm = FileManager.local(); | |
const cacheFile = fm.joinPath(fm.documentsDirectory(), "simpleanalytics.json"); | |
let data = null; | |
let isLive = true; | |
try { | |
let req = new Request(url); | |
data = await Promise.any([req.loadJSON(), wait(5000)]); | |
if (!data) throw new Error("No data"); | |
fm.writeString(cacheFile, JSON.stringify(data)); | |
} catch (e) { | |
if (fm.fileExists(cacheFile)) { | |
let cachedStr = fm.readString(cacheFile); | |
data = JSON.parse(cachedStr); | |
isLive = false; | |
} else { | |
data = { [metric]: 0, [`${metric}_short`]: "0k" }; | |
isLive = false; | |
} | |
} | |
data.isLive = isLive; | |
return data; | |
}; | |
const gaugeCircle = async ({ | |
on, | |
value = 0.5, | |
color = "hsl(0, 0%, 100%)", | |
background = "hsl(0, 0%, 10%)", | |
size = 60, | |
barWidth = 4, | |
startAngle = 135, | |
endAngle = 45, | |
lowerText = "", | |
upperText = "", | |
isLive = false | |
}) => { | |
if (value < 0) value = 0; | |
if (value > 1) value = 1; | |
// Calculate total arc (in degrees) | |
let totalAngle = (360 - startAngle + endAngle) % 360; | |
if (totalAngle === 0) totalAngle = 360; | |
const fillAngle = totalAngle * value; | |
const w = new WebView(); | |
await w.loadHTML('<canvas id="c"></canvas>'); | |
let base64 = await w.evaluateJavaScript(` | |
let color = "${color}"; | |
let background = "${background}"; | |
let size = ${size} * 3; | |
let lineWidth = ${barWidth} * 3; | |
let startAngle = ${startAngle}; | |
let totalAngle = ${totalAngle}; | |
let fillAngle = ${fillAngle}; | |
let lowerText = "${lowerText}"; | |
let upperText = "${upperText}"; | |
let isLive = ${isLive}; | |
let canvas = document.getElementById('c'); | |
let ctx = canvas.getContext('2d'); | |
canvas.width = size; | |
canvas.height = size; | |
let centerX = size / 2; | |
let centerY = size / 2; | |
let radius = (size - lineWidth - 1) / 2; | |
const degToRad = (deg) => deg * Math.PI / 180; | |
ctx.lineCap = 'round'; | |
// Draw background arc | |
ctx.beginPath(); | |
ctx.strokeStyle = background; | |
ctx.lineWidth = lineWidth; | |
ctx.arc(centerX, centerY, radius, degToRad(startAngle), degToRad(startAngle + totalAngle)); | |
ctx.stroke(); | |
// Draw fill arc | |
ctx.beginPath(); | |
ctx.strokeStyle = color; | |
ctx.lineWidth = lineWidth; | |
ctx.arc(centerX, centerY, radius, degToRad(startAngle), degToRad(startAngle + fillAngle)); | |
ctx.stroke(); | |
if (!isLive) { | |
ctx.beginPath(); | |
ctx.fillStyle = color; | |
ctx.arc(size - 20, 20, 8, 0, 2 * Math.PI); | |
ctx.fill(); | |
} | |
// Draw endpoint labels if provided; inset by 35px | |
if (lowerText && upperText) { | |
ctx.fillStyle = "white"; | |
// Use a font size scaled by 3 (adjust as needed) | |
ctx.font = "bold 18px sans-serif"; | |
ctx.textAlign = "center"; | |
ctx.textBaseline = "middle"; | |
// Offset from circle edge; adjust offset if needed | |
let offset = lineWidth / 2; | |
let insetX = 35; | |
let radLower = degToRad(startAngle); | |
let radUpper = degToRad(startAngle + totalAngle); | |
let xLower = centerX + (radius + offset) * Math.cos(radLower); | |
let yLower = centerY + (radius + offset) * Math.sin(radLower); | |
let xUpper = centerX + (radius + offset) * Math.cos(radUpper); | |
let yUpper = centerY + (radius + offset) * Math.sin(radUpper); | |
ctx.fillText(lowerText, xLower + insetX, yLower); | |
ctx.fillText(upperText, xUpper - insetX, yUpper); | |
} | |
completion(canvas.toDataURL().replace("data:image/png;base64,","")) | |
`, true); | |
const image = Image.fromData(Data.fromBase64String(base64)); | |
let stack = on.addStack(); | |
stack.size = new Size(size, size); | |
stack.backgroundImage = image; | |
stack.centerAlignContent(); | |
stack.setPadding(barWidth, barWidth, barWidth, barWidth); | |
return stack; | |
}; | |
// Get SA data | |
const saData = await loadSAData(); | |
const mrrValue = Number(saData[metric]) || 0; | |
const metricShort = saData[`${metric}_short`] || "0k"; | |
// Main script starts here | |
const widget = new ListWidget(); | |
widget.backgroundColor = new Color("#7c7c7c", 1.0); | |
// Create a container to hold the gauge and center overlay | |
const container = widget.addStack(); | |
container.layoutVertically(); | |
container.centerAlignContent(); | |
// Use a gaugeSize of 60 | |
const gaugeSize = 60; | |
// Compute dynamic gauge bounds: lower 10k range. | |
const lowerBound = Math.floor(mrrValue / 10000) * 10000; | |
const upperBound = lowerBound + 10000; | |
const lowerBoundStr = `${lowerBound / 1000}k`; | |
const upperBoundStr = `${upperBound / 1000}k`; | |
let percent = (mrrValue - lowerBound) / (upperBound - lowerBound); | |
if (percent < 0) percent = 0; | |
if (percent > 1) percent = 1; | |
// Build gauge: arc from 135° to 45° (270° total), with endpoint labels drawn inside the arc. | |
const gaugeStack = await gaugeCircle({ | |
on: container, | |
value: percent, | |
color: "hsl(0, 0%, 100%)", | |
background: "hsl(0, 0%, 10%)", | |
size: gaugeSize, | |
barWidth: 6, | |
startAngle: 135, | |
endAngle: 45, | |
lowerText: lowerBoundStr, | |
upperText: upperBoundStr, | |
isLive: saData.isLive | |
}); | |
// Add overlay for center text | |
const overlay = gaugeStack.addStack(); | |
overlay.layoutVertically(); | |
overlay.centerAlignContent(); | |
const titleStack = overlay.addStack(); | |
titleStack.layoutHorizontally(); | |
titleStack.addSpacer(); | |
const titleTxt = titleStack.addText(title); | |
titleTxt.font = Font.boldSystemFont(titleFontSize); | |
titleTxt.centerAlignText(); | |
titleStack.addSpacer(); | |
const valueStack = overlay.addStack(); | |
valueStack.layoutHorizontally();// | |
valueStack.addSpacer(); | |
const valueTxt = valueStack.addText(metricShort); | |
valueTxt.font = Font.systemFont(metricFontSize); | |
valueTxt.centerAlignText(); | |
valueStack.addSpacer(); | |
if (!config.runsInWidget) { | |
// widget.presentAccessoryCircular(); | |
widget.presentSmall(); | |
} | |
Script.setWidget(widget); | |
Script.complete(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment