Public/Add-DhSummary.ps1

function Add-DhSummary {
    <#
    .SYNOPSIS
        Add a row of KPI metric tiles at the top of the dashboard body.

    .DESCRIPTION
        Renders a horizontal strip of summary cards. Each tile shows an optional
        icon, a formatted value, a label, and an optional sub-label. Tiles can
        be coloured with the same threshold classes used on table cells.

        By default the strip is wrapped in a collapsible container (same chrome
        as Add-DhCollapsible) with a clickable header bar — handy when the
        dashboard has many tiles and you want to give viewers the option to
        fold them away. Pass -Collapsible:$false to render a flat strip with
        no header.

    .PARAMETER Report Dashboard object from New-DhDashboard.

    .PARAMETER Items
        Array of tile definition hashtables:
          @{
              Label = 'Caption text' # REQUIRED
              Value = 42 # REQUIRED — the main metric
              Icon = 'X' # optional emoji / unicode
              SubLabel = 'of 100 total' # optional small text below label
              Class = 'cell-danger' # optional: cell-ok | cell-warn | cell-danger
              Format = 'number' # optional: same as column Format values
              Locale = 'en-US' # optional BCP-47 locale
              Decimals = 0 # optional decimal places
              Currency = 'USD' # optional: for Format='currency'

              # ---- Trend / delta indicator (v1.4+) — optional ----
              Previous = 219 # baseline for auto-computed delta vs Value
              Trend = 'up' # 'auto' (default) | 'up' | 'down' | 'flat'
              TrendIsGood = $true # $true=force green, $false=force red, omit=semantic default
              DeltaFormat = 'both' # 'percent' | 'absolute' | 'both' (default)

              # ---- Sparkline (v1.4+) — optional inline mini-chart below the value ----
              Sparkline = @(12,18,15,22,19,24,31) # raw numeric series
              SparklineMin = 0 # optional Y-axis min (default: series min)
              SparklineMax = 100 # optional Y-axis max (default: series max)
              SparklineStyle = 'line' # 'line' (default) | 'area' | 'bars'
          }

    .PARAMETER Collapsible
        Wrap the summary tile strip in a collapsible container with a header
        bar (same look & feel as Add-DhCollapsible). Defaults to $true. Pass
        -Collapsible:$false for a flat strip with no header.

    .PARAMETER Title
        Header text for the collapsible container. Defaults to 'Summary'.
        Only used when -Collapsible is $true.

    .PARAMETER Icon
        Optional emoji / unicode icon shown before the collapsible header
        title. Only used when -Collapsible is $true.

    .PARAMETER DefaultOpen
        Start expanded (default $true). Only used when -Collapsible is $true.

    .EXAMPLE
        Add-DhSummary -Report $report -Items @(
            @{ Label='Total Items'; Value=247; Icon='X' }
            @{ Label='Active'; Value=231; Icon='Y'; Class='cell-ok' }
            @{ Label='Warnings'; Value=12; Icon='W'; Class='cell-warn' }
            @{ Label='Critical'; Value=4; Icon='E'; Class='cell-danger' }
            @{ Label='Monthly Cost'; Value=12450.75;
               Format='currency'; Locale='en-US'; Currency='USD'; Decimals=2 }
            @{ Label='Total Storage'; Value=1610612736; Format='bytes' }
            @{ Label='Avg Uptime'; Value=99.87;
               Format='percent'; Locale='en-US'; Decimals=2 }
        )

    .EXAMPLE
        # Custom collapsible header — fold away initially
        Add-DhSummary -Report $report -Title 'Key metrics' -Icon 'K' -DefaultOpen $false `
            -Items @(
                @{ Label='Total'; Value=247 }
                @{ Label='Active'; Value=231; Class='cell-ok' }
            )

    .EXAMPLE
        # Opt out of the collapsible chrome — flat tile strip (pre-1.3.2 behaviour)
        Add-DhSummary -Report $report -Collapsible:$false -Items @(
            @{ Label='Total'; Value=247 }
        )
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report,
        [Parameter(Mandatory)] [object[]]  $Items,
        [bool]     $Collapsible = $true,
        [string]   $Title       = 'Summary',
        [string]   $Icon        = '',
        [bool]     $DefaultOpen = $true,

        # ---- v1.4.1 — per-NavGroup summary ----
        # When -NavGroup is set, the strip lives inside the per-panel block
        # flow (hidden / shown by the existing two-tier nav routing) rather
        # than at the top of the page. Each NavGroup can have its own strip
        # with its own tiles. Without -NavGroup, the legacy top-of-page
        # singleton behaviour is preserved.
        [string]   $NavGroup    = '',
        [string]   $NavSubGroup = '',
        # Block Id used when -NavGroup is set (auto-derived from the group name
        # when omitted). Ignored when there's no NavGroup.
        [ValidatePattern('^$|^[A-Za-z0-9_-]+$')]
        [string]   $Id          = ''
    )

    $replacing = $Report.Contains('Summary')

    $normItems = foreach ($item in $Items) {
        if ($item -isnot [hashtable] -and $item -isnot [System.Collections.Specialized.OrderedDictionary]) {
            throw "Add-DhSummary: Each item must be a hashtable. Got: $($item.GetType().Name)"
        }
        if (-not $item.Contains('Label') -or $null -eq $item['Label']) {
            throw "Add-DhSummary: Each item must have a 'Label' key."
        }
        if (-not $item.Contains('Value')) {
            throw "Add-DhSummary: Each item must have a 'Value' key."
        }
        $t = @{} + $item
        if (-not $t.Contains('Icon'))     { $t['Icon']     = '' }
        if (-not $t.Contains('SubLabel')) { $t['SubLabel'] = '' }
        if (-not $t.Contains('Class'))    { $t['Class']    = '' }
        if (-not $t.Contains('Format'))   { $t['Format']   = '' }
        if (-not $t.Contains('Locale'))   { $t['Locale']   = '' }
        if (-not $t.Contains('Decimals')) { $t['Decimals'] = -1 }
        if (-not $t.Contains('Currency')) { $t['Currency'] = '' }

        # ---- Per-tile Action (v1.4.2 — click-to-jump) ----
        # Optional Action=@{TableId;Filter?;Url?;Label?} makes the whole tile
        # clickable. Same shape as Add-DhAlertBanner -Action.
        if ($t.Contains('Action') -and $null -ne $t['Action']) {
            $act = $t['Action']
            if ($act -isnot [hashtable] -and $act -isnot [System.Collections.Specialized.OrderedDictionary]) {
                throw "Add-DhSummary: tile '$($t.Label)' — Action must be a hashtable. Got: $($act.GetType().Name)"
            }
            $hasT = $act.Contains('TableId') -and -not [string]::IsNullOrWhiteSpace([string]$act['TableId'])
            $hasU = $act.Contains('Url')     -and -not [string]::IsNullOrWhiteSpace([string]$act['Url'])
            if (-not $hasT -and -not $hasU) {
                throw "Add-DhSummary: tile '$($t.Label)' — Action requires either a 'TableId' (jump to table) or a 'Url' (external link)."
            }
            if ($hasT -and $hasU) {
                throw "Add-DhSummary: tile '$($t.Label)' — Action must specify EITHER 'TableId' OR 'Url', not both."
            }
            $t['Action'] = @{
                Label   = if ($act.Contains('Label'))  { [string]$act.Label }   else { '' }
                TableId = if ($hasT) { [string]$act.TableId } else { '' }
                Filter  = if ($act.Contains('Filter')) { [string]$act.Filter }  else { '' }
                Url     = if ($hasU) { [string]$act.Url }     else { '' }
            }
        } else {
            $t['Action'] = $null
        }

        # ---- Tile style dispatch (v1.4 F3 + F4) ----
        if (-not $t.Contains('Style') -or [string]::IsNullOrWhiteSpace([string]$t['Style'])) {
            $t['Style'] = 'tile'
        } else {
            $style = ([string]$t['Style']).ToLowerInvariant()
            if ($style -notin @('tile','bignumber','gauge')) {
                throw "Add-DhSummary: tile '$($t.Label)' — Style must be 'tile', 'bignumber', or 'gauge'. Got: $($t['Style'])"
            }
            $t['Style'] = $style
        }

        # Bignumber-specific: optional Caption (small text below the hero value)
        if (-not $t.Contains('Caption')) { $t['Caption'] = '' }

        # ---- Unified Rag → Thresholds + RagNoData (v1.4 F13) ----
        # Done BEFORE the gauge Value check so a null Value combined with
        # Rag.NoData=$true is accepted (gauge renderer paints cell-nodata).
        $t['RagNoData'] = $false
        if ($t.Contains('Rag') -and $null -ne $t['Rag']) {
            $rag = ConvertTo-DhRagThresholds -Rag $t['Rag'] -Context "Add-DhSummary tile '$($t.Label)'"
            $t['Thresholds'] = $rag.Thresholds
            $t['RagNoData']  = $rag.NoData
            # Strip the raw Rag key once consumed — keeps the downstream item shape
            # canonical (Thresholds[] + RagNoData) and avoids double-emit in JSON.
            $t.Remove('Rag')
        }

        # Gauge-specific: Min/Max/Unit/Thresholds
        if ($t['Style'] -eq 'gauge') {
            # Value must be numeric for a gauge — EXCEPT when -Rag NoData=$true is
            # set, in which case a null/missing Value is explicitly allowed and
            # the renderer paints the gauge cell-nodata. Without that opt-in,
            # null/non-numeric is still rejected so authors don't accidentally
            # ship a broken-looking gauge.
            $isMissingValue = ($null -eq $t['Value']) -or ([string]::IsNullOrWhiteSpace([string]$t['Value']))
            $allowNoData = $t.Contains('RagNoData') -and $t['RagNoData']
            if ($isMissingValue -and $allowNoData) {
                # accepted — JS gauge renderer detects the missing value and paints cell-nodata
            } else {
                $gVal = 0.0
                $okGv = [double]::TryParse(
                    [string]$t['Value'],
                    [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$gVal)
                if (-not $okGv) {
                    throw "Add-DhSummary: tile '$($t.Label)' — Style='gauge' requires a numeric Value. Got: $($t['Value']). (Use -Rag with NoData=`$true to allow null values that render as cell-nodata.)"
                }
            }
            # Don't replace $t['Value'] — keep the user's original (could be an int) for the centre readout
        }
        foreach ($k in 'Min','Max') {
            if ($t.Contains($k) -and $null -ne $t[$k]) {
                $n = 0.0
                $okN = [double]::TryParse(
                    [string]$t[$k],
                    [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$n)
                if (-not $okN) {
                    throw "Add-DhSummary: tile '$($t.Label)' — $k must be a number, got: $($t[$k])"
                }
                $t[$k] = $n
            } else {
                # Gauge defaults: 0..100. Other styles ignore Min/Max.
                $t[$k] = if ($t['Style'] -eq 'gauge') { if ($k -eq 'Min') { 0.0 } else { 100.0 } } else { $null }
            }
        }
        if (-not $t.Contains('Unit')) { $t['Unit'] = '' }

        # Thresholds for gauge — array of @{ Min=..; Max=..; Class='cell-...' }
        # Same shape as Add-DhTable column thresholds (numeric only — string Value
        # thresholds don't apply to a numeric gauge).
        # When -Rag was used above, Thresholds is already populated and validated;
        # this block is the fallback path for legacy Thresholds=@(...) callers
        # plus a defensive re-validation of Rag-derived thresholds.
        $thOut = @()
        if ($t.Contains('Thresholds') -and $t['Thresholds']) {
            foreach ($th in @($t['Thresholds'])) {
                if ($th -isnot [hashtable] -and $th -isnot [System.Collections.Specialized.OrderedDictionary]) {
                    throw "Add-DhSummary: tile '$($t.Label)' — each Thresholds entry must be a hashtable. Got: $($th.GetType().Name)"
                }
                if (-not $th.Contains('Class') -or [string]::IsNullOrWhiteSpace([string]$th['Class'])) {
                    throw "Add-DhSummary: tile '$($t.Label)' — each Thresholds entry must have a non-empty Class key."
                }
                $thHash = @{ Class = [string]$th['Class'] }
                foreach ($bk in 'Min','Max') {
                    if ($th.Contains($bk) -and $null -ne $th[$bk]) {
                        $n = 0.0
                        $okN = [double]::TryParse(
                            [string]$th[$bk],
                            [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                            [System.Globalization.CultureInfo]::InvariantCulture,
                            [ref]$n)
                        if (-not $okN) {
                            throw "Add-DhSummary: tile '$($t.Label)' — threshold $bk must be a number, got: $($th[$bk])"
                        }
                        $thHash[$bk] = $n
                    }
                }
                $thOut += $thHash
            }
        }
        $t['Thresholds'] = @($thOut)

        # ---- Trend / delta indicator (v1.4 F1) ----
        # Previous (nullable number). Triggers auto-derived delta when present.
        # IMPORTANT: parse with InvariantCulture — PowerShell [string] on a double
        # uses invariant format (period decimal), but [double]::TryParse defaults
        # to CurrentCulture which on locales like it-IT treats period as the
        # thousands separator, silently turning 92330.25 into 9233025.
        if ($t.Contains('Previous') -and $null -ne $t['Previous']) {
            $n = 0.0
            $okN = [double]::TryParse(
                [string]$t['Previous'],
                [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$n)
            if (-not $okN) {
                throw "Add-DhSummary: tile '$($t.Label)' — Previous must be a number, got: $($t['Previous'])"
            }
            $t['Previous'] = $n
        } else {
            $t['Previous'] = $null
        }

        # Trend ('auto' default | 'up' | 'down' | 'flat'). 'auto' = derive from Previous/Value.
        if (-not $t.Contains('Trend') -or [string]::IsNullOrWhiteSpace([string]$t['Trend'])) {
            $t['Trend'] = 'auto'
        } else {
            $trend = ([string]$t['Trend']).ToLowerInvariant()
            if ($trend -notin @('auto','up','down','flat')) {
                throw "Add-DhSummary: tile '$($t.Label)' — Trend must be 'auto', 'up', 'down', or 'flat'. Got: $($t['Trend'])"
            }
            $t['Trend'] = $trend
        }

        # TrendIsGood (nullable bool). $null = semantic default in JS (up=good, down=bad, flat=neutral).
        if ($t.Contains('TrendIsGood') -and $null -ne $t['TrendIsGood']) {
            $t['TrendIsGood'] = [bool]$t['TrendIsGood']
        } else {
            $t['TrendIsGood'] = $null
        }

        # DeltaFormat ('percent' | 'absolute' | 'both' default).
        if (-not $t.Contains('DeltaFormat') -or [string]::IsNullOrWhiteSpace([string]$t['DeltaFormat'])) {
            $t['DeltaFormat'] = 'both'
        } else {
            $df = ([string]$t['DeltaFormat']).ToLowerInvariant()
            if ($df -notin @('percent','absolute','both')) {
                throw "Add-DhSummary: tile '$($t.Label)' — DeltaFormat must be 'percent', 'absolute', or 'both'. Got: $($t['DeltaFormat'])"
            }
            $t['DeltaFormat'] = $df
        }

        # ---- Sparkline (v1.4 F2) — optional inline mini-chart below the value ----
        # Normalise the series into a numeric array. Anything not parseable as a
        # number is dropped (with a warning) so a bad value doesn't break the SVG.
        # NOTE: parse with InvariantCulture (see Previous note above).
        $sparkArr = @()
        if ($t.Contains('Sparkline') -and $null -ne $t['Sparkline']) {
            $raw = @($t['Sparkline'])
            foreach ($v in $raw) {
                if ($null -eq $v) { continue }
                $n = 0.0
                $okN = [double]::TryParse(
                    [string]$v,
                    [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$n)
                if ($okN) {
                    $sparkArr += $n
                } else {
                    Write-Warning "Add-DhSummary: tile '$($t.Label)' — Sparkline value '$v' is not numeric and will be skipped."
                }
            }
        }
        $t['Sparkline'] = @($sparkArr)

        # Optional numeric Y-axis bounds — leave as $null when not supplied so JS uses series min/max
        foreach ($k in 'SparklineMin','SparklineMax') {
            if ($t.Contains($k) -and $null -ne $t[$k]) {
                $n = 0.0
                $okN = [double]::TryParse(
                    [string]$t[$k],
                    [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$n)
                if (-not $okN) {
                    throw "Add-DhSummary: tile '$($t.Label)' — $k must be a number, got: $($t[$k])"
                }
                $t[$k] = $n
            } else {
                $t[$k] = $null
            }
        }

        # Style — 'line' (default) | 'area' | 'bars'
        if (-not $t.Contains('SparklineStyle') -or [string]::IsNullOrWhiteSpace([string]$t['SparklineStyle'])) {
            $t['SparklineStyle'] = 'line'
        } else {
            $style = ([string]$t['SparklineStyle']).ToLowerInvariant()
            if ($style -notin @('line','area','bars')) {
                throw "Add-DhSummary: tile '$($t.Label)' — SparklineStyle must be 'line', 'area', or 'bars'. Got: $($t['SparklineStyle'])"
            }
            $t['SparklineStyle'] = $style
        }

        $t
    }

    # ---- Storage: per-NavGroup strip OR legacy top-of-page singleton ----
    if ([string]::IsNullOrWhiteSpace($NavGroup)) {
        # Legacy path — singleton at the top of the page (unchanged from v1.4.0).
        $Report['Summary'] = @($normItems)
        $Report['SummaryOptions'] = [ordered]@{
            Collapsible = $Collapsible
            Title       = $Title
            Icon        = $Icon
            DefaultOpen = $DefaultOpen
        }
        if ($replacing) {
            Write-Warning "Add-DhSummary: Report already has a global Summary - replacing the existing tiles. (Pass -NavGroup to add a per-panel strip instead.)"
        }
        Write-Verbose "Add-DhSummary: $(@($normItems).Count) tile(s) added at top-of-page.$(if ($Collapsible) { ' (collapsible)' } else { ' (flat)' })"
    } else {
        # v1.4.1+ — per-NavGroup strip; lives in $Report.Blocks so the nav
        # routing hides / shows it with the rest of the panel content.
        if (-not $Report.Contains('Blocks')) {
            $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
        }
        # Auto-derive Id from NavGroup + NavSubGroup if user didn't supply one.
        # Slugify (lowercase + non-alphanum to '-'): 'My Group' → 'my-group'.
        function _slug([string] $s) {
            if ([string]::IsNullOrWhiteSpace($s)) { return '' }
            return ($s.ToLowerInvariant() -replace '[^a-z0-9_-]','-').Trim('-')
        }
        $blockId = $Id
        if ([string]::IsNullOrWhiteSpace($blockId)) {
            $blockId = 'summary-' + (_slug $NavGroup)
            if (-not [string]::IsNullOrWhiteSpace($NavSubGroup)) {
                $blockId += '-' + (_slug $NavSubGroup)
            }
        }
        # Duplicate-Id guard mirroring the other block cmdlets
        foreach ($existing in $Report.Blocks) {
            if ($existing.Id -eq $blockId) {
                throw "Add-DhSummary: A block with Id '$blockId' already exists. Supply -Id to disambiguate, or pick a different -NavGroup."
            }
        }
        $Report.Blocks.Add([ordered]@{
            BlockType   = 'summarystrip'
            Id          = $blockId
            Title       = $Title
            Icon        = $Icon
            Items       = @($normItems)
            Collapsible = [bool]$Collapsible
            DefaultOpen = [bool]$DefaultOpen
            NavGroup    = $NavGroup
            NavSubGroup = $NavSubGroup
        })
        Write-Verbose "Add-DhSummary: $(@($normItems).Count) tile(s) added as block '$blockId' (NavGroup='$NavGroup'$(if ($NavSubGroup) { "', NavSubGroup='$NavSubGroup" }))."
    }
}