
Top spend categories widget for iOS
Top spend categories widget for iOS using Scriptable app.
I have made this for myself, sharing it as someone might find this helpful.
Requirements:
Scriptable app (free): https://apps.apple.com/app/scriptable/id1405459188
actual-http-api: https://github.com/jhonderson/actual-http-api
Setup:
- Install scriptable
- click on add new script and copy following script
- replace “API_URL” value with you actual-http-api
- replace “API_KEY” value with you actual-http-api api key
- replace “BUDGET_SYNC_ID” value with your sync id
- add new small widget from scriptable app on home screen
- hold on script and select script, done
if you want help setting up, just comment.
Script:
```javascript
// ==========================================
// CONFIGURATION
// ==========================================
const API_URL = "https://api.your-actual-http-api.com";
const API_KEY = "API_KEY";
const BUDGET_SYNC_ID = "BUDGET_SYNC_ID";
// Currency symbol shown before amounts
const CURRENCY_SYMBOL = "$"; // e.g., "$"
// Set to 'false' to pull live API data from server
const USE_MOCK_DATA = false;
// ==========================================
// WIDGET INITIALIZATION
// ==========================================
const widget = await createWidget();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
widget.presentSmall();
}
Script.complete();
// ==========================================
// MAIN WIDGET BUILDER
// ==========================================
async function createWidget() {
const listWidget = new ListWidget();
listWidget.backgroundColor = new Color("#0D0D0E");
listWidget.setPadding(2, 12, 2, 12);
// Fetch Category Data
const categories = await fetchBudgetData();
// HEADER ROW: BUDGETS | FULL MONTH NAME
const headerStack = listWidget.addStack();
headerStack.layoutHorizontally();
headerStack.centerAlignContent();
const titleText = headerStack.addText("BUDGETS");
titleText.font = Font.heavySystemFont(10);
titleText.textColor = Color.white();
headerStack.addSpacer();
// FULL MONTH NAME
const monthStr = new Date().toLocaleString("en-US", { month: "long" }).toUpperCase();
const monthText = headerStack.addText(monthStr);
monthText.font = Font.boldSystemFont(10);
monthText.textColor = new Color("#6C6C70");
// Fixed tight spacing below title
listWidget.addSpacer(6);
// CATEGORY ROWS (5 Items)
const displayCategories = categories.slice(0, 6);
displayCategories.forEach((cat, index) => {
const spent = Math.round(cat.spent);
const budget = Math.round(cat.budget);
const avail = Math.max(0, budget - spent);
const pct = budget > 0 ? Math.round((spent / budget) * 100) : 0;
const rowStack = listWidget.addStack();
rowStack.layoutVertically();
// Top Line: Category | Spent | Avail | %
const topStack = rowStack.addStack();
topStack.layoutHorizontally();
topStack.centerAlignContent();
// UPPERCASE CATEGORY NAME
const nameText = topStack.addText(cat.name.toUpperCase());
nameText.font = Font.semiboldSystemFont(7.5);
nameText.textColor = Color.white();
nameText.lineLimit = 1;
topStack.addSpacer();
// SPENT | AVAIL WITH CURRENCY
const spendText = topStack.addText(`${CURRENCY_SYMBOL}${formatNum(spent)}`);
spendText.font = Font.regularSystemFont(7);
spendText.textColor = new Color("#8E8E93");
topStack.addSpacer(2);
const availText = topStack.addText(`${CURRENCY_SYMBOL}${formatNum(avail)}`);
availText.font = Font.regularSystemFont(7);
availText.textColor = new Color("#8E8E93");
topStack.addSpacer(1.5);
// Percentage
const barColor = getProgressColor(pct);
const pctText = topStack.addText(`${pct}%`);
pctText.font = Font.regularSystemFont(7.5);
pctText.textColor = barColor;
rowStack.addSpacer(1.5);
// Bottom Line: FIXED 4PT THICKNESS & FULL WIDTH BAR
const barStack = rowStack.addStack();
barStack.layoutHorizontally();
// Draw 300x8 canvas image for sharpness
const progressBarImg = drawSlightlyRoundedBar(300, 8, pct, barColor, new Color("#222224"), 2);
const imgNode = barStack.addImage(progressBarImg);
imgNode.resizable = true;
// Explicitly set point dimensions (134pt wide fits 158pt small widget with 12pt margins)
imgNode.imageSize = new Size(134, 4);
if (index < displayCategories.length - 1) {
listWidget.addSpacer(4); // Controlled fixed gap between rows prevents collapse
}
});
return listWidget;
}
// ==========================================
// DATA FETCHING & API LOGIC
// ==========================================
async function fetchBudgetData() {
if (USE_MOCK_DATA) {
return [
{ name: "Food", spent: 300, budget: 1000 },
{ name: "Transport", spent: 700, budget: 1000 },
{ name: "Groceries", spent: 500, budget: 1000 },
{ name: "Shopping", spent: 100, budget: 1000 },
{ name: "Coffee", spent: 100, budget: 1000 },
{ name: "Restaurants", spent: 900, budget: 1000 }
].sort((a, b) => b.spent - a.spent);
}
try {
const now = new Date();
const monthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const url = `${API_URL}/v1/budgets/${BUDGET_SYNC_ID}/months/${monthStr}/categories`;
const req = new Request(url);
req.headers = {
"x-api-key": API_KEY,
"budget-sync-id": BUDGET_SYNC_ID,
"Content-Type": "application/json"
};
const res = await req.loadJSON();
const rawCategories = Array.isArray(res) ? res : (res.data || []);
return rawCategories
.map(cat => ({
name: cat.name,
spent: Math.abs(cat.spent || cat.activity || 0) / 100,
budget: (cat.budgeted || cat.assigned || 0) / 100
}))
.sort((a, b) => b.spent - a.spent);
} catch (err) {
console.error("API Error: " + err);
return [];
}
}
// ==========================================
// UI DRAWING HELPERS
// ==========================================
function drawSlightlyRoundedBar(width, height, percentage, fillColor, trackColor, radius) {
const draw = new DrawContext();
draw.opaque = false;
draw.size = new Size(width, height);
// Background Track
const trackPath = new Path();
trackPath.addRoundedRect(new Rect(0, 0, width, height), radius, radius);
draw.addPath(trackPath);
draw.setFillColor(trackColor);
draw.fillPath();
// Fill Track
if (percentage > 0) {
const clampedPct = Math.min(100, Math.max(0, percentage));
const fillWidth = (width * clampedPct) / 100;
const fillPath = new Path();
fillPath.addRoundedRect(new Rect(0, 0, fillWidth, height), radius, radius);
draw.addPath(fillPath);
draw.setFillColor(fillColor);
draw.fillPath();
}
return draw.getImage();
}
function getProgressColor(pct) {
if (pct >= 90) return new Color("#FF453A"); // Red
if (pct >= 65) return new Color("#FF9F0A"); // Orange
if (pct >= 40) return new Color("#FFD60A"); // Yellow
return new Color("#30D158"); // Green
}
function formatNum(num) {
return Math.round(num).toLocaleString();
}
```
Script is fully made using AI, Only design is mine.