1
2
3
4
5(function () {
6 'use strict';
7
8
9 const STORAGE_KEY_TX = 'finance_app_transactions';
10 const STORAGE_KEY_THEME = 'finance_app_theme';
11
12 let transactions = [];
13 let categories = window.FinanceData.DEFAULT_CATEGORIES;
14
15 let currentFilters = {
16 search: '',
17 category: 'all',
18 type: 'all',
19 startDate: '',
20 endDate: ''
21 };
22
23 let currentSort = {
24 sortBy: 'date',
25 sortOrder: 'desc'
26 };
27
28 let editingTransactionId = null;
29 let hoveredChartIndex = -1;
30
31
32 const elements = {};
33
34 function initElements() {
35 elements.themeToggleBtn = document.getElementById('theme-toggle-btn');
36 elements.addTxBtn = document.getElementById('add-tx-btn');
37 elements.importCsvBtn = document.getElementById('import-csv-btn');
38 elements.exportCsvBtn = document.getElementById('export-csv-btn');
39 elements.shortcutsBtn = document.getElementById('shortcuts-btn');
40 elements.csvFileInput = document.getElementById('csv-file-input');
41
42
43 elements.netWorthVal = document.getElementById('net-worth-val');
44 elements.incomeVal = document.getElementById('income-val');
45 elements.expensesVal = document.getElementById('expenses-val');
46 elements.savingsVal = document.getElementById('savings-val');
47 elements.savingsRateVal = document.getElementById('savings-rate-val');
48
49
50 elements.searchInput = document.getElementById('search-input');
51 elements.categoryFilter = document.getElementById('category-filter');
52 elements.typeFilter = document.getElementById('type-filter');
53 elements.txTbody = document.getElementById('tx-tbody');
54 elements.txCount = document.getElementById('tx-count');
55
56
57 elements.budgetList = document.getElementById('budget-list');
58
59
60 elements.spendingCanvas = document.getElementById('spending-canvas');
61
62
63 elements.txModal = document.getElementById('tx-modal');
64 elements.txModalTitle = document.getElementById('tx-modal-title');
65 elements.txForm = document.getElementById('tx-form');
66 elements.txIdInput = document.getElementById('tx-id');
67 elements.txTypeInput = document.getElementById('tx-type');
68 elements.txDateInput = document.getElementById('tx-date');
69 elements.txAmountInput = document.getElementById('tx-amount');
70 elements.txCategoryInput = document.getElementById('tx-category');
71 elements.txDescInput = document.getElementById('tx-desc');
72 elements.txNotesInput = document.getElementById('tx-notes');
73 elements.modalCloseBtn = document.getElementById('modal-close-btn');
74 elements.modalCancelBtn = document.getElementById('modal-cancel-btn');
75
76
77 elements.shortcutsModal = document.getElementById('shortcuts-modal');
78 elements.shortcutsCloseBtn = document.getElementById('shortcuts-close-btn');
79
80
81 elements.toastContainer = document.getElementById('toast-container');
82 }
83
84
85 function loadInitialData() {
86 try {
87 const stored = localStorage.getItem(STORAGE_KEY_TX);
88 if (stored) {
89 transactions = JSON.parse(stored);
90 } else {
91 transactions = [...window.FinanceData.DEFAULT_TRANSACTIONS];
92 saveData();
93 }
94 } catch (e) {
95 console.warn('Failed to load from localStorage, using defaults', e);
96 transactions = [...window.FinanceData.DEFAULT_TRANSACTIONS];
97 }
98 }
99
100
101 function saveData() {
102 try {
103 localStorage.setItem(STORAGE_KEY_TX, JSON.stringify(transactions));
104 } catch (e) {
105 console.error('Failed to save to localStorage', e);
106 }
107 }
108
109
110 function initTheme() {
111 const savedTheme = localStorage.getItem(STORAGE_KEY_THEME) || 'dark';
112 setTheme(savedTheme);
113 }
114
115 function setTheme(theme) {
116 if (theme === 'dark') {
117 document.documentElement.setAttribute('data-theme', 'dark');
118 if (elements.themeToggleBtn) elements.themeToggleBtn.innerHTML = '☀️';
119 } else {
120 document.documentElement.removeAttribute('data-theme');
121 if (elements.themeToggleBtn) elements.themeToggleBtn.innerHTML = '🌙';
122 }
123 localStorage.setItem(STORAGE_KEY_THEME, theme);
124 renderCharts();
125 }
126
127 function toggleTheme() {
128 const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
129 setTheme(isDark ? 'light' : 'dark');
130 showToast(`Switched to ${isDark ? 'light' : 'dark'} theme`, 'info');
131 }
132
133
134 function showToast(message, type = 'info') {
135 if (!elements.toastContainer) return;
136 const toast = document.createElement('div');
137 toast.className = `toast toast-${type}`;
138 toast.innerHTML = `
139 <span>${type === 'success' ? '✓' : type === 'error' ? '⚠️' : 'ℹ️'}</span>
140 <span>${message}</span>
141 `;
142 elements.toastContainer.appendChild(toast);
143
144 setTimeout(() => toast.classList.add('show'), 10);
145 setTimeout(() => {
146 toast.classList.remove('show');
147 setTimeout(() => toast.remove(), 300);
148 }, 3000);
149 }
150
151
152 function populateCategories() {
153 if (!elements.categoryFilter || !elements.txCategoryInput) return;
154
155 elements.categoryFilter.innerHTML = '<option value="all">All Categories</option>';
156 elements.txCategoryInput.innerHTML = '';
157
158 categories.forEach(cat => {
159
160 const opt = document.createElement('option');
161 opt.value = cat.id;
162 opt.textContent = `${cat.icon} ${cat.name}`;
163 elements.categoryFilter.appendChild(opt);
164
165
166 const formOpt = document.createElement('option');
167 formOpt.value = cat.id;
168 formOpt.textContent = `${cat.icon} ${cat.name}`;
169 elements.txCategoryInput.appendChild(formOpt);
170 });
171 }
172
173
174 function renderSummary() {
175 const summary = window.FinanceData.calculateSummary(transactions);
176
177 if (elements.netWorthVal) {
178 const net = summary.netSavings;
179 elements.netWorthVal.textContent = (net >= 0 ? '$' : '-$') + Math.abs(net).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
180 elements.netWorthVal.style.color = net >= 0 ? 'var(--text-primary)' : 'var(--expense-color)';
181 }
182
183 if (elements.incomeVal) {
184 elements.incomeVal.textContent = '$' + summary.totalIncome.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
185 }
186
187 if (elements.expensesVal) {
188 elements.expensesVal.textContent = '$' + summary.totalExpenses.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
189 }
190
191 if (elements.savingsVal) {
192 elements.savingsVal.textContent = '$' + summary.netSavings.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
193 }
194
195 if (elements.savingsRateVal) {
196 elements.savingsRateVal.textContent = `${summary.savingsRate}% rate`;
197 }
198 }
199
200
201 function renderBudgets() {
202 if (!elements.budgetList) return;
203
204 const categorySpending = window.FinanceData.calculateCategorySpending(transactions, categories);
205 elements.budgetList.innerHTML = '';
206
207 categorySpending.forEach(cat => {
208 if (cat.budget <= 0) return;
209
210 const item = document.createElement('div');
211 item.className = 'budget-item';
212
213 const fillClass = cat.status === 'danger' ? 'fill-danger' : cat.status === 'warning' ? 'fill-warning' : 'fill-good';
214
215 item.innerHTML = `
216 <div class="budget-info">
217 <span class="budget-label">
218 <span>${cat.icon}</span>
219 <span>${cat.name}</span>
220 </span>
221 <span class="budget-values">
222 $${cat.spent.toLocaleString()} / $${cat.budget.toLocaleString()} (${cat.percentage}%)
223 </span>
224 </div>
225 <div class="progress-track">
226 <div class="progress-fill ${fillClass}" style="width: ${Math.min(cat.percentage, 100)}%;"></div>
227 </div>
228 `;
229
230 elements.budgetList.appendChild(item);
231 });
232 }
233
234
235 function renderCharts() {
236 if (!elements.spendingCanvas || !window.FinanceCharts) return;
237
238 const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
239 const trendData = window.FinanceData.calculateDailySpendingTrends(transactions, 30);
240
241 window.FinanceCharts.renderSpendingChart(elements.spendingCanvas, trendData, {
242 isDark,
243 hoveredIndex: hoveredChartIndex
244 });
245 }
246
247
248 function initChartInteractions() {
249 if (!elements.spendingCanvas) return;
250
251 elements.spendingCanvas.addEventListener('mousemove', (e) => {
252 const rect = elements.spendingCanvas.getBoundingClientRect();
253 const x = e.clientX - rect.left;
254 const width = rect.width;
255 const paddingLeft = 55;
256 const paddingRight = 20;
257 const plotWidth = width - paddingLeft - paddingRight;
258
259 const trendData = window.FinanceData.calculateDailySpendingTrends(transactions, 30);
260 if (trendData.length === 0) return;
261
262 if (x >= paddingLeft && x <= width - paddingRight) {
263 const ratio = (x - paddingLeft) / plotWidth;
264 const idx = Math.round(ratio * (trendData.length - 1));
265 if (idx !== hoveredChartIndex && idx >= 0 && idx < trendData.length) {
266 hoveredChartIndex = idx;
267 renderCharts();
268 }
269 } else if (hoveredChartIndex !== -1) {
270 hoveredChartIndex = -1;
271 renderCharts();
272 }
273 });
274
275 elements.spendingCanvas.addEventListener('mouseleave', () => {
276 if (hoveredChartIndex !== -1) {
277 hoveredChartIndex = -1;
278 renderCharts();
279 }
280 });
281 }
282
283
284 function renderTransactions() {
285 if (!elements.txTbody) return;
286
287 const filtered = window.FinanceData.filterTransactions(transactions, currentFilters);
288 const sorted = window.FinanceData.sortTransactions(filtered, currentSort.sortBy, currentSort.sortOrder);
289
290 if (elements.txCount) {
291 elements.txCount.textContent = `Showing ${sorted.length} of ${transactions.length} transactions`;
292 }
293
294 if (sorted.length === 0) {
295 elements.txTbody.innerHTML = `
296 <tr>
297 <td colspan="6">
298 <div class="empty-state">
299 <div>🔍</div>
300 <p>No transactions found matching your criteria</p>
301 </div>
302 </td>
303 </tr>
304 `;
305 return;
306 }
307
308 elements.txTbody.innerHTML = '';
309
310 sorted.forEach(tx => {
311 const tr = document.createElement('tr');
312 const catObj = categories.find(c => c.id === tx.category) || { name: tx.category, icon: '🏷️' };
313 const isIncome = tx.type === 'income';
314
315 tr.innerHTML = `
316 <td>${tx.date}</td>
317 <td>
318 <span class="badge ${isIncome ? 'badge-income' : 'badge-expense'}">
319 ${isIncome ? '📈 Income' : '📉 Expense'}
320 </span>
321 </td>
322 <td>
323 <span>${catObj.icon} ${catObj.name}</span>
324 </td>
325 <td>
326 <strong>${escapeHtml(tx.description)}</strong>
327 ${tx.notes ? `<div style="font-size:11px; color:var(--text-muted);">${escapeHtml(tx.notes)}</div>` : ''}
328 </td>
329 <td class="${isIncome ? 'amount-income' : 'amount-expense'}">
330 ${isIncome ? '+' : '-'}$${parseFloat(tx.amount).toFixed(2)}
331 </td>
332 <td>
333 <div class="table-actions">
334 <button class="btn btn-secondary btn-xs edit-btn" data-id="${tx.id}">Edit</button>
335 <button class="btn btn-danger btn-xs delete-btn" data-id="${tx.id}">Delete</button>
336 </div>
337 </td>
338 `;
339
340 elements.txTbody.appendChild(tr);
341 });
342
343
344 elements.txTbody.querySelectorAll('.edit-btn').forEach(btn => {
345 btn.addEventListener('click', (e) => {
346 const id = e.currentTarget.getAttribute('data-id');
347 openTxModal(id);
348 });
349 });
350
351 elements.txTbody.querySelectorAll('.delete-btn').forEach(btn => {
352 btn.addEventListener('click', (e) => {
353 const id = e.currentTarget.getAttribute('data-id');
354 handleDeleteTx(id);
355 });
356 });
357 }
358
359 function escapeHtml(str) {
360 if (!str) return '';
361 return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
362 }
363
364
365 function openTxModal(txId = null) {
366 editingTransactionId = txId;
367
368 if (txId) {
369
370 const tx = transactions.find(t => t.id === txId);
371 if (!tx) return;
372
373 elements.txModalTitle.textContent = 'Edit Transaction';
374 elements.txIdInput.value = tx.id;
375 elements.txTypeInput.value = tx.type;
376 elements.txDateInput.value = tx.date;
377 elements.txAmountInput.value = tx.amount;
378 elements.txCategoryInput.value = tx.category;
379 elements.txDescInput.value = tx.description;
380 elements.txNotesInput.value = tx.notes || '';
381 } else {
382
383 elements.txModalTitle.textContent = 'Add New Transaction';
384 elements.txForm.reset();
385 elements.txIdInput.value = '';
386 elements.txDateInput.value = new Date().toISOString().split('T')[0];
387 elements.txTypeInput.value = 'expense';
388 elements.txCategoryInput.value = 'food';
389 }
390
391 elements.txModal.classList.add('active');
392 setTimeout(() => elements.txDescInput.focus(), 100);
393 }
394
395 function closeTxModal() {
396 elements.txModal.classList.remove('active');
397 editingTransactionId = null;
398 }
399
400 function handleFormSubmit(e) {
401 e.preventDefault();
402
403 const txData = {
404 id: elements.txIdInput.value || null,
405 date: elements.txDateInput.value,
406 type: elements.txTypeInput.value,
407 amount: parseFloat(elements.txAmountInput.value),
408 category: elements.txCategoryInput.value,
409 description: elements.txDescInput.value,
410 notes: elements.txNotesInput.value
411 };
412
413 try {
414 if (editingTransactionId) {
415 transactions = window.FinanceData.updateTransaction(transactions, editingTransactionId, txData);
416 showToast('Transaction updated successfully', 'success');
417 } else {
418 transactions = window.FinanceData.addTransaction(transactions, txData);
419 showToast('Transaction added successfully', 'success');
420 }
421
422 saveData();
423 refreshAll();
424 closeTxModal();
425 } catch (err) {
426 showToast(err.message, 'error');
427 }
428 }
429
430 function handleDeleteTx(id) {
431 const tx = transactions.find(t => t.id === id);
432 if (!tx) return;
433
434 if (confirm(`Are you sure you want to delete "${tx.description}"?`)) {
435 transactions = window.FinanceData.deleteTransaction(transactions, id);
436 saveData();
437 refreshAll();
438 showToast('Transaction deleted', 'info');
439 }
440 }
441
442
443 function handleCsvExport() {
444 const csvContent = window.FinanceData.generateCSV(transactions);
445 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
446 const url = URL.createObjectURL(blob);
447 const link = document.createElement('a');
448 link.setAttribute('href', url);
449 link.setAttribute('download', `finance_export_${new Date().toISOString().split('T')[0]}.csv`);
450 document.body.appendChild(link);
451 link.click();
452 document.body.removeChild(link);
453 showToast('CSV export downloaded', 'success');
454 }
455
456
457 function handleCsvImport(e) {
458 const file = e.target.files[0];
459 if (!file) return;
460
461 const reader = new FileReader();
462 reader.onload = function (evt) {
463 const text = evt.target.result;
464 const parsed = window.FinanceData.parseCSV(text);
465
466 if (!parsed.success) {
467 showToast('CSV Import failed: ' + parsed.errors.join('; '), 'error');
468 return;
469 }
470
471 const replaceAll = confirm(`Found ${parsed.transactions.length} valid transactions in CSV.\n\nClick 'OK' to REPLACE current transactions, or 'Cancel' to MERGE them.`);
472
473 if (replaceAll) {
474 transactions = parsed.transactions;
475 } else {
476 transactions = [...parsed.transactions, ...transactions];
477 }
478
479 saveData();
480 refreshAll();
481 showToast(`Imported ${parsed.transactions.length} transactions successfully!`, 'success');
482 e.target.value = '';
483 };
484 reader.readAsText(file);
485 }
486
487
488 function openShortcutsModal() {
489 elements.shortcutsModal.classList.add('active');
490 }
491
492 function closeShortcutsModal() {
493 elements.shortcutsModal.classList.remove('active');
494 }
495
496
497 function initKeyboardShortcuts() {
498 document.addEventListener('keydown', (e) => {
499
500 const isInputActive = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName);
501
502 if (e.key === 'Escape') {
503 closeTxModal();
504 closeShortcutsModal();
505 return;
506 }
507
508 if (isInputActive) return;
509
510 if (e.key === 'n' || e.key === 'N') {
511 e.preventDefault();
512 openTxModal();
513 } else if (e.key === '/') {
514 e.preventDefault();
515 if (elements.searchInput) elements.searchInput.focus();
516 } else if (e.key === 'd' || e.key === 'D') {
517 e.preventDefault();
518 toggleTheme();
519 } else if (e.key === '?') {
520 e.preventDefault();
521 openShortcutsModal();
522 }
523 });
524 }
525
526
527 function refreshAll() {
528 renderSummary();
529 renderBudgets();
530 renderCharts();
531 renderTransactions();
532 }
533
534
535 function bindEvents() {
536
537 if (elements.themeToggleBtn) {
538 elements.themeToggleBtn.addEventListener('click', toggleTheme);
539 }
540
541
542 if (elements.addTxBtn) {
543 elements.addTxBtn.addEventListener('click', () => openTxModal());
544 }
545
546
547 if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeTxModal);
548 if (elements.modalCancelBtn) elements.modalCancelBtn.addEventListener('click', closeTxModal);
549
550
551 if (elements.txForm) elements.txForm.addEventListener('submit', handleFormSubmit);
552
553
554 if (elements.searchInput) {
555 elements.searchInput.addEventListener('input', (e) => {
556 currentFilters.search = e.target.value;
557 renderTransactions();
558 });
559 }
560
561 if (elements.categoryFilter) {
562 elements.categoryFilter.addEventListener('change', (e) => {
563 currentFilters.category = e.target.value;
564 renderTransactions();
565 });
566 }
567
568 if (elements.typeFilter) {
569 elements.typeFilter.addEventListener('change', (e) => {
570 currentFilters.type = e.target.value;
571 renderTransactions();
572 });
573 }
574
575
576 if (elements.exportCsvBtn) {
577 elements.exportCsvBtn.addEventListener('click', handleCsvExport);
578 }
579
580 if (elements.importCsvBtn) {
581 elements.importCsvBtn.addEventListener('click', () => elements.csvFileInput.click());
582 }
583
584 if (elements.csvFileInput) {
585 elements.csvFileInput.addEventListener('change', handleCsvImport);
586 }
587
588
589 if (elements.shortcutsBtn) {
590 elements.shortcutsBtn.addEventListener('click', openShortcutsModal);
591 }
592 if (elements.shortcutsCloseBtn) {
593 elements.shortcutsCloseBtn.addEventListener('click', closeShortcutsModal);
594 }
595
596
597 window.addEventListener('resize', () => {
598 renderCharts();
599 });
600 }
601
602
603 function init() {
604 initElements();
605 loadInitialData();
606 populateCategories();
607 initTheme();
608 bindEvents();
609 initChartInteractions();
610 initKeyboardShortcuts();
611 refreshAll();
612 }
613
614
615 if (document.readyState === 'loading') {
616 document.addEventListener('DOMContentLoaded', init);
617 } else {
618 init();
619 }
620
621})();
622
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.