Private/TrendReport.ps1

function Get-HLTrendSeries {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$ScanResults
    )

    if (@($ScanResults).Count -lt 2) {
        throw 'A trend needs at least two scan results of the same computer.'
    }

    foreach ($scanResult in $ScanResults) {
        Assert-HLComparableScanResult -ScanResult $scanResult -InputName Reference
    }

    $computerNames = @($ScanResults | ForEach-Object { ([string]$_.system.ComputerName).ToUpperInvariant() } | Sort-Object -Unique)
    if ($computerNames.Count -gt 1) {
        throw ("A trend covers one computer; found results for: {0}." -f (@($computerNames) -join ', '))
    }
    $baselineNames = @($ScanResults | ForEach-Object { [string]$_.baseline.name } | Sort-Object -Unique)
    if ($baselineNames.Count -gt 1) {
        throw ("A trend covers one baseline; found: {0}." -f (@($baselineNames) -join ', '))
    }

    $ordered = @($ScanResults | Sort-Object -Property @{ Expression = { [datetime]$_.scan.collectedAt } })
    $scanIds = @($ordered | ForEach-Object { [string]$_.scan.id } | Sort-Object -Unique)
    if ($scanIds.Count -ne $ordered.Count) {
        throw 'The trend input contains duplicate scan IDs.'
    }

    $rows = New-Object System.Collections.Generic.List[object]
    $previousScore = $null
    foreach ($scanResult in $ordered) {
        $score = $scanResult.summary.HardeningScore
        $delta = if ($null -ne $previousScore -and $null -ne $score) { [math]::Round([double]$score - [double]$previousScore, 1) } else { $null }
        $rows.Add([pscustomobject][ordered]@{
            CollectedAt      = [string]$scanResult.scan.collectedAt
            ScanId           = [string]$scanResult.scan.id
            ModuleVersion    = [string]$scanResult.scan.moduleVersion
            BaselineVersion  = [string]$scanResult.baseline.version
            HardeningScore   = $score
            ScoreDelta       = $delta
            EvidenceCoverage = $scanResult.summary.EvidenceCoverage
            Pass             = [int]$scanResult.summary.Pass
            Fail             = [int]$scanResult.summary.Fail
            Warning          = [int]$scanResult.summary.Warning
            Excepted         = [int]$scanResult.summary.Excepted
            Unknown          = [int]$scanResult.summary.Unknown
            Error            = [int]$scanResult.summary.Error
        })
        if ($null -ne $score) { $previousScore = $score }
    }

    return [pscustomobject][ordered]@{
        ComputerName = [string]$ordered[-1].system.ComputerName
        BaselineName = [string]$ordered[-1].baseline.name
        Rows         = $rows.ToArray()
    }
}

function ConvertTo-HLTrendMarkdown {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Series
    )

    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add('# Hardening Lens trend report')
    $lines.Add('')
    $lines.Add(('* **Host:** `{0}`' -f $Series.ComputerName))
    $lines.Add(('* **Baseline:** `{0}`' -f $Series.BaselineName))
    $lines.Add(('* **Scans:** {0}' -f @($Series.Rows).Count))
    $lines.Add('')
    $lines.Add('| Collected | Score | Delta | Coverage | Pass | Fail | Warning | Excepted | Unknown | Error | Module | Baseline |')
    $lines.Add('|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|')
    foreach ($row in @($Series.Rows)) {
        $score = if ($null -ne $row.HardeningScore) { '{0:N1}%' -f [double]$row.HardeningScore } else { 'n/a' }
        $delta = if ($null -ne $row.ScoreDelta) { '{0:+0.0;-0.0;0.0}' -f [double]$row.ScoreDelta } else { '' }
        $coverage = if ($null -ne $row.EvidenceCoverage) { '{0:N1}%' -f [double]$row.EvidenceCoverage } else { 'n/a' }
        $lines.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} | {9} | {10} | {11} |' -f
            $row.CollectedAt, $score, $delta, $coverage, $row.Pass, $row.Fail, $row.Warning, $row.Excepted, $row.Unknown, $row.Error, $row.ModuleVersion, $row.BaselineVersion))
    }
    $lines.Add('')
    $lines.Add('Generated by Hardening Lens. Score changes reflect configuration, baseline, and catalog evolution; review the drift reports for field-level causes.')
    return ($lines.ToArray() -join [Environment]::NewLine)
}

