Get the Nth Working Day of the Month

Summary

I recently needed to schedule a task with Windows Task Scheduler to run at midday on the 5th weekday (Mon-Fri) of every month in 2020. Windows Task Scheduler lacks the functionality to configure such a schedule, so I needed to create a separate trigger for each month. Rather than do this by hand, I wrote a function in PowerShell to calculate the Nth weekday of a given month and year.

Function: Get-NthWorkingDayOfMonth

The function I wrote can be found here on Github.

Usage

In the following example, the 10th working day in January 2020 is obtained:
(NB: By default a working week is implicitly Monday to Friday, this can be customised using the -WorkingDaysOfWeek parameter)

PS C:\> Get-NthWorkingDayOfMonth -Nth 10 -Month 1 -Year 2020

14 January 2020 00:00:00

Depending on your use case you may wish to exclude certain days of the month from being treated as working days (such as public holidays), this can be achieved using the -DayOfMonthToExclude parameter. In the following example, the 1st day of the month is excluded as a working day:

PS C:\> Get-NthWorkingDayOfMonth -Nth 10 -Month 1 -Year 2020 -DayOfMonthToExclude 1

15 January 2020 00:00:00

For further examples and use instructions, see Get-NthWorkingDayOfMonth.

UPDATE 2020-09-13

In September 2020 I wrote a function Get-UKBankHoliday to retrieve UK bank holiday dates. Here’s an example of how it can be used in conjunction with Get-NthWorkingDayOfMonth to obtain the 3rd working day in April 2021 excluding UK bank holidays (in England and Wales) as working days:

$NthWorkingDay = 3
$Month = 4
$Year = 2021

$BankHolidays = Get-UKBankHoliday -Month $Month -Year $Year -Jurisdiction england-and-wales

$GetNthWorkingDayOfMonthArgs = @{
    Nth = $NthWorkingDay
    Month = $Month
    Year = $Year
}

If ($BankHolidays) {
    $GetNthWorkingDayOfMonthArgs.Add("DayOfMonthToExclude", $BankHolidays.Date.Day)
}

Get-NthWorkingDayOfMonth @GetNthWorkingDayOfMonthArgs

07 April 2021 00:00:00

Unobtainable Dates

If an Nth value is specified in combination with a month and year that cannot be satisfied then a terminating error is thrown, EG:

