Переглянути джерело

Lots of tidying + documenting of JS

Transaction table header now includes category if appropriate
master
Chris Smith 13 роки тому
джерело
коміт
90d4b50ee7
1 змінених файлів з 272 додано та 112 видалено
  1. 272
    112
      analyser.js

+ 272
- 112
analyser.js Переглянути файл

@@ -1,13 +1,188 @@
1 1
 var previousPoint = null;
2 2
 var state = {};
3 3
 var plots = {};
4
-var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
5 4
 
5
+// -----------------------------------------------------------------------------
6
+// ------------------ Date handling --------------------------------------------
7
+// -----------------------------------------------------------------------------
8
+
9
+Date.prototype.months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
10
+
11
+/**
12
+ * Formats this date as year and two digit month, separated by a '-'.
13
+ *
14
+ * @return This date formatted as a key
15
+ */
16
+Date.prototype.getKey = function() {
17
+ return this.getFullYear() + '-' + (this.getMonth() < 9 ? '0' : '') + (this.getMonth() + 1);
18
+}
19
+
20
+/**
21
+ * Gets a textual representation of this date's month
22
+ *
23
+ * @return This date's month name
24
+ */
25
+Date.prototype.getDisplayMonth = function() {
26
+ return this.months[this.getMonth()];
27
+}
28
+
29
+/**
30
+ * Gets a textual representation of the range of months between this date and
31
+ * the specified other. It is assumed that the other date will occur after this.
32
+ *
33
+ * @param {Date} other The end date
34
+ * @return {String} A textual representation of the date range
35
+ */
36
+Date.prototype.getRangeText = function(other) {
37
+ if (this.getFullYear() == other.getFullYear() && this.getMonth() == other.getMonth()) {
38
+  return this.getDisplayMonth() + ' ' + this.getFullYear();
39
+ } else if (this.getFullYear() == other.getFullYear()) {
40
+  return this.getDisplayMonth() + '-' + other.getDisplayMonth() + ' ' + this.getFullYear();
41
+ } else {
42
+  return this.getDisplayMonth() + ' ' + this.getFullYear() + ' - ' +  other.getDisplayMonth() + ' ' + other.getFullYear();
43
+ }
44
+}
45
+
46
+/**
47
+ * Gets a date object corresponding to the specified timestamp. If advanceToNext
48
+ * is specified, and the timestamp doesn't already correspond to the first of
49
+ * the month, the date is forwarded to the first day of the next month.
50
+ *
51
+ * @param {int} timestamp The timestamp to convert to a date
52
+ * @param {bool} advanceToNext Whether to advance to the 1st or not
53
+ * @return {Date} A corresponding date object
54
+ */
55
+function getDate(timestamp, advanceToNext) {
56
+ var date = new Date(timestamp);
57
+
58
+ if (advanceToNext && date.getDate() > 1) {
59
+  date.setDate(1);
60
+  date.getMonth() == 11 && date.setYear(date.getFullYear() + 1);
61
+  date.setMonth(date.getMonth() == 11 ? 0 : date.getMonth() + 1);
62
+ }
63
+
64
+ return date;
65
+}
66
+
67
+// -----------------------------------------------------------------------------
68
+// ------------------ Data handling --------------------------------------------
69
+// -----------------------------------------------------------------------------
70
+
71
+/**
72
+ * Calculates the sum of transactions belonging to each category within
73
+ * the specified data.
74
+ *
75
+ * @param data An array of transactions to include
76
+ * @param {bool} incoming True to tally income, false to tally expenses
77
+ * @return A mapping of categories to the sum of their transactions
78
+ */
79
+function getCategoryTotals(data, incoming) {
80
+ var catData = {};
81
+
82
+ $.each(data, function() {
83
+  trans = this;
84
+  var category = trans.Category ? trans.Category : 'Unsorted';
85
+
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
+
92
+ return catData;
93
+}
94
+
95
+/**
96
+ * Retrieves an array of transactions which occur between the specified two
97
+ * dates. This has a resolution of a month -- any data in the same month
98
+ * as the start date will be included, and any data after the month of the
99
+ * end date will be excluded.
100
+ *
101
+ * @param {Date} start The date to start including data at
102
+ * @param {Date} end The date to stop including data at
103
+ * @return An array of transactions between the two dates
104
+ */
105
+function getDataForRange(start, end) {
106
+ var include = false;
107
+ var included = [];
108
+ $.each(data, function(month, monthData) {
109
+  include |= month == start.getKey();
110
+
111
+  if (include) {
112
+   $.each(monthData, function(index, trans) {
113
+    included.push(trans);
114
+   });
115
+  }
116
+
117
+  include &= month != end.getKey();
118
+ });
119
+
120
+ return included;
121
+}
122
+
123
+// -----------------------------------------------------------------------------
124
+// ------------------ State handling -------------------------------------------
125
+// -----------------------------------------------------------------------------
126
+
127
+/**
128
+ * Update the page's state with the specified new state. This causes the state
129
+ * to be loaded into the user's history so they can use back and forward
130
+ * functionality in their browser. New state is merged with the old state.
131
+ *
132
+ * @param newState The new properties to add to the state
133
+ * @param invalidatedState An array of state keys to remove
134
+ */
135
+function setState(newState, invalidatedState) {
136
+ $.extend(true, state, newState);
137
+
138
+ $.each(invalidatedState, function(_, x) { delete state[x]; });
139
+
140
+ $.history.load(JSON.stringify(state));
141
+}
142
+
143
+/**
144
+ * Called when the page state changes (either via a call to $.history.load or
145
+ * by the user manually changing the fragment or going back or forward).
146
+ *
147
+ * @param {string} hash The new page fragment
148
+ */
149
+function handleStateChange(hash) {
150
+ var oldState = $.extend({}, state);
151
+
152
+ try {
153
+  state = JSON.parse(hash);
154
+ } catch (ex) {
155
+  state = {};
156
+ }
157
+
158
+ if (state.start && state.end && state.type) {
159
+  // Update the transaction table and pie charts
160
+  showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter, state.expanded);
161
+
162
+  // If the selection has changed, update the visual representation
163
+  (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }});
164
+ }
165
+}
166
+
167
+// -----------------------------------------------------------------------------
168
+
169
+/**
170
+ * Adds an 'alt' class to every other visible row in the specified table.
171
+ *
172
+ * @param table The table to be marked-up
173
+ */
6 174
 function colourTableRows(table) {
7 175
  $('tr', table).removeClass('alt');
8 176
  $('tr:visible:even', table).addClass('alt');
9 177
 }
