Topic: How to create a "Recent Activity" page with multiple models

Many web apps have a "dashboard" or "recent activity" page that shows what has changed over a certain time period. I'm going to describe how I implemented such a page in a Rails project.

Imagine an application with WorkOrder and Comment models. A WorkOrder has many Comments, and a Comment belongs to a WorkOrder. A help desk system might use a similar design, where a support person creates a new WorkOrder with a description of the problem, and it is updated with comments until it is marked as completed. The Comment model uses Rail's built-in timestamps, but the WorkOrder model has both start_date and completed_date fields.

Our "recent activity" page needs to display all started work orders, completed work orders and posted comments in chronological order over a specified period of time. How can we do this?

First, let's use three separate find calls to gather up our objects. We'd do this in our controller:

started_work_orders = WorkOrder.find(:all, :conditions => ["start_date >= ?", 3.days.ago])
completed_work_orders = WorkOrder.find(:all, :conditions => ["completed_date >= ?", 3.days.ago])
comments = Comment.find(:all, :conditions => ["created_at >= ?", 3.days.ago])

Notice I'm not using a single find call on WorkOrder with an "or" condition. That's because we'd have no way to tell whether any particular WorkOrder was in the list because it was opened over the last three days or because it was closed! Since we are going to combine these into a single list (so we can sort all activity chronologically), we need a way to determine which list a particular WorkOrder came from. For this we can use a virtual attribute that isn't saved to the database using the attr_accessor method. In the WorkOrder.rb model, add:
attr_accessor :sort_using_start_date

Now we'll set this virtual attribute to either true or false so we can refer to it later:
started_work_orders.each { |wo| wo.sort_using_start_date = true }
completed_work_orders.each { |wo| wo.sort_using_start_date = false }

Great! Now we can combine all our matching objects into a single array and store it in an instance variable for our view:
@recently_active_objects = started_work_orders + completed_work_orders + comments

Next step: sort the list! But how? A WorkOrder will need to sort via start_date or completed_date, and a Comment will need to use created_at. One solution to this problem is to have all the objects provide their sort time via the same method. We'll call it sort_timestamp. Adding it to the Comment.rb model is simple enough:
def sort_timestamp
  self.created_at
end

For the WorkOrder.rb model, we need a bit of logic to make sure we return the correct date:
def sort_timestamp
  self.sort_using_start_date ? self.start_date : self.completed_date
end

Now all the objects in our @recently_active_objects array will respond to the sort_timestamp method. The sort becomes easy:
@recently_active_objects = @recently_active_objects.sort { |x,y| y.sort_timestamp <=> x.sort_timestamp }

Notice the positions of x and y around the <=>. This will sort in reverse (so the latest objects appear first in the list). If you want the opposite behavior, just reverse x and y.

The only tricky part left is to identify each object as you loop through the array in your view, so you can display the appropriate information. I suggest creating a helper method like this:

def activity_message_for(object)
  case object.class.name
    when 'WorkOrder'
      object.sort_using_start_date ? "Work Order #{object.id} was started" : "Work Order #{object.id} was completed"
    when 'Comment'
      "A comment was added to Work Order #{object.work_order.id}"
  end
end

Your view would then iterate through @recently_active_objects, display a formatted version of each element's sort_timestamp value, and call activity_message_for(object) to get an appropriate activity message.

If you ever need to add additional models to your activity page, just make sure they implement a sort_timestamp method and modify the activity_message_for helper.

EDIT: Fixed case statement in activity_message_for

Last edited by jeffj312 (2008-03-11 16:56:12)

Re: How to create a "Recent Activity" page with multiple models

Excellent information!

One question...Is it possible to limit the number of results in @recently_active_objects?

Re: How to create a "Recent Activity" page with multiple models

Yes, you can limit @recently_active_objects after you've sorted it:

@recently_active_objects = @recently_active_objects[0..14]

That would replace the array with the first 15 elements (or do nothing if there are already 15 or fewer elements).

This doesn't actually reduce the number of objects you are sorting, however. You can add the limit to each of your model queries to possibly save some database calls and sorting time:

   1. started_work_orders = WorkOrder.find(:all, :conditions => ["start_date >= ?", 3.days.ago], :limit => 15)
   2. completed_work_orders = WorkOrder.find(:all, :conditions => ["completed_date >= ?", 3.days.ago], :limit => 15)
   3. comments = Comment.find(:all, :conditions => ["created_at >= ?", 3.days.ago], :limit => 15)

