mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
more (modular local offline not selling data) analytics!
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,11 +14,71 @@
|
|||||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
||||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/*
|
||||||
|
Build-time analytics adapter for the rover UI.
|
||||||
|
|
||||||
|
React only calls window.roverAnalytics.track/identify. Keeping the Umami
|
||||||
|
adapter here means analytics can still be removed, replaced, or configured
|
||||||
|
by changing this injected file instead of rebuilding app logic around a
|
||||||
|
specific analytics provider.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var pendingCalls = [];
|
||||||
|
var flushTimer = null;
|
||||||
|
|
||||||
|
function callUmami(method, args) {
|
||||||
|
if (!window.umami || typeof window.umami[method] !== 'function') return false;
|
||||||
|
window.umami[method].apply(window.umami, args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingCalls() {
|
||||||
|
if (!pendingCalls.length) return;
|
||||||
|
if (!window.umami) return;
|
||||||
|
|
||||||
|
pendingCalls = pendingCalls.filter(function (call) {
|
||||||
|
return !callUmami(call.method, call.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pendingCalls.length && flushTimer) {
|
||||||
|
window.clearInterval(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(method, args) {
|
||||||
|
if (callUmami(method, args)) return;
|
||||||
|
pendingCalls.push({ method: method, args: args });
|
||||||
|
|
||||||
|
/*
|
||||||
|
The React app may fire route/session events before Umami's deferred
|
||||||
|
script has executed. Queueing preserves those early events while still
|
||||||
|
letting the whole adapter no-op harmlessly if the script is blocked.
|
||||||
|
*/
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = window.setInterval(flushPendingCalls, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.roverAnalytics = {
|
||||||
|
track: function (name, data) {
|
||||||
|
enqueue('track', [name, data || {}]);
|
||||||
|
},
|
||||||
|
identify: function (data) {
|
||||||
|
enqueue('identify', [data || {}]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('load', flushPendingCalls);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- otterlytics testing for blocking local -->
|
<!-- otterlytics testing for blocking local -->
|
||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C4SKjZW7.js"></script>
|
<script type="module" crossorigin src="/assets/index-CL-6TNyJ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+55
-9
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
|||||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||||
|
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||||
|
|
||||||
function useLayoutMode() {
|
function useLayoutMode() {
|
||||||
const [mode, setMode] = useState(() => {
|
const [mode, setMode] = useState(() => {
|
||||||
@@ -130,9 +131,21 @@ function MobileFeatureTabs({
|
|||||||
// disabled service and direct-address mode.
|
// disabled service and direct-address mode.
|
||||||
return Boolean(vote?.votingEnabled);
|
return Boolean(vote?.votingEnabled);
|
||||||
});
|
});
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(tab) => {
|
||||||
|
/*
|
||||||
|
Tab changes are one of the highest-signal UI events because the app is a
|
||||||
|
dense single-page control surface. Recording the selected panel gives
|
||||||
|
Umami useful journeys without tracking every button inside each panel.
|
||||||
|
*/
|
||||||
|
setActiveTab(tab);
|
||||||
|
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'mobile_features' });
|
||||||
|
},
|
||||||
|
[layout],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<section className="text-base">
|
<section className="text-base">
|
||||||
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
|
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="chat">Chat</Tab>
|
<Tab id="chat">Chat</Tab>
|
||||||
<Tab id="activities">Activities</Tab>
|
<Tab id="activities">Activities</Tab>
|
||||||
@@ -325,33 +338,66 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
}
|
}
|
||||||
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
||||||
|
|
||||||
const openHelp = useCallback(() => setHelpVisible(true), []);
|
useEffect(() => {
|
||||||
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
if (!fullscreenVisible) return;
|
||||||
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
|
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
||||||
|
}, [fullscreenMode, fullscreenVisible, layout]);
|
||||||
|
|
||||||
|
const openHelp = useCallback(() => {
|
||||||
|
setHelpVisible(true);
|
||||||
|
trackAnalyticsEvent('help_open', { layout, source: 'panel' });
|
||||||
|
}, [layout]);
|
||||||
|
const closeHelp = useCallback(() => {
|
||||||
|
setHelpVisible(false);
|
||||||
|
trackAnalyticsEvent('help_close', { layout });
|
||||||
|
}, [layout]);
|
||||||
|
const closeQuickstart = useCallback(() => {
|
||||||
|
setQuickstartVisible(false);
|
||||||
|
trackAnalyticsEvent('quickstart_close', { layout });
|
||||||
|
}, [layout]);
|
||||||
const handleFloatingFullscreen = useCallback(async () => {
|
const handleFloatingFullscreen = useCallback(async () => {
|
||||||
if (fullscreenIsIOS) {
|
if (fullscreenIsIOS) {
|
||||||
|
trackAnalyticsEvent('fullscreen_prompt_manual_open', { layout, mode: 'pwa-hint', source: 'floating_button' });
|
||||||
showPrompt();
|
showPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entered = await enterFullscreen();
|
const entered = await enterFullscreen();
|
||||||
|
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
|
||||||
|
layout,
|
||||||
|
source: 'floating_button',
|
||||||
|
});
|
||||||
if (!entered) {
|
if (!entered) {
|
||||||
showPrompt();
|
showPrompt();
|
||||||
}
|
}
|
||||||
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
|
}, [enterFullscreen, fullscreenIsIOS, layout, showPrompt]);
|
||||||
const setQuickstartShowOnLoad = useCallback(
|
const setQuickstartShowOnLoad = useCallback(
|
||||||
(enabled) => {
|
(enabled) => {
|
||||||
const next = Boolean(enabled);
|
const next = Boolean(enabled);
|
||||||
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||||
|
trackAnalyticsEvent('quickstart_show_on_load_change', { layout, enabled: next });
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setQuickstartVisible(false);
|
setQuickstartVisible(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveQuickstartSettings],
|
[layout, saveQuickstartSettings],
|
||||||
);
|
);
|
||||||
const openHelpFromQuickstart = useCallback(() => {
|
const openHelpFromQuickstart = useCallback(() => {
|
||||||
setQuickstartVisible(false);
|
setQuickstartVisible(false);
|
||||||
setHelpVisible(true);
|
setHelpVisible(true);
|
||||||
}, []);
|
trackAnalyticsEvent('help_open', { layout, source: 'quickstart' });
|
||||||
|
}, [layout]);
|
||||||
|
const handleFullscreenPromptEnter = useCallback(async () => {
|
||||||
|
const entered = await enterFullscreen();
|
||||||
|
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
|
||||||
|
layout,
|
||||||
|
source: 'prompt',
|
||||||
|
});
|
||||||
|
return entered;
|
||||||
|
}, [enterFullscreen, layout]);
|
||||||
|
const handleFullscreenPromptDismiss = useCallback(() => {
|
||||||
|
dismiss();
|
||||||
|
trackAnalyticsEvent('fullscreen_dismiss', { layout, mode: fullscreenMode });
|
||||||
|
}, [dismiss, fullscreenMode, layout]);
|
||||||
|
|
||||||
const renderedLayout = useMemo(
|
const renderedLayout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -387,8 +433,8 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
<FullscreenPrompt
|
<FullscreenPrompt
|
||||||
visible={fullscreenVisible}
|
visible={fullscreenVisible}
|
||||||
mode={fullscreenMode}
|
mode={fullscreenMode}
|
||||||
onEnterFullscreen={enterFullscreen}
|
onEnterFullscreen={handleFullscreenPromptEnter}
|
||||||
onDismiss={dismiss}
|
onDismiss={handleFullscreenPromptDismiss}
|
||||||
/>
|
/>
|
||||||
<QuickstartOverlay
|
<QuickstartOverlay
|
||||||
visible={quickstartVisible}
|
visible={quickstartVisible}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Analytics Reporter
|
||||||
|
// Purpose: Publishes page/session context to the optional build-time analytics
|
||||||
|
// adapter. Scope: observes route, layout, nickname, role, verification, and
|
||||||
|
// rover assignment without owning any analytics vendor implementation.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { identifyAnalyticsSession, trackAnalyticsEvent } from './index.js';
|
||||||
|
|
||||||
|
function detectLayout() {
|
||||||
|
if (typeof window === 'undefined') return 'desktop';
|
||||||
|
if (window.innerWidth >= 1024) return 'desktop';
|
||||||
|
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAnalyticsLayout() {
|
||||||
|
const [layout, setLayout] = useState(() => detectLayout());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function updateLayout() {
|
||||||
|
setLayout(detectLayout());
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayout();
|
||||||
|
window.addEventListener('resize', updateLayout);
|
||||||
|
return () => window.removeEventListener('resize', updateLayout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsReporter() {
|
||||||
|
const location = useLocation();
|
||||||
|
const layout = useAnalyticsLayout();
|
||||||
|
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
|
const session = useSessionSelector((state) => state.session);
|
||||||
|
const previousRouteRef = useRef(null);
|
||||||
|
const previousLayoutRef = useRef(null);
|
||||||
|
const previousRoverRef = useRef(null);
|
||||||
|
const previousIdentityRef = useRef('');
|
||||||
|
const route = location.pathname || '/';
|
||||||
|
const nickname = String(profile?.nickname || session?.nickname || '').trim();
|
||||||
|
const roverId = String(session?.assignment?.roverId || '').trim();
|
||||||
|
const role = String(session?.role || '').trim();
|
||||||
|
const verified = Boolean(session?.isVerified);
|
||||||
|
|
||||||
|
const identity = useMemo(
|
||||||
|
() => ({
|
||||||
|
route,
|
||||||
|
layout,
|
||||||
|
nickname,
|
||||||
|
hasNickname: Boolean(nickname),
|
||||||
|
roverId,
|
||||||
|
assignedRover: Boolean(roverId),
|
||||||
|
role,
|
||||||
|
verified,
|
||||||
|
}),
|
||||||
|
[layout, nickname, role, route, roverId, verified],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serialized = JSON.stringify(identity);
|
||||||
|
if (previousIdentityRef.current === serialized) return;
|
||||||
|
previousIdentityRef.current = serialized;
|
||||||
|
|
||||||
|
/*
|
||||||
|
This pushes the current browser/session context into the injected adapter.
|
||||||
|
The adapter is responsible for applying build-time privacy/config choices,
|
||||||
|
such as whether nickname and rover id should be sent to Umami.
|
||||||
|
*/
|
||||||
|
identifyAnalyticsSession(identity);
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousRouteRef.current === route) return;
|
||||||
|
previousRouteRef.current = route;
|
||||||
|
trackAnalyticsEvent('route_enter', { route, layout });
|
||||||
|
}, [layout, route]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousLayoutRef.current === layout) return;
|
||||||
|
previousLayoutRef.current = layout;
|
||||||
|
trackAnalyticsEvent('layout_change', { route, layout });
|
||||||
|
}, [layout, route]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roverId || previousRoverRef.current === roverId) return;
|
||||||
|
previousRoverRef.current = roverId;
|
||||||
|
trackAnalyticsEvent('rover_assigned', { roverId, route, layout });
|
||||||
|
}, [layout, route, roverId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Analytics Bridge
|
||||||
|
// Purpose: Gives React a tiny, provider-neutral analytics surface. Scope:
|
||||||
|
// forwards optional app events to a build-time injected browser adapter without
|
||||||
|
// importing Umami, embedding website ids, or making rover controls depend on
|
||||||
|
// analytics availability.
|
||||||
|
|
||||||
|
const MAX_EVENT_NAME_LENGTH = 80;
|
||||||
|
const MAX_PROPERTY_KEY_LENGTH = 80;
|
||||||
|
const MAX_STRING_VALUE_LENGTH = 240;
|
||||||
|
|
||||||
|
function getAdapter() {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return window.roverAnalytics && typeof window.roverAnalytics === 'object'
|
||||||
|
? window.roverAnalytics
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEventName(name) {
|
||||||
|
if (typeof name !== 'string') return null;
|
||||||
|
const normalized = name.trim().toLowerCase().replace(/[^a-z0-9_:-]+/g, '_');
|
||||||
|
return normalized ? normalized.slice(0, MAX_EVENT_NAME_LENGTH) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value) {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed.slice(0, MAX_STRING_VALUE_LENGTH) : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
/*
|
||||||
|
Analytics properties should stay compact and dashboard-friendly. Arrays
|
||||||
|
are reduced to primitive strings instead of sending nested structures that
|
||||||
|
Umami cannot use well for event breakdowns.
|
||||||
|
*/
|
||||||
|
const normalized = value
|
||||||
|
.map((entry) => normalizeValue(entry))
|
||||||
|
.filter((entry) => entry !== undefined)
|
||||||
|
.map((entry) => String(entry));
|
||||||
|
return normalized.length ? normalized.slice(0, 12).join(',') : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload(payload = {}) {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
|
||||||
|
return Object.entries(payload).reduce((clean, [key, value]) => {
|
||||||
|
if (typeof key !== 'string') return clean;
|
||||||
|
const normalizedKey = key.trim().replace(/[^a-zA-Z0-9_:-]+/g, '_').slice(0, MAX_PROPERTY_KEY_LENGTH);
|
||||||
|
if (!normalizedKey) return clean;
|
||||||
|
const normalizedValue = normalizeValue(value);
|
||||||
|
if (normalizedValue === undefined) return clean;
|
||||||
|
clean[normalizedKey] = normalizedValue;
|
||||||
|
return clean;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackAnalyticsEvent(name, payload = {}) {
|
||||||
|
const eventName = normalizeEventName(name);
|
||||||
|
if (!eventName) return;
|
||||||
|
const adapter = getAdapter();
|
||||||
|
if (!adapter || typeof adapter.track !== 'function') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Analytics must be observability-only. Catching adapter errors here keeps a
|
||||||
|
broken or blocked third-party script from interfering with rover driving,
|
||||||
|
scanner input, chat, or any other live-control UI.
|
||||||
|
*/
|
||||||
|
adapter.track(eventName, normalizePayload(payload));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Analytics event failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifyAnalyticsSession(payload = {}) {
|
||||||
|
const adapter = getAdapter();
|
||||||
|
if (!adapter || typeof adapter.identify !== 'function') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Session identity is centralized so individual events do not need to repeat
|
||||||
|
nickname, rover, role, layout, and route data. The injected build-time
|
||||||
|
adapter still decides which fields are forwarded to the actual provider.
|
||||||
|
*/
|
||||||
|
adapter.identify(normalizePayload(payload));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Analytics identify failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
function clampRgbChannel(value) {
|
function clampRgbChannel(value) {
|
||||||
if (!Number.isFinite(value)) return null;
|
if (!Number.isFinite(value)) return null;
|
||||||
@@ -189,7 +190,7 @@ function Leaderboard({ players, ownPlayer }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BarcodeGamesPanel() {
|
export default function BarcodeGamesPanel() {
|
||||||
const { state, voteForGame } = useBarcodeGameState();
|
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||||
const [pendingGameId, setPendingGameId] = useState(null);
|
const [pendingGameId, setPendingGameId] = useState(null);
|
||||||
const activeGame = state.activeGame;
|
const activeGame = state.activeGame;
|
||||||
const display = activeGame?.display || {};
|
const display = activeGame?.display || {};
|
||||||
@@ -204,10 +205,41 @@ export default function BarcodeGamesPanel() {
|
|||||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||||
const activeTheme = getGameTheme(activeGame?.themeColor);
|
const activeTheme = getGameTheme(activeGame?.themeColor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectionState.stale || !connectionState.lastReceivedAt) return;
|
||||||
|
/*
|
||||||
|
Stale barcode-game state is worth tracking because it points to a real
|
||||||
|
interaction problem: users can be looking at old game choices or scores
|
||||||
|
even though the rest of the page appears loaded.
|
||||||
|
*/
|
||||||
|
trackAnalyticsEvent('barcode_game_state_stale', {
|
||||||
|
connected: connectionState.connected,
|
||||||
|
phase: state.phase || 'unknown',
|
||||||
|
});
|
||||||
|
}, [connectionState.connected, connectionState.lastReceivedAt, connectionState.stale, state.phase]);
|
||||||
|
|
||||||
const handleVote = async (gameId) => {
|
const handleVote = async (gameId) => {
|
||||||
setPendingGameId(gameId);
|
setPendingGameId(gameId);
|
||||||
|
trackAnalyticsEvent('barcode_game_vote', {
|
||||||
|
gameId,
|
||||||
|
phase: state.phase || 'unknown',
|
||||||
|
status: 'started',
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await voteForGame(gameId);
|
await voteForGame(gameId);
|
||||||
|
trackAnalyticsEvent('barcode_game_vote', {
|
||||||
|
gameId,
|
||||||
|
phase: state.phase || 'unknown',
|
||||||
|
status: 'accepted',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
trackAnalyticsEvent('barcode_game_vote', {
|
||||||
|
gameId,
|
||||||
|
phase: state.phase || 'unknown',
|
||||||
|
status: 'failed',
|
||||||
|
reason: error?.message || 'unknown',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setPendingGameId(null);
|
setPendingGameId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
export default function NicknameForm({ compact = false }) {
|
export default function NicknameForm({ compact = false }) {
|
||||||
const { setNickname } = useSessionActions();
|
const { setNickname } = useSessionActions();
|
||||||
@@ -26,6 +27,14 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
// the driver UI so chat/user-list labels stay consistent across routes.
|
// the driver UI so chat/user-list labels stay consistent across routes.
|
||||||
await setNickname(trimmed);
|
await setNickname(trimmed);
|
||||||
save({ nickname: trimmed });
|
save({ nickname: trimmed });
|
||||||
|
/*
|
||||||
|
The event records that a nickname was saved, while the shared analytics
|
||||||
|
session reporter owns the actual nickname field. Keeping that split
|
||||||
|
prevents every form call site from needing to know privacy/config rules.
|
||||||
|
*/
|
||||||
|
trackAnalyticsEvent('nickname_set', {
|
||||||
|
length: trimmed.length,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
|||||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
||||||
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||||
const CHAT_DOCK_MIN_HEIGHT = 144;
|
const CHAT_DOCK_MIN_HEIGHT = 144;
|
||||||
@@ -173,6 +174,18 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
// unavailable, including disabled service and direct-address mode.
|
// unavailable, including disabled service and direct-address mode.
|
||||||
return Boolean(vote?.votingEnabled);
|
return Boolean(vote?.votingEnabled);
|
||||||
});
|
});
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(tab) => {
|
||||||
|
/*
|
||||||
|
Desktop users spend most of their time on this one route, so panel
|
||||||
|
changes are the cleanest way to understand feature usage without adding
|
||||||
|
analytics calls to every nested control in the rover dashboard.
|
||||||
|
*/
|
||||||
|
setActiveTab(tab);
|
||||||
|
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'desktop_right_pane' });
|
||||||
|
},
|
||||||
|
[layout],
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const chatDock = chatDockRef.current;
|
const chatDock = chatDockRef.current;
|
||||||
@@ -259,7 +272,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="text-base">
|
<section className="text-base">
|
||||||
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
|
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="telemetry">Controls</Tab>
|
<Tab id="telemetry">Controls</Tab>
|
||||||
<Tab id="activities">Activities</Tab>
|
<Tab id="activities">Activities</Tab>
|
||||||
|
|||||||
@@ -1,6 +1,56 @@
|
|||||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
||||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/*
|
||||||
|
Example analytics adapter. React talks only to window.roverAnalytics, so
|
||||||
|
this file owns the provider-specific forwarding without app-side Umami code.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var pendingCalls = [];
|
||||||
|
var flushTimer = null;
|
||||||
|
|
||||||
|
function callUmami(method, args) {
|
||||||
|
if (!window.umami || typeof window.umami[method] !== 'function') return false;
|
||||||
|
window.umami[method].apply(window.umami, args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingCalls() {
|
||||||
|
if (!pendingCalls.length) return;
|
||||||
|
if (!window.umami) return;
|
||||||
|
|
||||||
|
pendingCalls = pendingCalls.filter(function (call) {
|
||||||
|
return !callUmami(call.method, call.args);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pendingCalls.length && flushTimer) {
|
||||||
|
window.clearInterval(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(method, args) {
|
||||||
|
if (callUmami(method, args)) return;
|
||||||
|
pendingCalls.push({ method: method, args: args });
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = window.setInterval(flushPendingCalls, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.roverAnalytics = {
|
||||||
|
track: function (name, data) {
|
||||||
|
enqueue('track', [name, data || {}]);
|
||||||
|
},
|
||||||
|
identify: function (data) {
|
||||||
|
enqueue('identify', [data || {}]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('load', flushPendingCalls);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- otterlytics testing for blocking local -->
|
<!-- otterlytics testing for blocking local -->
|
||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSessionActions, useSessionSelector } from './SessionContext.jsx';
|
|||||||
import messageSound from '../assets/message.mp3';
|
import messageSound from '../assets/message.mp3';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
|
import { trackAnalyticsEvent } from '../analytics/index.js';
|
||||||
|
|
||||||
const CHAT_TIMELINE_DEFAULT = {
|
const CHAT_TIMELINE_DEFAULT = {
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -218,6 +219,15 @@ export function ChatProvider({ children }) {
|
|||||||
if (resp.error) {
|
if (resp.error) {
|
||||||
reject(new Error(resp.error));
|
reject(new Error(resp.error));
|
||||||
} else {
|
} else {
|
||||||
|
/*
|
||||||
|
Count successful chat sends without forwarding chat text. The
|
||||||
|
centralized analytics session identity can still attach nickname
|
||||||
|
when the build-time adapter is configured to include it.
|
||||||
|
*/
|
||||||
|
trackAnalyticsEvent('chat_send', {
|
||||||
|
hasTts: Boolean(tts),
|
||||||
|
length: typeof text === 'string' ? text.trim().length : 0,
|
||||||
|
});
|
||||||
resolve(resp);
|
resolve(resp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.js
|
|||||||
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
|
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
|
||||||
import { SettingsProvider } from './settings/index.js'
|
import { SettingsProvider } from './settings/index.js'
|
||||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||||
|
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -25,6 +26,7 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
<DeterrenceChaos />
|
<DeterrenceChaos />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AnalyticsReporter />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/spectate" element={<SpectatorApp />} />
|
<Route path="/spectate" element={<SpectatorApp />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user