RafiFinance
A complete personal finance tracker built as a single HTML file. No server, no database, no framework. Transactions, budgets, saving goals, health score, 6-month analysis, works offline as a PWA.
RafiFinance v4 is a finance tracker I built because every other option was either too complicated, required an account, or did something weird with my data. So I made my own, in a single HTML file.
So I built my own under one constraint: a single HTML file — no npm, no build step, no server, no framework. Data stays on the device in localStorage under rfv4, exportable as JSON at any time.
Deployed on Cloudflare Pages at finance.rafiarsya.com, free hosting, global CDN, automatic HTTPS, zero config. The PWA service worker is generated as a Blob URL at runtime, so even the offline capability requires no external files.
The whole app is one HTML file, installed to the home screen as a PWA. These are the screens I actually use day to day.
The health score is a weighted composite of three independent signals about your financial habits:
score = (savingsRate × 0.4)
+ (budgetAdherence × 0.35)
+ (goalProgress × 0.25)
-- savingsRate (0–100):
savings = totalIncome - totalExpenses
rate = savings / totalIncome × 100
normalized = clamp(rate / 20 × 100, 0, 100)
-- saving 20% of income = 100 points
-- budgetAdherence (0–100):
for each category with a budget:
adherence_i = clamp(1 - overspend_i/budget_i, 0, 1)
budgetAdherence = mean(adherence_i) × 100
-- goalProgress (0–100):
for each saving goal:
progress_i = saved_i / target_i
goalProgress = mean(progress_i) × 100
score = clamp(score, 0, 100)The weights reflect real priorities. Savings rate carries the most (40%) because a buffer is the foundation. Budget adherence (35%) prevents spending creep. Goal progress (25%) weighs least — having no active goal shouldn't tank an otherwise healthy score.
# Monthly income RM 3,000, expenses RM 2,400
savings = 3000 - 2400 = 600
rate = 600/3000 × 100 = 20%
savingsRate (normalized) = clamp(20/20 × 100) = 100
# Budgets: Food RM400 (spent 380), Transport RM150 (spent 165)
adherence_food = clamp(1 - 0/400, 0, 1) = 1.00
adherence_transport = clamp(1 - 15/150, 0, 1) = 0.90
budgetAdherence = mean(1.00, 0.90) × 100 = 95
# One goal: 1200/5000 saved
goalProgress = (1200/5000) × 100 = 24
score = 100×0.4 + 95×0.35 + 24×0.25
= 40 + 33.25 + 6
= 79.25 → 79 / 100monthsRemaining = (deadline.year - today.year) × 12
+ (deadline.month - today.month)
monthlyRequired = (targetAmount - savedSoFar) / monthsRemainingThe calculation updates live as transactions land. Overshoot one month and the next target drops; fall behind and the remaining months absorb a slightly higher target. It always shows the shortest path to the goal, not a plan fixed once and forgotten.
# Goal: Save RM 5,000 for a laptop
savedSoFar = RM 1,200
deadline = 8 months from today
monthlyRequired = (5000 - 1200) / 8
= 3800 / 8
= RM 475 / month
# After month 1, saved an extra RM 600 (RM 1,075 total):
monthsRemaining = 7
monthlyRequired = (5000 - 1075) / 7
= RM 560.71 / month ← recalculated automaticallyThe entire app, HTML structure, ~800 lines of CSS, ~1800 lines of vanilla JavaScript, lives in one HTML file. All state is managed with a single global object persisted to localStorage:
{
"transactions": [
{ "id": "t_1710000000000",
"type": "expense", // "income" | "expense"
"amount": 45.50,
"category": "Food",
"date": "2026-06-01",
"note": "Lunch at cafe"
}
],
"budgets": {
"Food": 400,
"Transport": 150,
"Entertainment": 100
},
"goals": [
{ "id": "g_1",
"title": "New Laptop",
"target": 5000,
"deadline": "2026-12-01",
"saved": 1200
}
],
"reminders": [ ... ],
"settings": { "currency": "MYR", "theme": "dark" }
}The whole state serialises to one JSON string — export is JSON.stringify(state), import is JSON.parse(fileContent). One-line backup and restore, no server round-trip, no account. The data never leaves the device unless you export it.
# Export button click:
const blob = new Blob(
[JSON.stringify(state, null, 2)],
{ type: 'application/json' }
);
// → downloads "rafifinance-backup-2026-06-19.json"
# Import on a new device:
const state = JSON.parse(fileContent);
localStorage.setItem('rfv4', JSON.stringify(state));
location.reload();
// → all transactions, budgets, goals restored instantlyThe PWA manifest is embedded inline as a data: URL. The service worker is generated from a Blob URL at runtime, no external sw.js file. Everything self-contained:
<link rel="manifest" href="data:application/json,{...}">, the manifest JSON is encoded directly into the HTML, no separate file needed.Blob, then registered via URL.createObjectURL(blob), no separate sw.js file on the server.