With the limit parameter added, the best case is you'd retrieve exactly 15 objects while the worst case would retrieve 45 objects (15 for each query), throwing away 30 after truncating the array to 15.

Re: How to create a "Recent Activity" page with multiple models

Great tutorial, thanks jeffj312.

For some reason I can't get the helper method to print the activity messages in the view. I presume it's a problem with the way I'm calling the method;

# dashboard/index.html.erb

<ul>
    <% @recent_objects.each do |object| %>
    <li>
        <ul class="newsfeed_item">
            <li><%= object.class %></li>
            <li><%= activity_message_for(object) %></li>
        </ul>
    </li>
    <% end %>
</ul>


As you can see, I've tried printing the object.class in the browser so I know for sure that the object classes are available to the helper method - and it works just fine, the class is printed. But none of my activity messages return in the browser, and I don't get any error messages, either;

# application_helper.rb

  def activity_message_for(object)
    case object.class
      when User
        "#{object.login} has joined."
      when Post
        "#{object.title} was posted."
      when Comment
        "#{object.body} was posted."
    end
  end


What am I doing wrong?

On a different note, it would great if we could introduce daily dividers between the recent objects, like the way Facebook divides the Newsfeed by the date. Any ideas on how to do this?

Re: How to create a "Recent Activity" page with multiple models

Sorry about that. "object.class == Model" returns true in script/console, so I don't know exactly why it isn't working here. I'm sure I'm missing something obvious. Here's a modification that DOES work:

def activity_message_for(object)
  case object.class.name
  when 'WorkOrder'
    object.sort_using_start_date ? "Work Order #{object.id} was started" : "Work Order #{object.id} was completed"
  when 'Comment'
    "A comment was added to Work Order #{object.work_order.id}"
  end
end

If you want to separate the activity by days, here's one way that uses the ActiveSupport group_by method:
# dashboard/index.html.erb
<% @recent_objects.group_by{ |object| object.sort_timestamp.midnight }.each do |day, objects| %>
  <h3><%= day.strftime("%A, %B %e") %></h3>
    <ul>
    <% objects.each do |object| %>
      <li><%= object.sort_timestamp.strftime("%l:%M %p") %> - <%= activity_message_for(object) %></li>
    <% end %>
    </ul>
<% end %>

I didn't test this but I think it should work. Sorry about the poor ERB; I use Haml.

Re: How to create a "Recent Activity" page with multiple models

Excellent, both suggestions work really well. I'm very impressed with the date divider! Would you be able to briefly explain how it works?

I'll admit to being more than a little eager to find out why we need to specify the class.name rather than just comparing to the object.class directly - but either way, it's working. Thanks again.

Re: How to create a "Recent Activity" page with multiple models

There's a really good Railscast about group_by here:

http://railscasts.com/episodes/29

For grouping by days when you have a timestamp, I used the "midnight" method in the group_by block to essentially reduce the granularity of the timestamp's value to one day. Every timestamp that occurs on a particular day will yield the same value if you call "midnight" on it. That is what allows group_by to split the @recent_objects array into multiple arrays by day.

I just noticed I didn't do any sorting. I'm not sure if group_by will preserve sort order. If it doesn't, you'll need to perform your sorting after calling group_by:

@recent_objects.group_by{ |object| object.sort_timestamp.midnight }.sort.each do |day, objects|
  ...
  objects.sort{ |x,y| x.sort_timestamp <=> y.sort_timestamp }.each do |object|
    ...
  end
end

Re: How to create a "Recent Activity" page with multiple models

I recall watching that episode a while back. I should always check Railscasts before asking questions!

At the moment, I don't need to work with anything more complex than object.created_at, so I'm simply using the created_at method on all recent_objects - but you're right, the sort (created_at) order isn't intact, but applying the sort after the grouping hasn't worked either. This is what I have right now;

# dashboard_controller.rb

  def index
    new_people = User.find(:all)
    new_posts = Post.find(:all)
    new_comments = Comment.find(:all)

    @recent_objects = (new_people + new_posts + new_comments).sort{|x,y| y.created_at <=> x.created_at}.paginate :per_page => 10, :page => params[:page]
    @user = current_user   
  end


# dashboard/index.html.erb

