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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. * Parses a date string in the format YYYY-MM-DD HH:MM:SS.
  43. *
  44. * @param {String} input The input string
  45. * @return {int} Number of milliseconds since 1970
  46. */
  47. Date.parseYMD = function(input) {
  48. var parts = input.split(/[:\-\s]/);
  49. var date = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), parseInt(parts[3], 10), parseInt(parts[4], 10), parseInt(parts[5], 10));
  50. return date.getTime();
  51. }
  52. /**
  53. * Gets a date object corresponding to the specified timestamp. If advanceToNext
  54. * is specified, and the timestamp doesn't already correspond to the first of
  55. * the month, the date is forwarded to the first day of the next month.
  56. *
  57. * @param {int} timestamp The timestamp to convert to a date
  58. * @param {bool} advanceToNext Whether to advance to the 1st or not
  59. * @return {Date} A corresponding date object
  60. */
  61. function getDate(timestamp, advanceToNext) {
  62. var date = new Date(timestamp);
  63. if (advanceToNext && date.getDate() > 1) {
  64. date.setDate(1);
  65. date.getMonth() == 11 && date.setYear(date.getFullYear() + 1);
  66. date.setMonth(date.getMonth() == 11 ? 0 : date.getMonth() + 1);
  67. }
  68. return date;
  69. }
  70. // -----------------------------------------------------------------------------
  71. // ------------------ Data handling --------------------------------------------
  72. // -----------------------------------------------------------------------------
  73. /**
  74. * Calculates the sum of transactions belonging to each category within
  75. * the specified data.
  76. *
  77. * @param data An array of transactions to include
  78. * @param {bool} incoming True to tally income, false to tally expenses
  79. * @return A mapping of categories to the sum of their transactions
  80. */
  81. function getCategoryTotals(data, incoming) {
  82. var catData = {};
  83. $.each(data, function() {
  84. trans = this;
  85. var category = trans.Category ? trans.Category : 'Unsorted';
  86. if (category != '(Ignored)' && incoming == trans.Amount > 0) {
  87. if (!catData[category]) { catData[category] = 0; }
  88. catData[category] += Math.abs(trans.Amount);
  89. }
  90. });
  91. return catData;
  92. }
  93. /**
  94. * Retrieves an array of transactions which occur between the specified two
  95. * dates. This has a resolution of a month -- any data in the same month
  96. * as the start date will be included, and any data after the month of the
  97. * end date will be excluded.
  98. *
  99. * @param {Date} start The date to start including data at
  100. * @param {Date} end The date to stop including data at
  101. * @return An array of transactions between the two dates
  102. */
  103. function getDataForRange(start, end) {
  104. var include = false;
  105. var included = [];
  106. $.each(data, function(month, monthData) {
  107. include |= month == start.getKey();
  108. if (include) {
  109. $.each(monthData, function(index, trans) {
  110. included.push(trans);
  111. });
  112. }
  113. include &= month != end.getKey();
  114. });
  115. return included;
  116. }
  117. // -----------------------------------------------------------------------------
  118. // ------------------ State handling -------------------------------------------
  119. // -----------------------------------------------------------------------------
  120. /**
  121. * Update the page's state with the specified new state. This causes the state
  122. * to be loaded into the user's history so they can use back and forward
  123. * functionality in their browser. New state is merged with the old state.
  124. *
  125. * @param newState The new properties to add to the state
  126. * @param invalidatedState An array of state keys to remove
  127. * @param invalidatedSubState An map of state subkeys to remove
  128. */
  129. function setState(newState, invalidatedState, invalidatedSubState) {
  130. oldState = $.extend(true, {}, state);
  131. $.extend(true, state, newState);
  132. invalidatedState && $.each(invalidatedState, function(_, x) { delete state[x]; });
  133. invalidatedSubState && $.each(invalidatedSubState, function(key, values) {
  134. $.each(values, function() {
  135. delete state[key][this];
  136. });
  137. });
  138. $.history.load(JSON.stringify(state));
  139. }
  140. /**
  141. * Called when the page state changes (either via a call to $.history.load or
  142. * by the user manually changing the fragment or going back or forward).
  143. *
  144. * @param {string} hash The new page fragment
  145. */
  146. function handleStateChange(hash) {
  147. try {
  148. state = JSON.parse(hash);
  149. } catch (ex) {
  150. state = {};
  151. }
  152. if (state.start && state.end && state.type) {
  153. if (state.start == oldState.start && state.end == oldState.end && state.type == oldState.type && state.categoryFilter == oldState.categoryFilter) {
  154. // Just show/hide nodes as required
  155. ensureExpanded(oldState.expanded, state.expanded);
  156. } else {
  157. // Update the transaction table and pie charts
  158. showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter, state.expanded);
  159. }
  160. // If the selection has changed, update the visual representation
  161. (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }});
  162. }
  163. }
  164. // -----------------------------------------------------------------------------
  165. /**
  166. * Formats the specified number in a manner suitable for a currency. That is,
  167. * fixed to two decimal places and with a thousand separator every 3 digits.
  168. *
  169. * @return A string representation of the number as a currency
  170. */
  171. Number.prototype.toCurrency = function() {
  172. return this.toFixed(2).replace(/([0-9])(?=([0-9]{3})+\.)/g, '$1,');
  173. };
  174. /**
  175. * Computes the arithmatic mean, variance and deviation for the given array.
  176. *
  177. * @param a Array of numbers to be averaged
  178. * @return A map containing the mean, variance and deviation
  179. */
  180. function getAverage(a){
  181. var r = {mean: 0, variance: 0, deviation: 0};
  182. var length = a.length;
  183. // Sum the array
  184. for (var sum = 0, i = length; i--; sum += a[i]);
  185. var mean = r.mean = sum / length
  186. // Sum the squares of the differences from the mean
  187. for (var i = length, sum = 0; i--; sum += Math.pow(a[i] - mean, 2));
  188. r.deviation = Math.sqrt(r.variance = sum / length)
  189. return r;
  190. }
  191. /**
  192. * Adds an 'alt' class to every other visible row in the specified table.
  193. *
  194. * @param table The table to be marked-up
  195. */
  196. function colourTableRows(table) {
  197. $('tr', table).removeClass('alt');
  198. $('tr:visible:even', table).addClass('alt');
  199. }
  200. /**
  201. * Shows a tooltip with the specified content at the given co-ordinates.
  202. *
  203. * @param {int} x The x co-ordinate to show the tooltip at
  204. * @param {int} y The y co-ordinate to show the tooltip at
  205. * @param contents The content to display in the tooltip element
  206. */
  207. function showTooltip(x, y, contents) {
  208. $('<div id="tooltip">' + contents + '</div>').css( {
  209. position: 'absolute',
  210. display: 'none',
  211. top: y + 5,
  212. left: x + 5,
  213. border: '1px solid #fdd',
  214. padding: '2px',
  215. 'background-color': '#fee',
  216. }).appendTo("body").fadeIn(200);
  217. }
  218. /**
  219. * Called when the user clicks on the expand/contract toggle on a transaction
  220. * line where similar entries have been merged.
  221. *
  222. * @param event The corresponding event
  223. */
  224. function expandLinkHandler(event) {
  225. var text = $(this).text();
  226. var expanded = text.substr(0, 2) == '(+';
  227. if (expanded) {
  228. var newExpanded = {};
  229. newExpanded[event.data.id] = true;
  230. setState({expanded: newExpanded}, []);
  231. } else {
  232. setState({}, [], {expanded: [event.data.id]});
  233. }
  234. colourTableRows($('#historytable'));
  235. return false;
  236. }
  237. /**
  238. * Ensures that the desired elements are appropriately expanded or collapsed.
  239. *
  240. * @param oldList A map containing keys for each entry that was previously expanded
  241. * @param newList A map containing keys for each entry that should now be expanded
  242. */
  243. function ensureExpanded(oldList, newList) {
  244. oldList = oldList || {};
  245. newList = newList || {};
  246. $.each(newList, function(id, _) {
  247. if (!oldList[id]) {
  248. // This entry needs to be expanded
  249. $('.hidden' + id).show();
  250. var handle = $('#collapseHandle' + id);
  251. handle.text(handle.text().replace(/\+/, '-'));
  252. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('single')).toCurrency());
  253. }
  254. });
  255. $.each(oldList, function(id, _) {
  256. if (!newList[id]) {
  257. // This entry needs to be collapsed
  258. $('.hidden' + id).hide();
  259. var handle = $('#collapseHandle' + id);
  260. handle.text(handle.text().replace(/\-/, '+'));
  261. handle.parents('tr').find('td.amount').text(parseFloat(handle.data('total')).toCurrency());
  262. }
  263. });
  264. colourTableRows($('#historytable'));
  265. }
  266. /**
  267. * Determines if the two transactions should be merged together. That is,
  268. * whether the transactions have an identical description, type and category.
  269. *
  270. * @param a The first transaction
  271. * @param b The second transaction
  272. * @return True if the transactions should be merged, false otherwise
  273. */
  274. function shouldMerge(a, b) {
  275. return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category;
  276. }
  277. /**
  278. * Draws a pie chart of transactions by category.
  279. *
  280. * @param included An array of transactions to include in the chart
  281. * @param incoming True to show income, false to show expenses
  282. */
  283. function drawCategoryPieChart(included, incoming) {
  284. var pieData = getCategoryTotals(included, incoming);
  285. var total = 0;
  286. $.each(pieData, function(_, amount) { total += amount; });
  287. var seriesData = [];
  288. $.each(pieData, function(category, amount) {
  289. seriesData.push({ label: category + ' (&pound;' + amount.toCurrency() + ', ' + Math.floor(100 * amount / total) + '%)', data: amount });
  290. });
  291. seriesData.sort(function(a, b) { return b.data - a.data; });
  292. plots.expense = $.plot($('#expense'), seriesData, {
  293. series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
  294. grid: { clickable: true }
  295. });
  296. }
  297. /**
  298. * Calculates repeat transactions within the specified data.
  299. *
  300. * @param data The data to be analysed
  301. */
  302. function calculateRepeatTransactions(data) {
  303. $('#repeats').show();
  304. $('#repeats tr.data').remove();
  305. var table = $('#repeats table');
  306. // This assumes data is sorted by date
  307. var timeSpan = Date.parseYMD(data[data.length - 1].Date.date) - Date.parseYMD(data[0].Date.date);
  308. var descs = {};
  309. $.each(data, function() {
  310. if (!descs[this.Description]) { descs[this.Description] = []; }
  311. descs[this.Description].push(this);
  312. });
  313. var monthTotal = 0;
  314. $.each(descs, function(desc) {
  315. // We only care if there are at least more than 2
  316. if (this.length < 3) { return; }
  317. var lastTime = 0;
  318. var differences = [];
  319. var amounts = [];
  320. $.each(this, function() {
  321. var time = Date.parseYMD(this.Date.date);
  322. lastTime > 0 && differences.push(time - lastTime);
  323. lastTime = time;
  324. amounts.push(this.Amount);
  325. });
  326. var average = getAverage(differences);
  327. var averageAmount = getAverage(amounts);
  328. // I may have just made this metric up. Sue me.
  329. var stability = average.deviation / average.mean;
  330. var periodInDays = average.mean / (1000 * 60 * 60 * 24);
  331. var stretch = average.mean * differences.length / timeSpan;
  332. if (stretch > 0.5) {
  333. // Happens across a decent proportion of our timespan
  334. var monthValue, periodText, classes;
  335. if (stability < 0.5 && ((periodInDays >= 5 && periodInDays <= 9) || (periodInDays >= 27 && periodInDays <= 32))) {
  336. // Stable and roughly weekly or monthly
  337. monthValue = (periodInDays <= 9 ? 4 : 1) * averageAmount.mean;
  338. periodText = periodInDays <= 9 ? 'Weekly' : 'Monthly';
  339. classes = 'data';
  340. } else {
  341. // Somewhat sporadic
  342. monthValue = averageAmount.mean * 30.4 / periodInDays;
  343. periodText = 'Sporadic (~' + periodInDays.toFixed(1) + ' days)';
  344. classes = 'data sporadic';
  345. }
  346. var tr = $('<tr/>').addClass(classes).appendTo(table);
  347. $('<td/>').text(desc).appendTo(tr);
  348. $('<td/>').text(this[0].Category ? this[0].Category : 'Unsorted').appendTo(tr);
  349. $('<td/>').text(periodText).appendTo(tr);
  350. $('<td class="amount"/>').text(averageAmount.mean.toCurrency()).appendTo(tr);
  351. $('<td class="amount"/>').text(monthValue.toCurrency()).appendTo(tr);
  352. monthTotal += monthValue;
  353. }
  354. });
  355. colourTableRows(table);
  356. var tr = $('<tr/>').addClass('data total').appendTo(table);
  357. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  358. $('<td class="amount"></td>').text(monthTotal.toCurrency()).appendTo(tr);
  359. }
  360. /**
  361. * Displays transactions and draws a category pie chart for the specified
  362. * date range. Note that dates have a granularity of a month.
  363. *
  364. * @param {int} start The timestamp to start including transactions from
  365. * @param {int} end The timestamp to stop including transactions at
  366. * @param {bool} incoming Whether or not to include incoming transactions (income)
  367. * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
  368. * @param {string} categoryFilter The category to filter transactions to (or null)
  369. * @param expanded An object containing entries indicating which merged
  370. * transactions should be shown as expanded
  371. */
  372. function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
  373. $('#historytable tr.data').remove();
  374. $('#historytable').show();
  375. expanded = expanded || [];
  376. var startDate = getDate(start, 1), endDate = getDate(end);
  377. $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
  378. var included = getDataForRange(startDate, endDate);
  379. var filtered = $.grep(included, function(x) {
  380. var category = x.Category ? x.Category : 'Unsorted';
  381. return (incoming == x.Amount > 0) && (!categoryFilter || categoryFilter == category);
  382. });
  383. var table = $('#historytable table');
  384. var total = 0;
  385. var lastEntry = {};
  386. var id = 0;
  387. $.each(filtered, function() {
  388. total += this.Amount;
  389. var category = this.Category ? this.Category : 'Unsorted';
  390. var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
  391. if (shouldMerge(lastEntry, this)) {
  392. if (lastEntry.id) {
  393. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  394. lastEntry.count++;
  395. $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
  396. } else {
  397. lastEntry.id = ++id;
  398. lastEntry.count = 1;
  399. var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
  400. var a = $('<span>').addClass('link').text(prefix + '1)').attr('id', 'collapseHandle' + lastEntry.id).appendTo($('td.desc', lastEntry.tr).append(' '));
  401. a.bind('click', { id: lastEntry.id }, expandLinkHandler);
  402. a.data('single', lastEntry.Amount);
  403. }
  404. lastEntry.Amount = Math.round(100 * (lastEntry.Amount + this.Amount)) / 100;
  405. $('#collapseHandle' + lastEntry.id).data('total', lastEntry.Amount);
  406. !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount.toCurrency());
  407. tr.addClass('collapsed hidden' + lastEntry.id);
  408. } else {
  409. lastEntry = $.extend({}, this, {tr: tr});
  410. }
  411. $('<td/>').text(this.Date.date.split(' ')[0]).appendTo(tr);
  412. $('<td/>').text(this.Type ? this.Type : 'Other').appendTo(tr);
  413. $('<td/>').text(this.Category ? this.Category : '').appendTo(tr);
  414. $('<td/>').addClass('desc').text(this.Description).appendTo(tr);
  415. $('<td/>').addClass('amount').text(this.Amount.toCurrency()).appendTo(tr);
  416. });
  417. var tr = $('<tr/>').addClass('data total').appendTo(table);
  418. $('<th colspan="4" class="total">Total</th>').appendTo(tr);
  419. $('<td class="amount"></td>').text(total.toCurrency()).appendTo(tr);
  420. colourTableRows(table);
  421. drawCategoryPieChart(included, incoming);
  422. calculateRepeatTransactions(included);
  423. }
  424. $(function() {
  425. var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}];
  426. var categories = {};
  427. var min = new Date().getTime(), max = 0;
  428. $.each(data, function(month, entries) {
  429. var split = month.split('-');
  430. var timestamp = new Date(split[0], split[1] - 1).getTime();
  431. var sum = [0, 0];
  432. $.each(entries, function() {
  433. if (this.Category == '(Ignored)') { return; }
  434. if (this.Amount < 0) {
  435. var category = this.Category ? this.Category : 'Unsorted';
  436. if (!categories[category]) { categories[category] = {}; }
  437. if (!categories[category][timestamp]) { categories[category][timestamp] = 0; }
  438. categories[category][timestamp] -= this.Amount;
  439. }
  440. sum[this.Amount < 0 ? 1 : 0] += this.Amount;
  441. });
  442. transData[0].data.push([timestamp, sum[0]]);
  443. transData[1].data.push([timestamp, sum[1]]);
  444. transData[2].data.push([timestamp, sum[0] + sum[1]]);
  445. min = Math.min(min, timestamp);
  446. max = Math.max(max, timestamp);
  447. });
  448. var catData = [];
  449. $.each(categories, function(category, entries) {
  450. var series = {label: category, data: []};
  451. var total = 0;
  452. $.each(transData[0].data, function() {
  453. var timestamp = this[0];
  454. var val = entries[timestamp] ? entries[timestamp] : 0;
  455. total += val;
  456. series.data.push([timestamp, val]);
  457. });
  458. series.total = total;
  459. catData.push(series);
  460. });
  461. var markings = [];
  462. // Add a marking for each year division
  463. var year = new Date(new Date(max).getFullYear(), 0);
  464. while (year.getTime() > min) {
  465. markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
  466. year.setFullYear(year.getFullYear() - 1);
  467. }
  468. catData.sort(function(a, b) { return a.total - b.total; });
  469. plots.cathistory = $.plot($('#cathistory'), catData, {
  470. xaxis: { mode: 'time', timeformat: '%y/%m' },
  471. legend: { noColumns: 2 },
  472. series: {
  473. stack: true,
  474. lines: { show: true, fill: true }
  475. },
  476. grid: {
  477. markings: markings
  478. }
  479. });
  480. markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } });
  481. plots.history = $.plot($('#history'), transData, {
  482. xaxis: { mode: 'time', timeformat: '%y/%m' },
  483. series: {
  484. lines: { show: true, fill: true },
  485. points: { show: true }
  486. },
  487. legend: { noColumns: 3, position: 'nw' },
  488. grid: {
  489. hoverable: true,
  490. clickable: true,
  491. markings: markings
  492. },
  493. selection: { mode : "x" }
  494. });
  495. $("#history").bind("plothover", function (event, pos, item) {
  496. if (item) {
  497. var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex};
  498. if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
  499. previousPoint = id;
  500. $("#tooltip").remove();
  501. var x = item.datapoint[0],
  502. y = item.datapoint[1].toFixed(2);
  503. var date = new Date(x);
  504. var seriesTitles = ["Money in", "Money out", "Balance change"];
  505. showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + date.getDisplayMonth() + " " + date.getFullYear() + " = " + y);
  506. }
  507. } else {
  508. $("#tooltip").remove();
  509. previousPoint = null;
  510. }
  511. });
  512. $('#history').bind('plotselected', function(event, ranges) {
  513. var startDate = parseInt(ranges.xaxis.from.toFixed());
  514. var endDate = parseInt(ranges.xaxis.to.toFixed());
  515. if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
  516. setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
  517. }
  518. });
  519. $('#history').bind('plotclick', function(event, pos, item) {
  520. if (item) {
  521. setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']);
  522. }
  523. });
  524. $('#expense').bind('plotclick', function(event, pos, item) {
  525. setState({ categoryFilter: item.series.label.replace(/ \(.*$/, '') }, ['expanded']);
  526. });
  527. $.history.init(handleStateChange);
  528. });