Förberedelse fas 2: testmiljö fix + analytics tracker i mobilappen

This commit is contained in:
Sven (AAMOS AI)
2026-08-06 16:46:30 +07:00
parent 13c6dcacd8
commit 03e30755dc
11 changed files with 251 additions and 11 deletions
+149
View File
@@ -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;
}