<ul>
<% @recent_objects.group_by{ |object| object.created_at.midnight }.each do |day, objects| %>
  <li class="news_date"><h3><%= day.strftime("%A, %B %e") %></h3></li>
   <% objects.sort{|x,y| x.created_at <=> y.created_at}.each do |object| %>
        <li>
            <ul class="news_item">
             <li><%= object.created_at.strftime("%l:%M %p") %></li>
                <li><%= activity_message_for(object) %></li>
           </ul>
        </li>
    <% end %>
<% end %>
</ul>


RyanB has the group_by in the controller, so I need to move the grouping out of the view to try his .keys hack for fixing the order - but I can't get the paginate method to work on the group_by...

Last edited by Neil (2008-03-11 15:30:02)

Re: How to create a "Recent Activity" page with multiple models

Your messages under each day heading should certainly be sorting correctly, but you still need to sort the days themselves. Try adding ".sort" before you call "each" on line 4. The comments for the Railscast indicate it should properly sort by the keys of the hash returned by group_by:

@recent_objects.group_by{ |object| object.created_at.midnight }.sort.each do |day, objects|
   ...
end

Re: How to create a "Recent Activity" page with multiple models

I added the .sort to line 4 in the index.html.erb, but doesn't seem to have made a difference. Do I need to specify how to 'sort' (i.e. created_at)? Or do I need to call day.sort too, by any chance?

Re: How to create a "Recent Activity" page with multiple models

Do the days show up out of order, or do the entries for each day show up unsorted? When you call sort on the output of group_by, it should sort based on the keys (which are the days in this case). So you don't need to specifically sort by the day.

If you remove your pagination calls from your controller, does everything appear properly?

Re: How to create a "Recent Activity" page with multiple models

The days show up out of order, but some days show up more than once and with objects that follow-on in chronological order from the last instance of day (so the objects aren't the same). However, all entries within the days are sorted correctly.

If I remove pagination the sort works, but in reverse order, i.e. oldest at top of the page.

Re: How to create a "Recent Activity" page with multiple models

You can reverse the sort like this:

@recent_objects.group_by{ |object| object.created_at.midnight }.sort.reverse.each do |day, objects|
  ...
end

Pagination is going to be a problem because it will create page breaks between entries on the same day. You might need to use a different approach than pagination. For example, if you are displaying the last few days of activity, you might have a link to display each day instead of a list of pages.

Re: How to create a "Recent Activity" page with multiple models

I had to do this:

<% @recent_objects.group_by{ |object| object.created_at.midnight }.sort.reverse.each do |day, objects| %>
  <li class="news_date"><h3><%= day.strftime("%A, %B %e") %></h3></li>
   <% objects.sort{|x,y| x.created_at <=> y.created_at}.reverse.each do |object| %>

And unless my eyes are being incredibly cruel and deceiving me (again), it seems to be working exactly was I wanted it to, inc. paginated results, correctly chronologically ordered across different instances of a day. Woohoo.

Re: How to create a "Recent Activity" page with multiple models

Thanks for the info! I got it working on my site, and as far as a user's public profile it shows the most recent activity for the public page of each user showing their recent activity....

http://www.oshieroo.com/profile/cjmiyake

I also have this working on the user's private page...displaying the most recent activity for all users.

However, on the user's home page, I want the user to be able to click a close button or x button on the each recent activity notification to close it. So when a user comes back to their page, they can go through and close (delete?) each message that is displayed. If you have used facebook, or have an account at text-link-ads they do similar things for notifications. Is this possible using this code, or would you have to store the data in a database and allow the user to delete them as they read them?

Re: How to create a "Recent Activity" page with multiple models

Since you need to track state information for each recent activity (per-user state information), you'd have to move this into the database. Off the top of my head I'm thinking that a background process could create recent activity entries for all users on a schedule, and then the home page controller could display and delete those entries after the user acknowledges each one.

Re: How to create a "Recent Activity" page with multiple models

jeff, I was thinking along the same lines. I just knocked together an rss aggregator which stores each post from a feed as a database entry; I don't think cjmiyake's suggestion would be wildly different.

cjmiyake, I was going to include the same functionality too, so I'll check back if I get anywhere (still a Rails newbie, but I'm trying out new stuff all the time!).

18

Re: How to create a "Recent Activity" page with multiple models

What about the productivity of this solution? If you have 15 models it will be 15 queries at the one moment and if site have 100 000 active users...