diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3264164
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+VITE_APP_ENV=
+VITE_GA_MEASUREMENT_ID=
+VITE_UMAMI_SRC=
+VITE_UMAMI_WEBSITE_ID=
diff --git a/src/hooks/useCompetitionAnalytics/index.ts b/src/hooks/useCompetitionAnalytics/index.ts
new file mode 100644
index 0000000..95984a9
--- /dev/null
+++ b/src/hooks/useCompetitionAnalytics/index.ts
@@ -0,0 +1 @@
+export * from './useCompetitionAnalytics';
diff --git a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx
new file mode 100644
index 0000000..a1047e2
--- /dev/null
+++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx
@@ -0,0 +1,78 @@
+import { renderHook } from '@testing-library/react';
+import { PropsWithChildren } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { trackCompetitionEvent } from '@/lib/analytics';
+import { useAuth } from '@/providers/AuthProvider';
+import { useCompetitionAnalytics } from './useCompetitionAnalytics';
+
+jest.mock('@/lib/analytics', () => ({
+ trackCompetitionEvent: jest.fn(),
+}));
+
+jest.mock('@/providers/AuthProvider', () => ({
+ useAuth: jest.fn(),
+}));
+
+jest.mock('../usePageActivityTracking', () => ({
+ usePageActivityTracking: jest.fn(),
+}));
+
+const wrapper = (initialEntry: string) => {
+ function TestWrapper({ children }: PropsWithChildren) {
+ return {children};
+ }
+
+ return TestWrapper;
+};
+
+describe('useCompetitionAnalytics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useAuth).mockReturnValue({
+ user: null,
+ } as unknown as ReturnType);
+ });
+
+ it('tracks competition views with stable labels for every competition route', () => {
+ renderHook(() => useCompetitionAnalytics('ExampleComp2026'), {
+ wrapper: wrapper('/competitions/ExampleComp2026/events/333-r1/2'),
+ });
+
+ expect(trackCompetitionEvent).toHaveBeenCalledWith('competition_viewed', {
+ competition_id: 'ExampleComp2026',
+ page: 'event_group',
+ user_id: undefined,
+ });
+ });
+
+ it('keeps feature-specific events for known competition pages', () => {
+ renderHook(() => useCompetitionAnalytics('ExampleComp2026'), {
+ wrapper: wrapper('/competitions/ExampleComp2026/admin/remote'),
+ });
+
+ expect(trackCompetitionEvent).toHaveBeenCalledWith('live_activities_opened', {
+ competition_id: 'ExampleComp2026',
+ page: 'live_activities',
+ feature: 'live_activities',
+ user_id: undefined,
+ });
+ });
+
+ it.each([
+ ['/competitions/ExampleComp2026/activities', 'schedule'],
+ ['/competitions/ExampleComp2026/activities/1', 'schedule_activity'],
+ ['/competitions/ExampleComp2026/rooms', 'schedule_rooms'],
+ ['/competitions/ExampleComp2026/rooms/main', 'schedule_room'],
+ ])('tracks schedule views for %s', (initialEntry, page) => {
+ renderHook(() => useCompetitionAnalytics('ExampleComp2026'), {
+ wrapper: wrapper(initialEntry),
+ });
+
+ expect(trackCompetitionEvent).toHaveBeenCalledWith('schedule_viewed', {
+ competition_id: 'ExampleComp2026',
+ page,
+ feature: undefined,
+ user_id: undefined,
+ });
+ });
+});
diff --git a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts
new file mode 100644
index 0000000..645b286
--- /dev/null
+++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts
@@ -0,0 +1,88 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
+import { trackCompetitionEvent } from '@/lib/analytics';
+import { competitionPageName } from '@/lib/analyticsPages';
+import { useAuth } from '@/providers/AuthProvider';
+import { usePageActivityTracking } from '../usePageActivityTracking';
+
+const featureViewEventName = (page: string) => {
+ if (page === 'groups') {
+ return 'groups_viewed';
+ }
+
+ if (
+ page === 'schedule' ||
+ page === 'schedule_activity' ||
+ page === 'schedule_rooms' ||
+ page === 'schedule_room'
+ ) {
+ return 'schedule_viewed';
+ }
+
+ if (page === 'assignments') {
+ return 'assignments_viewed';
+ }
+
+ if (page === 'live_activities') {
+ return 'live_activities_opened';
+ }
+
+ return undefined;
+};
+
+export const useCompetitionAnalytics = (competitionId?: string) => {
+ const location = useLocation();
+ const { user } = useAuth();
+ const lastCompetitionId = useRef();
+ const lastPageEventKey = useRef();
+
+ const page = useMemo(
+ () => (competitionId ? competitionPageName(location.pathname, competitionId) : 'competition'),
+ [competitionId, location.pathname],
+ );
+
+ useEffect(() => {
+ if (!competitionId) {
+ return;
+ }
+
+ if (lastCompetitionId.current !== competitionId) {
+ trackCompetitionEvent('competition_viewed', {
+ competition_id: competitionId,
+ page,
+ user_id: user?.id,
+ });
+ lastCompetitionId.current = competitionId;
+ }
+ }, [competitionId, page, user?.id]);
+
+ useEffect(() => {
+ if (!competitionId) {
+ return;
+ }
+
+ const eventName = featureViewEventName(page);
+ if (!eventName) {
+ return;
+ }
+
+ const eventKey = `${competitionId}:${location.pathname}${location.search}:${eventName}`;
+ if (lastPageEventKey.current === eventKey) {
+ return;
+ }
+
+ trackCompetitionEvent(eventName, {
+ competition_id: competitionId,
+ page,
+ feature: page === 'live_activities' ? 'live_activities' : undefined,
+ user_id: user?.id,
+ });
+ lastPageEventKey.current = eventKey;
+ }, [competitionId, location.pathname, location.search, page, user?.id]);
+
+ usePageActivityTracking({
+ competitionId,
+ page,
+ userId: user?.id,
+ });
+};
diff --git a/src/hooks/usePageActivityTracking/index.ts b/src/hooks/usePageActivityTracking/index.ts
new file mode 100644
index 0000000..575befa
--- /dev/null
+++ b/src/hooks/usePageActivityTracking/index.ts
@@ -0,0 +1 @@
+export * from './usePageActivityTracking';
diff --git a/src/hooks/usePageActivityTracking/usePageActivityTracking.test.ts b/src/hooks/usePageActivityTracking/usePageActivityTracking.test.ts
new file mode 100644
index 0000000..483314c
--- /dev/null
+++ b/src/hooks/usePageActivityTracking/usePageActivityTracking.test.ts
@@ -0,0 +1,75 @@
+import { renderHook } from '@testing-library/react';
+import { trackCompetitionEvent } from '@/lib/analytics';
+import { usePageActivityTracking } from './usePageActivityTracking';
+
+jest.mock('@/lib/analytics', () => ({
+ trackCompetitionEvent: jest.fn(),
+}));
+
+const setVisibilityState = (visibilityState: DocumentVisibilityState) => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: visibilityState,
+ });
+};
+
+describe('usePageActivityTracking', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ setVisibilityState('visible');
+ jest.mocked(trackCompetitionEvent).mockClear();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('tracks active visible time every 60 seconds', () => {
+ renderHook(() =>
+ usePageActivityTracking({
+ competitionId: 'ExampleComp2026',
+ page: 'groups',
+ userId: 123,
+ }),
+ );
+
+ jest.advanceTimersByTime(59_000);
+ expect(trackCompetitionEvent).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1_000);
+ expect(trackCompetitionEvent).toHaveBeenCalledWith('page_active_60s', {
+ competition_id: 'ExampleComp2026',
+ page: 'groups',
+ user_id: 123,
+ });
+ });
+
+ it('does not track while hidden', () => {
+ setVisibilityState('hidden');
+
+ renderHook(() =>
+ usePageActivityTracking({
+ competitionId: 'ExampleComp2026',
+ page: 'groups',
+ }),
+ );
+
+ jest.advanceTimersByTime(60_000);
+
+ expect(trackCompetitionEvent).not.toHaveBeenCalled();
+ });
+
+ it('clears the timer on unmount', () => {
+ const { unmount } = renderHook(() =>
+ usePageActivityTracking({
+ competitionId: 'ExampleComp2026',
+ page: 'groups',
+ }),
+ );
+
+ unmount();
+ jest.advanceTimersByTime(60_000);
+
+ expect(trackCompetitionEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/usePageActivityTracking/usePageActivityTracking.ts b/src/hooks/usePageActivityTracking/usePageActivityTracking.ts
new file mode 100644
index 0000000..e2a8e01
--- /dev/null
+++ b/src/hooks/usePageActivityTracking/usePageActivityTracking.ts
@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import { trackCompetitionEvent } from '@/lib/analytics';
+
+const ACTIVE_INTERVAL_MS = 60_000;
+
+interface UsePageActivityTrackingParams {
+ competitionId?: string;
+ page: string;
+ userId?: number;
+}
+
+export const usePageActivityTracking = ({
+ competitionId,
+ page,
+ userId,
+}: UsePageActivityTrackingParams) => {
+ useEffect(() => {
+ if (!competitionId || typeof document === 'undefined') {
+ return undefined;
+ }
+
+ const trackActivePage = () => {
+ if (document.visibilityState !== 'visible') {
+ return;
+ }
+
+ trackCompetitionEvent('page_active_60s', {
+ competition_id: competitionId,
+ page,
+ user_id: userId,
+ });
+ };
+
+ const intervalId = window.setInterval(trackActivePage, ACTIVE_INTERVAL_MS);
+
+ return () => {
+ window.clearInterval(intervalId);
+ };
+ }, [competitionId, page, userId]);
+};
diff --git a/src/hooks/usePageTracking/usePageTracking.ts b/src/hooks/usePageTracking/usePageTracking.ts
index af88340..b3d4973 100644
--- a/src/hooks/usePageTracking/usePageTracking.ts
+++ b/src/hooks/usePageTracking/usePageTracking.ts
@@ -1,14 +1,33 @@
import { useEffect } from 'react';
import ReactGA from 'react-ga4';
import { useLocation } from 'react-router-dom';
+import { configureUmamiAnalytics, identifyUser, loadUmamiScript } from '@/lib/analytics';
import { useAuth } from '@/providers/AuthProvider';
-export const usePageTracking = (trackingCode) => {
+export const usePageTracking = (trackingCode?: string) => {
const location = useLocation();
const { user } = useAuth();
+ const umamiSrc = import.meta.env.VITE_UMAMI_SRC;
+ const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
+
+ configureUmamiAnalytics({
+ src: umamiSrc,
+ websiteId: umamiWebsiteId,
+ });
useEffect(() => {
- if (ReactGA.isInitialized) {
+ loadUmamiScript({
+ src: umamiSrc,
+ websiteId: umamiWebsiteId,
+ });
+ }, [umamiSrc, umamiWebsiteId]);
+
+ useEffect(() => {
+ identifyUser(user?.id);
+ }, [user?.id]);
+
+ useEffect(() => {
+ if (!trackingCode || ReactGA.isInitialized) {
return;
}
@@ -39,10 +58,10 @@ export const usePageTracking = (trackingCode) => {
delegate_status: null,
});
}
- } else if (!ReactGA.isInitialized) {
+ } else if (trackingCode && !ReactGA.isInitialized) {
console.log('Would have set userId to', user?.id);
}
- }, [user]);
+ }, [trackingCode, user]);
useEffect(() => {
if (ReactGA.isInitialized) {
@@ -51,8 +70,8 @@ export const usePageTracking = (trackingCode) => {
page: location.pathname + location.search,
title: document.title,
});
- } else {
+ } else if (trackingCode) {
console.log('Would have logged pageview for', location);
}
- }, [location]);
+ }, [location, trackingCode]);
};
diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tsx
index a8f40b1..1a697b4 100644
--- a/src/layouts/CompetitionLayout/CompetitionLayout.tsx
+++ b/src/layouts/CompetitionLayout/CompetitionLayout.tsx
@@ -7,6 +7,7 @@ import { ErrorFallback, LastFetchedAt, NoteBox, NotifyCompRemoteBar } from '@/co
import { Container } from '@/components/Container';
import { StyledNavLink } from '@/components/StyledNavLink/StyledNavLink';
import { useWcif } from '@/hooks/queries/useWcif';
+import { useCompetitionAnalytics } from '@/hooks/useCompetitionAnalytics';
import { useApp } from '@/providers/AppProvider';
import { WCIFProvider } from '@/providers/WCIFProvider';
import { useCompetitionLayoutTabs } from './CompetitionLayout.tabs';
@@ -20,6 +21,8 @@ export function CompetitionLayout() {
const { data: wcif, dataUpdatedAt, isFetching } = useWcif(competitionId!);
+ useCompetitionAnalytics(competitionId);
+
const { tabs } = useCompetitionLayoutTabs({
competitionId: competitionId!,
wcif: wcif,
diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts
new file mode 100644
index 0000000..e70d98f
--- /dev/null
+++ b/src/lib/analytics.test.ts
@@ -0,0 +1,146 @@
+import {
+ __resetAnalyticsForTests,
+ configureUmamiAnalytics,
+ identifyUser,
+ isValidEventName,
+ loadUmamiScript,
+ trackCompetitionEvent,
+ trackEvent,
+} from './analytics';
+
+describe('analytics', () => {
+ beforeEach(() => {
+ __resetAnalyticsForTests();
+ document.head.innerHTML = '';
+ window.umami = undefined;
+ });
+
+ it('loads the Umami script only when configured', () => {
+ loadUmamiScript();
+
+ expect(document.querySelector('script')).toBeNull();
+
+ loadUmamiScript({
+ src: 'https://analytics.example.com/script.js',
+ websiteId: 'website-id',
+ });
+ loadUmamiScript({
+ src: 'https://analytics.example.com/script.js',
+ websiteId: 'website-id',
+ });
+
+ const scripts = document.querySelectorAll('script');
+ expect(scripts).toHaveLength(1);
+ expect(scripts[0]).toHaveAttribute('src', 'https://analytics.example.com/script.js');
+ expect(scripts[0]).toHaveAttribute('data-website-id', 'website-id');
+ });
+
+ it('no-ops when Umami is not available', () => {
+ expect(() => trackEvent('competition_viewed')).not.toThrow();
+ });
+
+ it('flushes route events that fire before the Umami script loads', () => {
+ configureUmamiAnalytics({
+ src: 'https://analytics.example.com/script.js',
+ websiteId: 'website-id',
+ });
+
+ trackCompetitionEvent('competition_viewed', {
+ competitionId: 'ExampleComp2026',
+ page: 'groups',
+ });
+
+ loadUmamiScript({
+ src: 'https://analytics.example.com/script.js',
+ websiteId: 'website-id',
+ });
+
+ const track = jest.fn();
+ window.umami = { track };
+ document.querySelector('script')?.dispatchEvent(new Event('load'));
+
+ expect(track).toHaveBeenCalledWith(
+ 'competition_viewed',
+ expect.objectContaining({
+ app: 'competitiongroups',
+ auth_status: 'anonymous',
+ competition_id: 'ExampleComp2026',
+ page: 'groups',
+ }),
+ );
+ });
+
+ it('tracks event data with shared app and auth properties', () => {
+ const track = jest.fn();
+ window.umami = { track };
+
+ trackCompetitionEvent('competition_viewed', {
+ competitionId: 'ExampleComp2026',
+ page: 'groups',
+ user_id: 123,
+ });
+
+ expect(track).toHaveBeenCalledWith(
+ 'competition_viewed',
+ expect.objectContaining({
+ app: 'competitiongroups',
+ auth_status: 'logged_in',
+ competition_id: 'ExampleComp2026',
+ page: 'groups',
+ user_id: '123',
+ }),
+ );
+ });
+
+ it('does not send invalid event names', () => {
+ const track = jest.fn();
+ window.umami = { track };
+
+ expect(isValidEventName('x'.repeat(51))).toBe(false);
+ trackEvent('x'.repeat(51));
+
+ expect(track).not.toHaveBeenCalled();
+ });
+
+ it('identifies logged-in users by numeric ID only', () => {
+ const identify = jest.fn();
+ window.umami = {
+ identify,
+ track: jest.fn(),
+ };
+
+ identifyUser(123);
+
+ expect(identify).toHaveBeenCalledWith(
+ '123',
+ expect.objectContaining({
+ app: 'competitiongroups',
+ auth_status: 'logged_in',
+ }),
+ );
+ });
+
+ it('identifies logged-in users after the Umami script loads', () => {
+ loadUmamiScript({
+ src: 'https://analytics.example.com/script.js',
+ websiteId: 'website-id',
+ });
+
+ identifyUser(123);
+
+ const identify = jest.fn();
+ window.umami = {
+ identify,
+ track: jest.fn(),
+ };
+ document.querySelector('script')?.dispatchEvent(new Event('load'));
+
+ expect(identify).toHaveBeenCalledWith(
+ '123',
+ expect.objectContaining({
+ app: 'competitiongroups',
+ auth_status: 'logged_in',
+ }),
+ );
+ });
+});
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..0745ada
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,204 @@
+type AnalyticsPrimitive = string | number | boolean | null | undefined;
+
+export type AnalyticsProperties = Record;
+
+type UmamiTrack = {
+ track: (eventName: string, eventData?: AnalyticsProperties) => void;
+ identify?: ((id: string, data?: AnalyticsProperties) => void) &
+ ((data: AnalyticsProperties) => void);
+};
+
+type PendingEvent = {
+ eventName: string;
+ properties?: AnalyticsProperties;
+};
+
+const APP_NAME = 'competitiongroups';
+const MAX_EVENT_NAME_LENGTH = 50;
+const MAX_PENDING_EVENTS = 50;
+const UMAMI_SCRIPT_ID = 'umami-analytics-script';
+
+let currentUserId: string | undefined;
+let pendingEvents: PendingEvent[] = [];
+let umamiConfigured = false;
+
+const getEnvironment = () => (typeof __APP_ENV__ === 'undefined' ? 'test' : __APP_ENV__);
+
+const getVersion = () => {
+ const gitTag = typeof __GIT_TAG__ === 'undefined' ? '' : __GIT_TAG__;
+ const gitCommit = typeof __GIT_COMMIT__ === 'undefined' ? '' : __GIT_COMMIT__;
+
+ return gitTag || gitCommit;
+};
+
+const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
+
+const getUmami = () => {
+ if (!isBrowser()) {
+ return undefined;
+ }
+
+ return window.umami;
+};
+
+const sanitizeProperties = (properties: AnalyticsProperties = {}): AnalyticsProperties =>
+ Object.fromEntries(
+ Object.entries(properties).filter(([, value]) => {
+ const valueType = typeof value;
+ return (
+ value === null ||
+ valueType === 'string' ||
+ valueType === 'number' ||
+ valueType === 'boolean'
+ );
+ }),
+ );
+
+const baseProperties = (): AnalyticsProperties => ({
+ app: APP_NAME,
+ environment: getEnvironment(),
+ version: getVersion(),
+});
+
+const eventProperties = (properties: AnalyticsProperties = {}): AnalyticsProperties => {
+ const sanitized = sanitizeProperties(properties);
+ const userId = sanitized.user_id ?? currentUserId;
+
+ return {
+ ...baseProperties(),
+ ...sanitized,
+ auth_status: userId ? 'logged_in' : 'anonymous',
+ ...(userId ? { user_id: String(userId) } : {}),
+ };
+};
+
+export const isValidEventName = (eventName: string) =>
+ eventName.length > 0 && eventName.length <= MAX_EVENT_NAME_LENGTH;
+
+export const configureUmamiAnalytics = ({
+ src,
+ websiteId,
+}: {
+ src?: string;
+ websiteId?: string;
+} = {}) => {
+ umamiConfigured = Boolean(src && websiteId);
+};
+
+const sendEvent = (eventName: string, properties?: AnalyticsProperties) => {
+ const umami = getUmami();
+ if (!umami?.track) {
+ return false;
+ }
+
+ umami.track(eventName, eventProperties(properties));
+ return true;
+};
+
+const queueEvent = (eventName: string, properties?: AnalyticsProperties) => {
+ if (!umamiConfigured) {
+ return;
+ }
+
+ pendingEvents = [...pendingEvents.slice(-(MAX_PENDING_EVENTS - 1)), { eventName, properties }];
+};
+
+const identifyCurrentUser = () => {
+ const umami = getUmami();
+ if (!umami?.identify || !currentUserId) {
+ return false;
+ }
+
+ umami.identify(currentUserId, {
+ ...baseProperties(),
+ auth_status: 'logged_in',
+ });
+ return true;
+};
+
+const flushPendingEvents = () => {
+ if (!pendingEvents.length) {
+ identifyCurrentUser();
+ return;
+ }
+
+ const events = pendingEvents;
+ pendingEvents = [];
+
+ events.forEach(({ eventName, properties }) => {
+ if (!sendEvent(eventName, properties)) {
+ queueEvent(eventName, properties);
+ }
+ });
+
+ identifyCurrentUser();
+};
+
+export const loadUmamiScript = ({
+ src,
+ websiteId,
+}: {
+ src?: string;
+ websiteId?: string;
+} = {}) => {
+ if (!isBrowser() || !src || !websiteId) {
+ return;
+ }
+
+ configureUmamiAnalytics({ src, websiteId });
+
+ if (document.getElementById(UMAMI_SCRIPT_ID)) {
+ flushPendingEvents();
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.id = UMAMI_SCRIPT_ID;
+ script.defer = true;
+ script.src = src;
+ script.dataset.websiteId = websiteId;
+ script.addEventListener('load', flushPendingEvents);
+ document.head.appendChild(script);
+};
+
+export const identifyUser = (userId?: number | string | null) => {
+ currentUserId = userId ? String(userId) : undefined;
+ identifyCurrentUser();
+};
+
+export const trackEvent = (eventName: string, properties?: AnalyticsProperties) => {
+ if (!isValidEventName(eventName)) {
+ if (getEnvironment() === 'development') {
+ console.warn(`Skipping Umami event with invalid name: ${eventName}`);
+ }
+ return;
+ }
+
+ if (!sendEvent(eventName, properties)) {
+ queueEvent(eventName, properties);
+ }
+};
+
+export const trackCompetitionEvent = (
+ eventName: string,
+ properties: AnalyticsProperties & { competitionId?: string; competition_id?: string },
+) => {
+ const { competitionId, ...rest } = properties;
+
+ trackEvent(eventName, {
+ ...rest,
+ competition_id: properties.competition_id ?? competitionId,
+ });
+};
+
+declare global {
+ interface Window {
+ umami?: UmamiTrack;
+ }
+}
+
+export const __resetAnalyticsForTests = () => {
+ currentUserId = undefined;
+ pendingEvents = [];
+ umamiConfigured = false;
+};
diff --git a/src/lib/analyticsPages.test.ts b/src/lib/analyticsPages.test.ts
new file mode 100644
index 0000000..2addf9c
--- /dev/null
+++ b/src/lib/analyticsPages.test.ts
@@ -0,0 +1,38 @@
+import { competitionPageName } from './analyticsPages';
+
+describe('analyticsPages', () => {
+ it.each([
+ ['/competitions/ExampleComp2026', 'groups'],
+ ['/competitions/ExampleComp2026/events', 'events'],
+ ['/competitions/ExampleComp2026/events/333-r1', 'event_groups'],
+ ['/competitions/ExampleComp2026/events/333-r1/2', 'event_group'],
+ ['/competitions/ExampleComp2026/activities', 'schedule'],
+ ['/competitions/ExampleComp2026/activities/1', 'schedule_activity'],
+ ['/competitions/ExampleComp2026/rooms', 'schedule_rooms'],
+ ['/competitions/ExampleComp2026/rooms/main', 'schedule_room'],
+ ['/competitions/ExampleComp2026/admin/remote', 'live_activities'],
+ ['/competitions/ExampleComp2026/admin/scramblers', 'assignments'],
+ ['/competitions/ExampleComp2026/persons/12/results', 'person_results'],
+ ['/competitions/ExampleComp2026/persons/wca/2016TEST01/results', 'person_wca_results'],
+ ])('maps %s to %s', (pathname, page) => {
+ expect(competitionPageName(pathname, 'ExampleComp2026')).toBe(page);
+ });
+
+ it('uses stable coarse labels for unknown competition routes', () => {
+ expect(
+ competitionPageName(
+ '/competitions/ExampleComp2026/custom/potentially-sensitive-value',
+ 'ExampleComp2026',
+ ),
+ ).toBe('other');
+ });
+
+ it('uses stable coarse labels for unknown admin routes', () => {
+ expect(
+ competitionPageName(
+ '/competitions/ExampleComp2026/admin/potentially-sensitive-value',
+ 'ExampleComp2026',
+ ),
+ ).toBe('admin_other');
+ });
+});
diff --git a/src/lib/analyticsPages.ts b/src/lib/analyticsPages.ts
new file mode 100644
index 0000000..323057c
--- /dev/null
+++ b/src/lib/analyticsPages.ts
@@ -0,0 +1,115 @@
+const sanitizePageName = (value: string) =>
+ value
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
+ .replace(/[^a-zA-Z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .toLowerCase();
+
+export const competitionPageName = (pathname: string, competitionId: string) => {
+ const competitionRoot = `/competitions/${competitionId}`;
+ const relativePath = pathname.replace(competitionRoot, '').replace(/^\/+|\/+$/g, '');
+ const segments = relativePath.split('/').filter(Boolean);
+ const [section, second, third, fourth] = segments;
+
+ if (!section) {
+ return 'groups';
+ }
+
+ if (section === 'persons' && second === 'wca') {
+ return fourth ? `person_wca_${sanitizePageName(fourth)}` : 'person_wca';
+ }
+
+ if (section === 'persons') {
+ return third ? `person_${sanitizePageName(third)}` : 'person';
+ }
+
+ if (section === 'personal-bests' || section === 'personal-records') {
+ return sanitizePageName(section);
+ }
+
+ if (section === 'compare-schedules') {
+ return 'compare_schedules';
+ }
+
+ if (section === 'events') {
+ return third ? 'event_group' : second ? 'event_groups' : 'events';
+ }
+
+ if (section === 'activities') {
+ return second ? 'schedule_activity' : 'schedule';
+ }
+
+ if (section === 'rooms') {
+ return second ? 'schedule_room' : 'schedule_rooms';
+ }
+
+ if (section === 'psych-sheet') {
+ return second ? 'psych_sheet_event' : 'psych_sheet';
+ }
+
+ if (section === 'results') {
+ return second ? 'results_round' : 'results';
+ }
+
+ if (section === 'admin') {
+ if (second === 'remote') {
+ return 'live_activities';
+ }
+
+ if (second === 'webhooks') {
+ return 'webhooks';
+ }
+
+ if (second === 'scramblers') {
+ return 'assignments';
+ }
+
+ if (second === 'stats') {
+ return 'stats';
+ }
+
+ if (second === 'sum-of-ranks') {
+ return 'sum_of_ranks';
+ }
+
+ return second ? 'admin_other' : 'admin';
+ }
+
+ if (section === 'remote' || section === 'live') {
+ return 'live_activities';
+ }
+
+ if (section === 'webhooks') {
+ return 'webhooks';
+ }
+
+ if (section === 'scramblers') {
+ return 'assignments';
+ }
+
+ if (section === 'stats') {
+ return 'stats';
+ }
+
+ if (section === 'sum-of-ranks') {
+ return 'sum_of_ranks';
+ }
+
+ if (section === 'stream') {
+ return 'stream';
+ }
+
+ if (section === 'information') {
+ return 'information';
+ }
+
+ if (section === 'explore') {
+ return 'explore';
+ }
+
+ if (section === 'groups-schedule') {
+ return 'groups_schedule';
+ }
+
+ return 'other';
+};
diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx
index 007f250..e4118b8 100644
--- a/src/pages/Competition/Remote/index.tsx
+++ b/src/pages/Competition/Remote/index.tsx
@@ -9,6 +9,7 @@ import { NotifyCompConnectionStatus } from '@/components/NotifyCompConnectionSta
import { RemoteActivitySummaryList } from '@/components/RemoteActivitySummaryList';
import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl';
import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';
+import { trackCompetitionEvent } from '@/lib/analytics';
import { isCompetitionDayOrAfter } from '@/lib/competitionDates';
import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities';
import { canUseNotifyCompRemoteControls } from '@/lib/notifyCompWebSocketStatus';
@@ -76,6 +77,11 @@ export default function CompetitionRemote() {
if (confirmed) {
await remote.importCompetition();
+ trackCompetitionEvent('live_activity_created', {
+ competition_id: competitionId,
+ feature: 'live_activities',
+ user_id: user?.id,
+ });
}
};
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index e7d6bb4..054717b 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -3,8 +3,13 @@
declare const __GIT_COMMIT__: string;
declare const __GIT_TAG__: string;
+declare const __APP_ENV__: string;
interface ImportMetaEnv {
+ readonly VITE_APP_ENV?: string;
+ readonly VITE_GA_MEASUREMENT_ID?: string;
+ readonly VITE_UMAMI_SRC?: string;
+ readonly VITE_UMAMI_WEBSITE_ID?: string;
readonly VITE_NOTIFY_COMP_ORIGIN?: string;
readonly VITE_NOTIFYCOMP_API_ORIGIN?: string;
readonly VITE_NOTIFYCOMP_WS_ORIGIN?: string;
diff --git a/vite.config.ts b/vite.config.ts
index 229055b..98d0acc 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -20,8 +20,24 @@ function getGitTag() {
const GIT_COMMIT = getGitCommitHash();
const GIT_TAG = getGitTag();
+const getAppEnvironment = (mode: string) => {
+ if (process.env.VITE_APP_ENV) {
+ return process.env.VITE_APP_ENV;
+ }
+
+ if (process.env.BRANCH === 'main') {
+ return 'production';
+ }
+
+ if (process.env.BRANCH === 'beta') {
+ return 'beta';
+ }
+
+ return process.env.CONTEXT || mode;
+};
+
// https://vitejs.dev/config/
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
plugins: [
react(),
viteTsconfigPaths(),
@@ -43,7 +59,8 @@ export default defineConfig({
},
},
define: {
+ __APP_ENV__: JSON.stringify(getAppEnvironment(mode)),
__GIT_COMMIT__: JSON.stringify(GIT_COMMIT),
__GIT_TAG__: JSON.stringify(GIT_TAG || ''),
},
-});
+}));