ShiftDate() Function: Intelligent Date Adjustments

You can save users a lot of time with intelligent date handling.

I first learned this lesson from Allen Browne's excellent article, Intelligent handling of dates at the start of a calendar year.  In the article, Allen advocates for automatically rolling back to the previous year when a user enters a fourth quarter (Oct-Dec) month and day in a textbox when the current date is in the first quarter (Jan-Feb).

A similar concept comes into play when adjusting dates.  

For example, imagine a recurring task that needs to get done on the last day of each month.  If you just take the current year, month, and day and plug them into the DateSerial function while adding one to the month variable, you won't get good results.

What would be better is if we could simply add a month to an existing month-end date and get the following month's month-end date.  The ShiftDate() function does that and a whole lot more.

The Approach

The ShiftDate() function returns a new date relative to the start date.

By default, if the date is at month end, then the month end will be preserved.

The new date can come before or after the start date depending on whether the Delta value is negative or positive, respectively.

We can force the returned date to be a business day, but this optional feature is turned off by default.

For the sake of readability, I chose to use a string as the DatePart parameter rather than an enum.  While an enum would get us better compile-time checking in VBA, the string option is more readable if we want to call the function from a query or property sheet (where you are forced to call enums by their numerical value rather than their enum item identifier).

The Algorithm

  1. Use a guard clause to verify the calling code passed a supported DatePart value
  2. Use a Select Case statement to branch to appropriate code based on DatePart
  3. For Days, simply add or subtract the Delta value
  4. For Weeks, multiply the Delta value by seven then add or subtract the result
  5. For Years and Months, check if the starting date is the end of a month; if it is, preserve it if MaintainEndOfMonth is True
  6. For Years, add or subtract Delta value to the year portion of the DateSerial function
  7. For whole Months, add or subtract Delta value to the month portion of the DateSerial function
  8. For partial Months, add or subtract the whole number portion of the Delta value to the month portion of DateSerial
  9. For partial Months where MaintainEndOfMonth is True, convert the decimal portion of the Delta value to 1, 2, 3, or 4 weeks
  10. For partial Months where MaintainEndOfMonth is False, convert the decimal portion to the nearest number of days based on an average month (not the current month)
  11. If ForceBusinessDay is True and the shifted date:
    • falls on a Saturday then convert to the nearest Friday
    • falls on a Sunday then convert to the nearest Monday

The Function

Here is the ShiftDate() function on its own without its required dependencies.

'---------------------------------------------------------------------------------------
' Procedure : ShiftDate
' DateTime  : 2/4/2009 - 6/2/2023
' Author    : Mike Wolfe
' Source    : https://nolongerset.com/shiftdate/
' Purpose   : Returns a new date relative to the startdate.  By default, if the date is
'               at month end, the month end will be preserved.
' Notes     - If MaintainEndOfMonth is True and DatePart is "Month" and Delta is a fraction,
'               then the fraction will be rounded to the nearest 0.25 and 7 days will
'               be added for every .25.
'           - To shift date backwards, pass a negative value as the Delta argument.
'>>> ShiftDate(#1/1/2009#, "Month", 2)
' 3/1/2009
'>>> ShiftDate(#1/31/2009#, "Month", 1)
' 2/28/2009
'>>> ShiftDate(#2/28/2009#, "Months", 2)
' 4/30/2009
'>>> Shiftdate(#2/29/2008#, "Year", 1)
' 2/28/2009
'>>> ShiftDate(#2/28/2011#, "Year", 1)
' 2/29/2012
'>>> ShiftDate(#12/25/2009#, "Week", -2)
' 12/11/2009
'>>> ShiftDate(#10/31/2009#, "Day", -30)
' 10/1/2009
'---------------------------------------------------------------------------------------
'
Function ShiftDate(StartDate As Date, DatePart As String, Delta As Variant, _
                   Optional MaintainEndOfMonth As Boolean = True, _
                   Optional ForceBusinessDay As Boolean = False)
