From 4634972079351887355274ae1bfc98e3ebb36d80 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Mon, 12 Jan 2026 10:34:05 +0000 Subject: [PATCH 01/16] Put the fake container at position 3 --- dotcom-rendering/src/frontend/feFront.ts | 1 + .../src/model/createCollection.ts | 96 +++++++++++++++++++ .../src/model/enhanceCollections.ts | 4 +- .../src/server/handler.front.web.ts | 36 ++++--- dotcom-rendering/src/types/front.ts | 1 + 5 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 dotcom-rendering/src/model/createCollection.ts diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts index e4a7cc3df2c..0675232c780 100644 --- a/dotcom-rendering/src/frontend/feFront.ts +++ b/dotcom-rendering/src/frontend/feFront.ts @@ -328,6 +328,7 @@ export type FECollection = { config: FECollectionConfig; hasMore: boolean; targetedTerritory?: Territory; + bucket?: FEFrontCard[]; }; export type FEFrontConfig = { diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts new file mode 100644 index 00000000000..e148030ec23 --- /dev/null +++ b/dotcom-rendering/src/model/createCollection.ts @@ -0,0 +1,96 @@ +import type { FECollection, FEFrontCard } from '../frontend/feFront'; +import type { DCRCollectionType } from '../types/front'; +import { enhanceCards } from './enhanceCards'; + +const acrossTheGuardianCollection: DCRCollectionType = { + id: 'hardcoded-collection', + displayName: 'Across The Guardian', + description: undefined, + collectionType: 'static/medium/4', + href: undefined, + grouped: { + splash: [], + snap: [], + huge: [], + veryBig: [], + big: [], + standard: [], + }, + curated: [], + bucket: [], + backfill: [], + treats: [], + config: { + showDateHeader: false, + }, + canShowMore: false, + targetedTerritory: undefined, + aspectRatio: '5:4', +}; + +const PILLAR_CONTAINERS = ['Culture', 'Opinion', 'Sport', 'Lifestyle']; +const isPillarContainer = (collection: FECollection) => + PILLAR_CONTAINERS.includes(collection.displayName); + +type PillarCollection = { + pillar: string; + curated: FEFrontCard[]; +}; +const getPillarCards = (collections: FECollection[]) => { + return collections.filter(isPillarContainer).map((collection) => { + return { pillar: collection.displayName, curated: collection.curated }; + }); +}; + +const getCuratedList = (PillarCollections: PillarCollection[]) => { + const curatedList: FEFrontCard[] = []; + const bucketList: FEFrontCard[] = []; + + for (const collection of PillarCollections) { + const [firstCard, ...remaining] = collection.curated; + if (firstCard) curatedList.push(firstCard); + bucketList.push(...remaining); + } + + return { curatedList, bucketList }; +}; + +export const createFakeCollection = ( + collections: FECollection[], +): DCRCollectionType => { + const pillarCards = getPillarCards(collections); + const { curatedList, bucketList } = getCuratedList(pillarCards); + + return { + ...acrossTheGuardianCollection, + curated: enhanceCards(curatedList, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + bucket: enhanceCards(bucketList, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + }; +}; + +/* + * History = { + * cardId = "1234" + * viewCount = 1 + * } + * */ + +// curated = [ +// {opinion 1}, +// {sport 1}, +// {culture 1 }, +// {lifestyle 1 }, +// ] + +// bucket = [ +// +// ] +// console.log(getPillarCards()) diff --git a/dotcom-rendering/src/model/enhanceCollections.ts b/dotcom-rendering/src/model/enhanceCollections.ts index 7e6c62464ba..9e827594208 100644 --- a/dotcom-rendering/src/model/enhanceCollections.ts +++ b/dotcom-rendering/src/model/enhanceCollections.ts @@ -122,7 +122,7 @@ export const enhanceCollections = ({ collection.collectionType, ); - return { + const x = { id, displayName, description: @@ -169,5 +169,7 @@ export const enhanceCollections = ({ targetedTerritory: collection.targetedTerritory, aspectRatio: collection.config.aspectRatio, }; + console.log(x); + return x; }); }; diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index c338f2101f3..c76ecb20dbb 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -4,6 +4,7 @@ import type { FEFront } from '../frontend/feFront'; import type { FETagPage } from '../frontend/feTagPage'; import { decideTagPageBranding, pickBrandingForEdition } from '../lib/branding'; import { decideTrail } from '../lib/decideTrail'; +import { createFakeCollection } from '../model/createCollection'; import { enhanceCards } from '../model/enhanceCards'; import { enhanceCollections } from '../model/enhanceCollections'; import { @@ -24,6 +25,26 @@ const enhanceFront = (body: unknown): Front => { const serverTime = Date.now(); + const acrossTheG = createFakeCollection(data.pressedPage.collections); + const collections = enhanceCollections({ + collections: data.pressedPage.collections, + editionId: data.editionId, + pageId: data.pageId, + onPageDescription: data.pressedPage.frontProperties.onPageDescription, + isOnPaidContentFront: data.config.isPaidContent, + discussionApiUrl: data.config.discussionApiUrl, + frontBranding: pickBrandingForEdition( + data.pressedPage.frontProperties.commercial.editionBrandings, + data.editionId, + ), + }); + + const combinedCollections = [ + ...collections.slice(0, 3), + acrossTheG, + ...collections.slice(3), + ]; + return { ...data, webTitle: `${ @@ -31,20 +52,7 @@ const enhanceFront = (body: unknown): Front => { } | The Guardian`, pressedPage: { ...data.pressedPage, - collections: enhanceCollections({ - collections: data.pressedPage.collections, - editionId: data.editionId, - pageId: data.pageId, - onPageDescription: - data.pressedPage.frontProperties.onPageDescription, - isOnPaidContentFront: data.config.isPaidContent, - discussionApiUrl: data.config.discussionApiUrl, - frontBranding: pickBrandingForEdition( - data.pressedPage.frontProperties.commercial - .editionBrandings, - data.editionId, - ), - }), + collections: combinedCollections, }, mostViewed: data.mostViewed.map((trail) => decideTrail(trail)), trendingTopics: extractTrendingTopicsFomFront( diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 50269bbc989..8b1aa844f30 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -144,6 +144,7 @@ export type DCRCollectionType = { collectionBranding?: CollectionBranding; targetedTerritory?: Territory; aspectRatio?: AspectRatio; + bucket?: DCRFrontCard[]; }; export type DCRGroupedTrails = { From 16b8bc3137e6f477797c0792a32ef5957f98fbdc Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 14:02:50 +0000 Subject: [PATCH 02/16] Add cards from `more {PILLAR}` to backfill --- .../src/model/createCollection.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index e148030ec23..c08331ddb1a 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -29,9 +29,15 @@ const acrossTheGuardianCollection: DCRCollectionType = { }; const PILLAR_CONTAINERS = ['Culture', 'Opinion', 'Sport', 'Lifestyle']; +const MORE_PILLAR_CONTAINERS = ['More culture', 'More sport', 'More lifestyle']; + const isPillarContainer = (collection: FECollection) => PILLAR_CONTAINERS.includes(collection.displayName); +const isMorePillarContainer = (collection: FECollection) => { + return MORE_PILLAR_CONTAINERS.includes(collection.displayName); +}; + type PillarCollection = { pillar: string; curated: FEFrontCard[]; @@ -42,6 +48,15 @@ const getPillarCards = (collections: FECollection[]) => { }); }; +const getMoreCards = (collections: FECollection[]) => { + return collections + .filter(isMorePillarContainer) + .map((collection) => { + return collection.curated; + }) + .flat(); +}; + const getCuratedList = (PillarCollections: PillarCollection[]) => { const curatedList: FEFrontCard[] = []; const bucketList: FEFrontCard[] = []; @@ -59,8 +74,9 @@ export const createFakeCollection = ( collections: FECollection[], ): DCRCollectionType => { const pillarCards = getPillarCards(collections); + const moreBucket = getMoreCards(collections); const { curatedList, bucketList } = getCuratedList(pillarCards); - + const combineBucket = [...bucketList, ...moreBucket]; return { ...acrossTheGuardianCollection, curated: enhanceCards(curatedList, { @@ -68,7 +84,7 @@ export const createFakeCollection = ( discussionApiUrl: 'string', editionId: 'UK', }), - bucket: enhanceCards(bucketList, { + bucket: enhanceCards(combineBucket, { cardInTagPage: false, discussionApiUrl: 'string', editionId: 'UK', From 6fe13416b09616b857fc72a0ddf04a74db03c4b4 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 14:03:20 +0000 Subject: [PATCH 03/16] Add a dynamic four container to handle user personalisation --- .../src/components/DecideContainer.tsx | 19 +++ .../DynamicMediumFour.importable.tsx | 147 ++++++++++++++++++ dotcom-rendering/src/layouts/FrontLayout.tsx | 1 + .../src/model/enhanceCollections.ts | 4 +- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 dotcom-rendering/src/components/DynamicMediumFour.importable.tsx diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 5ff6ed3bed9..895463c2fc0 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -8,6 +8,7 @@ import type { DCRGroupedTrails, } from '../types/front'; import { DynamicFast } from './DynamicFast'; +import { DynamicMediumFour } from './DynamicMediumFour.importable'; import { DynamicPackage } from './DynamicPackage'; import { DynamicSlow } from './DynamicSlow'; import { FixedLargeSlowXIV } from './FixedLargeSlowXIV'; @@ -48,6 +49,7 @@ type Props = { collectionId: number; containerLevel?: DCRContainerLevel; isInStarRatingVariant?: boolean; + backfillBucket?: DCRFrontCard[]; }; export const DecideContainer = ({ @@ -64,6 +66,7 @@ export const DecideContainer = ({ collectionId, containerLevel, isInStarRatingVariant, + backfillBucket, }: Props) => { switch (containerType) { case 'dynamic/fast': @@ -298,6 +301,22 @@ export const DecideContainer = ({ ); case 'static/medium/4': + if (backfillBucket) { + return ( + + + + ); + } return ( { + if (isMediaCard(format) || isNewsletter) { + return 'top'; + } + + return 'bottom'; +}; + +type Props = { + trails: DCRFrontCard[]; + imageLoading: Loading; + containerPalette?: DCRContainerPalette; + showAge?: boolean; + serverTime?: number; + showImage?: boolean; + aspectRatio: AspectRatio; + containerLevel?: DCRContainerLevel; + isInStarRatingVariant?: boolean; + backfillBucket?: DCRFrontCard[]; +}; + +// const LOCAL_STATE = ["/commentisfree/2026/jan/13/iran-protesters-western-intervention-us-israel", "ID", "ID"] + +const filterViewedCards = ( + cards?: DCRFrontCard[], + viewedCards?: string[], +): DCRFrontCard[] => { + if (!cards) return []; + return cards.filter((card) => { + return !viewedCards?.includes(card.url); + }); +}; +export const ViewHistoryKey = 'gu.history.viewedCards'; + +export const DynamicMediumFour = ({ + trails, + containerPalette, + showAge, + serverTime, + imageLoading, + showImage = true, + aspectRatio, + containerLevel = 'Primary', + isInStarRatingVariant, + backfillBucket, +}: Props) => { + const [orderedTrails, setOrderedTrails] = useState( + trails.slice(0, 4), + ); + const [shouldShowHighlights, setShouldShowHighlights] = + useState(false); + useEffect(() => { + // get local state + const viewedCards = storage.local.get(ViewHistoryKey) as string[]; + const unviewedFirstCards = filterViewedCards( + trails.slice(0, 4), + viewedCards, + ); + const unviewedSecondCards = filterViewedCards( + backfillBucket, + viewedCards, + ); + const leftOver = 4 - unviewedFirstCards.length; + setOrderedTrails([ + ...unviewedFirstCards, + ...unviewedSecondCards.slice(0, leftOver), + ]); + setShouldShowHighlights(true); + }, [trails, backfillBucket]); + + return ( + <> + +
    + {orderedTrails.map((card, cardIndex) => { + return ( +
  • 0} + isVisible={shouldShowHighlights} + > + +
  • + ); + })} +
+ + ); +}; diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index af5744c02bb..3dda46c05bb 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -530,6 +530,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { isInStarRatingVariant={ isInStarRatingVariant } + backfillBucket={collection.bucket} /> diff --git a/dotcom-rendering/src/model/enhanceCollections.ts b/dotcom-rendering/src/model/enhanceCollections.ts index 9e827594208..7e6c62464ba 100644 --- a/dotcom-rendering/src/model/enhanceCollections.ts +++ b/dotcom-rendering/src/model/enhanceCollections.ts @@ -122,7 +122,7 @@ export const enhanceCollections = ({ collection.collectionType, ); - const x = { + return { id, displayName, description: @@ -169,7 +169,5 @@ export const enhanceCollections = ({ targetedTerritory: collection.targetedTerritory, aspectRatio: collection.config.aspectRatio, }; - console.log(x); - return x; }); }; From f47645179291c134eba3b14ef250bc845dcea3b3 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 14:03:32 +0000 Subject: [PATCH 04/16] track clicks to card --- .../components/Card/components/CardLink.tsx | 34 +++++++++++++++++++ .../src/components/Card/components/LI.tsx | 4 +++ 2 files changed, 38 insertions(+) diff --git a/dotcom-rendering/src/components/Card/components/CardLink.tsx b/dotcom-rendering/src/components/Card/components/CardLink.tsx index a5954193db9..8254edf73ed 100644 --- a/dotcom-rendering/src/components/Card/components/CardLink.tsx +++ b/dotcom-rendering/src/components/Card/components/CardLink.tsx @@ -1,6 +1,8 @@ import { css } from '@emotion/react'; import { focusHalo } from '@guardian/source/foundations'; import { getZIndex } from '../../../lib/getZIndex'; +import { ViewHistoryKey } from '../../DynamicMediumFour.importable'; +import { storage } from '@guardian/libs'; const fauxLinkStyles = css` position: absolute; @@ -27,10 +29,12 @@ const InternalLink = ({ linkTo, headlineText, dataLinkName, + trackCardClick, }: { linkTo: string; headlineText: string; dataLinkName?: string; + trackCardClick?: () => void; }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content @@ -39,6 +43,7 @@ const InternalLink = ({ css={fauxLinkStyles} data-link-name={dataLinkName} aria-label={headlineText} + onClick={trackCardClick} /> ); }; @@ -68,12 +73,39 @@ const ExternalLink = ({ ); }; +const isValidHighlightsState = (history: unknown): history is string[] => + Array.isArray(history) && + history.every((highlight) => typeof highlight === 'string'); + +export const getViewState = (): string[] | undefined => { + try { + const highlightHistory = storage.local.get(ViewHistoryKey); + + if (!isValidHighlightsState(highlightHistory)) { + throw new Error(`Invalid ${ViewHistoryKey} value`); + } + + return highlightHistory; + } catch (e) { + /* error parsing the string, so remove the key */ + storage.local.remove(ViewHistoryKey); + return undefined; + } +}; + export const CardLink = ({ linkTo, headlineText, 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? isExternalLink, }: Props) => { + const saveState = (url: string) => { + console.log('saveState', url); + const viewedCards = getViewState() ?? []; + console.log('viewedCards', viewedCards); + storage.local.set(ViewHistoryKey, [...viewedCards, url]); + }; + return ( <> {isExternalLink && ( @@ -81,6 +113,7 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} + trackCardClick={() => saveState(linkTo)} /> )} {!isExternalLink && ( @@ -88,6 +121,7 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} + trackCardClick={() => saveState(linkTo)} /> )} diff --git a/dotcom-rendering/src/components/Card/components/LI.tsx b/dotcom-rendering/src/components/Card/components/LI.tsx index 1657bea3f19..b34c773a886 100644 --- a/dotcom-rendering/src/components/Card/components/LI.tsx +++ b/dotcom-rendering/src/components/Card/components/LI.tsx @@ -100,6 +100,8 @@ type Props = { offsetBottomPaddingOnDivider?: boolean; /** Overrides the vertical divider colour */ verticalDividerColour?: string; + + isVisible?: boolean; }; export const LI = ({ @@ -114,6 +116,7 @@ export const LI = ({ snapAlignStart = false, offsetBottomPaddingOnDivider = false, verticalDividerColour = palette('--section-border'), + isVisible = true, }: Props) => { // Decide sizing const sizeStyles = decideSize(percentage, stretch); @@ -133,6 +136,7 @@ export const LI = ({ padSidesOnMobile && sidePaddingStylesMobile(padSidesMobileOverride), snapAlignStart && snapAlignStartStyles, + { visibility: isVisible ? 'visible' : 'hidden' }, ]} > {children} From c054629071036815849228793834f416b6f0a749 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 15:35:09 +0000 Subject: [PATCH 05/16] Add helper button to clear storage --- .../src/components/DynamicMediumFour.importable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index f52ed4f0a64..f8377d015c1 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -1,4 +1,5 @@ import { storage } from '@guardian/libs'; +import { Button } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; @@ -14,7 +15,6 @@ import { UL } from './Card/components/UL'; import type { Loading } from './CardPicture'; // eslint-disable-next-line import/no-cycle import { FrontCard } from './FrontCard'; -import { Button } from '@guardian/source/react-components'; const getMediaPositionOnDesktop = ( format: ArticleFormat, From 6a67896827f511a6a45c42faa251328842a2984c Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 15:35:35 +0000 Subject: [PATCH 06/16] shuffle buckets --- .../src/model/createCollection.ts | 32 +++++++++++++++---- .../src/server/handler.front.web.ts | 9 ++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index c08331ddb1a..637628a157e 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -49,14 +49,32 @@ const getPillarCards = (collections: FECollection[]) => { }; const getMoreCards = (collections: FECollection[]) => { - return collections + const buckets = collections .filter(isMorePillarContainer) - .map((collection) => { - return collection.curated; - }) - .flat(); + .map((collection) => collection.curated); + + const maxLength = Math.max(...buckets.map((b) => b.length)); + + const result = []; + + for (let i = 0; i < maxLength; i++) { + for (const bucket of buckets) { + if (bucket[i]) { + result.push(bucket[i]); + } + } + } + + return result; }; +function shuffle(array: (FEFrontCard | undefined)[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} const getCuratedList = (PillarCollections: PillarCollection[]) => { const curatedList: FEFrontCard[] = []; const bucketList: FEFrontCard[] = []; @@ -77,6 +95,8 @@ export const createFakeCollection = ( const moreBucket = getMoreCards(collections); const { curatedList, bucketList } = getCuratedList(pillarCards); const combineBucket = [...bucketList, ...moreBucket]; + + const shuffledBucket = shuffle(combineBucket) as FEFrontCard[]; return { ...acrossTheGuardianCollection, curated: enhanceCards(curatedList, { @@ -84,7 +104,7 @@ export const createFakeCollection = ( discussionApiUrl: 'string', editionId: 'UK', }), - bucket: enhanceCards(combineBucket, { + bucket: enhanceCards(shuffledBucket, { cardInTagPage: false, discussionApiUrl: 'string', editionId: 'UK', diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index c76ecb20dbb..734658d8b7c 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -39,10 +39,15 @@ const enhanceFront = (body: unknown): Front => { ), }); + const inFocusIndex = + data.pressedPage.collections.findIndex( + (c) => c.displayName === 'In focus', + ) + 1; + const combinedCollections = [ - ...collections.slice(0, 3), + ...collections.slice(0, inFocusIndex), acrossTheG, - ...collections.slice(3), + ...collections.slice(inFocusIndex), ]; return { From 0e213d526c6faaa1da3c5dd00f5c1d17dc294bd3 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Tue, 13 Jan 2026 17:32:04 +0000 Subject: [PATCH 07/16] WIP --- .../DynamicMediumFour.importable.tsx | 61 +++++++++------ .../src/model/createCollection.ts | 76 +++++++++---------- dotcom-rendering/src/types/front.ts | 6 +- 3 files changed, 77 insertions(+), 66 deletions(-) diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index f8377d015c1..51df3f7e819 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -8,6 +8,7 @@ import type { DCRContainerLevel, DCRContainerPalette, DCRFrontCard, + PillarBucket, } from '../types/front'; import { LI } from './Card/components/LI'; import type { MediaPositionType } from './Card/components/MediaWrapper'; @@ -37,21 +38,19 @@ type Props = { aspectRatio: AspectRatio; containerLevel?: DCRContainerLevel; isInStarRatingVariant?: boolean; - backfillBucket?: DCRFrontCard[]; + backfillBucket?: PillarBucket; }; -// const LOCAL_STATE = ["/commentisfree/2026/jan/13/iran-protesters-western-intervention-us-israel", "ID", "ID"] +export const ViewHistoryKey = 'gu.history.viewedCards'; -const filterViewedCards = ( - cards?: DCRFrontCard[], - viewedCards?: string[], -): DCRFrontCard[] => { - if (!cards) return []; - return cards.filter((card) => { - return !viewedCards?.includes(card.url); - }); +const filterBuckets = (backfillBucket: PillarBucket, viewedList: string[]) => { + return Object.fromEntries( + Object.entries(backfillBucket).map(([key, values]) => [ + key, + values.filter((card) => !viewedList.includes(card.url)), + ]), + ); }; -export const ViewHistoryKey = 'gu.history.viewedCards'; export const DynamicMediumFour = ({ trails, @@ -71,21 +70,35 @@ export const DynamicMediumFour = ({ const [shouldShowHighlights, setShouldShowHighlights] = useState(false); useEffect(() => { - // get local state - const viewedCards = storage.local.get(ViewHistoryKey) as string[]; - const unviewedFirstCards = filterViewedCards( - trails.slice(0, 4), - viewedCards, - ); - const unviewedSecondCards = filterViewedCards( + if (!backfillBucket) { + setShouldShowHighlights(true); + return; + } + // // get local state + const viewedCards = storage.local.get(ViewHistoryKey); + + if (!viewedCards) { + setShouldShowHighlights(true); + return; + } + const filteredBuckets = filterBuckets( backfillBucket, - viewedCards, + viewedCards as string[], ); - const leftOver = 4 - unviewedFirstCards.length; - setOrderedTrails([ - ...unviewedFirstCards, - ...unviewedSecondCards.slice(0, leftOver), - ]); + console.log('filteredBuckets', filteredBuckets); + const { Opinion, Sport, Culture, Lifestyle } = filteredBuckets; + const newTrails = [ + Opinion[0], + Sport[0], + Culture[0], + Lifestyle[0], + ].filter(Boolean); + console.log(newTrails); + if (newTrails.length <= 0) { + setShouldShowHighlights(true); + return; + } + setOrderedTrails(newTrails); setShouldShowHighlights(true); }, [trails, backfillBucket]); diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index 637628a157e..74cf3072c83 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -1,5 +1,5 @@ import type { FECollection, FEFrontCard } from '../frontend/feFront'; -import type { DCRCollectionType } from '../types/front'; +import type { DCRCollectionType, PillarBucket } from '../types/front'; import { enhanceCards } from './enhanceCards'; const acrossTheGuardianCollection: DCRCollectionType = { @@ -29,7 +29,12 @@ const acrossTheGuardianCollection: DCRCollectionType = { }; const PILLAR_CONTAINERS = ['Culture', 'Opinion', 'Sport', 'Lifestyle']; -const MORE_PILLAR_CONTAINERS = ['More culture', 'More sport', 'More lifestyle']; +const MORE_PILLAR_CONTAINERS = [ + 'More culture', + 'More sport', + 'More lifestyle', + 'More opinion', +]; const isPillarContainer = (collection: FECollection) => PILLAR_CONTAINERS.includes(collection.displayName); @@ -37,52 +42,48 @@ const isPillarContainer = (collection: FECollection) => const isMorePillarContainer = (collection: FECollection) => { return MORE_PILLAR_CONTAINERS.includes(collection.displayName); }; +const normaliseMorePillarName = (displayName: string): string => + displayName.replace(/^More\s+/i, '').replace(/^./, (c) => c.toUpperCase()); type PillarCollection = { pillar: string; curated: FEFrontCard[]; }; const getPillarCards = (collections: FECollection[]) => { - return collections.filter(isPillarContainer).map((collection) => { - return { pillar: collection.displayName, curated: collection.curated }; - }); -}; - -const getMoreCards = (collections: FECollection[]) => { - const buckets = collections - .filter(isMorePillarContainer) - .map((collection) => collection.curated); - - const maxLength = Math.max(...buckets.map((b) => b.length)); - - const result = []; - - for (let i = 0; i < maxLength; i++) { - for (const bucket of buckets) { - if (bucket[i]) { - result.push(bucket[i]); - } + const pillarCards = collections + .filter(isPillarContainer) + .map((collection) => { + return { + pillar: collection.displayName, + curated: [...collection.curated], + }; + }); + + for (const collection of collections.filter(isMorePillarContainer)) { + const pillarName = normaliseMorePillarName(collection.displayName); + + const pillar = pillarCards.find((p) => p.pillar === pillarName); + + if (pillar) { + pillar.curated.push(...collection.curated); } } - return result; + return pillarCards; }; -function shuffle(array: (FEFrontCard | undefined)[]) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; -} const getCuratedList = (PillarCollections: PillarCollection[]) => { const curatedList: FEFrontCard[] = []; - const bucketList: FEFrontCard[] = []; + const bucketList: PillarBucket = {}; for (const collection of PillarCollections) { - const [firstCard, ...remaining] = collection.curated; + const firstCard = collection.curated[0]; if (firstCard) curatedList.push(firstCard); - bucketList.push(...remaining); + bucketList[collection.pillar] = enhanceCards(collection.curated, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }); } return { curatedList, bucketList }; @@ -92,11 +93,10 @@ export const createFakeCollection = ( collections: FECollection[], ): DCRCollectionType => { const pillarCards = getPillarCards(collections); - const moreBucket = getMoreCards(collections); const { curatedList, bucketList } = getCuratedList(pillarCards); - const combineBucket = [...bucketList, ...moreBucket]; - const shuffledBucket = shuffle(combineBucket) as FEFrontCard[]; + console.log(bucketList); + return { ...acrossTheGuardianCollection, curated: enhanceCards(curatedList, { @@ -104,11 +104,7 @@ export const createFakeCollection = ( discussionApiUrl: 'string', editionId: 'UK', }), - bucket: enhanceCards(shuffledBucket, { - cardInTagPage: false, - discussionApiUrl: 'string', - editionId: 'UK', - }), + bucket: bucketList, }; }; diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 8b1aa844f30..d024c405cd9 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -117,7 +117,9 @@ export type DCRSnapType = { }; export type AspectRatio = FEAspectRatio; - +export type PillarBucket = { + [key: string]: DCRFrontCard[]; +}; export type DCRCollectionType = { id: string; displayName: string; @@ -144,7 +146,7 @@ export type DCRCollectionType = { collectionBranding?: CollectionBranding; targetedTerritory?: Territory; aspectRatio?: AspectRatio; - bucket?: DCRFrontCard[]; + bucket?: PillarBucket; }; export type DCRGroupedTrails = { From 007ea15ce45801642db28d4cb2cc168ff1f3c9b1 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Wed, 14 Jan 2026 16:48:35 +0000 Subject: [PATCH 08/16] Refactor models to include more containers and remove cards that are in the highlights container --- .../src/model/createCollection.ts | 165 ++++++++++-------- 1 file changed, 89 insertions(+), 76 deletions(-) diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index 74cf3072c83..9d49b7624db 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -1,5 +1,5 @@ import type { FECollection, FEFrontCard } from '../frontend/feFront'; -import type { DCRCollectionType, PillarBucket } from '../types/front'; +import type { DCRCollectionType, DCRFrontCard } from '../types/front'; import { enhanceCards } from './enhanceCards'; const acrossTheGuardianCollection: DCRCollectionType = { @@ -17,7 +17,6 @@ const acrossTheGuardianCollection: DCRCollectionType = { standard: [], }, curated: [], - bucket: [], backfill: [], treats: [], config: { @@ -28,101 +27,115 @@ const acrossTheGuardianCollection: DCRCollectionType = { aspectRatio: '5:4', }; -const PILLAR_CONTAINERS = ['Culture', 'Opinion', 'Sport', 'Lifestyle']; -const MORE_PILLAR_CONTAINERS = [ - 'More culture', - 'More sport', - 'More lifestyle', - 'More opinion', -]; - -const isPillarContainer = (collection: FECollection) => - PILLAR_CONTAINERS.includes(collection.displayName); +type Pillar = 'sport' | 'lifestyle' | 'opinion' | 'culture'; -const isMorePillarContainer = (collection: FECollection) => { - return MORE_PILLAR_CONTAINERS.includes(collection.displayName); +type PillarContainer = { + pillar: Pillar; + containerName: string; }; -const normaliseMorePillarName = (displayName: string): string => - displayName.replace(/^More\s+/i, '').replace(/^./, (c) => c.toUpperCase()); +const pillarContainers: PillarContainer[] = [ + //culture + { containerName: 'Culture', pillar: 'culture' }, + { containerName: 'What to watch', pillar: 'culture' }, + { containerName: 'What to listen to', pillar: 'culture' }, + { containerName: 'What to read', pillar: 'culture' }, + { containerName: 'What to play', pillar: 'culture' }, + { containerName: 'What to visit', pillar: 'culture' }, + { containerName: 'More culture', pillar: 'culture' }, + //opinion + { containerName: 'Opinion', pillar: 'opinion' }, + { containerName: 'More opinion', pillar: 'opinion' }, + { containerName: 'Editorials', pillar: 'opinion' }, + //sport + { containerName: 'Sport', pillar: 'sport' }, + { containerName: 'More sport', pillar: 'sport' }, + //lifestyle + { containerName: 'Lifestyle', pillar: 'lifestyle' }, + { containerName: 'The Filter', pillar: 'lifestyle' }, + { containerName: 'Food', pillar: 'lifestyle' }, + { containerName: 'Relationships', pillar: 'lifestyle' }, + { containerName: 'Money & consumer', pillar: 'lifestyle' }, + { containerName: 'Health & fitness', pillar: 'lifestyle' }, + { containerName: 'Fashion & beauty', pillar: 'lifestyle' }, + { containerName: 'Travel', pillar: 'lifestyle' }, + { containerName: 'More Lifestyle', pillar: 'lifestyle' }, +]; -type PillarCollection = { - pillar: string; - curated: FEFrontCard[]; +type DCRPillarCards = { + lifestyle: DCRFrontCard[]; + opinion: DCRFrontCard[]; + sport: DCRFrontCard[]; + culture: DCRFrontCard[]; }; -const getPillarCards = (collections: FECollection[]) => { - const pillarCards = collections - .filter(isPillarContainer) - .map((collection) => { - return { - pillar: collection.displayName, - curated: [...collection.curated], - }; - }); - for (const collection of collections.filter(isMorePillarContainer)) { - const pillarName = normaliseMorePillarName(collection.displayName); +const getPillarCards = (collections: FECollection[]): DCRPillarCards => { + const HighlightUrls = collections + .filter((collection) => 'Highlights' === collection.displayName) + .flatMap((collection) => collection.curated) + .map((card) => card.properties.webUrl); + + const pillarCards: Record = { + lifestyle: [], + opinion: [], + sport: [], + culture: [], + }; - const pillar = pillarCards.find((p) => p.pillar === pillarName); + for (const collection of collections) { + const pillarContainer = pillarContainers.find( + (pillar) => pillar.containerName === collection.displayName, + ); - if (pillar) { - pillar.curated.push(...collection.curated); - } - } + if (!pillarContainer) continue; + const curatedCards = [...collection.curated].filter( + (card) => !HighlightUrls.includes(card.properties.webUrl), + ); - return pillarCards; -}; - -const getCuratedList = (PillarCollections: PillarCollection[]) => { - const curatedList: FEFrontCard[] = []; - const bucketList: PillarBucket = {}; + pillarCards[pillarContainer.pillar].push(...curatedCards); + } - for (const collection of PillarCollections) { - const firstCard = collection.curated[0]; - if (firstCard) curatedList.push(firstCard); - bucketList[collection.pillar] = enhanceCards(collection.curated, { + return { + lifestyle: enhanceCards(pillarCards.lifestyle, { cardInTagPage: false, discussionApiUrl: 'string', editionId: 'UK', - }); - } + }), + opinion: enhanceCards(pillarCards.opinion, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + sport: enhanceCards(pillarCards.sport, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + culture: enhanceCards(pillarCards.culture, { + cardInTagPage: false, + discussionApiUrl: 'string', + editionId: 'UK', + }), + }; +}; - return { curatedList, bucketList }; +const getCuratedList = (buckets: DCRPillarCards): DCRFrontCard[] => { + return [ + buckets.opinion[0], + buckets.sport[0], + buckets.culture[0], + buckets.lifestyle[0], + ] as DCRFrontCard[]; }; export const createFakeCollection = ( collections: FECollection[], ): DCRCollectionType => { const pillarCards = getPillarCards(collections); - const { curatedList, bucketList } = getCuratedList(pillarCards); - - console.log(bucketList); + const curatedList = getCuratedList(pillarCards); return { ...acrossTheGuardianCollection, - curated: enhanceCards(curatedList, { - cardInTagPage: false, - discussionApiUrl: 'string', - editionId: 'UK', - }), - bucket: bucketList, + curated: curatedList, + bucket: pillarCards, }; }; - -/* - * History = { - * cardId = "1234" - * viewCount = 1 - * } - * */ - -// curated = [ -// {opinion 1}, -// {sport 1}, -// {culture 1 }, -// {lifestyle 1 }, -// ] - -// bucket = [ -// -// ] -// console.log(getPillarCards()) From 94978598c3be03903975a23bdb0e0340a5d711ed Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Wed, 14 Jan 2026 17:10:25 +0000 Subject: [PATCH 09/16] Refactor types to improve safety --- .../DynamicMediumFour.importable.tsx | 55 ++++++++++--------- .../src/model/createCollection.ts | 27 +++++---- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index 51df3f7e819..1b16e68ac2a 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -3,6 +3,8 @@ import { Button } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; +import type { DCRPillarCards } from '../model/createCollection'; +import { getCuratedList, PILLARS } from '../model/createCollection'; import type { AspectRatio, DCRContainerLevel, @@ -43,13 +45,25 @@ type Props = { export const ViewHistoryKey = 'gu.history.viewedCards'; -const filterBuckets = (backfillBucket: PillarBucket, viewedList: string[]) => { - return Object.fromEntries( - Object.entries(backfillBucket).map(([key, values]) => [ - key, - values.filter((card) => !viewedList.includes(card.url)), - ]), - ); +const filterBuckets = ( + backfillBucket: PillarBucket, + viewedList: string[], +): DCRPillarCards => { + const result: DCRPillarCards = { + opinion: [], + sport: [], + culture: [], + lifestyle: [], + }; + + for (const pillar of PILLARS) { + result[pillar] = + backfillBucket[pillar]?.filter( + (card) => !viewedList.includes(card.url), + ) ?? []; + } + + return result; }; export const DynamicMediumFour = ({ @@ -70,6 +84,7 @@ export const DynamicMediumFour = ({ const [shouldShowHighlights, setShouldShowHighlights] = useState(false); useEffect(() => { + // if we don't have a backfill bucket, show the default 4 if (!backfillBucket) { setShouldShowHighlights(true); return; @@ -77,28 +92,18 @@ export const DynamicMediumFour = ({ // // get local state const viewedCards = storage.local.get(ViewHistoryKey); - if (!viewedCards) { + // if we don't have a view history, show the default 4 + if (!Array.isArray(viewedCards)) { setShouldShowHighlights(true); return; } - const filteredBuckets = filterBuckets( - backfillBucket, - viewedCards as string[], - ); - console.log('filteredBuckets', filteredBuckets); - const { Opinion, Sport, Culture, Lifestyle } = filteredBuckets; - const newTrails = [ - Opinion[0], - Sport[0], - Culture[0], - Lifestyle[0], - ].filter(Boolean); - console.log(newTrails); - if (newTrails.length <= 0) { - setShouldShowHighlights(true); - return; + const filteredBuckets = filterBuckets(backfillBucket, viewedCards); + + const newTrails = getCuratedList(filteredBuckets); + if (newTrails.length > 0) { + setOrderedTrails(newTrails); } - setOrderedTrails(newTrails); + setShouldShowHighlights(true); }, [trails, backfillBucket]); diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index 9d49b7624db..e8b073f8edc 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -27,7 +27,14 @@ const acrossTheGuardianCollection: DCRCollectionType = { aspectRatio: '5:4', }; -type Pillar = 'sport' | 'lifestyle' | 'opinion' | 'culture'; +export type Pillar = 'sport' | 'lifestyle' | 'opinion' | 'culture'; + +export const PILLARS = [ + 'sport', + 'lifestyle', + 'opinion', + 'culture', +] as const satisfies readonly Pillar[]; type PillarContainer = { pillar: Pillar; @@ -61,12 +68,7 @@ const pillarContainers: PillarContainer[] = [ { containerName: 'More Lifestyle', pillar: 'lifestyle' }, ]; -type DCRPillarCards = { - lifestyle: DCRFrontCard[]; - opinion: DCRFrontCard[]; - sport: DCRFrontCard[]; - culture: DCRFrontCard[]; -}; +export type DCRPillarCards = Record; const getPillarCards = (collections: FECollection[]): DCRPillarCards => { const HighlightUrls = collections @@ -118,13 +120,10 @@ const getPillarCards = (collections: FECollection[]): DCRPillarCards => { }; }; -const getCuratedList = (buckets: DCRPillarCards): DCRFrontCard[] => { - return [ - buckets.opinion[0], - buckets.sport[0], - buckets.culture[0], - buckets.lifestyle[0], - ] as DCRFrontCard[]; +export const getCuratedList = (buckets: DCRPillarCards): DCRFrontCard[] => { + return PILLARS.map((pillar) => buckets[pillar][0]).filter( + (card): card is DCRFrontCard => card !== undefined, + ); }; export const createFakeCollection = ( From adf16e840eeea3679d0e468ea278f765f5266944 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Wed, 14 Jan 2026 17:15:15 +0000 Subject: [PATCH 10/16] Position immediately after news --- dotcom-rendering/src/server/handler.front.web.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 734658d8b7c..834e9823b6e 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -39,15 +39,15 @@ const enhanceFront = (body: unknown): Front => { ), }); - const inFocusIndex = + const acrossTheGPosition = data.pressedPage.collections.findIndex( - (c) => c.displayName === 'In focus', + (c) => c.displayName === 'News', ) + 1; const combinedCollections = [ - ...collections.slice(0, inFocusIndex), + ...collections.slice(0, acrossTheGPosition), acrossTheG, - ...collections.slice(inFocusIndex), + ...collections.slice(acrossTheGPosition), ]; return { From e564823cc602cd328bc44dd2f31bd1a6205eed65 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Wed, 14 Jan 2026 17:16:34 +0000 Subject: [PATCH 11/16] Order cards by pillars --- .../src/components/DynamicMediumFour.importable.tsx | 1 + dotcom-rendering/src/model/createCollection.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index 1b16e68ac2a..af07943ba34 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -83,6 +83,7 @@ export const DynamicMediumFour = ({ ); const [shouldShowHighlights, setShouldShowHighlights] = useState(false); + useEffect(() => { // if we don't have a backfill bucket, show the default 4 if (!backfillBucket) { diff --git a/dotcom-rendering/src/model/createCollection.ts b/dotcom-rendering/src/model/createCollection.ts index e8b073f8edc..807e5cabc4a 100644 --- a/dotcom-rendering/src/model/createCollection.ts +++ b/dotcom-rendering/src/model/createCollection.ts @@ -27,13 +27,13 @@ const acrossTheGuardianCollection: DCRCollectionType = { aspectRatio: '5:4', }; -export type Pillar = 'sport' | 'lifestyle' | 'opinion' | 'culture'; +export type Pillar = 'opinion' | 'sport' | 'culture' | 'lifestyle'; export const PILLARS = [ - 'sport', - 'lifestyle', 'opinion', + 'sport', 'culture', + 'lifestyle', ] as const satisfies readonly Pillar[]; type PillarContainer = { From 66fce134de57b7225a16d0ea3815281814c3a500 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 15 Jan 2026 11:03:42 +0000 Subject: [PATCH 12/16] Add helper file for handling local state --- .../DynamicMediumFour.importable.tsx | 15 +++++----- .../src/lib/personalisationHistory.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 dotcom-rendering/src/lib/personalisationHistory.ts diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index af07943ba34..0bee3deb816 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -1,8 +1,9 @@ -import { storage } from '@guardian/libs'; +import { isUndefined, storage } from '@guardian/libs'; import { Button } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; +import { getDemotedState } from '../lib/personalisationHistory'; import type { DCRPillarCards } from '../model/createCollection'; import { getCuratedList, PILLARS } from '../model/createCollection'; import type { @@ -49,7 +50,7 @@ const filterBuckets = ( backfillBucket: PillarBucket, viewedList: string[], ): DCRPillarCards => { - const result: DCRPillarCards = { + const filteredPillarBuckets: DCRPillarCards = { opinion: [], sport: [], culture: [], @@ -57,13 +58,13 @@ const filterBuckets = ( }; for (const pillar of PILLARS) { - result[pillar] = + filteredPillarBuckets[pillar] = backfillBucket[pillar]?.filter( (card) => !viewedList.includes(card.url), ) ?? []; } - return result; + return filteredPillarBuckets; }; export const DynamicMediumFour = ({ @@ -86,15 +87,15 @@ export const DynamicMediumFour = ({ useEffect(() => { // if we don't have a backfill bucket, show the default 4 - if (!backfillBucket) { + if (isUndefined(backfillBucket)) { setShouldShowHighlights(true); return; } // // get local state - const viewedCards = storage.local.get(ViewHistoryKey); + const viewedCards = getDemotedState(); // if we don't have a view history, show the default 4 - if (!Array.isArray(viewedCards)) { + if (isUndefined(viewedCards) || viewedCards.length === 0) { setShouldShowHighlights(true); return; } diff --git a/dotcom-rendering/src/lib/personalisationHistory.ts b/dotcom-rendering/src/lib/personalisationHistory.ts new file mode 100644 index 00000000000..25adb376f06 --- /dev/null +++ b/dotcom-rendering/src/lib/personalisationHistory.ts @@ -0,0 +1,29 @@ +import { storage } from '@guardian/libs'; + +type CardEvent = 'VIEW' | 'CLICK'; + +export const DemotedCardsHistoryKey = 'gu.history.demotedCards'; +export const ViewedCardsHistoryKey = 'gu.history.viewedCards'; + +const MAX_VIEW_COUNT = 2; + +type DemotedCardsState = string[]; +const isValidDemotedState = (history: unknown): history is DemotedCardsState => + Array.isArray(history) && history.every((card) => typeof card === 'string'); + +/* Retrieve the user's demoted card state from local storage */ +export const getDemotedState = (): DemotedCardsState | undefined => { + try { + const demotedCardHistory = storage.local.get(DemotedCardsHistoryKey); + + if (!isValidDemotedState(demotedCardHistory)) { + throw new Error(`Invalid ${DemotedCardsHistoryKey} value`); + } + + return demotedCardHistory; + } catch (e) { + /* error parsing the string, so remove the key */ + storage.local.remove(DemotedCardsHistoryKey); + return undefined; + } +}; From a3522e9e81e1f71bc69e928fd93e323f74d0fc85 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 15 Jan 2026 14:53:06 +0000 Subject: [PATCH 13/16] Add view tracking and update naming --- .../components/Card/components/CardLink.tsx | 50 ++------ .../src/components/DecideContainer.tsx | 9 +- .../DynamicMediumFour.importable.tsx | 71 ++++++----- dotcom-rendering/src/layouts/FrontLayout.tsx | 2 +- .../src/lib/personalisationHistory.ts | 113 ++++++++++++++++-- 5 files changed, 166 insertions(+), 79 deletions(-) diff --git a/dotcom-rendering/src/components/Card/components/CardLink.tsx b/dotcom-rendering/src/components/Card/components/CardLink.tsx index 8254edf73ed..c25c1222940 100644 --- a/dotcom-rendering/src/components/Card/components/CardLink.tsx +++ b/dotcom-rendering/src/components/Card/components/CardLink.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/react'; import { focusHalo } from '@guardian/source/foundations'; import { getZIndex } from '../../../lib/getZIndex'; -import { ViewHistoryKey } from '../../DynamicMediumFour.importable'; -import { storage } from '@guardian/libs'; +import { trackPersonalisationClick } from '../../../lib/personalisationHistory'; const fauxLinkStyles = css` position: absolute; @@ -29,12 +28,12 @@ const InternalLink = ({ linkTo, headlineText, dataLinkName, - trackCardClick, + trackPersonalisationCardClick, }: { linkTo: string; headlineText: string; dataLinkName?: string; - trackCardClick?: () => void; + trackPersonalisationCardClick?: () => void; }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content @@ -43,7 +42,7 @@ const InternalLink = ({ css={fauxLinkStyles} data-link-name={dataLinkName} aria-label={headlineText} - onClick={trackCardClick} + onClick={trackPersonalisationCardClick} /> ); }; @@ -52,12 +51,12 @@ const ExternalLink = ({ linkTo, headlineText, dataLinkName, - trackCardClick, + trackPersonalisationCardClick, }: { linkTo: string; headlineText: string; dataLinkName?: string; - trackCardClick?: () => void; + trackPersonalisationCardClick?: () => void; }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-has-content -- we have an aria-label attribute describing the content @@ -68,44 +67,17 @@ const ExternalLink = ({ aria-label={headlineText + ' (opens in new tab)'} target="_blank" rel="noreferrer" - onClick={trackCardClick} + onClick={trackPersonalisationCardClick} /> ); }; -const isValidHighlightsState = (history: unknown): history is string[] => - Array.isArray(history) && - history.every((highlight) => typeof highlight === 'string'); - -export const getViewState = (): string[] | undefined => { - try { - const highlightHistory = storage.local.get(ViewHistoryKey); - - if (!isValidHighlightsState(highlightHistory)) { - throw new Error(`Invalid ${ViewHistoryKey} value`); - } - - return highlightHistory; - } catch (e) { - /* error parsing the string, so remove the key */ - storage.local.remove(ViewHistoryKey); - return undefined; - } -}; - export const CardLink = ({ linkTo, headlineText, 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? isExternalLink, }: Props) => { - const saveState = (url: string) => { - console.log('saveState', url); - const viewedCards = getViewState() ?? []; - console.log('viewedCards', viewedCards); - storage.local.set(ViewHistoryKey, [...viewedCards, url]); - }; - return ( <> {isExternalLink && ( @@ -113,7 +85,9 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} - trackCardClick={() => saveState(linkTo)} + trackPersonalisationCardClick={() => + trackPersonalisationClick(linkTo) + } /> )} {!isExternalLink && ( @@ -121,7 +95,9 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} - trackCardClick={() => saveState(linkTo)} + trackPersonalisationCardClick={() => + trackPersonalisationClick(linkTo) + } /> )} diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 895463c2fc0..1636960eb2e 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -6,6 +6,7 @@ import type { DCRContainerType, DCRFrontCard, DCRGroupedTrails, + PillarBucket, } from '../types/front'; import { DynamicFast } from './DynamicFast'; import { DynamicMediumFour } from './DynamicMediumFour.importable'; @@ -49,7 +50,7 @@ type Props = { collectionId: number; containerLevel?: DCRContainerLevel; isInStarRatingVariant?: boolean; - backfillBucket?: DCRFrontCard[]; + pillarBuckets?: PillarBucket; }; export const DecideContainer = ({ @@ -66,7 +67,7 @@ export const DecideContainer = ({ collectionId, containerLevel, isInStarRatingVariant, - backfillBucket, + pillarBuckets, }: Props) => { switch (containerType) { case 'dynamic/fast': @@ -301,7 +302,7 @@ export const DecideContainer = ({ ); case 'static/medium/4': - if (backfillBucket) { + if (pillarBuckets) { return ( ); diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx index 0bee3deb816..e6f30f07a7d 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx @@ -3,7 +3,12 @@ import { Button } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; -import { getDemotedState } from '../lib/personalisationHistory'; +import { + DemotedCardsHistoryKey, + getDemotedState, + trackView, + ViewedCardsHistoryKey, +} from '../lib/personalisationHistory'; import type { DCRPillarCards } from '../model/createCollection'; import { getCuratedList, PILLARS } from '../model/createCollection'; import type { @@ -17,7 +22,6 @@ import { LI } from './Card/components/LI'; import type { MediaPositionType } from './Card/components/MediaWrapper'; import { UL } from './Card/components/UL'; import type { Loading } from './CardPicture'; -// eslint-disable-next-line import/no-cycle import { FrontCard } from './FrontCard'; const getMediaPositionOnDesktop = ( @@ -41,14 +45,12 @@ type Props = { aspectRatio: AspectRatio; containerLevel?: DCRContainerLevel; isInStarRatingVariant?: boolean; - backfillBucket?: PillarBucket; + pillarBuckets?: PillarBucket; }; -export const ViewHistoryKey = 'gu.history.viewedCards'; - const filterBuckets = ( - backfillBucket: PillarBucket, - viewedList: string[], + pillarBuckets: PillarBucket, + demotedCards: string[], ): DCRPillarCards => { const filteredPillarBuckets: DCRPillarCards = { opinion: [], @@ -59,8 +61,8 @@ const filterBuckets = ( for (const pillar of PILLARS) { filteredPillarBuckets[pillar] = - backfillBucket[pillar]?.filter( - (card) => !viewedList.includes(card.url), + pillarBuckets[pillar]?.filter( + (card) => !demotedCards.includes(card.url), ) ?? []; } @@ -77,45 +79,56 @@ export const DynamicMediumFour = ({ aspectRatio, containerLevel = 'Primary', isInStarRatingVariant, - backfillBucket, + pillarBuckets, }: Props) => { const [orderedTrails, setOrderedTrails] = useState( trails.slice(0, 4), ); - const [shouldShowHighlights, setShouldShowHighlights] = - useState(false); + const [shouldShowCards, setShouldShowCards] = useState(false); + + const [hasTrackedView, setHasTrackedView] = useState(false); useEffect(() => { - // if we don't have a backfill bucket, show the default 4 - if (isUndefined(backfillBucket)) { - setShouldShowHighlights(true); + if (isUndefined(pillarBuckets)) { + setShouldShowCards(true); return; } - // // get local state - const viewedCards = getDemotedState(); - // if we don't have a view history, show the default 4 - if (isUndefined(viewedCards) || viewedCards.length === 0) { - setShouldShowHighlights(true); + const demotedCards = getDemotedState() ?? []; + + if (demotedCards.length === 0) { + setShouldShowCards(true); return; } - const filteredBuckets = filterBuckets(backfillBucket, viewedCards); - const newTrails = getCuratedList(filteredBuckets); - if (newTrails.length > 0) { - setOrderedTrails(newTrails); + const filteredBuckets = filterBuckets(pillarBuckets, demotedCards); + + const curatedTrails = getCuratedList(filteredBuckets); + + if (curatedTrails.length > 0) { + setOrderedTrails(curatedTrails); } - setShouldShowHighlights(true); - }, [trails, backfillBucket]); + setShouldShowCards(true); + }, [trails, pillarBuckets]); + + useEffect(() => { + if (shouldShowCards && !hasTrackedView) { + trackView(orderedTrails); + setHasTrackedView(true); + } + }, [orderedTrails, hasTrackedView, shouldShowCards]); return ( <>
    @@ -127,7 +140,7 @@ export const DynamicMediumFour = ({ key={card.url} padSides={true} showDivider={cardIndex > 0} - isVisible={shouldShowHighlights} + isVisible={shouldShowCards} > { isInStarRatingVariant={ isInStarRatingVariant } - backfillBucket={collection.bucket} + pillarBuckets={collection.bucket} /> diff --git a/dotcom-rendering/src/lib/personalisationHistory.ts b/dotcom-rendering/src/lib/personalisationHistory.ts index 25adb376f06..03469232d74 100644 --- a/dotcom-rendering/src/lib/personalisationHistory.ts +++ b/dotcom-rendering/src/lib/personalisationHistory.ts @@ -1,18 +1,31 @@ -import { storage } from '@guardian/libs'; - -type CardEvent = 'VIEW' | 'CLICK'; +import { isObject, storage } from '@guardian/libs'; +import type { DCRFrontCard } from '../types/front'; export const DemotedCardsHistoryKey = 'gu.history.demotedCards'; export const ViewedCardsHistoryKey = 'gu.history.viewedCards'; const MAX_VIEW_COUNT = 2; -type DemotedCardsState = string[]; -const isValidDemotedState = (history: unknown): history is DemotedCardsState => - Array.isArray(history) && history.every((card) => typeof card === 'string'); +type DemotedCardHistory = string[]; +type CardHistory = { cardUrl: string; viewCount: number }; +type ViewedCardHistory = CardHistory[]; + +/** + * + * A card qualifies for demotion if it has been either + * clicked once + * or + * viewed twice + * + * We use these two metrics as a proxy for (positive or negative) user engagement with the card + */ -/* Retrieve the user's demoted card state from local storage */ -export const getDemotedState = (): DemotedCardsState | undefined => { +const isValidDemotedState = (history: unknown): history is DemotedCardHistory => + Array.isArray(history) && + history.every((cardUrl) => typeof cardUrl === 'string'); + +/* Retrieve the user's demoted card list from local storage */ +export const getDemotedState = (): DemotedCardHistory | undefined => { try { const demotedCardHistory = storage.local.get(DemotedCardsHistoryKey); @@ -27,3 +40,87 @@ export const getDemotedState = (): DemotedCardsState | undefined => { return undefined; } }; + +const isValidViewState = (history: unknown): history is ViewedCardHistory => + Array.isArray(history) && + history.every( + (viewedCard) => + isObject(viewedCard) && + 'cardUrl' in viewedCard && + 'viewCount' in viewedCard && + typeof viewedCard.cardUrl === 'string' && + typeof viewedCard.viewCount === 'number', + ); + +/* Retrieve the user's viewed card state from local storage */ +export const getViewedState = (): ViewedCardHistory | undefined => { + try { + const ViewedCardState = storage.local.get(ViewedCardsHistoryKey); + + if (!isValidViewState(ViewedCardState)) { + throw new Error(`Invalid ${ViewedCardsHistoryKey} value`); + } + + return ViewedCardState; + } catch (e) { + /* error parsing the string, so remove the key */ + storage.local.remove(ViewedCardsHistoryKey); + return undefined; + } +}; + +export const trackView = (cards: DCRFrontCard[]): void => { + const recentlyViewedCardUrls = cards.map((card) => card.url); + + const viewedCards = getViewedState() ?? []; + + const cardsForDemotion: DemotedCardHistory = []; + + const updatedViewedCards = [...viewedCards]; + + for (const url of recentlyViewedCardUrls) { + const index = updatedViewedCards.findIndex( + (card) => card.cardUrl === url, + ); + + if (index > -1 && updatedViewedCards[index]) { + // Card already exists, increment count by 1 + const incrementedViewCount = + updatedViewedCards[index].viewCount + 1; + + const updatedCard = { + ...updatedViewedCards[index], + viewCount: incrementedViewCount, + }; + + updatedViewedCards[index] = updatedCard; + + if (incrementedViewCount >= MAX_VIEW_COUNT) { + cardsForDemotion.push(updatedCard.cardUrl); + } + } else { + // New card → add with count 1 + updatedViewedCards.push({ + cardUrl: url, + viewCount: 1, + }); + } + } + + // Persist viewed cards + storage.local.set(ViewedCardsHistoryKey, updatedViewedCards); + + // Persist deduped list of demoted cards + if (cardsForDemotion.length > 0) { + const demotedCards = getDemotedState() ?? []; + const nextDemoted = Array.from( + new Set([...demotedCards, ...cardsForDemotion]), + ); + + storage.local.set(DemotedCardsHistoryKey, nextDemoted); + } +}; +export const trackPersonalisationClick = (url: string): void => { + const demotedCards = getDemotedState() ?? []; + storage.local.set(DemotedCardsHistoryKey, [...demotedCards, url]); +}; From d7c36da9e8fb00e230b25ac7adba4d50085fa518 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 22 Jan 2026 08:55:34 +0000 Subject: [PATCH 14/16] Rename to personalised container as dynamic is overloaded --- dotcom-rendering/src/components/DecideContainer.tsx | 4 ++-- .../src/components/DynamicMediumFour.importable.tsx | 2 +- dotcom-rendering/src/model/createCollection.ts | 4 ++-- dotcom-rendering/src/server/handler.front.web.ts | 13 ++++++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 1636960eb2e..4e7fd1f0853 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -9,7 +9,7 @@ import type { PillarBucket, } from '../types/front'; import { DynamicFast } from './DynamicFast'; -import { DynamicMediumFour } from './DynamicMediumFour.importable'; +import { PersonalisedMediumFour } from './DynamicMediumFour.importable'; import { DynamicPackage } from './DynamicPackage'; import { DynamicSlow } from './DynamicSlow'; import { FixedLargeSlowXIV } from './FixedLargeSlowXIV'; @@ -305,7 +305,7 @@ export const DecideContainer = ({ if (pillarBuckets) { return ( - { const serverTime = Date.now(); - const acrossTheG = createFakeCollection(data.pressedPage.collections); + const personalisedContainer = createFakeCollection( + data.pressedPage.collections, + ); + const collections = enhanceCollections({ collections: data.pressedPage.collections, editionId: data.editionId, @@ -39,15 +42,15 @@ const enhanceFront = (body: unknown): Front => { ), }); - const acrossTheGPosition = + const personalisedContainerPosition = data.pressedPage.collections.findIndex( (c) => c.displayName === 'News', ) + 1; const combinedCollections = [ - ...collections.slice(0, acrossTheGPosition), - acrossTheG, - ...collections.slice(acrossTheGPosition), + ...collections.slice(0, personalisedContainerPosition), + personalisedContainer, + ...collections.slice(personalisedContainerPosition), ]; return { From 2a64975edae504e8a55e48ee7351d5b6580a0ad8 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 22 Jan 2026 09:05:28 +0000 Subject: [PATCH 15/16] Add personalised container ab test --- ab-testing/config/abTests.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index 5a71df2c826..b264a9be74b 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -70,6 +70,18 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + { + name: "fronts-and-curation-personalised-container", + description: "Testing the a personalised container component on fronts", + owners: ["fronts.and.curation@guardian.co.uk"], + expirationDate: `2026-02-22`, + type: "server", + status: "OFF", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, ]; const activeABtests = ABTests.filter((test) => test.status === "ON"); From 663d4f59bf1c72472ee9f81a7e57f146f2f3f365 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 22 Jan 2026 10:57:30 +0000 Subject: [PATCH 16/16] Wrap personalised container with a 0% server side AB test --- ab-testing/config/abTests.ts | 2 +- dotcom-rendering/src/components/Card/Card.tsx | 18 +++++++- .../components/Card/components/CardLink.tsx | 4 ++ .../src/components/DecideContainer.tsx | 2 +- ... => PersonalisedMediumFour.importable.tsx} | 44 +++++++++---------- .../src/lib/personalisationHistory.ts | 2 +- .../src/server/handler.front.web.ts | 23 ++++++---- 7 files changed, 60 insertions(+), 35 deletions(-) rename dotcom-rendering/src/components/{DynamicMediumFour.importable.tsx => PersonalisedMediumFour.importable.tsx} (84%) diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index b264a9be74b..9fb9d0a837c 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -76,7 +76,7 @@ const ABTests: ABTest[] = [ owners: ["fronts.and.curation@guardian.co.uk"], expirationDate: `2026-02-22`, type: "server", - status: "OFF", + status: "ON", audienceSize: 0 / 100, audienceSpace: "A", groups: ["control", "variant"], diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 67ac1787560..151d1df0d40 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -168,6 +168,7 @@ export type Props = { isInStarRatingVariant?: boolean; starRatingSize?: RatingSizeType; isInOnwardsAbTestVariant?: boolean; + isInPersonalisationVariant?: boolean; }; const starWrapper = (cardHasImage: boolean) => css` @@ -201,13 +202,16 @@ const waveformWrapper = ( left: 0; right: 0; bottom: 0; + svg { display: block; width: 100%; height: ${mediaPositionOnMobile === 'top' ? 50 : 29}px; + ${from.mobileMedium} { height: ${mediaPositionOnMobile === 'top' ? 50 : 33}px; } + ${from.tablet} { height: ${mediaPositionOnDesktop === 'top' ? 50 : 33}px; } @@ -221,12 +225,15 @@ const HorizontalDivider = () => ( border-top: 1px solid ${palette('--card-border-top')}; height: 1px; width: 50%; + ${from.tablet} { width: 100px; } + ${from.desktop} { width: 140px; } + margin-top: ${space[3]}px; } `} @@ -241,6 +248,7 @@ const podcastImageStyles = ( return css` width: 69px; height: 69px; + ${from.tablet} { width: 98px; height: 98px; @@ -254,11 +262,14 @@ const podcastImageStyles = ( return css` width: 98px; height: 98px; + ${from.tablet} { width: 120px; height: 120px; } + /** The image takes the full height on desktop, so that the waveform sticks to the bottom of the card. */ + ${from.desktop} { width: ${isHorizontalOnDesktop ? 'unset' : '120px'}; height: ${isHorizontalOnDesktop ? 'unset' : '120px'}; @@ -429,6 +440,7 @@ export const Card = ({ isInStarRatingVariant, starRatingSize = 'small', isInOnwardsAbTestVariant, + isInPersonalisationVariant, }: Props) => { const hasSublinks = supportingContent && supportingContent.length > 0; const sublinkPosition = decideSublinkPosition( @@ -612,8 +624,8 @@ export const Card = ({ /** - * Media cards have contrasting background colours. We add additional - * padding to these cards to keep the text readable. -- */ +* padding to these cards to keep the text readable. +- */ const isMediaCardOrNewsletter = isMediaCard(format) || isNewsletter; const showPill = isMediaCardOrNewsletter && !isGallerySecondaryOnward; @@ -910,6 +922,7 @@ export const Card = ({ ${until.tablet} { display: none; } + ${from.desktop} { display: none; } @@ -946,6 +959,7 @@ export const Card = ({ headlineText={headlineText} dataLinkName={resolvedDataLinkName} isExternalLink={isExternalLink} + isInPersonalisationVariant={isInPersonalisationVariant} /> {headlinePosition === 'outer' && (
    { return ( <> @@ -86,6 +88,7 @@ export const CardLink = ({ headlineText={headlineText} dataLinkName={dataLinkName} trackPersonalisationCardClick={() => + isInPersonalisationVariant && trackPersonalisationClick(linkTo) } /> @@ -96,6 +99,7 @@ export const CardLink = ({ headlineText={headlineText} dataLinkName={dataLinkName} trackPersonalisationCardClick={() => + isInPersonalisationVariant && trackPersonalisationClick(linkTo) } /> diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 4e7fd1f0853..2011045087c 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -9,7 +9,6 @@ import type { PillarBucket, } from '../types/front'; import { DynamicFast } from './DynamicFast'; -import { PersonalisedMediumFour } from './DynamicMediumFour.importable'; import { DynamicPackage } from './DynamicPackage'; import { DynamicSlow } from './DynamicSlow'; import { FixedLargeSlowXIV } from './FixedLargeSlowXIV'; @@ -29,6 +28,7 @@ import { FlexibleGeneral } from './FlexibleGeneral'; import { FlexibleSpecial } from './FlexibleSpecial'; import { Island } from './Island'; import { NavList } from './NavList'; +import { PersonalisedMediumFour } from './PersonalisedMediumFour.importable'; import { ScrollableFeature } from './ScrollableFeature.importable'; import { ScrollableHighlights } from './ScrollableHighlights.importable'; import { ScrollableMedium } from './ScrollableMedium.importable'; diff --git a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx b/dotcom-rendering/src/components/PersonalisedMediumFour.importable.tsx similarity index 84% rename from dotcom-rendering/src/components/DynamicMediumFour.importable.tsx rename to dotcom-rendering/src/components/PersonalisedMediumFour.importable.tsx index 4ea62fa753c..c0e8a3efead 100644 --- a/dotcom-rendering/src/components/DynamicMediumFour.importable.tsx +++ b/dotcom-rendering/src/components/PersonalisedMediumFour.importable.tsx @@ -1,14 +1,9 @@ -import { isUndefined, storage } from '@guardian/libs'; -import { Button } from '@guardian/source/react-components'; +import { isUndefined } from '@guardian/libs'; import { useEffect, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; -import { - DemotedCardsHistoryKey, - getDemotedState, - trackView, - ViewedCardsHistoryKey, -} from '../lib/personalisationHistory'; +import { getDemotedState, trackView } from '../lib/personalisationHistory'; +import { useBetaAB } from '../lib/useAB'; import type { DCRPillarCards } from '../model/createCollection'; import { getCuratedList, PILLARS } from '../model/createCollection'; import type { @@ -88,8 +83,15 @@ export const PersonalisedMediumFour = ({ const [hasTrackedView, setHasTrackedView] = useState(false); + const abTests = useBetaAB(); + const isInPersonalisationVariant = + abTests?.isUserInTestGroup( + 'fronts-and-curation-personalised-container', + 'variant', + ) ?? false; + useEffect(() => { - if (isUndefined(pillarBuckets)) { + if (isUndefined(pillarBuckets) || !isInPersonalisationVariant) { setShouldShowCards(true); return; } @@ -110,27 +112,22 @@ export const PersonalisedMediumFour = ({ } setShouldShowCards(true); - }, [trails, pillarBuckets]); + }, [trails, pillarBuckets, isInPersonalisationVariant]); useEffect(() => { - if (shouldShowCards && !hasTrackedView) { + if (shouldShowCards && !hasTrackedView && isInPersonalisationVariant) { trackView(orderedTrails); setHasTrackedView(true); } - }, [orderedTrails, hasTrackedView, shouldShowCards]); + }, [ + orderedTrails, + hasTrackedView, + shouldShowCards, + isInPersonalisationVariant, + ]); return ( <> -
      {orderedTrails.map((card, cardIndex) => { return ( @@ -170,6 +167,9 @@ export const PersonalisedMediumFour = ({ } canPlayInline={false} isInStarRatingVariant={isInStarRatingVariant} + isInPersonalisationVariant={ + isInPersonalisationVariant + } /> ); diff --git a/dotcom-rendering/src/lib/personalisationHistory.ts b/dotcom-rendering/src/lib/personalisationHistory.ts index 03469232d74..1e0e1c6ca07 100644 --- a/dotcom-rendering/src/lib/personalisationHistory.ts +++ b/dotcom-rendering/src/lib/personalisationHistory.ts @@ -99,7 +99,7 @@ export const trackView = (cards: DCRFrontCard[]): void => { cardsForDemotion.push(updatedCard.cardUrl); } } else { - // New card → add with count 1 + // New card -> add with count 1 updatedViewedCards.push({ cardUrl: url, viewCount: 1, diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index dae5353663c..b439933c0f4 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -25,9 +25,14 @@ const enhanceFront = (body: unknown): Front => { const serverTime = Date.now(); - const personalisedContainer = createFakeCollection( - data.pressedPage.collections, - ); + const isInPersonalisedContainerTest = + data.config.serverSideABTests[ + `fronts-and-curation-personalised-container` + ]; + + const personalisedContainer = isInPersonalisedContainerTest + ? createFakeCollection(data.pressedPage.collections) + : undefined; const collections = enhanceCollections({ collections: data.pressedPage.collections, @@ -47,11 +52,13 @@ const enhanceFront = (body: unknown): Front => { (c) => c.displayName === 'News', ) + 1; - const combinedCollections = [ - ...collections.slice(0, personalisedContainerPosition), - personalisedContainer, - ...collections.slice(personalisedContainerPosition), - ]; + const combinedCollections = personalisedContainer + ? [ + ...collections.slice(0, personalisedContainerPosition), + personalisedContainer, + ...collections.slice(personalisedContainerPosition), + ] + : collections; return { ...data,