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 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 |