PHP/JavaScript webapp to analyse spending habits
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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 total = 0;
  258. $.each(pieData, function(_, amount) { total += amount; });
  259. var seriesData = [];
  260. $.each(pieData, function(category, amount) {
  261. seriesData.push({ label: category + ' (&pound;' + amount.toCurrency() + ', ' + Math.floor(100 * amount / total) + '%)', data: amount });
  262. });
  263. seriesData.sort(function(a, b) { return b.data - a.data; });
  264. plots.expense = $.plot($('#expense'), seriesData, {
  265. series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
  266. grid: { clickable: true }
  267. });
  268. }
  269. /**
  270. * Displays transactions and draws a category pie chart for the specified
  271. * date range. Note that dates have a granularity of a month.
  272. *
  273. * @param {int} start The timestamp to start including transactions from
  274. * @param {int} end The timestamp to stop including transactions at
  275. * @param {bool} incoming Whether or not to include incoming transactions (income)
  276. * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
  277. * @param {string} categoryFilter The category to filter transactions to (or null)
  278. * @param expanded An object containing entries indicating which merged
  279. * transactions should be shown as expanded
  280. */
  281. function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
  282. $('#historytable tr.data').remove();
  283. $('#historytable').show();
  284. expanded = expanded || [];
  285. var startDate = getDate(start, 1), endDate = getDate(end);
  286. $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
  287. var included = getDataForRange(startDate, endDate);
  288. var filtered = $.grep(included, function(x) {
  289. var category = x.Category ? x.Category : 'Unsorted';
  290. return (incoming == x.Amount > 0) && (!categoryFilter || categoryFilter == category);
  291. });
  292. var table = $('#historytable table');
  293. var total = 0;
  294. var lastEntry = {};
  295. var id = 0;
  296. $.each(filtered, function() {
  297. total += this.Amount;
  298. var category = this.Category ? this.Category : 'Unsorted';
  299. var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
  300. if (shouldMerge(lastEntry, this)) {
  301. if (lastEntry.id) {
  302. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  303. lastEntry.count++;
  304. $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
  305. } else {
  306. lastEntry.id = ++id;
  307. lastEntry.count = 1;
  308. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  309. var a = $('<span>').addClass('link').text(prefix + '1)').attr('id', 'collapseHandle' + lastEntry.id).appendTo($('td.desc', lastEntry.tr).append(' '));
  310. a.bind('click', { id: lastEntry.id }, expandLinkHandler);
  311. a.data('single', lastEntry.Amount);
  312. }
  313. lastEntry.Amount = Math.round(100 * (lastEntry.Amount + this.Amount)) / 100;
  314. $('#collapseHandle' + lastEntry.id).data('total', lastEntry.Amount);
  315. !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount.toCurrency());
  316. tr.addClass('collapsed hidden' + lastEntry.id);
  317. } else {
  318. lastEntry = $.extend({}, this, {tr: tr});
  319. }
  320. $('<td/>').text(this.Date.date.split(' ')[0]).appendTo(tr);
  321. $('<td/>').text(this.Type ? this.Type : 'Other').appendTo(tr);
  322. $('<td/>').text(this.Category ? this.Category : '').appendTo(tr);
  323. $('<td/>').addClass('desc').text(this.Description).appendTo(tr);
  324. $('<td/>').addClass('amount').text(this.Amount.toCurrency()).appendTo(tr);
  325. });
  326. var tr = $('<tr/>').addClass('data total').appendTo(table);
  327. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  328. $('<td class="amount"></td>').text(total.toCurrency()).appendTo(tr);
  329. colourTableRows(table);
  330. drawCategoryPieChart(included, incoming);
  331. }
  332. $(function() {
  333. var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}];
  334. var categories = {};
  335. var min = new Date().getTime(), max = 0;
  336. $.each(data, function(month, entries) {
  337. var split = month.split('-');
  338. var timestamp = new Date(split[0], split[1] - 1).getTime();
  339. var sum = [0, 0];
  340. $.each(entries, function() {
  341. if (this.Category == '(Ignored)') { return; }
  342. if (this.Amount < 0) {
  343. var category = this.Category ? this.Category : 'Unsorted';
  344. if (!categories[category]) { categories[category] = {}; }
  345. if (!categories[category][timestamp]) { categories[category][timestamp] = 0; }
  346. categories[category][timestamp] -= this.Amount;
  347. }
  348. sum[this.Amount < 0 ? 1 : 0] += this.Amount;
  349. });
  350. transData[0].data.push([timestamp, sum[0]]);
  351. transData[1].data.push([timestamp, sum[1]]);
  352. transData[2].data.push([timestamp, sum[0] + sum[1]]);
  353. min = Math.min(min, timestamp);
  354. max = Math.max(max, timestamp);
  355. });
  356. var catData = [];
  357. $.each(categories, function(category, entries) {
  358. var series = {label: category, data: []};
  359. var total = 0;
  360. $.each(transData[0].data, function() {
  361. var timestamp = this[0];
  362. var val = entries[timestamp] ? entries[timestamp] : 0;
  363. total += val;
  364. series.data.push([timestamp, val]);
  365. });
  366. series.total = total;
  367. catData.push(series);
  368. });
  369. var markings = [];
  370. // Add a marking for each year division
  371. var year = new Date(new Date(max).getFullYear(), 0);
  372. while (year.getTime() > min) {
  373. markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
  374. year.setFullYear(year.getFullYear() - 1);
  375. }
  376. catData.sort(function(a, b) { return a.total - b.total; });
  377. plots.cathistory = $.plot($('#cathistory'), catData, {
  378. xaxis: { mode: 'time', timeformat: '%y/%m' },
  379. legend: { noColumns: 2 },
  380. series: {
  381. stack: true,
  382. lines: { show: true, fill: true }
  383. },
  384. grid: {
  385. markings: markings
  386. }
  387. });
  388. markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } });
  389. plots.history = $.plot($('#history'), transData, {
  390. xaxis: { mode: 'time', timeformat: '%y/%m' },
  391. series: {
  392. lines: { show: true, fill: true },
  393. points: { show: true }
  394. },
  395. legend: { noColumns: 3, position: 'nw' },
  396. grid: {
  397. hoverable: true,
  398. clickable: true,
  399. markings: markings
  400. },
  401. selection: { mode : "x" }
  402. });
  403. $("#history").bind("plothover", function (event, pos, item) {
  404. if (item) {
  405. var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex};
  406. if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
  407. previousPoint = id;
  408. $("#tooltip").remove();
  409. var x = item.datapoint[0],
  410. y = item.datapoint[1].toFixed(2);
  411. var date = new Date(x);
  412. var seriesTitles = ["Money in", "Money out", "Balance change"];
  413. showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + date.getDisplayMonth() + " " + date.getFullYear() + " = " + y);
  414. }
  415. } else {
  416. $("#tooltip").remove();
  417. previousPoint = null;
  418. }
  419. });
  420. $('#history').bind('plotselected', function(event, ranges) {
  421. var startDate = parseInt(ranges.xaxis.from.toFixed());
  422. var endDate = parseInt(ranges.xaxis.to.toFixed());
  423. if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
  424. setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
  425. }
  426. });
  427. $('#history').bind('plotclick', function(event, pos, item) {
  428. if (item) {
  429. setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']);
  430. }
  431. });
  432. $('#expense').bind('plotclick', function(event, pos, item) {
  433. setState({ categoryFilter: item.series.label.replace(/ \(.*$/, '') }, ['expanded']);
  434. });
  435. $.history.init(handleStateChange);
  436. });