From 30020eddb9e591a6d811f21523f617d7b0ce3e17 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 13:08:28 +0000
Subject: [PATCH 1/6] Initial plan
From 2baccc5eaefec4d906282653b8a66fbde43a2fa4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 13:15:13 +0000
Subject: [PATCH 2/6] fix(matomo): add global CTA click tracking
Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135
Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com>
---
assets/source/js/matomoTracking.js | 142 ++++++++++++++++++++++++
assets/source/js/matomoTracking.test.js | 116 +++++++++++++++++++
assets/source/js/municipio.js | 4 +-
3 files changed, 261 insertions(+), 1 deletion(-)
create mode 100644 assets/source/js/matomoTracking.js
create mode 100644 assets/source/js/matomoTracking.test.js
diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js
new file mode 100644
index 0000000000..d21d0b113f
--- /dev/null
+++ b/assets/source/js/matomoTracking.js
@@ -0,0 +1,142 @@
+const matomoTrackingSelector = [
+ "button",
+ "a.btn",
+ "a.c-button",
+ 'a[role="button"]',
+].join(", ");
+
+const defaultTrackingCategory = "UI Interaction";
+const defaultTrackingAction = "Click";
+
+/**
+ * Normalize whitespace in tracking labels.
+ *
+ * @param {string | null | undefined} value The label to normalize.
+ * @returns {string} The normalized label.
+ */
+export function normalizeTrackingLabel(value) {
+ return (value || "").replace(/\s+/g, " ").trim();
+}
+
+/**
+ * Derive a Matomo tracking name for a CTA element.
+ *
+ * @param {Element} element The element to derive a name from.
+ * @returns {string} The derived tracking name.
+ */
+export function getMatomoTrackingName(element) {
+ return normalizeTrackingLabel(
+ element.getAttribute("data-matomo-name") ||
+ element.getAttribute("aria-label") ||
+ element.getAttribute("title") ||
+ element.innerText ||
+ element.textContent,
+ );
+}
+
+/**
+ * Apply default Matomo data attributes unless they are explicitly set.
+ *
+ * @param {HTMLElement} element The element to normalize.
+ * @returns {void}
+ */
+export function applyMatomoTrackingAttributes(element) {
+ const trackingName = getMatomoTrackingName(element);
+
+ if (!element.dataset.matomoCategory) {
+ element.dataset.matomoCategory = defaultTrackingCategory;
+ }
+
+ if (!element.dataset.matomoAction) {
+ element.dataset.matomoAction = defaultTrackingAction;
+ }
+
+ if (!element.dataset.matomoName && trackingName) {
+ element.dataset.matomoName = trackingName;
+ }
+}
+
+/**
+ * Find a trackable CTA element from an event target.
+ *
+ * @param {EventTarget | null} target The event target.
+ * @returns {HTMLElement | null} The closest trackable element.
+ */
+export function getMatomoTrackableElement(target) {
+ if (!(target instanceof Element)) {
+ return null;
+ }
+
+ const element = target.closest(matomoTrackingSelector);
+
+ return element instanceof HTMLElement ? element : null;
+}
+
+/**
+ * Normalize trackable elements in the current DOM.
+ *
+ * @returns {void}
+ */
+export function normalizeMatomoTrackableElements() {
+ document
+ .querySelectorAll(matomoTrackingSelector)
+ .forEach((element) =>
+ applyMatomoTrackingAttributes(element),
+ );
+}
+
+/**
+ * Handle delegated click tracking for Matomo.
+ *
+ * @param {MouseEvent} event The click event.
+ * @returns {void}
+ */
+export function handleMatomoTrackingClick(event) {
+ const trackableElement = getMatomoTrackableElement(event.target);
+
+ if (!trackableElement) {
+ return;
+ }
+
+ applyMatomoTrackingAttributes(trackableElement);
+
+ const trackingQueue = window._paq;
+
+ if (!trackingQueue || typeof trackingQueue.push !== "function") {
+ return;
+ }
+
+ const trackingName = trackableElement.dataset.matomoName || "";
+
+ if (!trackingName) {
+ return;
+ }
+
+ trackingQueue.push([
+ "trackEvent",
+ trackableElement.dataset.matomoCategory || defaultTrackingCategory,
+ trackableElement.dataset.matomoAction || defaultTrackingAction,
+ trackingName,
+ ]);
+}
+
+/**
+ * Initialize global Matomo CTA tracking.
+ *
+ * @returns {void}
+ */
+export function initializeMatomoTracking() {
+ document.addEventListener("click", handleMatomoTrackingClick);
+
+ if (document.readyState === "loading") {
+ document.addEventListener(
+ "DOMContentLoaded",
+ normalizeMatomoTrackableElements,
+ { once: true },
+ );
+
+ return;
+ }
+
+ normalizeMatomoTrackableElements();
+}
diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js
new file mode 100644
index 0000000000..98f3de8479
--- /dev/null
+++ b/assets/source/js/matomoTracking.test.js
@@ -0,0 +1,116 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import {
+ getMatomoTrackableElement,
+ handleMatomoTrackingClick,
+ initializeMatomoTracking,
+ normalizeMatomoTrackableElements,
+} from "./matomoTracking";
+
+describe("matomoTracking", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ window._paq = [];
+ });
+
+ afterEach(() => {
+ delete window._paq;
+ });
+
+ it("normalizes trackable elements with derived aria-label names", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ normalizeMatomoTrackableElements();
+
+ const button = document.querySelector("button");
+
+ expect(button.dataset.matomoCategory).toBe("UI Interaction");
+ expect(button.dataset.matomoAction).toBe("Click");
+ expect(button.dataset.matomoName).toBe("Open menu");
+ });
+
+ it("preserves explicitly provided matomo attributes", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ normalizeMatomoTrackableElements();
+
+ const link = document.querySelector("a");
+
+ expect(link.dataset.matomoCategory).toBe("Custom category");
+ expect(link.dataset.matomoAction).toBe("Open");
+ expect(link.dataset.matomoName).toBe("Custom name");
+ });
+
+ it("finds the closest trackable element for nested CTA content", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ const target = document.querySelector(".c-button__label-text");
+ const trackableElement = getMatomoTrackableElement(target);
+
+ expect(trackableElement).toBe(document.querySelector("a"));
+ });
+
+ it("tracks clicks for delegated button-like links", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ initializeMatomoTracking();
+
+ const target = document.querySelector(".c-button__label-text");
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([
+ ["trackEvent", "UI Interaction", "Click", "Read more"],
+ ]);
+ expect(document.querySelector("a").dataset.matomoName).toBe("Read more");
+ });
+
+ it("tracks dynamically injected buttons through delegated listeners", () => {
+ initializeMatomoTracking();
+
+ const button = document.createElement("button");
+ button.textContent = "Dynamic CTA";
+ document.body.appendChild(button);
+
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([
+ ["trackEvent", "UI Interaction", "Click", "Dynamic CTA"],
+ ]);
+ expect(button.dataset.matomoName).toBe("Dynamic CTA");
+ });
+
+ it("does not push events when the tracking queue is unavailable", () => {
+ document.body.innerHTML = '';
+ delete window._paq;
+
+ expect(() =>
+ handleMatomoTrackingClick({
+ target: document.querySelector("button"),
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/assets/source/js/municipio.js b/assets/source/js/municipio.js
index 0fdc96c177..88f8b0c216 100644
--- a/assets/source/js/municipio.js
+++ b/assets/source/js/municipio.js
@@ -6,6 +6,7 @@ import { initializeCollapsibleSearch } from './collapsibleSearch';
import { initializeSessionManager } from './sessionManager';
import { initializeHashHighlightManager } from './hashHighlightManager';
import { initializeHashUpdateManager } from './hashUpdateManager';
+import { initializeMatomoTracking } from './matomoTracking';
import { initPostsListAsync } from './postsList';
import { initializeWpApiSettingsNonceRefresh } from './restApi/wpApiSettings';
@@ -20,4 +21,5 @@ initializeComments();
initializeCollapsibleSearch();
initializeHashHighlightManager();
initializeHashUpdateManager(8 * 10);
-initPostsListAsync();
\ No newline at end of file
+initializeMatomoTracking();
+initPostsListAsync();
From e1dba18b247a4e6d998a8c222469c0b04befccd5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 13:16:40 +0000
Subject: [PATCH 3/6] test(matomo): add tracking teardown coverage
Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135
Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com>
---
assets/source/js/matomoTracking.js | 12 ++++++++----
assets/source/js/matomoTracking.test.js | 13 +++++++++++++
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js
index d21d0b113f..c79b45acb2 100644
--- a/assets/source/js/matomoTracking.js
+++ b/assets/source/js/matomoTracking.js
@@ -7,6 +7,7 @@ const matomoTrackingSelector = [
const defaultTrackingCategory = "UI Interaction";
const defaultTrackingAction = "Click";
+const handleMatomoTrackingDomReady = () => normalizeMatomoTrackableElements();
/**
* Normalize whitespace in tracking labels.
@@ -131,12 +132,15 @@ export function initializeMatomoTracking() {
if (document.readyState === "loading") {
document.addEventListener(
"DOMContentLoaded",
- normalizeMatomoTrackableElements,
+ handleMatomoTrackingDomReady,
{ once: true },
);
-
- return;
+ } else {
+ normalizeMatomoTrackableElements();
}
- normalizeMatomoTrackableElements();
+ return () => {
+ document.removeEventListener("click", handleMatomoTrackingClick);
+ document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady);
+ };
}
diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js
index 98f3de8479..72707d2ce4 100644
--- a/assets/source/js/matomoTracking.test.js
+++ b/assets/source/js/matomoTracking.test.js
@@ -103,6 +103,19 @@ describe("matomoTracking", () => {
expect(button.dataset.matomoName).toBe("Dynamic CTA");
});
+ it("returns a cleanup function that removes delegated tracking", () => {
+ document.body.innerHTML = '';
+
+ const cleanup = initializeMatomoTracking();
+ cleanup();
+
+ document
+ .querySelector("button")
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([]);
+ });
+
it("does not push events when the tracking queue is unavailable", () => {
document.body.innerHTML = '';
delete window._paq;
From 6795ef93ab2cb1cee811b590770814a48cebe689 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 13:17:57 +0000
Subject: [PATCH 4/6] fix(matomo): tighten cleanup listener handling
Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135
Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com>
---
assets/source/js/matomoTracking.js | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js
index c79b45acb2..88bb12dbc8 100644
--- a/assets/source/js/matomoTracking.js
+++ b/assets/source/js/matomoTracking.js
@@ -124,12 +124,15 @@ export function handleMatomoTrackingClick(event) {
/**
* Initialize global Matomo CTA tracking.
*
- * @returns {void}
+ * @returns {() => void} Cleanup function for removing tracking listeners.
*/
export function initializeMatomoTracking() {
+ let hasDomReadyListener = false;
+
document.addEventListener("click", handleMatomoTrackingClick);
if (document.readyState === "loading") {
+ hasDomReadyListener = true;
document.addEventListener(
"DOMContentLoaded",
handleMatomoTrackingDomReady,
@@ -141,6 +144,9 @@ export function initializeMatomoTracking() {
return () => {
document.removeEventListener("click", handleMatomoTrackingClick);
- document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady);
+
+ if (hasDomReadyListener) {
+ document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady);
+ }
};
}
From 65e53928e7cbcb01e1192ba301172295c5a7ca63 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 14:45:11 +0000
Subject: [PATCH 5/6] fix(matomo): move component targeting to backend filters
Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/fe32e8a8-e44f-4b7c-9744-c6e47772ea4a
Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com>
---
assets/source/js/matomoTracking.js | 4 +-
assets/source/js/matomoTracking.test.js | 29 +++++++++++++--
library/Controller/BaseController.php | 49 +++++++++++++++++++++++++
3 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js
index 88bb12dbc8..ebbafa19bc 100644
--- a/assets/source/js/matomoTracking.js
+++ b/assets/source/js/matomoTracking.js
@@ -1,8 +1,6 @@
const matomoTrackingSelector = [
"button",
- "a.btn",
- "a.c-button",
- 'a[role="button"]',
+ "[data-matomo-category]",
].join(", ");
const defaultTrackingCategory = "UI Interaction";
diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js
index 72707d2ce4..67cc8af1b9 100644
--- a/assets/source/js/matomoTracking.test.js
+++ b/assets/source/js/matomoTracking.test.js
@@ -38,7 +38,6 @@ describe("matomoTracking", () => {
it("preserves explicitly provided matomo attributes", () => {
document.body.innerHTML = `
{
it("finds the closest trackable element for nested CTA content", () => {
document.body.innerHTML = `
-
+
Read more
`;
@@ -72,7 +75,11 @@ describe("matomoTracking", () => {
it("tracks clicks for delegated button-like links", () => {
document.body.innerHTML = `
-
+
Read more
`;
@@ -88,6 +95,22 @@ describe("matomoTracking", () => {
expect(document.querySelector("a").dataset.matomoName).toBe("Read more");
});
+ it("does not track links without backend-provided matomo markers", () => {
+ document.body.innerHTML = `
+
+ Read more
+
+ `;
+
+ initializeMatomoTracking();
+
+ document
+ .querySelector(".c-button__label-text")
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(window._paq).toEqual([]);
+ });
+
it("tracks dynamically injected buttons through delegated listeners", () => {
initializeMatomoTracking();
diff --git a/library/Controller/BaseController.php b/library/Controller/BaseController.php
index 6211e09aea..d66d24b051 100644
--- a/library/Controller/BaseController.php
+++ b/library/Controller/BaseController.php
@@ -448,6 +448,13 @@ function ($attributes) {
);
}
+ $this->wpService->addFilter(
+ 'ComponentLibrary/Component/Attribute',
+ [$this, 'filterMatomoTrackingAttributes'],
+ 20,
+ 1,
+ );
+
//Wordpress hooks
$this->data['hook'] = (object) array(
'innerLoopStart' => $this->hook('inner_loop_start'),
@@ -511,6 +518,48 @@ private function getCurrentUrl(array $queryParam = []): string
return urldecode($permalink ?? '');
}
+ /**
+ * Add default Matomo tracking attributes to rendered component links and buttons.
+ *
+ * @param array $attributes The component attributes.
+ *
+ * @return array The modified component attributes.
+ */
+ public function filterMatomoTrackingAttributes(array $attributes): array
+ {
+ if (!$this->shouldAddMatomoTrackingAttributes($attributes)) {
+ return $attributes;
+ }
+
+ if (empty($attributes['data-matomo-category'])) {
+ $attributes['data-matomo-category'] = 'UI Interaction';
+ }
+
+ if (empty($attributes['data-matomo-action'])) {
+ $attributes['data-matomo-action'] = 'Click';
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Determine whether a component should receive default Matomo tracking attributes.
+ *
+ * @param array $attributes The component attributes.
+ *
+ * @return bool True when Matomo tracking attributes should be added.
+ */
+ private function shouldAddMatomoTrackingAttributes(array $attributes): bool
+ {
+ $buttonTypes = ['button', 'submit', 'reset'];
+
+ return !empty($attributes['href'])
+ || in_array($attributes['type'] ?? null, $buttonTypes, true)
+ || ($attributes['role'] ?? null) === 'button'
+ || !empty($attributes['data-open'])
+ || !empty($attributes['aria-controls']);
+ }
+
/**
* Get the emblem to use
*
From c682509d98f08216456ddf0638fb60ea404d2ba6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 14:47:08 +0000
Subject: [PATCH 6/6] test(matomo): clarify backend targeting assertion
Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/fe32e8a8-e44f-4b7c-9744-c6e47772ea4a
Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com>
---
assets/source/js/matomoTracking.test.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js
index 67cc8af1b9..decc872d4d 100644
--- a/assets/source/js/matomoTracking.test.js
+++ b/assets/source/js/matomoTracking.test.js
@@ -95,7 +95,7 @@ describe("matomoTracking", () => {
expect(document.querySelector("a").dataset.matomoName).toBe("Read more");
});
- it("does not track links without backend-provided matomo markers", () => {
+ it("does not track links without matomo tracking attributes", () => {
document.body.innerHTML = `
Read more