Förberedelse fas 2: testmiljö fix + analytics tracker i mobilappen
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
type AnalyticsEvent,
|
||||
type Tracker,
|
||||
createHttpSend,
|
||||
createTracker,
|
||||
} from "@app/analytics";
|
||||
import { API_BASE } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { BRAND } from "./brand";
|
||||
|
||||
function getAppVersion(): string {
|
||||
const v = Constants.expoConfig?.version;
|
||||
return typeof v === "string" ? v : "unknown";
|
||||
}
|
||||
|
||||
function getLocale(): string {
|
||||
const locales = Constants.expoConfig?.locales;
|
||||
const first = Array.isArray(locales) ? (locales[0] as unknown) : null;
|
||||
return typeof first === "string" ? first : "sv-SE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile analytics provider (spec §8.2).
|
||||
*
|
||||
* v1 limitation: the tracker keeps an in-memory queue only. Events are lost
|
||||
* when the app process is killed before the periodic flush. Persisting the
|
||||
* queue to AsyncStorage is left for a future iteration.
|
||||
*/
|
||||
|
||||
const ANALYTICS_ENABLED_KEY = `${BRAND.slug}:analytics-enabled`;
|
||||
const ANALYTICS_ANON_ID_KEY = `${BRAND.slug}:analytics-anon-id`;
|
||||
const ANALYTICS_SESSION_ID_KEY = `${BRAND.slug}:analytics-session-id`;
|
||||
|
||||
interface AnalyticsContextValue {
|
||||
track: (event: AnalyticsEvent) => void;
|
||||
flush: () => Promise<void>;
|
||||
enabled: boolean | null;
|
||||
setEnabled: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);
|
||||
|
||||
function generateUuid(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrCreateAnonymousId(): Promise<string> {
|
||||
let id = await AsyncStorage.getItem(ANALYTICS_ANON_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUuid();
|
||||
await AsyncStorage.setItem(ANALYTICS_ANON_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getOrCreateSessionId(): Promise<string> {
|
||||
let id = await AsyncStorage.getItem(ANALYTICS_SESSION_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUuid();
|
||||
await AsyncStorage.setItem(ANALYTICS_SESSION_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
|
||||
const accessToken = useAuth((s) => s.accessToken);
|
||||
const [enabled, setEnabledState] = useState<boolean | null>(null);
|
||||
const [anonymousId, setAnonymousId] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const trackerRef = useRef<Tracker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const [storedEnabled, anon, session] = await Promise.all([
|
||||
AsyncStorage.getItem(ANALYTICS_ENABLED_KEY),
|
||||
getOrCreateAnonymousId(),
|
||||
getOrCreateSessionId(),
|
||||
]);
|
||||
// Default to enabled until the user explicitly opts out (GDPR opt-out model).
|
||||
setEnabledState(storedEnabled === null ? true : storedEnabled === "true");
|
||||
setAnonymousId(anon);
|
||||
setSessionId(session);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const tracker = useMemo<Tracker | null>(() => {
|
||||
if (!anonymousId || !sessionId) return null;
|
||||
const send = createHttpSend({ apiBaseUrl: API_BASE, authToken: accessToken ?? undefined });
|
||||
return createTracker(
|
||||
{
|
||||
apiBaseUrl: API_BASE,
|
||||
anonymousId,
|
||||
sessionId,
|
||||
appVersion: getAppVersion(),
|
||||
platform: Platform.OS === "ios" ? "ios" : Platform.OS === "android" ? "android" : "web",
|
||||
locale: getLocale(),
|
||||
maxBatchSize: 20,
|
||||
flushIntervalMs: 10_000,
|
||||
},
|
||||
send,
|
||||
);
|
||||
}, [anonymousId, sessionId, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
trackerRef.current = tracker;
|
||||
return () => {
|
||||
tracker?.destroy();
|
||||
};
|
||||
}, [tracker]);
|
||||
|
||||
const setEnabled = async (next: boolean) => {
|
||||
await AsyncStorage.setItem(ANALYTICS_ENABLED_KEY, String(next));
|
||||
setEnabledState(next);
|
||||
if (next) {
|
||||
await trackerRef.current?.flush();
|
||||
}
|
||||
};
|
||||
|
||||
const track = (event: AnalyticsEvent) => {
|
||||
if (enabled !== true || !trackerRef.current) return;
|
||||
trackerRef.current.track(event);
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
await trackerRef.current?.flush();
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ track, flush, enabled, setEnabled }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[enabled],
|
||||
);
|
||||
|
||||
return <AnalyticsContext.Provider value={value}>{children}</AnalyticsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAnalytics(): AnalyticsContextValue {
|
||||
const ctx = useContext(AnalyticsContext);
|
||||
if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user