Drupalwoo ☉

Building a Drupal statistics dashboard with Chart.js integration

Building a Drupal statistics dashboard with Chart.js integration gives editors a clear view of publishing activity without replacing specialist analytics software. The dashboard can present content totals, publishing trends, taxonomy usage, and author activity using records already stored in Drupal. Chart.js provides the responsive charts, while Drupal manages permissions, content workflows, and the surrounding site experience. Drupal can report editorial content metrics directly; traffic analytics still require a source such as Matomo or server logs, so published totals should not be presented as equivalent to visits.

A maintainable implementation usually consists of a small custom Drupal module rather than a heavily modified contrib project. It exposes a permission-protected JSON endpoint, renders dashboard markup through Twig, and attaches a bundled Chart.js build to the page. This keeps data aggregation on the server, presentation in the browser, and access aligned with Drupal’s role and permission model. The module can remain independent of a particular theme and continue working when the visual design changes.

The finished page should answer routine editorial questions: How many items were published this month? Which content types are growing? Are important topics represented consistently? It should also show when the figures were last refreshed and which filters are active. Clear labels and restrained colour palettes are more useful than a crowded collection of decorative widgets.

Australian deployments add practical constraints. Dates should use en-AU and DD/MM/YYYY, while reporting periods must respect the site’s configured Australian timezone and applicable daylight-saving changes instead of a fixed offset. Teams in Sydney, Melbourne, Brisbane, and Perth may review the same data at different hours, so the dashboard should name its timezone. Local project teams often use plain labels such as “stats view” or “reporting page”, terminology that works well in editorial handovers.

Plan the metrics and data model

Begin with decisions rather than chart types. A publishing team might need monthly output, the share of each content type, new contributors, and the number of items carrying priority terms. A content team might instead need an ageing report showing untouched material. Each measure needs a definition, a time window, an owner, and a clear reason for inclusion; otherwise the dashboard accumulates vanity metrics that nobody uses.

Map each measure to Drupal data: nodes, users, taxonomy terms, comments, moderation states, or a field such as field_reviewed. Resolve entity labels in the aggregation layer rather than exposing numeric IDs to JavaScript. Grouping should happen in the site’s configured reporting timezone, and the service should return the same result for equivalent requests regardless of the visitor’s browser locale. Store metric definitions as configuration where practical so labels and thresholds can change without rewriting the controller.

Define a compact JSON contract before drawing anything. It should include a refresh timestamp, totals, a chronological series, and labelled categories. Keep personal data out of aggregates unless the report genuinely requires it, and use stable labels or opaque identifiers when a metric involves individual contributors.

{
  "updatedAt": "2025-03-08T09:00:00+11:00",
  "totals": { "published": 128 },
  "series": [
    { "label": "Jan", "value": 32 },
    { "label": "Feb", "value": 47 }
  ],
  "contentTypes": [
    { "label": "Article", "value": 84 }
  ]
}

Create the Drupal data endpoint

Create a custom module with a route that returns JsonResponse. Protect it with a dedicated view statistics dashboard permission so only authorised roles can retrieve the figures. Put reusable queries and aggregation logic in services; controllers should validate input and shape the response rather than contain all reporting rules.

statistics_dashboard.data:
  path: '/statistics-dashboard/data'
  defaults:
    _controller: '\Drupal\statistics_dashboard\Controller\DashboardDataController::data'
  requirements:
    _permission: 'view statistics dashboard'

For example, a controller can accept a whitelisted period and query published nodes. The following fragment shows the important permission, date, and validation boundaries; the reporting timezone should be read from site configuration rather than always using Sydney.

$days = (int) ($request->query->get('days') ?? 30);
if (!in_array($days, [7, 30, 90, 365], true)) {
  $days = 30;
}

$cutoff = (new DateTimeImmutable('now', new DateTimeZone('Australia/Sydney')))
  ->modify("-{$days} days");

$query = $this->database->select('node_field_data', 'n');
$query->fields('n', ['created', 'type']);
$query->condition('n.status', 1);
$query->condition('n.created', $cutoff->getTimestamp(), '>=');
$rows = $query->execute()->fetchAll();

Return a cacheable response with a short lifetime and invalidate it when relevant content changes. Cache contexts should include the user’s dashboard permission, while cache tags can cover node types and the nodes represented in the result. For large sites, calculate expensive totals in a queue worker and store the result in Drupal’s cache. If the dashboard must extend a contributed module, this hook interaction guide explains how modules coordinate without creating fragile dependencies. The JSON route is read-only, but any future save or export action still needs Drupal’s CSRF protection.

Render responsive Chart.js widgets

Attach the dashboard’s JavaScript through Drupal’s libraries API and pass the endpoint through drupalSettings. A Twig template then provides the canvas and a text summary. Give each widget a unique ID when a page contains more than one chart.

