Skip to content

Commit 475f174

Browse files
committed
Add competition analytics tracking
Load configured Umami analytics, attach competition page events, and track active page time. Include environment declarations and focused coverage for analytics helpers and activity timers.
1 parent 2830e96 commit 475f174

13 files changed

Lines changed: 500 additions & 8 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
VITE_APP_ENV=
2+
VITE_GA_MEASUREMENT_ID=
3+
VITE_UMAMI_SRC=
4+
VITE_UMAMI_WEBSITE_ID=
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useCompetitionAnalytics';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useEffect, useMemo, useRef } from 'react';
2+
import { useLocation } from 'react-router-dom';
3+
import { trackCompetitionEvent } from '@/lib/analytics';
4+
import { useAuth } from '@/providers/AuthProvider';
5+
import { usePageActivityTracking } from '../usePageActivityTracking';
6+
7+
const competitionPageName = (pathname: string, competitionId: string) => {
8+
const competitionRoot = `/competitions/${competitionId}`;
9+
const relativePath = pathname.replace(competitionRoot, '') || '/';
10+
11+
if (relativePath === '/') {
12+
return 'groups';
13+
}
14+
15+
if (relativePath.startsWith('/activities') || relativePath.startsWith('/rooms')) {
16+
return 'schedule';
17+
}
18+
19+
if (relativePath === '/live' || relativePath === '/admin/remote') {
20+
return 'live_activities';
21+
}
22+
23+
if (relativePath === '/admin/scramblers') {
24+
return 'assignments';
25+
}
26+
27+
return relativePath.replace(/^\//, '').replace(/\//g, '_') || 'competition';
28+
};
29+
30+
const pageViewEventName = (page: string) => {
31+
if (page === 'groups') {
32+
return 'groups_viewed';
33+
}
34+
35+
if (page === 'schedule') {
36+
return 'schedule_viewed';
37+
}
38+
39+
if (page === 'assignments') {
40+
return 'assignments_viewed';
41+
}
42+
43+
if (page === 'live_activities') {
44+
return 'live_activities_opened';
45+
}
46+
47+
return undefined;
48+
};
49+
50+
export const useCompetitionAnalytics = (competitionId?: string) => {
51+
const location = useLocation();
52+
const { user } = useAuth();
53+
const lastCompetitionId = useRef<string>();
54+
const lastPageEventKey = useRef<string>();
55+
56+
const page = useMemo(
57+
() => (competitionId ? competitionPageName(location.pathname, competitionId) : 'competition'),
58+
[competitionId, location.pathname],
59+
);
60+
61+
useEffect(() => {
62+
if (!competitionId) {
63+
return;
64+
}
65+
66+
if (lastCompetitionId.current !== competitionId) {
67+
trackCompetitionEvent('competition_viewed', {
68+
competition_id: competitionId,
69+
page,
70+
user_id: user?.id,
71+
});
72+
lastCompetitionId.current = competitionId;
73+
}
74+
}, [competitionId, page, user?.id]);
75+
76+
useEffect(() => {
77+
if (!competitionId) {
78+
return;
79+
}
80+
81+
const eventName = pageViewEventName(page);
82+
if (!eventName) {
83+
return;
84+
}
85+
86+
const eventKey = `${competitionId}:${location.pathname}:${eventName}`;
87+
if (lastPageEventKey.current === eventKey) {
88+
return;
89+
}
90+
91+
trackCompetitionEvent(eventName, {
92+
competition_id: competitionId,
93+
page,
94+
feature: page === 'live_activities' ? 'live_activities' : undefined,
95+
user_id: user?.id,
96+
});
97+
lastPageEventKey.current = eventKey;
98+
}, [competitionId, location.pathname, page, user?.id]);
99+
100+
usePageActivityTracking({
101+
competitionId,
102+
page,
103+
userId: user?.id,
104+
});
105+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './usePageActivityTracking';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { trackCompetitionEvent } from '@/lib/analytics';
3+
import { usePageActivityTracking } from './usePageActivityTracking';
4+
5+
jest.mock('@/lib/analytics', () => ({
6+
trackCompetitionEvent: jest.fn(),
7+
}));
8+
9+
const setVisibilityState = (visibilityState: DocumentVisibilityState) => {
10+
Object.defineProperty(document, 'visibilityState', {
11+
configurable: true,
12+
value: visibilityState,
13+
});
14+
};
15+
16+
describe('usePageActivityTracking', () => {
17+
beforeEach(() => {
18+
jest.useFakeTimers();
19+
setVisibilityState('visible');
20+
jest.mocked(trackCompetitionEvent).mockClear();
21+
});
22+
23+
afterEach(() => {
24+
jest.useRealTimers();
25+
});
26+
27+
it('tracks active visible time every 60 seconds', () => {
28+
renderHook(() =>
29+
usePageActivityTracking({
30+
competitionId: 'ExampleComp2026',
31+
page: 'groups',
32+
userId: 123,
33+
}),
34+
);
35+
36+
jest.advanceTimersByTime(59_000);
37+
expect(trackCompetitionEvent).not.toHaveBeenCalled();
38+
39+
jest.advanceTimersByTime(1_000);
40+
expect(trackCompetitionEvent).toHaveBeenCalledWith('page_active_60s', {
41+
competition_id: 'ExampleComp2026',
42+
page: 'groups',
43+
user_id: 123,
44+
});
45+
});
46+
47+
it('does not track while hidden', () => {
48+
setVisibilityState('hidden');
49+
50+
renderHook(() =>
51+
usePageActivityTracking({
52+
competitionId: 'ExampleComp2026',
53+
page: 'groups',
54+
}),
55+
);
56+
57+
jest.advanceTimersByTime(60_000);
58+
59+
expect(trackCompetitionEvent).not.toHaveBeenCalled();
60+
});
61+
62+
it('clears the timer on unmount', () => {
63+
const { unmount } = renderHook(() =>
64+
usePageActivityTracking({
65+
competitionId: 'ExampleComp2026',
66+
page: 'groups',
67+
}),
68+
);
69+
70+
unmount();
71+
jest.advanceTimersByTime(60_000);
72+
73+
expect(trackCompetitionEvent).not.toHaveBeenCalled();
74+
});
75+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useEffect } from 'react';
2+
import { trackCompetitionEvent } from '@/lib/analytics';
3+
4+
const ACTIVE_INTERVAL_MS = 60_000;
5+
6+
interface UsePageActivityTrackingParams {
7+
competitionId?: string;
8+
page: string;
9+
userId?: number;
10+
}
11+
12+
export const usePageActivityTracking = ({
13+
competitionId,
14+
page,
15+
userId,
16+
}: UsePageActivityTrackingParams) => {
17+
useEffect(() => {
18+
if (!competitionId || typeof document === 'undefined') {
19+
return undefined;
20+
}
21+
22+
const trackActivePage = () => {
23+
if (document.visibilityState !== 'visible') {
24+
return;
25+
}
26+
27+
trackCompetitionEvent('page_active_60s', {
28+
competition_id: competitionId,
29+
page,
30+
user_id: userId,
31+
});
32+
};
33+
34+
const intervalId = window.setInterval(trackActivePage, ACTIVE_INTERVAL_MS);
35+
36+
return () => {
37+
window.clearInterval(intervalId);
38+
};
39+
}, [competitionId, page, userId]);
40+
};

src/hooks/usePageTracking/usePageTracking.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
import { useEffect } from 'react';
22
import ReactGA from 'react-ga4';
33
import { useLocation } from 'react-router-dom';
4+
import { identifyUser, loadUmamiScript } from '@/lib/analytics';
45
import { useAuth } from '@/providers/AuthProvider';
56

6-
export const usePageTracking = (trackingCode) => {
7+
export const usePageTracking = (trackingCode?: string) => {
78
const location = useLocation();
89
const { user } = useAuth();
910

1011
useEffect(() => {
11-
if (ReactGA.isInitialized) {
12+
loadUmamiScript({
13+
src: import.meta.env.VITE_UMAMI_SRC,
14+
websiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID,
15+
});
16+
}, []);
17+
18+
useEffect(() => {
19+
identifyUser(user?.id);
20+
}, [user?.id]);
21+
22+
useEffect(() => {
23+
if (!trackingCode || ReactGA.isInitialized) {
1224
return;
1325
}
1426

@@ -39,10 +51,10 @@ export const usePageTracking = (trackingCode) => {
3951
delegate_status: null,
4052
});
4153
}
42-
} else if (!ReactGA.isInitialized) {
54+
} else if (trackingCode && !ReactGA.isInitialized) {
4355
console.log('Would have set userId to', user?.id);
4456
}
45-
}, [user]);
57+
}, [trackingCode, user]);
4658

4759
useEffect(() => {
4860
if (ReactGA.isInitialized) {
@@ -51,8 +63,8 @@ export const usePageTracking = (trackingCode) => {
5163
page: location.pathname + location.search,
5264
title: document.title,
5365
});
54-
} else {
66+
} else if (trackingCode) {
5567
console.log('Would have logged pageview for', location);
5668
}
57-
}, [location]);
69+
}, [location, trackingCode]);
5870
};

