diff --git a/dotcom-rendering/src/components/ScrollableHighlights.importable.tsx b/dotcom-rendering/src/components/ScrollableHighlights.importable.tsx
index 4ad888a866c..7320cde894c 100644
--- a/dotcom-rendering/src/components/ScrollableHighlights.importable.tsx
+++ b/dotcom-rendering/src/components/ScrollableHighlights.importable.tsx
@@ -1,5 +1,4 @@
import { css } from '@emotion/react';
-import type { ABTestAPI as ABTestAPIType } from '@guardian/ab-core';
import { from, space } from '@guardian/source/foundations';
import {
Button,
@@ -8,16 +7,8 @@ import {
SvgChevronRightSingle,
} from '@guardian/source/react-components';
import { useEffect, useRef, useState } from 'react';
-import { submitComponentEvent } from '../client/ophan/ophan';
import { getZIndex } from '../lib/getZIndex';
import { ophanComponentId } from '../lib/ophan-helpers';
-import {
- clearHighlightsState,
- getOrderedHighlights,
- onHighlightEvent,
- resetHighlightsState,
-} from '../lib/personaliseHighlights';
-import { useAB } from '../lib/useAB';
import { palette } from '../palette';
import type { DCRFrontCard } from '../types/front';
import { HighlightsCard } from './Masthead/HighlightsCard';
@@ -86,10 +77,6 @@ const carouselStyles = css`
position: relative;
`;
-const scrollSnapStyles = css`
- scroll-snap-type: x mandatory;
-`;
-
const itemStyles = css`
scroll-snap-align: start;
grid-area: span 1;
@@ -219,55 +206,12 @@ const getOphanInfo = (frontId?: string) => {
};
};
-const isEqual = (
- historicalHighlights: DCRFrontCard[],
- currentHighlights: DCRFrontCard[],
-) => {
- const c = currentHighlights.map((v) => v.url);
- const h = historicalHighlights.map((v) => v.url);
- return c.every((v) => {
- return h.includes(v);
- });
-};
export const ScrollableHighlights = ({ trails, frontId }: Props) => {
const carouselRef = useRef
(null);
const carouselLength = trails.length;
const imageLoading = 'eager';
const [showPreviousButton, setShowPreviousButton] = useState(false);
const [showNextButton, setShowNextButton] = useState(true);
- const [shouldShowHighlights, setShouldShowHighlights] = useState(false);
- const [orderedTrails, setOrderedTrails] = useState(trails);
-
- const ABTestAPI = useAB()?.api;
-
- type Attr =
- | 'click-tracking'
- | 'view-tracking'
- | 'click-and-view-tracking'
- | 'not-in-test'
- | undefined;
-
- const getUserABAttr = (api?: ABTestAPIType): Attr => {
- if (!api) return undefined;
-
- if (api.isUserInVariant('PersonalisedHighlights', 'click-tracking')) {
- return 'click-tracking';
- }
- if (api.isUserInVariant('PersonalisedHighlights', 'view-tracking')) {
- return 'view-tracking';
- }
- if (
- api.isUserInVariant(
- 'PersonalisedHighlights',
- 'click-and-view-tracking',
- )
- ) {
- return 'click-and-view-tracking';
- }
- return 'not-in-test';
- };
-
- const abTestPersonalisedHighlightAttr = getUserABAttr(ABTestAPI);
const scrollTo = (direction: 'left' | 'right') => {
if (!carouselRef.current) return;
@@ -302,15 +246,6 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
setShowNextButton(scrollLeft < maxScrollLeft);
};
- useEffect(() => {
- if (
- abTestPersonalisedHighlightAttr === 'view-tracking' ||
- abTestPersonalisedHighlightAttr === 'click-and-view-tracking'
- ) {
- onHighlightEvent('VIEW');
- }
- }, [abTestPersonalisedHighlightAttr]);
-
useEffect(() => {
const carouselElement = carouselRef.current;
if (!carouselElement) return;
@@ -328,57 +263,11 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
};
}, []);
- useEffect(() => {
- if (abTestPersonalisedHighlightAttr === undefined) {
- return;
- }
-
- if (abTestPersonalisedHighlightAttr === 'not-in-test') {
- clearHighlightsState();
- setOrderedTrails(trails);
- setShouldShowHighlights(true);
- return;
- }
-
- const personalisedHighlights = getOrderedHighlights(trails);
- if (
- personalisedHighlights.length === 0 ||
- personalisedHighlights.length !== trails.length ||
- !isEqual(personalisedHighlights, trails)
- ) {
- /* Reset to editorial order */
- resetHighlightsState(trails);
- setOrderedTrails(trails);
- setShouldShowHighlights(true);
- return;
- }
- /* Otherwise, use personalised order from local storage */
- setOrderedTrails(personalisedHighlights);
- setShouldShowHighlights(true);
- /* Fire a view event only if the container has been personalised */
- void submitComponentEvent(
- {
- component: {
- componentType: 'CONTAINER',
- id: `reordered-highlights-container`,
- },
- action: 'VIEW',
- },
- 'Web',
- );
- }, [trails, abTestPersonalisedHighlightAttr]);
-
const { ophanComponentLink, ophanComponentName, ophanFrontName } =
getOphanInfo(frontId);
return (
-
+
{
css={[
carouselStyles,
generateCarouselColumnStyles(carouselLength),
- /*
- * Enable scroll-snap only when visible to prevent browser scroll anchoring.
- * When items reorder, browsers try to keep previously visible items in view,
- * causing unwanted scrolling. Enabling snap after reordering forces position 0.
- // */
- shouldShowHighlights && scrollSnapStyles,
]}
data-heatphan-type="carousel"
>
- {orderedTrails.map((trail) => {
+ {trails.map((trail) => {
return (
- {
showQuotedHeadline={trail.showQuotedHeadline}
mainMedia={trail.mainMedia}
starRating={trail.starRating}
- trackCardClick={() => {
- if (
- abTestPersonalisedHighlightAttr ===
- 'click-tracking' ||
- abTestPersonalisedHighlightAttr ===
- 'click-and-view-tracking'
- ) {
- onHighlightEvent('CLICK', trail);
- }
- }}
/>
);
diff --git a/dotcom-rendering/src/experiments/ab-tests.ts b/dotcom-rendering/src/experiments/ab-tests.ts
index e4d1970c5c5..86475f13119 100644
--- a/dotcom-rendering/src/experiments/ab-tests.ts
+++ b/dotcom-rendering/src/experiments/ab-tests.ts
@@ -1,14 +1,8 @@
import type { ABTest } from '@guardian/ab-core';
import { abTestTest } from './tests/ab-test-test';
import { noAuxiaSignInGate } from './tests/no-auxia-sign-in-gate';
-import { personalisedHighlights } from './tests/personalised-highlights';
import { userBenefitsApi } from './tests/user-benefits-api';
// keep in sync with ab-tests in frontend
// https://github.com/guardian/frontend/tree/main/static/src/javascripts/projects/common/modules/experiments/ab-tests.ts
-export const tests: ABTest[] = [
- abTestTest,
- userBenefitsApi,
- personalisedHighlights,
- noAuxiaSignInGate,
-];
+export const tests: ABTest[] = [abTestTest, userBenefitsApi, noAuxiaSignInGate];
diff --git a/dotcom-rendering/src/experiments/tests/personalised-highlights.ts b/dotcom-rendering/src/experiments/tests/personalised-highlights.ts
deleted file mode 100644
index 38e9f4b2555..00000000000
--- a/dotcom-rendering/src/experiments/tests/personalised-highlights.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { ABTest } from '@guardian/ab-core';
-
-export const personalisedHighlights: ABTest = {
- id: 'PersonalisedHighlights',
- start: '2025-11-17',
- expiry: '2026-01-28',
- author: 'Anna Beddow',
- description:
- 'Allow user behaviour to personalise the ordering of cards in the highlights container.',
- audience: 0.75,
- audienceOffset: 0,
- successMeasure: '',
- audienceCriteria: '',
- idealOutcome: '',
- showForSensitive: true,
- canRun: () => true,
- variants: [
- {
- id: 'control',
- test: (): void => {
- /* no-op */
- },
- },
- {
- id: 'click-tracking',
- test: (): void => {
- /* no-op */
- },
- },
- {
- id: 'view-tracking',
- test: (): void => {
- /* no-op */
- },
- },
- {
- id: 'click-and-view-tracking',
- test: (): void => {
- /* no-op */
- },
- },
- ],
-};
diff --git a/dotcom-rendering/src/lib/personaliseHighlights.test.ts b/dotcom-rendering/src/lib/personaliseHighlights.test.ts
deleted file mode 100644
index bb901aea948..00000000000
--- a/dotcom-rendering/src/lib/personaliseHighlights.test.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { storage } from '@guardian/libs';
-import { trails } from '../../fixtures/manual/highlights-trails';
-import type { DCRFrontCard } from '../types/front';
-import {
- getCardsFromState,
- getHighlightsState,
- HighlightsHistoryKey,
- initialiseHighlightsState,
- onCardClick,
- onCardView,
- onHighlightEvent,
- resetHighlightsState,
- saveHighlightsState,
-} from './personaliseHighlights';
-
-// Mock @guardian/libs storage + isObject
-jest.mock('@guardian/libs', () => {
- const store = new Map();
- return {
- // Keep isObject compatible with the production version's usage
- isObject: (x: unknown) => x !== null && typeof x === 'object',
- storage: {
- local: {
- get: jest.fn((key: string) => store.get(key)),
- set: jest.fn((key: string, val: unknown) =>
- store.set(key, val),
- ),
- remove: jest.fn((key: string) => store.delete(key)),
- },
- },
- };
-});
-
-const asCards = (n: number): DCRFrontCard[] =>
- trails.slice(0, n) as unknown as DCRFrontCard[];
-
-const highlightCards: DCRFrontCard[] = asCards(6);
-
-const baseHighlights = [
- { cardUrl: highlightCards[0]!.url, viewCount: 0, wasClicked: false },
- { cardUrl: highlightCards[1]!.url, viewCount: 0, wasClicked: false },
- { cardUrl: highlightCards[2]!.url, viewCount: 0, wasClicked: false },
- { cardUrl: highlightCards[3]!.url, viewCount: 0, wasClicked: false },
- { cardUrl: highlightCards[4]!.url, viewCount: 0, wasClicked: false },
- { cardUrl: highlightCards[5]!.url, viewCount: 0, wasClicked: false },
-];
-
-describe('Personalise Highlights', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- // Ensure storage starts empty for each test
- (storage.local.get as jest.Mock).mockImplementation(() => undefined);
- (storage.local.set as jest.Mock).mockImplementation(jest.fn());
- (storage.local.remove as jest.Mock).mockImplementation(jest.fn());
- });
-
- it('should convert cards to highlights state', () => {
- const result = initialiseHighlightsState(highlightCards);
- expect(result.slice(0, 6)).toEqual(baseHighlights);
- });
-
- it('should get cards from stored highlights', () => {
- const result = getCardsFromState(baseHighlights, highlightCards);
- expect(result).toEqual(highlightCards.slice(0, 6));
- });
-
- it('should track a card view: increments first two only', () => {
- const updatedHighlights = onCardView(baseHighlights);
- // first two incremented
- expect(updatedHighlights[0]!.viewCount).toBe(1);
- expect(updatedHighlights[1]!.viewCount).toBe(1);
- // others unchanged
- for (let i = 2; i < updatedHighlights.length; i++) {
- expect(updatedHighlights[i]!.viewCount).toBe(0);
- }
- });
-
- it('should not move any cards to the back until viewCount reaches 2', () => {
- let storedHighlights = [...baseHighlights];
-
- // After 1st view
- storedHighlights = onCardView(storedHighlights);
-
- // No reordering yet; first two should still be at the front with viewCount 1
- expect(storedHighlights.slice(0, 2).map((h) => h.cardUrl)).toEqual([
- baseHighlights[0]!.cardUrl,
- baseHighlights[1]!.cardUrl,
- ]);
- expect(storedHighlights[0]!.viewCount).toBe(1);
- expect(storedHighlights[1]!.viewCount).toBe(1);
- });
-
- it('should move first two cards to the back once they each reach 2 views (and preserve their order)', () => {
- let storedHighlights = [...baseHighlights];
-
- // Simulate two renders where we track views for the first two each time
- storedHighlights = onCardView(storedHighlights); // both -> 1
- storedHighlights = onCardView(storedHighlights); // both -> 2 (now move to back in the same order)
-
- // The two moved cards should appear at the end, in the same order as they were viewed
- const moved = storedHighlights.slice(-2);
- expect(moved[0]!.cardUrl).toBe(baseHighlights[0]!.cardUrl);
- expect(moved[1]!.cardUrl).toBe(baseHighlights[1]!.cardUrl);
- // And their viewCount should be >= 2
- expect(moved[0]!.viewCount).toBeGreaterThanOrEqual(2);
- expect(moved[1]!.viewCount).toBeGreaterThanOrEqual(2);
-
- // The items now at the front should be the ones that used to be at positions 2..end-2
- const frontUrls = storedHighlights
- .slice(0, storedHighlights.length - 2)
- .map((h) => h.cardUrl);
- expect(frontUrls).toEqual(
- baseHighlights.slice(2).map((h) => h.cardUrl),
- );
- });
-
- it('should cap view tracking to the first two items only and never increment others', () => {
- let storedHighlights = [...baseHighlights];
- // Run view tracking many times
- for (let i = 0; i < 5; i++) {
- storedHighlights = onCardView(storedHighlights);
- }
-
- // Only two items per call are ever incremented (the current first two at that time).
- // Items not at indices 0 or 1 in any pass remain 0.
- const nonFrontItems = storedHighlights.slice(2);
- for (const h of nonFrontItems) {
- // Some items might have become front items as reordering happens,
- // but any item that never sat in the first two should remain 0.
- // To keep this deterministic with our baseHighlights size, check original tail two:
- // baseHighlights[4] and baseHighlights[5] never appear in the first two before the first reshuffle,
- // and after 5 iterations it's still not guaranteed they were front. To be safe,
- // assert at least that at least one tail item remains at 0 (indicating only fronts are incremented).
- expect(h.viewCount).toBeGreaterThanOrEqual(0);
- }
- });
-
- it('should mark a clicked card and move it to the back', () => {
- const target = highlightCards[2]!; // click the 3rd card
- const result = onCardClick(baseHighlights, target);
-
- // the clicked card should be last and marked clicked
- const last = result[result.length - 1]!;
- expect(last.cardUrl).toBe(target.url);
- expect(last.wasClicked).toBe(true);
-
- // length and other items preserved (minus the original instance of the clicked card)
- expect(result).toHaveLength(baseHighlights.length);
- const urlsInOrderExceptClicked = [
- ...baseHighlights.slice(0, 2),
- ...baseHighlights.slice(3),
- ].map((h) => h.cardUrl);
- expect(result.slice(0, -1).map((h) => h.cardUrl)).toEqual(
- urlsInOrderExceptClicked,
- );
- });
-
- it('should not reorder if a card was already clicked', () => {
- const clickedOnce = onCardClick(baseHighlights, highlightCards[0]);
- const clickedTwice = onCardClick(clickedOnce, highlightCards[0]);
- expect(clickedTwice).toEqual(clickedOnce);
- });
-
- it('should be a no-op if trackCardClick is called without a card or with an unknown card', () => {
- expect(onCardClick(baseHighlights, undefined)).toEqual(baseHighlights);
-
- const unknownCard = {
- ...highlightCards[0],
- url: 'https://unknown.example',
- } as DCRFrontCard;
- expect(onCardClick(baseHighlights, unknownCard)).toEqual(
- baseHighlights,
- );
- });
-
- it('getHighlightsState returns undefined and clears invalid storage', () => {
- // Put an invalid value in storage (not an array)
- (storage.local.get as jest.Mock).mockReturnValueOnce({ bad: 'value' });
-
- const result = getHighlightsState();
- expect(result).toBeUndefined();
- expect(storage.local.remove).toHaveBeenCalledWith(HighlightsHistoryKey);
- });
-
- it('initialiseHighlightsState returns highlights when storage is valid', () => {
- const valid = initialiseHighlightsState(highlightCards.slice(0, 3));
- (storage.local.get as jest.Mock).mockReturnValueOnce(valid);
-
- const result = getHighlightsState();
- expect(result).toEqual(valid);
- expect(storage.local.remove).not.toHaveBeenCalled();
- });
-
- it('saveHighlightsState sets storage under HighlightsHistoryKey', () => {
- const hist = initialiseHighlightsState(highlightCards.slice(0, 2));
- saveHighlightsState(hist);
- expect(storage.local.set).toHaveBeenCalledWith(
- HighlightsHistoryKey,
- hist,
- );
- });
-
- it('resetHighlightsState removes and sets new highlights', () => {
- const cards = highlightCards.slice(0, 4);
- resetHighlightsState(cards);
-
- expect(storage.local.remove).toHaveBeenCalledWith(HighlightsHistoryKey);
- // The set call should have been made once with converted history
- const setArgs = (storage.local.set as jest.Mock).mock.calls.find(
- ([key]) => key === HighlightsHistoryKey,
- );
- expect(setArgs).toBeTruthy();
- const [, stored] = setArgs!;
- const expected = initialiseHighlightsState(cards);
- expect(stored).toEqual(expected);
- });
-
- it('onHighlightEvent VIEW updates first two viewCounts and stores', () => {
- // Seed storage with a known set of highlights
- const seeded = [...baseHighlights];
- (storage.local.get as jest.Mock).mockReturnValueOnce(seeded);
-
- onHighlightEvent('VIEW');
-
- // Expect storage.set with updated history
- expect(storage.local.set).toHaveBeenCalledWith(
- HighlightsHistoryKey,
- expect.any(Array),
- );
- const [, finalHistory] = (storage.local.set as jest.Mock).mock.calls[0];
- expect((finalHistory as typeof baseHighlights)[0]!.viewCount).toBe(1);
- expect((finalHistory as typeof baseHighlights)[1]!.viewCount).toBe(1);
- });
-
- it('onHighlightEvent CLICK marks the provided card and stores', () => {
- const seeded = [...baseHighlights];
- (storage.local.get as jest.Mock).mockReturnValueOnce(seeded);
-
- const clickCard = highlightCards[1]!;
- onHighlightEvent('CLICK', clickCard);
-
- expect(storage.local.set).toHaveBeenCalledWith(
- HighlightsHistoryKey,
- expect.any(Array),
- );
- const [, finalHistory] = (storage.local.set as jest.Mock).mock.calls[0];
- const last = (finalHistory as typeof baseHighlights).slice(-1)[0]!;
- expect(last.cardUrl).toBe(clickCard.url);
- expect(last.wasClicked).toBe(true);
- });
-});
diff --git a/dotcom-rendering/src/lib/personaliseHighlights.ts b/dotcom-rendering/src/lib/personaliseHighlights.ts
deleted file mode 100644
index d4cc2e8f718..00000000000
--- a/dotcom-rendering/src/lib/personaliseHighlights.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { isObject, storage } from '@guardian/libs';
-import type { DCRFrontCard } from '../types/front';
-
-/*
- * We want to better surface content in the "highlights" container that is beyond the fold.
- * To do this, user engagement will affect the ordering of the container.
- * If a card has been clicked, we consider that card engaged with and move it to the back of the container.
- * If a card has been viewed 2 or more times but not actively interacted with (i.e. clicked), we consider the card unengaged and move it to the back of the container.
- * If editorial has updated the highlights container, we reset the ordering to allow for editorial oversight.
- * The user only stores one highlight container history in storage. If a different front is visited that has a highlights container,
- * it will reset local storage with the new container data.
- * */
-
-type HighlightCardState = {
- cardUrl: string;
- viewCount: number;
- wasClicked: boolean;
-};
-
-export type HighlightsState = Array;
-
-type CardEvent = 'VIEW' | 'CLICK';
-
-export const HighlightsHistoryKey = 'gu.history.highlights';
-
-const MAX_VIEW_COUNT = 2;
-
-const isValidHighlightsState = (history: unknown): history is HighlightsState =>
- Array.isArray(history) &&
- history.every(
- (highlight) =>
- isObject(highlight) &&
- 'cardUrl' in highlight &&
- 'viewCount' in highlight &&
- 'wasClicked' in highlight &&
- typeof highlight.cardUrl === 'string' &&
- typeof highlight.viewCount === 'number' &&
- typeof highlight.wasClicked === 'boolean',
- );
-
-/* Retrieve the user's highlights state from local storage */
-export const getHighlightsState = (): HighlightsState | undefined => {
- try {
- const highlightHistory = storage.local.get(HighlightsHistoryKey);
-
- if (!isValidHighlightsState(highlightHistory)) {
- throw new Error(`Invalid ${HighlightsHistoryKey} value`);
- }
-
- return highlightHistory;
- } catch (e) {
- /* error parsing the string, so remove the key */
- storage.local.remove(HighlightsHistoryKey);
- return undefined;
- }
-};
-
-/* clear highlight history from local storage */
-export const clearHighlightsState = (): void => {
- storage.local.remove(HighlightsHistoryKey);
-};
-
-/* store personalised highlights in local storage */
-export const saveHighlightsState = (order: HighlightsState): void => {
- storage.local.set(HighlightsHistoryKey, order);
-};
-
-/* Maps DCR front cards to history records, initialising view and click tracking */
-export const initialiseHighlightsState = (
- cards: Array,
-): HighlightsState => {
- return cards.map((card) => ({
- cardUrl: card.url,
- viewCount: 0,
- wasClicked: false,
- }));
-};
-
-/* Reset highlight history in local storage */
-export const resetHighlightsState = (cards: DCRFrontCard[]): void => {
- clearHighlightsState();
- const highlights = initialiseHighlightsState(cards);
- saveHighlightsState(highlights);
-};
-
-/* Maps history to DCR front cards */
-export const getCardsFromState = (
- history: HighlightsState,
- trails: DCRFrontCard[],
-): DCRFrontCard[] => {
- return history
- .map((highlight) =>
- trails.find((card) => card.url === highlight.cardUrl),
- )
- .filter((card): card is DCRFrontCard => !!card);
-};
-
-export const getOrderedHighlights = (
- trails: DCRFrontCard[],
-): DCRFrontCard[] => {
- const history = getHighlightsState() ?? [];
- return getCardsFromState(history, trails);
-};
-
-/* Track when a user has clicked on a highlight card */
-export const onCardClick = (
- highlights: HighlightsState,
- card?: DCRFrontCard,
-): HighlightsState => {
- /* if we don't have a card, return highlights as is */
- if (!card) return highlights;
- const index = highlights.findIndex((el) => el.cardUrl === card.url);
-
- const foundCard = highlights[index];
-
- /* if we can't find the card, or it has already been clicked, return highlights as is */
- if (!foundCard || foundCard.wasClicked) return highlights;
-
- const updatedCard = {
- ...foundCard,
- wasClicked: true,
- };
-
- /* Rebuild without the clicked card, then append the updated one */
- return [
- ...highlights.slice(0, index),
- ...highlights.slice(index + 1),
- updatedCard,
- ];
-};
-
-/*
- * Track a view for the first 2 cards in the highlights container and reorder if necessary.
- * Persist the view count and the new card order in local storage
- */
-export const onCardView = (highlights: HighlightsState): HighlightsState => {
- const viewedCards = highlights.slice(0, 2);
-
- const updatedCards: HighlightCardState[] = [];
-
- const newHighlights = highlights.map((el) => {
- if (viewedCards.includes(el) && el.viewCount < MAX_VIEW_COUNT) {
- const newViewCount = el.viewCount + 1;
- const updated = { ...el, viewCount: newViewCount };
- updatedCards.push(updated);
- return updated;
- }
- return el;
- });
-
- /* Separate the updated cards that now have viewCount >= 2 */
- const toMove = updatedCards.filter((el) => el.viewCount >= MAX_VIEW_COUNT);
- if (toMove.length === 0) return newHighlights;
-
- /* Remove the updated cards from their current positions */
- const remaining = newHighlights.filter((el) => !toMove.includes(el));
-
- /* Append the updated cards to the end, preserving order */
- return [...remaining, ...toMove];
-};
-
-export const onHighlightEvent = (
- event: CardEvent,
- card?: DCRFrontCard,
-): void => {
- const localHighlights = getHighlightsState() ?? [];
-
- const updatedHighlights: HighlightsState =
- event === 'VIEW'
- ? onCardView(localHighlights)
- : onCardClick(localHighlights, card);
-
- saveHighlightsState(updatedHighlights);
-};