PS C:\Bitbucket> Get-NthWorkingDayOfMonth -Nth 25 -Month 1 -Year 2020
There isn't a 25th working day (Monday, Tuesday, Wednesday, Thursday, Friday) in January 2020.
At C:\Bitbucket\Get-NthWorkingDayOfMonth.ps1:112 char:5
+     Throw "There isn't a $($Nth)$($OrdinalIndicator) working day ($($ ...
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (There isn't a 2...n January 2020.:String) [], RuntimeException
    + FullyQualifiedErrorId : There isn't a 25th working day (Monday, Tuesday, Wednesday, Thursday, Friday) in January 2020.
 
PS C:\Bitbucket>

Demonstration: My Use Case

As mentioned in the summary, I needed to schedule a task using Windows Task Scheduler to run on the 5th weekday (Mon-Fri) of every month in 2020. I started by using Get-NthWorkingDayOfMonth to produce a list of dates. Obtaining the 5th working day of each month where a working week consists of the days Mon-Fri is not an outlandish requirement, so I didn’t expect any errors to be returned but best to be safe…

Once satisfied there was nothing awry with the collection of dates, my plan was to populate a scheduled task with a series of twelve date/time type triggers, one for each month. In my case, the scheduled task already existed, I just needed to replace the existing triggers with new date/times for 2020. I opted to do the following:

. .\Get-NthWorkingDayOfMonth.ps1

1..12 | ForEach-Object {
    $Date = Get-Date -Date (Get-NthWorkingDayOfMonth -Nth 5 -Month $_ -Year 2020) -Format 'yyyy-MM-dd'
    $Time = "T12:00:00"
    "    <TimeTrigger>"
    "      <StartBoundary>$($Date)$($Time)</StartBoundary>"
    "      <Enabled>true</Enabled>"
    "    </TimeTrigger>"
}  
schtasks.exe /create /xml "C:\thecliguy\Monthly Data Import Task.xml" /tn "TheCliGuy\Monthly Data Import Task" /ru "testdomain\thecliguy" /rp *

An alternative (and in hindsight simpler) approach would have been to use PowerShell’s New-ScheduledTaskTrigger cmdlet to replace the existing triggers with twelve new triggers:

. .\Get-NthWorkingDayOfMonth.ps1

$Triggers = @()
$Hour = 12

1..12 | ForEach-Object {
    $Date = Get-Date -Date (Get-NthWorkingDayOfMonth -Nth 5 -Month $_ -Year 2020) 
    
    # The New-ScheduledTaskTrigger cmdlet always results in the trigger setting
    # 'Synchronize across time zones' being enabled.
    # 
    # To workaround this, New-ScheduledTaskTrigger is supplied with a dummy 
    # date/time, which is subsequently overwritten by updating the trigger's
    # StartBoundary property with the actual date/time (formatted without a UTC 
    # offset).
    #
    # For further details see: 
    # https://www.thecliguy.co.uk/2020/02/09/scheduled-task-trigger-synchronize-across-time-zones/
    $Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date -Day 1 -Month 1 -Year 1 -Hour 0)
    $Trigger.StartBoundary = (Get-Date -Date $Date.Date -Hour $Hour -Format 'yyyy-MM-ddTHH:mm:ss')
    
    $Triggers += $Trigger
}

Set-ScheduledTask -TaskName "Monthly Data Import Task" -Trigger $Triggers

Finally, to ensure that the scheduled task had been correctly populated with the triggers, I used the following:

PS C:\> (Get-ScheduledTask -TaskName "Monthly Data Import Task").Triggers | Select-Object StartBoundary, Enabled

StartBoundary       Enabled
-------------       -------
2020-01-07T12:00:00    True
2020-02-07T12:00:00    True
2020-03-06T12:00:00    True
2020-04-07T12:00:00    True
2020-05-07T12:00:00    True
2020-06-05T12:00:00    True
2020-07-07T12:00:00    True
2020-08-07T12:00:00    True
2020-09-07T12:00:00    True
2020-10-07T12:00:00    True
2020-11-06T12:00:00    True
2020-12-07T12:00:00    True

Further Reading

Working on this task lead me to dabble with various scheduling tools and specifications to find out how they handled different conditions. These are just some of my notes pertaining to this research.

iCalendar

In my function Get-NthWorkingDayOfMonth I took the decision to throw a terminating error if an Nth value is specified in combination with a month and year that cannot be satisfied. I was curious to see how this problem is handled by the iCalendar specification.

The specification’s latest (as at Jan 2020) RFC (5545) acknowledges this issue as follows (excerpt from page 43):

Recurrence rules may generate recurrence instances with an invalid
date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM
on a day where the local time is moved forward by an hour at 1:00
AM).  Such recurrence instances MUST be ignored and MUST NOT be
counted as part of the recurrence set.

Microsoft Outlook

The Outlook 2016 UI prevents a user from scheduling a recurrent event on a specific Nth weekday (Mon-Fri) of every month beyond the fourth:

Outlook 2016 Appointment Recurrence

I wondered if you could circumvent this limitation by scheduling a recurrent event on the 4th weekday of every month, saving it to an iCalendar file (.ics), editing the file in a text editor, replacing BYSETPOS=4 with BYSETPOS=5 and importing the file. The answer is no, you can’t. When attempting to import the file nothing happens, there’s no error message. I also tried by constructing a minimal iCalendar file (opposed to the one produced by Outlook), this also failed to import.

Comments

Leaving comments has been disabled for this post.

Copyright © 2018 - 2022 thecliguy.co.uk
For details, see Licences and Copyright