PSBusinessTime.psm1

#region Private
function GetElapsedTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [DateTime]$StartDate,

        [Parameter(Mandatory)]
        [DateTime]$EndDate,

        [Parameter(Mandatory)]
        [DateTime]$StartHour,

        [Parameter(Mandatory)]
        [DateTime]$FinishHour
    )

    $Subtractor = New-TimeSpan

    $StartHourDifference = $StartHour.TimeOfDay - $StartDate.TimeOfDay
    if ($StartHourDifference -gt 0) {
        $Subtractor += $StartHourDifference
    }

    $FinishHourDifference = $EndDate.TimeOfDay - $FinishHour.TimeOfDay
    if ($FinishHourDifference -gt 0) {
        $Subtractor += $FinishHourDifference
    }

    (New-TimeSpan -Start $StartDate -End $EndDate) - $Subtractor
}
#endregion

#region Public
function Add-WorkingDays {
    <#
    .SYNOPSIS
        Add a number of working days onto a given date.
    .DESCRIPTION
        Add a number of working days onto a given date.

        What constitutes a "working day" in terms of day of the week, or calendar date, is arbitrary and completely customisable.

        In other words, the default parameters dictate normal working days, which are Monday through Friday.

        You can also specify particular dates, or days of the week, to be regarded as non-working dates via the -NonWorkingDates and -NonWorkingDaysOfWeek parameters.
    .PARAMETER Date
        The starting date used for calculation.
        
        The default value is the current datetime.
    .PARAMETER Days
        The number of working days to add onto from the given date.
        
        Number can be negative in order to substract from the given date, too.
    .PARAMETER NonWorkingDaysOfWeek
        The days of the week, representated as strings e.g. 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday', which denotes non-working days of the week.
        
        Days specified in this parameter will not be considered as working days.

        Default values are Saturday and Sunday.
    .PARAMETER NonWorkingDates
        An array of datetime objects which denote specific non-working dates.
        
        Dates specified in this parameter will not be considered as working days.
    .EXAMPLE
        Add-WorkingDays -Days 3
        
        Adds 3 working days onto the current date. For example, if today's date is 2022-04-07, then the returned datetime object will be 2022-04-12.
    .EXAMPLE
        Add-WorkingDays -Days -3

        Minuses 3 working days from the current date. For example, if today's date is 2022-04-07, then the returned datetime object will be 2022-04-04.
    .EXAMPLE
        Add-WorkingDays -Date (Get-Date '2022-04-14') -Days 5 -NonWorkingDates (Get-Date '2022-04-15'),(Get-Date '2022-04-18')

        Add 5 working days from 2022-04-14, discounting 2022-04-15 (Good Friday) and 2022-04-18 (Easter Monday) as working days. The returned datetime object will be 2022-04-25.
    .EXAMPLE
        Add-WorkingDays -Days 1 -NonWorkingDaysOfWeek 'Friday','Saturday','Sunday'

        Add 1 working day onto the current date. For example, if today's date is 2022-04-07, then the returned datetime object will be 2022-04-11.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.DateTime
    .NOTES
        Chris Dent (@indented-automation) wrote this in the WinAdmins Discord
        https://discord.com/channels/618712310185197588/618857671608500234/913855890384371712
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [object]$Date = (Get-Date),

        [Parameter(Mandatory)]
        [int]$Days,

        [Parameter()]
        [ValidateSet('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')]
        [String[]]$NonWorkingDaysOfWeek = @('Saturday','Sunday'),

        [Parameter()]
        [DateTime[]]$NonWorkingDates
    )

    $increment = $Days / [Math]::Abs($Days)
    do {
        $Date = $Date.AddDays($increment)
        
        if ($NonWorkingDaysOfWeek -notcontains $Date.DayOfWeek -And $NonWorkingDates -notcontains $Date.Date) {
            $Days -= $increment
        }
    } while ($Days)

    return $Date
}

