PHP/JavaScript webapp to analyse spending habits
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

analyser.js 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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. * Adds an 'alt' class to every other visible row in the specified table.
  165. *
  166. * @param table The table to be marked-up
  167. */
  168. function colourTableRows(table) {
  169. $('tr', table).removeClass('alt');
  170. $('tr:visible:even', table).addClass('alt');
  171. }
  172. /**
  173. * Shows a tooltip with the specified content at the given co-ordinates.
  174. *
  175. * @param {int} x The x co-ordinate to show the tooltip at
  176. * @param {int} y The y co-ordinate to show the tooltip at
  177. * @param contents The content to display in the tooltip element
  178. */
  179. function showTooltip(x, y, contents) {
  180. $('<div id="tooltip">' + contents + '</div>').css( {
  181. position: 'absolute',
  182. display: 'none',
  183. top: y + 5,
  184. left: x + 5,
  185. border: '1px solid #fdd',
  186. padding: '2px',
  187. 'background-color': '#fee',
  188. }).appendTo("body").fadeIn(200);
  189. }
  190. /**
  191. * Called when the user clicks on the expand/contract toggle on a transaction
  192. * line where similar entries have been merged.
  193. *
  194. * @param event The corresponding event
  195. */
  196. function expandLinkHandler(event) {
  197. var text = $(this).text();
  198. var expanded = text.substr(0, 2) == '(+';
  199. if (expanded) {
  200. var newExpanded = {};
  201. newExpanded[event.data.id] = true;
  202. setState({expanded: newExpanded}, []);
  203. } else {
  204. setState({}, [], {expanded: [event.data.id]});
  205. }
  206. colourTableRows($('#historytable'));
  207. return false;
  208. }
  209. /**
  210. * Ensures that the desired elements are appropriately expanded or collapsed.
  211. *
  212. * @param oldList A map containing keys for each entry that was previously expanded
  213. * @param newList A map containing keys for each entry that should now be expanded
  214. */
  215. function ensureExpanded(oldList, newList) {
  216. oldList = oldList || {};
  217. newList = newList || {};
  218. $.each(newList, function(id, _) {
  219. if (!oldList[id]) {
  220. // This entry needs to be expanded
  221. $('.hidden' + id).show();
  222. var handle = $('#collapseHandle' + id);
  223. handle.text(handle.text().replace(/\+/, '-'));
  224. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('single')).toCurrency());
  225. }
  226. });
  227. $.each(oldList, function(id, _) {
  228. if (!newList[id]) {
  229. // This entry needs to be collapsed
  230. $('.hidden' + id).hide();
  231. var handle = $('#collapseHandle' + id);
  232. handle.text(handle.text().replace(/\-/, '+'));
  233. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('total')).toCurrency());
  234. }
  235. });
  236. colourTableRows($('#historytable'));
  237. }
  238. /**
  239. * Determines if the two transactions should be merged together. That is,
  240. * whether the transactions have an identical description, type and category.
  241. *
  242. * @param a The first transaction
  243. * @param b The second transaction
  244. * @return True if the transactions should be merged, false otherwise
  245. */
  246. function shouldMerge(a, b) {
  247. return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category;
  248. }
  249. /**
  250. * Draws a pie chart of transactions by category.
  251. *
  252. * @param included An array of transactions to include in the chart
  253. * @param incoming True to show income, false to show expenses
  254. */
  255. function drawCategoryPieChart(included, incoming) {
  256. var pieData = getCategoryTotals(included, incoming);
  257. var seriesData = [];
  258. $.each(pieData, function(category, amount) {
  259. seriesData.push({ label: category + ' (' + Math.round(amount) + ')', data: amount });
  260. });
  261. seriesData.sort(function(a, b) { return b.data - a.data; });
  262. plots.expense = $.plot($('#expense'), seriesData, {
  263. series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
  264. grid: { clickable: true }
  265. });
  266. }
  267. /**
  268. * Displays transactions and draws a category pie chart for the specified
  269. * date range. Note that dates have a granularity of a month.
  270. *
  271. * @param {int} start The timestamp to start including transactions from
  272. * @param {int} end The timestamp to stop including transactions at
  273. * @param {bool} incoming Whether or not to include incoming transactions (income)
  274. * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
  275. * @param {string} categoryFilter The category to filter transactions to (or null)
  276. * @param expanded An object containing entries indicating which merged
  277. * transactions should be shown as expanded
  278. */
  279. function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
  280. $('#historytable tr.data').remove();
  281. $('#historytable').show();
  282. expanded = expanded || [];
  283. var startDate = getDate(start, 1), endDate = getDate(end);
  284. $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
  285. var included = getDataForRange(startDate, endDate);
  286. var filtered = $.grep(included, function(x) {
  287. var category = x.Category ? x.Category : 'Unsorted';
  288. return (incoming == x.Amount > 0) && (!categoryFilter || categoryFilter == category);
  289. });
  290. var table = $('#historytable table');
  291. var total = 0;
  292. var lastEntry = {};
  293. var id = 0;
  294. $.each(filtered, function() {
  295. total += this.Amount;
  296. var category = this.Category ? this.Category : 'Unsorted';
  297. var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
  298. if (shouldMerge(lastEntry, this)) {
  299. if (lastEntry.id) {
  300. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  301. lastEntry.count++;
  302. $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
  303. } else {
  304. lastEntry.id = ++id;
  305. lastEntry.count = 1;
  306. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  307. var a = $('<span>').addClass('link').text(prefix + '1)').attr('id', 'collapseHandle' + lastEntry.id).appendTo($('td.desc', lastEntry.tr).append(' '));
  308. a.bind('click', { id: lastEntry.id }, expandLinkHandler);
  309. a.data('single', lastEntry.Amount);
  310. }
  311. lastEntry.Amount = Math.round(100 * (lastEntry.Amount + this.Amount)) / 100;
  312. $('#collapseHandle' + lastEntry.id).data('total', lastEntry.Amount);
  313. !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount);
  314. tr.addClass('collapsed hidden' + lastEntry.id);
  315. } else {
  316. lastEntry = $.extend({}, this, {tr: tr});
  317. }
  318. $('<td/>').text(this.Date.date.split(' ')[0]).appendTo(tr);
  319. $('<td/>').text(this.Type ? this.Type : 'Other').appendTo(tr);
  320. $('<td/>').text(this.Category ? this.Category : '').appendTo(tr);
  321. $('<td/>').addClass('desc').text(this.Description).appendTo(tr);
  322. $('<td/>').addClass('amount').text(this.Amount.toCurrency()).appendTo(tr);
  323. });
  324. var tr = $('<tr/>').addClass('data total').appendTo(table);
  325. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  326. $('<td class="amount"></td>').text(total.toCurrency()).appendTo(tr);
  327. colourTableRows(table);
  328. drawCategoryPieChart(included, incoming);
  329. }
  330. $(function() {
  331. var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}];
  332. var categories = {};
  333. var min = new Date().getTime(), max = 0;
  334. $.each(data, function(month, entries) {
  335. var split = month.split('-');
  336. var timestamp = new Date(split[0], split[1] - 1).getTime();
  337. var sum = [0, 0];
  338. $.each(entries, function() {
  339. if (this.Category == '(Ignored)') { return; }
  340. if (this.Amount < 0) {
  341. var category = this.Category ? this.Category : 'Unsorted';
  342. if (!categories[category]) { categories[category] = {}; }
  343. if (!categories[category][timestamp]) { categories[category][timestamp] = 0; }
  344. categories[category][timestamp] -= this.Amount;
  345. }
  346. sum[this.Amount < 0 ? 1 : 0] += this.Amount;
  347. });
  348. transData[0].data.push([timestamp, sum[0]]);
  349. transData[1].data.push([timestamp, sum[1]]);
  350. transData[2].data.push([timestamp, sum[0] + sum[1]]);
  351. min = Math.min(min, timestamp);
  352. max = Math.max(max, timestamp);
  353. });
  354. var catData = [];
  355. $.each(categories, function(category, entries) {
  356. var series = {label: category, data: []};
  357. var total = 0;
  358. $.each(transData[0].data, function() {
  359. var timestamp = this[0];
  360. var val = entries[timestamp] ? entries[timestamp] : 0;
  361. total += val;
  362. series.data.push([timestamp, val]);
  363. });
  364. series.total = total;
  365. catData.push(series);
  366. });
  367. var markings = [];
  368. // Add a marking for each year division
  369. var year = new Date(new Date(max).getFullYear(), 0);
  370. while (year.getTime() > min) {
  371. markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
  372. year.setFullYear(year.getFullYear() - 1);
  373. }
  374. catData.sort(function(a, b) { return a.total - b.total; });
  375. plots.cathistory = $.plot($('#cathistory'), catData, {
  376. xaxis: { mode: 'time', timeformat: '%y/%m' },
  377. legend: { noColumns: 2 },
  378. series: {
  379. stack: true,
  380. lines: { show: true, fill: true }
  381. },
  382. grid: {
  383. markings: markings
  384. }
  385. });
  386. markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } });
  387. plots.history = $.plot($('#history'), transData, {
  388. xaxis: { mode: 'time', timeformat: '%y/%m' },
  389. series: {
  390. lines: { show: true, fill: true },
  391. points: { show: true }
  392. },
  393. legend: { noColumns: 3, position: 'nw' },
  394. grid: {
  395. hoverable: true,
  396. clickable: true,
  397. markings: markings
  398. },
  399. selection: { mode : "x" }
  400. });
  401. $("#history").bind("plothover", function (event, pos, item) {
  402. if (item) {
  403. var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex};
  404. if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
  405. previousPoint = id;
  406. $("#tooltip").remove();
  407. var x = item.datapoint[0],
  408. y = item.datapoint[1].toFixed(2);
  409. var date = new Date(x);
  410. var seriesTitles = ["Money in", "Money out", "Balance change"];
  411. showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + date.getDisplayMonth() + " " + date.getFullYear() + " = " + y);
  412. }
  413. } else {
  414. $("#tooltip").remove();
  415. previousPoint = null;
  416. }
  417. });
  418. $('#history').bind('plotselected', function(event, ranges) {
  419. var startDate = parseInt(ranges.xaxis.from.toFixed());
  420. var endDate = parseInt(ranges.xaxis.to.toFixed());
  421. if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
  422. setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
  423. }
  424. });
  425. $('#history').bind('plotclick', function(event, pos, item) {
  426. if (item) {
  427. setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']);
  428. }
  429. });
  430. $('#expense').bind('plotclick', function(event, pos, item) {
  431. setState({ categoryFilter: item.series.label.replace(/ \([0-9]+\)$/, '') }, ['expanded']);
  432. });
  433. $.history.init(handleStateChange);
  434. });