PHP/JavaScript webapp to analyse spending habits
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

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. });