function Get-WorkingDates {
    <#
    .SYNOPSIS
        Return all the working dates between two given datetimes.
    .DESCRIPTION
        Return all the working dates between two given datetimes.

        This is helpful to identify the specific dates between two dates which are considered to be "working day(s)".

        What constitutes a "working day" in terms of day of the week, or calendar date, including working hours, is arbitrary and completely customisable.

        In other words, the default parameters dictate normal working days, which are Monday through Friday.

        You can also specify particular dates, or days of the week, to be regarded as non-working dates via the -NonWorkingDates and -NonWorkingDaysOfWeek parameters.

        This function does not consider the time, only the date, when determining whether it is a working date or not.
    .PARAMETER StartDate
        The datetime object to identify all the working dates from. It must be an older datetime than -EndDate.
    .PARAMETER EndDate
        The datetime object to identify all the working dates to. It must be a newer datetime than -StartDate.
    .PARAMETER NonWorkingDaysOfWeek
        The days of the week, representated as strings e.g. 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday', which denotes non-working days of the week.
        
        Days specified in this parameter will not be considered as working days.

        Default values are Saturday and Sunday.
    .PARAMETER NonWorkingDates
        An array of datetime objects which denote specific non-working dates.

        Dates specified in this parameter will not be considered as working days.
    .EXAMPLE
        Get-WorkingDates -StartDate (Get-Date '2022-04-11') -EndDate (Get-Date '2022-04-11')
        
        The function will return a datetime object of date '2022-04-11' because '2022-04-11' is considered a working date, as it is a date within the default parameters, and is the only working date within the provided range. '2022-04-11' is a Monday.
    .EXAMPLE
        Get-WorkingDates -StartDate (Get-Date '2022-04-09') -EndDate (Get-Date '2022-04-09')

        The function will not produce any output (null) because '2022-04-09' is not considered a working day within the default parameters and the given range. '2022-04-09' is a Saturday.
    .EXAMPLE
        Get-WorkingDates -StartDate (Get-Date '2022-04-04') -EndDate (Get-Date '2022-04-17')

        The function will return an array of 10 datetime objects for '2022-04-04' through to '2022-04-08', and '2022-04-11' through to '2022-04-15'. These are considered working dates within the default parameters. '2022-04-04' through to '2022-04-08' is Monday through to Friday, and '2022-04-11' through to '2022-04-15' is Monday through to Friday.
    .EXAMPLE
        Get-WorkingDates -StartDate (Get-Date '2022-04-04') -EndDate (Get-Date '2022-04-17') -NonWorkingDates (Get-Date '2022-04-05')

        The function will return an array of 9 datetime objects for '2022-04-04', '2022-04-06' through to '2022-04-08', and and '2022-04-11' through to '2022-04-15'. These are considered working dates within the defined parameters. '2022-04-05' is considered a non-working date, whereas every other date inbetween the range is considered a working date.
    .EXAMPLE
        Get-WorkingDates -StartDate (Get-Date '2022-04-04') -EndDate (Get-Date '2022-04-17') -NonWorkingDaysOfWeek 'Saturday','Sunday','Monday'

        The function will return an array of 8 datetime objects for '2022-04-05' through to '2022-04-08', and '2022-04-12' through to '2022-04-15'. These are considered working dates within the defined parameters. Saturdays, Sundays, and Mondays, are considered non-working days, therefore every other date inbetween the range is considered a working date.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.DateTime[]
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [DateTime]$StartDate,

        [Parameter(Mandatory)]
        [ValidateScript({
            if ($StartDate -gt $_) { throw "-StartDate must be less than -EndDate." } else { return $true }
        })]
        [DateTime]$EndDate,

        [Parameter()]
        [ValidateSet('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')]
        [String[]]$NonWorkingDaysOfWeek = @('Saturday','Sunday'),

        [Parameter()]
        [DateTime[]]$NonWorkingDates
    )

    # This can return 1 less than intended if we do not do this change.
    # For example, if the dates in between are 3 working days,
    # but the time span between them are 2 whole days,
    # this returns 2 instead of 3
    $StartDate = $StartDate.Date
    $EndDate   = $EndDate.Date.AddSeconds(1)

    $TimeSpan = New-TimeSpan -Start $StartDate -End $EndDate
    $Days = [Math]::Ceiling($TimeSpan.TotalDays)
    $Date = $StartDate

    $WorkingDays = do {
        if ($NonWorkingDaysOfWeek -notcontains $Date.DayOfWeek -And $NonWorkingDates -notcontains $Date.Date) {
            $Date.Date
        }
        $Date = $Date.AddDays(1)
        $Days--
    } while ($Days)

    $WorkingDays
}

