Skip to content

Instantly share code, notes, and snippets.

@adriaandotcom
Last active March 10, 2025 21:50
Show Gist options
  • Save adriaandotcom/75b92d97195be1e68021ed041c702c92 to your computer and use it in GitHub Desktop.
Save adriaandotcom/75b92d97195be1e68021ed041c702c92 to your computer and use it in GitHub Desktop.
Scriptable iOS MRR Gauge on Lockscreen
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