1
2
3
4
5
6(function (exports) {
7 'use strict';
8
9
10 const DEFAULT_CATEGORIES = [
11 { id: 'housing', name: 'Housing & Rent', budget: 1500, icon: '🏠', color: '#6366f1' },
12 { id: 'food', name: 'Food & Groceries', budget: 600, icon: '🛒', color: '#10b981' },
13 { id: 'transportation', name: 'Transportation', budget: 350, icon: '🚗', color: '#f59e0b' },
14 { id: 'utilities', name: 'Utilities & Bills', budget: 250, icon: '⚡', color: '#06b6d4' },
15 { id: 'entertainment', name: 'Entertainment & Fun', budget: 200, icon: '🎬', color: '#ec4899' },
16 { id: 'shopping', name: 'Shopping', budget: 300, icon: '🛍️', color: '#8b5cf6' },
17 { id: 'healthcare', name: 'Healthcare & Fitness', budget: 150, icon: '🏥', color: '#ef4444' },
18 { id: 'savings', name: 'Investments & Savings', budget: 500, icon: '💰', color: '#14b8a6' },
19 { id: 'income', name: 'Income Source', budget: 0, icon: '💵', color: '#22c55e' }
20 ];
21
22
23 function getDateOffset(daysAgo) {
24 const d = new Date();
25 d.setDate(d.getDate() - daysAgo);
26 return d.toISOString().split('T')[0];
27 }
28
29
30 const DEFAULT_TRANSACTIONS = [
31 { id: 'tx-1', date: getDateOffset(0), category: 'income', type: 'income', amount: 3800.00, description: 'Bi-weekly Salary Deposit', notes: 'Main employer paycheck' },
32 { id: 'tx-2', date: getDateOffset(1), category: 'housing', type: 'expense', amount: 1450.00, description: 'Monthly Rent Payment', notes: 'Apartment rent' },
33 { id: 'tx-3', date: getDateOffset(2), category: 'food', type: 'expense', amount: 142.50, description: 'Trader Joe\'s Groceries', notes: 'Weekly grocery restock' },
34 { id: 'tx-4', date: getDateOffset(4), category: 'utilities', type: 'expense', amount: 98.40, description: 'Electric & Gas Utility', notes: 'City Power & Light' },
35 { id: 'tx-5', date: getDateOffset(5), category: 'transportation', type: 'expense', amount: 48.00, description: 'Gas Station Refill', notes: 'Fuel' },
36 { id: 'tx-6', date: getDateOffset(7), category: 'entertainment', type: 'expense', amount: 24.99, description: 'Netflix & Spotify Subscriptions', notes: 'Monthly streaming' },
37 { id: 'tx-7', date: getDateOffset(9), category: 'food', type: 'expense', amount: 64.20, description: 'Dining out at Bistro', notes: 'Dinner with friends' },
38 { id: 'tx-8', date: getDateOffset(11), category: 'shopping', type: 'expense', amount: 129.99, description: 'Running Shoes', notes: 'New footwear' },
39 { id: 'tx-9', date: getDateOffset(14), category: 'income', type: 'income', amount: 450.00, description: 'Freelance Design Consulting', notes: 'Side project' },
40 { id: 'tx-10', date: getDateOffset(15), category: 'healthcare', type: 'expense', amount: 45.00, description: 'Pharmacy & Vitamins', notes: 'Health supplies' },
41 { id: 'tx-11', date: getDateOffset(18), category: 'food', type: 'expense', amount: 185.30, description: 'Whole Foods Market', notes: 'Groceries' },
42 { id: 'tx-12', date: getDateOffset(20), category: 'savings', type: 'expense', amount: 500.00, description: 'Index Fund Contribution', notes: 'Monthly investment' },
43 { id: 'tx-13', date: getDateOffset(22), category: 'transportation', type: 'expense', amount: 35.00, description: 'Subway Pass Refill', notes: 'Transit card' },
44 { id: 'tx-14', date: getDateOffset(25), category: 'entertainment', type: 'expense', amount: 75.00, description: 'Concert Tickets', notes: 'Live event' },
45 { id: 'tx-15', date: getDateOffset(28), category: 'income', type: 'income', amount: 3800.00, description: 'Bi-weekly Salary Deposit', notes: 'Main employer paycheck' }
46 ];
47
48
49
50
51 function validateTransaction(tx) {
52 const errors = [];
53 if (!tx.date || isNaN(Date.parse(tx.date))) {
54 errors.push('A valid date is required.');
55 }
56 if (!tx.description || typeof tx.description !== 'string' || tx.description.trim() === '') {
57 errors.push('Description is required.');
58 }
59 const amt = parseFloat(tx.amount);
60 if (isNaN(amt) || amt <= 0) {
61 errors.push('Amount must be a positive number.');
62 }
63 if (!tx.type || (tx.type !== 'income' && tx.type !== 'expense')) {
64 errors.push('Type must be either "income" or "expense".');
65 }
66 if (!tx.category || typeof tx.category !== 'string') {
67 errors.push('Category is required.');
68 }
69 return {
70 valid: errors.length === 0,
71 errors
72 };
73 }
74
75
76
77
78 function calculateSummary(transactions) {
79 let totalIncome = 0;
80 let totalExpenses = 0;
81
82 (transactions || []).forEach(tx => {
83 const amt = parseFloat(tx.amount) || 0;
84 if (tx.type === 'income') {
85 totalIncome += amt;
86 } else if (tx.type === 'expense') {
87 totalExpenses += amt;
88 }
89 });
90
91 const netSavings = totalIncome - totalExpenses;
92 const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0;
93
94 return {
95 totalIncome: Math.round(totalIncome * 100) / 100,
96 totalExpenses: Math.round(totalExpenses * 100) / 100,
97 netSavings: Math.round(netSavings * 100) / 100,
98 savingsRate: Math.round(savingsRate * 10) / 10
99 };
100 }
101
102
103
104
105 function calculateCategorySpending(transactions, categories = DEFAULT_CATEGORIES) {
106 const spendingMap = {};
107 (categories || []).forEach(cat => {
108 spendingMap[cat.id] = 0;
109 });
110
111 (transactions || []).forEach(tx => {
112 if (tx.type === 'expense') {
113 const catId = tx.category;
114 const amt = parseFloat(tx.amount) || 0;
115 spendingMap[catId] = (spendingMap[catId] || 0) + amt;
116 }
117 });
118
119 return (categories || [])
120 .filter(cat => cat.id !== 'income')
121 .map(cat => {
122 const spent = Math.round((spendingMap[cat.id] || 0) * 100) / 100;
123 const budget = cat.budget || 0;
124 const remaining = Math.round((budget - spent) * 100) / 100;
125 const percentage = budget > 0 ? Math.min(Math.round((spent / budget) * 100), 200) : (spent > 0 ? 100 : 0);
126 let status = 'good';
127 if (percentage >= 100) status = 'danger';
128 else if (percentage >= 80) status = 'warning';
129
130 return {
131 id: cat.id,
132 name: cat.name,
133 icon: cat.icon,
134 color: cat.color,
135 budget,
136 spent,
137 remaining,
138 percentage,
139 status
140 };
141 });
142 }
143
144
145
146
147
148 function calculateDailySpendingTrends(transactions, daysLimit = 30) {
149 const map = {};
150
151
152 const sorted = [...(transactions || [])].sort((a, b) => new Date(a.date) - new Date(b.date));
153
154 if (sorted.length === 0) {
155 return [];
156 }
157
158
159 let startDate = new Date(sorted[0].date);
160 let endDate = new Date(sorted[sorted.length - 1].date);
161
162
163 const diffDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
164 if (diffDays < 7) {
165 startDate = new Date(endDate);
166 startDate.setDate(startDate.getDate() - 7);
167 }
168
169
170 const curr = new Date(startDate);
171 while (curr <= endDate) {
172 const dateStr = curr.toISOString().split('T')[0];
173 map[dateStr] = { date: dateStr, income: 0, expense: 0 };
174 curr.setDate(curr.getDate() + 1);
175 }
176
177
178 (transactions || []).forEach(tx => {
179 if (map[tx.date]) {
180 const amt = parseFloat(tx.amount) || 0;
181 if (tx.type === 'income') {
182 map[tx.date].income += amt;
183 } else {
184 map[tx.date].expense += amt;
185 }
186 }
187 });
188
189 const dates = Object.keys(map).sort();
190 let cumulative = 0;
191
192 return dates.map(d => {
193 const inc = Math.round(map[d].income * 100) / 100;
194 const exp = Math.round(map[d].expense * 100) / 100;
195 const net = inc - exp;
196 cumulative += net;
197
198
199 const dateObj = new Date(d + 'T00:00:00');
200 const label = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
201
202 return {
203 date: d,
204 label,
205 income: inc,
206 expense: exp,
207 net: Math.round(net * 100) / 100,
208 cumulative: Math.round(cumulative * 100) / 100
209 };
210 });
211 }
212
213
214
215
216 function filterTransactions(transactions, filters = {}) {
217 const { search, category, startDate, endDate, type } = filters;
218 const query = search ? search.trim().toLowerCase() : '';
219
220 return (transactions || []).filter(tx => {
221
222 if (query) {
223 const descMatch = (tx.description || '').toLowerCase().includes(query);
224 const notesMatch = (tx.notes || '').toLowerCase().includes(query);
225 const catMatch = (tx.category || '').toLowerCase().includes(query);
226 const amtMatch = (tx.amount || '').toString().includes(query);
227 if (!descMatch && !notesMatch && !catMatch && !amtMatch) {
228 return false;
229 }
230 }
231
232
233 if (category && category !== 'all') {
234 if (tx.category !== category) {
235 return false;
236 }
237 }
238
239
240 if (type && type !== 'all') {
241 if (tx.type !== type) {
242 return false;
243 }
244 }
245
246
247 if (startDate && tx.date < startDate) {
248 return false;
249 }
250 if (endDate && tx.date > endDate) {
251 return false;
252 }
253
254 return true;
255 });
256 }
257
258
259
260
261 function sortTransactions(transactions, sortBy = 'date', sortOrder = 'desc') {
262 const copy = [...(transactions || [])];
263 const modifier = sortOrder === 'asc' ? 1 : -1;
264
265 return copy.sort((a, b) => {
266 if (sortBy === 'date') {
267 return modifier * (new Date(a.date) - new Date(b.date));
268 }
269 if (sortBy === 'amount') {
270 return modifier * (parseFloat(a.amount) - parseFloat(b.amount));
271 }
272 if (sortBy === 'description') {
273 return modifier * a.description.localeCompare(b.description);
274 }
275 if (sortBy === 'category') {
276 return modifier * a.category.localeCompare(b.category);
277 }
278 return 0;
279 });
280 }
281
282
283
284
285 function addTransaction(transactions, newTx) {
286 const validation = validateTransaction(newTx);
287 if (!validation.valid) {
288 throw new Error('Invalid transaction: ' + validation.errors.join(', '));
289 }
290
291 const txToAdd = {
292 id: newTx.id || 'tx-' + Date.now() + '-' + Math.random().toString(36).substring(2, 7),
293 date: newTx.date,
294 description: newTx.description.trim(),
295 amount: parseFloat(newTx.amount),
296 type: newTx.type,
297 category: newTx.category,
298 notes: (newTx.notes || '').trim()
299 };
300
301 return [txToAdd, ...(transactions || [])];
302 }
303
304
305
306
307 function updateTransaction(transactions, id, updatedTx) {
308 const validation = validateTransaction(updatedTx);
309 if (!validation.valid) {
310 throw new Error('Invalid transaction: ' + validation.errors.join(', '));
311 }
312
313 return (transactions || []).map(tx => {
314 if (tx.id === id) {
315 return {
316 ...tx,
317 date: updatedTx.date,
318 description: updatedTx.description.trim(),
319 amount: parseFloat(updatedTx.amount),
320 type: updatedTx.type,
321 category: updatedTx.category,
322 notes: (updatedTx.notes || '').trim()
323 };
324 }
325 return tx;
326 });
327 }
328
329
330
331
332 function deleteTransaction(transactions, id) {
333 return (transactions || []).filter(tx => tx.id !== id);
334 }
335
336
337
338
339 function generateCSV(transactions) {
340 const headers = ['id', 'date', 'type', 'category', 'amount', 'description', 'notes'];
341 const rows = (transactions || []).map(tx => {
342 return [
343 tx.id || '',
344 tx.date || '',
345 tx.type || '',
346 tx.category || '',
347 tx.amount || 0,
348 `"${(tx.description || '').replace(/"/g, '""')}"`,
349 `"${(tx.notes || '').replace(/"/g, '""')}"`
350 ].join(',');
351 });
352
353 return [headers.join(','), ...rows].join('\n');
354 }
355
356
357
358
359 function parseCSV(csvContent) {
360 if (!csvContent || typeof csvContent !== 'string') {
361 return { success: false, transactions: [], errors: ['Empty CSV content'] };
362 }
363
364 const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== '');
365 if (lines.length === 0) {
366 return { success: false, transactions: [], errors: ['No content in CSV'] };
367 }
368
369
370 function parseCSVLine(text) {
371 const result = [];
372 let cur = '';
373 let inQuotes = false;
374
375 for (let i = 0; i < text.length; i++) {
376 const char = text[i];
377 if (char === '"') {
378 if (inQuotes && text[i + 1] === '"') {
379 cur += '"';
380 i++;
381 } else {
382 inQuotes = !inQuotes;
383 }
384 } else if (char === ',' && !inQuotes) {
385 result.push(cur);
386 cur = '';
387 } else {
388 cur += char;
389 }
390 }
391 result.push(cur);
392 return result;
393 }
394
395 const headerLine = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase());
396
397
398 const dateIdx = headerLine.indexOf('date');
399 const typeIdx = headerLine.indexOf('type');
400 const catIdx = headerLine.indexOf('category');
401 const amtIdx = headerLine.indexOf('amount');
402 const descIdx = headerLine.indexOf('description');
403 const notesIdx = headerLine.indexOf('notes');
404
405 if (dateIdx === -1 || amtIdx === -1 || descIdx === -1) {
406 return {
407 success: false,
408 transactions: [],
409 errors: ['CSV must contain headers: "date", "amount", and "description"']
410 };
411 }
412
413 const imported = [];
414 const errors = [];
415
416 for (let i = 1; i < lines.length; i++) {
417 const cols = parseCSVLine(lines[i]);
418 if (cols.length === 0 || (cols.length === 1 && cols[0].trim() === '')) continue;
419
420 const rawDate = cols[dateIdx] ? cols[dateIdx].trim() : '';
421 const rawAmt = cols[amtIdx] ? cols[amtIdx].trim().replace(/[\$,]/g, '') : '0';
422 const rawDesc = cols[descIdx] ? cols[descIdx].trim() : 'Imported item';
423 let rawType = typeIdx !== -1 && cols[typeIdx] ? cols[typeIdx].trim().toLowerCase() : '';
424 let rawCat = catIdx !== -1 && cols[catIdx] ? cols[catIdx].trim().toLowerCase() : '';
425 const rawNotes = notesIdx !== -1 && cols[notesIdx] ? cols[notesIdx].trim() : '';
426
427 const amt = parseFloat(rawAmt);
428
429
430 if (!rawType) {
431 rawType = amt < 0 ? 'expense' : (rawCat === 'income' ? 'income' : 'expense');
432 }
433
434
435 if (!rawCat) {
436 rawCat = rawType === 'income' ? 'income' : 'food';
437 }
438
439 const tx = {
440 id: 'tx-import-' + i + '-' + Date.now(),
441 date: rawDate,
442 amount: Math.abs(amt),
443 type: rawType === 'income' ? 'income' : 'expense',
444 category: rawCat,
445 description: rawDesc,
446 notes: rawNotes
447 };
448
449 const val = validateTransaction(tx);
450 if (val.valid) {
451 imported.push(tx);
452 } else {
453 errors.push(`Row ${i + 1}: ${val.errors.join(' ')}`);
454 }
455 }
456
457 return {
458 success: imported.length > 0,
459 transactions: imported,
460 errors
461 };
462 }
463
464
465 exports.DEFAULT_CATEGORIES = DEFAULT_CATEGORIES;
466 exports.DEFAULT_TRANSACTIONS = DEFAULT_TRANSACTIONS;
467 exports.validateTransaction = validateTransaction;
468 exports.calculateSummary = calculateSummary;
469 exports.calculateCategorySpending = calculateCategorySpending;
470 exports.calculateDailySpendingTrends = calculateDailySpendingTrends;
471 exports.filterTransactions = filterTransactions;
472 exports.sortTransactions = sortTransactions;
473 exports.addTransaction = addTransaction;
474 exports.updateTransaction = updateTransaction;
475 exports.deleteTransaction = deleteTransaction;
476 exports.generateCSV = generateCSV;
477 exports.parseCSV = parseCSV;
478
479})(typeof exports !== 'undefined' ? exports : (window.FinanceData = {}));
480
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.