function New-BusinessTimeSpan {
    <#
    .SYNOPSIS
        Get the elapsed time between two dates, where the time measured is only inbetween "business hours".
    .DESCRIPTION
        Get the elapsed time between two dates, where the time measured is only inbetween "business hours".

        This is helpful to measure the amount of time past from a start datetime, to an end datetime, while only considering "business hours".

        What constitutes "business hours" in terms of day of the week, or calendar date, including working hours, is arbitrary and completely customisable.

        In other words, the default parameters dictate normal working days, which are Monday through Friday and 08:00 through 17:00.

        You can also specify particular dates, or days of the week, to be regarded as non-working dates via the -NonWorkingDates and -NonWorkingDaysOfWeek parameters.

        This function does consider both date and time while calculating the elapsed time.
    .PARAMETER Start
        The datetime object to start calculating the elapsed time from. It must be an older datetime than -End.
    .PARAMETER End
        The datetime object to end calculating the elapsed time to. It must be a newer datetime than -Start.
    .PARAMETER StartHour
        The starting hour of a typical working day. The default starting hour is 08:00 (AM).
        
        Note: this parameter is a datetime object is, however only the time is used for calculation. The date is ignored.
    .PARAMETER FinishHour
        The final hour of a typical working day. The default final hour is 17:00.
                
        Note: this parameter is a datetime object is, however only the time is used for calculation. The date is ignored.
    .PARAMETER NonWorkingDaysOfWeek
        The days of the week, representated as strings e.g. 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday', which denotes non-working days of the week.
        
        Days specified in this parameter will not be considered as working days.

        Default values are Saturday and Sunday.
    .PARAMETER NonWorkingDates
        An array of datetime objects which denote specific non-working dates.
        
        Dates specified in this parameter will not be considered as working days.
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-04-11 10:00:00') -End (Get-Date '2022-04-11 10:37:00')
        
        The function will return a timespan object of 37 minutes. 2022-04-11 is a Monday and the whole time inbetween the date range given is within the default parameters.
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-04-11 08:00:00') -End (Get-Date '2022-04-12 08:00:00')
        
        The function will return a timespan object of 9 hours. 2022-04-11 is a Monday and 2022-04-12 is a Tuesday, and only 9 hours is considered "working hours" within the default parameters.
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-04-11 13:00:00') -End (Get-Date '2022-04-13 13:00:00')
        
        The function will return a timespan object of 18 hours. 2022-04-11 through 2022-04-13 is Monday through Wednesday, and only 18 hours is considered "working hours" within the default parameters.
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-04-01 00:00:00') -End (Get-Date '2022-04-30 23:59:59') -NonWorkingDates (Get-Date '2022-04-15'), (Get-Date '2022-04-18')
        
        The function will return a timespan object of 162 hours. 2022-04-01 through 2022-04-30 is an entire calendar month, and only 162 hours is considered "working hours" within the defined parameters. '2022-04-15' and '2022-04-18' are considered non-working dates.
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-01-01 00:00:00') -End (Get-Date '2022-12-31 23:59:59') -NonWorkingDates (Get-Date '2022-01-03'), (Get-Date '2022-04-15'), (Get-Date '2022-04-18'), (Get-Date '2022-05-02'), (Get-Date '2022-06-02'), (Get-Date '2022-06-03'), (Get-Date '2022-08-29'), (Get-Date '2022-12-26'), (Get-Date '2022-12-27')
        
        The function will return a timespan object of 2259 hours. 2022-01-01 through 2022-12-31 is an entire year, and only 2259 hours is considered "working hours" within the defined parameters. All dates passed to -NonWorkingDates are considered non-working dates (public holidays in the UK for 2022).
    .EXAMPLE
        New-BusinessTimeSpan -Start (Get-Date '2022-01-01 00:00:00') -End (Get-Date '2022-12-31 23:59:59') -NonWorkingDaysOfWeek 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'
        
        The function will return a timespan object of 0 hours. 2022-01-01 through 2022-12-31 is an entire year, and 0 hours is considered "working hours" within the defined parameters. All days passed to -NonWorkingDaysOfWeek are considered non-working days, hence the result of 0 hours.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.TimeSpan
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [datetime]$Start,
        
        [Parameter(Mandatory)]
        [ValidateScript({
            if ($Start -gt $_) { throw "-Start must be less than -End." } else { return $true }
        })]
        [datetime]$End,

        [Parameter()]
        [DateTime]$StartHour = '08:00:00',

        [Parameter()]
        [ValidateScript({
            if ($StartHour -gt $_) { throw "-StartHour must be less than -FinishHour." } else { return $true }
        })]
        [DateTime]$FinishHour = '17:00:00',

        [Parameter()]
        [ValidateSet('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')]
        [String[]]$NonWorkingDaysOfWeek = @('Saturday','Sunday'),

        [Parameter()]
        [DateTime[]]$NonWorkingDates
    )

    $CommonParams = @{
        NonWorkingDaysOfWeek = $NonWorkingDaysOfWeek
        NonWorkingDates      = $NonWorkingDates
    }

    $WorkingHours = New-TimeSpan -Start $StartHour -End $FinishHour
    $WorkingDays  = Get-WorkingDates -StartDate $Start -EndDate $End @CommonParams

    if ($null -eq $WorkingDays) {
        New-TimeSpan
    }
    elseif ($WorkingDays.Count -eq 1) {
        $Params = @{
            StartHour = $StartHour
            FinishHour = $FinishHour
        }

        $_Start = Get-Date ('{0}/{1}/{2} {3}:{4}:{5}' -f $WorkingDays.Year,
                                                         $WorkingDays.Month,
                                                         $WorkingDays.Day,
                                                         $StartHour.Hour,
                                                         $StartHour.Minute,
                                                         $StartHour.Second)

        if ($Start -le $_Start) {
            $Params["StartDate"] = $_Start
        }
        else {
            $Params["StartDate"] = $Start
        }

        $_End = Get-Date ('{0}/{1}/{2} {3}:{4}:{5}' -f $WorkingDays.Year,
                                                           $WorkingDays.Month,
                                                           $WorkingDays.Day,
                                                           $FinishHour.Hour,
                                                           $FinishHour.Minute,
                                                           $FinishHour.Second)

        if ($End -gt $_End) {
            $Params["EndDate"] = $_End
        }
        else {
            $Params["EndDate"] = $End
        }

        $Result = GetElapsedTime @Params

        if ($Result.Ticks -le 0) {
            New-TimeSpan
        }
        else {
            $Result
        }
    }
    else {
        $NumberOfWorkingDays = $WorkingDays.Count
        $ElapsedTime = New-TimeSpan
        $InBetweenHours = New-TimeSpan
        
        $FirstDayEnd = Get-Date ('{0}/{1}/{2} {3}:{4}:{5}' -f $Start.Year,
                                                                  $Start.Month,
                                                                  $Start.Day,
                                                                  $FinishHour.Hour, 
                                                                  $FinishHour.Minute, 
                                                                  $FinishHour.Second)

        if (Test-WorkingDay -Date $Start -StartHour $StartHour -FinishHour $FinishHour @CommonParams) {        
            $Params = @{
                StartDate  = $Start
                EndDate    = $FirstDayEnd
                StartHour  = $StartHour
                FinishHour = $FinishHour
            }
            $ElapsedTime += (GetElapsedTime @Params)
            $NumberOfWorkingDays--
        }
        elseif ($Start -gt $FirstDayEnd) {
            $NumberOfWorkingDays--
        }

        $LastDayStart = Get-Date ('{0}/{1}/{2} {3}:{4}:{5}' -f $End.Year,
                                                               $End.Month,
                                                               $End.Day,
                                                               $StartHour.Hour, 
                                                               $StartHour.Minute, 
                                                               $StartHour.Second)

        if (Test-WorkingDay -Date $End -StartHour $StartHour -FinishHour $FinishHour @CommonParams) {
            $Params = @{
                StartDate  = $LastDayStart
                EndDate    = $End
                StartHour  = $StartHour
                FinishHour = $FinishHour
            }
            $ElapsedTime += (GetElapsedTime @Params)
            $NumberOfWorkingDays--
        }
        elseif ($End -lt $LastDayStart) {
            $NumberOfWorkingDays--
        }

        $InBetweenHours = New-TimeSpan -Seconds ($NumberOfWorkingDays * $WorkingHours.TotalSeconds)
        $InBetweenHours + $ElapsedTime
    }
}