Dim Fraction As Single

    Select Case DatePart
    Case "Year", "Month", "Week", "Day", "Years", "Months", "Weeks", "Days"
        'These are all acceptable values for DatePart
    Case Else
        Err.Raise 5, "DateFunctions.ShiftDate", "Invalid DatePart argument: " & DatePart & _
                                                vbCrLf & vbCrLf & "Must be one of: 'Year', 'Month', 'Week', 'Day'"
    End Select

    Select Case DatePart
    Case "Day", "Days"
        ShiftDate = DateSerial(Year(StartDate), Month(StartDate), Day(StartDate) + Delta)
    Case "Week", "Weeks"
        Const DaysPerWeek As Long = 7
        ShiftDate = DateSerial(Year(StartDate), Month(StartDate), Day(StartDate) + (Delta * DaysPerWeek))
    Case Else
        If MaintainEndOfMonth And IsEndOfMonth(StartDate) Then
            ShiftDate = MonthEnd(StartDate, IIf(DatePart = "Year", 12, 1) * Delta)
        Else
            Select Case DatePart
            Case "Year", "Years": ShiftDate = DateSerial(Year(StartDate) + Delta, Month(StartDate), Day(StartDate))
            Case "Month", "Months"
                ShiftDate = DateSerial(Year(StartDate), Month(StartDate) + Int(Delta), Day(StartDate))
                If Int(Delta) <> Delta Then
                    Fraction = Delta - Int(Delta)
                    If MaintainEndOfMonth Then
                        Select Case Fraction
                        Case Is >= 0.875: ShiftDate = ShiftDate + 28
                        Case Is >= 0.625: ShiftDate = ShiftDate + 21
                        Case Is >= 0.375: ShiftDate = ShiftDate + 14
                        Case Is >= 0.125: ShiftDate = ShiftDate + 7
                        End Select
                    Else
                        'Average out number of days in a month
                        ShiftDate = ShiftDate + VBA.Round(365.25 / 12 * Fraction)
                    End If
                End If
            End Select
        End If
    End Select
    
    If ForceBusinessDay Then
        'If new date falls on a weekend, then we shift accordingly:
        '   If Delta is 0 then go to the closest weekday (Friday for Saturday, Monday for Sunday)
        '   Otherwise, go at least the amount of the delta, but not less:
        '       i.e., one business day after Fri is Mon and one business day before Mon is Fri
        Select Case Weekday(ShiftDate, vbMonday)
        Case 1 To 5
            'Monday - Friday, leave these days alone
        Case 6
            'Saturday
            If Delta <= 0 Then
                ShiftDate = ShiftDate - 1
            Else
                ShiftDate = ShiftDate + 2
            End If
        Case 7
            'Sunday
            If Delta >= 0 Then
                ShiftDate = ShiftDate + 1
            Else
                ShiftDate = ShiftDate - 2
            End If
        End Select
    End If
End Function

The Full Code

The code below includes required helper functions that I've written about in the past:

The code can be copied and pasted into a blank standard module to get a fully-working solution that you can easily integrate into your projects:

Option Compare Database
Option Explicit

'https://nolongerset.com/convenience-date-functions/
Function IsEndOfMonth(DateToCheck As Date) As Boolean
    IsEndOfMonth = (DateToCheck = DateSerial(Year(DateToCheck), Month(DateToCheck) + 1, 0))
End Function

'Returns 1/31/, 2/28/ or 2/29/, 3/31/, 4/30/, 5/31/, 6/30/, 7/31/, 8/31/, 9/30/, 10/31/, 11/30/, 12/31/
Function MonthEnd(AsOfDate As Date, Optional RelativeMonth As Integer = 0) As Date
    MonthEnd = DateSerial(Year(AsOfDate), Month(AsOfDate) + RelativeMonth + 1, 0)
End Function

