Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
"recall" prior events for easy entry of similar events
This is the "memorized" transactions feature. You enter an actor
name, click the "recall" link, and Bucketwise pulls up the last
event you entered that used that actor. Click "recall" again and
it gets the one before that, etc.

Note that, for usability purposes, this swaps the position of
the actor and amount fields. Before, you'd enter the amount and
then the actor. Now, you enter the actor and then the amount
(because you may want to recall the amount based on the actor).
  • Loading branch information
jamis committed May 10, 2009
1 parent 093e620 commit 290f324
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rdoc
@@ -1,5 +1,7 @@
=== (new)

* Recall previously entered transactions for easy entry of similar events [Jamis Buck]

* Hide bucket list if there are less than 2 buckets for an account [Jamis Buck]

* Increase "recent buckets" window size to 10 (from 5) [Jamis Buck]
Expand Down
2 changes: 1 addition & 1 deletion TODO
Expand Up @@ -3,6 +3,7 @@ KNOWN ISSUES
--------------------------------------------------------------------------

* After adding an event with a new tag, the tag autocompletion won't pick up the new tag until the page is reloaded.
* After adding an event with a new actor, the actor autocompletion won't pick up the new actor until the page is reloaded.
* Merging one bucket into another can result in events having two line-items referencing the same bucket.

--------------------------------------------------------------------------
Expand All @@ -18,7 +19,6 @@ FEATURES that would be nice to have someday (in no particular order)
* better 404 and 500 error pages
* searching
* reporting
* transaction templates ("saved" or "memorized" transactions)
* scheduled transactions (occur automatically at specified intervals)
* print stylesheet
* oauth authentication for API
Expand Down
11 changes: 9 additions & 2 deletions app/controllers/events_controller.rb
Expand Up @@ -7,6 +7,13 @@ class EventsController < ApplicationController

def index
respond_to do |format|
format.js do
json = events.to_json(eager_options(:root => "events", :include => { :tagged_items => { :only => [:amount, :id], :methods => :name }, :line_items => { :only => [:account_id, :bucket_id, :amount, :role], :methods => [] }}))

render :update do |page|
page << "Events.doneLoadingRecalledEvents(#{json})"
end
end
format.xml do
render :xml => events.to_xml(eager_options(:root => "events"))
end
Expand Down Expand Up @@ -73,7 +80,7 @@ def destroy
protected

attr_reader :event, :container, :account, :bucket, :tag, :events
helper_method :event
helper_method :event, :container, :account, :bucket

def find_event
@event = Event.find(params[:id])
Expand Down Expand Up @@ -114,7 +121,7 @@ def find_events
raise ArgumentError, "unsupported container type #{container.class}"
end

more_pages, list = container.send(association).send(method, params[:page], :size => params[:size])
more_pages, list = container.send(association).send(method, params[:page], :size => params[:size], :actor => params[:actor])
unless list.first.is_a?(Event)
list = list.map do |item|
event = item.event
Expand Down
9 changes: 8 additions & 1 deletion app/models/actor.rb
Expand Up @@ -2,9 +2,16 @@ class Actor < ActiveRecord::Base
belongs_to :subscription
has_many :events

validates_presence_of :name, :sort_name
attr_accessible :name, :sort_name

def self.normalize_name(name)
name.strip.upcase
end

def self.normalize(name)
name = name.strip
sort_name = name.upcase
sort_name = normalize_name(name)

actor = find_by_sort_name(sort_name)
if actor
Expand Down
6 changes: 6 additions & 0 deletions app/models/event.rb
Expand Up @@ -130,6 +130,12 @@ def to_xml(options={})
super(options.merge(:methods => methods, :except => except))
end

def to_json(options={})
methods = Array(options[:methods]).dup
methods |= [:balance, :value, :role]
super(options.merge(:methods => methods))
end

protected

def line_item_validations
Expand Down
16 changes: 14 additions & 2 deletions app/models/subscription.rb
Expand Up @@ -12,8 +12,20 @@ def recent(n=0, options={})
size = (options[:size] || DEFAULT_PAGE_SIZE).to_i
n = n.to_i

