Public/Add-DhBullet.ps1

function Add-DhBullet {
    <#
    .SYNOPSIS
        Add a bullet-chart block (actual vs target with performance bands).

    .DESCRIPTION
        Renders one horizontal bullet bar per item — the canonical visualisation
        for "actual vs target with qualitative ranges" per the IT-infrastructure
        KPI dashboard specification (§2 Widget Types).

        Each bullet row shows:
          • a full-width track from Min to Max
          • optional coloured performance bands (RAG ranges)
          • a centred solid value bar from Min to Value
          • an optional vertical target marker
          • a readout (Value + Unit + optional 'target N') on the right

        Multiple items stack vertically inside a single block. Pair this with
        Add-DhSummary for high-level KPI tiles and use bullets for the
        actual-vs-SLA detail (latency vs target, headroom vs minimum, etc.).

    .PARAMETER Report
        Dashboard object from New-DhDashboard.

    .PARAMETER Id
        Unique identifier (alphanumeric, dash, underscore).

    .PARAMETER Title
        Block heading shown above the bullets.

    .PARAMETER Items
        Array of bullet definition hashtables:
          @{
              Label = 'DC01' # REQUIRED — left-side row label
              Value = 42 # REQUIRED — the actual numeric value
              Target = 50 # optional — vertical marker on the track
              Min = 0 # optional — track start (default 0)
              Max = 120 # optional — track end (default 100)
              Unit = 'ms' # optional — suffix in the readout
              Class = 'cell-warn' # optional — explicit value-bar colour;
                                        # wins over auto threshold match

              # ---- Performance bands (one of these) ----
              # Modern shape (v1.4+):
              Rag = @{
                  Green = @{ Max = 50 }
                  Amber = @{ Min = 50; Max = 80 }
                  Red = @{ Min = 80 }
                  NoData = $false
              }
              # OR legacy array shape (also accepted under alias 'Bands'):
              Thresholds = @(
                  @{ Max = 50; Class = 'cell-ok' }
                  @{ Min = 50; Max = 80; Class = 'cell-warn' }
                  @{ Min = 80; Class = 'cell-danger' }
              )
          }

        When both Rag and Thresholds (or Bands) are supplied, Rag wins.

    .PARAMETER NavGroup
        Primary nav group label (enables two-tier nav).

    .PARAMETER NavSubGroup
        Optional second-level group under NavGroup (enables three-tier nav).

    .EXAMPLE
        # Three rows sharing one RAG band definition
        $latencyRag = @{
            Green = @{ Max = 50 }
            Amber = @{ Min = 50; Max = 80 }
            Red = @{ Min = 80 }
        }
        Add-DhBullet -Report $report -Id 'auth-latency' -Title 'LDAP Bind Time' `
            -Items @(
                @{ Label='DC01'; Value=42; Target=50; Max=120; Unit='ms'; Rag=$latencyRag }
                @{ Label='DC02'; Value=68; Target=50; Max=120; Unit='ms'; Rag=$latencyRag }
                @{ Label='DC03'; Value=92; Target=50; Max=120; Unit='ms'; Rag=$latencyRag }
            )

    .EXAMPLE
        # Headroom — bigger is better, so flip the RAG semantics
        Add-DhBullet -Report $report -Id 'cpu-headroom' -Title 'CPU Headroom per Host' `
            -Items @(
                @{ Label='esxi-01'; Value=42; Target=30; Min=0; Max=100; Unit='%'
                   Rag=@{ Green=@{Min=30}; Amber=@{Min=15;Max=30}; Red=@{Max=15} }
                }
            )
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_-]+$')]
        [string] $Id,
        [Parameter(Mandatory)] [string] $Title,
        [Parameter(Mandatory)] [object[]] $Items,
        [string] $NavGroup    = '',
        [string] $NavSubGroup = ''
    )

    if (-not $Report.Contains('Blocks')) {
        $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
    }

    # Duplicate-Id guard (same rule applied by other block cmdlets)
    foreach ($existing in $Report.Blocks) {
        if ($existing.Id -eq $Id) {
            throw "Add-DhBullet: A block with Id '$Id' already exists in this report. Use a unique Id."
        }
    }

    # Helper — parse with InvariantCulture (see locale note in Add-DhSummary)
    function _tryNum {
        param([object] $v, [string] $label, [string] $ctx)
        $n = 0.0
        $ok = [double]::TryParse(
            [string]$v,
            [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [ref]$n)
        if (-not $ok) {
            throw "Add-DhBullet: $ctx — $label must be a number, got: $v"
        }
        return $n
    }

    $normItems = foreach ($item in $Items) {
        if ($item -isnot [hashtable] -and $item -isnot [System.Collections.Specialized.OrderedDictionary]) {
            throw "Add-DhBullet: each item must be a hashtable. Got: $($item.GetType().Name)"
        }
        if (-not $item.Contains('Label') -or [string]::IsNullOrWhiteSpace([string]$item['Label'])) {
            throw "Add-DhBullet: each item must have a non-empty 'Label' key."
        }
        if (-not $item.Contains('Value')) {
            throw "Add-DhBullet: each item must have a 'Value' key."
        }
        $b = @{} + $item
        $ctx = "bullet item '$($b.Label)'"

        # ---- Threshold band resolution (v1.4 F13) — done FIRST so the gauge-style
        # NoData escape hatch is known before the Value type check below.
        # Precedence: Rag > Thresholds > Bands (Bands is a documented alias).
        $b['RagNoData'] = $false
        if ($b.Contains('Rag') -and $null -ne $b['Rag']) {
            $rag = ConvertTo-DhRagThresholds -Rag $b['Rag'] -Context "Add-DhBullet $ctx"
            $b['Thresholds'] = $rag.Thresholds
            $b['RagNoData']  = $rag.NoData
            $b.Remove('Rag')
            if ($b.Contains('Bands')) { $b.Remove('Bands') }
        } elseif ($b.Contains('Thresholds') -and $b['Thresholds']) {
            if ($b.Contains('Bands')) { $b.Remove('Bands') }
        } elseif ($b.Contains('Bands') -and $b['Bands']) {
            $b['Thresholds'] = @($b['Bands'])
            $b.Remove('Bands')
        }
        if (-not $b.Contains('Thresholds')) { $b['Thresholds'] = @() }

        # Value — numeric, UNLESS Rag.NoData=$true was opted in (then $null is OK
        # and the row renders cell-nodata at 0% width). Mirrors Add-DhSummary gauge.
        $isMissingValue = ($null -eq $b['Value']) -or ([string]::IsNullOrWhiteSpace([string]$b['Value']))
        if ($isMissingValue -and $b['RagNoData']) {
            # accepted — leave Value as $null; JS renderer handles the nodata fallback
        } else {
            $b['Value'] = _tryNum $b['Value'] 'Value' $ctx
        }

        # Target — optional numeric
        if ($b.Contains('Target') -and $null -ne $b['Target']) {
            $b['Target'] = _tryNum $b['Target'] 'Target' $ctx
        } else {
            $b['Target'] = $null
        }

        # Min / Max — default 0..100
        if ($b.Contains('Min') -and $null -ne $b['Min']) { $b['Min'] = _tryNum $b['Min'] 'Min' $ctx } else { $b['Min'] = 0.0 }
        if ($b.Contains('Max') -and $null -ne $b['Max']) { $b['Max'] = _tryNum $b['Max'] 'Max' $ctx } else { $b['Max'] = 100.0 }
        if ($b['Max'] -le $b['Min']) {
            throw "Add-DhBullet: $ctx — Max ($($b.Max)) must be greater than Min ($($b.Min))."
        }

        # Unit / Class
        if (-not $b.Contains('Unit'))  { $b['Unit']  = '' }
        if (-not $b.Contains('Class')) { $b['Class'] = '' }

        # Validate / normalise the resulting threshold array
        $thOut = @()
        foreach ($th in @($b['Thresholds'])) {
            if ($th -isnot [hashtable] -and $th -isnot [System.Collections.Specialized.OrderedDictionary]) {
                throw "Add-DhBullet: $ctx — each Thresholds/Bands entry must be a hashtable. Got: $($th.GetType().Name)"
            }
            if (-not $th.Contains('Class') -or [string]::IsNullOrWhiteSpace([string]$th['Class'])) {
                throw "Add-DhBullet: $ctx — each Thresholds/Bands entry must have a non-empty Class key."
            }
            $row = @{ Class = [string]$th['Class'] }
            foreach ($bk in 'Min','Max') {
                if ($th.Contains($bk) -and $null -ne $th[$bk]) {
                    $row[$bk] = _tryNum $th[$bk] "threshold $bk" $ctx
                }
            }
            $thOut += $row
        }
        $b['Thresholds'] = @($thOut)

        $b
    }

    $Report.Blocks.Add([ordered]@{
        BlockType    = 'bullet'
        Id           = $Id
        Title        = $Title
        Items        = @($normItems)
        NavGroup     = $NavGroup
        NavSubGroup  = $NavSubGroup
    })
    Write-Verbose "Add-DhBullet: '$Id' ($(@($normItems).Count) row(s))."
}