Public/Get-AzLocalApplyUpdatesScheduleCycleCalendar.ps1

function Get-AzLocalApplyUpdatesScheduleCycleCalendar {
    <#
    .SYNOPSIS
        Projects an apply-updates-schedule.yml forward one full cycle
        (or any -Days window) and renders a human-readable per-day
        calendar of which UpdateRing(s) are eligible on each UTC day.
 
    .DESCRIPTION
        Step.3's "what is the upcoming cycle going to do?" advisor.
        Built on top of Get-AzLocalApplyUpdatesScheduleNextFirings so all
        ISO-8601 week math, cycle-anchor wrap arithmetic, UNION semantics
        across overlapping schedule rows, and AllowedUpdateVersions
        resolution flow through Resolve-AzLocalCurrentUpdateRing - no
        duplicate week-arithmetic code paths to drift.
 
        Works correctly for cycles of any length (4, 8, 13, 26, 52
        weeks) and across year boundaries (ISO weeks 52 / 53 / W1).
 
        Default projection horizon is one full cycle (CycleWeeks * 7
        days) starting today, which means an operator currently in week
        8 of an 8-week cycle sees week 8 (today + a few days), then the
        full new week-1-through-week-7 of the next cycle, exposing
        exactly when each ring will fire next.
 
        The IsCycleWrap flag and CycleWeekLabel ("X of N (cycle wraps)")
        make the moment of rotation back to the start of the cycle
        unambiguous in both the object output and the markdown table.
 
    .PARAMETER Schedule
        Parsed config object from Get-AzLocalApplyUpdatesScheduleConfig.
 
    .PARAMETER StartDate
        First UTC day to project. Default: today (UTC).
 
    .PARAMETER Days
        Number of consecutive UTC days to project. Default: one full
        cycle ($Schedule.CycleWeeks * 7). Range: 1..3650 so operators
        can ask for multi-cycle views.
 
    .PARAMETER AsMarkdown
        Switch. When set, returns a single [string] containing a fully
        rendered markdown report (heading + intro + calendar table, plus
        the per-ring projection section when -IncludePerRingSummary is
        also supplied). When not set, returns the per-day [PSCustomObject]
        pipeline for programmatic consumers.
 
    .PARAMETER IncludePerRingSummary
        Switch. When set together with -AsMarkdown, also emits a
        "### Per-ring projection" section that lists each ring referenced
        by the schedule with its next eligible UTC date and the full set
        of eligible dates within the projection horizon. Helpful for
        operators answering "when will my Prod ring fire next?".
 
    .PARAMETER ClusterRingCounts
        Optional hashtable mapping ring name (case-insensitive) to the
        count of clusters currently tagged with that UpdateRing. When
        supplied AND -AsMarkdown is set, the per-day calendar table and
        the per-ring projection table both gain a "Clusters in ring(s)"
        / "Cluster count" column so operators can see at a glance how
        many clusters each upcoming firing will touch. Pure cmdlet -
        callers (e.g. Test-AzLocalApplyUpdatesScheduleCoverage / Step.3
        yml) build this dictionary from their cluster CSV or live tag
        scan; the cmdlet itself does no Azure or CSV I/O.
 
    .OUTPUTS
        Default: [PSCustomObject[]], one per UTC day, with:
          DateUtc [datetime] - midnight UTC
          DayOfWeekName [string] - 'Sun'..'Sat'
          CycleWeek [int] - 1..CycleWeeks
          CycleWeeksTotal [int] - Schedule.CycleWeeks
          CycleWeekLabel [string] - 'X of N' (+ ' (cycle wraps)' on the wrap day)
          IsCycleWrap [bool] - true on day-of-week-Monday when CycleWeek rolls back to 1
          Rings [string[]] - UNION of all matching rows
          UpdateRingValue [string] - ';'-joined for display
          AllowedUpdateVersions [string[]] - effective allow-list ([] = no constraint / Latest)
          AllowedUpdateVersionsValue [string] - ';'-joined for display
          AllowedUpdateVersionsSource [string] - 'row' | 'top-level' | 'none'
          MatchedRowCount [int]
          IsDeadDay [bool] - true when Rings is empty
          Reason [string] - Resolve-AzLocalCurrentUpdateRing reason
 
        With -AsMarkdown: [string] containing the rendered markdown
        report.
 
    .EXAMPLE
        $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path .\.github\apply-updates-schedule.yml
        Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg | Format-Table -AutoSize
 
    .EXAMPLE
        # Render the markdown for a pipeline step summary (the Step.3 use case)
        $md = Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg -AsMarkdown -IncludePerRingSummary
        $md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
 
    .EXAMPLE
        # Three full cycles ahead for a 13-week quarterly rotation
        Get-AzLocalApplyUpdatesScheduleCycleCalendar -Schedule $cfg -Days ($cfg.CycleWeeks * 7 * 3) |
            Where-Object IsDeadDay | Format-Table DateUtc, DayOfWeekName, CycleWeek
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]], [string])]
    param(
        [Parameter(Mandatory = $true)]
        [PSCustomObject]$Schedule,

        [Parameter(Mandatory = $false)]
        [datetime]$StartDate = ([datetime]::UtcNow.Date),

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 3650)]
        [int]$Days = 0,

        [Parameter(Mandatory = $false)]
        [switch]$AsMarkdown,

        [Parameter(Mandatory = $false)]
        [switch]$IncludePerRingSummary,

        [Parameter(Mandatory = $false)]
        [hashtable]$ClusterRingCounts
    )

    Set-StrictMode -Version Latest

    $cycleWeeks = [int]$Schedule.CycleWeeks
    if ($cycleWeeks -lt 1) {
        throw "Get-AzLocalApplyUpdatesScheduleCycleCalendar: Schedule.CycleWeeks must be >= 1. Got: $cycleWeeks."
    }
    if ($Days -eq 0) { $Days = $cycleWeeks * 7 }

    $start = [datetime]::SpecifyKind($StartDate.Date, [DateTimeKind]::Utc)

    # Re-use the existing day-iterator so we inherit all of
    # Resolve-AzLocalCurrentUpdateRing's correctness (ISO week math,
    # anchor wrap across years, AllowedUpdateVersions resolution).
    # Get-AzLocalApplyUpdatesScheduleNextFirings caps -Days at 366; for
    # longer horizons we iterate in chunks.
    $rawRows = New-Object System.Collections.Generic.List[psobject]
    $cursor  = $start
    $remain  = $Days
    while ($remain -gt 0) {
        $chunk = if ($remain -gt 366) { 366 } else { $remain }
        $batch = Get-AzLocalApplyUpdatesScheduleNextFirings -Schedule $Schedule -StartDate $cursor -Days $chunk
        foreach ($r in @($batch)) { [void]$rawRows.Add($r) }
        $cursor = $cursor.AddDays($chunk)
        $remain -= $chunk
    }

    # Enrich each day with CycleWeekLabel, IsCycleWrap, IsDeadDay, and
    # the allow-list trio that NextFirings does not project today. For
    # the allow-list we call Resolve-AzLocalCurrentUpdateRing once per
    # day (NextFirings already does this internally; calling again is
    # cheap and keeps the enrichment local to this cmdlet without
    # changing NextFirings' contract).
    $rows = New-Object System.Collections.Generic.List[psobject]
    $prevCycleWeek = $null
    foreach ($r in $rawRows) {
        $moment = ([datetime]::SpecifyKind($r.DateUtc.Date, [DateTimeKind]::Utc)).AddHours(12)
        $resolved = Resolve-AzLocalCurrentUpdateRing -Schedule $Schedule -Now $moment
        # IsCycleWrap = the first day on which CycleWeek rolls back to
        # 1 after being at CycleWeeksTotal. For a 1-week "cycle" this
        # is true every Monday; for an N-week cycle it is true once per
        # cycle on the day-of-week when ISO-week increments from
        # anchor+N-1 to anchor+N (canonically a Monday in ISO-8601).
        $isWrap = ($null -ne $prevCycleWeek -and $r.CycleWeek -eq 1 -and $prevCycleWeek -eq $cycleWeeks)
        $label  = if ($isWrap) { "**1** of $cycleWeeks _(cycle wraps)_" } else { "$($r.CycleWeek) of $cycleWeeks" }
        $rings  = @($r.Rings)
        $rows.Add([pscustomobject]@{
            DateUtc                     = $r.DateUtc
            DayOfWeekName               = $r.DayOfWeekName
            CycleWeek                   = $r.CycleWeek
            CycleWeeksTotal             = $cycleWeeks
            CycleWeekLabel              = $label
            IsCycleWrap                 = $isWrap
            Rings                       = $rings
            UpdateRingValue             = $r.UpdateRingValue
            AllowedUpdateVersions       = @($resolved.AllowedUpdateVersions)
            AllowedUpdateVersionsValue  = $resolved.AllowedUpdateVersionsValue
            AllowedUpdateVersionsSource = $resolved.AllowedUpdateVersionsSource
            MatchedRowCount             = $r.MatchedRowCount
            IsDeadDay                   = ($rings.Count -eq 0)
            Reason                      = $resolved.Reason
        }) | Out-Null
        $prevCycleWeek = $r.CycleWeek
    }

    if (-not $AsMarkdown) {
        return $rows.ToArray()
    }

    # Normalise the optional ring -> count map to case-insensitive lookup
    # so callers can pass any casing (tag values are not consistent in
    # the wild). $hasCounts gates the extra column in the markdown.
    $ringCounts = $null
    $hasCounts  = $false
    if ($PSBoundParameters.ContainsKey('ClusterRingCounts') -and $ClusterRingCounts) {
        $ringCounts = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($k in $ClusterRingCounts.Keys) {
            if ($null -eq $k) { continue }
            $kn = [string]$k
            if ([string]::IsNullOrWhiteSpace($kn)) { continue }
            $vRaw = $ClusterRingCounts[$k]
            $vInt = 0
            if ($null -ne $vRaw -and [int]::TryParse([string]$vRaw, [ref]$vInt)) { } else { $vInt = 0 }
            if (-not $ringCounts.ContainsKey($kn.Trim())) { $ringCounts[$kn.Trim()] = $vInt }
        }
        $hasCounts = ($ringCounts.Count -gt 0)
    }

    # ---- Markdown rendering ----------------------------------------
    $sb = New-Object System.Text.StringBuilder
    $endDate = $start.AddDays($Days - 1)
    [void]$sb.AppendLine("## Cycle calendar - next $Days day(s) (cycle length: $cycleWeeks week(s))")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Projection horizon.** ``$($start.ToString('yyyy-MM-dd'))`` -> ``$($endDate.ToString('yyyy-MM-dd'))`` (UTC). For each UTC day the table below lists which ``UpdateRing`` value(s) ``Resolve-AzLocalCurrentUpdateRing`` would return given your schedule file. Days where multiple schedule rows match are UNIONed (all eligible rings shown, deduplicated). The ``AllowedUpdateVersions`` column shows the effective allow-list for that day (empty = no constraint - install the latest Ready update).")
    [void]$sb.AppendLine()
    if ($Days -ge $cycleWeeks * 7) {
        [void]$sb.AppendLine("**Why this is one full cycle.** Your schedule declares ``cycleWeeks: $cycleWeeks``, so the projection covers $($cycleWeeks * 7) day(s). If today is partway through the current cycle (for example week $cycleWeeks of a $cycleWeeks-week cycle), the table starts on today's CycleWeek and rolls through the start of the next cycle - so you can see exactly when each ring will next fire. The ``(cycle wraps)`` annotation marks the row on which the rotation resets to week 1.")
        [void]$sb.AppendLine()
    }
    if ($hasCounts) {
        [void]$sb.AppendLine('| Date (UTC) | Day | CycleWeek | Eligible rings | Clusters in ring(s) | AllowedUpdateVersions |')
        [void]$sb.AppendLine('|---|---|---|---|---|---|')
    } else {
        [void]$sb.AppendLine('| Date (UTC) | Day | CycleWeek | Eligible rings | AllowedUpdateVersions |')
        [void]$sb.AppendLine('|---|---|---|---|---|')
    }
    foreach ($r in $rows) {
        $ringsCell = if ($r.Rings -and @($r.Rings).Count -gt 0) {
            (@($r.Rings) | ForEach-Object { '`' + $_ + '`' }) -join ', '
        } else {
            '_(none - dead day)_'
        }
        $allowCell = if (@($r.AllowedUpdateVersions).Count -gt 0) {
            (@($r.AllowedUpdateVersions) | ForEach-Object { '`' + $_ + '`' }) -join ', '
        } else {
            '_(no constraint)_'
        }
        if ($hasCounts) {
            $countCell = if ($r.Rings -and @($r.Rings).Count -gt 0) {
                $parts = @()
                $total = 0
                foreach ($rg in @($r.Rings)) {
                    $cnt = 0
                    if ($ringCounts.ContainsKey($rg)) { $cnt = $ringCounts[$rg] }
                    $parts += "``$rg``: $cnt"
                    $total += $cnt
                }
                if (@($r.Rings).Count -gt 1) {
                    (($parts -join ', ') + " (total: $total)")
                } else {
                    ($parts -join ', ')
                }
            } else {
                '_(0 - dead day)_'
            }
            [void]$sb.AppendLine("| $($r.DateUtc.ToString('yyyy-MM-dd')) | $($r.DayOfWeekName) | $($r.CycleWeekLabel) | $ringsCell | $countCell | $allowCell |")
        } else {
            [void]$sb.AppendLine("| $($r.DateUtc.ToString('yyyy-MM-dd')) | $($r.DayOfWeekName) | $($r.CycleWeekLabel) | $ringsCell | $allowCell |")
        }
    }
    [void]$sb.AppendLine()

    if ($IncludePerRingSummary) {
        # Build the universe of rings: UNION of all rings the schedule
        # ever references (so we surface "this ring is configured but
        # never fires in the horizon" as a dead-ring signal too).
        $allRings = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($srow in @($Schedule.Schedule)) {
            foreach ($rg in ($srow.rings -split ';')) {
                $tg = $rg.Trim()
                if (-not [string]::IsNullOrWhiteSpace($tg)) { [void]$allRings.Add($tg) }
            }
        }

        # Index ring -> sorted list of eligible dates within the horizon.
        $ringToDates = @{}
        foreach ($ring in $allRings) { $ringToDates[$ring] = New-Object System.Collections.Generic.List[datetime] }
        foreach ($r in $rows) {
            foreach ($rg in @($r.Rings)) {
                if ($ringToDates.ContainsKey($rg)) {
                    [void]$ringToDates[$rg].Add($r.DateUtc)
                } else {
                    # Ring appeared in resolver output but not in schedule
                    # ring set (case-insensitive miss?). Add defensively.
                    $ringToDates[$rg] = New-Object System.Collections.Generic.List[datetime]
                    [void]$ringToDates[$rg].Add($r.DateUtc)
                }
            }
        }

        [void]$sb.AppendLine('### Per-ring projection (next eligible UTC dates within the horizon)')
        [void]$sb.AppendLine()
        [void]$sb.AppendLine("**What this shows.** One row per ``UpdateRing`` referenced anywhere in your schedule. ``Next eligible`` is the soonest UTC date in the horizon when ``Resolve-AzLocalCurrentUpdateRing`` returns that ring. ``All eligible dates`` is the full list (deduplicated, ordered). A ring with ``(none in horizon)`` is configured in the schedule but does not fire within the next $Days day(s) - usually because its ``weeksInCycle`` does not intersect the horizon (e.g. a Prod ring scheduled for weeks 5-8 of an 8-week cycle, when today is still in week 1).")
        [void]$sb.AppendLine()
        if ($hasCounts) {
            [void]$sb.AppendLine('| UpdateRing | Cluster count | Next eligible | Eligible days (count) | All eligible dates |')
            [void]$sb.AppendLine('|---|---|---|---|---|')
        } else {
            [void]$sb.AppendLine('| UpdateRing | Next eligible | Eligible days (count) | All eligible dates |')
            [void]$sb.AppendLine('|---|---|---|---|')
        }
        foreach ($ring in ($ringToDates.Keys | Sort-Object)) {
            $dates = @($ringToDates[$ring])
            $clusterCountCell = if ($hasCounts) {
                $cv = 0
                if ($ringCounts.ContainsKey($ring)) { $cv = $ringCounts[$ring] }
                $cv.ToString()
            } else { $null }
            if ($dates.Count -eq 0) {
                if ($hasCounts) {
                    [void]$sb.AppendLine("| ``$ring`` | $clusterCountCell | _(none in horizon)_ | 0 | - |")
                } else {
                    [void]$sb.AppendLine("| ``$ring`` | _(none in horizon)_ | 0 | - |")
                }
                continue
            }
            $next  = $dates[0].ToString('yyyy-MM-dd')
            $count = $dates.Count
            $list  = ($dates | ForEach-Object { $_.ToString('yyyy-MM-dd') }) -join ', '
            if ($hasCounts) {
                [void]$sb.AppendLine("| ``$ring`` | $clusterCountCell | $next | $count | $list |")
            } else {
                [void]$sb.AppendLine("| ``$ring`` | $next | $count | $list |")
            }
        }
        [void]$sb.AppendLine()
    }

    return $sb.ToString()
}