diff --git a/src/content-gate/gate.js b/src/content-gate/gate.js index 6a17bae8b4..3d4366dacb 100644 --- a/src/content-gate/gate.js +++ b/src/content-gate/gate.js @@ -160,9 +160,19 @@ function getGateEventPayload( payload, gate ) { /** * Handle when the gate is seen. * - * @param {HTMLElement} gate The gate element. + * @param {HTMLElement} gate The gate element. + * @param {boolean} shouldRecordHit Whether to record a hit in RAS for this seen event. Defaults to false. */ -function handleSeen( gate ) { +function handleSeen( gate, shouldRecordHit = false ) { + if ( shouldRecordHit ) { + // paywall_hits - Number of times reader has reached a paywall. + window.newspackRAS = window.newspackRAS || []; + window.newspackRAS.push( function ( ras ) { + const currentHits = ras.store.get( 'paywall_hits' ) || 0; + ras.store.set( 'paywall_hits', currentHits + 1 ); + } ); + } + if ( 'function' !== typeof window.gtag ) { return; } @@ -314,14 +324,16 @@ domReady( function () { } else { window.addEventListener( 'resize', handleFloatingElements ); handleFloatingElements(); + let seen = false; // Seen event for inline gate. const detectSeen = () => { const delta = ( gate?.getBoundingClientRect().top || 0 ) - window.innerHeight / 2; if ( delta < 0 ) { + handleSeen( gate, ! seen ); if ( 'function' === typeof window.gtag ) { - handleSeen( gate ); document.removeEventListener( 'scroll', detectSeen ); } + seen = true; } }; document.addEventListener( 'scroll', detectSeen ); diff --git a/src/reader-activation/article-view.js b/src/reader-activation/article-view.js index b932ed2dbd..0b4893ac3a 100644 --- a/src/reader-activation/article-view.js +++ b/src/reader-activation/article-view.js @@ -36,5 +36,25 @@ export default function setupArticleViewsAggregates( ras ) { } per_month[ month ][ data.post_id ] = true; ras.store.set( 'article_view_per_month', per_month ); + + // articles_read — A cumulative count of articles the reader has read. + const uniqueViews = ras.getUniqueActivitiesBy( 'article_view', 'post_id' ); + ras.store.set( 'articles_read', uniqueViews.length ); + + // favorite_categories — A list of the reader's most-engaged content categories, ordered by frequency. + const allActivities = ras.getActivities( 'article_view' ); + const catCounts = {}; + for ( const activity of allActivities ) { + const cats = activity.data?.categories || []; + for ( const cat of cats ) { + catCounts[ cat ] = ( catCounts[ cat ] || 0 ) + 1; + } + } + const topCategories = Object.entries( catCounts ) + .filter( ( [ , count ] ) => count >= 2 ) + .sort( ( a, b ) => b[ 1 ] - a[ 1 ] ) + .slice( 0, 5 ) + .map( ( [ id ] ) => Number( id ) ); + ras.store.set( 'favorite_categories', topCategories ); } ); } diff --git a/src/reader-activation/article-view.test.js b/src/reader-activation/article-view.test.js new file mode 100644 index 0000000000..95e6e00244 --- /dev/null +++ b/src/reader-activation/article-view.test.js @@ -0,0 +1,76 @@ +import setupArticleViewsAggregates from './article-view'; +import { createMockRAS } from './mocks/ras'; + +describe( 'setupArticleViewsAggregates', () => { + let mock; + + beforeEach( () => { + mock = createMockRAS(); + setupArticleViewsAggregates( mock.ras ); + } ); + + afterEach( () => { + mock.reset(); + } ); + + function simulateArticleView( data, timestamp = Date.now() ) { + mock.trigger( 'activity', { action: 'article_view', data, timestamp } ); + } + + it( 'should register an activity listener', () => { + expect( mock.ras.on ).toHaveBeenCalledWith( 'activity', expect.any( Function ) ); + } ); + + it( 'should ignore non-article_view actions', () => { + mock.trigger( 'activity', { action: 'other_action', data: {}, timestamp: Date.now() } ); + expect( mock.ras.store.set ).not.toHaveBeenCalled(); + } ); + + describe( 'articles_read', () => { + it( 'should set articles_read to count of unique post IDs', () => { + mock.addActivity( 'article_view', { post_id: 1, categories: [] } ); + mock.addActivity( 'article_view', { post_id: 2, categories: [] } ); + simulateArticleView( { post_id: 2, categories: [] } ); + expect( mock.storeData.articles_read ).toBe( 2 ); + } ); + + it( 'should not increment for duplicate post IDs', () => { + mock.addActivity( 'article_view', { post_id: 1, categories: [] } ); + mock.addActivity( 'article_view', { post_id: 1, categories: [] } ); + simulateArticleView( { post_id: 1, categories: [] } ); + expect( mock.storeData.articles_read ).toBe( 1 ); + } ); + } ); + + describe( 'favorite_categories', () => { + it( 'should contain category IDs sorted by frequency', () => { + mock.addActivity( 'article_view', { post_id: 1, categories: [ 10, 20 ] } ); + mock.addActivity( 'article_view', { post_id: 2, categories: [ 10 ] } ); + mock.addActivity( 'article_view', { post_id: 3, categories: [ 20, 30 ] } ); + simulateArticleView( { post_id: 3, categories: [ 20, 30 ] } ); + // 10 appears 2x, 20 appears 2x, 30 appears 1x (excluded — needs >= 2). + expect( mock.storeData.favorite_categories ).toEqual( [ 10, 20 ] ); + } ); + + it( 'should exclude categories with only 1 view', () => { + mock.addActivity( 'article_view', { post_id: 1, categories: [ 10 ] } ); + simulateArticleView( { post_id: 2, categories: [ 20 ] } ); + // Each category has only 1 view. + expect( mock.storeData.favorite_categories ).toEqual( [] ); + } ); + + it( 'should limit to top 5 categories', () => { + // Each category needs at least 2 views to be included. + mock.addActivity( 'article_view', { post_id: 1, categories: [ 1, 2, 3, 4, 5, 6, 7 ] } ); + mock.addActivity( 'article_view', { post_id: 2, categories: [ 1, 2, 3, 4, 5, 6, 7 ] } ); + simulateArticleView( { post_id: 3, categories: [ 1, 2, 3, 4, 5, 6, 7 ] } ); + expect( mock.storeData.favorite_categories ).toHaveLength( 5 ); + } ); + + it( 'should handle articles with no categories', () => { + mock.addActivity( 'article_view', { post_id: 1 } ); + simulateArticleView( { post_id: 1 } ); + expect( mock.storeData.favorite_categories ).toEqual( [] ); + } ); + } ); +} ); diff --git a/src/reader-activation/engagement.js b/src/reader-activation/engagement.js new file mode 100644 index 0000000000..040abbf961 --- /dev/null +++ b/src/reader-activation/engagement.js @@ -0,0 +1,19 @@ +/* globals newspack_reader_data */ + +/** + * Set up general reader engagement fields. + * + * @param {Object} ras Reader Activation object. + */ +export default function setupEngagement( ras ) { + // first_visit_date — preserve the oldest known value (server or client). + const serverFirstVisit = newspack_reader_data?.items?.first_visit_date; + const serverValue = serverFirstVisit ? JSON.parse( serverFirstVisit ) : null; + const clientValue = ras.store.get( 'first_visit_date' ); + const candidates = [ serverValue, clientValue ].filter( Boolean ); + const firstVisit = candidates.length ? Math.min( ...candidates ) : Date.now(); + ras.store.set( 'first_visit_date', firstVisit ); + + // last_active — Date reader was last seen on site. + ras.store.set( 'last_active', Date.now() ); +} diff --git a/src/reader-activation/engagement.test.js b/src/reader-activation/engagement.test.js new file mode 100644 index 0000000000..d09487a0ad --- /dev/null +++ b/src/reader-activation/engagement.test.js @@ -0,0 +1,62 @@ +import setupEngagement from './engagement'; +import { createMockRAS } from './mocks/ras'; + +describe( 'setupEngagement', () => { + let mock; + + beforeEach( () => { + mock = createMockRAS(); + window.newspack_reader_data = {}; + } ); + + afterEach( () => { + mock.reset(); + delete window.newspack_reader_data; + } ); + + it( 'should set first_visit_date on first call', () => { + setupEngagement( mock.ras ); + expect( mock.ras.store.set ).toHaveBeenCalledWith( 'first_visit_date', expect.any( Number ) ); + } ); + + it( 'should preserve existing client first_visit_date when no server value', () => { + mock.storeData.first_visit_date = 1000; + setupEngagement( mock.ras ); + expect( mock.storeData.first_visit_date ).toBe( 1000 ); + } ); + + it( 'should prefer older server value over newer client value', () => { + const oldServerValue = 1000; + const newClientValue = 9999; + mock.storeData.first_visit_date = newClientValue; + window.newspack_reader_data = { + items: { first_visit_date: JSON.stringify( oldServerValue ) }, + }; + setupEngagement( mock.ras ); + expect( mock.storeData.first_visit_date ).toBe( oldServerValue ); + } ); + + it( 'should prefer older client value over newer server value', () => { + const oldClientValue = 1000; + const newServerValue = 9999; + mock.storeData.first_visit_date = oldClientValue; + window.newspack_reader_data = { + items: { first_visit_date: JSON.stringify( newServerValue ) }, + }; + setupEngagement( mock.ras ); + expect( mock.storeData.first_visit_date ).toBe( oldClientValue ); + } ); + + it( 'should always set last_active', () => { + setupEngagement( mock.ras ); + expect( mock.ras.store.set ).toHaveBeenCalledWith( 'last_active', expect.any( Number ) ); + } ); + + it( 'should set last_active to a recent timestamp', () => { + const before = Date.now(); + setupEngagement( mock.ras ); + const after = Date.now(); + expect( mock.storeData.last_active ).toBeGreaterThanOrEqual( before ); + expect( mock.storeData.last_active ).toBeLessThanOrEqual( after ); + } ); +} ); diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 7a06913d6a..68e512c9d7 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -9,6 +9,7 @@ import { getCookie, setCookie, generateID, debugLog } from './utils.js'; import overlays from './overlays.js'; import initAnalytics from './analytics.js'; import setupArticleViewsAggregates from './article-view.js'; +import setupEngagement from './engagement.js'; import initSubscriptionTiersForm from './subscription-tiers-form.js'; import { openAuthModal as _openAuthModal } from '../reader-activation-auth/auth-modal.js'; @@ -490,6 +491,7 @@ function init() { initSubscriptionTiersForm( readerActivation ); fixClientID(); setupArticleViewsAggregates( readerActivation ); + setupEngagement( readerActivation ); attachAuthCookiesListener(); attachNewsletterFormListener(); pushActivities(); diff --git a/src/reader-activation/mocks/ras.js b/src/reader-activation/mocks/ras.js new file mode 100644 index 0000000000..9a42c958d3 --- /dev/null +++ b/src/reader-activation/mocks/ras.js @@ -0,0 +1,75 @@ +/** + * Creates a mock RAS (Reader Activation System) object for testing. + * + * @return {Object} Mock RAS with store, event handlers, and activity helpers. + */ +export function createMockRAS() { + const storeData = {}; + const activities = []; + const handlers = {}; + + const ras = { + store: { + get: jest.fn( key => storeData[ key ] ?? null ), + set: jest.fn( ( key, value ) => { + storeData[ key ] = value; + } ), + }, + on: jest.fn( ( event, callback ) => { + handlers[ event ] = callback; + } ), + getActivities: jest.fn( () => activities ), + getUniqueActivitiesBy: jest.fn( () => { + const seen = {}; + return activities.filter( a => { + if ( seen[ a.data.post_id ] ) { + return false; + } + seen[ a.data.post_id ] = true; + return true; + } ); + } ), + }; + + return { + ras, + /** + * Get the current store data. + */ + storeData, + /** + * Add an activity to the internal activities array. + * + * @param {string} action Activity action name. + * @param {Object} data Activity data. + * @param {number} timestamp Optional timestamp. + */ + addActivity( action, data, timestamp = Date.now() ) { + activities.push( { action, data, timestamp } ); + }, + /** + * Trigger a registered event handler. + * + * @param {string} event Event name. + * @param {Object} detail Event detail payload. + */ + trigger( event, detail ) { + if ( handlers[ event ] ) { + handlers[ event ]( { detail } ); + } + }, + /** + * Reset all state between tests. + */ + reset() { + for ( const key in storeData ) { + delete storeData[ key ]; + } + for ( const event in handlers ) { + delete handlers[ event ]; + } + activities.length = 0; + jest.clearAllMocks(); + }, + }; +}