JumpstartLab/tracks

View on GitHub
app/models/recurring_todo.rb

Summary

Maintainability
F
4 days
Test Coverage
class RecurringTodo < ActiveRecord::Base
  attr_protected :user

  belongs_to :context
  belongs_to :project
  belongs_to :user

  has_many :todos
  
  scope :active, :conditions => { :state => 'active'}
  scope :completed, :conditions => { :state => 'completed'}

  include IsTaggable

  include AASM
  aasm_column :state
  aasm_initial_state :active

  aasm_state :active, :enter => Proc.new { |t| t.occurences_count = 0 }
  aasm_state :completed, :enter => Proc.new { |t| t.completed_at = Time.zone.now }, :exit => Proc.new { |t| t.completed_at = nil }

  aasm_event :complete do
    transitions :to => :completed, :from => [:active]
  end

  aasm_event :activate do
    transitions :to => :active, :from => [:completed]
  end

  validates_presence_of :description
  validates_presence_of :recurring_period
  validates_presence_of :target
  validates_presence_of :ends_on
  validates_presence_of :context

  validates_length_of :description, :maximum => 100
  validates_length_of :notes, :maximum => 60000, :allow_nil => true

  validate :period_specific_validations
  validate :starts_and_ends_on_validations
  validate :set_recurrence_on_validations

  def period_specific_validations
    if %W[daily weekly monthly yearly].include?(recurring_period)
      self.send("validate_#{recurring_period}")
    else
      errors.add(:recurring_period, "is an unknown recurrence pattern: '#{self.recurring_period}'")
    end
  end

  def validate_daily
    if (!only_work_days) && daily_every_x_days.blank?
      errors[:base] << "Every other nth day may not be empty for recurrence setting"
    end
  end

  def validate_weekly
    if weekly_every_x_week.blank?
      errors[:base] << "Every other nth week may not be empty for recurrence setting"
    end
    something_set = false
    %w{sunday monday tuesday wednesday thursday friday saturday}.each { |day| something_set ||= self.send("on_#{day}") }
    errors[:base] << "You must specify at least one day on which the todo recurs" unless something_set
  end

  def validate_monthly
    case recurrence_selector
    when 0 # 'monthly_every_x_day'
      errors[:base] << "The day of the month may not be empty for recurrence setting" if monthly_every_x_day.blank?
      errors[:base] << "Every other nth month may not be empty for recurrence setting" if monthly_every_x_month.blank?
    when 1 # 'monthly_every_xth_day'
      errors[:base] <<"Every other nth month may not be empty for recurrence setting" if monthly_every_x_month2.blank?
      errors[:base] <<"The nth day of the month may not be empty for recurrence setting" if monthly_every_xth_day.blank?
      errors[:base] <<"The day of the month may not be empty for recurrence setting" if monthly_day_of_week.blank?
    else
      raise Exception.new, "unexpected value of recurrence selector '#{self.recurrence_selector}'"
    end
  end

  def validate_yearly
    case recurrence_selector
    when 0 # 'yearly_every_x_day'
      errors[:base] << "The month of the year may not be empty for recurrence setting" if yearly_month_of_year.blank?
      errors[:base] << "The day of the month may not be empty for recurrence setting" if yearly_every_x_day.blank?
    when 1 # 'yearly_every_xth_day'
      errors[:base] << "The month of the year may not be empty for recurrence setting" if yearly_month_of_year2.blank?
      errors[:base] << "The nth day of the month may not be empty for recurrence setting" if yearly_every_xth_day.blank?
      errors[:base] << "The day of the week may not be empty for recurrence setting" if yearly_day_of_week.blank?
    else
      raise Exception.new, "unexpected value of recurrence selector '#{self.recurrence_selector}'"
    end
  end

  def starts_and_ends_on_validations
    errors[:base] << "The start date needs to be filled in" if start_from.blank?
    case self.ends_on
    when 'ends_on_number_of_times'
      errors[:base] << "The number of recurrences needs to be filled in for 'Ends on'" if number_of_occurences.blank?
    when "ends_on_end_date"
      errors[:base] << "The end date needs to be filled in for 'Ends on'" if end_date.blank?
    else
      errors[:base] << "The end of the recurrence is not selected" unless ends_on == "no_end_date"
    end
  end

  def set_recurrence_on_validations
    # show always or x days before due date. x not null
    case self.target
    when 'show_from_date'
      # no validations
    when 'due_date'
      errors[:base] << "Please select when to show the action" if show_always.nil?
      unless show_always
        errors[:base] << "Please fill in the number of days to show the todo before the due date" if show_from_delta.blank?
      end
    else
      raise Exception.new, "unexpected value of recurrence target selector '#{self.recurrence_target}'"
    end
  end

  # the following recurrence patterns can be stored:
  #
  # daily todos - recurrence_period = 'daily'
  #   every nth day - nth stored in every_other1
  #   every work day - only_work_days = true
  #   tracks will choose between both options using only_work_days
  # weekly todos - recurrence_period = 'weekly'
  #   every nth week on a specific day -
  #      nth stored in every_other1 and the specific day is stored in every_day
  # monthly todos - recurrence_period = 'monthly'
  #   every day x of nth month - x stored in every_other1 and nth is stored in every_other2
  #   the xth y-day of every nth month (the forth tuesday of every 2 months) -
  #      x stored in every_other3, y stored in every_count, nth stored in every_other2
  #   choosing between both options is done on recurrence_selector where 0 is
  #   for first type and 1 for second type
  # yearly todos - recurrence_period = 'yearly'
  #   every day x of month y - x is stored in every_other1, y is stored in every_other2
  #   the x-th day y of month z (the forth tuesday of september) -
  #     x is stored in every_other3, y is stored in every_count, z is stored in every_other2
  #   choosing between both options is done on recurrence_selector where 0 is
  #   for first type and 1 for second type

  # DAILY

  def daily_selector=(selector)
    case selector
    when 'daily_every_x_day'
      self.only_work_days = false
    when 'daily_every_work_day'
      self.only_work_days = true
    else
      raise Exception.new, "unknown daily recurrence pattern: '#{selector}'"
    end
  end

  def daily_every_x_days=(x)
    self.every_other1 = x if recurring_period=='daily'
  end

  def daily_every_x_days
    return self.every_other1
  end

  # WEEKLY

  def weekly_every_x_week=(x)
    self.every_other1 = x if recurring_period=='weekly'
  end

  def weekly_every_x_week
    return self.every_other1
  end

  def switch_week_day (day, position)
    self.every_day = '       ' if self.every_day.nil?
    self.every_day = self.every_day[0,position] + day + self.every_day[position+1,self.every_day.length]
  end

  def weekly_return_monday=(selector)
    switch_week_day(selector,1) if recurring_period=='weekly'
  end

  def weekly_return_tuesday=(selector)
    switch_week_day(selector,2) if recurring_period=='weekly'
  end

  def weekly_return_wednesday=(selector)
    switch_week_day(selector,3) if recurring_period=='weekly'
  end

  def weekly_return_thursday=(selector)
    switch_week_day(selector,4) if recurring_period=='weekly'
  end

  def weekly_return_friday=(selector)
    switch_week_day(selector,5) if recurring_period=='weekly'
  end

  def weekly_return_saturday=(selector)
    switch_week_day(selector,6) if recurring_period=='weekly'
  end

  def weekly_return_sunday=(selector)
    switch_week_day(selector,0) if recurring_period=='weekly'
  end

  def on_xday(n)
    unless self.every_day.nil?
      return self.every_day[n,1] == ' ' ? false : true
    else
      return false
    end
  end

  def on_monday
    return on_xday(1)
  end

  def on_tuesday
    return on_xday(2)
  end

  def on_wednesday
    return on_xday(3)
  end

  def on_thursday
    return on_xday(4)
  end

  def on_friday
    return on_xday(5)
  end

  def on_saturday
    return on_xday(6)
  end

  def on_sunday
    return on_xday(0)
  end

  # MONTHLY

  def monthly_selector=(selector)
    self.recurrence_selector = ( (selector=='monthly_every_x_day') ? 0 : 1) if recurring_period=='monthly'
  end

  def monthly_every_x_day=(x)
    self.every_other1 = x if recurring_period=='monthly'
  end

  def monthly_every_x_day
    return self.every_other1
  end

  def is_monthly_every_x_day
    return self.recurrence_selector == 0 if recurring_period == 'monthly'
    return false
  end

  def is_monthly_every_xth_day
    return self.recurrence_selector == 1 if recurring_period == 'monthly'
    return false
  end

  def monthly_every_x_month=(x)
    self.every_other2 = x if recurring_period=='monthly' && recurrence_selector == 0
  end

  def monthly_every_x_month
    # in case monthly pattern is every day x, return every_other2 otherwise
    # return a default value
    return self.recurrence_selector == 0 ? self.every_other2 : 1
  end

  def monthly_every_x_month2=(x)
    self.every_other2 = x if recurring_period=='monthly' && recurrence_selector == 1
  end

  def monthly_every_x_month2
    # in case monthly pattern is every xth day, return every_other2 otherwise
    # return a default value
    return self.recurrence_selector == 1 ? self.every_other2 : 1
  end

  def monthly_every_xth_day=(x)
    self.every_other3 = x if recurring_period=='monthly'
  end

  def monthly_every_xth_day(default=nil)
    return self.every_other3 unless self.every_other3.nil?
    return default
  end

  def monthly_day_of_week=(dow)
    self.every_count = dow if recurring_period=='monthly'
  end

  def monthly_day_of_week
    return self.every_count
  end

  # YEARLY

  def yearly_selector=(selector)
    self.recurrence_selector = ( (selector=='yearly_every_x_day') ? 0 : 1) if recurring_period=='yearly'
  end

  def yearly_month_of_year=(moy)
    self.every_other2 = moy if self.recurring_period=='yearly'  && self.recurrence_selector == 0
  end

  def yearly_month_of_year
    # if recurrence pattern is every x day in a month, return month otherwise
    # return a default value
    return self.recurrence_selector == 0 ? self.every_other2 : Time.zone.now.month
  end

  def yearly_month_of_year2=(moy)
    self.every_other2 = moy if self.recurring_period=='yearly' && self.recurrence_selector == 1
  end

  def yearly_month_of_year2
    # if recurrence pattern is every xth day in a month, return month otherwise
    # return a default value
    return self.recurrence_selector == 1 ? self.every_other2 : Time.zone.now.month
  end

  def yearly_every_x_day=(x)
    self.every_other1 = x if recurring_period=='yearly'
  end

  def yearly_every_x_day
    return self.every_other1
  end

  def yearly_every_xth_day=(x)
    self.every_other3 = x if recurring_period=='yearly'
  end

  def yearly_every_xth_day
    return self.every_other3
  end

  def yearly_day_of_week=(dow)
    self.every_count=dow if recurring_period=='yearly'
  end

  def yearly_day_of_week
    return self.every_count
  end

  # target

  def recurring_target=(t)
    self.target = t
  end

  def recurring_target_as_text
    case self.target
    when 'due_date'
      return I18n.t("todos.recurrence.pattern.due")
    when 'show_from_date'
      return I18n.t("todos.recurrence.pattern.show")
    else
      raise Exception.new, "unexpected value of recurrence target '#{self.target}'"
    end
  end

  def recurring_show_days_before=(days)
    self.show_from_delta=days
  end

  def recurring_show_always=(value)
    self.show_always=value
  end

  def daily_recurrence_pattern
    return I18n.t("todos.recurrence.pattern.on_work_days") if only_work_days
    if every_other1 > 1
      return I18n.t("todos.recurrence.pattern.every_n", :n => every_other1) + " " + I18n.t("common.days_midsentence.other")
    else
      return I18n.t("todos.recurrence.pattern.every_day")
    end
  end
  
  def weekly_recurrence_pattern
    if every_other1 > 1
      return I18n.t("todos.recurrence.pattern.every_n", :n => every_other1) + " " + I18n.t("common.weeks")
    else
      return I18n.t('todos.recurrence.pattern.weekly')
    end
  end
  
  def monthly_recurrence_pattern
    return "invalid repeat pattern" if every_other2.nil?
    if self.recurrence_selector == 0
      on_day = " #{I18n.t('todos.recurrence.pattern.on_day_n', :n => self.every_other1)}"
      if self.every_other2>1
        return I18n.t("todos.recurrence.pattern.every_n", :n => self.every_other2) + " " + I18n.t('common.months') + on_day
      else
        return I18n.t("todos.recurrence.pattern.every_month") + on_day
      end
    else
      if self.every_other2>1
        n_months = "#{self.every_other2} #{I18n.t('common.months')}"
      else
        n_months = I18n.t('common.month')
      end
      return I18n.t('todos.recurrence.pattern.every_xth_day_of_every_n_months',
        :x => self.xth, :day => self.day_of_week, :n_months => n_months)
    end
  end
  
  def yearly_recurrence_pattern
    if self.recurrence_selector == 0
      return I18n.t("todos.recurrence.pattern.every_year_on",
        :date => I18n.l(DateTime.new(Time.zone.now.year, self.every_other2, self.every_other1), :format => :month_day))
    else
      return I18n.t("todos.recurrence.pattern.every_year_on",
        :date => I18n.t("todos.recurrence.pattern.the_xth_day_of_month", :x => self.xth, :day => self.day_of_week, :month => self.month_of_year))
    end
  end
  
  def recurrence_pattern
    return "invalid repeat pattern" if every_other1.nil?
    case recurring_period
    when 'daily'   then daily_recurrence_pattern
    when 'weekly'  then weekly_recurrence_pattern
    when 'monthly' then monthly_recurrence_pattern
    when 'yearly'  then yearly_recurrence_pattern
    else
      return 'unknown recurrence pattern: period unknown'
    end
  end

  def xth
    xth_day = [
      I18n.t('todos.recurrence.pattern.first'),I18n.t('todos.recurrence.pattern.second'),I18n.t('todos.recurrence.pattern.third'),
      I18n.t('todos.recurrence.pattern.fourth'),I18n.t('todos.recurrence.pattern.last')]
    return self.every_other3.nil? ? '??' : xth_day[self.every_other3-1]
  end

  def day_of_week
    return self.every_count.nil? ? '??' : I18n.t('todos.recurrence.pattern.day_names')[self.every_count]
  end

  def month_of_year
    return self.every_other2.nil? ? '??' : I18n.t('todos.recurrence.pattern.month_names')[self.every_other2]
  end

  def starred?
    return has_tag?(Todo::STARRED_TAG_NAME)
  end

  def get_due_date(previous)
    case self.target
    when 'due_date'
      return get_next_date(previous)
    when 'show_from_date'
      # so leave due date empty
      return nil
    else
      raise Exception.new, "unexpected value of recurrence target '#{self.target}'"
    end
  end

  def get_show_from_date(previous)
    case self.target
    when 'due_date'
      # so set show from date relative to due date unless show_always is true or show_from_delta is nil
      (self.show_always? || self.show_from_delta.nil?) ? nil : get_due_date(previous) - self.show_from_delta.days
    when 'show_from_date'
      # Leave due date empty
      get_next_date(previous)
    else
      raise Exception.new, "unexpected value of recurrence target '#{self.target}'"
    end
  end

  def get_next_date(previous)
    case self.recurring_period
    when 'daily'   then get_daily_date(previous)
    when 'weekly'  then get_weekly_date(previous)
    when 'monthly' then get_monthly_date(previous)
    when 'yearly'  then get_yearly_date(previous)
    else
      raise Exception.new, "unknown recurrence pattern: '#{self.recurring_period}'"
    end
  end

  def get_daily_date(previous)
    # previous is the due date of the previous todo or it is the completed_at
    # date when the completed_at date is after due_date (i.e. you did not make
    # the due date in time)
    #
    # assumes self.recurring_period == 'daily'

    start = determine_start(previous, 1.day)

    if self.only_work_days
      return start + 2.day if start.wday() == 6 # saturday
      return start + 1.day if start.wday() == 0 # sunday
      return start
    else # every nth day; n = every_other1
      # if there was no previous todo, do not add n: the first todo starts on
      # today or on start_from
      return previous == nil ? start : start+every_other1.day-1.day
    end
  end

  def get_weekly_date(previous)
    # determine start
    if previous == nil
      start = self.start_from.nil? ? Time.zone.now : self.start_from
    else
      start = previous + 1.day
      if start.wday() == 0
        # we went to a new week , go to the nth next week and find first match
        # that week. Note that we already went into the next week, so -1
        start += (self.every_other1-1).week
      end
      unless self.start_from.nil?
        # check if the start_from date is later than previous. If so, use
        # start_from as start to search for next date
        start = self.start_from if self.start_from > previous
      end
    end

    # check if there are any days left this week for the next todo
    start.wday().upto 6 do |i|
      return start + (i-start.wday()).days unless self.every_day[i,1] == ' '
    end

    # we did not find anything this week, so check the nth next, starting from
    # sunday
    start = start + self.every_other1.week - (start.wday()).days

    # check if there are any days left this week for the next todo
    start.wday().upto 6 do |i|
      return start + (i-start.wday()).days unless self.every_day[i,1] == ' '
    end

    raise Exception.new, "unable to find next weekly date (#{self.every_day})"
  end

  def get_monthly_date(previous)

    start = determine_start(previous)
    day = self.every_other1
    n = self.every_other2

    case self.recurrence_selector
    when 0 # specific day of the month
      if start.mday >= day
        # there is no next day n in this month, search in next month
        #
        #  start += n.months
        #
        # The above seems to not work. Fiddle with timezone. Looks like we hit a
        # bug in rails here where 2008-12-01 +0100 plus 1.month becomes
        # 2008-12-31 +0100. For now, just calculate in UTC and convert back to
        # local timezone.
        #
        #  TODO: recheck if future rails versions have this problem too
        start = Time.utc(start.year, start.month, start.day)+n.months
        start = Time.zone.local(start.year, start.month, start.day)

        # go back to day
      end
      return Time.zone.local(start.year, start.month, day)

    when 1 # relative weekday of a month
      the_next = get_xth_day_of_month(self.every_other3, self.every_count, start.month, start.year)
      if the_next.nil? || the_next <= start
        # the nth day is already passed in this month, go to next month and try
        # again

        # fiddle with timezone. Looks like we hit a bug in rails here where
        # 2008-12-01 +0100 plus 1.month becomes 2008-12-31 +0100. For now, just
        # calculate in UTC and convert back to local timezone.
        #  TODO: recheck if future rails versions have this problem too
        the_next = Time.utc(the_next.year, the_next.month, the_next.day)+n.months
        the_next = Time.zone.local(the_next.year, the_next.month, the_next.day)

        # TODO: if there is still no match, start will be set to nil. if we ever
        # support 5th day of the month, we need to handle this case
        the_next = get_xth_day_of_month(self.every_other3, self.every_count, the_next.month, the_next.year)
      end
      return the_next
    else
      raise Exception.new, "unknown monthly recurrence selection (#{self.recurrence_selector})"
    end
    return nil
  end

  def get_xth_day_of_month(x, weekday, month, year)
    if x == 5
      # last -> count backwards. use UTC to avoid strange timezone oddities
      # where last_day -= 1.day seems to shift tz+0100 to tz+0000
      last_day = Time.utc(year, month, Time.days_in_month(month))
      while last_day.wday != weekday
        last_day -= 1.day
      end
      # convert back to local timezone
      return Time.zone.local(last_day.year, last_day.month, last_day.day)
    else
      # 1-4th -> count upwards last -> count backwards. use UTC to avoid strange
      # timezone oddities where last_day -= 1.day seems to shift tz+0100 to
      # tz+0000
      start = Time.utc(year,month,1)
      n = x
      while n > 0
        while start.wday() != weekday
          start+= 1.day
        end
        n -= 1
        start += 1.day unless n==0
      end
      # convert back to local timezone
      return Time.zone.local(start.year, start.month, start.day)
    end
  end

  def get_yearly_date(previous)
    start = determine_start(previous)
    day = self.every_other1
    month = self.every_other2

    case self.recurrence_selector
    when 0 # specific day of a specific month
      if start.month > month || (start.month == month && start.day >= day)
        # if there is no next month n and day m in this year, search in next
        # year
        start = Time.zone.local(start.year+1, month, 1)
      else
        # if there is a next month n, stay in this year
        start = Time.zone.local(start.year, month, 1)
      end
      return Time.zone.local(start.year, month, day)

    when 1 # relative weekday of a specific month
      # if there is no next month n in this year, search in next year
      the_next = start.month > month ? Time.zone.local(start.year+1, month, 1) : start

      # get the xth day of the month
      the_next = get_xth_day_of_month(self.every_other3, self.every_count, month, the_next.year)

      # if the_next is before previous, we went back into the past, so try next
      # year
      the_next = get_xth_day_of_month(self.every_other3, self.every_count, month, start.year+1) if the_next <= start

      return the_next
    else
      raise Exception.new, "unknown monthly recurrence selection (#{self.recurrence_selector})"
    end
    return nil
  end

  def has_next_todo(previous)
    return self.occurences_count < self.number_of_occurences unless self.number_of_occurences.nil?
    return true if self.end_date.nil? || self.ends_on == 'no_end_date'
    
    case self.target
    when 'due_date'
      return get_due_date(previous) <= self.end_date
    when 'show_from_date'
      return get_show_from_date(previous) <= self.end_date
    else
      raise Exception.new, "unexpected value of recurrence target '#{self.target}'"
    end
  end

  def toggle_completion!
    return completed? ? activate! : complete!
  end

  def toggle_star!
    if starred?
      _remove_tags(Todo::STARRED_TAG_NAME)
    else
      _add_tags(Todo::STARRED_TAG_NAME)
    end
    tags.reload
    starred?
  end

  def remove_from_project!
    self.project = nil
    self.save
  end

  def clear_todos_association
    unless todos.nil?
      self.todos.each do |t|
        t.recurring_todo = nil
        t.save
      end
    end
  end

  def inc_occurences
    self.occurences_count += 1
    self.save
  end

  protected

  # Determine start date to calculate next date for recurring todo
  # offset needs to be 1.day for daily patterns
  def determine_start(previous, offset=0.day)

    if previous.nil?
      start = self.start_from.nil? ? Time.zone.now : self.start_from
      # skip to present
      start = Time.zone.now if Time.zone.now > start
    else
      start = previous + offset

      # check if the start_from date is later than previous. If so, use
      # start_from as start to search for next date
      start = self.start_from if ( self.start_from && self.start_from > previous )
    end

    return start
  end

end