'---------------------------------------------------------------------------------------
' Procedure : ShiftDate
' DateTime  : 2/4/2009 - 6/2/2023
' Author    : Mike Wolfe
' Source    : https://nolongerset.com/shiftdate/
' Purpose   : Returns a new date relative to the startdate.  By default, if the date is
'               at month end, the month end will be preserved.
' Notes     - If MaintainEndOfMonth is True and DatePart is "Month" and Delta is a fraction,
'               then the fraction will be rounded to the nearest 0.25 and 7 days will
'               be added for every .25.
'           - To shift date backwards, pass a negative value as the Delta argument.
'>>> ShiftDate(#1/1/2009#, "Month", 2)
' 3/1/2009
'>>> ShiftDate(#1/31/2009#, "Month", 1)
' 2/28/2009
'>>> ShiftDate(#2/28/2009#, "Months", 2)
' 4/30/2009
'>>> Shiftdate(#2/29/2008#, "Year", 1)
' 2/28/2009
'>>> ShiftDate(#2/28/2011#, "Year", 1)
' 2/29/2012
'>>> ShiftDate(#12/25/2009#, "Week", -2)
' 12/11/2009
'>>> ShiftDate(#10/31/2009#, "Day", -30)
' 10/1/2009
'---------------------------------------------------------------------------------------
'
Function ShiftDate(StartDate As Date, DatePart As String, Delta As Variant, _
                   Optional MaintainEndOfMonth As Boolean = True, _
                   Optional ForceBusinessDay As Boolean = False)
Dim Fraction As Single

    Select Case DatePart
    Case "Year", "Month", "Week", "Day", "Years", "Months", "Weeks", "Days"
        'These are all acceptable values for DatePart
    Case Else
        Err.Raise 5, "DateFunctions.ShiftDate", "Invalid DatePart argument: " & DatePart & _
                                                vbCrLf & vbCrLf & "Must be one of: 'Year', 'Month', 'Week', 'Day'"
    End Select

    Select Case DatePart
    Case "Day", "Days"
        ShiftDate = DateSerial(Year(StartDate), Month(StartDate), Day(StartDate) + Delta)
    Case "Week", "Weeks"
        Const DaysPerWeek As Long = 7
        ShiftDate = DateSerial(Year(StartDate), Month(StartDate), Day(StartDate) + (Delta * DaysPerWeek))
    Case Else
        If MaintainEndOfMonth And IsEndOfMonth(StartDate) Then
            ShiftDate = MonthEnd(StartDate, IIf(DatePart = "Year", 12, 1) * Delta)
        Else
            Select Case DatePart
            Case "Year", "Years": ShiftDate = DateSerial(Year(StartDate) + Delta, Month(StartDate), Day(StartDate))
            Case "Month", "Months"
                ShiftDate = DateSerial(Year(StartDate), Month(StartDate) + Int(Delta), Day(StartDate))
                If Int(Delta) <> Delta Then
                    Fraction = Delta - Int(Delta)
                    If MaintainEndOfMonth Then
                        Select Case Fraction
                        Case Is >= 0.875: ShiftDate = ShiftDate + 28
                        Case Is >= 0.625: ShiftDate = ShiftDate + 21
                        Case Is >= 0.375: ShiftDate = ShiftDate + 14
                        Case Is >= 0.125: ShiftDate = ShiftDate + 7
                        End Select
                    Else
                        'Average out number of days in a month
                        ShiftDate = ShiftDate + VBA.Round(365.25 / 12 * Fraction)
                    End If
                End If
            End Select
        End If
    End Select
    
    If ForceBusinessDay Then
        'If new date falls on a weekend, then we shift accordingly:
        '   If Delta is 0 then go to the closest weekday (Friday for Saturday, Monday for Sunday)
        '   Otherwise, go at least the amount of the delta, but not less:
        '       i.e., one business day after Fri is Mon and one business day before Mon is Fri
        Select Case Weekday(ShiftDate, vbMonday)
        Case 1 To 5
            'Monday - Friday, leave these days alone
        Case 6
            'Saturday
            If Delta <= 0 Then
                ShiftDate = ShiftDate - 1
            Else
                ShiftDate = ShiftDate + 2
            End If
        Case 7
            'Sunday
            If Delta >= 0 Then
                ShiftDate = ShiftDate + 1
            Else
                ShiftDate = ShiftDate - 2
            End If
        End Select
    End If
End Function

Sample Usage