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.
 
    .PARAMETER CronFiringsByDate
        Optional hashtable mapping 'yyyy-MM-dd' (UTC) date strings to a
        sorted, deduplicated [string[]] of 'HH:mm' UTC firing times that
        the Step.6 apply-updates pipeline cron(s) will produce on that
        date. When supplied AND -AsMarkdown is set, the per-day calendar
        table gains a centered "Ring CRON Start Time (Step 6 pipeline)"
        column immediately after "Date (UTC)". The cell renders up to 2
        firing times verbatim; any additional firings beyond the first 2
        are summarised as "(+N)" (e.g. "02:00, 04:00 (+1)" when there
        are 3 firings). Days with no entry in the hashtable, no firings
        for the day, or that are "dead days" (no eligible rings) render
        as "_(none)_". Pure render-time - this cmdlet does no YAML I/O;
        callers (e.g. Export-AzLocalApplyUpdatesScheduleAudit) build the
        dictionary from `Read-AzLocalApplyUpdatesYamlCrons` +
        `ConvertFrom-AzLocalCronExpression`.
 
    .PARAMETER WindowMatchByRingAndDate
        Optional nested hashtable indicating, per (UpdateRing, UTC date)
        pair, what fraction of the clusters tagged with that ring have
        an `UpdateStartWindow` tag that covers AT LEAST ONE Step.6 cron
        firing on that date. Shape:
 
            @{
                'Cdn' = @{
                    '2026-06-15' = @{ Matching = 29; Total = 30 }
                    '2026-06-22' = @{ Matching = 28; Total = 30 }
                }
                'Prod' = @{
                    '2026-06-15' = @{ Matching = 1; Total = 40 }
                }
            }
 
        Ring keys are case-insensitive; the inner per-date hashtable
        keys are 'yyyy-MM-dd' UTC; the leaf hashtables MUST include
        integer-coercible 'Matching' and 'Total' values. 'Pct' is
        computed when not supplied (Matching/Total, with Total=0 short-
        circuiting to a "(0 clusters)" cell). When supplied AND
        -AsMarkdown is set, the per-day calendar table gains a "Tag
        Start Window Match (>=95%)" column immediately after "Eligible
        rings". When multiple rings are eligible on a single date, the
        cell shows a per-ring breakdown: ``Cdn``: True 29/30 (97%);
        ``Prod``: False 1/40 (3%). "True" iff Matching/Total >= 0.95.
        Dead-day rows and rings missing from the hashtable render as
        "_(n/a)_". Same pure-render contract as -CronFiringsByDate;
        callers compute the dictionary from the cluster CSV they already
        load and the cron firings already parsed for -CronFiringsByDate.
 
    .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,

        [Parameter(Mandatory = $false)]
        [hashtable]$CronFiringsByDate,

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

    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)
    }

    # Normalise the optional cron-firings-by-date map. Keys are
    # 'yyyy-MM-dd' strings; values are sorted, deduplicated 'HH:mm' UTC
    # firing times. $hasCronFirings gates the new "Ring CRON Start
    # Time" column in the per-day calendar markdown.
    $cronFirings    = $null
    $hasCronFirings = $false
    if ($PSBoundParameters.ContainsKey('CronFiringsByDate') -and $CronFiringsByDate) {
        $cronFirings = New-Object 'System.Collections.Generic.Dictionary[string,string[]]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($k in $CronFiringsByDate.Keys) {
            if ($null -eq $k) { continue }
            $kn = ([string]$k).Trim()
            if ([string]::IsNullOrWhiteSpace($kn)) { continue }
            $raw = $CronFiringsByDate[$k]
            if ($null -eq $raw) { continue }
            $arr = @($raw | ForEach-Object { if ($null -ne $_) { ([string]$_).Trim() } } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
            if ($arr.Count -eq 0) { continue }
            $sorted = [string[]]($arr | Sort-Object -Unique)
            if (-not $cronFirings.ContainsKey($kn)) { $cronFirings[$kn] = $sorted }
        }
        $hasCronFirings = ($cronFirings.Count -gt 0)
    }

    # Normalise the optional window-match map. Outer keys are ring
    # names (case-insensitive); inner keys are 'yyyy-MM-dd' UTC dates;
    # leaf hashtables MUST include 'Matching' and 'Total' (integers).
    # 'Pct' is computed if not supplied. $hasWindowMatch gates the new
    # "Tag Start Window Match (>=95%)" column.
    $windowMatch    = $null
    $hasWindowMatch = $false
    if ($PSBoundParameters.ContainsKey('WindowMatchByRingAndDate') -and $WindowMatchByRingAndDate) {
        $windowMatch = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[string,hashtable]]' ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($rk in $WindowMatchByRingAndDate.Keys) {
            if ($null -eq $rk) { continue }
            $rn = ([string]$rk).Trim()
            if ([string]::IsNullOrWhiteSpace($rn)) { continue }
            $perDateRaw = $WindowMatchByRingAndDate[$rk]
            if ($null -eq $perDateRaw -or -not ($perDateRaw -is [hashtable])) { continue }
            $perDate = New-Object 'System.Collections.Generic.Dictionary[string,hashtable]' ([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($dk in $perDateRaw.Keys) {
                if ($null -eq $dk) { continue }
                $dn = ([string]$dk).Trim()
                if ([string]::IsNullOrWhiteSpace($dn)) { continue }
                $leafRaw = $perDateRaw[$dk]
                if ($null -eq $leafRaw -or -not ($leafRaw -is [hashtable])) { continue }
                $matchingI = 0
                $totalI    = 0
                if ($leafRaw.ContainsKey('Matching')) { [void][int]::TryParse([string]$leafRaw['Matching'], [ref]$matchingI) }
                if ($leafRaw.ContainsKey('Total'))    { [void][int]::TryParse([string]$leafRaw['Total'],    [ref]$totalI) }
                $pctD = 0.0
                if ($leafRaw.ContainsKey('Pct') -and $null -ne $leafRaw['Pct']) {
                    [void][double]::TryParse([string]$leafRaw['Pct'], [ref]$pctD)
                } elseif ($totalI -gt 0) {
                    $pctD = [double]$matchingI / [double]$totalI
                }
                if (-not $perDate.ContainsKey($dn)) {
                    $perDate[$dn] = @{ Matching = $matchingI; Total = $totalI; Pct = $pctD }
                }
            }
            if ($perDate.Count -gt 0 -and -not $windowMatch.ContainsKey($rn)) { $windowMatch[$rn] = $perDate }
        }
        $hasWindowMatch = ($windowMatch.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()
    }
    # Header pieces: build incrementally so a future 3rd optional
    # column slots in cleanly. Order MUST be:
    # Date | [Cron firings] | Day | CycleWeek | Eligible rings | [Window match] | [Cluster counts] | AllowedUpdateVersions
    $headerCells = New-Object System.Collections.Generic.List[string]
    $alignCells  = New-Object System.Collections.Generic.List[string]
    [void]$headerCells.Add('Date (UTC)'); [void]$alignCells.Add('---')
    if ($hasCronFirings) {
        [void]$headerCells.Add('Ring CRON Start Time<br>(Step 6 pipeline)')
        [void]$alignCells.Add(':---:')
    }
    [void]$headerCells.Add('Day');             [void]$alignCells.Add('---')
    [void]$headerCells.Add('CycleWeek');       [void]$alignCells.Add('---')
    [void]$headerCells.Add('Eligible rings');  [void]$alignCells.Add('---')
    if ($hasWindowMatch) {
        [void]$headerCells.Add('Tag Start Window Match (>=95%)')
        [void]$alignCells.Add('---')
    }
    if ($hasCounts) {
        [void]$headerCells.Add('Clusters in ring(s)')
        [void]$alignCells.Add('---')
    }
    [void]$headerCells.Add('AllowedUpdateVersions'); [void]$alignCells.Add('---')
    [void]$sb.AppendLine('| ' + ($headerCells -join ' | ') + ' |')
    [void]$sb.AppendLine('|' + (($alignCells | ForEach-Object { $_ }) -join '|') + '|')
    foreach ($r in $rows) {
        $dateKey = $r.DateUtc.ToString('yyyy-MM-dd')
        $isDead  = (-not $r.Rings -or @($r.Rings).Count -eq 0)
        $ringsCell = if (-not $isDead) {
            (@($r.Rings) | ForEach-Object { '`' + $_ + '`' }) -join ', '
        } else {
            '_(none - dead day)_'
        }
        $allowCell = if (@($r.AllowedUpdateVersions).Count -gt 0) {
            (@($r.AllowedUpdateVersions) | ForEach-Object { '`' + $_ + '`' }) -join ', '
        } else {
            '_(no constraint)_'
        }
        $cronCell = $null
        if ($hasCronFirings) {
            if ($isDead) {
                $cronCell = '_(none - dead day)_'
            } else {
                $fires = @()
                if ($cronFirings.ContainsKey($dateKey)) { $fires = @($cronFirings[$dateKey]) }
                if ($fires.Count -eq 0) {
                    $cronCell = '_(none)_'
                } elseif ($fires.Count -le 2) {
                    $cronCell = ($fires -join ', ')
                } else {
                    $extra = $fires.Count - 2
                    $cronCell = (($fires | Select-Object -First 2) -join ', ') + " (+$extra)"
                }
            }
        }
        $matchCell = $null
        if ($hasWindowMatch) {
            if ($isDead) {
                $matchCell = '_(n/a - dead day)_'
            } else {
                $parts = @()
                foreach ($rg in @($r.Rings)) {
                    $perDate = $null
                    if ($windowMatch.ContainsKey($rg)) { $perDate = $windowMatch[$rg] }
                    if ($null -eq $perDate -or -not $perDate.ContainsKey($dateKey)) {
                        $parts += "``$rg``: _(n/a)_"
                        continue
                    }
                    $leaf = $perDate[$dateKey]
                    $tot  = [int]$leaf['Total']
                    $mat  = [int]$leaf['Matching']
                    if ($tot -le 0) {
                        $parts += "``$rg``: _(0 clusters)_"
                        continue
                    }
                    $pct  = [double]$leaf['Pct']
                    $isOk = ($pct -ge 0.95)
                    $pctTxt = [int][math]::Round($pct * 100)
                    $parts += "``$rg``: $isOk $mat/$tot ($pctTxt%)"
                }
                $matchCell = ($parts -join '; ')
            }
        }
        $countCell = $null
        if ($hasCounts) {
            $countCell = if (-not $isDead) {
                $cparts = @()
                $total = 0
                foreach ($rg in @($r.Rings)) {
                    $cnt = 0
                    if ($ringCounts.ContainsKey($rg)) { $cnt = $ringCounts[$rg] }
                    $cparts += "``$rg``: $cnt"
                    $total += $cnt
                }
                if (@($r.Rings).Count -gt 1) {
                    (($cparts -join ', ') + " (total: $total)")
                } else {
                    ($cparts -join ', ')
                }
            } else {
                '_(0 - dead day)_'
            }
        }
        # Assemble cells in the same order as the header.
        $rowCells = New-Object System.Collections.Generic.List[string]
        [void]$rowCells.Add($dateKey)
        if ($hasCronFirings) { [void]$rowCells.Add($cronCell) }
        [void]$rowCells.Add([string]$r.DayOfWeekName)
        [void]$rowCells.Add([string]$r.CycleWeekLabel)
        [void]$rowCells.Add($ringsCell)
        if ($hasWindowMatch) { [void]$rowCells.Add($matchCell) }
        if ($hasCounts)      { [void]$rowCells.Add($countCell) }
        [void]$rowCells.Add($allowCell)
        [void]$sb.AppendLine('| ' + ($rowCells -join ' | ') + ' |')
    }
    [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()
}