Recurring Items in a Rails Application

Send to friend

I recently had to implement some recurring items in a Rails application – we’ll call them “entries” here though you can use this technique for appointments, bills, or whatever else. After thinking about it for a while, I settled on:

  • At any point, we needed to know what the next recurrence date was.
  • The potential schedules needed to be data-driven (because the client was unsure what would be needed).

Here’s what I came up with. If your needs are different, you’ll no doubt need to alter it. First, the schedule model has a schedule_type, an interval, a specific value, and a description. The combination of these things lets me implement Schedule#next_date to return the fate for the next date given a schedule object and the current date. Here’s the model:


class Schedule

  has_many :entries

  attr_accessible :schedule_type, :description, :interval, :specific

  SCHEDULE_TYPE = {:first => 0, :last => 1, :weekly => 2, :monthly => 3}

def next_date(current_date)
  case schedule_type
  when SCHEDULE_TYPE[:first]
    first_day = (current_date + 1.month).beginning_of_month
    wday = first_day.wday
    if specific >= wday
      first_day + specific - wday
    else
      first_day + 7 + specific - wday
    end
  when SCHEDULE_TYPE[:last]
    if specific == -1
      (current_date + 1.month).end_of_month
    else
      last_day = (current_date + 1.month).end_of_month
      wday = last_day.wday
      if wday >= specific
        last_day - wday + specific
      else
        last_day - wday - 7 + specific
      end
    end
  when SCHEDULE_TYPE[:weekly]
    current_date + interval.weeks
  when SCHEDULE_TYPE[:monthly]
    current_date + interval.months
  end
end

So we can have a schedule that is “first Monday of the month” or “every 3 weeks” among other things. I stock these up using db-populate; here’s some of the population file:


Schedule.create_or_update(:id => 1,
  :schedule_type => Schedule::SCHEDULE_TYPE[:monthly],
  :interval => 1, :description => "Every month")
Schedule.create_or_update(:id => 2,
  :schedule_type => Schedule::SCHEDULE_TYPE[:monthly],
  :interval => 2, :description => "Every other month")

Schedule.create_or_update(:id => 4,
  :schedule_type => Schedule::SCHEDULE_TYPE[:weekly],
  :interval => 1, :description => "Every week")

Schedule.create_or_update(:id => 8,
  :schedule_type => Schedule::SCHEDULE_TYPE[:first],
  :specific => 0, :description => "First Sunday of every month")

Schedule.create_or_update(:id => 15,
  :schedule_type => Schedule::SCHEDULE_TYPE[:last],
  :specific => 0, :description => "Last Sunday of every month")

Schedule.create_or_update(:id => 22,
  :schedule_type => Schedule::SCHEDULE_TYPE[:last],
  :specific => -1, :description => "Last day of every month")

Then the entry model ties into the schedule model. Here are the important (for this purpose) bits of the entry class:


class Entry < ActiveRecord::Base
  belongs_to :schedule
  named_scope :ready_to_recur, lambda { |date|
    {:conditions => ["recurring = 1 AND next_date <= ? AND schedule_id IS NOT NULL AND next_created = 0", date ]} }
  before_save :set_up_recurrence

  def make_next_recurrence
    if recurring? && !schedule_id.nil? && !next_created?
      entry = Entry.create(
        :entry_date => next_date,
        :reference => reference,
        <more attributes here>
        :recurring => true,
        :schedule_id => schedule_id
        )
      update_attribute(:next_created, true)
    end
    entry
  end

  def set_up_recurrence
    if recurring? && !schedule_id.nil?
      self.next_date = Schedule.find(schedule_id).next_date(entry_date)
      self.next_created = false if next_created.nil?
    end
    true
  end
end

Finally, the whole thing is driven by a rake task that we run every night. This task finds all the entries that are ready to recur and creates the next entry:


desc 'Create the recurring entries for today'
task :daily_recurring_entries => :environment do
  Entry.ready_to_recur(Date.today).each do |entry|
    entry.make_next_recurrence
  end
end

Images: