Friday, November 21, 2008
Calculating recurrences

I'm making a scheduling application. Handling OneOff events was easy but I'm having problems with recurring events, do you have any code that does this?

Sure!  It's funny that for such a common need there are very few free examples around. In fact, there are even people who sell this kind of logic for big bucks (This does a lot but like buying LeadTools just for bitmaps, is the extra weight actually going to be used?)

The fact is that the recurrence logic itself isn't the most tedious part of adding recurring events to a scheduling app, but I'll cover those realities after giving you the code.

Below is a shared function so you don't have to instantiate an object to use it. It takes an Event start date, Event end date (including a Nothing if the event has no end date), a start and end range of dates that you want the recurrences for, an increment type (year, month, week or day) and an increment value (so you can have items recur every 2 days, 4 months, etc.)

As written, the code concerns itself only with the full 24hour day section of a recurrences' date by the use of [date variable].Date.  If your app needs to work with full time precision, such as events that occur every 3 days at 4:07pm, just take out the datepart trimming.  The comments give other hints about functionality you'll probably want to add before putting this in production.

This code outputs a vanilla Arraylist of simple custom date wrapper objects.  In a production app you'd of course output a typesafe list/collection or maybe a dataset (not that we're geeks or anything but when we use it we overload it to output all of them, and if you want that full code it will only cost you $80 <g>).

Public Class RecurCalc 

#Region "Public Declarations" 

   Public Enum IncrementType 
      Year = 0 
      Month = 1 
      Week = 2 
      Day = 3 
   End Enum 

#End Region 

#Region "Public Methods" 

   Public Shared Function Getrecurrences(ByVal EventStartDate As Date, _ 
      ByVal EventEndDate As Date, ByVal TargetStartDate As Date, _ 
      ByVal TargetEndDate As Date, ByVal EventIncrementType As IncrementType, _ 
      ByVal Increment As Integer) As ArrayList 

      Dim dtFirstEvent As Date 
      Dim arRet As New ArrayList 
      Dim iCounter As Integer = 0 
      Dim ClickerInterval As DateInterval 
      Dim iCounterIncrement As Integer = 0 
      Dim dtClicker As Date 

      'We only care about the 24hour day, so get rid of any time values 
      EventStartDate = EventStartDate.Date 
      TargetStartDate = TargetStartDate.Date 
      TargetEndDate = TargetEndDate.Date 

      'Real app throws ArgumentOutOfRange or custom exceptions for Starts specified after Ends 
      'demo just will pass thru and return the empty arraylist 

      'first make sure that the CurrentMonthDate is greater than the start date 
      If EventStartDate > TargetEndDate Then 
         'Real app throws ArgumentOutOfRange or custom exception 
         'demo just returns the empty arraylist 
         Return arRet 
      End If 


      'and if there'e no end date for the event, consider the target end the end date 
      'this accounts for null dates being passed 
      If JustDatePart(EventEndDate) = DateTime.MinValue.Date Then 
         EventEndDate = TargetEndDate 
      Else 
         EventEndDate = EventEndDate.Date 
      End If 

      'and that the event isn't over before the target starts 
      'obviously this comes after the EventEndDate adjustment 
      If EventEndDate < TargetStartDate Then 

         'Real app might throw custom exception, 
         'but probably not in this case because in actual use you'd likely be 
         'iterating through a set of event definitions and only care about a list if 
         'one is applicable 
         'demo just returns the empty arraylist 

         Return arRet 
      End If 


      'convert our increment type enum value to 
      'the framework dateinterval type enum values 
      If EventIncrementType = IncrementType.Year Then 
         ClickerInterval = DateInterval.Year 
      ElseIf EventIncrementType = IncrementType.Month Then 
         ClickerInterval = DateInterval.Month 
      ElseIf EventIncrementType = IncrementType.Week Or _ 
         EventIncrementType = IncrementType.Day Then 
         ClickerInterval = DateInterval.Day 
      End If 


      'calculate the first recurrence that falls in the target range 
      'since Week is not a framework dateinterval, we calc it by using Day multiplied by 7 
      Select Case EventIncrementType 
         Case IncrementType.Year, IncrementType.Month, IncrementType.Day 
            If TargetStartDate > EventStartDate Then 
               iCounter = DateDiff(ClickerInterval, EventStartDate, TargetStartDate) / Increment 
               dtFirstEvent = DateAdd(ClickerInterval, iCounter * Increment, EventStartDate) 

               If dtFirstEvent < TargetStartDate Then 
                  'account for it losing the first because we're truncating it to just the date 
                  dtFirstEvent = DateAdd(ClickerInterval, Increment, dtFirstEvent) 
               End If 
            Else 
               dtFirstEvent = EventStartDate 
            End If 

            iCounterIncrement = Increment 
         Case IncrementType.Week 
            If TargetStartDate > EventStartDate Then 
               iCounter = (DateDiff(ClickerInterval, EventStartDate, TargetStartDate) / 7) / Increment 
               dtFirstEvent = DateAdd(ClickerInterval, (iCounter * 7) * Increment, EventStartDate) 

               If dtFirstEvent < TargetStartDate Then 
                  'account for it losing the first because we're truncating it to just the date 
                  dtFirstEvent = DateAdd(ClickerInterval, Increment * 7, dtFirstEvent) 
               End If 
            Else 
               dtFirstEvent = EventStartDate 
            End If 

            iCounterIncrement = Increment * 7 

      End Select 


      'now you have the first recurrence of the target range 
      'fill in the remaining recurrences 

      If (dtFirstEvent <= TargetEndDate) And (dtFirstEvent <= EventEndDate) Then 
         dtClicker = dtFirstEvent 
         Do 
            arRet.Add(New RecurItem(dtClicker)) 
            dtClicker = DateAdd(ClickerInterval, iCounterIncrement, dtClicker) 
         Loop Until (dtClicker > TargetEndDate) Or (dtClicker > EventEndDate) 
      End If 

      Return arRet 


   End Function 

