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 || ''), }, -}); +}));