var previousPoint = null; var state = {}; var oldState = {}; var plots = {}; // ----------------------------------------------------------------------------- // ------------------ Date handling -------------------------------------------- // ----------------------------------------------------------------------------- Date.prototype.months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; /** * Formats this date as year and two digit month, separated by a '-'. * * @return This date formatted as a key */ Date.prototype.getKey = function() { return this.getFullYear() + '-' + (this.getMonth() < 9 ? '0' : '') + (this.getMonth() + 1); } /** * Gets a textual representation of this date's month * * @return This date's month name */ Date.prototype.getDisplayMonth = function() { return this.months[this.getMonth()]; } /** * Gets a textual representation of the range of months between this date and * the specified other. It is assumed that the other date will occur after this. * * @param {Date} other The end date * @return {String} A textual representation of the date range */ Date.prototype.getRangeText = function(other) { if (this.getFullYear() == other.getFullYear() && this.getMonth() == other.getMonth()) { return this.getDisplayMonth() + ' ' + this.getFullYear(); } else if (this.getFullYear() == other.getFullYear()) { return this.getDisplayMonth() + '-' + other.getDisplayMonth() + ' ' + this.getFullYear(); } else { return this.getDisplayMonth() + ' ' + this.getFullYear() + ' - ' + other.getDisplayMonth() + ' ' + other.getFullYear(); } } /** * Parses a date string in the format YYYY-MM-DD HH:MM:SS. * * @param {String} input The input string * @return {int} Number of milliseconds since 1970 */ Date.parseYMD = function(input) { var parts = input.split(/[:\-\s]/); 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)); return date.getTime(); } /** * Gets a date object corresponding to the specified timestamp. If advanceToNext * is specified, and the timestamp doesn't already correspond to the first of * the month, the date is forwarded to the first day of the next month. * * @param {int} timestamp The timestamp to convert to a date * @param {bool} advanceToNext Whether to advance to the 1st or not * @return {Date} A corresponding date object */ function getDate(timestamp, advanceToNext) { var date = new Date(timestamp); if (advanceToNext && date.getDate() > 1) { date.setDate(1); date.getMonth() == 11 && date.setYear(date.getFullYear() + 1); date.setMonth(date.getMonth() == 11 ? 0 : date.getMonth() + 1); } return date; } // ----------------------------------------------------------------------------- // ------------------ Data handling -------------------------------------------- // ----------------------------------------------------------------------------- /** * Calculates the sum of transactions belonging to each category within * the specified data. * * @param data An array of transactions to include * @param {bool} incoming True to tally income, false to tally expenses * @return A mapping of categories to the sum of their transactions */ function getCategoryTotals(data, incoming) { var catData = {}; $.each(data, function() { trans = this; var category = trans.Category ? trans.Category : 'Unsorted'; if (category != '(Ignored)' && incoming == trans.Amount > 0) { if (!catData[category]) { catData[category] = 0; } catData[category] += Math.abs(trans.Amount); } }); return catData; } /** * Retrieves an array of transactions which occur between the specified two * dates. This has a resolution of a month -- any data in the same month * as the start date will be included, and any data after the month of the * end date will be excluded. * * @param {Date} start The date to start including data at * @param {Date} end The date to stop including data at * @return An array of transactions between the two dates */ function getDataForRange(start, end) { var include = false; var included = []; $.each(data, function(month, monthData) { include |= month == start.getKey(); if (include) { $.each(monthData, function(index, trans) { included.push(trans); }); } include &= month != end.getKey(); }); return included; } // ----------------------------------------------------------------------------- // ------------------ State handling ------------------------------------------- // ----------------------------------------------------------------------------- /** * Update the page's state with the specified new state. This causes the state * to be loaded into the user's history so they can use back and forward * functionality in their browser. New state is merged with the old state. * * @param newState The new properties to add to the state * @param invalidatedState An array of state keys to remove * @param invalidatedSubState An map of state subkeys to remove */ function setState(newState, invalidatedState, invalidatedSubState) { oldState = $.extend(true, {}, state); $.extend(true, state, newState); invalidatedState && $.each(invalidatedState, function(_, x) { delete state[x]; }); invalidatedSubState && $.each(invalidatedSubState, function(key, values) { $.each(values, function() { delete state[key][this]; }); }); $.history.load(JSON.stringify(state)); } /** * Called when the page state changes (either via a call to $.history.load or * by the user manually changing the fragment or going back or forward). * * @param {string} hash The new page fragment */ function handleStateChange(hash) { try { state = JSON.parse(hash); } catch (ex) { state = {}; } if (state.start && state.end && state.type) { if (state.start == oldState.start && state.end == oldState.end && state.type == oldState.type && state.categoryFilter == oldState.categoryFilter) { // Just show/hide nodes as required ensureExpanded(oldState.expanded, state.expanded); } else { // Update the transaction table and pie charts showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter, state.expanded); } // If the selection has changed, update the visual representation (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }}); } } // ----------------------------------------------------------------------------- /** * Formats the specified number in a manner suitable for a currency. That is, * fixed to two decimal places and with a thousand separator every 3 digits. * * @return A string representation of the number as a currency */ Number.prototype.toCurrency = function() { return this.toFixed(2).replace(/([0-9])(?=([0-9]{3})+\.)/g, '$1,'); }; /** * Computes the arithmatic mean, variance and deviation for the given array. * * @param a Array of numbers to be averaged * @return A map containing the mean, variance and deviation */ function getAverage(a){ var r = {mean: 0, variance: 0, deviation: 0}; var length = a.length; // Sum the array for (var sum = 0, i = length; i--; sum += a[i]); var mean = r.mean = sum / length // Sum the squares of the differences from the mean for (var i = length, sum = 0; i--; sum += Math.pow(a[i] - mean, 2)); r.deviation = Math.sqrt(r.variance = sum / length) return r; } /** * Adds an 'alt' class to every other visible row in the specified table. * * @param table The table to be marked-up */ function colourTableRows(table) { $('tr', table).removeClass('alt'); $('tr:visible:even', table).addClass('alt'); } /** * Shows a tooltip with the specified content at the given co-ordinates. * * @param {int} x The x co-ordinate to show the tooltip at * @param {int} y The y co-ordinate to show the tooltip at * @param contents The content to display in the tooltip element */ function showTooltip(x, y, contents) { $('
' + contents + '
').css( { position: 'absolute', display: 'none', top: y + 5, left: x + 5, border: '1px solid #fdd', padding: '2px', 'background-color': '#fee', }).appendTo("body").fadeIn(200); } /** * Called when the user clicks on the expand/contract toggle on a transaction * line where similar entries have been merged. * * @param event The corresponding event */ function expandLinkHandler(event) { var text = $(this).text(); var expanded = text.substr(0, 2) == '(+'; if (expanded) { var newExpanded = {}; newExpanded[event.data.id] = true; setState({expanded: newExpanded}, []); } else { setState({}, [], {expanded: [event.data.id]}); } colourTableRows($('#historytable')); return false; } /** * Ensures that the desired elements are appropriately expanded or collapsed. * * @param oldList A map containing keys for each entry that was previously expanded * @param newList A map containing keys for each entry that should now be expanded */ function ensureExpanded(oldList, newList) { oldList = oldList || {}; newList = newList || {}; $.each(newList, function(id, _) { if (!oldList[id]) { // This entry needs to be expanded $('.hidden' + id).show(); var handle = $('#collapseHandle' + id); handle.text(handle.text().replace(/\+/, '-')); handle.parents('tr').find('td.amount').text(parseFloat(handle.data('single')).toCurrency()); } }); $.each(oldList, function(id, _) { if (!newList[id]) { // This entry needs to be collapsed $('.hidden' + id).hide(); var handle = $('#collapseHandle' + id); handle.text(handle.text().replace(/\-/, '+')); handle.parents('tr').find('td.amount').text(parseFloat(handle.data('total')).toCurrency()); } }); colourTableRows($('#historytable')); } /** * Determines if the two transactions should be merged together. That is, * whether the transactions have an identical description, type and category. * * @param a The first transaction * @param b The second transaction * @return True if the transactions should be merged, false otherwise */ function shouldMerge(a, b) { return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category; } /** * Draws a pie chart of transactions by category. * * @param included An array of transactions to include in the chart * @param incoming True to show income, false to show expenses */ function drawCategoryPieChart(included, incoming) { var pieData = getCategoryTotals(included, incoming); var total = 0; $.each(pieData, function(_, amount) { total += amount; }); var seriesData = []; $.each(pieData, function(category, amount) { seriesData.push({ label: category + ' (£' + amount.toCurrency() + ', ' + Math.floor(100 * amount / total) + '%)', data: amount }); }); seriesData.sort(function(a, b) { return b.data - a.data; }); plots.expense = $.plot($('#expense'), seriesData, { series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } }, grid: { clickable: true } }); } /** * Calculates repeat transactions within the specified data. * * @param data The data to be analysed */ function calculateRepeatTransactions(data) { $('#repeats').show(); $('#repeats tr.data').remove(); var table = $('#repeats table'); // This assumes data is sorted by date var timeSpan = Date.parseYMD(data[data.length - 1].Date.date) - Date.parseYMD(data[0].Date.date); var descs = {}; $.each(data, function() { if (!descs[this.Description]) { descs[this.Description] = []; } descs[this.Description].push(this); }); var monthTotal = 0; $.each(descs, function(desc) { // We only care if there are at least more than 2 if (this.length < 3) { return; } var lastTime = 0; var differences = []; var amounts = []; $.each(this, function() { var time = Date.parseYMD(this.Date.date); lastTime > 0 && differences.push(time - lastTime); lastTime = time; amounts.push(this.Amount); }); var average = getAverage(differences); var averageAmount = getAverage(amounts); // I may have just made this metric up. Sue me. var stability = average.deviation / average.mean; var periodInDays = average.mean / (1000 * 60 * 60 * 24); var stretch = average.mean * differences.length / timeSpan; if (stretch > 0.5) { // Happens across a decent proportion of our timespan var monthValue, periodText, classes; if (stability < 0.5 && ((periodInDays >= 5 && periodInDays <= 9) || (periodInDays >= 27 && periodInDays <= 32))) { // Stable and roughly weekly or monthly monthValue = (periodInDays <= 9 ? 4 : 1) * averageAmount.mean; periodText = periodInDays <= 9 ? 'Weekly' : 'Monthly'; classes = 'data'; } else { // Somewhat sporadic monthValue = averageAmount.mean * 30.4 / periodInDays; periodText = 'Sporadic (~' + periodInDays.toFixed(1) + ' days)'; classes = 'data sporadic'; } var tr = $('').addClass(classes).appendTo(table); $('').text(desc).appendTo(tr); $('').text(this[0].Category ? this[0].Category : 'Unsorted').appendTo(tr); $('').text(periodText).appendTo(tr); $('').text(averageAmount.mean.toCurrency()).appendTo(tr); $('').text(monthValue.toCurrency()).appendTo(tr); monthTotal += monthValue; } }); colourTableRows(table); var tr = $('').addClass('data total').appendTo(table); $('Total').appendTo(tr); $('').text(monthTotal.toCurrency()).appendTo(tr); } /** * Displays transactions and draws a category pie chart for the specified * date range. Note that dates have a granularity of a month. * * @param {int} start The timestamp to start including transactions from * @param {int} end The timestamp to stop including transactions at * @param {bool} incoming Whether or not to include incoming transactions (income) * @param {bool} outgoing Whether or not to include outgoing transactions (expenses) * @param {string} categoryFilter The category to filter transactions to (or null) * @param expanded An object containing entries indicating which merged * transactions should be shown as expanded */ function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) { $('#historytable tr.data').remove(); $('#historytable').show(); expanded = expanded || []; var startDate = getDate(start, 1), endDate = getDate(end); $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate)); var included = getDataForRange(startDate, endDate); var filtered = $.grep(included, function(x) { var category = x.Category ? x.Category : 'Unsorted'; return (incoming == x.Amount > 0) && (!categoryFilter || categoryFilter == category); }); var table = $('#historytable table'); var total = 0; var lastEntry = {}; var id = 0; $.each(filtered, function() { total += this.Amount; var category = this.Category ? this.Category : 'Unsorted'; var tr = $('').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table); if (shouldMerge(lastEntry, this)) { if (lastEntry.id) { var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+'); lastEntry.count++; $('span', lastEntry.tr).text(prefix + lastEntry.count + ')'); } else { lastEntry.id = ++id; lastEntry.count = 1; var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+'); var a = $('').addClass('link').text(prefix + '1)').attr('id', 'collapseHandle' + lastEntry.id).appendTo($('td.desc', lastEntry.tr).append(' ')); a.bind('click', { id: lastEntry.id }, expandLinkHandler); a.data('single', lastEntry.Amount); } lastEntry.Amount = Math.round(100 * (lastEntry.Amount + this.Amount)) / 100; $('#collapseHandle' + lastEntry.id).data('total', lastEntry.Amount); !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount.toCurrency()); tr.addClass('collapsed hidden' + lastEntry.id); } else { lastEntry = $.extend({}, this, {tr: tr}); } $('').text(this.Date.date.split(' ')[0]).appendTo(tr); $('').text(this.Type ? this.Type : 'Other').appendTo(tr); $('').text(this.Category ? this.Category : '').appendTo(tr); $('').addClass('desc').text(this.Description).appendTo(tr); $('').addClass('amount').text(this.Amount.toCurrency()).appendTo(tr); }); var tr = $('').addClass('data total').appendTo(table); $('Total').appendTo(tr); $('').text(total.toCurrency()).appendTo(tr); colourTableRows(table); drawCategoryPieChart(included, incoming); calculateRepeatTransactions(included); } $(function() { var transData = [{label: 'Income', data: []}, {label: 'Expense', data: []}, {label: 'Difference', data: []}]; var categories = {}; var min = new Date().getTime(), max = 0; $.each(data, function(month, entries) { var split = month.split('-'); var timestamp = new Date(split[0], split[1] - 1).getTime(); var sum = [0, 0]; $.each(entries, function() { if (this.Category == '(Ignored)') { return; } if (this.Amount < 0) { var category = this.Category ? this.Category : 'Unsorted'; if (!categories[category]) { categories[category] = {}; } if (!categories[category][timestamp]) { categories[category][timestamp] = 0; } categories[category][timestamp] -= this.Amount; } sum[this.Amount < 0 ? 1 : 0] += this.Amount; }); transData[0].data.push([timestamp, sum[0]]); transData[1].data.push([timestamp, sum[1]]); transData[2].data.push([timestamp, sum[0] + sum[1]]); min = Math.min(min, timestamp); max = Math.max(max, timestamp); }); var catData = []; $.each(categories, function(category, entries) { var series = {label: category, data: []}; var total = 0; $.each(transData[0].data, function() { var timestamp = this[0]; var val = entries[timestamp] ? entries[timestamp] : 0; total += val; series.data.push([timestamp, val]); }); series.total = total; catData.push(series); }); var markings = []; // Add a marking for each year division var year = new Date(new Date(max).getFullYear(), 0); while (year.getTime() > min) { markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } }); year.setFullYear(year.getFullYear() - 1); } catData.sort(function(a, b) { return a.total - b.total; }); plots.cathistory = $.plot($('#cathistory'), catData, { xaxis: { mode: 'time', timeformat: '%y/%m' }, legend: { noColumns: 2 }, series: { stack: true, lines: { show: true, fill: true } }, grid: { markings: markings } }); markings.push({ color: '#000', lineWidth: 1, yaxis: { from: 0, to: 0 } }); plots.history = $.plot($('#history'), transData, { xaxis: { mode: 'time', timeformat: '%y/%m' }, series: { lines: { show: true, fill: true }, points: { show: true } }, legend: { noColumns: 3, position: 'nw' }, grid: { hoverable: true, clickable: true, markings: markings }, selection: { mode : "x" } }); $("#history").bind("plothover", function (event, pos, item) { if (item) { var id = {dataIndex: item.dataIndex, seriesIndex: item.seriesIndex}; if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) { previousPoint = id; $("#tooltip").remove(); var x = item.datapoint[0], y = item.datapoint[1].toFixed(2); var date = new Date(x); var seriesTitles = ["Money in", "Money out", "Balance change"]; showTooltip(item.pageX, item.pageY, (seriesTitles[item.seriesIndex]) + " during " + date.getDisplayMonth() + " " + date.getFullYear() + " = " + y); } } else { $("#tooltip").remove(); previousPoint = null; } }); $('#history').bind('plotselected', function(event, ranges) { var startDate = parseInt(ranges.xaxis.from.toFixed()); var endDate = parseInt(ranges.xaxis.to.toFixed()); if (state.start != startDate || state.end != endDate || state.type != 'expenses') { setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']); } }); $('#history').bind('plotclick', function(event, pos, item) { if (item) { setState({ start: item.datapoint[0], end: item.datapoint[0], type: item.seriesIndex == 0 ? 'income' : 'expenses' }, ['categoryFilter', 'expanded']); } }); $('#expense').bind('plotclick', function(event, pos, item) { setState({ categoryFilter: item.series.label.replace(/ \(.*$/, '') }, ['expanded']); }); $.history.init(handleStateChange); });