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