Skip to content

Commit e8341d6

Browse files
committed
Fix queued Umami competition events
1 parent 475f174 commit e8341d6

3 files changed

Lines changed: 154 additions & 20 deletions

File tree

src/hooks/usePageTracking/usePageTracking.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +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';
4+
import { configureUmamiAnalytics, identifyUser, loadUmamiScript } from '@/lib/analytics';
55
import { useAuth } from '@/providers/AuthProvider';
66

77
export const usePageTracking = (trackingCode?: string) => {
88
const location = useLocation();
99
const { user } = useAuth();
10+
const umamiSrc = import.meta.env.VITE_UMAMI_SRC;
11+
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
12+
13+
configureUmamiAnalytics({
14+
src: umamiSrc,
15+
websiteId: umamiWebsiteId,
16+
});
1017

1118
useEffect(() => {
1219
loadUmamiScript({
13-
src: import.meta.env.VITE_UMAMI_SRC,
14-
websiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID,
20+
src: umamiSrc,
21+
websiteId: umamiWebsiteId,
1522
});
16-
}, []);
23+
}, [umamiSrc, umamiWebsiteId]);
1724

1825
useEffect(() => {
1926
identifyUser(user?.id);

src/lib/analytics.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
__resetAnalyticsForTests,
3+
configureUmamiAnalytics,
24
identifyUser,
35
isValidEventName,
46
loadUmamiScript,
@@ -8,6 +10,7 @@ import {
810

911
describe('analytics', () => {
1012
beforeEach(() => {
13+
__resetAnalyticsForTests();
1114
document.head.innerHTML = '';
1215
window.umami = undefined;
1316
});
@@ -36,6 +39,37 @@ describe('analytics', () => {
3639
expect(() => trackEvent('competition_viewed')).not.toThrow();
3740
});
3841

42+
it('flushes route events that fire before the Umami script loads', () => {
43+
configureUmamiAnalytics({
44+
src: 'https://analytics.example.com/script.js',
45+
websiteId: 'website-id',
46+
});
47+
48+
trackCompetitionEvent('competition_viewed', {
49+
competitionId: 'ExampleComp2026',
50+
page: 'groups',
51+
});
52+
53+
loadUmamiScript({
54+
src: 'https://analytics.example.com/script.js',
55+
websiteId: 'website-id',
56+
});
57+
58+
const track = jest.fn();
59+
window.umami = { track };
60+
document.querySelector('script')?.dispatchEvent(new Event('load'));
61+
62+
expect(track).toHaveBeenCalledWith(
63+
'competition_viewed',
64+
expect.objectContaining({
65+
app: 'competitiongroups',
66+
auth_status: 'anonymous',
67+
competition_id: 'ExampleComp2026',
68+
page: 'groups',
69+
}),
70+
);
71+
});
72+
3973
it('tracks event data with shared app and auth properties', () => {
4074
const track = jest.fn();
4175
window.umami = { track };
@@ -85,4 +119,28 @@ describe('analytics', () => {
85119
}),
86120
);
87121
});
122+
123+
it('identifies logged-in users after the Umami script loads', () => {
124+
loadUmamiScript({
125+
src: 'https://analytics.example.com/script.js',
126+
websiteId: 'website-id',
127+
});
128+
129+
identifyUser(123);
130+
131+
const identify = jest.fn();
132+
window.umami = {
133+
identify,
134+
track: jest.fn(),
135+
};
136+
document.querySelector('script')?.dispatchEvent(new Event('load'));
137+
138+
expect(identify).toHaveBeenCalledWith(
139+
'123',
140+
expect.objectContaining({
141+
app: 'competitiongroups',
142+
auth_status: 'logged_in',
143+
}),
144+
);
145+
});
88146
});

src/lib/analytics.ts

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ type UmamiTrack = {
88
((data: AnalyticsProperties) => void);
99
};
1010

11+
type PendingEvent = {
12+
eventName: string;
13+
properties?: AnalyticsProperties;
14+
};
15+
1116
const APP_NAME = 'competitiongroups';
1217
const MAX_EVENT_NAME_LENGTH = 50;
18+
const MAX_PENDING_EVENTS = 50;
1319
const UMAMI_SCRIPT_ID = 'umami-analytics-script';
1420

1521
let currentUserId: string | undefined;
22+
let pendingEvents: PendingEvent[] = [];
23+
let umamiConfigured = false;
1624

1725
const getEnvironment = () => (typeof __APP_ENV__ === 'undefined' ? 'test' : __APP_ENV__);
1826

@@ -67,14 +75,80 @@ const eventProperties = (properties: AnalyticsProperties = {}): AnalyticsPropert
6775
export const isValidEventName = (eventName: string) =>
6876
eventName.length > 0 && eventName.length <= MAX_EVENT_NAME_LENGTH;
6977

78+
export const configureUmamiAnalytics = ({
79+
src,
80+
websiteId,
81+
}: {
82+
src?: string;
83+
websiteId?: string;
84+
} = {}) => {
85+
umamiConfigured = Boolean(src && websiteId);
86+
};
87+
88+
const sendEvent = (eventName: string, properties?: AnalyticsProperties) => {
89+
const umami = getUmami();
90+
if (!umami?.track) {
91+
return false;
92+
}
93+
94+
umami.track(eventName, eventProperties(properties));
95+
return true;
96+
};
97+
98+
const queueEvent = (eventName: string, properties?: AnalyticsProperties) => {
99+
if (!umamiConfigured) {
100+
return;
101+
}
102+
103+
pendingEvents = [...pendingEvents.slice(-(MAX_PENDING_EVENTS - 1)), { eventName, properties }];
104+
};
105+
106+
const identifyCurrentUser = () => {
107+
const umami = getUmami();
108+
if (!umami?.identify || !currentUserId) {
109+
return false;
110+
}
111+
112+
umami.identify(currentUserId, {
113+
...baseProperties(),
114+
auth_status: 'logged_in',
115+
});
116+
return true;
117+
};
118+
119+
const flushPendingEvents = () => {
120+
if (!pendingEvents.length) {
121+
identifyCurrentUser();
122+
return;
123+
}
124+
125+
const events = pendingEvents;
126+
pendingEvents = [];
127+
128+
events.forEach(({ eventName, properties }) => {
129+
if (!sendEvent(eventName, properties)) {
130+
queueEvent(eventName, properties);
131+
}
132+
});
133+
134+
identifyCurrentUser();
135+
};
136+
70137
export const loadUmamiScript = ({
71138
src,
72139
websiteId,
73140
}: {
74141
src?: string;
75142
websiteId?: string;
76143
} = {}) => {
77-
if (!isBrowser() || !src || !websiteId || document.getElementById(UMAMI_SCRIPT_ID)) {
144+
if (!isBrowser() || !src || !websiteId) {
145+
return;
146+
}
147+
148+
configureUmamiAnalytics({ src, websiteId });
149+
150+
if (document.getElementById(UMAMI_SCRIPT_ID)) {
151+
flushPendingEvents();
78152
return;
79153
}
80154

@@ -83,21 +157,13 @@ export const loadUmamiScript = ({
83157
script.defer = true;
84158
script.src = src;
85159
script.dataset.websiteId = websiteId;
160+
script.addEventListener('load', flushPendingEvents);
86161
document.head.appendChild(script);
87162
};
88163

89164
export const identifyUser = (userId?: number | string | null) => {
90165
currentUserId = userId ? String(userId) : undefined;
91-
92-
const umami = getUmami();
93-
if (!umami?.identify || !currentUserId) {
94-
return;
95-
}
96-
97-
umami.identify(currentUserId, {
98-
...baseProperties(),
99-
auth_status: 'logged_in',
100-
});
166+
identifyCurrentUser();
101167
};
102168

103169
export const trackEvent = (eventName: string, properties?: AnalyticsProperties) => {
@@ -108,12 +174,9 @@ export const trackEvent = (eventName: string, properties?: AnalyticsProperties)
108174
return;
109175
}
110176

111-
const umami = getUmami();
112-
if (!umami?.track) {
113-
return;
177+
if (!sendEvent(eventName, properties)) {
178+
queueEvent(eventName, properties);
114179
}
115-
116-
umami.track(eventName, eventProperties(properties));
117180
};
118181

119182
export const trackCompetitionEvent = (
@@ -133,3 +196,9 @@ declare global {
133196
umami?: UmamiTrack;
134197
}
135198
}
199+
200+
export const __resetAnalyticsForTests = () => {
201+
currentUserId = undefined;
202+
pendingEvents = [];
203+
umamiConfigured = false;
204+
};

0 commit comments

Comments
 (0)