From 6a46e1ddee6158642091860428da977fb0a36b29 Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Thu, 4 Dec 2025 13:42:52 +0000 Subject: [PATCH] Remove personalised highlights test --- .../components/Card/components/CardLink.tsx | 7 - .../src/components/DecideContainer.tsx | 2 +- .../components/Masthead/HighlightsCard.tsx | 3 - .../ScrollableHighlights.importable.tsx | 131 +-------- dotcom-rendering/src/experiments/ab-tests.ts | 8 +- .../tests/personalised-highlights.ts | 43 --- .../src/lib/personaliseHighlights.test.ts | 251 ------------------ .../src/lib/personaliseHighlights.ts | 174 ------------ 8 files changed, 4 insertions(+), 615 deletions(-) delete mode 100644 dotcom-rendering/src/experiments/tests/personalised-highlights.ts delete mode 100644 dotcom-rendering/src/lib/personaliseHighlights.test.ts delete mode 100644 dotcom-rendering/src/lib/personaliseHighlights.ts diff --git a/dotcom-rendering/src/components/Card/components/CardLink.tsx b/dotcom-rendering/src/components/Card/components/CardLink.tsx index f3c64c323ed..a5954193db9 100644 --- a/dotcom-rendering/src/components/Card/components/CardLink.tsx +++ b/dotcom-rendering/src/components/Card/components/CardLink.tsx @@ -21,19 +21,16 @@ type Props = { headlineText: string; dataLinkName?: string; isExternalLink: boolean; - trackCardClick?: () => void; }; 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 @@ -42,7 +39,6 @@ const InternalLink = ({ css={fauxLinkStyles} data-link-name={dataLinkName} aria-label={headlineText} - onClick={trackCardClick} /> ); }; @@ -77,7 +73,6 @@ export const CardLink = ({ 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, - trackCardClick, }: Props) => { return ( <> @@ -86,7 +81,6 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} - trackCardClick={trackCardClick} /> )} {!isExternalLink && ( @@ -94,7 +88,6 @@ export const CardLink = ({ linkTo={linkTo} headlineText={headlineText} dataLinkName={dataLinkName} - trackCardClick={trackCardClick} /> )} diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index ba558aa0b93..26b2ae8c07f 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -235,7 +235,7 @@ export const DecideContainer = ({ return ; case 'scrollable/highlights': return ( - + ); diff --git a/dotcom-rendering/src/components/Masthead/HighlightsCard.tsx b/dotcom-rendering/src/components/Masthead/HighlightsCard.tsx index e77d30a493c..0467776edf1 100644 --- a/dotcom-rendering/src/components/Masthead/HighlightsCard.tsx +++ b/dotcom-rendering/src/components/Masthead/HighlightsCard.tsx @@ -32,7 +32,6 @@ export type HighlightsCardProps = { byline?: string; isExternalLink: boolean; starRating?: Rating; - trackCardClick: () => void; }; const container = css` @@ -134,7 +133,6 @@ export const HighlightsCard = ({ byline, isExternalLink, starRating, - trackCardClick, }: HighlightsCardProps) => { const isMediaCard = isMedia(format); @@ -146,7 +144,6 @@ export const HighlightsCard = ({ headlineText={headlineText} dataLinkName={dataLinkName} isExternalLink={isExternalLink} - trackCardClick={trackCardClick} />
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 (
  1. { showQuotedHeadline={trail.showQuotedHeadline} mainMedia={trail.mainMedia} starRating={trail.starRating} - trackCardClick={() => { - if ( - abTestPersonalisedHighlightAttr === - 'click-tracking' || - abTestPersonalisedHighlightAttr === - 'click-and-view-tracking' - ) { - onHighlightEvent('CLICK', trail); - } - }} />
  2. ); 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); -};