src/layouts/CompetitionLayout/CompetitionLayout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ErrorFallback, LastFetchedAt, NoteBox, NotifyCompRemoteBar } from '@/co
77
import { Container } from '@/components/Container';
88
import { StyledNavLink } from '@/components/StyledNavLink/StyledNavLink';
99
import { useWcif } from '@/hooks/queries/useWcif';
10+
import { useCompetitionAnalytics } from '@/hooks/useCompetitionAnalytics';
1011
import { useApp } from '@/providers/AppProvider';
1112
import { WCIFProvider } from '@/providers/WCIFProvider';
1213
import { useCompetitionLayoutTabs } from './CompetitionLayout.tabs';
@@ -20,6 +21,8 @@ export function CompetitionLayout() {
2021

2122
const { data: wcif, dataUpdatedAt, isFetching } = useWcif(competitionId!);
2223

24+
useCompetitionAnalytics(competitionId);
25+
2326
const { tabs } = useCompetitionLayoutTabs({
2427
competitionId: competitionId!,
2528
wcif: wcif,

src/lib/analytics.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
identifyUser,
3+
isValidEventName,
4+
loadUmamiScript,
5+
trackCompetitionEvent,
6+
trackEvent,
7+
} from './analytics';
8+
9+
describe('analytics', () => {
10+
beforeEach(() => {
11+
document.head.innerHTML = '';
12+
window.umami = undefined;
13+
});
14+
15+
it('loads the Umami script only when configured', () => {
16+
loadUmamiScript();
17+
18+
expect(document.querySelector('script')).toBeNull();
19+
20+
loadUmamiScript({
21+
src: 'https://analytics.example.com/script.js',
22+
websiteId: 'website-id',
23+
});
24+
loadUmamiScript({
25+
src: 'https://analytics.example.com/script.js',
26+
websiteId: 'website-id',
27+
});
28+
29+
const scripts = document.querySelectorAll('script');
30+
expect(scripts).toHaveLength(1);
31+
expect(scripts[0]).toHaveAttribute('src', 'https://analytics.example.com/script.js');
32+
expect(scripts[0]).toHaveAttribute('data-website-id', 'website-id');
33+
});
34+
35+
it('no-ops when Umami is not available', () => {
36+
expect(() => trackEvent('competition_viewed')).not.toThrow();
37+
});
38+
39+
it('tracks event data with shared app and auth properties', () => {
40+
const track = jest.fn();
41+
window.umami = { track };
42+
43+
trackCompetitionEvent('competition_viewed', {
44+
competitionId: 'ExampleComp2026',
45+
page: 'groups',
46+
user_id: 123,
47+
});
48+
49+
expect(track).toHaveBeenCalledWith(
50+
'competition_viewed',
51+
expect.objectContaining({
52+
app: 'competitiongroups',
53+
auth_status: 'logged_in',
54+
competition_id: 'ExampleComp2026',
55+
page: 'groups',
56+
user_id: '123',
57+
}),
58+
);
59+
});
60+
61+
it('does not send invalid event names', () => {
62+
const track = jest.fn();
63+
window.umami = { track };
64+
65+
expect(isValidEventName('x'.repeat(51))).toBe(false);
66+
trackEvent('x'.repeat(51));
67+
68+
expect(track).not.toHaveBeenCalled();
69+
});
70+
71+
it('identifies logged-in users by numeric ID only', () => {
72+
const identify = jest.fn();
73+
window.umami = {
74+
identify,
75+
track: jest.fn(),
76+
};
77+
78+
identifyUser(123);
79+
80+
expect(identify).toHaveBeenCalledWith(
81+
'123',
82+
expect.objectContaining({
83+
app: 'competitiongroups',
84+
auth_status: 'logged_in',
85+
}),
86+
);
87+
});
88+
});

0 commit comments

Comments
 (0)