mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
more (modular local offline not selling data) analytics!
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user