$build['#attached']['library'][] = 'statistics_dashboard';
$build['#attached']['drupalSettings']['statisticsDashboard'] = [
  'endpoint' => Url::fromRoute('statistics_dashboard.data')->toString(),
];
<div class="statistics-dashboard" data-drupal-selector="statistics-dashboard">
  <canvas id="statistics-chart" role="img"
    aria-describedby="statistics-chart-summary">
    Published content by month
  </canvas>
  <p id="statistics-chart-summary">Published content by month</p>
</div>

Bundle Chart.js with the site’s JavaScript build instead of relying on a public CDN. Pin a compatible major version, commit the generated bundle, and declare it in libraries.yml; this gives Content Security Policy and deployment teams a predictable local asset.

statistics_dashboard:
  version: 1.x
  js:
    - js/vendor/chart.umd.js: { minified: true }
    - js/statistics-dashboard.js: { attributes: { defer: true } }
(async () => {
  const root = document.querySelector(
    '[data-drupal-selector="statistics-dashboard"]'
  );
  if (!root) return;

  try {
    const response = await fetch(
      drupalSettings.statisticsDashboard.endpoint,
      { headers: { Accept: 'application/json' } }
    );
    if (!response.ok) throw new Error('Statistics request failed');

    const data = await response.json();
    new Chart(root.querySelector('canvas'), {
      type: 'bar',
      data: {
        labels: data.series.map(item => item.label),
        datasets: [{
          label: 'Published items',
          data: data.series.map(item => item.value),
          backgroundColor: '#1f6f8b'
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: { legend: { display: false } }
      }
    });
  } catch (error) {
    console.error('Unable to render statistics dashboard', error);
  }
})();

The visualisation should follow the shape of the question:

Chart.js view Best suited to Advantage Limitation
Line Publishing trends over time Reveals direction and sudden changes Labels can crowd short periods
Bar Comparing content types or topics Makes magnitude differences familiar Requires a sensible zero baseline
Doughnut Share of a small category set Presents compact part-to-whole data Becomes unclear with many slices
Stacked bar Content mix across periods Shows totals and composition together Depends on consistent colours and ordering

A canvas is not accessible by default. Keep an adjacent text summary, update a screen-reader-friendly data list after each fetch, and provide a meaningful aria-label or aria-describedby. Filters must remain keyboard operable, colours must not be the only distinction, and animation should respect the user’s reduced-motion preference. The chart container also needs a defined height on small screens so responsive resizing does not produce clipped labels.

Add filters, permissions and privacy

Filters belong in the URL so an editor can bookmark or share a view. Typical controls are period, content type, author, and taxonomy term. Pass only known keys and values to the server, validate every option against an allowlist, and calculate date boundaries in the reporting timezone. Format visible dates with Intl.DateTimeFormat('en-AU') so shared links remain unambiguous.

Permissions require extra care because a dashboard can reveal information that individual pages hide. The endpoint must apply the same audience and publishing rules as the underlying data, or intentionally aggregate to a level that cannot expose restricted content. Do not publish draft totals to anonymous users, and avoid author names when counts alone answer the question. For Australian organisations subject to the Privacy Act, document the purpose and retention of contributor data; security planning should also align with current ACSC guidance.

Treat the endpoint like any other public interface. Return only same-origin data, validate content types, limit request frequency, and log failures without recording sensitive filters. Cache responses per permission context, and keep Chart.js out of third-party domains so a strict Content Security Policy remains workable. A local Drupal module also gives Australian teams a supportable delivery model: project estimates and recurring managed-service costs can be separated clearly in AUD rather than hidden inside opaque plugin subscriptions.

Test, deploy and maintain the dashboard

Test the data and presentation separately. PHPUnit tests should cover date boundaries, timezone changes, empty datasets, content-type grouping, and denied permissions. A kernel test can confirm that the route returns JSON only for an authorised role, while a browser test can verify that Chart.js receives the expected labels and values. Use realistic fixtures, including a site with several content types and no activity in the current period. Automate these checks so routine Drupal updates expose regressions before editors see them.

Deployment should run npm ci, rebuild the Chart.js bundle, and make Drupal’s cache and asset pipeline refresh in a predictable order. Keep the generated asset versioned with the release, retain the previous bundle for rollback, and avoid manual edits to compiled JavaScript. Schedule Drupal and JavaScript dependency reviews, run security scans, and retest the chart after Chart.js major upgrades because options and plugin APIs can change.

Assign ownership for metric definitions, data freshness, and broken-endpoint alerts. A scheduled queue refresh can rebuild expensive aggregates, while short response caching keeps ordinary page views fast. Further Drupal development material can help when adapting the module to a different content model. Document the agreed definitions, the last review date, and the Australian support window so the dashboard remains a dependable editorial tool after launch.