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 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. var previousPoint = null;
  2. var state = {};
  3. var plots = {};
  4. // -----------------------------------------------------------------------------
  5. // ------------------ Date handling --------------------------------------------
  6. // -----------------------------------------------------------------------------
  7. Date.prototype.months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  8. /**
  9. * Formats this date as year and two digit month, separated by a '-'.
  10. *
  11. * @return This date formatted as a key
  12. */
  13. Date.prototype.getKey = function() {
  14. return this.getFullYear() + '-' + (this.getMonth() < 9 ? '0' : '') + (this.getMonth() + 1);
  15. }
  16. /**
  17. * Gets a textual representation of this date's month
  18. *
  19. * @return This date's month name
  20. */
  21. Date.prototype.getDisplayMonth = function() {
  22. return this.months[this.getMonth()];
  23. }
  24. /**
  25. * Gets a textual representation of the range of months between this date and
  26. * the specified other. It is assumed that the other date will occur after this.
  27. *
  28. * @param {Date} other The end date
  29. * @return {String} A textual representation of the date range
  30. */
  31. Date.prototype.getRangeText = function(other) {
  32. if (this.getFullYear() == other.getFullYear() && this.getMonth() == other.getMonth()) {
  33. return this.getDisplayMonth() + ' ' + this.getFullYear();
  34. } else if (this.getFullYear() == other.getFullYear()) {
  35. return this.getDisplayMonth() + '-' + other.getDisplayMonth() + ' ' + this.getFullYear();
  36. } else {
  37. return this.getDisplayMonth() + ' ' + this.getFullYear() + ' - ' + other.getDisplayMonth() + ' ' + other.getFullYear();
  38. }
  39. }
  40. /**
  41. * Gets a date object corresponding to the specified timestamp. If advanceToNext
  42. * is specified, and the timestamp doesn't already correspond to the first of
  43. * the month, the date is forwarded to the first day of the next month.
  44. *
  45. * @param {int} timestamp The timestamp to convert to a date
  46. * @param {bool} advanceToNext Whether to advance to the 1st or not
  47. * @return {Date} A corresponding date object
  48. */
  49. function getDate(timestamp, advanceToNext) {
  50. var date = new Date(timestamp);
  51. if (advanceToNext && date.getDate() > 1) {
  52. date.setDate(1);
  53. date.getMonth() == 11 && date.setYear(date.getFullYear() + 1);
  54. date.setMonth(date.getMonth() == 11 ? 0 : date.getMonth() + 1);
  55. }
  56. return date;
  57. }
  58. // -----------------------------------------------------------------------------
  59. // ------------------ Data handling --------------------------------------------
  60. // -----------------------------------------------------------------------------
  61. /**
  62. * Calculates the sum of transactions belonging to each category within
  63. * the specified data.
  64. *
  65. * @param data An array of transactions to include
  66. * @param {bool} incoming True to tally income, false to tally expenses
  67. * @return A mapping of categories to the sum of their transactions
  68. */
  69. function getCategoryTotals(data, incoming) {
  70. var catData = {};
  71. $.each(data, function() {
  72. trans = this;
  73. var category = trans.Category ? trans.Category : 'Unsorted';
  74. if (category != '(Ignored)' && incoming == trans.Amount > 0) {
  75. if (!catData[category]) { catData[category] = 0; }
  76. catData[category] += Math.abs(trans.Amount);
  77. }
  78. });
  79. return catData;
  80. }
  81. /**
  82. * Retrieves an array of transactions which occur between the specified two
  83. * dates. This has a resolution of a month -- any data in the same month
  84. * as the start date will be included, and any data after the month of the
  85. * end date will be excluded.
  86. *
  87. * @param {Date} start The date to start including data at
  88. * @param {Date} end The date to stop including data at
  89. * @return An array of transactions between the two dates
  90. */
  91. function getDataForRange(start, end) {
  92. var include = false;
  93. var included = [];
  94. $.each(data, function(month, monthData) {
  95. include |= month == start.getKey();
  96. if (include) {
  97. $.each(monthData, function(index, trans) {
  98. included.push(trans);
  99. });
  100. }
  101. include &= month != end.getKey();
  102. });
  103. return included;
  104. }
  105. // -----------------------------------------------------------------------------
  106. // ------------------ State handling -------------------------------------------
  107. // -----------------------------------------------------------------------------
  108. /**
  109. * Update the page's state with the specified new state. This causes the state
  110. * to be loaded into the user's history so they can use back and forward
  111. * functionality in their browser. New state is merged with the old state.
  112. *
  113. * @param newState The new properties to add to the state
  114. * @param invalidatedState An array of state keys to remove
  115. */
  116. function setState(newState, invalidatedState) {
  117. $.extend(true, state, newState);
  118. $.each(invalidatedState, function(_, x) { delete state[x]; });
  119. $.history.load(JSON.stringify(state));
  120. }
  121. /**
  122. * Called when the page state changes (either via a call to $.history.load or
  123. * by the user manually changing the fragment or going back or forward).
  124. *
  125. * @param {string} hash The new page fragment
  126. */
  127. function handleStateChange(hash) {
  128. var oldState = $.extend({}, state);
  129. try {
  130. state = JSON.parse(hash);
  131. } catch (ex) {
  132. state = {};
  133. }
  134. if (state.start && state.end && state.type) {
  135. // Update the transaction table and pie charts
  136. showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter, state.expanded);
  137. // If the selection has changed, update the visual representation
  138. (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }});
  139. }
  140. }
  141. // -----------------------------------------------------------------------------
  142. /**
  143. * Adds an 'alt' class to every other visible row in the specified table.
  144. *
  145. * @param table The table to be marked-up
  146. */
  147. function colourTableRows(table) {
  148. $('tr', table).removeClass('alt');
  149. $('tr:visible:even', table).addClass('alt');
  150. }
  151. /**
  152. * Shows a tooltip with the specified content at the given co-ordinates.
  153. *
  154. * @param {int} x The x co-ordinate to show the tooltip at
  155. * @param {int} y The y co-ordinate to show the tooltip at
  156. * @param contents The content to display in the tooltip element
  157. */
  158. function showTooltip(x, y, contents) {
  159. $('<div id="tooltip">' + contents + '</div>').css( {
  160. position: 'absolute',
  161. display: 'none',
  162. top: y + 5,
  163. left: x + 5,
  164. border: '1px solid #fdd',
  165. padding: '2px',
  166. 'background-color': '#fee',
  167. }).appendTo("body").fadeIn(200);
  168. }
  169. /**
  170. * Called when the user clicks on the expand/contract toggle on a transaction
  171. * line where similar entries have been merged.
  172. *
  173. * @param event The corresponding event
  174. */
  175. function expandLinkHandler(event) {
  176. var text = $(this).text();
  177. var expanded = text.substr(0, 2) == '(+';
  178. if (!state.expanded) {
  179. state.expanded = {};
  180. }
  181. if (expanded) {
  182. state.expanded[event.data.id] = true;
  183. setState({}, []);
  184. } else {
  185. delete state.expanded[event.data.id];
  186. setState({}, []);
  187. }
  188. colourTableRows($('#historytable'));
  189. return false;
  190. }
  191. /**
  192. * Determines if the two transactions should be merged together. That is,
  193. * whether the transactions have an identical description, type and category.
  194. *
  195. * @param a The first transaction
  196. * @param b The second transaction
  197. * @return True if the transactions should be merged, false otherwise
  198. */
  199. function shouldMerge(a, b) {
  200. return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category;
  201. }
  202. /**
  203. * Draws a pie chart of transactions by category.
  204. *
  205. * @param included An array of transactions to include in the chart
  206. * @param incoming True to show income, false to show expenses
  207. */
  208. function drawCategoryPieChart(included, incoming) {
  209. var pieData = getCategoryTotals(included, incoming);
  210. var seriesData = [];
  211. $.each(pieData, function(category, amount) {
  212. seriesData.push({ label: category + ' (' + Math.round(amount) + ')', data: amount });
  213. });
  214. seriesData.sort(function(a, b) { return b.data - a.data; });
  215. plots.expense = $.plot($('#expense'), seriesData, {
  216. series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
  217. grid: { clickable: true }
  218. });
  219. }
  220. /**
  221. * Displays transactions and draws a category pie chart for the specified
  222. * date range. Note that dates have a granularity of a month.
  223. *
  224. * @param {int} start The timestamp to start including transactions from
  225. * @param {int} end The timestamp to stop including transactions at
  226. * @param {bool} incoming Whether or not to include incoming transactions (income)
  227. * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
  228. * @param {string} categoryFilter The category to filter transactions to (or null)
  229. * @param expanded An object containing entries indicating which merged
  230. * transactions should be shown as expanded
  231. */
  232. function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
  233. $('#historytable tr.data').remove();
  234. $('#historytable').show();
  235. expanded = expanded || [];
  236. var startDate = getDate(start, 1), endDate = getDate(end);
  237. $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
  238. var table = $('#historytable table');
  239. var lastEntry = {};
  240. var id = 0;
  241. var included = getDataForRange(startDate, endDate);
  242. $.each(included, function() {
  243. trans = this;
  244. if (incoming != trans.Amount > 0) { return; }
  245. var category = trans.Category ? trans.Category : 'Unsorted';
  246. if (categoryFilter && categoryFilter != category) { return; }
  247. var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
  248. if (shouldMerge(lastEntry, trans)) {
  249. if (lastEntry.id) {
  250. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  251. lastEntry.count++;
  252. $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
  253. } else {
  254. lastEntry.id = ++id;
  255. lastEntry.count = 1;
  256. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  257. var a = $('<span>').addClass('link').text(prefix + '1)').appendTo($('td.desc', lastEntry.tr).append(' '));
  258. a.bind('click', { id: lastEntry.id, tr: lastEntry.tr }, expandLinkHandler);
  259. }
  260. lastEntry.Amount = Math.round(100 * (lastEntry.Amount + trans.Amount)) / 100;
  261. !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount);
  262. tr.addClass('collapsed hidden' + lastEntry.id);
  263. } else {
  264. lastEntry = $.extend({}, trans, {tr: tr});
  265. }
  266. $('<td/>').text(trans.Date.date.split(' ')[0]).appendTo(tr);
  267. $('<td/>').text(trans.Type ? trans.Type : 'Other').appendTo(tr);
  268. $('<td/>').text(trans.Category ? trans.Category : '').appendTo(tr);
  269. $('<td/>').addClass('desc').text(trans.Description).appendTo(tr);
  270. $('<td/>').addClass('amount').text(trans.Amount).appendTo(tr);
  271. });
  272. colourTableRows(table);
  273. drawCategoryPieChart(included, incoming);
  274. }
  275. $(function() {
  276. var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}];
  277. var categories = {};
  278. var min = new Date().getTime(), max = 0;
  279. $.each(data, function(month, entries) {
  280. var split = month.split('-');
  281. var timestamp = new Date(split[0], split[1] - 1).getTime();
  282. var sum = [0, 0];
  283. $.each(entries, function() {
  284. if (this.Category == '(Ignored)') { return; }
  285. if (this.Amount < 0) {
  286. var category = this.Category ? this.Category : 'Unsorted';
  287. if (!categories[category]) { categories[category] = {}; }
  288. if (!categories[category][timestamp]) { categories[category][timestamp] = 0; }
  289. categories[category][timestamp] -= this.Amount;
  290. }
  291. sum[this.Amount < 0 ? 1 : 0] += this.Amount;
  292. });
  293. transData[0].data.push([timestamp, sum[0]]);
  294. transData[1].data.push([timestamp, sum[1]]);
  295. transData[2].data.push([timestamp, sum[0] + sum[1]]);
  296. min = Math.min(min, timestamp);
  297. max = Math.max(max, timestamp);
  298. });
  299. var catData = [];
  300. $.each(categories, function(category, entries) {
  301. var series = {label: category, data: []};
  302. var total = 0;
  303. $.each(transData[0].data, function() {
  304. var timestamp = this[0];
  305. var val = entries[timestamp] ? entries[timestamp] : 0;
  306. total += val;
  307. series.data.push([timestamp, val]);
  308. });
  309. series.total = total;
  310. catData.push(series);
  311. });
  312. var markings = [];
  313. // Add a marking for each year division
  314. var year = new Date(new Date(max).getFullYear(), 0);
  315. while (year.getTime() > min) {
  316. markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
  317. year.setFullYear(year.getFullYear() - 1);
  318. }
  319. catData.sort(function(a, b) { return a.total - b.total; });
  320. plots.cathistory = $.plot($('#cathistory'), catData, {
  321. xaxis: { mode: 'time', timeformat: '%y/%m' },
  322. legend: { noColumns: 2 },
  323. series: {
  324. stack: true,
  325. lines: { show: true, fill: true }
  326. },
  327. grid: {
  328. markings: markings
  329. }
  330. });
  331. markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } });
  332. plots.history = $.plot($('#history'), transData, {
  333. xaxis: { mode: 'time', timeformat: '%y/%m' },
  334. series: {
  335. lines: { show: true, fill: true },
  336. points: { show: true }
  337. },
  338. legend: { noColumns: 3, position: 'nw' },
  339. grid: {
  340. hoverable: true,
  341. clickable: true,
  342. markings: markings
  343. },
  344. selection: { mode : "x" }
  345. });
  346. $("#history").bind("plothover", function (event, pos, item) {
  347. if (item) {
  348. var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex};
  349. if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
  350. previousPoint = id;
  351. $("#tooltip").remove();
  352. var x = item.datapoint[0],
  353. y = item.datapoint[1].toFixed(2);
  354. var date = new Date(x);
  355. var seriesTitles = ["Money in", "Money out", "Balance change"];
  356. showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + months[date.getMonth()] + " " + date.getFullYear() + " = " + y);
  357. }
  358. } else {
  359. $("#tooltip").remove();
  360. previousPoint = null;
  361. }
  362. });
  363. $('#history').bind('plotselected', function(event, ranges) {
  364. var startDate = parseInt(ranges.xaxis.from.toFixed());
  365. var endDate = parseInt(ranges.xaxis.to.toFixed());
  366. if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
  367. setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
  368. }
  369. });
  370. $('#history').bind('plotclick', function(event, pos, item) {
  371. if (item) {
  372. setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']);
  373. }
  374. });
  375. $('#expense').bind('plotclick', function(event, pos, item) {
  376. setState({ categoryFilter: item.series.label.replace(/ \([0-9]+\)$/, '') }, ['expanded']);
  377. });
  378. $.history.init(handleStateChange);
  379. });