From 475f17499c262db731e5b2963b807d1ae4f1ea49 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 29 May 2026 18:18:55 -0700 Subject: [PATCH 1/4] 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. --- .env.example | 4 + src/hooks/useCompetitionAnalytics/index.ts | 1 + .../useCompetitionAnalytics.ts | 105 ++++++++++++++ src/hooks/usePageActivityTracking/index.ts | 1 + .../usePageActivityTracking.test.ts | 75 ++++++++++ .../usePageActivityTracking.ts | 40 ++++++ src/hooks/usePageTracking/usePageTracking.ts | 24 +++- .../CompetitionLayout/CompetitionLayout.tsx | 3 + src/lib/analytics.test.ts | 88 ++++++++++++ src/lib/analytics.ts | 135 ++++++++++++++++++ src/pages/Competition/Remote/index.tsx | 6 + src/vite-env.d.ts | 5 + vite.config.ts | 21 ++- 13 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 src/hooks/useCompetitionAnalytics/index.ts create mode 100644 src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts create mode 100644 src/hooks/usePageActivityTracking/index.ts create mode 100644 src/hooks/usePageActivityTracking/usePageActivityTracking.test.ts create mode 100644 src/hooks/usePageActivityTracking/usePageActivityTracking.ts create mode 100644 src/lib/analytics.test.ts create mode 100644 src/lib/analytics.ts 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.ts b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts new file mode 100644 index 0000000..147acd4 --- /dev/null +++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts @@ -0,0 +1,105 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { trackCompetitionEvent } from '@/lib/analytics'; +import { useAuth } from '@/providers/AuthProvider'; +import { usePageActivityTracking } from '../usePageActivityTracking'; + +const competitionPageName = (pathname: string, competitionId: string) => { + const competitionRoot = `/competitions/${competitionId}`; + const relativePath = pathname.replace(competitionRoot, '') || '/'; + + if (relativePath === '/') { + return 'groups'; + } + + if (relativePath.startsWith('/activities') || relativePath.startsWith('/rooms')) { + return 'schedule'; + } + + if (relativePath === '/live' || relativePath === '/admin/remote') { + return 'live_activities'; + } + + if (relativePath === '/admin/scramblers') { + return 'assignments'; + } + + return relativePath.replace(/^\//, '').replace(/\//g, '_') || 'competition'; +}; + +const pageViewEventName = (page: string) => { + if (page === 'groups') { + return 'groups_viewed'; + } + + if (page === 'schedule') { + 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 = pageViewEventName(page); + if (!eventName) { + return; + } + + const eventKey = `${competitionId}:${location.pathname}:${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, 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..b427bbe 100644 --- a/src/hooks/usePageTracking/usePageTracking.ts +++ b/src/hooks/usePageTracking/usePageTracking.ts @@ -1,14 +1,26 @@ import { useEffect } from 'react'; import ReactGA from 'react-ga4'; import { useLocation } from 'react-router-dom'; +import { 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(); useEffect(() => { - if (ReactGA.isInitialized) { + loadUmamiScript({ + src: import.meta.env.VITE_UMAMI_SRC, + websiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID, + }); + }, []); + + useEffect(() => { + identifyUser(user?.id); + }, [user?.id]); + + useEffect(() => { + if (!trackingCode || ReactGA.isInitialized) { return; } @@ -39,10 +51,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 +63,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..87ffa4a --- /dev/null +++ b/src/lib/analytics.test.ts @@ -0,0 +1,88 @@ +import { + identifyUser, + isValidEventName, + loadUmamiScript, + trackCompetitionEvent, + trackEvent, +} from './analytics'; + +describe('analytics', () => { + beforeEach(() => { + 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('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', + }), + ); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..ea1dcff --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,135 @@ +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); +}; + +const APP_NAME = 'competitiongroups'; +const MAX_EVENT_NAME_LENGTH = 50; +const UMAMI_SCRIPT_ID = 'umami-analytics-script'; + +let currentUserId: string | undefined; + +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 loadUmamiScript = ({ + src, + websiteId, +}: { + src?: string; + websiteId?: string; +} = {}) => { + if (!isBrowser() || !src || !websiteId || document.getElementById(UMAMI_SCRIPT_ID)) { + return; + } + + const script = document.createElement('script'); + script.id = UMAMI_SCRIPT_ID; + script.defer = true; + script.src = src; + script.dataset.websiteId = websiteId; + document.head.appendChild(script); +}; + +export const identifyUser = (userId?: number | string | null) => { + currentUserId = userId ? String(userId) : undefined; + + const umami = getUmami(); + if (!umami?.identify || !currentUserId) { + return; + } + + umami.identify(currentUserId, { + ...baseProperties(), + auth_status: 'logged_in', + }); +}; + +export const trackEvent = (eventName: string, properties?: AnalyticsProperties) => { + if (!isValidEventName(eventName)) { + if (getEnvironment() === 'development') { + console.warn(`Skipping Umami event with invalid name: ${eventName}`); + } + return; + } + + const umami = getUmami(); + if (!umami?.track) { + return; + } + + umami.track(eventName, eventProperties(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; + } +} 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 || ''), }, -}); +})); From e8341d6ea6ce55ff83f30388ed56614eb1718dcc Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 29 May 2026 18:31:47 -0700 Subject: [PATCH 2/4] Fix queued Umami competition events --- src/hooks/usePageTracking/usePageTracking.ts | 15 ++- src/lib/analytics.test.ts | 58 +++++++++++ src/lib/analytics.ts | 101 ++++++++++++++++--- 3 files changed, 154 insertions(+), 20 deletions(-) diff --git a/src/hooks/usePageTracking/usePageTracking.ts b/src/hooks/usePageTracking/usePageTracking.ts index b427bbe..b3d4973 100644 --- a/src/hooks/usePageTracking/usePageTracking.ts +++ b/src/hooks/usePageTracking/usePageTracking.ts @@ -1,19 +1,26 @@ import { useEffect } from 'react'; import ReactGA from 'react-ga4'; import { useLocation } from 'react-router-dom'; -import { identifyUser, loadUmamiScript } from '@/lib/analytics'; +import { configureUmamiAnalytics, identifyUser, loadUmamiScript } from '@/lib/analytics'; import { useAuth } from '@/providers/AuthProvider'; 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(() => { loadUmamiScript({ - src: import.meta.env.VITE_UMAMI_SRC, - websiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID, + src: umamiSrc, + websiteId: umamiWebsiteId, }); - }, []); + }, [umamiSrc, umamiWebsiteId]); useEffect(() => { identifyUser(user?.id); diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts index 87ffa4a..e70d98f 100644 --- a/src/lib/analytics.test.ts +++ b/src/lib/analytics.test.ts @@ -1,4 +1,6 @@ import { + __resetAnalyticsForTests, + configureUmamiAnalytics, identifyUser, isValidEventName, loadUmamiScript, @@ -8,6 +10,7 @@ import { describe('analytics', () => { beforeEach(() => { + __resetAnalyticsForTests(); document.head.innerHTML = ''; window.umami = undefined; }); @@ -36,6 +39,37 @@ describe('analytics', () => { 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 }; @@ -85,4 +119,28 @@ describe('analytics', () => { }), ); }); + + 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 index ea1dcff..0745ada 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -8,11 +8,19 @@ type UmamiTrack = { ((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__); @@ -67,6 +75,65 @@ const eventProperties = (properties: AnalyticsProperties = {}): AnalyticsPropert 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, @@ -74,7 +141,14 @@ export const loadUmamiScript = ({ src?: string; websiteId?: string; } = {}) => { - if (!isBrowser() || !src || !websiteId || document.getElementById(UMAMI_SCRIPT_ID)) { + if (!isBrowser() || !src || !websiteId) { + return; + } + + configureUmamiAnalytics({ src, websiteId }); + + if (document.getElementById(UMAMI_SCRIPT_ID)) { + flushPendingEvents(); return; } @@ -83,21 +157,13 @@ export const loadUmamiScript = ({ 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; - - const umami = getUmami(); - if (!umami?.identify || !currentUserId) { - return; - } - - umami.identify(currentUserId, { - ...baseProperties(), - auth_status: 'logged_in', - }); + identifyCurrentUser(); }; export const trackEvent = (eventName: string, properties?: AnalyticsProperties) => { @@ -108,12 +174,9 @@ export const trackEvent = (eventName: string, properties?: AnalyticsProperties) return; } - const umami = getUmami(); - if (!umami?.track) { - return; + if (!sendEvent(eventName, properties)) { + queueEvent(eventName, properties); } - - umami.track(eventName, eventProperties(properties)); }; export const trackCompetitionEvent = ( @@ -133,3 +196,9 @@ declare global { umami?: UmamiTrack; } } + +export const __resetAnalyticsForTests = () => { + currentUserId = undefined; + pendingEvents = []; + umamiConfigured = false; +}; From 768383354a9fd122205a472c45537cc097e25b67 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 29 May 2026 18:37:25 -0700 Subject: [PATCH 3/4] Use built-in Umami pageviews --- .../useCompetitionAnalytics.test.tsx | 60 +++++++++++++ .../useCompetitionAnalytics.ts | 34 ++------ src/lib/analyticsPages.test.ts | 26 ++++++ src/lib/analyticsPages.ts | 87 +++++++++++++++++++ 4 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx create mode 100644 src/lib/analyticsPages.test.ts create mode 100644 src/lib/analyticsPages.ts diff --git a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx new file mode 100644 index 0000000..0e236da --- /dev/null +++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx @@ -0,0 +1,60 @@ +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, + }); + }); +}); diff --git a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts index 147acd4..90a81d0 100644 --- a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts +++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts @@ -1,38 +1,16 @@ 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 competitionPageName = (pathname: string, competitionId: string) => { - const competitionRoot = `/competitions/${competitionId}`; - const relativePath = pathname.replace(competitionRoot, '') || '/'; - - if (relativePath === '/') { - return 'groups'; - } - - if (relativePath.startsWith('/activities') || relativePath.startsWith('/rooms')) { - return 'schedule'; - } - - if (relativePath === '/live' || relativePath === '/admin/remote') { - return 'live_activities'; - } - - if (relativePath === '/admin/scramblers') { - return 'assignments'; - } - - return relativePath.replace(/^\//, '').replace(/\//g, '_') || 'competition'; -}; - -const pageViewEventName = (page: string) => { +const featureViewEventName = (page: string) => { if (page === 'groups') { return 'groups_viewed'; } - if (page === 'schedule') { + if (page === 'schedule' || page === 'schedule_activity') { return 'schedule_viewed'; } @@ -78,12 +56,12 @@ export const useCompetitionAnalytics = (competitionId?: string) => { return; } - const eventName = pageViewEventName(page); + const eventName = featureViewEventName(page); if (!eventName) { return; } - const eventKey = `${competitionId}:${location.pathname}:${eventName}`; + const eventKey = `${competitionId}:${location.pathname}${location.search}:${eventName}`; if (lastPageEventKey.current === eventKey) { return; } @@ -95,7 +73,7 @@ export const useCompetitionAnalytics = (competitionId?: string) => { user_id: user?.id, }); lastPageEventKey.current = eventKey; - }, [competitionId, location.pathname, page, user?.id]); + }, [competitionId, location.pathname, location.search, page, user?.id]); usePageActivityTracking({ competitionId, diff --git a/src/lib/analyticsPages.test.ts b/src/lib/analyticsPages.test.ts new file mode 100644 index 0000000..2f62789 --- /dev/null +++ b/src/lib/analyticsPages.test.ts @@ -0,0 +1,26 @@ +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 labels for unknown competition routes', () => { + expect( + competitionPageName('/competitions/ExampleComp2026/custom/report', 'ExampleComp2026'), + ).toBe('custom_report'); + }); +}); diff --git a/src/lib/analyticsPages.ts b/src/lib/analyticsPages.ts new file mode 100644 index 0000000..7da1759 --- /dev/null +++ b/src/lib/analyticsPages.ts @@ -0,0 +1,87 @@ +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 === 'scramblers') { + return 'assignments'; + } + + return second ? sanitizePageName(second) : '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'; + } + + return sanitizePageName(segments.join('_')) || 'competition'; +}; From c393166fbbe97f088c8aeeef8d8cd9c956dce993 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 29 May 2026 18:41:18 -0700 Subject: [PATCH 4/4] Tighten analytics route labels --- .../useCompetitionAnalytics.test.tsx | 18 +++++++++++ .../useCompetitionAnalytics.ts | 7 +++- src/lib/analyticsPages.test.ts | 18 +++++++++-- src/lib/analyticsPages.ts | 32 +++++++++++++++++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx index 0e236da..a1047e2 100644 --- a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx +++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.test.tsx @@ -57,4 +57,22 @@ describe('useCompetitionAnalytics', () => { 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 index 90a81d0..645b286 100644 --- a/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts +++ b/src/hooks/useCompetitionAnalytics/useCompetitionAnalytics.ts @@ -10,7 +10,12 @@ const featureViewEventName = (page: string) => { return 'groups_viewed'; } - if (page === 'schedule' || page === 'schedule_activity') { + if ( + page === 'schedule' || + page === 'schedule_activity' || + page === 'schedule_rooms' || + page === 'schedule_room' + ) { return 'schedule_viewed'; } diff --git a/src/lib/analyticsPages.test.ts b/src/lib/analyticsPages.test.ts index 2f62789..2addf9c 100644 --- a/src/lib/analyticsPages.test.ts +++ b/src/lib/analyticsPages.test.ts @@ -18,9 +18,21 @@ describe('analyticsPages', () => { expect(competitionPageName(pathname, 'ExampleComp2026')).toBe(page); }); - it('uses stable labels for unknown competition routes', () => { + it('uses stable coarse labels for unknown competition routes', () => { expect( - competitionPageName('/competitions/ExampleComp2026/custom/report', 'ExampleComp2026'), - ).toBe('custom_report'); + 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 index 7da1759..323057c 100644 --- a/src/lib/analyticsPages.ts +++ b/src/lib/analyticsPages.ts @@ -56,11 +56,23 @@ export const competitionPageName = (pathname: string, competitionId: string) => return 'live_activities'; } + if (second === 'webhooks') { + return 'webhooks'; + } + if (second === 'scramblers') { return 'assignments'; } - return second ? sanitizePageName(second) : 'admin'; + if (second === 'stats') { + return 'stats'; + } + + if (second === 'sum-of-ranks') { + return 'sum_of_ranks'; + } + + return second ? 'admin_other' : 'admin'; } if (section === 'remote' || section === 'live') { @@ -83,5 +95,21 @@ export const competitionPageName = (pathname: string, competitionId: string) => return 'sum_of_ranks'; } - return sanitizePageName(segments.join('_')) || 'competition'; + if (section === 'stream') { + return 'stream'; + } + + if (section === 'information') { + return 'information'; + } + + if (section === 'explore') { + return 'explore'; + } + + if (section === 'groups-schedule') { + return 'groups_schedule'; + } + + return 'other'; };