All files / generators dashboard-content.ts

89.06% Statements 57/64
75% Branches 42/56
100% Functions 16/16
94.11% Lines 48/51

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274                                                                1x             1x                               81x 81x       81x                                   47x                           81x   47x 47x 81x   81x     81x   81x                     36x 81x 36x                                           20x 20x 20x   20x   20x                                   20x 20x   20x 1x     19x 19x   19x   68x   68x 68x     68x       19x                                         42x 42x   42x   41x                                                           28x 26x   25x 25x   28x 42x 42x     28x   25x                                 5x 3x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Generators/DashboardContent
 * @description Pure functions for building dashboard HTML sections with
 * metric cards and chart containers. Designed for agentic workflows to
 * extend any article type with data dashboards using data from European
 * Parliament MCP, World Bank API, or other data sources.
 *
 * Dashboard components:
 * - **Metric cards**: Key performance indicators with trends
 * - **Chart containers**: Canvas elements with embedded Chart.js configuration
 *   as data attributes, ready for client-side hydration
 *
 * Chart configurations are embedded as JSON in `data-chart-config` attributes
 * on `<canvas>` elements. An external client-side initialization script
 * provided by the embedding application can hydrate these using Chart.js
 * at runtime by reading the `data-chart-config` attributes.
 */
 
import { escapeHTML } from '../utils/file-utils.js';
import { getLocalizedString, DASHBOARD_STRINGS } from '../constants/languages.js';
import type {
  DashboardConfig,
  DashboardPanel,
  DashboardMetric,
  DashboardStrings,
  ChartConfig,
} from '../types/index.js';
 
/** Trend indicator symbols (accessible) */
const TREND_INDICATORS: Readonly<Record<string, string>> = {
  up: '↑',
  down: '↓',
  stable: '→',
};
 
/** CSS class for trend direction */
const TREND_CLASSES: Readonly<Record<string, string>> = {
  up: 'metric-trend-up',
  down: 'metric-trend-down',
  stable: 'metric-trend-stable',
};
 
// ─── Sub-section builders ────────────────────────────────────────────────────
 
/**
 * Build a single metric card HTML.
 *
 * @param metric - Metric data
 * @param trendPrefix - Localized prefix for trend aria-label
 * @returns HTML string for one metric card
 */
function buildMetricCard(metric: DashboardMetric, trendPrefix: string): string {
  const trendHtml = buildTrendIndicator(metric, trendPrefix);
  const unitHtml = metric.unit
    ? ` <span class="metric-unit">${escapeHTML(metric.unit)}</span>`
    : '';
 
  return `<div class="metric-card">
                <span class="metric-label">${escapeHTML(metric.label)}</span>
                <span class="metric-value">${escapeHTML(metric.value)}${unitHtml}</span>
                ${trendHtml}
              </div>`;
}
 
/**
 * Derive trend direction from an explicit trend value or numeric change.
 *
 * @param trend - Explicit trend or undefined
 * @param change - Numeric percentage change or undefined
 * @returns Resolved trend direction
 */
function resolveTrend(
  trend: DashboardMetric['trend'],
  change: number | undefined
): 'up' | 'down' | 'stable' {
  Eif (trend) return trend;
  if (change !== undefined && change > 0) return 'up';
  if (change !== undefined && change < 0) return 'down';
  return 'stable';
}
 
/**
 * Build trend indicator HTML for a metric.
 *
 * @param metric - Metric with optional trend and change
 * @param trendPrefix - Localized prefix for trend aria-label
 * @returns HTML string for trend indicator or empty string
 */
function buildTrendIndicator(metric: DashboardMetric, trendPrefix: string): string {
  if (!metric.trend && metric.change === undefined) return '';
 
  const trend = resolveTrend(metric.trend, metric.change);
  const trendClass = TREND_CLASSES[trend] ?? 'metric-trend-stable';
  const trendSymbol = TREND_INDICATORS[trend] ?? '→';
  const changeText =
    metric.change !== undefined
      ? ` ${metric.change > 0 ? '+' : ''}${metric.change.toFixed(1)}%`
      : '';
  const ariaLabel = `${trendPrefix}${changeText}`;
 
  return `<span class="${escapeHTML(trendClass)}" aria-label="${escapeHTML(ariaLabel)}">${trendSymbol}${escapeHTML(changeText)}</span>`;
}
 
/**
 * Build a metrics grid from an array of metrics.
 *
 * @param metrics - Array of metric data
 * @param trendPrefix - Localized prefix for trend aria-label
 * @returns HTML string for the metrics grid
 */
function buildMetricsGrid(metrics: readonly DashboardMetric[], trendPrefix: string): string {
  Iif (metrics.length === 0) return '';
  const cards = metrics.map((m) => buildMetricCard(m, trendPrefix)).join('\n              ');
  return `<div class="metrics-grid">
              ${cards}
            </div>`;
}
 
/**
 * Build a chart container with embedded configuration.
 * The chart configuration is serialized as JSON in a `data-chart-config`
 * attribute, ready for client-side hydration by Chart.js.
 *
 * A `<noscript>` fallback provides an accessible data table.
 *
 * @param chart - Chart configuration
 * @param panelIndex - Panel index for unique canvas ID
 * @param strings - Localized strings
 * @returns HTML string for chart container
 */
