Skip to content

Commit 357502c

Browse files
authored
Personalise highlights (#14834)
* Add visibility hidden to the highlights container * Hide highlights and unhide them if a user doesnt have a highlights history * inline css for now * Add logs for testing * Store article ID in history on click * Articles moving but container is at the end * Update local storage key * Auto scroll back to start on layout event * Check if contain the same urls before continuing * Update naming * UseEffect instead of LayoutEffect * Improve naming * Track views on cards and demote unengaged cards * Fix return type * Introduce `personalHighlights` module to manage highlight card ordering based on user engagement * Add helper to get ordered cards from stored history * Add helper methods to track clicks and views in order history * Add functions to demote cards and to track and store final card engagement * Update view tracker to store the first two cards viewed * Simplify api and add logging for testing * Logging and use visbility state instead of beforeUnload as it is better supported * Track views on show rather than on leave so that it doesn't interfeer with click tracking * Track views straight away * working * Refactor naming and add comments * Make trackCardClick optional so we only provide it on highlights * Add test suite for personalisedHighlights * Tidy up * Add AB Test set boiler plate which will be used in conjunction with guardian/frontend#28327 # Conflicts: # dotcom-rendering/src/experiments/ab-tests.ts * Add client side test mechanism into scrollable highlights * Prefer /* */ comments over // for VSCode integration * Prefer event handler style naming convention in personaliseHighlights so that it does semantically overlap with telemetry * Set ordered trails so that visibility is set only once in the useLayoutEffect * Update comments to improve readability * Fix linting * Fire a view event only if the container has been personalised * Lower max view count to 2 view as requested by editorial * Simplify flash solution by only applying scroll-snap styles once the highlights are visible. * Store a card identifier rather than the whole card to ensure editorial card updates are visible to users with stored history. (#14835) * Refactor test setup in Highlights test (#14836) * Store a card identifier rather than the whole card to ensure editorial card updates are visible to users with stored history. * Refactor ab test attribution so that we can apply personalisation behaviour only if a user is in a test variant * Move visibility setting inside useeffect * Remove control from the list of potential ab variants as this is covered by 'not-in-test' * Prefer undefined rather than 'undetermined' as this is representative of what is returned from SWR under the hood of the useAB hook * Set visibility when the user is not in the test
1 parent 7994b8c commit 357502c

File tree

8 files changed

+614
-5
lines changed

8 files changed

+614
-5
lines changed

dotcom-rendering/src/components/Card/components/CardLink.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,19 @@ type Props = {
2121
headlineText: string;
2222
dataLinkName?: string;
2323
isExternalLink: boolean;
24+
trackCardClick?: () => void;
2425
};
2526

2627
const InternalLink = ({
2728
linkTo,
2829
headlineText,
2930
dataLinkName,
31+
trackCardClick,
3032
}: {
3133
linkTo: string;
3234
headlineText: string;
3335
dataLinkName?: string;
36+
trackCardClick?: () => void;
3437
}) => {
3538
return (
3639
// eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content
@@ -39,6 +42,7 @@ const InternalLink = ({
3942
css={fauxLinkStyles}
4043
data-link-name={dataLinkName}
4144
aria-label={headlineText}
45+
onClick={trackCardClick}
4246
/>
4347
);
4448
};
@@ -47,10 +51,12 @@ const ExternalLink = ({
4751
linkTo,
4852
headlineText,
4953
dataLinkName,
54+
trackCardClick,
5055
}: {
5156
linkTo: string;
5257
headlineText: string;
5358
dataLinkName?: string;
59+
trackCardClick?: () => void;
5460
}) => {
5561
return (
5662
// eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content
@@ -61,6 +67,7 @@ const ExternalLink = ({
6167
aria-label={headlineText + ' (opens in new tab)'}
6268
target="_blank"
6369
rel="noreferrer"
70+
onClick={trackCardClick}
6471
/>
6572
);
6673
};
@@ -70,6 +77,7 @@ export const CardLink = ({
7077
headlineText,
7178
dataLinkName = 'article', //this makes sense if the link is to an article, but should this say something like "external" if it's an external link? are there any other uses/alternatives?
7279
isExternalLink,
80+
trackCardClick,
7381
}: Props) => {
7482
return (
7583
<>
@@ -78,13 +86,15 @@ export const CardLink = ({
7886
linkTo={linkTo}
7987
headlineText={headlineText}
8088
dataLinkName={dataLinkName}
89+
trackCardClick={trackCardClick}
8190
/>
8291
)}
8392
{!isExternalLink && (
8493
<InternalLink
8594
linkTo={linkTo}
8695
headlineText={headlineText}
8796
dataLinkName={dataLinkName}
97+
trackCardClick={trackCardClick}
8898
/>
8999
)}
90100
</>

dotcom-rendering/src/components/DecideContainer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export const DecideContainer = ({
233233
return <NavList trails={trails} showImage={true} />;
234234
case 'scrollable/highlights':
235235
return (
236-
<Island priority="feature" defer={{ until: 'visible' }}>
236+
<Island priority="critical">
237237
<ScrollableHighlights trails={trails} frontId={frontId} />
238238
</Island>
239239
);

dotcom-rendering/src/components/Masthead/HighlightsCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type HighlightsCardProps = {
3232
byline?: string;
3333
isExternalLink: boolean;
3434
starRating?: Rating;
35+
trackCardClick: () => void;
3536
};
3637

3738
const container = css`
@@ -133,6 +134,7 @@ export const HighlightsCard = ({
133134
byline,
134135
isExternalLink,
135136
starRating,
137+
trackCardClick,
136138
}: HighlightsCardProps) => {
137139
const isMediaCard = isMedia(format);
138140

@@ -144,6 +146,7 @@ export const HighlightsCard = ({
144146
headlineText={headlineText}
145147
dataLinkName={dataLinkName}
146148
isExternalLink={isExternalLink}
149+
trackCardClick={trackCardClick}
147150
/>
148151

149152
<div css={content}>

dotcom-rendering/src/components/ScrollableHighlights.importable.tsx

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { css } from '@emotion/react';
2+
import type { ABTestAPI as ABTestAPIType } from '@guardian/ab-core';
23
import { from, space } from '@guardian/source/foundations';
34
import {
45
Button,
@@ -7,8 +8,16 @@ import {
78
SvgChevronRightSingle,
89
} from '@guardian/source/react-components';
910
import { useEffect, useRef, useState } from 'react';
11+
import { submitComponentEvent } from '../client/ophan/ophan';
1012
import { getZIndex } from '../lib/getZIndex';
1113
import { ophanComponentId } from '../lib/ophan-helpers';
14+
import {
15+
clearHighlightsState,
16+
getOrderedHighlights,
17+
onHighlightEvent,
18+
resetHighlightsState,
19+
} from '../lib/personaliseHighlights';
20+
import { useAB } from '../lib/useAB';
1221
import { palette } from '../palette';
1322
import type { DCRFrontCard } from '../types/front';
1423
import { HighlightsCard } from './Masthead/HighlightsCard';
@@ -41,8 +50,7 @@ const carouselStyles = css`
4150
grid-auto-flow: column;
4251
overflow-x: auto;
4352
overflow-y: hidden;
44-
scroll-snap-type: x mandatory;
45-
scroll-behavior: smooth;
53+
scroll-behavior: auto;
4654
overscroll-behavior-x: contain;
4755
overscroll-behavior-y: auto;
4856
@@ -78,6 +86,10 @@ const carouselStyles = css`
7886
position: relative;
7987
`;
8088

89+
const scrollSnapStyles = css`
90+
scroll-snap-type: x mandatory;
91+
`;
92+
8193
const itemStyles = css`
8294
scroll-snap-align: start;
8395
grid-area: span 1;
@@ -207,12 +219,55 @@ const getOphanInfo = (frontId?: string) => {
207219
};
208220
};
209221

222+
const isEqual = (
223+
historicalHighlights: DCRFrontCard[],
224+
currentHighlights: DCRFrontCard[],
225+
) => {
226+
const c = currentHighlights.map((v) => v.url);
227+
const h = historicalHighlights.map((v) => v.url);
228+
return c.every((v) => {
229+
return h.includes(v);
230+
});
231+
};
210232
export const ScrollableHighlights = ({ trails, frontId }: Props) => {
211233
const carouselRef = useRef<HTMLOListElement | null>(null);
212234
const carouselLength = trails.length;
213235
const imageLoading = 'eager';
214236
const [showPreviousButton, setShowPreviousButton] = useState(false);
215237
const [showNextButton, setShowNextButton] = useState(true);
238+
const [shouldShowHighlights, setShouldShowHighlights] = useState(false);
239+
const [orderedTrails, setOrderedTrails] = useState<DCRFrontCard[]>(trails);
240+
241+
const ABTestAPI = useAB()?.api;
242+
243+
type Attr =
244+
| 'click-tracking'
245+
| 'view-tracking'
246+
| 'click-and-view-tracking'
247+
| 'not-in-test'
248+
| undefined;
249+
250+
const getUserABAttr = (api?: ABTestAPIType): Attr => {
251+
if (!api) return undefined;
252+
253+
if (api.isUserInVariant('PersonalisedHighlights', 'click-tracking')) {
254+
return 'click-tracking';
255+
}
256+
if (api.isUserInVariant('PersonalisedHighlights', 'view-tracking')) {
257+
return 'view-tracking';
258+
}
259+
if (
260+
api.isUserInVariant(
261+
'PersonalisedHighlights',
262+
'click-and-view-tracking',
263+
)
264+
) {
265+
return 'click-and-view-tracking';
266+
}
267+
return 'not-in-test';
268+
};
269+
270+
const abTestPersonalisedHighlightAttr = getUserABAttr(ABTestAPI);
216271

217272
const scrollTo = (direction: 'left' | 'right') => {
218273
if (!carouselRef.current) return;
@@ -247,6 +302,15 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
247302
setShowNextButton(scrollLeft < maxScrollLeft);
248303
};
249304

305+
useEffect(() => {
306+
if (
307+
abTestPersonalisedHighlightAttr === 'view-tracking' ||
308+
abTestPersonalisedHighlightAttr === 'click-and-view-tracking'
309+
) {
310+
onHighlightEvent('VIEW');
311+
}
312+
}, [abTestPersonalisedHighlightAttr]);
313+
250314
useEffect(() => {
251315
const carouselElement = carouselRef.current;
252316
if (!carouselElement) return;
@@ -264,11 +328,57 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
264328
};
265329
}, []);
266330

331+
useEffect(() => {
332+
if (abTestPersonalisedHighlightAttr === undefined) {
333+
return;
334+
}
335+
336+
if (abTestPersonalisedHighlightAttr === 'not-in-test') {
337+
clearHighlightsState();
338+
setOrderedTrails(trails);
339+
setShouldShowHighlights(true);
340+
return;
341+
}
342+
343+
const personalisedHighlights = getOrderedHighlights(trails);
344+
if (
345+
personalisedHighlights.length === 0 ||
346+
personalisedHighlights.length !== trails.length ||
347+
!isEqual(personalisedHighlights, trails)
348+
) {
349+
/* Reset to editorial order */
350+
resetHighlightsState(trails);
351+
setOrderedTrails(trails);
352+
setShouldShowHighlights(true);
353+
return;
354+
}
355+
/* Otherwise, use personalised order from local storage */
356+
setOrderedTrails(personalisedHighlights);
357+
setShouldShowHighlights(true);
358+
/* Fire a view event only if the container has been personalised */
359+
void submitComponentEvent(
360+
{
361+
component: {
362+
componentType: 'CONTAINER',
363+
id: `reordered-highlights-container`,
364+
},
365+
action: 'VIEW',
366+
},
367+
'Web',
368+
);
369+
}, [trails, abTestPersonalisedHighlightAttr]);
370+
267371
const { ophanComponentLink, ophanComponentName, ophanFrontName } =
268372
getOphanInfo(frontId);
269373

270374
return (
271-
<div css={containerStyles} data-link-name={ophanFrontName}>
375+
<div
376+
css={[
377+
containerStyles,
378+
{ visibility: shouldShowHighlights ? 'visible' : 'hidden' },
379+
]}
380+
data-link-name={ophanFrontName}
381+
>
272382
<ol
273383
data-link-name={ophanComponentLink}
274384
data-component={ophanComponentName}
@@ -277,10 +387,16 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
277387
css={[
278388
carouselStyles,
279389
generateCarouselColumnStyles(carouselLength),
390+
/*
391+
* Enable scroll-snap only when visible to prevent browser scroll anchoring.
392+
* When items reorder, browsers try to keep previously visible items in view,
393+
* causing unwanted scrolling. Enabling snap after reordering forces position 0.
394+
// */
395+
shouldShowHighlights && scrollSnapStyles,
280396
]}
281397
data-heatphan-type="carousel"
282398
>
283-
{trails.map((trail) => {
399+
{orderedTrails.map((trail) => {
284400
return (
285401
<li
286402
key={trail.url}
@@ -300,6 +416,16 @@ export const ScrollableHighlights = ({ trails, frontId }: Props) => {
300416
showQuotedHeadline={trail.showQuotedHeadline}
301417
mainMedia={trail.mainMedia}
302418
starRating={trail.starRating}
419+
trackCardClick={() => {
420+
if (
421+
abTestPersonalisedHighlightAttr ===
422+
'click-tracking' ||
423+
abTestPersonalisedHighlightAttr ===
424+
'click-and-view-tracking'
425+
) {
426+
onHighlightEvent('CLICK', trail);
427+
}
428+
}}
303429
/>
304430
</li>
305431
);

dotcom-rendering/src/experiments/ab-tests.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ABTest } from '@guardian/ab-core';
22
import { abTestTest } from './tests/ab-test-test';
33
import { auxiaSignInGate } from './tests/auxia-sign-in-gate';
44
import { noAuxiaSignInGate } from './tests/no-auxia-sign-in-gate';
5+
import { personalisedHighlights } from './tests/personalised-highlights';
56
import { signInGateMainControl } from './tests/sign-in-gate-main-control';
67
import { signInGateMainVariant } from './tests/sign-in-gate-main-variant';
78
import { userBenefitsApi } from './tests/user-benefits-api';
@@ -14,5 +15,6 @@ export const tests: ABTest[] = [
1415
signInGateMainControl,
1516
userBenefitsApi,
1617
auxiaSignInGate,
18+
personalisedHighlights,
1719
noAuxiaSignInGate,
1820
];
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { ABTest } from '@guardian/ab-core';
2+
3+
export const personalisedHighlights: ABTest = {
4+
id: 'PersonalisedHighlights',
5+
start: '2025-10-29',
6+
expiry: '2025-12-04',
7+
author: 'Anna Beddow',
8+
description:
9+
'Allow user behaviour to personalise the ordering of cards in the highlights container.',
10+
audience: 0,
11+
audienceOffset: 0,
12+
successMeasure: '',
13+
audienceCriteria: '',
14+
idealOutcome: '',
15+
showForSensitive: true,
16+
canRun: () => true,
17+
variants: [
18+
{
19+
id: 'control',
20+
test: (): void => {
21+
/* no-op */
22+
},
23+
},
24+
{
25+
id: 'click-tracking',
26+
test: (): void => {
27+
/* no-op */
28+
},
29+
},
30+
{
31+
id: 'view-tracking',
32+
test: (): void => {
33+
/* no-op */
34+
},
35+
},
36+
{
37+
id: 'click-and-view-tracking',
38+
test: (): void => {
39+
/* no-op */
40+
},
41+
},
42+
],
43+
};

0 commit comments

Comments
 (0)