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