Skip to content

Commit 2068e35

Browse files
authored
Merge pull request #14282 from guardian/ab/section-tracker-spike
Adds tracking to front sections
2 parents ad3b3c9 + 3d5c9f2 commit 2068e35

File tree

4 files changed

+393
-565
lines changed

4 files changed

+393
-565
lines changed

dotcom-rendering/src/components/FrontSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ export const FrontSection = ({
624624
data-component={ophanComponentName}
625625
data-container-name={containerName}
626626
data-container-level={containerLevel}
627+
data-collection-tracking={true}
627628
css={[
628629
fallbackStyles,
629630
containerStylesUntilLeftCol,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ComponentEvent } from '@guardian/ophan-tracker-js';
2+
import { useEffect, useRef } from 'react';
3+
import { submitComponentEvent } from '../client/ophan/ophan';
4+
5+
const collectionIdentifier = '[data-collection-tracking="true"]';
6+
7+
const getCollectionElements = (): HTMLElement[] => {
8+
return Array.from(document.querySelectorAll(collectionIdentifier));
9+
};
10+
11+
/**
12+
* Calculate the distance from the top of the element to the top of the page.
13+
*/
14+
const calculateDistanceFromTop = (collection: HTMLElement) => {
15+
return (
16+
collection.getBoundingClientRect().top + window.pageYOffset
17+
).toFixed(0);
18+
};
19+
20+
const reportInsertEvent = (elements: HTMLElement[]) => {
21+
for (const [index, element] of elements.entries()) {
22+
const sectionName = element.getAttribute('data-component');
23+
if (sectionName === null) continue;
24+
25+
const ophanComponentEvent: ComponentEvent = {
26+
component: {
27+
componentType: 'CONTAINER',
28+
id: element.id,
29+
/**
30+
* Labels:
31+
* - The name of the collection
32+
* - The number of the collection in the list (the top collection is 1)
33+
* - The distance from the top of the collection to the top of the page in pixels
34+
* - The total height of the page in pixels
35+
*/
36+
labels: [
37+
sectionName,
38+
(index + 1).toString(),
39+
calculateDistanceFromTop(element),
40+
document.body.offsetHeight.toString(),
41+
],
42+
},
43+
action: 'INSERT',
44+
};
45+
46+
void submitComponentEvent(ophanComponentEvent, 'Web');
47+
}
48+
};
49+
50+
const reportViewEvent = (element: HTMLElement) => {
51+
const sectionName = element.getAttribute('data-component');
52+
if (sectionName === null) return;
53+
54+
const ophanComponentEvent: ComponentEvent = {
55+
component: {
56+
componentType: 'CONTAINER',
57+
/**
58+
* Labels:
59+
* - The name of the collection
60+
* - The distance from the top of the collection to the top of the page in pixels
61+
* - The total height of the page in pixels
62+
*/
63+
labels: [
64+
sectionName,
65+
calculateDistanceFromTop(element),
66+
document.body.offsetHeight.toString(),
67+
],
68+
},
69+
action: 'VIEW',
70+
};
71+
72+
void submitComponentEvent(ophanComponentEvent, 'Web');
73+
};
74+
75+
/**
76+
* Report fronts section data to Ophan.
77+
*
78+
* Sends an event with action: 'INSERT' for each collection that is rendered on the page, even if out of the viewport.
79+
* Sends an event with action: 'VIEW' when a collection is viewed by the user, i.e. within the viewport.
80+
*
81+
* Assumptions:
82+
* - The id of the section/collection is also the human-readable name of the collection
83+
*/
84+
export const FrontSectionTracker = () => {
85+
/**
86+
* Use a ref to persist this across renders.
87+
* Rendering is not affected by what collections have been viewed by the user.
88+
*/
89+
const viewedCollectionsRef = useRef<Set<string>>(new Set());
90+
91+
useEffect(() => {
92+
if (!('IntersectionObserver' in window)) return;
93+
94+
const collectionElements: HTMLElement[] = getCollectionElements();
95+
96+
void reportInsertEvent(collectionElements);
97+
98+
const callback = (entries: IntersectionObserverEntry[]) => {
99+
for (const entry of entries) {
100+
if (entry.isIntersecting) {
101+
const collectionElement = entry.target as HTMLElement;
102+
const collectionName = collectionElement.id;
103+
104+
if (!viewedCollectionsRef.current.has(collectionName)) {
105+
viewedCollectionsRef.current.add(collectionName);
106+
reportViewEvent(collectionElement);
107+
observer.unobserve(collectionElement);
108+
}
109+
}
110+
}
111+
};
112+
const options = {
113+
rootMargin: '-100px',
114+
};
115+
const observer = new IntersectionObserver(callback, options);
116+
117+
for (const collection of collectionElements) {
118+
observer.observe(collection);
119+
}
120+
121+
return () => {
122+
observer.disconnect();
123+
};
124+
}, []);
125+
126+
return null;
127+
};

dotcom-rendering/src/layouts/FrontLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
MobileAdSlot,
1919
} from '../components/FrontsAdSlots';
2020
import { FrontSection } from '../components/FrontSection';
21+
import { FrontSectionTracker } from '../components/FrontSectionTracker.importable';
2122
import { HeaderAdSlot } from '../components/HeaderAdSlot';
2223
import { Island } from '../components/Island';
2324
import { LabsHeader } from '../components/LabsHeader';
@@ -746,6 +747,10 @@ export const FrontLayout = ({ front, NAV }: Props) => {
746747
/>
747748
</Island>
748749
</BannerWrapper>
750+
751+
<Island priority="feature" defer={{ until: 'idle' }}>
752+
<FrontSectionTracker />
753+
</Island>
749754
</>
750755
);
751756
};

0 commit comments

Comments
 (0)