PHP/JavaScript webapp to analyse spending habits
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

analyser.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. var previousPoint = null;
  2. var state = {};
  3. var oldState = {};
  4. var plots = {};
  5. // -----------------------------------------------------------------------------
  6. // ------------------ Date handling --------------------------------------------
  7. // -----------------------------------------------------------------------------
  8. Date.prototype.months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  9. /**
  10. * Formats this date as year and two digit month, separated by a '-'.
  11. *
  12. * @return This date formatted as a key
  13. */
  14. Date.prototype.getKey = function() {
  15. return this.getFullYear() + '-' + (this.getMonth() < 9 ? '0' : '') + (this.getMonth() + 1);
  16. }
  17. /**
  18. * Gets a textual representation of this date's month
  19. *
  20. * @return This date's month name
  21. */
  22. Date.prototype.getDisplayMonth = function() {
  23. return this.months[this.getMonth()];
  24. }
  25. /**
  26. * Gets a textual representation of the range of months between this date and
  27. * the specified other. It is assumed that the other date will occur after this.
  28. *
  29. * @param {Date} other The end date
  30. * @return {String} A textual representation of the date range
  31. */
  32. Date.prototype.getRangeText = function(other) {
  33. if (this.getFullYear() == other.getFullYear() && this.getMonth() == other.getMonth()) {
  34. return this.getDisplayMonth() + ' ' + this.getFullYear();
  35. } else if (this.getFullYear() == other.getFullYear()) {
  36. return this.getDisplayMonth() + '-' + other.getDisplayMonth() + ' ' + this.getFullYear();
  37. } else {
  38. return this.getDisplayMonth() + ' ' + this.getFullYear() + ' - ' + other.getDisplayMonth() + ' ' + other.getFullYear();
  39. }
  40. }
  41. /**
  42. * Gets a date object corresponding to the specified timestamp. If advanceToNext
  43. * is specified, and the timestamp doesn't already correspond to the first of
  44. * the month, the date is forwarded to the first day of the next month.
  45. *
  46. * @param {int} timestamp The timestamp to convert to a date
  47. * @param {bool} advanceToNext Whether to advance to the 1st or not
  48. * @return {Date} A corresponding date object
  49. */
  50. function getDate(timestamp, advanceToNext) {
  51. var date = new Date(timestamp);
  52. if (advanceToNext && date.getDate() > 1) {
  53. date.setDate(1);
  54. date.getMonth() == 11 && date.setYear(date.getFullYear() + 1);
  55. date.setMonth(date.getMonth() == 11 ? 0 : date.getMonth() + 1);
  56. }
  57. return date;
  58. }
  59. // -----------------------------------------------------------------------------
  60. // ------------------ Data handling --------------------------------------------
  61. // -----------------------------------------------------------------------------
  62. /**
  63. * Calculates the sum of transactions belonging to each category within
  64. * the specified data.
  65. *
  66. * @param data An array of transactions to include
  67. * @param {bool} incoming True to tally income, false to tally expenses
  68. * @return A mapping of categories to the sum of their transactions
  69. */
  70. function getCategoryTotals(data, incoming) {
  71. var catData = {};
  72. $.each(data, function() {
  73. trans = this;
  74. var category = trans.Category ? trans.Category : 'Unsorted';
  75. if (category != '(Ignored)' && incoming == trans.Amount > 0) {
  76. if (!catData[category]) { catData[category] = 0; }
  77. catData[category] += Math.abs(trans.Amount);
  78. }
  79. });
  80. return catData;
  81. }
  82. /**
  83. * Retrieves an array of transactions which occur between the specified two
  84. * dates. This has a resolution of a month -- any data in the same month
  85. * as the start date will be included, and any data after the month of the
  86. * end date will be excluded.
  87. *
  88. * @param {Date} start The date to start including data at
  89. * @param {Date} end The date to stop including data at
  90. * @return An array of transactions between the two dates
  91. */
  92. function getDataForRange(start, end) {
  93. var include = false;
  94. var included = [];
  95. $.each(data, function(month, monthData) {
  96. include |= month == start.getKey();
  97. if (include) {
  98. $.each(monthData, function(index, trans) {
  99. included.push(trans);
  100. });
  101. }
  102. include &= month != end.getKey();
  103. });
  104. return included;
  105. }
  106. // -----------------------------------------------------------------------------
  107. // ------------------ State handling -------------------------------------------
  108. // -----------------------------------------------------------------------------
  109. /**
  110. * Update the page's state with the specified new state. This causes the state
  111. * to be loaded into the user's history so they can use back and forward
  112. * functionality in their browser. New state is merged with the old state.
  113. *
  114. * @param newState The new properties to add to the state
  115. * @param invalidatedState An array of state keys to remove
  116. * @param invalidatedSubState An map of state subkeys to remove
  117. */
  118. function setState(newState, invalidatedState, invalidatedSubState) {
  119. oldState = $.extend(true, {}, state);
  120. $.extend(true, state, newState);
  121. invalidatedState && $.each(invalidatedState, function(_, x) { delete state[x]; });
  122. invalidatedSubState && $.each(invalidatedSubState, function(key, values) {
  123. $.each(values, function() {
  124. delete state[key][this];
  125. });
  126. });
  127. $.history.load(JSON.stringify(state));
  128. }
  129. /**
  130. * Called when the page state changes (either via a call to $.history.load or
  131. * by the user manually changing the fragment or going back or forward).
  132. *
  133. * @param {string} hash The new page fragment
  134. */
  135. function handleStateChange(hash) {
  136. try {
  137. state = JSON.parse(hash);
  138. } catch (ex) {
  139. state = {};
  140. }
  141. if (state.start && state.end && state.type) {
  142. if (state.start == oldState.start && state.end == oldState.end && state.type == oldState.type && state.categoryFilter == oldState.categoryFilter) {
  143. // Just show/hide nodes as required
  144. ensureExpanded(oldState.expanded, state.expanded);
  145. } else {
  146. // Update the transaction table and pie charts
  147. showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter, state.expanded);
  148. }
  149. // If the selection has changed, update the visual representation
  150. (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }});
  151. }
  152. }
  153. // -----------------------------------------------------------------------------
  154. /**
  155. * Formats the specified number in a manner suitable for a currency. That is,
  156. * fixed to two decimal places and with a thousand separator every 3 digits.
  157. *
  158. * @return A string representation of the number as a currency
  159. */
  160. Number.prototype.toCurrency = function() {
  161. return this.toFixed(2).replace(/([0-9])(?=([0-9]{3})+\.)/g, '$1,');
  162. };
  163. /**
  164. * Computes the arithmatic mean, variance and deviation for the given array.
  165. *
  166. * @param a Array of numbers to be averaged
  167. * @return A map containing the mean, variance and deviation
  168. */
  169. function getAverage(a){
  170. var r = {mean: 0, variance: 0, deviation: 0};
  171. var length = a.length;
  172. // Sum the array
  173. for (var sum = 0, i = length; i--; sum += a[i]);
  174. var mean = r.mean = sum / length
  175. // Sum the squares of the differences from the mean
  176. for (var i = length, sum = 0; i--; sum += Math.pow(a[i] - mean, 2));
  177. r.deviation = Math.sqrt(r.variance = sum / length)
  178. return r;
  179. }
  180. /**
  181. * Adds an 'alt' class to every other visible row in the specified table.
  182. *
  183. * @param table The table to be marked-up
  184. */
  185. function colourTableRows(table) {
  186. $('tr', table).removeClass('alt');
  187. $('tr:visible:even', table).addClass('alt');
  188. }
  189. /**
  190. * Shows a tooltip with the specified content at the given co-ordinates.
  191. *
  192. * @param {int} x The x co-ordinate to show the tooltip at
  193. * @param {int} y The y co-ordinate to show the tooltip at
  194. * @param contents The content to display in the tooltip element
  195. */
  196. function showTooltip(x, y, contents) {
  197. $('<div id="tooltip">' + contents + '</div>').css( {
  198. position: 'absolute',
  199. display: 'none',
  200. top: y + 5,
  201. left: x + 5,
  202. border: '1px solid #fdd',
  203. padding: '2px',
  204. 'background-color': '#fee',
  205. }).appendTo("body").fadeIn(200);
  206. }
  207. /**
  208. * Called when the user clicks on the expand/contract toggle on a transaction
  209. * line where similar entries have been merged.
  210. *
  211. * @param event The corresponding event
  212. */
  213. function expandLinkHandler(event) {
  214. var text = $(this).text();
  215. var expanded = text.substr(0, 2) == '(+';
  216. if (expanded) {
  217. var newExpanded = {};
  218. newExpanded[event.data.id] = true;
  219. setState({expanded: newExpanded}, []);
  220. } else {
  221. setState({}, [], {expanded: [event.data.id]});
  222. }
  223. colourTableRows($('#historytable'));
  224. return false;
  225. }
  226. /**
  227. * Ensures that the desired elements are appropriately expanded or collapsed.
  228. *
  229. * @param oldList A map containing keys for each entry that was previously expanded
  230. * @param newList A map containing keys for each entry that should now be expanded
  231. */
  232. function ensureExpanded(oldList, newList) {
  233. oldList = oldList || {};
  234. newList = newList || {};
  235. $.each(newList, function(id, _) {
  236. if (!oldList[id]) {
  237. // This entry needs to be expanded
  238. $('.hidden' + id).show();
  239. var handle = $('#collapseHandle' + id);
  240. handle.text(handle.text().replace(/\+/, '-'));
  241. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('single')).toCurrency());
  242. }
  243. });
  244. $.each(oldList, function(id, _) {
  245. if (!newList[id]) {
  246. // This entry needs to be collapsed
  247. $('.hidden' + id).hide();
  248. var handle = $('#collapseHandle' + id);
  249. handle.text(handle.text().replace(/\-/, '+'));
  250. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('total')).toCurrency());
  251. }
  252. });
  253. colourTableRows($('#historytable'));
  254. }
  255. /**
  256. * Determines if the two transactions should be merged together. That is,
  257. * whether the transactions have an identical description, type and category.
  258. *
  259. * @param a The first transaction
  260. * @param b The second transaction
  261. * @return True if the transactions should be merged, false otherwise
  262. */
  263. function shouldMerge(a, b) {
  264. return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category;
  265. }
  266. /**
  267. * Draws a pie chart of transactions by category.
  268. *
  269. * @param included An array of transactions to include in the chart
  270. * @param incoming True to show income, false to show expenses
  271. */
  272. function drawCategoryPieChart(included, incoming) {
  273. var pieData = getCategoryTotals(included, incoming);
  274. var total = 0;
  275. $.each(pieData, function(_, amount) { total += amount; });
  276. var seriesData = [];
  277. $.each(pieData, function(category, amount) {
  278. seriesData.push({ label: category + ' (&pound;' + amount.toCurrency() + ', ' + Math.floor(100 * amount / total) + '%)', data: amount });
  279. });
  280. seriesData.sort(function(a, b) { return b.data - a.data; });
  281. plots.expense = $.plot($('#expense'), seriesData, {
  282. series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
  283. grid: { clickable: true }
  284. });
  285. }
  286. /**
  287. * Calculates repeat transactions within the specified data.
  288. *
  289. * @param data The data to be analysed
  290. */
  291. function calculateRepeatTransactions(data) {
  292. $('#repeats').show();
  293. $('#repeats tr.data').remove();
  294. var table = $('#repeats table');
  295. var descs = {};
  296. $.each(data, function() {
  297. if (!descs[this.Description]) { descs[this.Description] = []; }
  298. descs[this.Description].push(this);
  299. });
  300. var monthTotal = 0;
  301. $.each(descs, function(desc) {
  302. // We only care if there are at least more than 2
  303. if (this.length < 3) { return; }
  304. var lastTime = 0;
  305. var differences = [];
  306. var amounts = [];
  307. $.each(this, function() {
  308. var time = new Date(this.Date.date).getTime();
  309. lastTime > 0 && differences.push(time - lastTime);
  310. lastTime = time;
  311. amounts.push(this.Amount);
  312. });
  313. var average = getAverage(differences);
  314. var averageAmount = getAverage(amounts);
  315. // I may have just made this metric up. Sue me.
  316. var stability = average.deviation / average.mean;
  317. var periodInDays = average.mean / (1000 * 60 * 60 * 24);
  318. if (stability < 0.5) {
  319. // Seems quite reliable...
  320. if ((periodInDays >= 5 && periodInDays <= 9) || (periodInDays >= 27 && periodInDays <= 32)) {
  321. // Roughly weekly or monthly
  322. var monthValue = (periodInDays <= 9 ? 4 : 1) * averageAmount.mean;
  323. var tr = $('<tr class="data"/>').appendTo(table);
  324. $('<td/>').text(desc).appendTo(tr);
  325. $('<td/>').text(this[0].Category ? this[0].Category : 'Unsorted').appendTo(tr);
  326. $('<td/>').text(periodInDays <= 9 ? 'Weekly' : 'Monthly').appendTo(tr);
  327. $('<td class="amount"/>').text(averageAmount.mean.toCurrency()).appendTo(tr);
  328. $('<td class="amount"/>').text(monthValue.toCurrency()).appendTo(tr);
  329. monthTotal += monthValue;
  330. }
  331. }
  332. });
  333. colourTableRows(table);
  334. var tr = $('<tr/>').addClass('data total').appendTo(table);
  335. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  336. $('<td class="amount"></td>').text(monthTotal.toCurrency()).appendTo(tr);
  337. }
  338. /**
  339. * Displays transactions and draws a category pie chart for the specified
  340. * date range. Note that dates have a granularity of a month.
  341. *
  342. * @param {int} start The timestamp to start including transactions from
  343. * @param {int} end The timestamp to stop including transactions at
  344. * @param {bool} incoming Whether or not to include incoming transactions (income)
  345. * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
  346. * @param {string} categoryFilter The category to filter transactions to (or null)
  347. * @param expanded An object containing entries indicating which merged
  348. * transactions should be shown as expanded
  349. */
  350. function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
  351. $('#historytable tr.data').remove();
  352. $('#historytable').show();
  353. expanded = expanded || [];
  354. var startDate = getDate(start, 1), endDate = getDate(end);
  355. $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
  356. var included = getDataForRange(startDate, endDate);
  357. var filtered = $.grep(included, function(x) {
  358. var category = x.Category ? x.Category : 'Unsorted';
  359. return (incoming == x.Amount > 0) && (!categoryFilter || categoryFilter == category);
  360. });
  361. var table = $('#historytable table');
  362. var total = 0;
  363. var lastEntry = {};
  364. var id = 0;
  365. $.each(filtered, function() {
  366. total += this.Amount;
  367. var category = this.Category ? this.Category : 'Unsorted';
  368. var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
  369. if (shouldMerge(lastEntry, this)) {
  370. if (lastEntry.id) {
  371. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  372. lastEntry.count++;
  373. $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
  374. } else {
  375. lastEntry.id = ++id;
  376. lastEntry.count = 1;
  377. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  378. var a = $('<span>').addClass('link').text(prefix + '1)').attr('id', 'collapseHandle' + lastEntry.id).appendTo($('td.desc', lastEntry.tr).append(' '));
  379. a.bind('click', { id: lastEntry.id }, expandLinkHandler);
  380. a.data('single', lastEntry.Amount);
  381. }
  382. lastEntry.Amount = Math.round(100 * (lastEntry.Amount + this.Amount)) / 100;
  383. $('#collapseHandle' + lastEntry.id).data('total', lastEntry.Amount);
  384. !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount.toCurrency());
  385. tr.addClass('collapsed hidden' + lastEntry.id);
  386. } else {
  387. lastEntry = $.extend({}, this, {tr: tr});
  388. }
  389. $('<td/>').text(this.Date.date.split(' ')[0]).appendTo(tr);
  390. $('<td/>').text(this.Type ? this.Type : 'Other').appendTo(tr);
  391. $('<td/>').text(this.Category ? this.Category : '').appendTo(tr);
  392. $('<td/>').addClass('desc').text(this.Description).appendTo(tr);
  393. $('<td/>').addClass('amount').text(this.Amount.toCurrency()).appendTo(tr);
  394. });
  395. var tr = $('<tr/>').addClass('data total').appendTo(table);
  396. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  397. $('<td class="amount"></td>').text(total.toCurrency()).appendTo(tr);
  398. colourTableRows(table);
  399. drawCategoryPieChart(included, incoming);
  400. calculateRepeatTransactions(included);
  401. }
  402. $(function() {
  403. var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}];
  404. var categories = {};
  405. var min = new Date().getTime(), max = 0;
  406. $.each(data, function(month, entries) {
  407. var split = month.split('-');
  408. var timestamp = new Date(split[0], split[1] - 1).getTime();
  409. var sum = [0, 0];
  410. $.each(entries, function() {
  411. if (this.Category == '(Ignored)') { return; }
  412. if (this.Amount < 0) {
  413. var category = this.Category ? this.Category : 'Unsorted';
  414. if (!categories[category]) { categories[category] = {}; }
  415. if (!categories[category][timestamp]) { categories[category][timestamp] = 0; }
  416. categories[category][timestamp] -= this.Amount;
  417. }
  418. sum[this.Amount < 0 ? 1 : 0] += this.Amount;
  419. });
  420. transData[0].data.push([timestamp, sum[0]]);
  421. transData[1].data.push([timestamp, sum[1]]);
  422. transData[2].data.push([timestamp, sum[0] + sum[1]]);
  423. min = Math.min(min, timestamp);
  424. max = Math.max(max, timestamp);
  425. });
  426. var catData = [];
  427. $.each(categories, function(category, entries) {
  428. var series = {label: category, data: []};
  429. var total = 0;
  430. $.each(transData[0].data, function() {
  431. var timestamp = this[0];
  432. var val = entries[timestamp] ? entries[timestamp] : 0;
  433. total += val;
  434. series.data.push([timestamp, val]);
  435. });
  436. series.total = total;
  437. catData.push(series);
  438. });
  439. var markings = [];
  440. // Add a marking for each year division
  441. var year = new Date(new Date(max).getFullYear(), 0);
  442. while (year.getTime() > min) {
  443. markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
  444. year.setFullYear(year.getFullYear() - 1);
  445. }
  446. catData.sort(function(a, b) { return a.total - b.total; });
  447. plots.cathistory = $.plot($('#cathistory'), catData, {
  448. xaxis: { mode: 'time', timeformat: '%y/%m' },
  449. legend: { noColumns: 2 },
  450. series: {
  451. stack: true,
  452. lines: { show: true, fill: true }
  453. },
  454. grid: {
  455. markings: markings
  456. }
  457. });
  458. markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } });
  459. plots.history = $.plot($('#history'), transData, {
  460. xaxis: { mode: 'time', timeformat: '%y/%m' },
  461. series: {
  462. lines: { show: true, fill: true },
  463. points: { show: true }
  464. },
  465. legend: { noColumns: 3, position: 'nw' },
  466. grid: {
  467. hoverable: true,
  468. clickable: true,
  469. markings: markings
  470. },
  471. selection: { mode : "x" }
  472. });
  473. $("#history").bind("plothover", function (event, pos, item) {
  474. if (item) {
  475. var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex};
  476. if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
  477. previousPoint = id;
  478. $("#tooltip").remove();
  479. var x = item.datapoint[0],
  480. y = item.datapoint[1].toFixed(2);
  481. var date = new Date(x);
  482. var seriesTitles = ["Money in", "Money out", "Balance change"];
  483. showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + date.getDisplayMonth() + " " + date.getFullYear() + " = " + y);
  484. }
  485. } else {
  486. $("#tooltip").remove();
  487. previousPoint = null;
  488. }
  489. });
  490. $('#history').bind('plotselected', function(event, ranges) {
  491. var startDate = parseInt(ranges.xaxis.from.toFixed());
  492. var endDate = parseInt(ranges.xaxis.to.toFixed());
  493. if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
  494. setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
  495. }
  496. });
  497. $('#history').bind('plotclick', function(event, pos, item) {
  498. if (item) {
  499. setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']);
  500. }
  501. });
  502. $('#expense').bind('plotclick', function(event, pos, item) {
  503. setState({ categoryFilter: item.series.label.replace(/ \(.*$/, '') }, ['expanded']);
  504. });
  505. $.history.init(handleStateChange);
  506. });