records = find(:all, :include => :account_items,
:order => "created_at DESC",
joins = []
conditions = []
parameters = []

if options[:actor]
joins << "LEFT JOIN actors ON actors.id = events.actor_id"
conditions << "actors.sort_name = ?"
parameters << Actor.normalize_name(options[:actor])
end

records = find(:all, :joins => joins,
:conditions => conditions.any? ? [conditions.join(" AND "), *parameters] : nil,
:include => :account_items,
:order => "events.created_at DESC",
:limit => size + 1,
:offset => n * size)

Expand Down
2 changes: 2 additions & 0 deletions app/models/tagged_item.rb
Expand Up @@ -9,6 +9,8 @@ class TaggedItem < ActiveRecord::Base

attr_accessible :tag, :tag_id, :amount

delegate :name, :to => :tag

def tag_id=(value)
case value
when Fixnum, /^\s*\d+\s*$/ then super(value)
Expand Down
19 changes: 11 additions & 8 deletions app/views/events/_form_general.html.haml
Expand Up @@ -7,19 +7,22 @@
%span.transfer_label <strong>When</strong> did this transfer occur?
= form.calendar_date_select :occurred_on, :size => 10

%p
%span.expense_label <strong>How much</strong> was paid?
%span.deposit_label <strong>How much</strong> was deposited?
%span.transfer_label <strong>How much</strong> was transferred?
== $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"}

%p
%span.expense_label <strong>Who</strong> received the payment?
%span.deposit_label <strong>Where</strong> did this deposit come from?
%span.transfer_label <strong>What</strong> was this transfer for?
= form.text_field :actor_name, :size => 30
#event_actor_name_select.autocomplete_select{:style => "display: none"}
= javascript_tag "Events.autocompleteActorField()"
- if form.object.new_record?
%span#recall_event{:style => "display: none"}= link_to_function "(recall)", "Events.recallEvent(#{subscription_events_path(subscription).to_json})"

#event_actor_name_select.autocomplete_select{:style => "display: none"}
= javascript_tag "Events.autocompleteActorField()"

%p
%span.expense_label <strong>How much</strong> was paid?
%span.deposit_label <strong>How much</strong> was deposited?
%span.transfer_label <strong>How much</strong> was transferred?
== $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"}

%p#memo_link{:style => visible?(!event_wants_memo?)}
<strong>Got more to say?</strong>
Expand Down
2 changes: 2 additions & 0 deletions doc/API.rdoc
Expand Up @@ -135,6 +135,8 @@ Returns the most recent events to have been added to BucketWise. They will be so

You can also specify the "include" query parameter as a comma-delimited list of any combination of "user", "line_items", and "tagged_items". Specifying "user" will include the user who created the event in the response. "line_items" will nest the line-items for the event in the response. "tagged_items" will nest the tagged items for the event in the response.

Lastly, you can specify the "actor" query parameter to return only transactions where the given actor is involved.

=== GET /accounts/1/events.xml

Returns a single page of events associated with the given account, ordered by the date they were said to have occurred. As above, it accepts both "page" and "size" query parameters to control which page, and how many results are returned.
Expand Down
182 changes: 175 additions & 7 deletions public/javascripts/events.js
Expand Up @@ -142,21 +142,27 @@ var Events = {
var li = document.createElement("li");
li.innerHTML = $('template.' + section).innerHTML;
ol.appendChild(li);
var input = li.down("input");
if(populate) {
var acctSelect = $('account_for_' + section);
var acctId = $F('account_for_' + section);
Events.populateBucket(li.down("select"), acctId,
var bucketSelect = li.down("select");
Events.populateBucket(bucketSelect, acctId,
{'skipAside':(section=='credit_options')});
if(populate != true) {
bucketSelect.setValue(populate.bucket_id);
input.setValue(Money.formatValue(Math.abs(populate.amount)));
}
}
li.down("input").focus();
input.focus();
},

removeLineItem: function(li) {
li.remove();
Events.updateUnassigned();
},

addTaggedItem: function() {
addTaggedItem: function(item) {
var ol = $('tagged_items');
var li = document.createElement("li");
var content = $('template.tags').innerHTML;
Expand All @@ -166,6 +172,11 @@ var Events = {

Events.autocompleteTagField(id);
li.down("input").focus();

if(item) {
li.down("input.number").setValue(Money.formatValue(item.amount));
li.down("input.tag").setValue(item.name);
}
},

autocompleteTagField: function(id, options) {
Expand Down Expand Up @@ -195,6 +206,17 @@ var Events = {

new Autocompleter.Local('event_actor_name', "event_actor_name_select",
Events.actors, options);

var element = $('event_actor_name');

element.observe('keyup', function() {
Events.recalledEvents = null;
if(element.present()) {
$('recall_event').show();
} else {
$('recall_event').hide();
}
});
},

removeTaggedItem: function(li) {
Expand Down Expand Up @@ -536,14 +558,18 @@ var Events = {
$('tags').down("input").focus();
},

revealPartialTags: function() {
Events.addTaggedItem();
Events.addTaggedItem();
revealPartialTags: function(bare) {
if(!bare) {
Events.addTaggedItem();
Events.addTaggedItem();
}

$('tag_items_collapsed').hide();
$('tag_items').show();

$('tag_items').down('input').focus();
if(!bare) {
$('tag_items').down('input').focus();
}
},

reset: function() {
Expand All @@ -568,6 +594,8 @@ var Events = {
$('tagged_items').innerHTML = "";
$('tags').hide();
$('tags_collapsed').show();

$('recall_event').hide();
},

cancel: function() {
Expand Down Expand Up @@ -647,5 +675,145 @@ var Events = {

returnToCaller: function() {
window.location.href = Events.return_to;
},

recallEvent: function(url) {
if(!Events.recalledEvents) {
Events.loadRecalledEvents(url);
return;
}

if(Events.recalledEvents.length == 0) {
alert("No transactions matched the criteria you specified.");
return;
}

Events.currentEvent = (Events.currentEvent + 1) % Events.recalledEvents.length;
var event = Events.recalledEvents[Events.currentEvent].event;

Events.rehydrate(event);
},

loadRecalledEvents: function(url) {
parameters = 'page=0&size=10&actor=' + encodeURIComponent($F('event_actor_name'));
new Ajax.Request(url, {
asynchronous:true,
evalScripts:true,
method:'get',
parameters:parameters
});
},

doneLoadingRecalledEvents: function(events) {
Events.recalledEvents = events;
Events.currentEvent = -1;
Events.recallEvent();
},

rehydrate: function(event) {
var saved_date = $F('event_occurred_on');
var saved_actor = $F('event_actor_name');

Events.reset();

$('event_occurred_on').value = saved_date;
$('event_actor_name').value = saved_actor;
$('expense_total').value = Money.formatValue(event.value);
$('event_memo').value = event.memo;
if($('event_memo').present()) Events.revealMemo();

$('recall_event').show();

switch(event.role) {
case "expense":
Events.rehydrateExpenseEvent(event);
break;
case "deposit":
Events.rehydrateDepositEvent(event);
break;
case "transfer":
Events.rehydrateTransferEvent(event);
break;
case "reallocation":
alert("Not yet implemented: can't rehydrate bucket reallocations");
return;
default:
alert("Can't rehydrate '" + event.role + "' events");
return;
}

Events.rehydrateTagsForEvent(event);
},

rehydrateSection: function(section, event) {
var items = event.line_items.select(function(item) { return item.role == section; });
if(items.length == 0) return;

$(section).show();

var account = Events.accounts[items[0].account_id];
$('account_for_' + section).setValue(account.id);
if(account.role == "checking" && Events.sectionWantsCheckOptions(section)) {
$(section + '.check_options').show();
$('event_check_number').setValue(event.check_number);
}

Events.updateBucketsFor(section);

if(items.length == 1) {
var select = $(section + '.single_bucket').down('select');
Events.selectBucket(select, items[0].bucket_id);
} else {
$(section + '.multiple_buckets').show();
$(section + '.single_bucket').hide();

items.each(function(item) {
Events.addLineItemTo(section, item);
});
}
},

rehydrateExpenseEvent: function(event) {
Events.revealExpenseForm();
Events.rehydrateSection('payment_source', event);
Events.rehydrateSection('credit_options', event);
},

rehydrateDepositEvent: function(event) {
Events.revealDepositForm();
Events.rehydrateSection('deposit', event);
},

rehydrateTransferEvent: function(event) {
Events.revealTransferForm();
Events.rehydrateSection('transfer_from', event);
Events.rehydrateSection('transfer_to', event);
},

rehydrateTagsForEvent: function(event) {
var whole_tags = $A(), partial_tags = $A();

event.tagged_items.each(function(item) {
if(item.amount < event.value) {
partial_tags.push(item);
} else {
whole_tags.push(item);
}
});

if(whole_tags.length > 0 || partial_tags.length > 0) {
Events.revealTags();
if(whole_tags.length > 0) {
var tags = whole_tags.map(function(item) { return item.name }).join(", ")
$('tags').down('input').setValue(tags);
}

if(partial_tags.length > 0) {
Events.revealPartialTags(true);
partial_tags.each(function(item) {
Events.addTaggedItem(item);
});
}
}
}
}

0 comments on commit 290f324

Please sign in to comment.