function buildChartContainer(
  chart: ChartConfig,
  panelIndex: number,
  strings: DashboardStrings
): string {
  const canvasId = `dashboard-chart-${panelIndex}`;
  const safeConfig = escapeHTML(JSON.stringify(chart));
  const titleHtml = chart.title ? `<h4 class="chart-title">${escapeHTML(chart.title)}</h4>` : '';
 
  const fallbackTable = buildChartFallbackTable(chart, strings);
 
  return `<div class="chart-container">
              ${titleHtml}
              <canvas id="${canvasId}" class="dashboard-chart" data-chart-config="${safeConfig}" role="img" aria-label="${escapeHTML(chart.title ?? strings.chartLabel)}"></canvas>
              <noscript>
                ${fallbackTable}
              </noscript>
            </div>`;
}
 
/**
 * Build an accessible fallback data table for a chart.
 * Displayed when JavaScript is disabled.
 *
 * @param chart - Chart configuration
 * @param strings - Localized strings
 * @returns HTML table string
 */
function buildChartFallbackTable(chart: ChartConfig, strings: DashboardStrings): string {
  const labels = chart.data.labels;
  const datasets = chart.data.datasets;
 
  if (labels.length === 0 || datasets.length === 0) {
    return `<p class="chart-no-data">${escapeHTML(strings.noChartData)}</p>`;
  }
 
  const headerCells = datasets.map((ds) => `<th scope="col">${escapeHTML(ds.label)}</th>`).join('');
  const header = `<tr><th scope="col" aria-hidden="true"></th>${headerCells}</tr>`;
 
  const rows = labels
    .map((label, i) => {
      const cells = datasets
        .map((ds) => {
          const val = ds.data[i];
          return `<td>${val !== undefined ? escapeHTML(String(val)) : '—'}</td>`;
        })
        .join('');
      return `<tr><th scope="row">${escapeHTML(label)}</th>${cells}</tr>`;
    })
    .join('\n                  ');
 
  return `<table class="chart-fallback-table" role="table">
                <thead>${header}</thead>
                <tbody>
                  ${rows}
                </tbody>
              </table>`;
}
 
/**
 * Build a single dashboard panel HTML.
 *
 * @param panel - Panel configuration
 * @param index - Panel index for unique IDs
 * @param strings - Localized strings
 * @returns HTML string for one panel
 */
function buildDashboardPanel(
  panel: DashboardPanel,
  index: number,
  strings: DashboardStrings
): string {
  const metricsHtml = panel.metrics ? buildMetricsGrid(panel.metrics, strings.trendPrefix) : '';
  const chartHtml = panel.chart ? buildChartContainer(panel.chart, index, strings) : '';
 
  if (!metricsHtml && !chartHtml) return '';
 
  return `<div class="dashboard-panel" role="region" aria-label="${escapeHTML(panel.title)}">
            <h3 class="panel-title">${escapeHTML(panel.title)}</h3>
            ${metricsHtml}
            ${chartHtml}
          </div>`;
}
 
// ─── Main builder ────────────────────────────────────────────────────────────
 
/**
 * Build a complete dashboard section HTML.
 *
 * Generates an accessible dashboard with metric cards and chart containers.
 * Charts are embedded as Canvas elements with `data-chart-config` JSON
 * attributes for client-side Chart.js hydration. A `<noscript>` fallback
 * provides accessible data tables when JavaScript is disabled.
 *
 * Returns an empty string if the config is null/undefined or contains
 * no panels with content.
 *
 * @param config - Dashboard configuration (null/undefined returns empty string)
 * @param lang - BCP 47 language code for localized UI text (defaults to "en")
 * @param heading - Optional custom section heading override
 * @returns HTML section string or empty string
 */
export function buildDashboardSection(
  config: DashboardConfig | null | undefined,
  lang: string = 'en',
  heading?: string
): string {
  if (!config) return '';
  if (config.panels.length === 0) return '';
 
  const strings: DashboardStrings = getLocalizedString(DASHBOARD_STRINGS, lang);
  const sectionHeading = heading ?? config.title ?? strings.sectionHeading;
 
  const panelsHtml = config.panels
    .map((panel, index) => buildDashboardPanel(panel, index, strings))
    .filter((html) => html.length > 0)
    .join('\n            ');
 
  Iif (!panelsHtml) return '';
 
  return `
          <section class="dashboard" role="region" aria-label="${escapeHTML(sectionHeading)}">
            <h2>${escapeHTML(sectionHeading)}</h2>
            <div class="dashboard-grid">
            ${panelsHtml}
            </div>
          </section>`;
}
 
/**
 * Check whether a dashboard configuration contains any chart containers.
 * Useful for conditionally including Chart.js client-side scripts.
 *
 * @param config - Dashboard configuration
 * @returns True if at least one panel has a chart
 */
export function dashboardHasCharts(config: DashboardConfig | null | undefined): boolean {
  if (!config) return false;
  return config.panels.some((panel) => panel.chart !== undefined);
}