Recurring Items in a Rails Application
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:
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
[sourcecode language='ruby']
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
[/sourcecode]
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:
[sourcecode language='ruby']
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")
[/sourcecode]
Then the entry model ties into the schedule model. Here are the important (for this purpose) bits of the entry class:
[sourcecode language='ruby']
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,
: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
[/sourcecode]
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:
[sourcecode language='ruby']
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
[/sourcecode]
- 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:[sourcecode language='ruby']
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
[/sourcecode]
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:
[sourcecode language='ruby']
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")
[/sourcecode]
Then the entry model ties into the schedule model. Here are the important (for this purpose) bits of the entry class:
[sourcecode language='ruby']
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,
: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
[/sourcecode]
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:
[sourcecode language='ruby']
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
[/sourcecode]