1
2
3(function () {
4 'use strict';
5
6 var FD = window.FinanceData;
7 var FC = window.FinanceCharts;
8 var $ = function (sel) { return document.querySelector(sel); };
9
10 var state = {
11 transactions: [],
12 budgets: {},
13 filters: { category: 'all', range: '6m', search: '' },
14 editingId: null
15 };
16
17 function todayIso() {
18 var d = new Date();
19 var pad = function (n) { return String(n).padStart(2, '0'); };
20 return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
21 }
22
23 function currentMonthKey() {
24 return FD.monthKey(todayIso());
25 }
26
27 function catDotClass(category) {
28 var i = FD.CATEGORIES.indexOf(category);
29 return i === -1 ? 'cat-income' : 'cat-' + i;
30 }
31
32 function escapeHtml(s) {
33 return String(s).replace(/[&<>"']/g, function (c) {
34 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
35 });
36 }
37
38 function save() {
39 FD.saveTransactions(localStorage, state.transactions);
40 }
41
42
43
44 var RANGE_MONTHS = { month: 1, '3m': 3, '6m': 6, '12m': 12 };
45
46 function rangeFromDate() {
47 var n = RANGE_MONTHS[state.filters.range];
48 if (!n) { return null; }
49 return FD.monthAdd(currentMonthKey(), -(n - 1)) + '-01';
50 }
51
52 function chartBounds() {
53 var n = RANGE_MONTHS[state.filters.range];
54 if (!n) { return {}; }
55 return {
56 fromMonth: FD.monthAdd(currentMonthKey(), -(n - 1)),
57 toMonth: currentMonthKey()
58 };
59 }
60
61 function visibleTransactions() {
62 return FD.filterTransactions(state.transactions, {
63 category: state.filters.category,
64 search: state.filters.search,
65 from: rangeFromDate()
66 });
67 }
68
69
70
71 function renderAll() {
72 renderStats();
73 renderChart();
74 renderBudgets();
75 renderTable();
76 }
77
78 function renderStats() {
79 var curKey = currentMonthKey();
80 var prevKey = FD.monthAdd(curKey, -1);
81 var cur = FD.totals(state.transactions, curKey);
82 var prev = FD.totals(state.transactions, prevKey);
83
84 $('#hero-value').textContent = FD.formatCurrency(cur.spent);
85 var deltaEl = $('#hero-delta');
86 if (cur.spent === 0 && prev.spent === 0) {
87 deltaEl.textContent = '';
88 } else {
89 var diff = FD.round2(cur.spent - prev.spent);
90 var span = document.createElement('span');
91 if (diff === 0) {
92 span.textContent = 'level with ' + FD.monthLabel(prevKey);
93 } else {
94
95 span.className = diff > 0 ? 'delta-bad' : 'delta-good';
96 span.textContent = (diff > 0 ? '▲ ' : '▼ ') + FD.formatCurrency(Math.abs(diff));
97 }
98 deltaEl.textContent = '';
99 deltaEl.appendChild(span);
100 if (diff !== 0) {
101 deltaEl.appendChild(document.createTextNode(' vs ' + FD.monthLabel(prevKey)));
102 }
103 }
104
105 $('#income-value').textContent = FD.formatCurrency(cur.income);
106 var netEl = $('#net-value');
107 netEl.textContent = (cur.net > 0 ? '+' : '') + FD.formatCurrency(cur.net);
108 netEl.classList.toggle('net-negative', cur.net < 0);
109 }
110
111 var RANGE_LABELS = {
112 month: 'this month', '3m': 'last 3 months', '6m': 'last 6 months',
113 '12m': 'last 12 months', all: 'all time'
114 };
115
116 function chartColors() {
117 var css = getComputedStyle(document.documentElement);
118 var get = function (name) { return css.getPropertyValue(name).trim(); };
119 return {
120 line: get('--series-1'),
121 surface: get('--surface-1'),
122 grid: get('--grid'),
123 axis: get('--axis'),
124 text: get('--text-primary'),
125 muted: get('--text-muted')
126 };
127 }
128
129 function renderChart() {
130 var list = visibleTransactions();
131 var series = FD.monthlySeries(list, chartBounds());
132 var canvas = $('#spend-chart');
133
134 var catLabel = state.filters.category === 'all' ? 'All categories' : state.filters.category;
135 $('#chart-subtitle').textContent = catLabel + ' · ' + RANGE_LABELS[state.filters.range];
136
137 FC.drawSpendingChart(canvas, series, {
138 colors: chartColors(),
139 formatValue: FD.formatCurrency,
140 formatTick: FD.formatCompactCurrency
141 });
142 FC.attachHover(canvas, $('#chart-tooltip'), {
143 label: FD.monthLabel,
144 value: FD.formatCurrency
145 });
146
147 if (series.length) {
148 var total = FD.round2(series.reduce(function (s, p) { return s + p.total; }, 0));
149 canvas.setAttribute('aria-label',
150 'Line chart of monthly spending, ' + FD.monthLabel(series[0].month) + ' to ' +
151 FD.monthLabel(series[series.length - 1].month) + ', ' + catLabel + ', total ' +
152 FD.formatCurrency(total) + '. Details are in the transactions table.');
153 } else {
154 canvas.setAttribute('aria-label', 'Line chart of monthly spending: no transactions in this range.');
155 }
156 }
157
158 function renderBudgets() {
159 var curKey = currentMonthKey();
160 $('#budget-month').textContent = FD.monthLabel(curKey);
161 var spending = FD.spendingByCategory(state.transactions, curKey);
162 var listEl = $('#budget-list');
163 listEl.textContent = '';
164
165 FD.CATEGORIES.forEach(function (cat, i) {
166 var spent = spending[cat] || 0;
167 var budget = state.budgets[cat];
168 var st = FD.budgetStatus(spent, budget);
169 var pct = st.ratio === null ? 0 : Math.round(st.ratio * 100);
170 var level = st.level === 'none' ? 'ok' : st.level;
171
172 var row = document.createElement('div');
173 row.className = 'budget-row';
174
175 var note;
176 var noteIcon = '';
177 if (st.level === 'over') {
178 noteIcon = '<span class="status-ic level-over" aria-hidden="true">⚠</span>';
179 note = 'Over by ' + FD.formatCurrency(FD.round2(spent - budget)) + ' · ' + pct + '% of budget';
180 } else if (st.level === 'warning') {
181 noteIcon = '<span class="status-ic level-warning" aria-hidden="true">●</span>';
182 note = 'Nearing limit · ' + pct + '% used';
183 } else if (st.level === 'none') {
184 note = 'No budget set';
185 } else {
186 note = pct + '% used';
187 }
188
189 row.innerHTML =
190 '<div class="budget-top">' +
191 '<span class="budget-name"><span class="cat-dot cat-' + i + '"></span>' + escapeHtml(cat) + '</span>' +
192 '<span class="budget-nums">' + FD.formatCurrency(spent) + ' <span>of</span> $' +
193 '<input class="budget-input" type="number" min="0" step="10" value="' + (budget || 0) + '"' +
194 ' data-cat="' + escapeHtml(cat) + '" aria-label="Monthly budget for ' + escapeHtml(cat) + '">' +
195 '</span>' +
196 '</div>' +
197 '<div class="budget-track level-' + level + '" role="progressbar" aria-valuemin="0" aria-valuemax="100"' +
198 ' aria-valuenow="' + Math.min(pct, 100) + '" aria-label="' + escapeHtml(cat) + ' budget used">' +
199 '<div class="budget-fill" style="width:' + Math.min(pct, 100) + '%"></div>' +
200 '</div>' +
201 '<div class="budget-note">' + noteIcon + '<span>' + escapeHtml(note) + '</span></div>';
202
203 listEl.appendChild(row);
204 });
205 }
206
207 function renderTable() {
208 var visible = FD.sortTransactions(visibleTransactions());
209 var tbody = $('#txn-tbody');
210
211 $('#txn-count').textContent = visible.length === state.transactions.length
212 ? visible.length + ' transaction' + (visible.length === 1 ? '' : 's')
213 : visible.length + ' shown · ' + state.transactions.length + ' total';
214
215 if (!visible.length) {
216 tbody.innerHTML = '<tr class="empty"><td colspan="5">No transactions match. Add one above or press <kbd>i</kbd> to import a CSV.</td></tr>';
217 return;
218 }
219
220 tbody.innerHTML = visible.map(function (t) {
221 var income = t.amount > 0;
222 return '<tr data-id="' + escapeHtml(t.id) + '"' + (t.id === state.editingId ? ' class="editing"' : '') + '>' +
223 '<td class="td-date">' + escapeHtml(t.date) + '</td>' +
224 '<td>' + escapeHtml(t.description) + '</td>' +
225 '<td class="td-category"><span class="cat-dot ' + catDotClass(t.category) + '"></span>' + escapeHtml(t.category) + '</td>' +
226 '<td class="td-amount' + (income ? ' income' : '') + '">' + (income ? '+' : '') + FD.formatCurrency(t.amount) + '</td>' +
227 '<td class="td-actions">' +
228 '<button type="button" class="btn btn-sm" data-action="edit">Edit</button> ' +
229 '<button type="button" class="btn btn-sm btn-danger" data-action="delete">Delete</button>' +
230 '</td>' +
231 '</tr>';
232 }).join('');
233 }
234
235
236
237 function resetForm() {
238 state.editingId = null;
239 $('#f-date').value = todayIso();
240 $('#f-desc').value = '';
241 $('#f-category').value = FD.CATEGORIES[0];
242 $('#f-amount').value = '';
243 $('#f-submit').textContent = 'Add';
244 $('#f-cancel').hidden = true;
245 $('#form-error').textContent = '';
246 }
247
248 function startEdit(id) {
249 var t = FD.getTransaction(state.transactions, id);
250 if (!t) { return; }
251 state.editingId = id;
252 $('#f-date').value = t.date;
253 $('#f-desc').value = t.description;
254 $('#f-category').value = t.category;
255 $('#f-amount').value = Math.abs(t.amount).toFixed(2);
256 $('#f-submit').textContent = 'Save changes';
257 $('#f-cancel').hidden = false;
258 $('#form-error').textContent = '';
259 renderTable();
260 $('#txn-form').scrollIntoView({ block: 'center', behavior: 'smooth' });
261 $('#f-desc').focus();
262 }
263
264 function cancelEdit() {
265 resetForm();
266 renderTable();
267 }
268
269 function submitForm(e) {
270 e.preventDefault();
271 var input = {
272 date: $('#f-date').value,
273 description: $('#f-desc').value,
274 category: $('#f-category').value,
275 amount: parseFloat($('#f-amount').value)
276 };
277 try {
278 if (state.editingId) {
279 state.transactions = FD.updateTransaction(state.transactions, state.editingId, input);
280 } else {
281 state.transactions = FD.addTransaction(state.transactions, input);
282 }
283 } catch (err) {
284 $('#form-error').textContent = err.message;
285 return;
286 }
287 save();
288 resetForm();
289 renderAll();
290 $('#f-desc').focus();
291 }
292
293
294
295 function exportCSV() {
296 var csv = FD.toCSV(FD.sortTransactions(state.transactions));
297 var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
298 var url = URL.createObjectURL(blob);
299 var a = document.createElement('a');
300 a.href = url;
301 a.download = 'transactions-' + todayIso() + '.csv';
302 document.body.appendChild(a);
303 a.click();
304 a.remove();
305 URL.revokeObjectURL(url);
306 setStatus('Exported ' + state.transactions.length + ' transactions.');
307 }
308
309 function setStatus(msg) {
310 $('#import-status').textContent = msg;
311 }
312
313 function importCSVText(text) {
314 var result = FD.transactionsFromCSV(text);
315 if (result.transactions.length) {
316 state.transactions = state.transactions.concat(result.transactions);
317 save();
318 renderAll();
319 }
320 var msg = result.transactions.length
321 ? 'Imported ' + result.transactions.length + ' transaction' + (result.transactions.length === 1 ? '' : 's') + '.'
322 : 'Nothing imported.';
323 if (result.errors.length) {
324 msg += ' Skipped ' + result.errors.length + ' row' + (result.errors.length === 1 ? '' : 's') +
325 ' — ' + result.errors.slice(0, 3).join(' · ') + (result.errors.length > 3 ? ' · …' : '');
326 }
327 setStatus(msg);
328 }
329
330
331
332 function applyThemeButton() {
333 var dark = document.documentElement.getAttribute('data-theme') !== 'light';
334 var btn = $('#theme-btn');
335 btn.textContent = dark ? 'Light mode' : 'Dark mode';
336 btn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
337 }
338
339 function toggleTheme() {
340 var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
341 document.documentElement.setAttribute('data-theme', next);
342 try { localStorage.setItem(FD.STORAGE_KEYS.theme, next); } catch (e) { }
343 applyThemeButton();
344 renderChart();
345 }
346
347
348
349 function onKeydown(e) {
350 var helpDialog = $('#help-dialog');
351 if (e.key === 'Escape') {
352 if (!helpDialog.open && state.editingId) { cancelEdit(); }
353 return;
354 }
355 var el = e.target;
356 var typing = el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ||
357 el.tagName === 'SELECT' || el.isContentEditable);
358 if (typing || e.metaKey || e.ctrlKey || e.altKey) { return; }
359
360 switch (e.key) {
361 case 'n':
362 e.preventDefault();
363 resetForm();
364 $('#f-desc').focus();
365 break;
366 case '/':
367 e.preventDefault();
368 $('#filter-search').focus();
369 break;
370 case 'e':
371 e.preventDefault();
372 exportCSV();
373 break;
374 case 'i':
375 e.preventDefault();
376 $('#import-file').click();
377 break;
378 case 't':
379 e.preventDefault();
380 toggleTheme();
381 break;
382 case '?':
383 e.preventDefault();
384 if (helpDialog.open) { helpDialog.close(); } else { helpDialog.showModal(); }
385 break;
386 }
387 }
388
389
390
391 function populateSelects() {
392 var filterSel = $('#filter-category');
393 filterSel.innerHTML = '<option value="all">All categories</option>' +
394 FD.ALL_CATEGORIES.map(function (c) {
395 return '<option value="' + escapeHtml(c) + '">' + escapeHtml(c) + '</option>';
396 }).join('');
397 $('#f-category').innerHTML = FD.CATEGORIES.concat([FD.INCOME_CATEGORY]).map(function (c) {
398 return '<option value="' + escapeHtml(c) + '">' + escapeHtml(c) + '</option>';
399 }).join('');
400 }
401
402 function bindEvents() {
403 $('#txn-form').addEventListener('submit', submitForm);
404 $('#f-cancel').addEventListener('click', cancelEdit);
405
406 $('#txn-tbody').addEventListener('click', function (e) {
407 var btn = e.target.closest('button[data-action]');
408 if (!btn) { return; }
409 var id = btn.closest('tr').getAttribute('data-id');
410 if (btn.getAttribute('data-action') === 'edit') {
411 startEdit(id);
412 } else {
413 var t = FD.getTransaction(state.transactions, id);
414 if (t && confirm('Delete "' + t.description + '" (' + FD.formatCurrency(t.amount) + ')?')) {
415 state.transactions = FD.deleteTransaction(state.transactions, id);
416 if (state.editingId === id) { resetForm(); }
417 save();
418 renderAll();
419 }
420 }
421 });
422
423 $('#filter-category').addEventListener('change', function (e) {
424 state.filters.category = e.target.value;
425 renderAll();
426 });
427 $('#filter-range').addEventListener('change', function (e) {
428 state.filters.range = e.target.value;
429 renderAll();
430 });
431 $('#filter-search').addEventListener('input', function (e) {
432 state.filters.search = e.target.value;
433 renderChart();
434 renderTable();
435 });
436 $('#clear-filters').addEventListener('click', function () {
437 state.filters = { category: 'all', range: '6m', search: '' };
438 $('#filter-category').value = 'all';
439 $('#filter-range').value = '6m';
440 $('#filter-search').value = '';
441 renderAll();
442 });
443
444 $('#export-btn').addEventListener('click', exportCSV);
445 $('#import-btn').addEventListener('click', function () { $('#import-file').click(); });
446 $('#import-file').addEventListener('change', function (e) {
447 var file = e.target.files && e.target.files[0];
448 if (!file) { return; }
449 var reader = new FileReader();
450 reader.onload = function () { importCSVText(String(reader.result)); };
451 reader.onerror = function () { setStatus('Could not read that file.'); };
452 reader.readAsText(file);
453 e.target.value = '';
454 });
455
456 $('#theme-btn').addEventListener('click', toggleTheme);
457 $('#help-btn').addEventListener('click', function () { $('#help-dialog').showModal(); });
458
459 $('#budget-list').addEventListener('change', function (e) {
460 var input = e.target.closest('.budget-input');
461 if (!input) { return; }
462 var v = parseFloat(input.value);
463 state.budgets[input.getAttribute('data-cat')] = isFinite(v) && v >= 0 ? FD.round2(v) : 0;
464 FD.saveBudgets(localStorage, state.budgets);
465 renderBudgets();
466 });
467
468 document.addEventListener('keydown', onKeydown);
469
470 var resizeTimer = null;
471 window.addEventListener('resize', function () {
472 clearTimeout(resizeTimer);
473 resizeTimer = setTimeout(renderChart, 150);
474 });
475 }
476
477 function init() {
478 var stored = FD.loadTransactions(localStorage);
479 if (stored === null) {
480 state.transactions = FD.demoData(todayIso());
481 save();
482 setStatus('Loaded demo data to explore — edit or delete anything, it’s all yours.');
483 } else {
484 state.transactions = stored;
485 }
486 state.budgets = FD.loadBudgets(localStorage);
487
488 populateSelects();
489 bindEvents();
490 resetForm();
491 applyThemeButton();
492 renderAll();
493 }
494
495 init();
496})();
497
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.