1
2
3
4
5(function () {
6 'use strict';
7
8 var F = window.Finance;
9 var C = window.Charts;
10
11 var LS_TX = 'finance.transactions.v1';
12 var LS_BUDGETS = 'finance.budgets.v1';
13
14 var state = {
15 txs: [],
16 budgets: {},
17 filters: { category: 'All', search: '', month: 'All' },
18 budgetMonth: todayStr().slice(0, 7),
19 editingId: null,
20 chart: null
21 };
22
23 function todayStr() {
24 var d = new Date();
25 var m = d.getMonth() + 1, day = d.getDate();
26 return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day);
27 }
28
29 var $ = function (sel) { return document.querySelector(sel); };
30 var els = {};
31
32
33
34 function loadState() {
35 var raw = null;
36 try { raw = localStorage.getItem(LS_TX); } catch (e) { }
37 if (raw) {
38 try { state.txs = JSON.parse(raw) || []; } catch (e) { state.txs = []; }
39 } else {
40 state.txs = F.sampleData(todayStr());
41 saveTxs();
42 }
43 var rawB = null;
44 try { rawB = localStorage.getItem(LS_BUDGETS); } catch (e) { }
45 if (rawB) {
46 try { state.budgets = JSON.parse(rawB) || {}; } catch (e) { state.budgets = null; }
47 }
48 if (!state.budgets || !Object.keys(state.budgets).length) {
49 state.budgets = Object.assign({}, F.DEFAULT_BUDGETS);
50 }
51 }
52
53 function saveTxs() {
54 try { localStorage.setItem(LS_TX, JSON.stringify(state.txs)); } catch (e) { }
55 }
56 function saveBudgets() {
57 try { localStorage.setItem(LS_BUDGETS, JSON.stringify(state.budgets)); } catch (e) { }
58 }
59
60
61
62 function render() {
63 renderStats();
64 renderChart();
65 renderBudgets();
66 renderMonthOptions();
67 renderTable();
68 }
69
70 function renderStats() {
71 var month = todayStr().slice(0, 7);
72 var totals = F.monthlyTotals(state.txs)[month] || { spent: 0, income: 0, net: 0 };
73 els.statSpent.textContent = F.formatMoney(totals.spent);
74 els.statIncome.textContent = F.formatMoney(totals.income);
75 els.statNet.textContent = F.formatMoney(totals.net);
76 els.statNet.classList.toggle('is-negative', totals.net < 0);
77 els.statMonthLabel.textContent = F.monthLabel(month);
78 }
79
80 function renderChart() {
81 var series = F.monthlySeries(state.txs, todayStr().slice(0, 7), 12);
82 state.chart = C.spendingChart(els.chartCanvas, els.chartTooltip, series, {
83 formatShort: F.formatMoneyShort,
84 formatLong: F.formatMoney,
85 monthLabel: F.monthLabel
86 });
87 }
88
89 function renderBudgets() {
90 els.budgetMonthLabel.textContent = F.monthLabel(state.budgetMonth);
91 var rows = F.budgetStatus(state.txs, state.budgets, state.budgetMonth);
92 C.renderBudgetBars(els.budgetList, rows, {
93 formatMoney: F.formatMoney,
94 onEdit: openBudgetEditor
95 });
96 }
97
98 function renderMonthOptions() {
99 var months = {};
100 state.txs.forEach(function (t) { months[F.monthKey(t.date)] = true; });
101 var keys = Object.keys(months).sort().reverse();
102 var current = state.filters.month;
103 els.filterMonth.innerHTML = '';
104 var all = document.createElement('option');
105 all.value = 'All';
106 all.textContent = 'All months';
107 els.filterMonth.appendChild(all);
108 keys.forEach(function (m) {
109 var opt = document.createElement('option');
110 opt.value = m;
111 opt.textContent = F.monthLabel(m);
112 els.filterMonth.appendChild(opt);
113 });
114 els.filterMonth.value = keys.indexOf(current) !== -1 ? current : 'All';
115 state.filters.month = els.filterMonth.value;
116 }
117
118 function renderTable() {
119 var visible = F.sortByDateDesc(F.filterTransactions(state.txs, state.filters));
120 els.txCount.textContent = visible.length + ' of ' + state.txs.length + ' transactions';
121 els.txBody.innerHTML = '';
122 if (!visible.length) {
123 var tr = document.createElement('tr');
124 var td = document.createElement('td');
125 td.colSpan = 5;
126 td.className = 'empty-note';
127 td.textContent = state.txs.length ? 'Nothing matches the current filters.' : 'No transactions yet — press N to add one.';
128 tr.appendChild(td);
129 els.txBody.appendChild(tr);
130 return;
131 }
132 visible.forEach(function (t) {
133 var tr = document.createElement('tr');
134 tr.dataset.id = t.id;
135
136 var tdDate = document.createElement('td');
137 tdDate.className = 'col-date';
138 tdDate.textContent = t.date;
139
140 var tdDesc = document.createElement('td');
141 tdDesc.className = 'col-desc';
142 tdDesc.textContent = t.description;
143
144 var tdCat = document.createElement('td');
145 var chip = document.createElement('span');
146 chip.className = 'cat-chip';
147 chip.textContent = t.category;
148 tdCat.appendChild(chip);
149
150 var tdAmt = document.createElement('td');
151 tdAmt.className = 'col-amount' + (t.amount > 0 ? ' is-income' : '');
152 tdAmt.textContent = (t.amount > 0 ? '+' : '') + F.formatMoney(t.amount);
153
154 var tdActions = document.createElement('td');
155 tdActions.className = 'col-actions';
156 var btnEdit = document.createElement('button');
157 btnEdit.className = 'btn-ghost btn-small';
158 btnEdit.textContent = 'Edit';
159 btnEdit.addEventListener('click', function () { openForm(t); });
160 var btnDel = document.createElement('button');
161 btnDel.className = 'btn-ghost btn-small btn-danger';
162 btnDel.textContent = 'Delete';
163 btnDel.addEventListener('click', function () { removeTx(t); });
164 tdActions.appendChild(btnEdit);
165 tdActions.appendChild(btnDel);
166
167 tr.appendChild(tdDate);
168 tr.appendChild(tdDesc);
169 tr.appendChild(tdCat);
170 tr.appendChild(tdAmt);
171 tr.appendChild(tdActions);
172 els.txBody.appendChild(tr);
173 });
174 }
175
176
177
178 function openForm(tx) {
179 state.editingId = tx ? tx.id : null;
180 els.formTitle.textContent = tx ? 'Edit transaction' : 'New transaction';
181 els.formError.hidden = true;
182 els.fDate.value = tx ? tx.date : todayStr();
183 els.fDesc.value = tx ? tx.description : '';
184 els.fCategory.value = tx ? tx.category : 'Groceries';
185 els.fAmount.value = tx ? Math.abs(tx.amount) : '';
186 els.formPanel.hidden = false;
187 els.fDesc.focus();
188 }
189
190 function closeForm() {
191 state.editingId = null;
192 els.formPanel.hidden = true;
193 }
194
195 function submitForm(e) {
196 e.preventDefault();
197 var category = els.fCategory.value;
198 var raw = Number(els.fAmount.value);
199 var signed = category === 'Income' ? Math.abs(raw) : -Math.abs(raw);
200 var tx = {
201 date: els.fDate.value,
202 description: els.fDesc.value,
203 category: category,
204 amount: signed
205 };
206 var v = F.validateTransaction(tx);
207 if (!v.ok) {
208 els.formError.textContent = v.errors.join('. ');
209 els.formError.hidden = false;
210 return;
211 }
212 if (state.editingId != null) {
213 state.txs = F.updateTransaction(state.txs, state.editingId, tx);
214 toast('Transaction updated');
215 } else {
216 state.txs = F.addTransaction(state.txs, tx);
217 toast('Transaction added');
218 }
219 saveTxs();
220 closeForm();
221 render();
222 }
223
224 function removeTx(tx) {
225 if (!window.confirm('Delete "' + tx.description + '" (' + F.formatMoney(tx.amount) + ')?')) return;
226 state.txs = F.deleteTransaction(state.txs, tx.id);
227 saveTxs();
228 toast('Transaction deleted');
229 render();
230 }
231
232
233
234 function openBudgetEditor(row) {
235 var input = window.prompt('Monthly budget for ' + row.category + ':', row.budget);
236 if (input == null) return;
237 var n = Number(input);
238 if (!isFinite(n) || n < 0) { toast('Budget must be a non-negative number'); return; }
239 state.budgets[row.category] = Math.round(n * 100) / 100;
240 saveBudgets();
241 renderBudgets();
242 toast(row.category + ' budget set to ' + F.formatMoney(n));
243 }
244
245
246
247 function exportCSV() {
248 var csv = F.toCSV(F.sortByDateDesc(state.txs));
249 var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
250 var url = URL.createObjectURL(blob);
251 var a = document.createElement('a');
252 a.href = url;
253 a.download = 'transactions.csv';
254 document.body.appendChild(a);
255 a.click();
256 document.body.removeChild(a);
257 URL.revokeObjectURL(url);
258 toast('Exported ' + state.txs.length + ' transactions');
259 }
260
261 function importCSVFile(file) {
262 var reader = new FileReader();
263 reader.onload = function () {
264 var result = F.importCSV(state.txs, String(reader.result));
265 state.txs = result.txs;
266 saveTxs();
267 render();
268 var msg = 'Imported ' + result.added + ' transaction' + (result.added === 1 ? '' : 's');
269 if (result.errors.length) msg += ' · ' + result.errors.length + ' row(s) skipped';
270 toast(msg);
271 if (result.errors.length) console.warn('CSV import issues:\n' + result.errors.join('\n'));
272 };
273 reader.readAsText(file);
274 }
275
276
277
278 function isTyping(e) {
279 var t = e.target;
280 return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
281 }
282
283 function onKeyDown(e) {
284 if (e.key === 'Escape') {
285 if (!els.helpOverlay.hidden) { els.helpOverlay.hidden = true; return; }
286 if (!els.formPanel.hidden) { closeForm(); return; }
287 if (document.activeElement === els.filterSearch) els.filterSearch.blur();
288 return;
289 }
290 if (isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
291 switch (e.key) {
292 case 'n': e.preventDefault(); openForm(null); break;
293 case '/': e.preventDefault(); els.filterSearch.focus(); els.filterSearch.select(); break;
294 case 'e': e.preventDefault(); exportCSV(); break;
295 case 'i': e.preventDefault(); els.fileInput.click(); break;
296 case '?': e.preventDefault(); els.helpOverlay.hidden = !els.helpOverlay.hidden; break;
297 case '[': e.preventDefault(); shiftBudgetMonth(-1); break;
298 case ']': e.preventDefault(); shiftBudgetMonth(1); break;
299 }
300 }
301
302 function shiftBudgetMonth(delta) {
303 state.budgetMonth = F.shiftMonth(state.budgetMonth, delta);
304 renderBudgets();
305 }
306
307
308
309 var toastTimer = null;
310 function toast(msg) {
311 els.toast.textContent = msg;
312 els.toast.classList.add('show');
313 clearTimeout(toastTimer);
314 toastTimer = setTimeout(function () { els.toast.classList.remove('show'); }, 2600);
315 }
316
317
318
319 function cacheEls() {
320 els.statSpent = $('#stat-spent');
321 els.statIncome = $('#stat-income');
322 els.statNet = $('#stat-net');
323 els.statMonthLabel = $('#stat-month-label');
324 els.chartCanvas = $('#spending-chart');
325 els.chartTooltip = $('#chart-tooltip');
326 els.budgetList = $('#budget-list');
327 els.budgetMonthLabel = $('#budget-month-label');
328 els.filterCategory = $('#filter-category');
329 els.filterMonth = $('#filter-month');
330 els.filterSearch = $('#filter-search');
331 els.txBody = $('#tx-body');
332 els.txCount = $('#tx-count');
333 els.formPanel = $('#form-panel');
334 els.formTitle = $('#form-title');
335 els.formError = $('#form-error');
336 els.fDate = $('#f-date');
337 els.fDesc = $('#f-desc');
338 els.fCategory = $('#f-category');
339 els.fAmount = $('#f-amount');
340 els.fileInput = $('#file-input');
341 els.helpOverlay = $('#help-overlay');
342 els.toast = $('#toast');
343 }
344
345 function fillCategorySelects() {
346 [els.filterCategory, els.fCategory].forEach(function (sel) {
347 var isFilter = sel === els.filterCategory;
348 sel.innerHTML = '';
349 if (isFilter) {
350 var all = document.createElement('option');
351 all.value = 'All';
352 all.textContent = 'All categories';
353 sel.appendChild(all);
354 }
355 F.CATEGORIES.forEach(function (c) {
356 var opt = document.createElement('option');
357 opt.value = c;
358 opt.textContent = c;
359 sel.appendChild(opt);
360 });
361 });
362 }
363
364 function bind() {
365 $('#btn-new').addEventListener('click', function () { openForm(null); });
366 $('#btn-export').addEventListener('click', exportCSV);
367 $('#btn-import').addEventListener('click', function () { els.fileInput.click(); });
368 $('#btn-help').addEventListener('click', function () { els.helpOverlay.hidden = false; });
369 $('#btn-close-help').addEventListener('click', function () { els.helpOverlay.hidden = true; });
370 els.helpOverlay.addEventListener('click', function (e) {
371 if (e.target === els.helpOverlay) els.helpOverlay.hidden = true;
372 });
373 $('#btn-cancel').addEventListener('click', closeForm);
374 $('#tx-form').addEventListener('submit', submitForm);
375 $('#budget-prev').addEventListener('click', function () { shiftBudgetMonth(-1); });
376 $('#budget-next').addEventListener('click', function () { shiftBudgetMonth(1); });
377 els.fileInput.addEventListener('change', function () {
378 if (els.fileInput.files.length) importCSVFile(els.fileInput.files[0]);
379 els.fileInput.value = '';
380 });
381 els.filterCategory.addEventListener('change', function () {
382 state.filters.category = els.filterCategory.value;
383 renderTable();
384 });
385 els.filterMonth.addEventListener('change', function () {
386 state.filters.month = els.filterMonth.value;
387 renderTable();
388 });
389 els.filterSearch.addEventListener('input', function () {
390 state.filters.search = els.filterSearch.value;
391 renderTable();
392 });
393 document.addEventListener('keydown', onKeyDown);
394 var resizeTimer = null;
395 window.addEventListener('resize', function () {
396 clearTimeout(resizeTimer);
397 resizeTimer = setTimeout(renderChart, 120);
398 });
399 }
400
401 document.addEventListener('DOMContentLoaded', function () {
402 cacheEls();
403 fillCategorySelects();
404 loadState();
405 bind();
406 render();
407 });
408})();
409
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.