1
2(function (global) {
3 'use strict';
4
5 const STORAGE_KEY = 'finance_dashboard_transactions_v1';
6 const DEFAULT_CATEGORIES = ['Housing', 'Food', 'Transport', 'Utilities', 'Health', 'Entertainment', 'Income', 'Savings', 'Other'];
7 const DEFAULT_BUDGETS = {
8 Housing: 1500,
9 Food: 650,
10 Transport: 300,
11 Utilities: 250,
12 Health: 200,
13 Entertainment: 250,
14 Savings: 500,
15 Other: 200
16 };
17
18 const seedTransactions = [
19 { id: 'seed-1', date: '2026-07-01', description: 'Paycheck', category: 'Income', amount: 4200 },
20 { id: 'seed-2', date: '2026-07-02', description: 'Rent', category: 'Housing', amount: -1450 },
21 { id: 'seed-3', date: '2026-07-04', description: 'Groceries', category: 'Food', amount: -126.42 },
22 { id: 'seed-4', date: '2026-07-06', description: 'Subway pass', category: 'Transport', amount: -86 },
23 { id: 'seed-5', date: '2026-07-10', description: 'Electric bill', category: 'Utilities', amount: -91.17 },
24 { id: 'seed-6', date: '2026-07-14', description: 'Coffee with Sam', category: 'Food', amount: -12.5 },
25 { id: 'seed-7', date: '2026-07-15', description: 'Index fund transfer', category: 'Savings', amount: -500 },
26 { id: 'seed-8', date: '2026-07-19', description: 'Movie night', category: 'Entertainment', amount: -44 }
27 ];
28
29 function uid() {
30 return 'tx-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
31 }
32
33 function roundMoney(value) {
34 return Math.round((Number(value) || 0) * 100) / 100;
35 }
36
37 function todayISO() {
38 return new Date().toISOString().slice(0, 10);
39 }
40
41 function normalizeTransaction(tx) {
42 if (!tx || typeof tx !== 'object') throw new Error('Transaction is required');
43 const amount = Number(tx.amount);
44 if (!Number.isFinite(amount)) throw new Error('Amount must be a number');
45 const date = String(tx.date || '').trim();
46 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error('Date must use YYYY-MM-DD');
47 const description = String(tx.description || '').trim();
48 if (!description) throw new Error('Description is required');
49 return {
50 id: tx.id || uid(),
51 date,
52 description,
53 category: String(tx.category || 'Other').trim() || 'Other',
54 amount: roundMoney(amount)
55 };
56 }
57
58 function sortTransactions(transactions) {
59 return [...transactions].sort((a, b) => b.date.localeCompare(a.date) || a.description.localeCompare(b.description));
60 }
61
62 function loadTransactions(storage) {
63 const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
64 if (!store) return seedTransactions.map(normalizeTransaction);
65 try {
66 const raw = store.getItem(STORAGE_KEY);
67 if (!raw) return seedTransactions.map(normalizeTransaction);
68 const parsed = JSON.parse(raw);
69 if (!Array.isArray(parsed)) throw new Error('Stored transactions are not an array');
70 return parsed.map(normalizeTransaction);
71 } catch (error) {
72 console.warn('Could not load transactions; using sample data.', error);
73 return seedTransactions.map(normalizeTransaction);
74 }
75 }
76
77 function saveTransactions(transactions, storage) {
78 const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
79 if (!store) return transactions;
80 store.setItem(STORAGE_KEY, JSON.stringify(transactions.map(normalizeTransaction)));
81 return transactions;
82 }
83
84 function addTransaction(transactions, tx) {
85 return sortTransactions([normalizeTransaction(tx), ...transactions]);
86 }
87
88 function updateTransaction(transactions, id, patch) {
89 let found = false;
90 const updated = transactions.map(tx => {
91 if (tx.id !== id) return tx;
92 found = true;
93 return normalizeTransaction({ ...tx, ...patch, id });
94 });
95 if (!found) throw new Error('Transaction not found');
96 return sortTransactions(updated);
97 }
98
99 function deleteTransaction(transactions, id) {
100 return transactions.filter(tx => tx.id !== id);
101 }
102
103 function filterTransactions(transactions, filters) {
104 const f = filters || {};
105 return transactions.filter(tx => {
106 if (f.category && f.category !== 'All' && tx.category !== f.category) return false;
107 if (f.query) {
108 const q = f.query.toLowerCase();
109 if (!(`${tx.description} ${tx.category}`.toLowerCase().includes(q))) return false;
110 }
111 if (f.month && !tx.date.startsWith(f.month)) return false;
112 return true;
113 });
114 }
115
116 function summarize(transactions) {
117 const income = roundMoney(transactions.filter(t => t.amount > 0).reduce((sum, t) => sum + t.amount, 0));
118 const expenses = roundMoney(Math.abs(transactions.filter(t => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)));
119 return { income, expenses, net: roundMoney(income - expenses), count: transactions.length };
120 }
121
122 function spendingByCategory(transactions, month) {
123 return transactions.reduce((acc, tx) => {
124 if (tx.amount >= 0) return acc;
125 if (month && !tx.date.startsWith(month)) return acc;
126 acc[tx.category] = roundMoney((acc[tx.category] || 0) + Math.abs(tx.amount));
127 return acc;
128 }, {});
129 }
130
131 function monthlyBudgetStatus(transactions, budgets, month) {
132 const spending = spendingByCategory(transactions, month);
133 return Object.keys(budgets || DEFAULT_BUDGETS).map(category => {
134 const budget = Number((budgets || DEFAULT_BUDGETS)[category]) || 0;
135 const spent = spending[category] || 0;
136 return { category, budget, spent, percent: budget > 0 ? Math.min(999, Math.round((spent / budget) * 100)) : 0 };
137 });
138 }
139
140 function csvEscape(value) {
141 const text = String(value == null ? '' : value);
142 return /[",\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
143 }
144
145 function toCSV(transactions) {
146 const rows = [['id', 'date', 'description', 'category', 'amount'], ...sortTransactions(transactions).map(t => [t.id, t.date, t.description, t.category, t.amount])];
147 return rows.map(row => row.map(csvEscape).join(',')).join('\n');
148 }
149
150 function parseCSV(text) {
151 const rows = [];
152 let row = [], field = '', quoted = false;
153 for (let i = 0; i < text.length; i++) {
154 const ch = text[i];
155 if (quoted) {
156 if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; }
157 else if (ch === '"') quoted = false;
158 else field += ch;
159 } else if (ch === '"') quoted = true;
160 else if (ch === ',') { row.push(field); field = ''; }
161 else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
162 else if (ch !== '\r') field += ch;
163 }
164 row.push(field);
165 if (row.some(cell => cell !== '')) rows.push(row);
166 if (!rows.length) return [];
167 const header = rows.shift().map(h => h.trim().toLowerCase());
168 return rows.map(cols => {
169 const obj = {};
170 header.forEach((key, i) => { obj[key] = cols[i] || ''; });
171 return normalizeTransaction({
172 id: obj.id || undefined,
173 date: obj.date,
174 description: obj.description,
175 category: obj.category,
176 amount: obj.amount
177 });
178 });
179 }
180
181 const api = {
182 STORAGE_KEY, DEFAULT_CATEGORIES, DEFAULT_BUDGETS, seedTransactions,
183 uid, roundMoney, todayISO, normalizeTransaction, sortTransactions,
184 loadTransactions, saveTransactions, addTransaction, updateTransaction, deleteTransaction,
185 filterTransactions, summarize, spendingByCategory, monthlyBudgetStatus, toCSV, parseCSV
186 };
187
188 if (typeof module !== 'undefined' && module.exports) module.exports = api;
189 global.FinanceData = api;
190})(typeof window !== 'undefined' ? window : globalThis);
191
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.