ElektoCalendarPosh.psm1
|
#region Private/Calendar.ps1 # Calendar core: building calendars and the day arithmetic every public function relies on. # # A calendar is a plain object (PSTypeName ElektoCalendarPosh.Calendar) holding the non-working # weekdays and the holidays. As in RiskSystem, a calendar covers from its first holiday to its last: # asking about a date outside them is an error, so that a file missing next year's holidays never # silently treats them as workdays. A single holiday covers from a year before it to a year after it # (RiskSystem goes 25 years back and 75 ahead, more than a scripting tool needs); a calendar without # holidays covers every date. # # Counting never walks day by day: whole weeks are counted arithmetically and the holidays that fall # on working weekdays are found by binary search, so the cost does not grow with the distance between # the dates. The results are the same as those of the Calendar class of Elekto's RiskSystem. $script:WeekdayNames = [Enum]::GetNames([DayOfWeek]) $script:DefaultWeekend = [DayOfWeek[]]@('Saturday', 'Sunday') # Parsed holiday files, by full path, reused while the file keeps the same content; a process that # goes through many files starts over at FileCacheLimit of them, so the cache never grows unbounded $script:FileCache = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) $script:FileCacheLimit = 64 # What Set-WorkdayCalendar stored: calendar objects and/or file paths, resolved on every use $script:DefaultCalendar = $null # The paths in WORKDAY_CALENDAR, made absolute when the module first sees the variable's value, so # that a later Set-Location does not change the calendar (as with Set-WorkdayCalendar) $script:EnvironmentCalendar = @{ Value = $null; Paths = @() } # The last joint calendar, reused while the same calendars are combined, as in a loop over dates $script:LastJoint = $null # The ids of the errors the module raises itself $script:ErrorIds = @('CalendarFileNotFound', 'InvalidCalendarLine', 'NoWorkingWeekday', 'NoCommonPeriod', 'InvalidCalendar', 'DateOutsideCalendar', 'DateOutOfRange', 'NoWorkdayInMonth') function ConvertTo-CalendarDate([datetime]$Date) { # Calendars work with dates only: midnight, and no time zone kind [datetime]::SpecifyKind($Date.Date, [DateTimeKind]::Unspecified) } function Format-CalendarText([string]$Text, [int]$MaxLength = [int]::MaxValue) { # Text that came from a file, a file name or the caller, made safe to print in a message: control # and formatting characters (terminal escapes, bidirectional overrides) are escaped as \uXXXX, and # anything past MaxLength is cut $builder = [Text.StringBuilder]::new() foreach ($char in $Text.ToCharArray()) { if ($builder.Length -ge $MaxLength) { [void]$builder.Append('...'); break } if ([char]::IsControl($char) -or [Globalization.CharUnicodeInfo]::GetUnicodeCategory($char) -eq 'Format') { [void]$builder.AppendFormat('\u{0:X4}', [int]$char) } else { [void]$builder.Append($char) } } $builder.ToString() } function New-CalendarError { # An error record that scripts can catch by exception type and tell apart by its id [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Only creates an object in memory.')] param([string]$Message, [string]$Id, [Management.Automation.ErrorCategory]$Category, [object]$Target, [type]$ExceptionType = [ArgumentException]) $exception = $ExceptionType::new($Message, [Exception]$null) [Management.Automation.ErrorRecord]::new($exception, $Id, $Category, $Target) } function ConvertTo-CalendarErrorRecord([Management.Automation.ErrorRecord]$ErrorRecord) { # The module's own errors pass unchanged; date arithmetic beyond what .NET represents (years 1 to # 9999, or a number of days too large) becomes DateOutOfRange instead of a .NET method error if (($ErrorRecord.FullyQualifiedErrorId -split ',')[0] -in $script:ErrorIds) { return $ErrorRecord } $exception = $ErrorRecord.Exception while ($exception -is [Management.Automation.MethodInvocationException] -and $exception.InnerException) { $exception = $exception.InnerException } if ($exception -is [ArgumentOutOfRangeException] -or $exception -is [OverflowException]) { return New-CalendarError 'The answer falls outside the dates that can be represented, from 0001-01-01 to 9999-12-31.' ` 'DateOutOfRange' InvalidArgument $ErrorRecord.TargetObject ([ArgumentOutOfRangeException]) } $ErrorRecord } function Write-CalendarError($Cmdlet, [Management.Automation.ErrorRecord]$ErrorRecord) { # An error about one date stops the command: the safe default, so that a date the calendar cannot # answer for never silently drops out of a list. When the caller passed -ErrorAction, it is an # ordinary error instead, under the action chosen: Stop still stops, SilentlyContinue skips the date. # # The commands check the dates they need with Get-CalendarCoverageError before working, so that # the usual error reaches here without an exception: PowerShell also records, in the caller's # -ErrorVariable, every exception caught inside a command, once per function it crossed. $record = ConvertTo-CalendarErrorRecord $ErrorRecord if ($Cmdlet.MyInvocation.BoundParameters.ContainsKey('ErrorAction')) { $Cmdlet.WriteError($record) } else { $Cmdlet.ThrowTerminatingError($record) } } function New-CalendarObject { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Only creates an object in memory.')] param( [string]$Name, [string]$Path, [datetime[]]$Holidays = @(), [DayOfWeek[]]$Weekend = $script:DefaultWeekend, # Coverage; computed from the holidays unless given [Nullable[datetime]]$MinDate, [Nullable[datetime]]$MaxDate ) $isWeekend = [bool[]]::new(7) foreach ($day in $Weekend) { $isWeekend[[int]$day] = $true } $workdaysPerWeek = @($isWeekend -eq $false).Count if ($workdaysPerWeek -eq 0) { throw (New-CalendarError "Calendar '$(Format-CalendarText $Name)' has no working weekday: at least one day of the week must be a workday." ` 'NoWorkingWeekday' InvalidArgument $Weekend) } $holidaySet = [System.Collections.Generic.HashSet[datetime]]::new() foreach ($holiday in $Holidays) { [void]$holidaySet.Add([datetime]::SpecifyKind($holiday.Date, [DateTimeKind]::Unspecified)) } $sortedHolidays = [datetime[]]::new($holidaySet.Count) $holidaySet.CopyTo($sortedHolidays) [Array]::Sort($sortedHolidays) # Only the holidays on working weekdays change a count; weekends are counted apart $workingHolidays = [System.Collections.Generic.List[datetime]]::new() foreach ($holiday in $sortedHolidays) { if (-not $isWeekend[[int]$holiday.DayOfWeek]) { $workingHolidays.Add($holiday) } } if (-not $PSBoundParameters.ContainsKey('MinDate')) { if ($sortedHolidays.Count -eq 1) { $MinDate = $sortedHolidays[0].AddYears(-1) $MaxDate = $sortedHolidays[0].AddYears(1) } elseif ($sortedHolidays.Count -gt 1) { $MinDate = $sortedHolidays[0] $MaxDate = $sortedHolidays[-1] } } [pscustomobject]@{ PSTypeName = 'ElektoCalendarPosh.Calendar' Name = $Name Weekend = [DayOfWeek[]]@($Weekend | Sort-Object -Unique { ([int]$_ + 6) % 7 }) # Monday first MinDate = $MinDate MaxDate = $MaxDate Path = $Path Holidays = $sortedHolidays HolidaySet = $holidaySet IsWeekend = $isWeekend WorkdaysPerWeek = $workdaysPerWeek WorkingHolidays = $workingHolidays.ToArray() } } function Read-CalendarFile([string]$Path) { # Reads a holiday file: one yyyy-MM-dd date per line, the rest of the line free; '#' starts a # comment. Before the first holiday, lines of weekday names declare the non-working weekdays, or # NoWeekend, as the first line that is not a comment, declares that there are none. Returns the # holidays and the weekend, if declared. The parse is cached by path and content. $file = [IO.FileInfo]::new($Path) if (-not $file.Exists) { throw (New-CalendarError "Calendar file '$(Format-CalendarText $Path)' not found." 'CalendarFileNotFound' ObjectNotFound $Path ` ([IO.FileNotFoundException])) } # The content decides, not the write time: a rewrite that keeps the size and the time (a copy that # preserves it, a coarse file system) must still be seen. Holiday files are small; hashing is cheap. # All bytes at once, too: a lazy reader left open by an error would keep the file locked on Windows. $bytes = [IO.File]::ReadAllBytes($file.FullName) $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) $cached = $null if ($script:FileCache.TryGetValue($file.FullName, [ref]$cached) -and $cached.Hash -eq $hash) { return $cached } $reader = [IO.StreamReader]::new([IO.MemoryStream]::new($bytes), [Text.UTF8Encoding]::new($false), $true) try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } $holidays = [System.Collections.Generic.List[datetime]]::new() $weekend = [System.Collections.Generic.List[DayOfWeek]]::new() $noWeekend = $false $lineNumber = 0 $contentLines = 0 foreach ($line in $text -split '\r\n|\n|\r') { $lineNumber++ $content = $line.Trim() if ($content.Length -eq 0 -or $content[0] -eq '#') { continue } $contentLines++ # A date line, by far the most common, is told by its first ten characters and what follows them $date = [datetime]::MinValue if ($content.Length -ge 10 -and [char]::IsDigit($content[0]) -and [datetime]::TryParseExact($content.Substring(0, 10), 'yyyy-MM-dd', [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date) -and ($content.Length -eq 10 -or [char]::IsWhiteSpace($content[10]) -or ',#'.Contains($content[10]))) { $holidays.Add($date) continue } $tokens = ($content -replace '#.*$', '').Trim() -split '[\s,]+' $isNoWeekend = $tokens.Count -eq 1 -and $tokens[0] -eq 'NoWeekend' $isWeekdays = @($tokens | Where-Object { $_ -notin $script:WeekdayNames }).Count -eq 0 $problem = $null if ($isNoWeekend -and $contentLines -gt 1) { $problem = 'NoWeekend must be the first line that is not a comment' } elseif ($isNoWeekend) { $noWeekend = $true } elseif ($isWeekdays -and $noWeekend) { $problem = 'weekday names cannot follow NoWeekend' } elseif ($isWeekdays -and $holidays.Count -gt 0) { $problem = 'the non-working weekdays must come before the first holiday' } elseif ($isWeekdays) { foreach ($token in $tokens) { $weekend.Add([DayOfWeek]$token) } } else { $problem = 'this is neither a date (yyyy-MM-dd), nor weekday names, nor NoWeekend' } if ($problem) { # The line goes into the message escaped and cut short: the file may hold anything $found = Format-CalendarText $line 80 throw (New-CalendarError "Line $lineNumber of '$(Format-CalendarText $file.FullName)': $problem. Found '$found'." ` 'InvalidCalendarLine' InvalidData $found ([FormatException])) } } $parsed = [pscustomobject]@{ Hash = $hash Path = $file.FullName Name = [IO.Path]::GetFileNameWithoutExtension($file.Name) Holidays = $holidays.ToArray() Weekend = $weekend.ToArray() WeekendDeclared = $noWeekend -or $weekend.Count -gt 0 Calendar = $null # the calendar of the file as it is, built on first use } if ($script:FileCache.Count -ge $script:FileCacheLimit -and -not $script:FileCache.ContainsKey($file.FullName)) { $script:FileCache.Clear() } $script:FileCache[$file.FullName] = $parsed $parsed } function Import-CalendarFile([string]$Path) { # The calendar of a holiday file, built once for each version of the file $parsed = Read-CalendarFile $Path if (-not $parsed.Calendar) { $weekend = if ($parsed.WeekendDeclared) { $parsed.Weekend } else { $script:DefaultWeekend } $parsed.Calendar = New-CalendarObject -Name $parsed.Name -Path $parsed.Path -Holidays $parsed.Holidays -Weekend $weekend } $parsed.Calendar } function Get-FullPath([string]$Path) { # Relative paths are relative to the PowerShell location, not to the process directory $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } function Join-Calendar([object[]]$Calendars) { # A day is a workday only if it is a workday in every calendar: holidays and weekends add up, and # the coverage is what all of them cover $holidays = foreach ($calendar in $Calendars) { $calendar.Holidays } $weekend = foreach ($calendar in $Calendars) { $calendar.Weekend } $bounded = @($Calendars | Where-Object { $null -ne $_.MinDate }) $minDate = $null $maxDate = $null if ($bounded.Count -gt 0) { $minDate = ($bounded | ForEach-Object MinDate | Sort-Object)[-1] $maxDate = ($bounded | ForEach-Object MaxDate | Sort-Object)[0] if ($minDate -gt $maxDate) { throw (New-CalendarError "Calendars '$(Format-CalendarText ($Calendars.Name -join "', '"))' have no period in common." ` 'NoCommonPeriod' InvalidArgument $Calendars) } } New-CalendarObject -Name ($Calendars.Name -join '+') -Holidays $holidays -Weekend $weekend ` -MinDate $minDate -MaxDate $maxDate } function Get-DefaultCalendar { if ($script:DefaultCalendar) { return Resolve-Calendar $script:DefaultCalendar } $value = $env:WORKDAY_CALENDAR if ($value -cne $script:EnvironmentCalendar.Value) { $paths = @($value -split [IO.Path]::PathSeparator | Where-Object { $_.Trim() } | ForEach-Object { Get-FullPath $_.Trim() }) $script:EnvironmentCalendar = @{ Value = $value; Paths = $paths } } if ($script:EnvironmentCalendar.Paths.Count -gt 0) { return Resolve-Calendar $script:EnvironmentCalendar.Paths } $script:WeekendsOnly } function Resolve-Calendar([object[]]$Calendar) { # Turns what the -Calendar parameter received (calendar objects, file paths, or nothing) into a # single calendar: the default one when nothing was given, a joint one when several were $items = @($Calendar | Where-Object { $null -ne $_ -and ($_ -isnot [string] -or $_.Trim()) }) if ($items.Count -eq 0) { return Get-DefaultCalendar } $calendars = @(foreach ($item in $items) { if ($item.PSObject.TypeNames -contains 'ElektoCalendarPosh.Calendar') { $item } elseif ($item -is [IO.FileInfo]) { Import-CalendarFile $item.FullName } elseif ($item -is [System.Management.Automation.PathInfo]) { Import-CalendarFile $item.ProviderPath } elseif ($item -is [string]) { Import-CalendarFile (Get-FullPath $item) } else { throw (New-CalendarError "A calendar must be an object from New-WorkdayCalendar or the path of a holiday file, not '$(Format-CalendarText "$item" 80)'." ` 'InvalidCalendar' InvalidArgument $item) } }) if ($calendars.Count -eq 1) { return $calendars[0] } $last = $script:LastJoint if ($last -and $last.Parts.Count -eq $calendars.Count) { $same = $true for ($i = 0; $i -lt $calendars.Count; $i++) { if (-not [object]::ReferenceEquals($last.Parts[$i], $calendars[$i])) { $same = $false; break } } if ($same) { return $last.Calendar } } $joint = Join-Calendar $calendars $script:LastJoint = @{ Parts = $calendars; Calendar = $joint } $joint } function Assert-CalendarCoverage($Calendar, [datetime]$Date) { # On every day a computation touches, so the check that passes stays inline and cheap if (($null -eq $Calendar.MinDate -or $Date -ge $Calendar.MinDate) -and ($null -eq $Calendar.MaxDate -or $Date -le $Calendar.MaxDate)) { return } throw (Get-CalendarCoverageError $Calendar $Date) } function Get-CalendarCoverageError($Calendar, [datetime]$Date) { # The error for a date the calendar does not cover, or nothing when it does if (($null -eq $Calendar.MinDate -or $Date -ge $Calendar.MinDate) -and ($null -eq $Calendar.MaxDate -or $Date -le $Calendar.MaxDate)) { return } $template = "Calendar '{0}' covers {1:yyyy-MM-dd} to {2:yyyy-MM-dd} and cannot tell whether {3:yyyy-MM-dd} is a workday." $message = $template -f (Format-CalendarText $Calendar.Name), $Calendar.MinDate, $Calendar.MaxDate, $Date if ($Calendar.Path) { $message += if ($Date -gt $Calendar.MaxDate) { " Add the holidays after {0:yyyy-MM-dd} to '{1}'." -f $Calendar.MaxDate, (Format-CalendarText $Calendar.Path) } else { " Add the holidays before {0:yyyy-MM-dd} to '{1}'." -f $Calendar.MinDate, (Format-CalendarText $Calendar.Path) } } New-CalendarError $message 'DateOutsideCalendar' InvalidArgument $Date ([ArgumentOutOfRangeException]) } function Test-CalendarDay($Calendar, [datetime]$Date) { Assert-CalendarCoverage $Calendar $Date -not $Calendar.IsWeekend[[int]$Date.DayOfWeek] -and -not $Calendar.HolidaySet.Contains($Date) } function Find-Workday($Calendar, [datetime]$Date, [int]$Direction, [switch]$OrSame) { # The first workday after Date (Direction 1) or before it (Direction -1); Date itself with OrSame Assert-CalendarCoverage $Calendar $Date if (-not $OrSame) { $Date = $Date.AddDays($Direction) } while (-not (Test-CalendarDay $Calendar $Date)) { $Date = $Date.AddDays($Direction) } $Date } function Find-LowerBound([datetime[]]$Sorted, [datetime]$Value) { # Index of the first element not less than Value (the elements are unique) $index = [Array]::BinarySearch($Sorted, $Value) if ($index -lt 0) { -bnot $index } else { $index } } function Measure-CalendarHoliday($Calendar, [datetime]$Start, [datetime]$End) { # Holidays on working weekdays in [Start, End) (Find-LowerBound $Calendar.WorkingHolidays $End) - (Find-LowerBound $Calendar.WorkingHolidays $Start) } function Measure-CalendarWorkday($Calendar, [datetime]$Start, [datetime]$End) { # Workdays in [Start, End), for Start <= End $days = ($End - $Start).Days if ($days -le 0) { return 0 } Assert-CalendarCoverage $Calendar $Start Assert-CalendarCoverage $Calendar $End.AddDays(-1) $weeks = [int][Math]::Floor($days / 7) $count = $weeks * $Calendar.WorkdaysPerWeek for ($day = $Start.AddDays(7 * $weeks); $day -lt $End; $day = $day.AddDays(1)) { if (-not $Calendar.IsWeekend[[int]$day.DayOfWeek]) { $count++ } } $count - (Measure-CalendarHoliday $Calendar $Start $End) } function Step-WorkingWeekday($Calendar, [datetime]$Date, [long]$Count) { # The Count-th working weekday after Date (before it, when negative), ignoring holidays $direction = [Math]::Sign($Count) $left = [Math]::Abs($Count) $weeks = [long][Math]::Floor(($left - 1) / $Calendar.WorkdaysPerWeek) $Date = $Date.AddDays(7 * $weeks * $direction) $left -= $weeks * $Calendar.WorkdaysPerWeek while ($left -gt 0) { $Date = $Date.AddDays($direction) if (-not $Calendar.IsWeekend[[int]$Date.DayOfWeek]) { $left-- } } $Date } function Add-CalendarWorkday($Calendar, [datetime]$Date, [int]$Days) { # The Days-th workday after Date (before it, when negative); Date itself when Days is 0, workday # or not, without consulting the calendar, whose holidays could not change that answer. As # RiskSystem's Calendar.AddWorkDays. if ($Days -eq 0) { return $Date } Assert-CalendarCoverage $Calendar $Date $direction = [Math]::Sign($Days) $pending = [Math]::Abs([long]$Days) # More workdays than the calendar has days: the answer is beyond it, whatever its holidays if ($null -ne $Calendar.MaxDate -and $pending -gt ($Calendar.MaxDate - $Calendar.MinDate).Days) { Assert-CalendarCoverage $Calendar $(if ($direction -gt 0) { $Calendar.MaxDate.AddDays(1) } else { $Calendar.MinDate.AddDays(-1) }) } while ($pending -gt 0) { $target = Step-WorkingWeekday $Calendar $Date ($pending * $direction) Assert-CalendarCoverage $Calendar $target # The holidays stepped over did not count: go on for as many days as they were $pending = if ($direction -gt 0) { Measure-CalendarHoliday $Calendar $Date.AddDays(1) $target.AddDays(1) } else { Measure-CalendarHoliday $Calendar $target $Date } $Date = $target } $Date } function Get-CalendarPeriodDelta($Calendar, [datetime]$From, [datetime]$To) { # Workdays in (From, To]; negative, and in (To, From], when To comes first. The difference of # RiskSystem's cumulative workday counts at To and at From. if ($From -le $To) { Measure-CalendarWorkday $Calendar $From.AddDays(1) $To.AddDays(1) } else { -(Measure-CalendarWorkday $Calendar $To.AddDays(1) $From.AddDays(1)) } } function Get-CalendarDelta($Calendar, [datetime]$Start, [datetime]$End, [string]$Mode) { # Workdays between Start and End under Mode, as RiskSystem's Calendar.GetDeltaWorkDays if ($Start -eq $End) { return [int]($Mode -eq 'Full' -and (Test-CalendarDay $Calendar $Start)) } if ($Start -gt $End) { return -(Get-CalendarDelta $Calendar $End $Start $Mode) } switch ($Mode) { 'StartOnNextWork' { $Start = Find-Workday $Calendar $Start 1 -OrSame } 'StartOnPrevWork' { $Start = Find-Workday $Calendar $Start -1 -OrSame } 'EndOnNextWork' { $End = Find-Workday $Calendar $End 1 -OrSame } 'EndOnPrevWork' { $End = Find-Workday $Calendar $End -1 -OrSame } 'StartAndEndOnNext' { $Start = Find-Workday $Calendar $Start 1 -OrSame $End = Find-Workday $Calendar $End 1 -OrSame } 'StartAndEndOnPrev' { $Start = Find-Workday $Calendar $Start -1 -OrSame $End = Find-Workday $Calendar $End -1 -OrSame } 'StartAndEndExpanding' { $Start = Find-Workday $Calendar $Start -1 -OrSame $End = Find-Workday $Calendar $End 1 -OrSame } 'StartAndEndCollapsing' { $Start = Find-Workday $Calendar $Start 1 -OrSame $End = Find-Workday $Calendar $End -1 -OrSame } } if ($Start -eq $End) { return [int]($Mode -eq 'Full' -and (Test-CalendarDay $Calendar $Start)) } $startIsWorkday = Test-CalendarDay $Calendar $Start $endIsWorkday = Test-CalendarDay $Calendar $End $delta = Get-CalendarPeriodDelta $Calendar $Start $End # (Start, End] becomes [Start, End], or [Start, End) in every mode but Full. RiskSystem has one more # case, delta 0 with a working Start and a non-working End, already covered by the last one here. if ($Mode -eq 'Full') { if ($startIsWorkday) { $delta++ } } elseif (-not $startIsWorkday -and $endIsWorkday) { $delta-- } elseif ($startIsWorkday -and -not $endIsWorkday) { $delta++ } $delta } function Get-AdjustedDate($Calendar, [datetime]$Date, [string]$Adjust) { # Moves a non-working Date to a workday under one of the usual business day conventions switch ($Adjust) { 'Following' { Find-Workday $Calendar $Date 1 -OrSame } 'Preceding' { Find-Workday $Calendar $Date -1 -OrSame } 'ModifiedFollowing' { $adjusted = Find-Workday $Calendar $Date 1 -OrSame if ($adjusted.Month -ne $Date.Month) { $adjusted = Find-Workday $Calendar $adjusted -1 } $adjusted } 'ModifiedPreceding' { $adjusted = Find-Workday $Calendar $Date -1 -OrSame if ($adjusted.Month -ne $Date.Month) { $adjusted = Find-Workday $Calendar $adjusted 1 } $adjusted } default { $Date } } } function Get-NthCalendarWorkdayInMonth($Calendar, [datetime]$Date, [int]$Nth) { # The Nth workday of Date's month, counting from its last one when Nth is negative. A month with # fewer workdays gives its last one (its first, from the end), so that a task meant for the end of # the month still runs: the results of RiskSystem's GetNthWorkingDay and GetNthToLastWorkingDay # (and GetWorkingMonthHead and GetWorkingMonthTail for 1 and -1). The search never leaves the # month, so it needs no coverage beyond it, and a month without any workday is an error: an answer # from another month would mislead a comparison with a date of this one. $first = [datetime]::new($Date.Year, $Date.Month, 1) $last = $first.AddMonths(1).AddDays(-1) $day, $end, $step = if ($Nth -gt 0) { $first, $last, 1 } else { $last, $first, -1 } $answer = $null $found = 0 while ($true) { if (Test-CalendarDay $Calendar $day) { $answer = $day if (++$found -eq [Math]::Abs($Nth)) { break } } if ($day -eq $end) { break } $day = $day.AddDays($step) } if ($null -eq $answer) { throw (New-CalendarError ("Calendar '{0}' has no workday in {1:yyyy-MM}." -f (Format-CalendarText $Calendar.Name), $first) ` 'NoWorkdayInMonth' ObjectNotFound $first ([InvalidOperationException])) } $answer } $script:WeekendsOnly = New-CalendarObject -Name 'Weekends' # Format-List shows what matters too (the table view is in the format file); the lookup structures stay out of sight Update-TypeData -TypeName 'ElektoCalendarPosh.Calendar' -DefaultDisplayPropertySet Name, Weekend, MinDate, MaxDate, Path -Force #endregion #region Public/Add-CalendarDay.ps1 <# .SYNOPSIS Adds calendar days to a date and, optionally, moves the result to a workday. .DESCRIPTION The days are plain calendar days. When the result is not a workday, -Adjust says what to do with it, following the usual business day conventions: - None (default): keep it. - Following: move it to the next workday. - Preceding: move it to the previous workday. - ModifiedFollowing: move it to the next workday, unless that falls in another month; then to the previous workday. - ModifiedPreceding: move it to the previous workday, unless that falls in another month; then to the next workday. With Days 0, it just applies the convention to the date. .PARAMETER Days How many calendar days to add; negative to go back. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Adjust What to do when the result is not a workday: None, Following, Preceding, ModifiedFollowing or ModifiedPreceding. None when omitted. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Add-CalendarDay 30 -Date 2026-10-01 -Adjust Following -Calendar ./holidays/br.txt 2026-11-03: thirty days later is Saturday, 2026-10-31; then come a Sunday and All Souls' Day. .EXAMPLE Add-CalendarDay 30 -Date 2026-10-01 -Adjust ModifiedFollowing -Calendar ./holidays/br.txt 2026-10-30: the following workday is in November, so the result goes back to the last workday of October. .EXAMPLE Add-CalendarDay 0 -Date 2026-11-01 -Adjust Preceding -Calendar ./holidays/br.txt 2026-10-30: moves a Sunday back to the previous workday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Add-CalendarDay { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory, Position = 0)] [int]$Days, [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [ValidateSet('None', 'Following', 'Preceding', 'ModifiedFollowing', 'ModifiedPreceding')] [string]$Adjust = 'None', [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The result is checked first, when it is to be adjusted: one outside the calendar is then # reported without an exception. Without adjustment the calendar is not consulted. $problem = $null try { $day = (ConvertTo-CalendarDate $Date).AddDays($Days) if ($Adjust -ne 'None') { $problem = Get-CalendarCoverageError $resolved $day } if (-not $problem) { Get-AdjustedDate $resolved $day $Adjust } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Add-Workday.ps1 <# .SYNOPSIS Adds workdays to a date, or subtracts them. .DESCRIPTION Returns the Days-th workday after the date, or before it when Days is negative. The date itself does not need to be a workday: one workday after a Saturday is the following Monday, and one workday before it is the Friday (when they are workdays). With Days 0 the date comes back unchanged, even if it is not a workday; Get-NextOrSameWorkday and Get-PreviousOrSameWorkday move a date to a workday. From a workday, Get-WorkdayDelta between the date and the result gives Days back. .PARAMETER Days How many workdays to add; negative to go back. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Add-Workday 5 -Date 2026-11-16 -Calendar ./holidays/br.txt 2026-11-24: the 17th, 18th, 19th, 23rd and 24th; the 20th is a holiday in Brazil. .EXAMPLE Add-Workday -2 Two workdays before today (D-2), in the default calendar. .EXAMPLE Import-Csv trades.csv | Add-Workday 2 The settlement date (D+2) of each trade, taken from the Date column. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Add-Workday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory, Position = 0)] [int]$Days, [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception. # Not with 0 days, which gives the date back without consulting the calendar. $problem = $null try { $day = ConvertTo-CalendarDate $Date if ($Days -ne 0) { $problem = Get-CalendarCoverageError $resolved $day } if (-not $problem) { Add-CalendarWorkday $resolved $day $Days } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-FirstWorkdayInMonth.ps1 <# .SYNOPSIS Returns the first workday of a month. .DESCRIPTION The first day of the month if it is a workday, otherwise the first workday after it. Get-NthWorkdayInMonth gives the second, the third, and so on. .PARAMETER Date Any date in the month; the current month when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-FirstWorkdayInMonth -Date 2026-11-15 -Calendar ./holidays/br.txt 2026-11-03: November 1st is a Sunday and the 2nd is All Souls' Day. .EXAMPLE Get-FirstWorkdayInMonth -Date (Get-Date).AddMonths(1) The first workday of next month. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-FirstWorkdayInMonth { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The first day of the month is checked first: one outside the calendar is then reported # without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved ([datetime]::new($day.Year, $day.Month, 1)) if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day 1 } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-LastWorkdayInMonth.ps1 <# .SYNOPSIS Returns the last workday of a month. .DESCRIPTION The last day of the month if it is a workday, otherwise the last workday before it. Get-NthWorkdayInMonth with a negative position gives the second to last, the third to last, and so on. .PARAMETER Date Any date in the month; the current month when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-LastWorkdayInMonth -Date 2026-10-14 -Calendar ./holidays/br.txt 2026-10-30: October 31st, 2026 is a Saturday. .EXAMPLE if ((Get-LastWorkdayInMonth) -eq [datetime]::Today) { ./Close-Month.ps1 } Closes the month on its last workday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-LastWorkdayInMonth { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The last day of the month is checked first: one outside the calendar is then reported # without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved ([datetime]::new($day.Year, $day.Month, 1).AddMonths(1).AddDays(-1)) if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day -1 } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-NextOrSameWorkday.ps1 <# .SYNOPSIS Returns the date itself if it is a workday, otherwise the first workday after it. .DESCRIPTION The "following" business day convention: a non-working date moves forward to the next workday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-NextOrSameWorkday -Date 2026-11-20 -Calendar ./holidays/br.txt 2026-11-23: the 20th is a holiday and the 21st and 22nd are a weekend. .EXAMPLE Get-NextOrSameWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt 2026-11-19 itself, a working Thursday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-NextOrSameWorkday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Find-Workday $resolved $day 1 -OrSame } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-NextWorkday.ps1 <# .SYNOPSIS Returns the first workday after a date. .DESCRIPTION The date itself never counts, workday or not. Use Get-NextOrSameWorkday to keep a date that is already a workday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-NextWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt 2026-11-23: the 20th is a holiday in Brazil and the 21st and 22nd are a weekend. .EXAMPLE Get-NextWorkday Tomorrow, or the first workday after it, in the default calendar. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-NextWorkday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Find-Workday $resolved $day 1 } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-NthWorkdayInMonth.ps1 <# .SYNOPSIS Returns the Nth workday of a month, counting from its start or from its end. .DESCRIPTION 1 is the first workday of the month, 2 the second, and so on; -1 is the last workday, -2 the second to last. When the month has fewer workdays than asked for, the result is its last workday (its first, counting from the end): a task set for the 22nd workday of the month still runs, on the last one, in a month that has only 20. .PARAMETER Nth The position: 1 to 31 from the start of the month, -1 to -31 from its end; 0 is not accepted. .PARAMETER Date Any date in the month; the current month when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-NthWorkdayInMonth 5 -Date 2026-11-01 -Calendar ./holidays/br.txt 2026-11-09: the workdays of November 2026 start on the 3rd, after a Sunday and All Souls' Day. .EXAMPLE Get-NthWorkdayInMonth -2 -Date 2026-11-01 -Calendar ./holidays/br.txt 2026-11-27, the second to last workday of November 2026. .EXAMPLE Get-NthWorkdayInMonth 25 -Date 2026-11-01 -Calendar ./holidays/br.txt 2026-11-30: November 2026 has only 19 workdays, so its last one stands for the 25th. .EXAMPLE if ((Get-NthWorkdayInMonth 3) -eq [datetime]::Today) { ./Send-MonthlyReport.ps1 } Sends the report on the third workday of each month. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-NthWorkdayInMonth { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory, Position = 0)] [ValidateRange(-31, 31)] [ValidateScript({ $_ -ne 0 }, ErrorMessage = 'Nth cannot be 0: 1 is the first workday of the month and -1 the last.')] [int]$Nth, [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The day of the month the count starts from is checked first: one outside the calendar is then # reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $first = [datetime]::new($day.Year, $day.Month, 1) $problem = Get-CalendarCoverageError $resolved $(if ($Nth -gt 0) { $first } else { $first.AddMonths(1).AddDays(-1) }) if (-not $problem) { Get-NthCalendarWorkdayInMonth $resolved $day $Nth } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-PreviousOrSameWorkday.ps1 <# .SYNOPSIS Returns the date itself if it is a workday, otherwise the last workday before it. .DESCRIPTION The "preceding" business day convention: a non-working date moves back to the previous workday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-PreviousOrSameWorkday -Date 2026-11-22 -Calendar ./holidays/br.txt 2026-11-19: the 22nd and 21st are a weekend and the 20th is a holiday in Brazil. .EXAMPLE Get-PreviousOrSameWorkday Today if it is a workday, otherwise the last workday before it. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-PreviousOrSameWorkday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Find-Workday $resolved $day -1 -OrSame } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-PreviousWorkday.ps1 <# .SYNOPSIS Returns the last workday before a date. .DESCRIPTION The date itself never counts, workday or not. Use Get-PreviousOrSameWorkday to keep a date that is already a workday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-PreviousWorkday -Date 2026-11-23 -Calendar ./holidays/br.txt 2026-11-19: the 22nd and 21st are a weekend and the 20th is a holiday in Brazil. .EXAMPLE $reference = Get-PreviousWorkday The last workday before today, typically the date of the latest market data. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.DateTime #> function Get-PreviousWorkday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Find-Workday $resolved $day -1 } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Get-Workday.ps1 <# .SYNOPSIS Lists the workdays between two dates. .DESCRIPTION Both dates are included when they are workdays. When End comes before Start, the list runs backwards, from Start to End. .PARAMETER Start The first date; the time of day is ignored. .PARAMETER End The last date; the time of day is ignored. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-Workday 2026-11-16 2026-11-24 -Calendar ./holidays/br.txt The 16th, 17th, 18th, 19th, 23rd and 24th of November 2026; the 20th is a holiday in Brazil. .EXAMPLE foreach ($day in Get-Workday 2026-01-01 2026-01-31) { ./Import-Prices.ps1 -Date $day } Runs an import for each workday of January. .OUTPUTS System.DateTime #> function Get-Workday { [CmdletBinding()] [OutputType([datetime])] param( [Parameter(Mandatory, Position = 0)] [datetime]$Start, [Parameter(Mandatory, Position = 1)] [datetime]$End, [object[]]$Calendar ) try { $resolved = Resolve-Calendar $Calendar $day = ConvertTo-CalendarDate $Start $last = ConvertTo-CalendarDate $End $step = if ($day -le $last) { 1 } else { -1 } # Both ends first, so that a range the calendar does not fully cover fails before any output Assert-CalendarCoverage $resolved $day Assert-CalendarCoverage $resolved $last while ($true) { if (Test-CalendarDay $resolved $day) { $day } if ($day -eq $last) { break } $day = $day.AddDays($step) } } catch { $PSCmdlet.ThrowTerminatingError((ConvertTo-CalendarErrorRecord $_)) } } #endregion #region Public/Get-WorkdayCalendar.ps1 <# .SYNOPSIS Returns a calendar: the default one, or the one given. .DESCRIPTION Without -Calendar, returns the calendar the module uses by default (see Set-WorkdayCalendar). With it, returns the calendar it describes, which is handy to read a holiday file once, or to combine several, and pass the result to many calls. The calendar shows its name, its non-working weekdays and the period it covers; its Holidays property lists the holidays. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. .EXAMPLE Get-WorkdayCalendar Shows the calendar in effect. .EXAMPLE $both = Get-WorkdayCalendar ./holidays/br.txt, ./holidays/us.txt Get-NextWorkday -Calendar $both Combines two calendars once and reuses the result. .EXAMPLE (Get-WorkdayCalendar ./holidays/br.txt).Holidays | Where-Object Year -EQ 2026 Lists the holidays of 2026. .OUTPUTS ElektoCalendarPosh.Calendar #> function Get-WorkdayCalendar { [CmdletBinding()] [OutputType('ElektoCalendarPosh.Calendar')] param( [Parameter(Position = 0)] [object[]]$Calendar ) try { Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } #endregion #region Public/Get-WorkdayDelta.ps1 <# .SYNOPSIS Counts the workdays between two dates. .DESCRIPTION By default (the Financial mode) the count includes the start date, when it is a workday, and excludes the end date: the Brazilian financial convention, set by Banco Central's Circular 2.456 of 1994 ("the count of workdays between two dates shall include the first and exclude the last"). From a workday, it counts the steps of Add-Workday: Get-WorkdayDelta $d (Add-Workday 5 $d) is 5. The result is negative when End comes before Start, with the same magnitude as the other way round. Other modes: - Full: includes both dates when they are workdays, like Excel's NETWORKDAYS. - None: the same as Financial. - StartOnNextWork, StartOnPrevWork: a non-working start moves first to the next (previous) workday. - EndOnNextWork, EndOnPrevWork: the same for the end. - StartAndEndOnNext, StartAndEndOnPrev: both move to the next (previous) workday. - StartAndEndExpanding: the start moves back and the end moves forward. - StartAndEndCollapsing: the start moves forward and the end moves back. .PARAMETER Start The start date; the time of day is ignored. Today when omitted. .PARAMETER End The end date; the time of day is ignored. Today when omitted. Either date can come from the pipeline, or from a property of the same name: piped values go to the one not given by name, to Start when neither is. .PARAMETER Mode How to count the ends: Financial (default), Full, None, StartOnNextWork, StartOnPrevWork, EndOnNextWork, EndOnPrevWork, StartAndEndOnNext, StartAndEndOnPrev, StartAndEndExpanding or StartAndEndCollapsing. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Get-WorkdayDelta -Start 2026-11-16 -End 2026-11-24 -Calendar ./holidays/br.txt 5: the 16th, 17th, 18th, 19th and 23rd. The 20th is a holiday and the 24th, the end, does not count. .EXAMPLE Get-WorkdayDelta -Start 2026-11-16 -End 2026-11-24 -Mode Full -Calendar ./holidays/br.txt 6: now the 24th counts too. .EXAMPLE '2026-12-31', '2027-06-30' | Get-WorkdayDelta -Start (Get-Date) The workdays from today until each date. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.Int32 #> function Get-WorkdayDelta { [CmdletBinding()] [OutputType([int])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Start = [datetime]::Today, [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$End = [datetime]::Today, [ValidateSet('Financial', 'Full', 'None', 'StartOnNextWork', 'StartOnPrevWork', 'EndOnNextWork', 'EndOnPrevWork', 'StartAndEndOnNext', 'StartAndEndOnPrev', 'StartAndEndExpanding', 'StartAndEndCollapsing')] [string]$Mode = 'Financial', [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # Both dates are checked first: one outside the calendar is then reported without an exception. # Not for the same date twice, which counts 0 without consulting the calendar, except in Full. $problem = $null try { $from = ConvertTo-CalendarDate $Start $to = ConvertTo-CalendarDate $End if ($from -ne $to -or $Mode -eq 'Full') { $problem = (Get-CalendarCoverageError $resolved $from) ?? (Get-CalendarCoverageError $resolved $to) } if (-not $problem) { Get-CalendarDelta $resolved $from $to $Mode } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/New-WorkdayCalendar.ps1 <# .SYNOPSIS Creates a workday calendar from a holiday file, a list of dates, or both. .DESCRIPTION A calendar is a set of non-working weekdays (Saturday and Sunday, unless told otherwise) plus a set of holidays. Every function in this module takes the calendar in its -Calendar parameter, which also accepts the path of a holiday file directly; New-WorkdayCalendar is for when the file needs adjustments (another name, another weekend, extra holidays) or when there is no file at all. The holiday file is plain text, UTF-8: # Comments start with '#'. Blank lines are ignored. # Optional, before the first holiday: the non-working weekdays, by English name. # Saturday and Sunday when omitted. Saturday Sunday # One holiday per line, as yyyy-MM-dd; the rest of the line is a free description. 2026-01-01 New Year's Day 2026-11-20 Black Consciousness Day 2026-12-25 Christmas Day 2027-01-01 New Year's Day Instead of weekday names, the word NoWeekend, as the first line that is not a comment, makes every day of the week a workday: only the holidays are not. A calendar covers from its first holiday to its last, and asking about a date outside them is an error: a file that lacks next year's holidays must not treat them as workdays. Listing the next January 1st, as above, covers the end of the year. A single holiday covers from a year before it to a year after it, and a calendar without holidays covers every date. .PARAMETER Path The holiday file. Relative paths are relative to the current location. .PARAMETER Holiday Holidays to add, besides those in the file. .PARAMETER Weekend The non-working weekdays, replacing those declared in the file and the default Saturday and Sunday. An empty list (@()) makes every weekday a workday. .PARAMETER Name The calendar name, shown in messages. The file name without extension when omitted. .EXAMPLE $br = New-WorkdayCalendar -Path ./holidays/br.txt Get-NextWorkday -Date 2026-11-19 -Calendar $br Reads the file once and uses the calendar. .EXAMPLE New-WorkdayCalendar -Path ./holidays/br.txt -Holiday 2026-12-24, 2026-12-31 -Name 'br-office' The national calendar plus two days the office is closed. .EXAMPLE New-WorkdayCalendar -Weekend Friday, Saturday A calendar for a Friday and Saturday weekend, without holidays. .OUTPUTS ElektoCalendarPosh.Calendar #> function New-WorkdayCalendar { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Only creates an object in memory.')] [CmdletBinding()] [OutputType('ElektoCalendarPosh.Calendar')] param( [Parameter(Position = 0)] [string]$Path, [datetime[]]$Holiday = @(), [DayOfWeek[]]$Weekend, [string]$Name ) try { $holidays = [System.Collections.Generic.List[datetime]]::new() $weekendDays = $script:DefaultWeekend $source = $null if ($Path) { $parsed = Read-CalendarFile (Get-FullPath $Path) $holidays.AddRange($parsed.Holidays) if ($parsed.WeekendDeclared) { $weekendDays = $parsed.Weekend } if (-not $Name) { $Name = $parsed.Name } $source = $parsed.Path } foreach ($day in $Holiday) { $holidays.Add($day) } if ($PSBoundParameters.ContainsKey('Weekend')) { $weekendDays = $Weekend } if (-not $Name) { $Name = 'Custom' } New-CalendarObject -Name $Name -Path $source -Holidays $holidays -Weekend $weekendDays } catch { $PSCmdlet.ThrowTerminatingError($_) } } #endregion #region Public/Set-WorkdayCalendar.ps1 <# .SYNOPSIS Sets the calendar the module uses when a function is called without -Calendar. .DESCRIPTION Without -Calendar, every function uses the default calendar, which is, in order of precedence: 1. The calendar set by Set-WorkdayCalendar in the current session. 2. The holiday file named by the WORKDAY_CALENDAR environment variable (several files may be given, separated by ';' on Windows and ':' elsewhere, and are combined). 3. Saturday and Sunday as the only non-working days. Calling Set-WorkdayCalendar without a calendar goes back to the second and third options. A holiday file set here is read again whenever it changes, so a long-running script always sees the current version. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. .PARAMETER PassThru Returns the calendar now in effect. .EXAMPLE Set-WorkdayCalendar ./holidays/br.txt Add-Workday 2 At the top of a script: from then on, every function uses the Brazilian calendar. .EXAMPLE Set-WorkdayCalendar ./holidays/br.txt, ./holidays/us.txt A day is a workday only if it is a workday both in Brazil and in the United States. .EXAMPLE Set-WorkdayCalendar Back to WORKDAY_CALENDAR or, without it, to Saturday and Sunday only. .OUTPUTS ElektoCalendarPosh.Calendar, with -PassThru. #> function Set-WorkdayCalendar { [CmdletBinding(SupportsShouldProcess)] [OutputType('ElektoCalendarPosh.Calendar')] param( [Parameter(Position = 0)] [object[]]$Calendar, [switch]$PassThru ) try { # Paths become absolute now, so that a later change of location does not change the calendar $items = @(foreach ($item in $Calendar) { if ($item -is [string]) { Get-FullPath $item } else { $item } }) # Resolving now reports a missing or malformed file here, not on first use $target = if ($items.Count -gt 0) { "calendar '$((Resolve-Calendar $items).Name)'" } else { 'the built-in default' } if ($PSCmdlet.ShouldProcess('default workday calendar', "Set to $target")) { $script:DefaultCalendar = if ($items.Count -gt 0) { $items } else { $null } } if ($PassThru) { Get-DefaultCalendar } } catch { $PSCmdlet.ThrowTerminatingError($_) } } #endregion #region Public/Test-NextDayIsWorkday.ps1 <# .SYNOPSIS Tells whether the day after a date is a workday. .DESCRIPTION Looks at the next calendar day, whatever the date itself is: true on the eve of a workday. Handy for jobs that prepare the next business day, which must run on a Sunday before a working Monday but not on a Thursday before a holiday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Test-NextDayIsWorkday -Date 2026-11-19 -Calendar ./holidays/br.txt False: 2026-11-20 is a holiday in Brazil. .EXAMPLE if (Test-NextDayIsWorkday) { ./Prepare-NextDay.ps1 } Prepares tomorrow only when tomorrow is a workday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.Boolean #> function Test-NextDayIsWorkday { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The day is checked first: a day outside the calendar is then reported without an exception $problem = $null try { $day = (ConvertTo-CalendarDate $Date).AddDays(1) $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Test-CalendarDay $resolved $day } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Test-PreviousDayIsWorkday.ps1 <# .SYNOPSIS Tells whether the day before a date was a workday. .DESCRIPTION Looks at the previous calendar day, whatever the date itself is: true on the day after a workday. Handy for jobs that process the previous business day, which must run on a Saturday after a working Friday but not on a Tuesday after a holiday. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Test-PreviousDayIsWorkday -Date 2026-11-21 -Calendar ./holidays/br.txt False: 2026-11-20 is a holiday in Brazil. .EXAMPLE if (Test-PreviousDayIsWorkday) { ./Import-YesterdayTrades.ps1 } Imports yesterday's trades only when yesterday was a workday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.Boolean #> function Test-PreviousDayIsWorkday { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The day is checked first: a day outside the calendar is then reported without an exception $problem = $null try { $day = (ConvertTo-CalendarDate $Date).AddDays(-1) $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Test-CalendarDay $resolved $day } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion #region Public/Test-Workday.ps1 <# .SYNOPSIS Tells whether a date is a workday. .DESCRIPTION A date is a workday when it is neither one of the calendar's non-working weekdays (Saturday and Sunday, unless the calendar says otherwise) nor one of its holidays. .PARAMETER Date The date; the time of day is ignored. Today when omitted. Accepts pipeline input, also from a property named Date. .PARAMETER Calendar The calendar: an object from New-WorkdayCalendar, the path of a holiday file, or several of them, combined so that a day is a workday only if it is a workday in all of them. When omitted, the default calendar is used (see Set-WorkdayCalendar). .EXAMPLE Test-Workday -Date 2026-11-20 -Calendar ./holidays/br.txt False when br.txt lists 2026-11-20 (Black Consciousness Day) as a holiday. .EXAMPLE if (Test-Workday) { ./Send-DailyReport.ps1 } Sends the report only on workdays of the default calendar. .EXAMPLE '2026-11-19', '2026-11-20', '2026-11-21' | Test-Workday -Calendar ./holidays/br.txt True, False, False: a Thursday, a holiday and a Saturday. .NOTES An error about a date (one the calendar does not cover, for instance) stops the command. With -ErrorAction, it follows the action chosen instead: -ErrorAction SilentlyContinue skips that date and goes on with the next ones, and -ErrorVariable collects the errors. .OUTPUTS System.Boolean #> function Test-Workday { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [datetime]$Date = [datetime]::Today, [object[]]$Calendar ) begin { try { $resolved = Resolve-Calendar $Calendar } catch { $PSCmdlet.ThrowTerminatingError($_) } } process { # The date is checked first: a date outside the calendar is then reported without an exception $problem = $null try { $day = ConvertTo-CalendarDate $Date $problem = Get-CalendarCoverageError $resolved $day if (-not $problem) { Test-CalendarDay $resolved $day } } catch { $problem = $_ } if ($problem) { Write-CalendarError $PSCmdlet $problem } } } #endregion |