Public/Get-BCRecurrence.ps1

function Get-BCRecurrence {
    <#
    .SYNOPSIS
    Expands a civil-date recurrence within explicit bounds.
    .DESCRIPTION
    This bounded helper is not an RFC 5545 implementation. From and To are inclusive; MaxOccurrences is required and expansion errors rather than silently truncating.
    .PARAMETER Pattern
    Recurrence shape to expand.
    .PARAMETER From
    Inclusive first eligible civil date and recurrence anchor.
    .PARAMETER To
    Inclusive final civil date.
    .PARAMETER MaxOccurrences
    Positive hard limit; exceeding it raises an error.
    .PARAMETER Interval
    Positive day, week, or month interval for interval-based patterns.
    .PARAMETER Day
    Requested day of month for MonthlyDay or Annual.
    .PARAMETER Month
    Month number for Annual.
    .PARAMETER Ordinal
    Weekday ordinal from 1 through 5 for NthWeekday.
    .PARAMETER Weekday
    One or more weekday names for weekly and weekday-based rules.
    .PARAMETER InvalidDayPolicy
    Behavior when a monthly or annual requested day is unavailable.
    .EXAMPLE
    Get-BCRecurrence -Pattern Annual -From '2024-02-29' -To '2028-12-31' -MaxOccurrences 10 -Month 2 -Day 29 -InvalidDayPolicy Clamp
    .INPUTS
    None.
    .OUTPUTS
    System.DateTime values with DateTimeKind Unspecified.
    .NOTES
    Every expansion has explicit date bounds and a positive occurrence cap. This is not RFC 5545.
    .LINK
    Get-BCDateRange
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('Daily','Weekly','MonthlyDay','NthWeekday','LastWeekday','Annual')][string]$Pattern,
        [Parameter(Mandatory)][datetime]$From,
        [Parameter(Mandatory)][datetime]$To,
        [Parameter(Mandatory)][ValidateRange(1,1000000)][int]$MaxOccurrences,
        [ValidateRange(1,1000000)][int]$Interval=1,
        [ValidateRange(1,31)][int]$Day,
        [ValidateRange(1,12)][int]$Month,
        [ValidateRange(1,5)][int]$Ordinal,
        [ValidateSet('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')][string[]]$Weekday,
        [ValidateSet('Skip','Clamp','Error')][string]$InvalidDayPolicy='Skip'
    )
    $start=[datetime]::SpecifyKind($From.Date,[DateTimeKind]::Unspecified)
    $end=[datetime]::SpecifyKind($To.Date,[DateTimeKind]::Unspecified)
    if ($end -lt $start) { throw 'To must be on or after From.' }
    if ($Pattern -in @('MonthlyDay','Annual') -and -not $PSBoundParameters.ContainsKey('Day')) { throw "-Day is required for $Pattern." }
    if ($Pattern -eq 'Annual' -and -not $PSBoundParameters.ContainsKey('Month')) { throw '-Month is required for Annual.' }
    if ($Pattern -in @('NthWeekday','LastWeekday') -and -not $PSBoundParameters.ContainsKey('Weekday')) { throw "-Weekday is required for $Pattern." }
    if ($Pattern -eq 'NthWeekday' -and -not $PSBoundParameters.ContainsKey('Ordinal')) { throw '-Ordinal is required for NthWeekday.' }
    $dates = [Collections.Generic.List[datetime]]::new()
    $firstMonth = [datetime]::new($start.Year,$start.Month,1)
    for ($cursor=$start; $cursor -le $end;) {
        $candidate=$false
        switch ($Pattern) {
            Daily { $candidate=(([int]($cursor-$start).TotalDays % $Interval) -eq 0) }
            Weekly {
                $weekStart = $start.AddDays(-(([int]$start.DayOfWeek - (ConvertTo-BCWeekdayNumber 'Monday') + 7) % 7))
                $cursorWeek = $cursor.AddDays(-(([int]$cursor.DayOfWeek - (ConvertTo-BCWeekdayNumber 'Monday') + 7) % 7))
                $weeks=[int](($cursorWeek-$weekStart).TotalDays/7)
                $candidate=($weeks % $Interval -eq 0) -and $cursor.DayOfWeek -eq $start.DayOfWeek
                if ($PSBoundParameters.ContainsKey('Weekday')) { $candidate=($weeks % $Interval -eq 0) -and ([string]$cursor.DayOfWeek -in $Weekday) }
            }
            MonthlyDay {
                $months=(($cursor.Year-$firstMonth.Year)*12 + $cursor.Month-$firstMonth.Month)
                if ($months % $Interval -eq 0) { $actual=Get-BCDayOfMonth -Year $cursor.Year -Month $cursor.Month -Day $Day -Policy $InvalidDayPolicy; $candidate=$actual -gt 0 -and $cursor.Day -eq $actual }
            }
            Annual {
                if ($cursor.Month -eq $Month) { $actual=Get-BCDayOfMonth -Year $cursor.Year -Month $Month -Day $Day -Policy $InvalidDayPolicy; $candidate=$actual -gt 0 -and $cursor.Day -eq $actual }
            }
            NthWeekday {
                if ([string]$cursor.DayOfWeek -in $Weekday) { $candidate=[int][math]::Floor(($cursor.Day-1)/7)+1 -eq $Ordinal }
            }
            LastWeekday {
                if ([string]$cursor.DayOfWeek -in $Weekday) { $candidate=($cursor.Day + 7) -gt [datetime]::DaysInMonth($cursor.Year,$cursor.Month) }
            }
        }
        if ($candidate) {
            if ($dates.Count -ge $MaxOccurrences) { throw "The recurrence exceeds MaxOccurrences ($MaxOccurrences)." }
            $dates.Add($cursor)
        }
        if ($cursor -eq $end) { break }
        $cursor=$cursor.AddDays(1)
    }
    $dates.ToArray()
}