function Test-WorkingDay {
    <#
    .SYNOPSIS
        Determine whether a given datetime is a working day.
    .DESCRIPTION
        Determine whether a given datetime is a working day.
        
        What constitutes a "working day" in terms of day of the week, or calendar date, including working hours, is arbitrary and completely customisable.
        
        In other words, the default parameters dictate normal working days, which are Monday through Friday, and normal working hours are 08:00 through 17:00.
        
        You can also specify particular dates, or days of the week, to be regarded as non-working dates via the -NonWorkingDates and -NonWorkingDaysOfWeek parameters.
        
        If the datetime of -Date falls outside of these parameters, you'll receive a boolean result.
    .PARAMETER Date
        The datetime to determine whether it is a working day or not.
    .PARAMETER StartHour
        The starting hour of a typical working day. The default starting hour is 08:00 (AM).
        
        Note: this parameter is a datetime object is, however only the time is used for calculation. The date is ignored.
    .PARAMETER FinishHour
        The final hour of a typical working day. The default final hour is 17:00.
        
        Note: this parameter is a datetime object is, however only the time is used for calculation. The date is ignored.
    .PARAMETER NonWorkingDaysOfWeek
        The days of the week, representated as strings e.g. 'Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday', which denotes non-working days of the week.
        
        Days specified in this parameter will not be considered as working days.

        Default values are Saturday and Sunday.
    .PARAMETER NonWorkingDates
        An array of datetime objects which denote specific non-working dates.
        
        Dates specified in this parameter will not be considered as working days.
    .EXAMPLE
        Test-WorkingDay -Date (Get-Date '2022-04-11 09:00:00')
        
        The function will return true because the datetime is within the default parameters. 2022-04-11 is a Monday, and 09:00 is between 08:00 and 17:00.
    .EXAMPLE
        Test-WorkingDay -Date (Get-Date '2022-04-10 11:00:00')

        The function will return false because the datetime is outside the default parameters. 2022-04-10 is a Sunday, and therefore is not a working day, regardless of the hour/minute.
    .EXAMPLE
        Test-WorkingDay -Date (Get-Date '2022-04-11 22:00:00') -StartHour (Get-Date '08:00:00') -FinishHour Get-Date '23:00:00'

        The function will return true because the datetime is within the defined parameters. 2022-04-11 is a Monday, and 22:00 is between 08:00 and 23:00.
        Note: a datetime object is passed for both -StartHour and -FinishHour, however only the time is used for calculation. The date is ignored.
    .EXAMPLE
        Test-WorkingDay -Date (Get-Date '2022-04-11 09:00:00') -NonWorkingDaysOfWeek 'Saturday','Sunday','Monday'

        The function will return false because the datetime is outside the defined parameters. 2022-04-11 is a Monday, and is considered a non working day of the week.
    .EXAMPLE
        Test-WorkingDay -Date (Get-Date '2022-04-11 09:00:00') -NonWorkingDates (Get-Date '2022-04-11')

        The function will return false because the datetime is outside the defined parameters. 2022-04-11 is a Monday, and is considered a non working date.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Boolean
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [DateTime]$Date,

        [Parameter()]
        [DateTime]$StartHour = '08:00:00',

        [Parameter()]
        [ValidateScript({
            if ($StartHour -gt $_) { throw "-StartHour must be less than -FinishHour." } else { return $true }
        })]
        [DateTime]$FinishHour = '17:00:00',

        [Parameter()]
        [ValidateSet('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')]
        [String[]]$NonWorkingDaysOfWeek = @('Saturday','Sunday'),

        [Parameter()]
        [DateTime[]]$NonWorkingDates
    )

    $NonWorkingDaysOfWeek -notcontains $Date.DayOfWeek -And
    $Date.TimeOfDay -ge $StartHour.TimeOfDay -And
    $Date.TimeOfDay -lt $FinishHour.TimeOfDay -And
    $NonWorkingDates -notcontains $Date.Date
}
#endregion