#End Region 

End Class 


Public Class RecurItem 

#Region "Private Members" 

   Private m_ItemDate As Date = DateTime.MinValue 

#End Region 

#Region "Public properties" 

   Public ReadOnly Property ItemDate() As Date 
         Get 
            Return m_ItemDate 
         End Get 
   End Property 

#End Region 

#Region "Public Methods" 

   Public Sub New(ByVal ItemDate As Date) 
         m_ItemDate = ItemDate 
   End Sub 

#End Region 

End Class

 

You can test this right now with a little self-contained demo. There's no IO or networking or spyware or anything, it's just the above functionality in a v1.1 gui.  Click here to give it a try.

Up at the top we'd mentioned that just getting recurrences, while important, isn't the end of the work.  Here're some points to consider for the rest of the job:

Most likely you're going to be saving events to something - a database, binary file or persisted dataset - and you won't want to waste space or impact performance by calculating all of the recurrences up front and saving them individually.  That might be marginally ok if the spec says that ALL events MUST have an exact end date, but in our experience once you allow recurrences the spec will change.  It's best if you just save the definition of the recurring event which includes at the least a UniqueID, short description, and the start and end dates (dbNull.Value as an End for events that go on forever).  Then when the event is to be displayed, you query for that information, add in the desired target date range and generate the list with a call to the function.

You'll probably have to have a way for users to see "completed" items so, building off of the above, another table should be created just for those completed items (using the UniqueID as the foreign key to the main table and at least a combination of that UniqueID and ItemDate for this new tables' own primary key).   With this extra table, and an added "IsCompleted" R/W boolean property on the RecurItem class, the logic would be to first get the event definition and generate the list for the required range, and then query for the completed items in that range and loop through the Arraylist looking for ItemDate matches, those that match get their RecurItem.IsCompleted property set to true.  

If users are supposed to be able to fix D/E mistakes by setting completed items back to incomplete, you'll need logic to remove the particular records from the table.

Now you just have to consider the ramifications of users wanting to edit existing Event definitions.  In this case, just hit the table holding completed items and get a count for the Event. If the count is zero, then you can just change the definition.  If the count is greater than zero and if the change that the user is making is related to the IncrementType, IncrementValue, Start date or End date and the change will orphan any of the completed items when recalculated then you have to cover those bases.  The fast & dirty way is to just delete all of the completed item records and update the Event definition record.  If, however, the completed items are important for future reference, you should either iterate through all of the completed items and convert them all to individual OneOffs then delete the original records from the completed items table, or force the original event definitions' End Date value to be the same as the last completed item and "autocomplete" all of the incomplete items.  All ways have merit, personally we code for all three, using one as a default and letting advanced users pick.

Minutia is what we love or we'd be surfers, so take that code and those tips and get back to the thrill of wrapping your app :)

Hope it helps!

Robert Smith
Kirkland, WA

 

added to smithvoice September 2004


Print  

pagecomment
  Add Comment



Submit Comment
  View Ratings
50.00%0
40.00%0
30.00%0
20.00%0
10.00%0

Number of Comments 0 , Average of Ratings
  View Comments
No comment.


Privacy Statement  |  Terms Of Use
Copyright 2008 by Robert C. Smith