more (modular local offline not selling data) analytics!

This commit is contained in:
legop3
2026-06-19 01:03:47 -04:00
parent c7e811a957
commit ea8e027be9
12 changed files with 547 additions and 139 deletions
+55 -9
View File
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
import { trackAnalyticsEvent } from './analytics/index.js';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -130,9 +131,21 @@ function MobileFeatureTabs({
// disabled service and direct-address mode.
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 (
<section className="text-base">
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="activities">Activities</Tab>
@@ -325,33 +338,66 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
}
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
const openHelp = useCallback(() => setHelpVisible(true), []);
const closeHelp = useCallback(() => setHelpVisible(false), []);
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
useEffect(() => {
if (!fullscreenVisible) return;
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 () => {
if (fullscreenIsIOS) {
trackAnalyticsEvent('fullscreen_prompt_manual_open', { layout, mode: 'pwa-hint', source: 'floating_button' });
showPrompt();
return;
}
const entered = await enterFullscreen();
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
layout,
source: 'floating_button',
});
if (!entered) {
showPrompt();
}
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
}, [enterFullscreen, fullscreenIsIOS, layout, showPrompt]);
const setQuickstartShowOnLoad = useCallback(
(enabled) => {
const next = Boolean(enabled);
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
trackAnalyticsEvent('quickstart_show_on_load_change', { layout, enabled: next });
if (!next) {
setQuickstartVisible(false);
}
},
[saveQuickstartSettings],
[layout, saveQuickstartSettings],
);
const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false);
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(
() =>
@@ -387,8 +433,8 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
onEnterFullscreen={handleFullscreenPromptEnter}
onDismiss={handleFullscreenPromptDismiss}
/>
<QuickstartOverlay
visible={quickstartVisible}
+94
View File
@@ -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;
}
+92
View File
@@ -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 useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
import CardFrame from '../CardFrame/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js';
function clampRgbChannel(value) {
if (!Number.isFinite(value)) return null;
@@ -189,7 +190,7 @@ function Leaderboard({ players, ownPlayer }) {
}
export default function BarcodeGamesPanel() {
const { state, voteForGame } = useBarcodeGameState();
const { state, connectionState, voteForGame } = useBarcodeGameState();
const [pendingGameId, setPendingGameId] = useState(null);
const activeGame = state.activeGame;
const display = activeGame?.display || {};
@@ -204,10 +205,41 @@ export default function BarcodeGamesPanel() {
const participants = Array.isArray(state.participants) ? state.participants : [];
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) => {
setPendingGameId(gameId);
trackAnalyticsEvent('barcode_game_vote', {
gameId,
phase: state.phase || 'unknown',
status: 'started',
});
try {
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 {
setPendingGameId(null);
}
@@ -4,6 +4,7 @@
import { useEffect, useState } from 'react';
import { useSessionActions } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
export default function NicknameForm({ compact = false }) {
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.
await setNickname(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) {
alert(err.message);
} finally {
+15 -2
View File
@@ -29,9 +29,10 @@ import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
import OverseerPreferencePanel from '../OverseerPreferencePanel/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 { themeGapClass, themeStackClass } from '../../themeFlags.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
const CHAT_DOCK_INITIAL_HEIGHT = 224;
const CHAT_DOCK_MIN_HEIGHT = 144;
@@ -173,6 +174,18 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
// unavailable, including disabled service and direct-address mode.
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(() => {
const chatDock = chatDockRef.current;
@@ -259,7 +272,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
return (
<section className="text-base">
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={handleTabChange}>
<TabList>
<Tab id="telemetry">Controls</Tab>
<Tab id="activities">Activities</Tab>
+50
View File
@@ -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. -->
<!-- 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 -->
<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>
+10
View File
@@ -8,6 +8,7 @@ import { useSessionActions, useSessionSelector } from './SessionContext.jsx';
import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { trackAnalyticsEvent } from '../analytics/index.js';
const CHAT_TIMELINE_DEFAULT = {
messages: [],
@@ -218,6 +219,15 @@ export function ChatProvider({ children }) {
if (resp.error) {
reject(new Error(resp.error));
} 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);
}
});
+2
View File
@@ -15,6 +15,7 @@ import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.js
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
@@ -25,6 +26,7 @@ createRoot(document.getElementById('root')).render(
<ChatProvider>
<DeterrenceChaos />
<BrowserRouter>
<AnalyticsReporter />
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />