Public/Add-DhGantt.ps1

function Add-DhGantt {
    <#
    .SYNOPSIS
        Add a Gantt timeline - one bar per task from Start to End on a shared time axis (v3.1.0).

    .DESCRIPTION
        Rows of start/end bars, grouped into lanes, coloured by status or category, with
        milestones, critical-path marks, vertical marker lines and shaded bands. The axis
        runs from seconds to years: backup and job windows, patch and migration waves,
        certificate and contract validity, boot order, project plans.

        Two ways to supply data, mutually exclusive:

          -Items explicit tasks (static)
          -TableId one bar per row of a table already added to the report. The chart
                     follows the table's filters, like a table-fed bar chart.

        Time rule: the browser never parses a date. Every Start / End / marker / band is
        turned into seconds from an origin here, in PowerShell, and the page draws the
        wall clock those values carry, whatever the reader's time zone. A [DateTimeOffset]
        shows its own clock (.DateTime); a [datetime] is taken as given (its Kind is not
        converted). A bar across a daylight-saving change is placed by wall clock.

    .PARAMETER Report
        Dashboard object from New-DhDashboard.

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

    .PARAMETER Title
        Block heading.

    .PARAMETER Items
        Tasks. Array of hashtables:

          @{
              Label = 'Restore database' # required: the bar (and row) text
              Start = [datetime] | '2026-11-03 22:00' | 0 # required
              End = [datetime] | '...' | 446 # missing: a milestone (diamond)
              Id = 'db' # unique; needed by DependsOn
              Group = 'Applications' # lane header
              Row = 'srv-db01' # items with the same Row share a line
              Status = 'ok' # ok | warn | danger | info | nodata (and the usual aliases)
              Category = 'Full backup' # colour from the chart palette, with a legend (ignored with Status)
              Critical = $true # marked as critical path
              Text = '7m 26s' # replaces the automatic duration text
              Tooltip = 'extra line'
              DependsOn = @('net') # Ids, shown as "after: ..."
              Detail = <drawer spec> # text, a section, sections, or @{ Title; Subtitle; Sections }
              LinkTableId = 'jobs'; LinkFilter = 'srv-db01'
          }

        Start and End are all dates (a [datetime], a [DateTimeOffset] or an invariant date
        string such as '2026-11-03 22:00' or '2026-11-03T22:00:00') or all numbers (elapsed
        -Unit). A string that reads as a number is a number. Mixing the two throws.

    .PARAMETER TableId
        Source table (add it first). One bar per row; rows whose start does not read as a
        date or number are left out and counted in a note under the chart.

    .PARAMETER LabelField
        Column with the bar text.

    .PARAMETER StartField
        Column with the start.

    .PARAMETER EndField
        Column with the end. Empty or missing: the row is a milestone.

    .PARAMETER GroupField
        Column whose value is the lane.

    .PARAMETER RowField
        Column whose value is the row: bars with the same value share a line (for example
        every job of one backup client).

    .PARAMETER StatusField
        Column with ok / warn / danger / info / nodata (or the usual aliases).

    .PARAMETER CategoryField
        Column whose value picks the bar colour from the chart palette.

    .PARAMETER Origin
        Zero of the elapsed axis. 'first' (default): the earliest start. A date: the axis
        clock starts there, and numeric items become offsets from it (so they can show a
        clock axis too).

    .PARAMETER AxisFormat
        Auto (default): Clock below two days of span, Date above, Elapsed for numbers
        without a date -Origin. Clock and Date need dates or a date -Origin.

    .PARAMETER Unit
        Unit of numeric Start / End / markers: s (default), min, h or d.

    .PARAMETER Markers
        Vertical lines: @(@{ At = <date or number>; Label = 'RTO target'; Status = 'warn' }).

    .PARAMETER Bands
        Shaded windows: @(@{ From = ...; To = ...; Label = 'Backup window' }).

    .PARAMETER SortBy
        None (declaration / table order, default), Start, End, Duration or Label. Rows are
        sorted inside their lane.

    .PARAMETER ShowDuration
        Show the duration (or Text) next to each bar. Default $true.

    .PARAMETER ShowDataTable
        A "Show data table" toggle under the chart: every task with start, end, duration and
        status. The accessible and printable alternative.

    .PARAMETER MaxItems
        Bars drawn at most (default 500). Mode A warns above it and draws the first ones;
        the page says how many were left out. Mode B draws the first -MaxItems filtered rows.

    .PARAMETER Legend
        Show the legend (statuses, categories, critical path, milestone). Default $true.

    .EXAMPLE
        # Steps of a recovery test on a clock axis, with a target line
        Add-DhGantt -Report $report -Id 'steps' -Title 'Recovery steps' -Items @(
            @{ Label = 'Storage'; Start = '2026-11-03 07:19:02'; End = '2026-11-03 07:23:31'; Status = 'ok' }
            @{ Label = 'Compute'; Start = '2026-11-03 07:23:32'; End = '2026-11-03 08:15:06'; Status = 'ok'; Critical = $true }
            @{ Label = 'Applications'; Start = '2026-11-03 08:15:06'; End = '2026-11-03 08:32:48'; Status = 'warn' }
        ) -Markers @(@{ At = '2026-11-03 08:30'; Label = 'RTO target'; Status = 'warn' })

    .EXAMPLE
        # Nightly backup jobs from a table, one line per client, with the backup window
        Add-DhGantt -Report $report -Id 'jobs-g' -Title 'Last night' -TableId 'jobs' `
            -LabelField 'Job' -StartField 'Started' -EndField 'Ended' -RowField 'Client' `
            -StatusField 'Result' -Bands @(@{ From = $night; To = $night.AddHours(8); Label = 'Backup window' })

    .EXAMPLE
        # Elapsed seconds from a common origin
        Add-DhGantt -Report $report -Id 'boot' -Title 'Start order' -Unit s -Items @(
            @{ Label = 'Database'; Start = 0; End = 446; Critical = $true; Id = 'db' }
            @{ Label = 'Web'; Start = 451; End = 573; DependsOn = 'db' }
        )
    #>

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

        # ---- Mode A: explicit tasks ----
        [Parameter(Mandatory, ParameterSetName = 'Items')] [object[]] $Items,

        # ---- Mode B: one bar per table row ----
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $TableId,
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $LabelField,
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $StartField,
        [Parameter(ParameterSetName = 'FromTable')] [string] $EndField      = '',
        [Parameter(ParameterSetName = 'FromTable')] [string] $GroupField    = '',
        [Parameter(ParameterSetName = 'FromTable')] [string] $RowField      = '',
        [Parameter(ParameterSetName = 'FromTable')] [string] $StatusField   = '',
        [Parameter(ParameterSetName = 'FromTable')] [string] $CategoryField = '',

        # ---- Axis and presentation ----
        [object] $Origin = 'first',
        [ValidateSet('Auto','Clock','Date','Elapsed')]
        [string] $AxisFormat = 'Auto',
        [ValidateSet('s','min','h','d')]
        [string] $Unit = 's',
        [object[]] $Markers = @(),
        [object[]] $Bands   = @(),
        [ValidateSet('None','Start','End','Duration','Label')]
        [string] $SortBy = 'None',
        [bool]   $ShowDuration = $true,
        [switch] $ShowDataTable,
        [ValidateRange(1, 2000)]
        [int]    $MaxItems = 500,
        [bool]   $Legend = $true,
        [bool]   $Collapsible = $true,    # wrap the block in a collapsible header (chevron + click-to-toggle)
        [bool]   $DefaultOpen = $true,    # whether the collapsible body starts expanded (only used when -Collapsible is $true)
        [ValidateRange(0,12)]
        [int]    $GridSpan    = 0,        # columns (1-12) to span in -report-grid layout; 0 = default flow
        [string] $NavPath     = '',       # 'A/B/C' - where this item lives. '//' escapes a literal '/'
        [ValidateSet('','Exact','Subtree','Global')]
        [string] $NavScope    = '',       # Exact (default) | Subtree (this node and below) | Global (everywhere)
        [string] $NavLabel    = '',       # display text for this node, decoupled from the path key
        [int]    $NavOrder    = 0         # explicit sibling ordering; 0 = declaration order
    )

    $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope -ItemKind 'Block' -Context 'Add-DhGantt'
    if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 }
    $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1

    if (-not $Report.Contains('Blocks')) {
        $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
    }
    foreach ($existing in $Report.Blocks) {
        if ($existing.Id -eq $Id) {
            throw "Add-DhGantt: A block with Id '$Id' already exists in this report. Use a unique Id."
        }
    }

    $inv      = [System.Globalization.CultureInfo]::InvariantCulture
    $epoch    = [datetime]::new(1970, 1, 1, 0, 0, 0, [System.DateTimeKind]::Unspecified)
    $unitSec  = switch ($Unit) { 's' { 1.0 } 'min' { 60.0 } 'h' { 3600.0 } 'd' { 86400.0 } }
    $numStyle = [System.Globalization.NumberStyles]::Float

    # One time value -> @{ K = 'date' | 'number'; V = seconds } (dates: wall-clock seconds since
    # 1970-01-01, the Kind ignored; numbers: seconds), $null when empty, K = 'bad' when unreadable.
    function _gtTime {
        param([AllowNull()] [object] $Value)
        if ($null -eq $Value) { return $null }
        if ($Value -is [System.Management.Automation.PSObject]) { $Value = $Value.PSObject.BaseObject }
        if ($Value -is [DateTimeOffset]) { return @{ K = 'date'; V = ($Value.DateTime - $epoch).TotalSeconds } }
        if ($Value -is [datetime])       { return @{ K = 'date'; V = ($Value - $epoch).TotalSeconds } }
        if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or
            $Value -is [single] -or $Value -is [int16] -or $Value -is [byte]) {
            return @{ K = 'number'; V = [double]$Value * $unitSec }
        }
        $s = ([string]$Value).Trim()
        if ($s -eq '') { return $null }
        $n = 0.0
        if ([double]::TryParse($s, $numStyle, $inv, [ref]$n)) { return @{ K = 'number'; V = $n * $unitSec } }
        $dto = [DateTimeOffset]::MinValue
        if ([DateTimeOffset]::TryParse($s, $inv, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$dto)) {
            return @{ K = 'date'; V = ($dto.DateTime - $epoch).TotalSeconds }
        }
        return @{ K = 'bad'; V = 0.0 }
    }
    function _gtNum([double] $v) { $v.ToString('R', $inv) }

    $mode  = $PSCmdlet.ParameterSetName
    $kind  = ''                 # 'date' | 'number', set by the first value seen
    $norm  = [System.Collections.Generic.List[object]]::new()
    $times = $null              # mode B: per table row [start, end] or $null

    # ---- Origin ---------------------------------------------------------------------------
    $originWall = $null
    if (-not ($Origin -is [string] -and $Origin -eq 'first')) {
        $o = _gtTime $Origin
        if ($null -eq $o -or $o.K -ne 'date') {
            throw "Add-DhGantt: -Origin must be 'first' or a date. Got: $Origin"
        }
        $originWall = [double]$o.V
    }

    if ($mode -eq 'Items') {
        if (@($Items).Count -lt 1) { throw "Add-DhGantt: -Items must contain at least one task." }
        $ids = @{}
        $i = 0
        foreach ($item in $Items) {
            $i++
            if ($item -isnot [System.Collections.IDictionary]) {
                throw "Add-DhGantt: each item must be a hashtable. Item $i is a $($item.GetType().Name)."
            }
            $label = if ($item.Contains('Label')) { [string]$item['Label'] } else { '' }
            if ([string]::IsNullOrWhiteSpace($label)) { throw "Add-DhGantt: item $i has no Label." }
            $ctx = "item $i ('$label')"
            $st = _gtTime $(if ($item.Contains('Start')) { $item['Start'] } else { $null })
            if ($null -eq $st) { throw "Add-DhGantt: $ctx has no Start." }
            if ($st.K -eq 'bad') { throw "Add-DhGantt: $ctx - Start '$($item['Start'])' is neither a date nor a number." }
            $en = _gtTime $(if ($item.Contains('End')) { $item['End'] } else { $null })
            if ($null -ne $en -and $en.K -eq 'bad') { throw "Add-DhGantt: $ctx - End '$($item['End'])' is neither a date nor a number." }
            if (-not $kind) { $kind = $st.K }
            foreach ($t in @($st, $en)) {
                if ($null -ne $t -and $t.K -ne $kind) {
                    throw "Add-DhGantt: $ctx mixes dates and numbers; use one kind of Start / End in a chart."
                }
            }
            if ($null -ne $en -and $en.V -lt $st.V) { throw "Add-DhGantt: $ctx ends before it starts." }

            $itemId = if ($item.Contains('Id') -and $item['Id']) { [string]$item['Id'] } else { '' }
            if ($itemId) {
                if ($ids.ContainsKey($itemId)) { throw "Add-DhGantt: duplicate item Id '$itemId'." }
                $ids[$itemId] = $label
            }
            $detail = ConvertTo-DhDetailSpec -Detail $(if ($item.Contains('Detail')) { $item['Detail'] } else { $null }) -Context "Add-DhGantt '$Id' $ctx"
            $norm.Add([ordered]@{
                Label       = $label
                S           = [double]$st.V
                E           = $(if ($null -ne $en) { [double]$en.V } else { $null })
                Id          = $itemId
                Group       = $(if ($item.Contains('Group'))    { [string]$item['Group'] }    else { '' })
                Row         = $(if ($item.Contains('Row'))      { [string]$item['Row'] }      else { '' })
                Status      = ConvertTo-DhStatusName $(if ($item.Contains('Status')) { $item['Status'] } else { $null })
                Category    = $(if ($item.Contains('Category')) { [string]$item['Category'] } else { '' })
                Critical    = [bool]($item.Contains('Critical') -and $item['Critical'])
                Text        = $(if ($item.Contains('Text'))     { [string]$item['Text'] }     else { '' })
                Tooltip     = $(if ($item.Contains('Tooltip'))  { [string]$item['Tooltip'] }  else { '' })
                DependsOn   = @($(if ($item.Contains('DependsOn') -and $null -ne $item['DependsOn']) { @($item['DependsOn']) | ForEach-Object { [string]$_ } | Where-Object { $_ } }))
                Detail      = $detail
                LinkTableId = $(if ($item.Contains('LinkTableId')) { [string]$item['LinkTableId'] } else { '' })
                LinkFilter  = $(if ($item.Contains('LinkFilter'))  { [string]$item['LinkFilter'] }  else { '' })
            })
        }
        # DependsOn names Ids of this chart; shown as the labels they point at
        foreach ($n in $norm) {
            $after = foreach ($d in $n.DependsOn) {
                if (-not $ids.ContainsKey($d)) { throw "Add-DhGantt: item '$($n.Label)' depends on unknown Id '$d'." }
                $ids[$d]
            }
            $n['After'] = @($after)
        }
        if ($norm.Count -gt $MaxItems) {
            Write-Warning "Add-DhGantt '$Id': $($norm.Count) items, over -MaxItems ($MaxItems). The chart draws the first $MaxItems and says how many were left out."
        }
    }
    else {
        $tbl = $null
        if ($Report.Contains('Tables') -and $Report.Tables) { $tbl = $Report.Tables | Where-Object { $_.Id -eq $TableId } | Select-Object -First 1 }
        if (-not $tbl) { throw "Add-DhGantt: Source table '$TableId' not found. Add the table before the Gantt chart." }
        $known = Get-DhTableFieldNames -Table $tbl
        foreach ($pair in @(
                @{ N = 'LabelField'; V = $LabelField }, @{ N = 'StartField'; V = $StartField }, @{ N = 'EndField'; V = $EndField },
                @{ N = 'GroupField'; V = $GroupField }, @{ N = 'RowField'; V = $RowField },
                @{ N = 'StatusField'; V = $StatusField }, @{ N = 'CategoryField'; V = $CategoryField })) {
            if ($pair.V -and $pair.V -cnotin $known) {
                throw "Add-DhGantt: -$($pair.N) '$($pair.V)' is not a field of table '$TableId'. Known fields: $(($known | Sort-Object) -join ', ')"
            }
        }
        # Every row's start / end in seconds, by row position: the page reads them instead of
        # parsing the cells (a [datetime] cell reaches the browser in the invariant US format).
        $times = [System.Collections.Generic.List[object]]::new()
        $skipped = 0
        foreach ($r in @($tbl.Data)) {
            $sv = if ($r -is [System.Collections.IDictionary] -and $r.Contains($StartField)) { $r[$StartField] } else { $null }
            $ev = if ($EndField -and $r -is [System.Collections.IDictionary] -and $r.Contains($EndField)) { $r[$EndField] } else { $null }
            $st = _gtTime $sv
            $en = _gtTime $ev
            $ok = $null -ne $st -and $st.K -ne 'bad' -and ($null -eq $en -or $en.K -ne 'bad')
            if ($ok -and -not $kind) { $kind = $st.K }
            if ($ok) { $ok = $st.K -eq $kind -and ($null -eq $en -or $en.K -eq $kind) }
            if ($ok -and $null -ne $en -and $en.V -lt $st.V) { $ok = $false }
            if ($ok) { $times.Add(@([double]$st.V, $(if ($null -ne $en) { [double]$en.V } else { $null }))) }
            else     { $times.Add($null); $skipped++ }
        }
        if ($times.Count -gt 0 -and $skipped -eq $times.Count) {
            throw "Add-DhGantt: no row of table '$TableId' has a '$StartField' that reads as a date or a number."
        }
        if ($skipped -gt 0) {
            Write-Warning "Add-DhGantt '$Id': $skipped of $($times.Count) rows of '$TableId' have no valid start / end (empty, unreadable, a different kind, or ending before they start). They are left out of the chart."
        }
    }

    # ---- Markers and bands (same kind as the tasks) ------------------------------------------
    $mkNorm = foreach ($m in @($Markers)) {
        if ($null -eq $m) { continue }
        if ($m -isnot [System.Collections.IDictionary]) { throw "Add-DhGantt: each marker must be a hashtable @{ At; Label; Status }." }
        $t = _gtTime $(if ($m.Contains('At')) { $m['At'] } else { $null })
        if ($null -eq $t -or $t.K -eq 'bad') { throw "Add-DhGantt: marker '$($m['Label'])' needs an At that is a date or a number." }
        if ($kind -and $t.K -ne $kind) { throw "Add-DhGantt: marker '$($m['Label'])' is a $($t.K), the tasks are $($kind)s." }
        [ordered]@{ V = [double]$t.V; Label = [string]$m['Label']; Status = ConvertTo-DhStatusName $(if ($m.Contains('Status')) { $m['Status'] } else { $null }) }
    }
    $bdNorm = foreach ($bd in @($Bands)) {
        if ($null -eq $bd) { continue }
        if ($bd -isnot [System.Collections.IDictionary]) { throw "Add-DhGantt: each band must be a hashtable @{ From; To; Label }." }
        $f = _gtTime $(if ($bd.Contains('From')) { $bd['From'] } else { $null })
        $t = _gtTime $(if ($bd.Contains('To'))   { $bd['To'] }   else { $null })
        if ($null -eq $f -or $null -eq $t -or $f.K -eq 'bad' -or $t.K -eq 'bad') { throw "Add-DhGantt: band '$($bd['Label'])' needs From and To that are dates or numbers." }
        if ($kind -and ($f.K -ne $kind -or $t.K -ne $kind)) { throw "Add-DhGantt: band '$($bd['Label'])' is not the same kind (date or number) as the tasks." }
        if ($t.V -lt $f.V) { throw "Add-DhGantt: band '$($bd['Label'])' ends before it starts." }
        [ordered]@{ From = [double]$f.V; To = [double]$t.V; Label = [string]$bd['Label'] }
    }
    $mkNorm = @($mkNorm); $bdNorm = @($bdNorm)

    # ---- Origin and axis ---------------------------------------------------------------------
    # Dates: every value becomes seconds from the origin (the earliest start unless -Origin).
    # Numbers: seconds as given; a date -Origin only lends them a clock.
    # @(...) around the if: a one-item chart would otherwise unroll to a scalar
    $starts = @(if ($mode -eq 'Items') { $norm | ForEach-Object { $_.S } } else { $times | Where-Object { $null -ne $_ } | ForEach-Object { $_[0] } })
    $shift = 0.0
    if ($kind -eq 'date') {
        if ($null -eq $originWall) { $originWall = [double](($starts | Measure-Object -Minimum).Minimum) }
        $shift = $originWall
    }
    $ends = @(if ($mode -eq 'Items') { $norm | ForEach-Object { if ($null -ne $_.E) { $_.E } else { $_.S } } } else { $times | Where-Object { $null -ne $_ } | ForEach-Object { if ($null -ne $_[1]) { $_[1] } else { $_[0] } } })
    $span = 0.0
    if ($starts.Count) { $span = [double](($ends | Measure-Object -Maximum).Maximum) - [double](($starts | Measure-Object -Minimum).Minimum) }

    $axis = $AxisFormat
    if ($axis -eq 'Auto') {
        $axis = if ($null -eq $originWall) { 'Elapsed' } elseif ($span -lt 172800) { 'Clock' } else { 'Date' }
    }
    elseif ($axis -ne 'Elapsed' -and $null -eq $originWall) {
        throw "Add-DhGantt: -AxisFormat $axis needs dates, or numbers with a date -Origin."
    }

    # ---- JSON (built here; Export-DhDashboard only places it) --------------------------------
    $q = { param($s) ConvertTo-DhJsonString ([string]$s) }
    $itemsJson = ''
    if ($mode -eq 'Items') {
        $itemsJson = (@($norm) | ForEach-Object {
            $n = $_
            $x = ''
            if ($null -ne $n.E)  { $x += ',"e":' + (_gtNum ($n.E - $shift)) }
            if ($n.Id)           { $x += ',"id":' + (& $q $n.Id) }
            if ($n.Group)        { $x += ',"group":' + (& $q $n.Group) }
            if ($n.Row)          { $x += ',"row":' + (& $q $n.Row) }
            if ($n.Status)       { $x += ',"status":' + (& $q $n.Status) }
            if ($n.Category)     { $x += ',"category":' + (& $q $n.Category) }
            if ($n.Critical)     { $x += ',"critical":true' }
            if ($n.Text)         { $x += ',"text":' + (& $q $n.Text) }
            if ($n.Tooltip)      { $x += ',"tooltip":' + (& $q $n.Tooltip) }
            if (@($n.After).Count) { $x += ',"after":[' + ((@($n.After) | ForEach-Object { & $q $_ }) -join ',') + ']' }
            if ($n.Detail)       { $x += ',"detail":' + (ConvertTo-DhDetailJson $n.Detail) }
            if ($n.LinkTableId)  { $x += ',"linkTableId":' + (& $q $n.LinkTableId) }
            if ($n.LinkFilter)   { $x += ',"linkFilter":' + (& $q $n.LinkFilter) }
            "{`"label`":$(& $q $n.Label),`"s`":$(_gtNum ($n.S - $shift))$x}"
        }) -join ','
    }
    $timesJson = ''
    if ($mode -eq 'FromTable') {
        $sb = [System.Text.StringBuilder]::new()
        $first = $true
        foreach ($t in $times) {
            if (-not $first) { [void]$sb.Append(',') }
            $first = $false
            if ($null -eq $t) { [void]$sb.Append('null') }
            elseif ($null -eq $t[1]) { [void]$sb.Append('[').Append((_gtNum ($t[0] - $shift))).Append(']') }
            else { [void]$sb.Append('[').Append((_gtNum ($t[0] - $shift))).Append(',').Append((_gtNum ($t[1] - $shift))).Append(']') }
        }
        $timesJson = $sb.ToString()
    }
    $mkJson = (@($mkNorm) | ForEach-Object {
        $x = if ($_.Status) { ',"status":' + (& $q $_.Status) } else { '' }
        "{`"at`":$(_gtNum ($_.V - $shift)),`"label`":$(& $q $_.Label)$x}"
    }) -join ','
    $bdJson = (@($bdNorm) | ForEach-Object {
        "{`"from`":$(_gtNum ($_.From - $shift)),`"to`":$(_gtNum ($_.To - $shift)),`"label`":$(& $q $_.Label)}"
    }) -join ','

    $fields = ''
    if ($mode -eq 'FromTable') {
        $fields = ",`"tableId`":$(& $q $TableId),`"labelField`":$(& $q $LabelField)"
        foreach ($pair in @(@('groupField', $GroupField), @('rowField', $RowField), @('statusField', $StatusField), @('categoryField', $CategoryField))) {
            if ($pair[1]) { $fields += ",`"$($pair[0])`":$(& $q $pair[1])" }
        }
        $fields += ",`"times`":[$timesJson]"
    }
    $originJson = if ($null -ne $originWall) { _gtNum $originWall } else { 'null' }
    $json = "`"mode`":$(if ($mode -eq 'Items') { '"items"' } else { '"fromtable"' }),`"axis`":$(& $q $axis.ToLowerInvariant()),`"origin`":$originJson," +
            "`"sortBy`":$(& $q $SortBy.ToLowerInvariant()),`"showDuration`":$(([bool]$ShowDuration).ToString().ToLower()),`"showDataTable`":$(([bool]$ShowDataTable).ToString().ToLower())," +
            "`"maxItems`":$MaxItems,`"legend`":$(([bool]$Legend).ToString().ToLower()),`"items`":[$itemsJson],`"markers`":[$mkJson],`"bands`":[$bdJson]$fields"

    $Report.Blocks.Add([ordered]@{
        BlockType     = 'gantt'
        Id            = $Id
        Title         = $Title
        Mode          = $(if ($mode -eq 'Items') { 'items' } else { 'fromtable' })
        Items         = $(if ($mode -eq 'Items') { @($Items) } else { @() })   # as given, so ConvertTo-DhYaml round-trips them
        TableId       = $TableId
        LabelField    = $LabelField
        StartField    = $StartField
        EndField      = $EndField
        GroupField    = $GroupField
        RowField      = $RowField
        StatusField   = $StatusField
        CategoryField = $CategoryField
        AxisFormat    = $AxisFormat
        Origin        = $Origin
        Unit          = $Unit
        Markers       = $Markers
        Bands         = $Bands
        SortBy        = $SortBy
        ShowDuration  = $ShowDuration
        ShowDataTable = [bool]$ShowDataTable
        MaxItems      = $MaxItems
        Legend        = $Legend
        GanttJson     = $json                # the browser JSON, seconds from the origin (ConvertTo-DhYaml drops it)
        NavPath       = $NavPath
        NavSegments   = $navDecl.Path
        NavSeqNo      = $navSeqNo
        NavScope      = $navDecl.Scope
        NavLabel      = $NavLabel
        NavOrder      = $NavOrder
        GridSpan      = $GridSpan
        Collapsible   = $Collapsible
        DefaultOpen   = $DefaultOpen
    })
    Write-Verbose "Add-DhGantt: '$Id' - $(if ($mode -eq 'Items') { "$($norm.Count) items" } else { "from table '$TableId'" }), $kind values, $axis axis."
}