function ConvertTo-HLTrendHtmlReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Series
    )

    $builder = New-Object Text.StringBuilder
    [void]$builder.AppendLine('<!doctype html>')
    [void]$builder.AppendLine('<html lang="en"><head><meta charset="utf-8">')
    [void]$builder.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">')
    [void]$builder.AppendLine('<meta http-equiv="Content-Security-Policy" content="default-src ''none''; style-src ''unsafe-inline''; img-src data:; base-uri ''none''; form-action ''none''">')
    [void]$builder.AppendLine('<title>Hardening Lens Trend Report</title>')
    [void]$builder.AppendLine(@'
<style>
:root{--bg:#07110d;--panel:#0c1b14;--line:#244434;--text:#e7f3ec;--muted:#9eb7aa;--accent:#55e69d;--fail:#ff6b6b}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -10%,#123c28 0,transparent 32%),var(--bg);color:var(--text);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
code,pre,.mono{font-family:"Cascadia Code","SFMono-Regular",Consolas,monospace}.wrap{max-width:1200px;margin:0 auto;padding:32px}.hero{border:1px solid var(--line);background:linear-gradient(135deg,rgba(85,230,157,.09),rgba(12,27,20,.96));border-radius:18px;padding:28px}.eyebrow{color:var(--accent);letter-spacing:.16em;text-transform:uppercase;font:700 12px/1.4 monospace}.hero h1{font-size:clamp(28px,5vw,46px);margin:7px 0 8px}.subtitle{color:var(--muted);margin:0}.section{margin-top:28px}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:var(--panel)}table{width:100%;border-collapse:collapse;min-width:900px}th,td{padding:11px 13px;text-align:left;border-bottom:1px solid rgba(36,68,52,.72)}th{background:#0e2017;color:#bcd1c5;font-size:12px;text-transform:uppercase;letter-spacing:.06em}.bar{background:#06100b;border:1px solid var(--line);border-radius:6px;height:10px;min-width:140px;overflow:hidden}.bar span{display:block;height:100%;background:var(--accent)}.delta-up{color:var(--accent);font-weight:700}.delta-down{color:var(--fail);font-weight:700}.small{font-size:12px;color:var(--muted)}.method{color:var(--muted)}.footer{color:var(--muted);text-align:center;margin:28px 0 8px;font-size:12px}
@media print{body{background:#fff;color:#111}.hero,.table-wrap{background:#fff;border-color:#bbb}th{background:#eee;color:#111}}
</style>
'@
)
    [void]$builder.AppendLine('</head><body><main class="wrap">')
    [void]$builder.AppendLine('<section class="hero">')
    [void]$builder.AppendLine('<div class="eyebrow">xGreeny / Windows Security Engineering</div>')
    [void]$builder.AppendLine('<h1>Hardening Lens Trend</h1>')
    [void]$builder.AppendLine(('<p class="subtitle">Posture over {0} scans of <strong>{1}</strong> against <strong>{2}</strong>.</p>' -f @($Series.Rows).Count,(ConvertTo-HLHtmlEncoded $Series.ComputerName),(ConvertTo-HLHtmlEncoded $Series.BaselineName)))
    [void]$builder.AppendLine('</section>')

    [void]$builder.AppendLine('<section class="section"><div class="table-wrap"><table><thead><tr><th>Collected</th><th>Score</th><th></th><th>Delta</th><th>Coverage</th><th>Pass</th><th>Fail</th><th>Warning</th><th>Excepted</th><th>Unknown</th><th>Error</th><th>Module / baseline</th></tr></thead><tbody>')
    foreach ($row in @($Series.Rows)) {
        $scoreValue = if ($null -ne $row.HardeningScore) { [double]$row.HardeningScore } else { $null }
        $scoreText = if ($null -ne $scoreValue) { '{0:N1}%' -f $scoreValue } else { 'n/a' }
        $barWidth = if ($null -ne $scoreValue) { [int][math]::Max(0, [math]::Min(100, $scoreValue)) } else { 0 }
        $deltaText = if ($null -ne $row.ScoreDelta) { '{0:+0.0;-0.0;0.0}' -f [double]$row.ScoreDelta } else { '' }
        $deltaClass = if ($null -ne $row.ScoreDelta -and [double]$row.ScoreDelta -gt 0) { 'delta-up' } elseif ($null -ne $row.ScoreDelta -and [double]$row.ScoreDelta -lt 0) { 'delta-down' } else { '' }
        $collected = try { ([datetime]$row.CollectedAt).ToString('yyyy-MM-dd HH:mm') } catch { [string]$row.CollectedAt }
        [void]$builder.AppendLine(('<tr><td class="mono">{0}</td><td class="mono">{1}</td><td><div class="bar"><span style="width:{2}%"></span></div></td><td class="mono {3}">{4}</td><td class="mono">{5}</td><td>{6}</td><td>{7}</td><td>{8}</td><td>{9}</td><td>{10}</td><td>{11}</td><td class="small">{12} / {13}</td></tr>' -f
            (ConvertTo-HLHtmlEncoded $collected),(ConvertTo-HLHtmlEncoded $scoreText),$barWidth,$deltaClass,(ConvertTo-HLHtmlEncoded $deltaText),(ConvertTo-HLHtmlEncoded $(if ($null -ne $row.EvidenceCoverage) { '{0:N1}%' -f [double]$row.EvidenceCoverage } else { 'n/a' })),(ConvertTo-HLHtmlEncoded $row.Pass),(ConvertTo-HLHtmlEncoded $row.Fail),(ConvertTo-HLHtmlEncoded $row.Warning),(ConvertTo-HLHtmlEncoded $row.Excepted),(ConvertTo-HLHtmlEncoded $row.Unknown),(ConvertTo-HLHtmlEncoded $row.Error),(ConvertTo-HLHtmlEncoded $row.ModuleVersion),(ConvertTo-HLHtmlEncoded $row.BaselineVersion)))
    }
    [void]$builder.AppendLine('</tbody></table></div></section>')
    [void]$builder.AppendLine('<section class="section"><p class="method">Score changes reflect configuration, baseline, and catalog evolution together; use the drift reports for field-level causes. Hardening Lens is a read-only technical posture assessment and does not certify compliance with any framework.</p></section>')
    [void]$builder.AppendLine('<div class="footer mono">hardening-lens / xGreeny | trend over result schema 1.0/1.1</div>')
    [void]$builder.AppendLine('</main></body></html>')
    return $builder.ToString()
}