diff --git a/app/javascript/application.js b/app/javascript/application.js index b3e67a5a..36d735e0 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,6 +2,7 @@ import "@hotwired/turbo-rails" import "controllers" import "loading_spinner" +import "matomo_events" // Show the progress bar after 200 milliseconds, not the default 500 Turbo.config.drive.progressBarDelay = 200; \ No newline at end of file diff --git a/app/javascript/filters.js b/app/javascript/filters.js index 32a79cee..16ad8aac 100644 --- a/app/javascript/filters.js +++ b/app/javascript/filters.js @@ -1,3 +1,5 @@ +import { trackFilterChange } from 'matomo_events' + // These elements aren't loaded with the initial DOM, they appear later. function initFilterToggle() { var filter_toggle = document.getElementById('filter-toggle'); @@ -9,7 +11,13 @@ function initFilterToggle() { }); [...filter_categories].forEach(element => { element.addEventListener('click', event => { - element.getElementsByClassName('filter-label')[0].classList.toggle('expanded'); + const label = element.getElementsByClassName('filter-label')[0]; + label.classList.toggle('expanded'); + + // Track filter category expansion + const filterName = label ? label.textContent.trim() : 'unknown'; + const isExpanded = label.classList.contains('expanded'); + trackFilterChange(filterName, 'category', isExpanded ? 'expanded' : 'collapsed'); }); }); } diff --git a/app/javascript/loading_spinner.js b/app/javascript/loading_spinner.js index 9aa6655c..556d36b6 100644 --- a/app/javascript/loading_spinner.js +++ b/app/javascript/loading_spinner.js @@ -1,3 +1,5 @@ +import { trackPagination } from 'matomo_events' + // Update the tab UI to reflect the newly-requested state. This function is called // by a click event handler in the tab UI. It follows a two-step process: function swapTabs(new_target) { @@ -56,6 +58,13 @@ document.addEventListener('click', function(event) { // Handle pagination clicks if (clickedElement.matches('.first a, .previous a, .next a')) { + // Track pagination + const urlParams = new URLSearchParams(clickedElement.href); + const pageParam = urlParams.get('page'); + if (pageParam) { + trackPagination(parseInt(pageParam)); + } + // Throw the spinner on the search results immediately document.getElementById('search-results').classList.add('spinner'); diff --git a/app/javascript/matomo_events.js b/app/javascript/matomo_events.js new file mode 100644 index 00000000..e7ec597e --- /dev/null +++ b/app/javascript/matomo_events.js @@ -0,0 +1,145 @@ +/** + * Matomo Tag Manager Event Dispatcher + * + * Pushes custom events to window._mtm for Tag Manager to capture. + * This module automatically tracks page views on turbo:load (history changes) + * and provides helper functions for tracking custom interactions. + * + * Ensures _mtm queue exists and safely pushes event objects for Tag Manager processing. + */ + +// Ensure _mtm queue exists +window._mtm = window._mtm || []; + +/** + * Push a custom event object to Matomo Tag Manager queue + * @param {Object} eventData - Event object with custom properties (e.g., {event: 'search_submitted', query: 'test'}) + */ +function pushEvent(eventData) { + if (!window._mtm) { + console.warn('Matomo Tag Manager (_mtm) not available'); + return; + } + window._mtm.push(eventData); +} + +/** + * Track a page view when URL/history changes (for SPA navigation) + * Pushes page metadata to Tag Manager for processing + * @param {string} pageUrl - Current page URL (defaults to window.location.href) + * @param {string} pageTitle - Page title (defaults to document.title) + */ +export function trackPageView(pageUrl = window.location.href, pageTitle = document.title) { + pushEvent({ + 'event': 'page_view', + 'page_url': pageUrl, + 'page_path': new URL(pageUrl).pathname + new URL(pageUrl).search, + 'page_title': pageTitle, + 'timestamp': new Date().toISOString() + }); +} + +/** + * Track a search submission + * @param {string} query - Search query text + * @param {Object} options - Additional event properties (searchType, filters, etc.) + */ +export function trackSearch(query, options = {}) { + pushEvent({ + 'event': 'search_submitted', + 'search_query': query, + 'search_type': options.searchType || 'keyword', + 'timestamp': new Date().toISOString(), + ...options + }); +} + +/** + * Track a filter change/interaction + * @param {string} filterName - Name of the filter (e.g., 'language', 'content_type') + * @param {string|Array} filterValue - Selected filter value(s) + * @param {string} action - Action type ('applied', 'removed', 'changed') + */ +export function trackFilterChange(filterName, filterValue, action = 'applied') { + pushEvent({ + 'event': 'filter_interaction', + 'filter_name': filterName, + 'filter_value': Array.isArray(filterValue) ? filterValue.join(',') : filterValue, + 'filter_action': action, + 'timestamp': new Date().toISOString() + }); +} + +/** + * Track a tab/source selection change + * @param {string} tabName - Tab name (e.g., 'primo', 'timdex', 'all') + */ +export function trackTabChange(tabName) { + pushEvent({ + 'event': 'tab_selected', + 'tab_name': tabName, + 'timestamp': new Date().toISOString() + }); +} + +/** + * Track pagination interaction + * @param {number} pageNumber - Page number selected + * @param {Object} options - Additional properties (per_page, total_results, etc.) + */ +export function trackPagination(pageNumber, options = {}) { + pushEvent({ + 'event': 'pagination', + 'page_number': pageNumber, + 'timestamp': new Date().toISOString(), + ...options + }); +} + +/** + * Track a custom interaction + * @param {string} eventName - Name of the event + * @param {Object} eventData - Custom event properties + */ +export function trackCustomEvent(eventName, eventData = {}) { + pushEvent({ + 'event': eventName, + 'timestamp': new Date().toISOString(), + ...eventData + }); +} + +/** + * Signal to Tag Manager that DOM content has been updated and Element Visibility conditions should be re-evaluated + * This is crucial for Matomo Tag Manager's Element Visibility triggers to work on page navigation without refresh + * Tag Manager creates Element Visibility triggers that only evaluate on initial page load—calling this + * after turbo:load tells Tag Manager to re-check visibility conditions for elements that may have appeared/disappeared + * @see https://matomo.org/guide/matomo-tag-manager/element-visibility/ + */ +export function notifyDOMUpdated() { + pushEvent({ + 'event': 'dom_updated', + 'timestamp': new Date().toISOString() + }); +} + +/** + * Listen for Turbo navigation and track page views automatically + * This allows Tag Manager to track history changes (page views without full page refresh) + */ +function initTurboTracking() { + let previousPageUrl = null; + + document.addEventListener('turbo:load', function(event) { + // Only track if URL actually changed (not the initial page load) + if (previousPageUrl && previousPageUrl !== window.location.href) { + trackPageView(window.location.href, document.title); + // Signal to Tag Manager that DOM has been updated so Element Visibility triggers are re-evaluated + notifyDOMUpdated(); + } + previousPageUrl = window.location.href; + }); +} + +// Initialize automatic Turbo tracking when module loads +initTurboTracking(); diff --git a/app/javascript/search_form.js b/app/javascript/search_form.js index fa603040..6bfa7582 100644 --- a/app/javascript/search_form.js +++ b/app/javascript/search_form.js @@ -1,3 +1,5 @@ +import { trackSearch, trackCustomEvent } from 'matomo_events' + var keywordField = document.getElementById('basic-search-main'); var advancedPanel = document.getElementById('advanced-search-panel'); var geoboxPanel = document.getElementById('geobox-search-panel'); @@ -61,20 +63,50 @@ function updateKeywordPlaceholder() { // panel. In all other TIMDEX UI apps, it's just the advanced panel. if (Array.from(allPanels).includes(geoboxPanel && geodistancePanel)) { document.getElementById('geobox-summary').addEventListener('click', () => { + trackCustomEvent('search_panel_toggled', { panel_type: 'geobox' }); togglePanelState(geoboxPanel); }); document.getElementById('geodistance-summary').addEventListener('click', () => { + trackCustomEvent('search_panel_toggled', { panel_type: 'geodistance' }); togglePanelState(geodistancePanel); }); document.getElementById('advanced-summary').addEventListener('click', () => { + trackCustomEvent('search_panel_toggled', { panel_type: 'advanced' }); togglePanelState(advancedPanel); }); } else { document.getElementById('advanced-summary').addEventListener('click', () => { + trackCustomEvent('search_panel_toggled', { panel_type: 'advanced' }); togglePanelState(advancedPanel); }); } +// Track form submission +const searchForm = document.querySelector('form[data-turbo-confirm], form[action*="/results"]'); +if (searchForm) { + searchForm.addEventListener('submit', (e) => { + const query = keywordField.value; + const searchType = determineSearchType(); + + if (query) { + trackSearch(query, { + searchType: searchType, + advanced_search: advancedPanel.open, + geobox_search: geoboxPanel ? geoboxPanel.open : false, + geodistance_search: geodistancePanel ? geodistancePanel.open : false + }); + } + }); +} + +// Helper function to determine which search type is active +function determineSearchType() { + if (geoboxPanel && geoboxPanel.open) return 'geobox'; + if (geodistancePanel && geodistancePanel.open) return 'geodistance'; + if (advancedPanel && advancedPanel.open) return 'advanced'; + return 'keyword'; +} + console.log('search_form.js loaded'); diff --git a/app/javascript/source_tabs.js b/app/javascript/source_tabs.js index 83e0ba72..52c1dda5 100644 --- a/app/javascript/source_tabs.js +++ b/app/javascript/source_tabs.js @@ -3,6 +3,8 @@ // Source: https://css-tricks.com/container-adapting-tabs-with-more-button/ // =========================================================================== +import { trackTabChange } from 'matomo_events' + // Store references to relevant selectors const container = document.querySelector('#tabs') const primary = container.querySelector('.primary') @@ -35,6 +37,17 @@ moreBtn.addEventListener('click', (e) => { moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary')) }) +// Track tab selection when a tab link is clicked +container.addEventListener('click', (e) => { + const tabLink = e.target.closest('a[href*="tab="]') + if (tabLink) { + const tabMatch = tabLink.href.match(/tab=([^&]*)/) + if (tabMatch) { + trackTabChange(tabMatch[1]) + } + } +}) + // adapt tabs const doAdapt = () => { diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 8647e56f..fb1eda8c 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -36,6 +36,16 @@ var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.async=true; g.src='<%= ENV['MATOMO_CONTAINER_URL'] %>'; s.parentNode.insertBefore(g,s); })(); + + // Track initial page view for Tag Manager + // (matomo_events.js will track subsequent page views on turbo:load) + _mtm.push({ + 'event': 'page_view', + 'page_url': window.location.href, + 'page_path': window.location.pathname + window.location.search, + 'page_title': document.title, + 'timestamp': new Date().toISOString() + }); <% elsif (ENV['MATOMO_URL'].present? && ENV['MATOMO_SITE_ID'].present?) %> diff --git a/config/importmap.rb b/config/importmap.rb index 672f444c..d6ed6df2 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -2,6 +2,7 @@ pin "application", preload: true pin "loading_spinner", preload: true +pin "matomo_events", preload: true pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true diff --git a/docs/MATOMO_TAG_MANAGER_TRACKING.md b/docs/MATOMO_TAG_MANAGER_TRACKING.md new file mode 100644 index 00000000..1b5cbbf0 --- /dev/null +++ b/docs/MATOMO_TAG_MANAGER_TRACKING.md @@ -0,0 +1,215 @@ +# Matomo Tag Manager Event Tracking Implementation + +## Overview + +This document describes the Matomo Tag Manager event tracking implementation for timdex-ui. The solution enables Matomo to track user interactions and page views when the page isn't fully refreshed—including history changes, tab selections, search submissions, filter interactions, and pagination. + +## Why This Was Needed + +When using Matomo **Tag Manager mode** (configured via `MATOMO_CONTAINER_URL`), the tag manager doesn't automatically track custom events. Without explicit event pushes to the `_mtm` queue, only the initial page load is tracked. When users navigate via Turbo Rails SPA features (without full page refresh), Tag Manager never sees those interactions. + +## Architecture + +### Module: `app/javascript/matomo_events.js` + +The core event dispatcher module that: +- Ensures `window._mtm` queue exists +- Exports helper functions to push events to Tag Manager +- Automatically listens to `turbo:load` events (SPA navigation) and tracks page views +- Provides specialized tracking functions for common interactions + +**Key Exports:** +- `trackPageView(pageUrl, pageTitle)` - Track page views on history changes +- `trackSearch(query, options)` - Track search submissions +- `trackFilterChange(filterName, filterValue, action)` - Track filter interactions +- `trackTabChange(tabName)` - Track source tab selections +- `trackPagination(pageNumber, options)` - Track pagination +- `trackCustomEvent(eventName, eventData)` - Generic event tracker + +### Integration Points + +#### 1. **Initial Page View** – `app/views/layouts/_head.html.erb` +```javascript +// Added to Tag Manager initialization +_mtm.push({ + 'event': 'page_view', + 'page_url': window.location.href, + 'page_path': window.location.pathname + window.location.search, + 'page_title': document.title, + 'timestamp': new Date().toISOString() +}); +``` + +#### 2. **SPA Navigation** – `app/javascript/matomo_events.js` +Automatically tracks page views when `turbo:load` fires (detects URL changes without full page refresh). + +#### 3. **Tab Selection** – `app/javascript/source_tabs.js` +```javascript +trackTabChange(tabName) // When user clicks primo/timdex/all tabs +``` + +#### 4. **Filter Interactions** – `app/javascript/filters.js` +```javascript +trackFilterChange(filterName, filterValue, action) // When filter categories expand/collapse +``` + +#### 5. **Search Submissions** – `app/javascript/search_form.js` +```javascript +trackSearch(query, options) // When user submits search +trackCustomEvent('search_panel_toggled', { panel_type: 'advanced' }) // Panel toggles +``` + +#### 6. **Pagination** – `app/javascript/loading_spinner.js` +```javascript +trackPagination(pageNumber) // When user clicks pagination links +``` + +### Event Data Structure + +All events pushed to `_mtm` follow a consistent structure: +```javascript +{ + 'event': 'event_name', // Required: identifies the event type + 'timestamp': ISO8601String, // When the event occurred + // ... additional custom properties +} +``` + +**Event Types Generated:** +- `page_view` - Page navigation without refresh +- `search_submitted` - User submitted a search +- `filter_interaction` - User changed filters +- `tab_selected` - User selected a source tab +- `pagination` - User navigated to a different page +- `search_panel_toggled` - User opened/closed advanced/geospatial search panels +- Custom events via `trackCustomEvent()` + +## Configuration in Matomo Tag Manager + +To capture and process these events in your Matomo Tag Manager: + +### Step 1: Create Data Layer Variable (Optional) +In Tag Manager UI, create variables to extract event properties: +- Variable Type: Data Layer Variable +- Data Layer Variable Name: `event` (captures the event type) + +### Step 2: Create Triggers +Create triggers that listen for custom events: + +1. **Page View Trigger** + - Trigger Type: Custom Event + - Event Name: `page_view` (exactly match the event name pushed to `_mtm`) + - Trigger fires on: All Custom Events with event type = page_view + +2. **Search Trigger** + - Trigger Type: Custom Event + - Event Name: `search_submitted` + +3. **Filter Trigger** + - Trigger Type: Custom Event + - Event Name: `filter_interaction` + +4. **Tab Trigger** + - Trigger Type: Custom Event + - Event Name: `tab_selected` + +5. **Pagination Trigger** + - Trigger Type: Custom Event + - Event Name: `pagination` + +### Step 3: Create Tags +Create tags that fire on these triggers. Examples: + +1. **Matomo Pageview Tag** + - Trigger: Page View Trigger + - Tag Type: Matomo Analytics + - Action: Track Page View + - Page Title: `{{page_title}}` + - Page URL: `{{page_url}}` + +2. **Matomo Event Tag** (for site search, filters, tabs, etc.) + - Trigger: (specific event trigger) + - Tag Type: Matomo Analytics + - Action: Track Event + - Category: `custom_user_interaction` (or appropriate category) + - Action: `{{event}}` + - Label: (extract from event data if needed) + +## How It Works: User Flow + +1. **User loads page** → Initial page view tracked via `_head.html.erb` +2. **User navigates to search results** → `turbo:load` fires → `matomo_events.js` automatically pushes page_view to `_mtm` +3. **User selects a tab (Primo/Timdex)** → `source_tabs.js` calls `trackTabChange()` → Event pushed to `_mtm` → Tag Manager trigger captures it +4. **User applies filters** → `filters.js` calls `trackFilterChange()` → Event pushed to `_mtm` → Tag Manager trigger captures it +5. **User submits search** → `search_form.js` calls `trackSearch()` → Event pushed to `_mtm` → Tag Manager trigger captures it +6. **User clicks pagination** → `loading_spinner.js` calls `trackPagination()` → Event pushed to `_mtm` → Tag Manager trigger captures it + +Tag Manager processes these events according to your configured triggers/tags and sends them to your Matomo analytics backend. + +## Testing + +To verify tracking is working: + +1. **Check in Matomo Real-time View:** + - Navigate through the app without page refresh + - You should see events appearing in real-time in your Matomo instance + +2. **Use browser console:** + ```javascript + // Check if _mtm queue has events + console.log(window._mtm); + ``` + You should see entries like: + ```javascript + [{event: 'page_view', page_url: '...', timestamp: '...'}, ...] + ``` + +3. **Use Tag Manager Debug Mode:** + - See Matomo Tag Manager's debug console to verify triggers are firing and tags are executing + +## Environment Variables + +Make sure these are configured for Tag Manager to work: + +- `MATOMO_CONTAINER_URL` - URL to your Matomo Tag Manager container script (e.g., `https://yourdomain.matomo.cloud/path/to/container.js`) + +Do NOT set `MATOMO_URL` and `MATOMO_SITE_ID` if using Tag Manager mode (they enable legacy mode instead). + +## Files Modified + +- `app/javascript/matomo_events.js` (created) +- `app/javascript/application.js` (added import) +- `app/views/layouts/_head.html.erb` (added initial page_view event) +- `app/javascript/source_tabs.js` (added tab tracking) +- `app/javascript/filters.js` (added filter tracking) +- `app/javascript/search_form.js` (added search & panel tracking) +- `app/javascript/loading_spinner.js` (added pagination tracking) +- `config/importmap.rb` (added matomo_events module) + +## Troubleshooting + +### Events not appearing in Matomo +1. Verify `MATOMO_CONTAINER_URL` is set correctly and the container script loads successfully (check browser Network tab) +2. Confirm Tag Manager triggers match the event names being pushed (exactly case-sensitive) +3. Check browser console for any JavaScript errors +4. Use Tag Manager's debug mode to see which events are being captured + +### Events only on initial page load +This usually means `turbo:load` tracking is working but other interactions aren't. Check: +1. That the JavaScript modifications to `source_tabs.js`, `filters.js`, etc. are correct +2. Browser console for any import/module errors +3. That the event functions are being called when interactions happen + +### Performance or double-tracking +- Events are pushed asynchronously to `_mtm` without blocking page interactions +- No duplicate pushes should occur; each interaction has one tracking point +- If seeing duplicates, check Tag Manager trigger conditions to ensure they don't match multiple times + +## Future Enhancements + +Possible additions to track more interactions: +- Link clicks to external resources +- Facet/filter value selections (not just category expansions) +- Full record view interactions +- OpenAlex/LibKey fulfillment link clicks +- Error/failed search tracking