Top spend categories widget for iOS

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:

  1. Install scriptable
  2. click on add new script and copy following script
  3. replace “API_URL” value with you actual-http-api
  4. replace “API_KEY” value with you actual-http-api api key
  5. replace “BUDGET_SYNC_ID” value with your sync id
  6. add new small widget from scriptable app on home screen
  7. 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.

u/Frazeiy — 5 days ago

[WTS] Gas stove with oven for 150 AED

Location: Al Barsha 3, Dubai
Price: 150 AED
Pickup only

u/Frazeiy — 1 month ago

[WTS] Super General Gas Stove with Oven

Location: Al Barsha 3, Dubai
Price: 150 AED

Pickup only

u/Frazeiy — 2 months ago
▲ 35 r/shortcuts+1 crossposts

[Experiment] An iOS Shortcut that logs transactions to Actual Budget using on-device NLP

Currently i am not sharing as this is still in wip, I just want suggestions and recommendations on what and how i can improve

So basically what this does is, it will process and add transaction in actual budget like:

“spend 30 at startbucks using hsbc yesterday”

currently, this will ask for input and we can type, later i am planning to add voice input and passing payment nessages from banks to this shortcuts, so whenever a payment is done, it will automatically pass the message to this and transaction will be added.

u/Frazeiy — 2 months ago