mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
analytics and embed meta update!!!
This commit is contained in:
+3
-4
@@ -4,15 +4,14 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<title>Roomba Rover</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "Multi Roomba Rover",
|
||||
"short_name": "MRR",
|
||||
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#020617",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/bitmap.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
+9
-41
@@ -58,7 +58,6 @@ import {
|
||||
themeGapClass,
|
||||
themeStackClass,
|
||||
} from './themes/index.js';
|
||||
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||
import useLayoutMode from './hooks/useLayoutMode.js';
|
||||
|
||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
@@ -117,15 +116,9 @@ function MobileFeatureTabs({
|
||||
});
|
||||
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">
|
||||
@@ -342,71 +335,46 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
}
|
||||
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenVisible) return;
|
||||
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
||||
}, [fullscreenMode, fullscreenVisible, layout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickstartVisible) return;
|
||||
trackAnalyticsEvent('quickstart_open', { layout });
|
||||
}, [layout, quickstartVisible]);
|
||||
|
||||
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, layout, showPrompt]);
|
||||
}, [enterFullscreen, fullscreenIsIOS, 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);
|
||||
}
|
||||
},
|
||||
[layout, saveQuickstartSettings],
|
||||
[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]);
|
||||
}, [enterFullscreen]);
|
||||
const handleFullscreenPromptDismiss = useCallback(() => {
|
||||
dismiss();
|
||||
trackAnalyticsEvent('fullscreen_dismiss', { layout, mode: fullscreenMode });
|
||||
}, [dismiss, fullscreenMode, layout]);
|
||||
}, [dismiss]);
|
||||
|
||||
const renderedLayout = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Analytics Reporter
|
||||
// Purpose: Publishes page/session context to the optional build-time analytics
|
||||
// Purpose: Publishes page/session context to the optional runtime 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';
|
||||
import { identifyAnalyticsSession } from './index.js';
|
||||
|
||||
function detectLayout() {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
@@ -31,15 +30,10 @@ function useAnalyticsLayout() {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -47,16 +41,14 @@ export default function AnalyticsReporter() {
|
||||
|
||||
const identity = useMemo(
|
||||
() => ({
|
||||
route,
|
||||
layout,
|
||||
nickname,
|
||||
hasNickname: Boolean(nickname),
|
||||
roverId,
|
||||
assignedRover: Boolean(roverId),
|
||||
role,
|
||||
verified,
|
||||
}),
|
||||
[layout, nickname, role, route, roverId, verified],
|
||||
[layout, nickname, role, roverId, verified],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,30 +57,13 @@ export default function AnalyticsReporter() {
|
||||
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.
|
||||
Session properties describe durable segmentation dimensions. Route is
|
||||
intentionally absent because Umami attaches the current URL to pageviews
|
||||
and events automatically, and nickname is reduced to a non-identifying
|
||||
boolean so aggregate analytics does not store user-entered names.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Analytics Bridge
|
||||
// Purpose: Gives React a tiny, provider-neutral analytics surface. Scope:
|
||||
// forwards optional app events to a build-time injected browser adapter without
|
||||
// forwards optional app events to a runtime-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;
|
||||
const throttledEvents = new Map();
|
||||
|
||||
function getAdapter() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -101,21 +100,3 @@ export function identifyAnalyticsSession(payload = {}) {
|
||||
console.warn('Analytics identify failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackAnalyticsEventThrottled(name, payload = {}, options = {}) {
|
||||
const eventName = normalizeEventName(name);
|
||||
if (!eventName) return;
|
||||
const throttleMs = Number.isFinite(options?.throttleMs) ? Math.max(0, options.throttleMs) : 30 * 1000;
|
||||
const key = `${eventName}:${typeof options?.key === 'string' ? options.key : JSON.stringify(normalizePayload(payload))}`;
|
||||
const now = Date.now();
|
||||
const lastTrackedAt = throttledEvents.get(key) || 0;
|
||||
|
||||
/*
|
||||
Reliability signals can repeat every reconnect loop or failed media retry.
|
||||
Throttling by a caller-owned key keeps Umami useful as an incident signal
|
||||
without turning transient outages into hundreds of duplicate custom events.
|
||||
*/
|
||||
if (now - lastTrackedAt < throttleMs) return;
|
||||
throttledEvents.set(key, now);
|
||||
trackAnalyticsEvent(eventName, payload);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import OverseerControlPanel from './OverseerControlPanel.jsx';
|
||||
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const MODES = [
|
||||
{ key: 'open', label: 'Open' },
|
||||
@@ -161,23 +160,17 @@ export default function AdminPanelContent() {
|
||||
|
||||
const handleModeChange = async (event) => {
|
||||
const mode = event.target.value;
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'started' });
|
||||
try {
|
||||
await setMode(mode);
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_mode_change', { mode, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceControl = async (roverId) => {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'started' });
|
||||
try {
|
||||
await requestControl(roverId, { force: true });
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('admin_force_control', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
@@ -187,12 +180,9 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: rover.id, scope: 'admin' });
|
||||
try {
|
||||
await rebootRover(rover.id);
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: rover.id, scope: 'admin', status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -206,7 +196,6 @@ export default function AdminPanelContent() {
|
||||
);
|
||||
if (!ok) return;
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
|
||||
trackAnalyticsEvent('rover_update_click', { roverId: rover.id });
|
||||
try {
|
||||
// The rover acknowledges once the privileged self-update helper has been
|
||||
// launched, not when the full install finishes. That is deliberate: a
|
||||
@@ -214,9 +203,7 @@ export default function AdminPanelContent() {
|
||||
// the same websocket would make the button look failed even when the
|
||||
// update is doing exactly what it should.
|
||||
await updateRover(rover.id);
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_update_result', { roverId: rover.id, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setUpdateStates((prev) => ({ ...prev, [rover.id]: false }));
|
||||
@@ -232,22 +219,15 @@ export default function AdminPanelContent() {
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
trackAnalyticsEvent('rover_update_all_click', { roverCount });
|
||||
try {
|
||||
// The server owns the fan-out because it has the authoritative online
|
||||
// rover map and can enforce admin privileges once before issuing the
|
||||
// existing per-rover update command to every connected rover.
|
||||
const result = await updateAllRovers();
|
||||
trackAnalyticsEvent('rover_update_all_result', {
|
||||
status: 'accepted',
|
||||
updated: result?.updated?.length || 0,
|
||||
failed: result?.failed?.length || 0,
|
||||
});
|
||||
if (result?.failed?.length) {
|
||||
alert(`Update requested for ${result.updated?.length || 0} rover(s). ${result.failed.length} rover(s) failed to queue.`);
|
||||
}
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_update_all_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
@@ -256,12 +236,9 @@ export default function AdminPanelContent() {
|
||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||
if (!ok) return;
|
||||
setServerRebooting(true);
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'started' });
|
||||
try {
|
||||
await rebootServer();
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('server_reboot_click', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
setServerRebooting(false);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Barcode Games Panel
|
||||
// Purpose: Shows barcode game selection, current game status, and player points in the Activities tab.
|
||||
// Scope: Keeps the driver-side UI compact but informative; game-specific rules stay in server game modules.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
@@ -261,10 +260,8 @@ export default function BarcodeGamesPanel() {
|
||||
}
|
||||
|
||||
function BarcodeGamesPanelContent() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const { state, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
const participantCountRef = useRef(0);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
// Voting should stop once the shared game system has moved past selection.
|
||||
@@ -278,77 +275,10 @@ function BarcodeGamesPanelContent() {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const signature = `${activeGame?.id || 'none'}:${activeGame?.status || state.phase || 'unknown'}`;
|
||||
if (activeSignatureRef.current === signature) return;
|
||||
activeSignatureRef.current = signature;
|
||||
|
||||
/*
|
||||
Game lifecycle events are derived from server state instead of button
|
||||
clicks so they still report games started by another user or by a scanner
|
||||
workflow outside this panel.
|
||||
*/
|
||||
trackAnalyticsEvent('barcode_game_active_change', {
|
||||
gameId: activeGame?.id || '',
|
||||
status: activeGame?.status || '',
|
||||
phase: state.phase || 'unknown',
|
||||
});
|
||||
|
||||
if (activeGame?.status === 'ending' || state.phase === 'ending') {
|
||||
trackAnalyticsEvent('barcode_game_round_end', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}
|
||||
}, [activeGame?.id, activeGame?.status, participants.length, state.phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (participants.length <= participantCountRef.current) {
|
||||
participantCountRef.current = participants.length;
|
||||
return;
|
||||
}
|
||||
participantCountRef.current = participants.length;
|
||||
trackAnalyticsEvent('barcode_game_player_join', {
|
||||
gameId: activeGame?.id || '',
|
||||
participantCount: participants.length,
|
||||
});
|
||||
}, [activeGame?.id, participants.length]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetry
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function StatusRow({ value, tone = 'neutral' }) {
|
||||
@@ -129,14 +128,11 @@ export default function DriveDockAction({
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_return', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_return_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -149,14 +145,11 @@ export default function DriveDockAction({
|
||||
setConfirmOpen(false);
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
trackAnalyticsEvent('drive_start', { roverId, source: 'drive_dock_action' });
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('drive_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -167,12 +160,9 @@ export default function DriveDockAction({
|
||||
if (!roverId || pending) return;
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
setPending('dock');
|
||||
trackAnalyticsEvent('dock_assist_start', { roverId });
|
||||
try {
|
||||
dockAssist.enterAssist();
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('dock_assist_start_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
@@ -186,12 +176,10 @@ export default function DriveDockAction({
|
||||
if (isMobile) triggerTouchHaptic('button');
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
trackAnalyticsEvent('dock_assist_exit', { roverId });
|
||||
return;
|
||||
}
|
||||
setShowModal(true);
|
||||
setConfirmOpen(true);
|
||||
trackAnalyticsEvent('dock_assist_open', { roverId });
|
||||
};
|
||||
|
||||
// The drive/dock card appears inside the mobile controls and can be held or
|
||||
|
||||
@@ -9,7 +9,6 @@ import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
const CAMERA_TILT_STEP_DEGREES = 0.5;
|
||||
@@ -56,25 +55,22 @@ function AuxColumnContent() {
|
||||
const handleHeadlightToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!headlightAvailable) return;
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
[headlightAvailable, roverId, setHeadlight],
|
||||
[headlightAvailable, setHeadlight],
|
||||
);
|
||||
|
||||
const handleLaserToggle = useCallback(
|
||||
(nextOn) => {
|
||||
if (!laserAvailable) return;
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'mobile_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
[laserAvailable, roverId, setLaser],
|
||||
[laserAvailable, setLaser],
|
||||
);
|
||||
|
||||
const handleHornStart = useCallback(() => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'mobile_control' });
|
||||
return startHorn();
|
||||
}, [roverId, startHorn]);
|
||||
}, [startHorn]);
|
||||
|
||||
const handleAuxPress = useCallback(
|
||||
(id, values) => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -861,7 +860,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
export default function PtzQueueCard() {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
@@ -886,7 +885,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
trackAnalyticsEvent('ptz_queue_join', { layout });
|
||||
try {
|
||||
const response = await ptzClaim();
|
||||
/*
|
||||
@@ -897,13 +895,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
||||
navigate('/ptz');
|
||||
}
|
||||
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('ptz_queue_join_result', {
|
||||
layout,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message || 'PTZ request failed.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
|
||||
@@ -36,7 +36,6 @@ import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../themes/index.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||
const CHAT_DOCK_MIN_HEIGHT = 144;
|
||||
@@ -103,23 +102,20 @@ function DriveDockPanel() {
|
||||
const cameraTiltStep = camera?.precisionMode
|
||||
? CAMERA_TILT_PRECISION_STEP_DEGREES
|
||||
: CAMERA_TILT_STEP_DEGREES;
|
||||
const trackedControls = useMemo(
|
||||
const auxControls = useMemo(
|
||||
() => ({
|
||||
setHeadlight: (nextOn) => {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setHeadlight(nextOn);
|
||||
},
|
||||
setLaser: (nextOn) => {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId, source: 'desktop_control', enabled: Boolean(nextOn) });
|
||||
setLaser(nextOn);
|
||||
},
|
||||
startHorn: () => {
|
||||
trackAnalyticsEvent('horn_start', { roverId, source: 'desktop_control' });
|
||||
return startHorn();
|
||||
},
|
||||
stopHorn,
|
||||
}),
|
||||
[roverId, setHeadlight, setLaser, startHorn, stopHorn],
|
||||
[setHeadlight, setLaser, startHorn, stopHorn],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -139,7 +135,7 @@ function DriveDockPanel() {
|
||||
label="Headlight"
|
||||
on={headlightState?.headlightOn}
|
||||
disabled={!roverId}
|
||||
onToggle={trackedControls.setHeadlight}
|
||||
onToggle={auxControls.setHeadlight}
|
||||
keyLabel={headlightLabel}
|
||||
/>
|
||||
)}
|
||||
@@ -148,7 +144,7 @@ function DriveDockPanel() {
|
||||
label="Laser"
|
||||
on={laserState?.laserOn}
|
||||
disabled={!roverId || roomLightsLockedOn}
|
||||
onToggle={trackedControls.setLaser}
|
||||
onToggle={auxControls.setLaser}
|
||||
keyLabel={laserLabel}
|
||||
/>
|
||||
)}
|
||||
@@ -157,8 +153,8 @@ function DriveDockPanel() {
|
||||
{hornAvailable && (
|
||||
<HornControl
|
||||
disabled={!roverId || hornBlocked}
|
||||
onStart={trackedControls.startHorn}
|
||||
onStop={trackedControls.stopHorn}
|
||||
onStart={auxControls.startHorn}
|
||||
onStop={auxControls.stopHorn}
|
||||
keyLabel={hornLabel}
|
||||
active={horn?.active}
|
||||
heat={horn?.heat}
|
||||
@@ -259,14 +255,12 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
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.
|
||||
Keep the selected desktop panel controlled here so the tab strip and
|
||||
panel content always move together.
|
||||
*/
|
||||
setActiveTab(tab);
|
||||
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'desktop_right_pane' });
|
||||
},
|
||||
[layout],
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
import {
|
||||
RESTART_DELAY_MS,
|
||||
UNMUTE_RETRY_MS,
|
||||
@@ -439,20 +438,6 @@ export default function RoverMediaPlayer({
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
/*
|
||||
WHEP status changes can repeat during automatic restart attempts. The
|
||||
throttled event keeps media reliability visible in analytics without
|
||||
reporting every retry loop as a separate user action.
|
||||
*/
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: nextStatus,
|
||||
detail: info || '',
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:${nextStatus}:${info || ''}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
}
|
||||
};
|
||||
@@ -469,15 +454,6 @@ export default function RoverMediaPlayer({
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message);
|
||||
trackAnalyticsEventThrottled(
|
||||
'whep_player_error',
|
||||
{
|
||||
roverId: effectiveRoverId,
|
||||
status: 'start_failed',
|
||||
detail: err.message,
|
||||
},
|
||||
{ key: `${effectiveRoverId || 'unknown'}:start_failed:${err.message}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
|
||||
@@ -168,22 +168,13 @@ export default function RoverQueuesPanel({
|
||||
return;
|
||||
}
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
|
||||
trackAnalyticsEvent('rover_queue_join', {
|
||||
trackAnalyticsEvent('rover_selected', {
|
||||
roverId: targetRoverId,
|
||||
alreadyAssigned: assignedRoverId === String(targetRoverId),
|
||||
alreadySelected: assignedRoverId === String(targetRoverId),
|
||||
});
|
||||
try {
|
||||
await requestControl(targetRoverId);
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'accepted',
|
||||
});
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_queue_join_result', {
|
||||
roverId: targetRoverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
|
||||
@@ -198,18 +189,10 @@ export default function RoverQueuesPanel({
|
||||
const ok = window.confirm(`Reboot your rover "${assignedRoverName}" now?`);
|
||||
if (!ok) return;
|
||||
setRebootPending(true);
|
||||
trackAnalyticsEvent('rover_reboot_click', { roverId: assignedRoverId, scope: 'own_rover' });
|
||||
try {
|
||||
await rebootOwnRover();
|
||||
trackAnalyticsEvent('rover_reboot_result', { roverId: assignedRoverId, scope: 'own_rover', status: 'accepted' });
|
||||
alert('Reboot command sent.');
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('rover_reboot_result', {
|
||||
roverId: assignedRoverId,
|
||||
scope: 'own_rover',
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRebootPending(false);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Session Document Title
|
||||
// Purpose: Uses the configured local inter-instance profile name as the browser tab title.
|
||||
// Scope: Owns only the document title lifecycle; the static HTML title remains the fallback.
|
||||
import { useEffect } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
const DEFAULT_DOCUMENT_TITLE = document.title;
|
||||
|
||||
export default function SessionDocumentTitle() {
|
||||
const interInstanceEnabled = useSessionSelector((state) => Boolean(state.session?.interInstances?.enabled));
|
||||
const interInstanceName = useSessionSelector((state) =>
|
||||
String(state.session?.interInstances?.profile?.name || '').trim(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
The static title from index.html remains the source of truth until the
|
||||
server confirms that inter-instance sharing is enabled and supplies a
|
||||
usable profile name. This prevents the profile's loading state from
|
||||
replacing the familiar fallback title with an empty or temporary value.
|
||||
*/
|
||||
if (!interInstanceEnabled || !interInstanceName) return undefined;
|
||||
|
||||
document.title = interInstanceName;
|
||||
|
||||
/*
|
||||
Restore the static title when the synchronized profile disappears or the
|
||||
feature is disabled. React also runs this cleanup during development's
|
||||
StrictMode effect check, so the component never leaves a stale server name
|
||||
behind when its session-derived conditions stop being true.
|
||||
*/
|
||||
return () => {
|
||||
document.title = DEFAULT_DOCUMENT_TITLE;
|
||||
};
|
||||
}, [interInstanceEnabled, interInstanceName]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
import {
|
||||
DEFAULT_PAGE_THEME_KEY,
|
||||
PAGE_THEME_OPTIONS,
|
||||
@@ -196,7 +195,6 @@ export default function SettingsPanel() {
|
||||
const handleTransportChange = (event) => {
|
||||
const next = event.target.value;
|
||||
savePageSettings((current) => ({ ...(current ?? {}), connectionTransport: next }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'connection_transport', value: next });
|
||||
reconnectSocketWithTransport(socket, next);
|
||||
};
|
||||
|
||||
@@ -204,30 +202,21 @@ export default function SettingsPanel() {
|
||||
const raw = Number(event.target.value);
|
||||
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
||||
trackAnalyticsEventThrottled(
|
||||
'settings_change',
|
||||
{ setting: key, value: next },
|
||||
{ key: `audio:${key}`, throttleMs: 3 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const handleMainBrushDuckEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), mainBrushDuckEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'mainBrushDuckEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleSwapMobileControlColumns = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), swapMobileControlColumns: checked }));
|
||||
trackAnalyticsEvent('mobile_controls_swap', { enabled: checked });
|
||||
trackAnalyticsEvent('settings_change', { setting: 'swapMobileControlColumns', value: checked });
|
||||
};
|
||||
|
||||
const handleDriveMacroBackoffEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
savePageSettings((current) => ({ ...(current ?? {}), driveMacroBackoffEnabled: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'driveMacroBackoffEnabled', value: checked });
|
||||
};
|
||||
|
||||
const handleInterInstanceTransferSettings = (event) => {
|
||||
@@ -238,7 +227,6 @@ export default function SettingsPanel() {
|
||||
letting users opt out of sending their current settings cookie.
|
||||
*/
|
||||
savePageSettings((current) => ({ ...(current ?? {}), interInstanceTransferSettings: checked }));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
|
||||
};
|
||||
|
||||
const movePageThemePreview = (direction) => {
|
||||
@@ -261,10 +249,6 @@ export default function SettingsPanel() {
|
||||
...(current ?? {}),
|
||||
backgroundTheme: previewPageThemeKey,
|
||||
}));
|
||||
trackAnalyticsEvent('settings_change', {
|
||||
setting: 'backgroundTheme',
|
||||
value: previewPageThemeKey,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoFilterChange = (event) => {
|
||||
@@ -276,7 +260,6 @@ export default function SettingsPanel() {
|
||||
...(current ?? {}),
|
||||
colorFilter: nextFilter,
|
||||
}));
|
||||
trackAnalyticsEvent('settings_change', { setting: 'videoColorFilter', value: nextFilter });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,14 +40,11 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
||||
rel="noopener noreferrer"
|
||||
aria-label={text}
|
||||
onClick={() => {
|
||||
trackAnalyticsEvent('social_link_click', {
|
||||
id: id || '',
|
||||
trackAnalyticsEvent('social_link_clicked', {
|
||||
destination: id || 'custom',
|
||||
label: text,
|
||||
layout,
|
||||
});
|
||||
if (id === 'discord') {
|
||||
trackAnalyticsEvent('discord_click', { layout });
|
||||
}
|
||||
}}
|
||||
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const CONNECTED_FADE_DELAY_MS = 4000;
|
||||
|
||||
@@ -42,39 +41,23 @@ export default function SocketConnectionPill() {
|
||||
setLastReason('');
|
||||
show();
|
||||
scheduleFade();
|
||||
trackAnalyticsEvent('socket_connect', { connected: true });
|
||||
};
|
||||
|
||||
const onDisconnect = (reason) => {
|
||||
setConnected(false);
|
||||
setLastReason(typeof reason === 'string' ? reason : 'disconnected');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_disconnect',
|
||||
{ reason: typeof reason === 'string' ? reason : 'disconnected' },
|
||||
{ key: `disconnect:${reason || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onConnectError = (err) => {
|
||||
setConnected(false);
|
||||
setLastReason(err?.message || 'connect error');
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_connect_error',
|
||||
{ message: err?.message || 'connect error' },
|
||||
{ key: `connect_error:${err?.message || 'unknown'}`, throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
const onReconnectAttempt = () => {
|
||||
setConnected(false);
|
||||
show();
|
||||
trackAnalyticsEventThrottled(
|
||||
'socket_reconnect_attempt',
|
||||
{ connected: false },
|
||||
{ key: 'socket_reconnect_attempt', throttleMs: 30 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
/*
|
||||
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
||||
@@ -87,11 +86,6 @@ export default function VolumeSettingsCard() {
|
||||
setDraft(nextDraft);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
||||
trackAnalyticsEventThrottled(
|
||||
'settings_change',
|
||||
{ setting: key, value: next },
|
||||
{ key: `volume:${key}`, throttleMs: 3 * 1000 },
|
||||
);
|
||||
};
|
||||
|
||||
// Sliders would be misleading before the first session sync lands.
|
||||
|
||||
@@ -18,7 +18,6 @@ import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
import CardFrame from '../../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../../analytics/index.js';
|
||||
|
||||
export default function VipAudioUploadCard({
|
||||
ownRoverId = '',
|
||||
@@ -88,9 +87,8 @@ export default function VipAudioUploadCard({
|
||||
(nextMode) => {
|
||||
const mode = nextMode === 'clip' ? 'clip' : 'live';
|
||||
saveVipAudio((current) => ({ ...(current || {}), pttMode: mode }));
|
||||
trackAnalyticsEvent('vip_ptt_mode_change', { mode, roverId });
|
||||
},
|
||||
[roverId, saveVipAudio],
|
||||
[saveVipAudio],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
@@ -109,11 +107,6 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_play', {
|
||||
roverId,
|
||||
size: selectedUpload.size,
|
||||
mime: selectedUpload.type || '',
|
||||
});
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
|
||||
@@ -124,9 +117,7 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setMessage('Upload playback started.');
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_play_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -141,13 +132,10 @@ export default function VipAudioUploadCard({
|
||||
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
trackAnalyticsEvent('vip_upload_stop', { roverId });
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
setMessage('Upload playback stopped.');
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_upload_stop_result', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -378,24 +366,20 @@ export default function VipAudioUploadCard({
|
||||
|
||||
if (!send) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_record_cancel', { roverId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roverId) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_no_rover' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chunks.length) {
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'skipped_empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
setClipState('sending');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'started' });
|
||||
try {
|
||||
const merged = mergeFloatChunks(chunks);
|
||||
const wavBytes = encodeWavMono16(merged, clipSampleRateRef.current || TARGET_SAMPLE_RATE);
|
||||
@@ -407,10 +391,8 @@ export default function VipAudioUploadCard({
|
||||
dataBase64,
|
||||
});
|
||||
setClipState('idle');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'accepted' });
|
||||
} catch (err) {
|
||||
setClipState('error');
|
||||
trackAnalyticsEvent('vip_clip_send', { roverId, status: 'failed', reason: err?.message || 'unknown' });
|
||||
setMessage(err?.message || 'Failed to send PTT clip.');
|
||||
}
|
||||
},
|
||||
@@ -426,7 +408,6 @@ export default function VipAudioUploadCard({
|
||||
clipChunksRef.current = [];
|
||||
clipRecordingRef.current = true;
|
||||
setClipState('recording');
|
||||
trackAnalyticsEvent('vip_clip_record_start', { roverId });
|
||||
}, [ensureClipPipeline, roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -453,17 +434,11 @@ export default function VipAudioUploadCard({
|
||||
await startWhipMic(roverId);
|
||||
if (!cancelled) {
|
||||
setMicState('live');
|
||||
trackAnalyticsEvent('vip_live_mic_start', { roverId });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
||||
trackAnalyticsEventThrottled(
|
||||
'vip_live_mic_error',
|
||||
{ roverId, reason: err?.message || 'unknown' },
|
||||
{ key: `${roverId}:${err?.message || 'unknown'}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
await stopMicCapture(roverId);
|
||||
}
|
||||
@@ -651,7 +626,6 @@ export default function VipAudioUploadCard({
|
||||
onChange={(event) => {
|
||||
const enabled = Boolean(event.target.checked);
|
||||
saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: enabled }));
|
||||
trackAnalyticsEvent('vip_open_mic_change', { roverId, enabled });
|
||||
}}
|
||||
/>
|
||||
<span>{clipMode ? 'Open mic (live mode only)' : 'Open mic'}</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipPrivateRoverAccessCard({
|
||||
requestableRovers = [],
|
||||
@@ -30,24 +29,14 @@ export default function VipPrivateRoverAccessCard({
|
||||
if (!roverId) return;
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_private_rover_request', { roverId, status: 'started' });
|
||||
try {
|
||||
const response = await requestPrivateRoverAccess?.(roverId);
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: response?.existing ? 'existing' : 'accepted',
|
||||
});
|
||||
if (response?.existing) {
|
||||
onMessage?.('You already have a pending request for that rover.');
|
||||
} else {
|
||||
onMessage?.('Private rover access request sent to lockdown admins.');
|
||||
}
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_private_rover_request', {
|
||||
roverId,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
onMessage?.(err.message || 'Failed to send request.');
|
||||
} finally {
|
||||
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useState } from 'react';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function VipVerificationCard({
|
||||
pendingRequestId,
|
||||
@@ -26,7 +25,6 @@ export default function VipVerificationCard({
|
||||
setRequestKeyInput(currentStoredKey);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_flow_start', { hasNickname: Boolean(nickname) });
|
||||
};
|
||||
|
||||
const cancelRequestFlow = () => {
|
||||
@@ -46,10 +44,6 @@ export default function VipVerificationCard({
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
trackAnalyticsEvent('vip_verify_attempt', {
|
||||
hasNickname: Boolean(nickname),
|
||||
hasIdentityKey: Boolean(requestKeyInput),
|
||||
});
|
||||
try {
|
||||
const applied = await applyIdentityKey(requestKeyInput);
|
||||
await requestVerification?.();
|
||||
@@ -57,9 +51,7 @@ export default function VipVerificationCard({
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('Verification request sent to lockdown admins.');
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('vip_verify_result', { status: 'failed', reason: err?.message || 'unknown' });
|
||||
onMessage?.(err.message || 'Failed to submit request.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<!-- 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', typeof data === 'undefined' ? [name] : [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>
|
||||
@@ -235,8 +235,8 @@ export function ChatProvider({ children }) {
|
||||
} 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.
|
||||
centralized analytics session identity adds aggregate role and
|
||||
assignment context without attaching message text or nickname.
|
||||
*/
|
||||
trackAnalyticsEvent('chat_send', {
|
||||
hasTts: Boolean(tts),
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||
import { trackAnalyticsEvent } from '../analytics/index.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
|
||||
@@ -283,6 +284,13 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/record-intent' });
|
||||
}, []);
|
||||
|
||||
const driveEngagementRoverRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
/* A new assignment gets one meaningful movement conversion event. */
|
||||
driveEngagementRoverRef.current = null;
|
||||
}, [pipeline.roverId]);
|
||||
|
||||
const setDriveVector = useCallback(
|
||||
(vector, meta = {}) => {
|
||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||
@@ -302,6 +310,14 @@ export function ControlSystemProvider({ children }) {
|
||||
);
|
||||
}
|
||||
const computed = computeDifferentialSpeeds(vector, speedOptions);
|
||||
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
|
||||
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
|
||||
driveEngagementRoverRef.current = pipeline.roverId;
|
||||
trackAnalyticsEvent('drive_engaged', {
|
||||
roverId: pipeline.roverId,
|
||||
source: meta.source || 'unknown',
|
||||
});
|
||||
}
|
||||
dispatch({
|
||||
type: 'control/update-drive',
|
||||
payload: { ...computed, source: meta.source ?? null },
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -281,7 +280,6 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
@@ -290,21 +288,18 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.dockAssist.toggleAssist();
|
||||
} else if (!outputs.buttons.dockMacro) {
|
||||
handleButtonEdge('dockMacro', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.headlightToggle && handleButtonEdge('headlightToggle', true)) {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleHeadlight();
|
||||
} else if (!outputs.buttons.headlightToggle) {
|
||||
handleButtonEdge('headlightToggle', false);
|
||||
}
|
||||
|
||||
if (outputs.buttons.laserToggle && handleButtonEdge('laserToggle', true)) {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'gamepad' });
|
||||
latest.toggleLaser();
|
||||
} else if (!outputs.buttons.laserToggle) {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isPrecisionDriveActive,
|
||||
resolveKeyboardSpeeds,
|
||||
} from './driveIntent.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -445,18 +444,14 @@ export default function KeyboardInputManager() {
|
||||
|
||||
if (newlyPressed.length > 0) {
|
||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||
trackAnalyticsEvent('drive_start', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.dockAssist.exitAssist();
|
||||
latest.setMode('drive');
|
||||
latest.runMacro('drive-sequence');
|
||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||
trackAnalyticsEvent('dock_assist_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.dockAssist.toggleAssist();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
||||
trackAnalyticsEvent('headlight_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleHeadlight();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
||||
trackAnalyticsEvent('laser_toggle', { roverId: latest.roverId || '', source: 'keyboard' });
|
||||
latest.toggleLaser();
|
||||
} else if (newlyPressed.some((token) => latest.keymap.videoFilterCycle?.has(token))) {
|
||||
cycleVideoFilter();
|
||||
|
||||
@@ -19,9 +19,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const inFlightRef = useRef(false);
|
||||
const lastAckSocketRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
const fingerprintRef = useRef('');
|
||||
|
||||
const ready =
|
||||
@@ -31,16 +29,8 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
|
||||
|
||||
const clearRetry = useCallback(() => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendIdentify = useCallback(async () => {
|
||||
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
try {
|
||||
if (!fingerprintRef.current) {
|
||||
/*
|
||||
@@ -68,17 +58,14 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
||||
}
|
||||
lastAckSocketRef.current = socket.id;
|
||||
clearRetry();
|
||||
} catch {
|
||||
clearRetry();
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
sendIdentify();
|
||||
}, 2000);
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
/*
|
||||
The permanent heartbeat below is the retry mechanism. A failed or
|
||||
half-open request must not create separate timer state that can stop
|
||||
future identity sends or disappear during a socket transition.
|
||||
*/
|
||||
}
|
||||
}, [
|
||||
clearRetry,
|
||||
connected,
|
||||
cookieUserId,
|
||||
identifySession,
|
||||
@@ -87,7 +74,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
overseerEnabled,
|
||||
ready,
|
||||
saveIdentity,
|
||||
socket?.id,
|
||||
socket,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,13 +107,17 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
}, [sendIdentify, socket?.connected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !socket?.connected) return undefined;
|
||||
/*
|
||||
Keep this interval installed whenever persisted identity settings are
|
||||
ready, including while Socket.IO is reconnecting. Each tick checks the
|
||||
current connection before sending, so reconnects resume heartbeats without
|
||||
depending on an acknowledgement or another effect recreating the timer.
|
||||
*/
|
||||
if (!ready) return undefined;
|
||||
const timer = setInterval(() => {
|
||||
if (!socket?.connected) return;
|
||||
sendIdentify();
|
||||
}, 60000);
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
}, [ready, sendIdentify, socket?.connected]);
|
||||
|
||||
useEffect(() => () => clearRetry(), [clearRetry]);
|
||||
}, [ready, sendIdentify, socket]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover and room video consumers.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { trackAnalyticsEventThrottled } from '../analytics/index.js';
|
||||
|
||||
function normalizeEntry(entry) {
|
||||
if (!entry) return null;
|
||||
@@ -96,23 +95,6 @@ export function useVideoRequests(sourceList = [], options = {}) {
|
||||
: { roverId: entry.id };
|
||||
socket.emit('video:request', payload, (resp = {}) => {
|
||||
if (cancelled) return;
|
||||
if (resp?.error) {
|
||||
/*
|
||||
Video authorization/session failures are operational signals, but
|
||||
this hook can request several feeds at once and retry after socket
|
||||
reconnects. Throttle by feed key so one broken camera does not flood
|
||||
Umami while still showing which rover or room camera is affected.
|
||||
*/
|
||||
trackAnalyticsEventThrottled(
|
||||
'video_request_failed',
|
||||
{
|
||||
type: entry.type,
|
||||
sourceId: entry.id,
|
||||
reason: resp.error,
|
||||
},
|
||||
{ key: `${entry.type}:${entry.id}:${resp.error}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
setSources((prev) => ({ ...prev, [entry.key]: resp }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import DatabaseAdminApp from './database/DatabaseAdminApp.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
|
||||
// The reporting route includes the charting and CSV libraries. Loading that
|
||||
@@ -32,7 +31,6 @@ createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<SessionProvider>
|
||||
<SessionDocumentTitle />
|
||||
<TelemetryProvider>
|
||||
<SettingsProvider>
|
||||
<ChatProvider>
|
||||
|
||||
@@ -9,7 +9,6 @@ import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import useScannerSpeech from './useScannerSpeech.js';
|
||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||
|
||||
const EMPTY_SCANNER_STATE = {
|
||||
beepAllowed: false,
|
||||
@@ -141,9 +140,6 @@ export default function ScannerContent() {
|
||||
lastReceivedAt = Date.now();
|
||||
if (!scannerReadyTrackedRef.current) {
|
||||
scannerReadyTrackedRef.current = true;
|
||||
trackAnalyticsEvent('scanner_route_ready', {
|
||||
beepAllowed: Boolean(nextState?.beepAllowed),
|
||||
});
|
||||
}
|
||||
setScannerState({
|
||||
...EMPTY_SCANNER_STATE,
|
||||
@@ -158,9 +154,6 @@ export default function ScannerContent() {
|
||||
|
||||
function handleScanAudio(payload = null) {
|
||||
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
||||
trackAnalyticsEvent('barcode_scan_audio_play', {
|
||||
hasPayload: Boolean(payload && typeof payload === 'object'),
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToScannerState() {
|
||||
@@ -206,13 +199,6 @@ export default function ScannerContent() {
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS;
|
||||
if (isStale && lastReceivedAt) {
|
||||
trackAnalyticsEventThrottled(
|
||||
'scanner_state_stale',
|
||||
{ connected: Boolean(socket.connected) },
|
||||
{ key: `scanner_state_stale:${Boolean(socket.connected)}`, throttleMs: 60 * 1000 },
|
||||
);
|
||||
}
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
@@ -261,10 +247,6 @@ export default function ScannerContent() {
|
||||
// The page intentionally sends only raw scanner text. The server reloads
|
||||
// the barcode registry, resolves labels/types, applies access policy, and
|
||||
// broadcasts the display state back to every scanner page.
|
||||
trackAnalyticsEvent('barcode_scan_submit', {
|
||||
length: code.length,
|
||||
beepAllowed: scannerState.beepAllowed,
|
||||
});
|
||||
socket.emit('barcode:scan', { code }, () => {});
|
||||
inputRef.current.value = '';
|
||||
focusInput();
|
||||
|
||||
+1
-15
@@ -1,23 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->'
|
||||
|
||||
function analyticsTags() {
|
||||
return {
|
||||
name: 'analytics-tags',
|
||||
transformIndexHtml(html) {
|
||||
const tagsPath = new URL('./src/config/analytics.html', import.meta.url)
|
||||
const tags = fs.existsSync(tagsPath) ? fs.readFileSync(tagsPath, 'utf8').trim() : ''
|
||||
return html.replace(ANALYTICS_PLACEHOLDER, tags)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), analyticsTags()],
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Listen on every local interface during development so a phone or tablet
|
||||
// on the same LAN can load the touch UI using this machine's network IP.
|
||||
|
||||
Reference in New Issue
Block a user