From b3297a6e2720fa91789bcae5cda70d7b950f0943 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 24 Mar 2026 12:49:25 -0400 Subject: [PATCH 1/7] feat(reader-data): add engagement fields Co-Authored-By: Claude Opus 4.6 (1M context) --- includes/content-gate/class-metering.php | 7 +++++++ src/content-gate/metering.js | 2 ++ src/reader-activation/article-view.js | 19 +++++++++++++++++++ src/reader-activation/engagement.js | 14 ++++++++++++++ src/reader-activation/index.js | 2 ++ 5 files changed, 44 insertions(+) create mode 100644 src/reader-activation/engagement.js diff --git a/includes/content-gate/class-metering.php b/includes/content-gate/class-metering.php index 45f6c444f5..146deb5d4e 100644 --- a/includes/content-gate/class-metering.php +++ b/includes/content-gate/class-metering.php @@ -426,6 +426,13 @@ public static function is_logged_in_metering_allowed( $post_id = null ) { // Allowed if the content has been accessed or the metering limit has not been reached. $allowed = $accessed_content || ! $limited; + // Increment paywall_hits in reader data when the reader is blocked. + if ( ! $allowed ) { + $current_hits = Reader_Data::get_data( \get_current_user_id(), 'paywall_hits' ); + $current_hits = $current_hits ? (int) json_decode( $current_hits ) : 0; + Reader_Data::update_item( \get_current_user_id(), 'paywall_hits', $current_hits + 1 ); + } + /** * Filters whether to allow content rendering through metering for logged in user. * diff --git a/src/content-gate/metering.js b/src/content-gate/metering.js index 924bf09213..db9435e71f 100644 --- a/src/content-gate/metering.js +++ b/src/content-gate/metering.js @@ -94,6 +94,8 @@ function meter( ras ) { if ( settings.count <= data.content.length && ! data.content.includes( settings.post_id ) ) { lockContent( ras ); ras.dispatchActivity( 'metering_restricted', { post_id: settings.post_id, metering: data } ); + const currentHits = ras.store.get( 'paywall_hits' ) || 0; + ras.store.set( 'paywall_hits', currentHits + 1 ); locked = true; } else { const gates = document.querySelectorAll( '.newspack-content-gate__gate' ); diff --git a/src/reader-activation/article-view.js b/src/reader-activation/article-view.js index b932ed2dbd..bb507a4589 100644 --- a/src/reader-activation/article-view.js +++ b/src/reader-activation/article-view.js @@ -36,5 +36,24 @@ export default function setupArticleViewsAggregates( ras ) { } per_month[ month ][ data.post_id ] = true; ras.store.set( 'article_view_per_month', per_month ); + + // articles_read — count of unique articles viewed. + const uniqueViews = ras.getUniqueActivitiesBy( 'article_view', 'post_id' ); + ras.store.set( 'articles_read', uniqueViews.length ); + + // favorite_categories — top 5 category IDs by view 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 ) + .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/engagement.js b/src/reader-activation/engagement.js new file mode 100644 index 0000000000..dd3c7bde74 --- /dev/null +++ b/src/reader-activation/engagement.js @@ -0,0 +1,14 @@ +/** + * Set up general reader engagement fields. + * + * @param {Object} ras Reader Activation object. + */ +export default function setupEngagement( ras ) { + // first_visit_date — set once, never overwritten. + if ( ! ras.store.get( 'first_visit_date' ) ) { + ras.store.set( 'first_visit_date', Date.now() ); + } + + // last_active — update on every page load. + ras.store.set( 'last_active', Date.now() ); +} 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(); From c3db9f42213b00263aa0e9e63aac680f6784f07d Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 24 Mar 2026 13:08:36 -0400 Subject: [PATCH 2/7] fix: check metered state after filter --- includes/content-gate/class-metering.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/includes/content-gate/class-metering.php b/includes/content-gate/class-metering.php index 146deb5d4e..77aab7bebf 100644 --- a/includes/content-gate/class-metering.php +++ b/includes/content-gate/class-metering.php @@ -426,13 +426,6 @@ public static function is_logged_in_metering_allowed( $post_id = null ) { // Allowed if the content has been accessed or the metering limit has not been reached. $allowed = $accessed_content || ! $limited; - // Increment paywall_hits in reader data when the reader is blocked. - if ( ! $allowed ) { - $current_hits = Reader_Data::get_data( \get_current_user_id(), 'paywall_hits' ); - $current_hits = $current_hits ? (int) json_decode( $current_hits ) : 0; - Reader_Data::update_item( \get_current_user_id(), 'paywall_hits', $current_hits + 1 ); - } - /** * Filters whether to allow content rendering through metering for logged in user. * @@ -441,6 +434,13 @@ public static function is_logged_in_metering_allowed( $post_id = null ) { */ self::$logged_in_metering_cache[ $post_id ] = apply_filters( 'newspack_content_gate_is_logged_in_metering_allowed', $allowed, $post_id ); + // Increment paywall_hits in reader data when the reader is blocked. + if ( ! self::$logged_in_metering_cache[ $post_id ] ) { + $current_hits = Reader_Data::get_data( \get_current_user_id(), 'paywall_hits' ); + $current_hits = $current_hits ? (int) json_decode( $current_hits ) : 0; + Reader_Data::update_item( \get_current_user_id(), 'paywall_hits', $current_hits + 1 ); + } + return self::$logged_in_metering_cache[ $post_id ]; } From ab17b6a419b33b2a40d97cdf465a1bd3d66fe99f Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 24 Mar 2026 13:25:52 -0400 Subject: [PATCH 3/7] fix: add unit tests --- src/reader-activation/article-view.test.js | 67 ++++++++++++++++++++ src/reader-activation/engagement.test.js | 39 ++++++++++++ src/reader-activation/mocks/ras.js | 72 ++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/reader-activation/article-view.test.js create mode 100644 src/reader-activation/engagement.test.js create mode 100644 src/reader-activation/mocks/ras.js diff --git a/src/reader-activation/article-view.test.js b/src/reader-activation/article-view.test.js new file mode 100644 index 0000000000..6208593864 --- /dev/null +++ b/src/reader-activation/article-view.test.js @@ -0,0 +1,67 @@ +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. + expect( mock.storeData.favorite_categories ).toEqual( [ 10, 20, 30 ] ); + } ); + + it( 'should limit to top 5 categories', () => { + mock.addActivity( 'article_view', { post_id: 1, categories: [ 1, 2, 3, 4, 5, 6, 7 ] } ); + simulateArticleView( { post_id: 1, 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.test.js b/src/reader-activation/engagement.test.js new file mode 100644 index 0000000000..a32aaaae6b --- /dev/null +++ b/src/reader-activation/engagement.test.js @@ -0,0 +1,39 @@ +import setupEngagement from './engagement'; +import { createMockRAS } from './mocks/ras'; + +describe( 'setupEngagement', () => { + let mock; + + beforeEach( () => { + mock = createMockRAS(); + } ); + + afterEach( () => { + mock.reset(); + } ); + + 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 not overwrite first_visit_date on subsequent calls', () => { + mock.storeData.first_visit_date = 1000; + setupEngagement( mock.ras ); + const firstVisitCalls = mock.ras.store.set.mock.calls.filter( ( [ key ] ) => key === 'first_visit_date' ); + expect( firstVisitCalls ).toHaveLength( 0 ); + } ); + + 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/mocks/ras.js b/src/reader-activation/mocks/ras.js new file mode 100644 index 0000000000..46310c982d --- /dev/null +++ b/src/reader-activation/mocks/ras.js @@ -0,0 +1,72 @@ +/** + * 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 ]; + } + activities.length = 0; + jest.clearAllMocks(); + }, + }; +} From 847b4346d153d5899f7bf07e4494e954d9604389 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 24 Mar 2026 15:08:42 -0400 Subject: [PATCH 4/7] fix: update paywall_hits on every gate --- includes/content-gate/class-metering.php | 7 ------- src/content-gate/gate.js | 13 +++++++++---- src/content-gate/metering.js | 2 -- src/reader-activation/article-view.js | 4 ++-- src/reader-activation/engagement.js | 4 ++-- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/includes/content-gate/class-metering.php b/includes/content-gate/class-metering.php index 77aab7bebf..45f6c444f5 100644 --- a/includes/content-gate/class-metering.php +++ b/includes/content-gate/class-metering.php @@ -434,13 +434,6 @@ public static function is_logged_in_metering_allowed( $post_id = null ) { */ self::$logged_in_metering_cache[ $post_id ] = apply_filters( 'newspack_content_gate_is_logged_in_metering_allowed', $allowed, $post_id ); - // Increment paywall_hits in reader data when the reader is blocked. - if ( ! self::$logged_in_metering_cache[ $post_id ] ) { - $current_hits = Reader_Data::get_data( \get_current_user_id(), 'paywall_hits' ); - $current_hits = $current_hits ? (int) json_decode( $current_hits ) : 0; - Reader_Data::update_item( \get_current_user_id(), 'paywall_hits', $current_hits + 1 ); - } - return self::$logged_in_metering_cache[ $post_id ]; } diff --git a/src/content-gate/gate.js b/src/content-gate/gate.js index 6a17bae8b4..00d59ba836 100644 --- a/src/content-gate/gate.js +++ b/src/content-gate/gate.js @@ -163,6 +163,13 @@ function getGateEventPayload( payload, gate ) { * @param {HTMLElement} gate The gate element. */ function handleSeen( gate ) { + // 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; } @@ -318,10 +325,8 @@ domReady( function () { const detectSeen = () => { const delta = ( gate?.getBoundingClientRect().top || 0 ) - window.innerHeight / 2; if ( delta < 0 ) { - if ( 'function' === typeof window.gtag ) { - handleSeen( gate ); - document.removeEventListener( 'scroll', detectSeen ); - } + handleSeen( gate ); + document.removeEventListener( 'scroll', detectSeen ); } }; document.addEventListener( 'scroll', detectSeen ); diff --git a/src/content-gate/metering.js b/src/content-gate/metering.js index db9435e71f..924bf09213 100644 --- a/src/content-gate/metering.js +++ b/src/content-gate/metering.js @@ -94,8 +94,6 @@ function meter( ras ) { if ( settings.count <= data.content.length && ! data.content.includes( settings.post_id ) ) { lockContent( ras ); ras.dispatchActivity( 'metering_restricted', { post_id: settings.post_id, metering: data } ); - const currentHits = ras.store.get( 'paywall_hits' ) || 0; - ras.store.set( 'paywall_hits', currentHits + 1 ); locked = true; } else { const gates = document.querySelectorAll( '.newspack-content-gate__gate' ); diff --git a/src/reader-activation/article-view.js b/src/reader-activation/article-view.js index bb507a4589..3b06a2cbc9 100644 --- a/src/reader-activation/article-view.js +++ b/src/reader-activation/article-view.js @@ -37,11 +37,11 @@ export default function setupArticleViewsAggregates( ras ) { per_month[ month ][ data.post_id ] = true; ras.store.set( 'article_view_per_month', per_month ); - // articles_read — count of unique articles viewed. + // 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 — top 5 category IDs by view frequency. + // 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 ) { diff --git a/src/reader-activation/engagement.js b/src/reader-activation/engagement.js index dd3c7bde74..feb902c41e 100644 --- a/src/reader-activation/engagement.js +++ b/src/reader-activation/engagement.js @@ -4,11 +4,11 @@ * @param {Object} ras Reader Activation object. */ export default function setupEngagement( ras ) { - // first_visit_date — set once, never overwritten. + // first_visit_date — Date of the reader's very first visit to the site, regardless of whether or not they registered. if ( ! ras.store.get( 'first_visit_date' ) ) { ras.store.set( 'first_visit_date', Date.now() ); } - // last_active — update on every page load. + // last_active — Date reader was last seen on site. ras.store.set( 'last_active', Date.now() ); } From 21c456eef54e695c68163367c031be1800ae59e8 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Tue, 24 Mar 2026 15:58:13 -0400 Subject: [PATCH 5/7] fix: reinstate gtag check --- src/content-gate/gate.js | 27 +++++++++++++++++---------- src/reader-activation/mocks/ras.js | 3 +++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/content-gate/gate.js b/src/content-gate/gate.js index 00d59ba836..3d4366dacb 100644 --- a/src/content-gate/gate.js +++ b/src/content-gate/gate.js @@ -160,15 +160,18 @@ 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 ) { - // 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 ); - } ); +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; @@ -321,12 +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 ); - document.removeEventListener( 'scroll', detectSeen ); + handleSeen( gate, ! seen ); + if ( 'function' === typeof window.gtag ) { + document.removeEventListener( 'scroll', detectSeen ); + } + seen = true; } }; document.addEventListener( 'scroll', detectSeen ); diff --git a/src/reader-activation/mocks/ras.js b/src/reader-activation/mocks/ras.js index 46310c982d..9a42c958d3 100644 --- a/src/reader-activation/mocks/ras.js +++ b/src/reader-activation/mocks/ras.js @@ -65,6 +65,9 @@ export function createMockRAS() { for ( const key in storeData ) { delete storeData[ key ]; } + for ( const event in handlers ) { + delete handlers[ event ]; + } activities.length = 0; jest.clearAllMocks(); }, From 4a7f352a67b38e787686b2727841fe0e6266c8e1 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Thu, 2 Apr 2026 10:38:25 -0400 Subject: [PATCH 6/7] fix: always use oldest first visit date --- src/reader-activation/engagement.js | 13 +++++++---- src/reader-activation/engagement.test.js | 29 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/reader-activation/engagement.js b/src/reader-activation/engagement.js index feb902c41e..040abbf961 100644 --- a/src/reader-activation/engagement.js +++ b/src/reader-activation/engagement.js @@ -1,13 +1,18 @@ +/* globals newspack_reader_data */ + /** * Set up general reader engagement fields. * * @param {Object} ras Reader Activation object. */ export default function setupEngagement( ras ) { - // first_visit_date — Date of the reader's very first visit to the site, regardless of whether or not they registered. - if ( ! ras.store.get( 'first_visit_date' ) ) { - ras.store.set( 'first_visit_date', Date.now() ); - } + // 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 index a32aaaae6b..d09487a0ad 100644 --- a/src/reader-activation/engagement.test.js +++ b/src/reader-activation/engagement.test.js @@ -6,10 +6,12 @@ describe( 'setupEngagement', () => { beforeEach( () => { mock = createMockRAS(); + window.newspack_reader_data = {}; } ); afterEach( () => { mock.reset(); + delete window.newspack_reader_data; } ); it( 'should set first_visit_date on first call', () => { @@ -17,11 +19,32 @@ describe( 'setupEngagement', () => { expect( mock.ras.store.set ).toHaveBeenCalledWith( 'first_visit_date', expect.any( Number ) ); } ); - it( 'should not overwrite first_visit_date on subsequent calls', () => { + it( 'should preserve existing client first_visit_date when no server value', () => { mock.storeData.first_visit_date = 1000; setupEngagement( mock.ras ); - const firstVisitCalls = mock.ras.store.set.mock.calls.filter( ( [ key ] ) => key === 'first_visit_date' ); - expect( firstVisitCalls ).toHaveLength( 0 ); + 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', () => { From 68296d0e3ca8c2a46c8e0824d5376c7f69cbcec9 Mon Sep 17 00:00:00 2001 From: Rasmy Nguyen Date: Thu, 2 Apr 2026 15:23:25 -0400 Subject: [PATCH 7/7] fix: only count categories with 2 or more views --- src/reader-activation/article-view.js | 1 + src/reader-activation/article-view.test.js | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/reader-activation/article-view.js b/src/reader-activation/article-view.js index 3b06a2cbc9..0b4893ac3a 100644 --- a/src/reader-activation/article-view.js +++ b/src/reader-activation/article-view.js @@ -51,6 +51,7 @@ export default function setupArticleViewsAggregates( ras ) { } } const topCategories = Object.entries( catCounts ) + .filter( ( [ , count ] ) => count >= 2 ) .sort( ( a, b ) => b[ 1 ] - a[ 1 ] ) .slice( 0, 5 ) .map( ( [ id ] ) => Number( id ) ); diff --git a/src/reader-activation/article-view.test.js b/src/reader-activation/article-view.test.js index 6208593864..95e6e00244 100644 --- a/src/reader-activation/article-view.test.js +++ b/src/reader-activation/article-view.test.js @@ -48,13 +48,22 @@ describe( 'setupArticleViewsAggregates', () => { 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. - expect( mock.storeData.favorite_categories ).toEqual( [ 10, 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 ] } ); - simulateArticleView( { 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 ); } );