10 178
 
179
+/**
180
+ * Shows a tooltip with the specified content at the given co-ordinates.
181
+ *
182
+ * @param {int} x The x co-ordinate to show the tooltip at
183
+ * @param {int} y The y co-ordinate to show the tooltip at
184
+ * @param contents The content to display in the tooltip element
185
+ */
11 186
 function showTooltip(x, y, contents) {
12 187
  $('<div id="tooltip">' + contents + '</div>').css( {
13 188
   position: 'absolute',
@@ -20,10 +195,12 @@ function showTooltip(x, y, contents) {
20 195
  }).appendTo("body").fadeIn(200);
21 196
 }
22 197
 
23
-function getDateKey(date) {
24
- return date.getFullYear() + '-' + (date.getMonth() < 9 ? '0' : '') + (date.getMonth() + 1);
25
-}
26
-
198
+/**
199
+ * Called when the user clicks on the expand/contract toggle on a transaction
200
+ * line where similar entries have been merged.
201
+ *
202
+ * @param event The corresponding event
203
+ */
27 204
 function expandLinkHandler(event) {
28 205
  var text = $(this).text();
29 206
  var expanded = text.substr(0, 2) == '(+';
@@ -44,110 +221,107 @@ function expandLinkHandler(event) {
44 221
  return false;
45 222
 }
46 223
 
47
-function setState(newState, invalidatedState) {
48
- $.extend(true, state, newState);
224
+/**
225
+ * Determines if the two transactions should be merged together. That is,
226
+ * whether the transactions have an identical description, type and category.
227
+ *
228
+ * @param a The first transaction
229
+ * @param b The second transaction
230
+ * @return True if the transactions should be merged, false otherwise
231
+ */
232
+function shouldMerge(a, b) {
233
+ return a.Description == b.Description && a.Type == b.Type && a.Category == b.Category;
234
+}
49 235
 
50
- $.each(invalidatedState, function(_, x) { delete state[x]; });
236
+/**
237
+ * Draws a pie chart of transactions by category.
238
+ *
239
+ * @param included An array of transactions to include in the chart
240
+ * @param incoming True to show income, false to show expenses
241
+ */
242
+function drawCategoryPieChart(included, incoming) {
243
+ var pieData = getCategoryTotals(included, incoming);
244
+ var seriesData = [];
245
+ $.each(pieData, function(category, amount) {
246
+  seriesData.push({ label: category + ' (' + Math.round(amount) + ')', data: amount });
247
+ });
51 248
 
52
- $.history.load(JSON.stringify(state));
249
+ seriesData.sort(function(a, b) { return b.data - a.data; });
250
+
251
+ plots.expense = $.plot($('#expense'), seriesData, {
252
+   series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
253
+   grid: { clickable: true }
254
+ });
53 255
 }
54 256
 
55
-function showSelectedMonths(start, end, incoming, outgoing, categoryFilter) {
257
+/**
258
+ * Displays transactions and draws a category pie chart for the specified
259
+ * date range. Note that dates have a granularity of a month.
260
+ *
261
+ * @param {int} start The timestamp to start including transactions from
262
+ * @param {int} end The timestamp to stop including transactions at
263
+ * @param {bool} incoming Whether or not to include incoming transactions (income)
264
+ * @param {bool} outgoing Whether or not to include outgoing transactions (expenses)
265
+ * @param {string} categoryFilter The category to filter transactions to (or null)
266
+ * @param expanded An object containing entries indicating which merged
267
+ *                 transactions should be shown as expanded
268
+ */
269
+function showSelectedMonths(start, end, incoming, outgoing, categoryFilter, expanded) {
56 270
  $('#historytable tr.data').remove();
57 271
  $('#historytable').show();
58 272
 
59
- var startDate = new Date(start), endDate = new Date(end);
60
-
61
- if (startDate.getDate() > 1) {
62
-  startDate.setDate(1);
63
-  startDate.getMonth() == 11 && startDate.setYear(startDate.getFullYear() + 1);
64
-  startDate.setMonth(startDate.getMonth() == 11 ? 0 : startDate.getMonth() + 1);
65
- }
273
+ expanded = expanded || [];
66 274
 
67
- var startKey = getDateKey(startDate), endKey = getDateKey(endDate);
275
+ var startDate = getDate(start, 1), endDate = getDate(end);
68 276
 
69
- if (startKey == endKey) {
70
-  $('#historytable h3').text('Transactions for ' + months[startDate.getMonth()] + ' ' + startDate.getFullYear());
71
- } else if (startDate.getFullYear() == endDate.getFullYear()) {
72
-  $('#historytable h3').text('Transactions for ' + months[startDate.getMonth()] + '-' + months[endDate.getMonth()] + ' ' + startDate.getFullYear());
73
- } else {
74
-  $('#historytable h3').text('Transactions for ' + months[startDate.getMonth()] + ' ' + startDate.getFullYear() + ' - ' +  months[endDate.getMonth()] + ' ' + endDate.getFullYear());
75
- }
277
+ $('#historytable h3').text((categoryFilter ? categoryFilter + ' t' : 'T') + 'ransactions for ' + startDate.getRangeText(endDate));
76 278
 
77
- var pieData = {};
78 279
  var table = $('#historytable table');
79
- var include = false;
80 280
  var lastEntry = {};
81 281
  var id = 0;
82
- $.each(data, function(month, monthData) {
83
-  if (month == startKey) { include = true; }
282
+ var included = getDataForRange(startDate, endDate);
84 283
 
85
-  if (include) {
86
-   $.each(monthData, function(index, trans) {
87
-    if (incoming != trans.Amount > 0) { return; }
88
-
89
-    var category = trans.Category ? trans.Category : 'Unsorted';
90
- 
91
-    if (category != '(Ignored)') {
92
-     if (!pieData[category]) { pieData[category] = 0; }
93
-     pieData[category] += Math.abs(trans.Amount);
94
-    }
95
-
96
-    if (categoryFilter && categoryFilter != category) { return; }
97
-
98
-    var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
99
-
100
-    if (lastEntry.Description == trans.Description && lastEntry.Type == trans.Type && lastEntry.Category == lastEntry.Category) {
101
-     tr.hide();
102
-
103
-     if (lastEntry.id) {
104
-      var prefix = '(' + (state.expanded && state.expanded[lastEntry.id] ? '-' : '+');
105
-      lastEntry.count++;
106
-      $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
107
-     } else {
108
-      lastEntry.id = ++id;
109
-      lastEntry.count = 1;
110
-      var prefix = '(' + (state.expanded && state.expanded[lastEntry.id] ? '-' : '+');
111
-      var a = $('<span>').addClass('link').text(prefix + '1)').appendTo($('td.desc', lastEntry.tr).append(' '));
112
-      a.bind('click', { id: lastEntry.id, tr: lastEntry.tr }, expandLinkHandler);
113
-     }
114
-
115
-     lastEntry.Amount = Math.round(100 * (lastEntry.Amount + trans.Amount)) / 100;
116
-     if (state.expanded && state.expanded[lastEntry.id]) {
117
-      tr.show();
118
-     } else {
119
-      $('.amount', lastEntry.tr).text(lastEntry.Amount);
120
-     }
121
-
122
-     tr.addClass('collapsed hidden' + lastEntry.id);
123
-
124
-    } else {
125
-     lastEntry = $.extend({}, trans, {tr: tr});
126
-    }
127
-
128
-    $('<td/>').text(trans.Date.date.split(' ')[0]).appendTo(tr);
129
-    $('<td/>').text(trans.Type ? trans.Type : 'Other').appendTo(tr);
130
-    $('<td/>').text(trans.Category ? trans.Category : '').appendTo(tr);
131
-    $('<td/>').addClass('desc').text(trans.Description).appendTo(tr);
132
-    $('<td/>').addClass('amount').text(trans.Amount).appendTo(tr);
133
-   });
134
-  }
284
+ $.each(included, function() {
285
+  trans = this;
286
+  if (incoming != trans.Amount > 0) { return; }
135 287
 
136
-  if (month == endKey) { include = false; }
137
- });
138
- colourTableRows(table);
288
+  var category = trans.Category ? trans.Category : 'Unsorted';
139 289
 
140
- var seriesData = [];
141
- $.each(pieData, function(category, amount) {
142
-  seriesData.push({ label: category + ' (' + Math.round(amount) + ')', data: amount });
143
- });
290
+  if (categoryFilter && categoryFilter != category) { return; }
144 291
 
145
- seriesData.sort(function(a, b) { return b.data - a.data; });
292
+  var tr = $('<tr/>').addClass('data').addClass('category' + category.replace(/[^a-zA-Z]*/g, '')).appendTo(table);
146 293
 
147
- plots.expense = $.plot($('#expense'), seriesData, {
148
-   series: { pie: { show: true, innerRadius: 0.5, highlight: { opacity: 0.5 } } },
149
-   grid: { clickable: true }
294
+  if (shouldMerge(lastEntry, trans)) {
295
+   if (lastEntry.id) {
296
+    var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
297
+    lastEntry.count++;
298
+    $('span', lastEntry.tr).text(prefix + lastEntry.count + ')');
299
+   } else {
300
+    lastEntry.id = ++id;
301
+    lastEntry.count = 1;
302
+    var prefix = '(' + (expanded[lastEntry.id] ? '-' : '+');
303
+    var a = $('<span>').addClass('link').text(prefix + '1)').appendTo($('td.desc', lastEntry.tr).append(' '));
304
+    a.bind('click', { id: lastEntry.id, tr: lastEntry.tr }, expandLinkHandler);
305
+   }
306
+
307
+   lastEntry.Amount = Math.round(100 * (lastEntry.Amount + trans.Amount)) / 100;
308
+
309
+   !expanded[lastEntry.id] && tr.hide() && $('.amount', lastEntry.tr).text(lastEntry.Amount);
310
+
311
+   tr.addClass('collapsed hidden' + lastEntry.id);
312
+  } else {
313
+    lastEntry = $.extend({}, trans, {tr: tr});
314
+  }
315
+
316
+  $('<td/>').text(trans.Date.date.split(' ')[0]).appendTo(tr);
317
+  $('<td/>').text(trans.Type ? trans.Type : 'Other').appendTo(tr);
318
+  $('<td/>').text(trans.Category ? trans.Category : '').appendTo(tr);
319
+  $('<td/>').addClass('desc').text(trans.Description).appendTo(tr);
320
+  $('<td/>').addClass('amount').text(trans.Amount).appendTo(tr);
150 321
  });
322
+
323
+ colourTableRows(table);
324
+ drawCategoryPieChart(included, incoming);
151 325
 }
152 326
 
153 327
 $(function() {
@@ -178,7 +352,7 @@ $(function() {
178 352
   transData[2].data.push([timestamp, sum[0] + sum[1]]);
179 353
   min = Math.min(min, timestamp);
180 354
   max = Math.max(max, timestamp);
181
- }); 
355
+ });
182 356
 
183 357
  var catData = [];
184 358
  $.each(categories, function(category, entries) {
@@ -199,6 +373,7 @@ $(function() {
199 373
 
200 374
  var markings = [];
201 375
 
376
+ // Add a marking for each year division
202 377
  var year = new Date(new Date(max).getFullYear(), 0);
203 378
  while (year.getTime() > min) {
204 379
   markings.push({ color: '#000', lineWidth: 1, xaxis: { from: year.getTime(), to: year.getTime() } });
@@ -231,7 +406,7 @@ $(function() {
231 406
    grid: {
232 407
      hoverable: true,
233 408
      clickable: true,
234
-     markings: markings 
409
+     markings: markings
235 410
    },
236 411
    selection: { mode : "x" }
237 412
  });
@@ -242,11 +417,11 @@ $(function() {
242 417
 
243 418
    if (previousPoint == null || previousPoint.dataIndex != id.dataIndex || previousPoint.seriesIndex != id.seriesIndex) {
244 419
     previousPoint = id;
245
-             
420
+
246 421
     $("#tooltip").remove();
247 422
     var x = item.datapoint[0],
248 423
         y = item.datapoint[1].toFixed(2);
249
-                 
424
+
250 425
     var date = new Date(x);
251 426
 
252 427
     var seriesTitles = ["Money in", "Money out", "Balance change"];
@@ -254,14 +429,14 @@ $(function() {
254 429
    }
255 430
   } else {
256 431
    $("#tooltip").remove();
257
-   previousPoint = null;            
432
+   previousPoint = null;
258 433
   }
259 434
  });
260 435
 
261 436
  $('#history').bind('plotselected', function(event, ranges) {
262 437
   var startDate = parseInt(ranges.xaxis.from.toFixed());
263 438
   var endDate = parseInt(ranges.xaxis.to.toFixed());
264
- 
439
+
265 440
   if (state.start != startDate || state.end != endDate || state.type != 'expenses') {
266 441
    setState({ start: startDate, end: endDate, type: 'expenses' }, ['categoryFilter', 'expanded']);
267 442
   }
@@ -277,20 +452,5 @@ $(function() {
277 452
   setState({ categoryFilter: item.series.label.replace(/ \([0-9]+\)$/, '') }, ['expanded']);
278 453
  });
279 454
 
280
- $.history.init(function(hash) {
281
-  var oldState = $.extend({}, state);
282
-
283
-  try {
284
-   state = JSON.parse(hash);
285
-  } catch (ex) {
286
-   state = {};
287
-  }
288
-
289
-  var match = /start:([0-9]+);end:([0-9]+);type:(income|expenses)/.exec(hash);
290
-
291
-  if (state.start && state.end && state.type) {
292
-   showSelectedMonths(state.start, state.end, state.type == 'income', state.type == 'expenses', state.categoryFilter);
293
-   (oldState.start != state.start || oldState.end != state.end) && plots.history.setSelection({ xaxis: { from: state.start, to: state.end }});
294
-  }
295
- });
455
+ $.history.init(handleStateChange);
296 456
 });

Завантаження…
Відмінити
Зберегти