/** * Day 4 — local, device-only beta analytics. * * This is NOT production/paid analytics and does NOT talk to any backend or * external SDK. It records a small set of anonymous class-flow events in * localStorage so the builder can understand beta usage (starts, drop-off, * revision saves, feedback) during a 10-20 student SSLC beta. * * Privacy: no email, phone, name, school, location, full answer text, uploaded * content, or private chat content is ever stored. Everything stays on this * device. All storage access is wrapped in try/catch so analytics can never * break class rendering. */ export type BetaAnalyticsEventName = | "tuition_opened" | "subject_selected" | "class_started" | "class_step_reached" | "class_completed" | "revision_saved" | "revision_page_opened" | "video_plan_opened" | "malayalam_support_viewed" | "study_chat_handoff_clicked" | "feedback_opened" | "feedback_submitted" | "bug_report_submitted" | "confusing_answer_reported" | "wrong_answer_reported" | "beta_access_viewed" | "beta_access_granted" | "beta_access_denied" | "beta_access_cleared"; export const BETA_ANALYTICS_EVENT_LABELS: Record = { tuition_opened: "Tuition opened", subject_selected: "Subject selected", class_started: "Class started", class_step_reached: "Class step reached", class_completed: "Class completed", revision_saved: "Revision saved", revision_page_opened: "Revision opened", video_plan_opened: "Video plan opened", malayalam_support_viewed: "Malayalam support viewed", study_chat_handoff_clicked: "Study Chat opened", feedback_opened: "Feedback opened", feedback_submitted: "Feedback submitted", bug_report_submitted: "Bug report submitted", confusing_answer_reported: "Confusing answer reported", wrong_answer_reported: "Wrong answer reported", beta_access_viewed: "Beta access viewed", beta_access_granted: "Beta access granted", beta_access_denied: "Beta access denied", beta_access_cleared: "Beta access cleared", }; export function getBetaEventLabel(name: BetaAnalyticsEventName): string { return BETA_ANALYTICS_EVENT_LABELS[name]; } export type BetaAnalyticsEvent = { id: string; name: BetaAnalyticsEventName; createdAt: string; sessionId: string; route: string; subject?: string; chapterTitle?: string; missionTitle?: string; stepLabel?: string; stepIndex?: number; metadata?: Record; }; export type BetaAnalyticsInput = Omit & { route?: string; }; export type BetaAnalyticsSummary = { totalEvents: number; classStarts: number; classCompletions: number; revisionSaves: number; feedbackCount: number; bugReportCount: number; confusingReportCount: number; wrongReportCount: number; betaAccessViewed: number; betaAccessGranted: number; betaAccessDenied: number; betaAccessCleared: number; videoPlanOpens: number; malayalamSupportViews: number; stepReachCounts: Array<{ stepLabel: string; count: number }>; lastEventAt: string | null; }; export type BetaStepDropoff = { stepLabel: string; stepIndex: number; reached: number; dropoffFromPrevious: number; }; export const BETA_ANALYTICS_STORAGE_KEY = "docdoe:beta-analytics"; export const BETA_SESSION_STORAGE_KEY = "docdoe:beta-session-id"; /** Keep storage bounded; retain only the most recent events. */ const MAX_STORED_EVENTS = 500; function hasStorage(): boolean { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function randomId(): string { try { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID(); } catch { // fall through to the manual id below } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function currentRoute(): string { if (typeof window === "undefined") return ""; try { return window.location?.pathname ?? ""; } catch { return ""; } } /** Stable per-device beta session id (created once, then reused). */ export function getBetaSessionId(): string { if (!hasStorage()) return "ssr"; try { const existing = window.localStorage.getItem(BETA_SESSION_STORAGE_KEY); if (existing && existing.trim()) return existing; const created = randomId(); window.localStorage.setItem(BETA_SESSION_STORAGE_KEY, created); return created; } catch { return "unavailable"; } } function isEventLike(value: unknown): value is BetaAnalyticsEvent { if (!value || typeof value !== "object") return false; const event = value as Record; return typeof event.id === "string" && typeof event.name === "string" && typeof event.createdAt === "string"; } /** Load all stored events. Corrupt/absent storage yields an empty list, never throws. */ export function loadBetaAnalytics(): BetaAnalyticsEvent[] { if (!hasStorage()) return []; try { const raw = window.localStorage.getItem(BETA_ANALYTICS_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter(isEventLike); } catch { return []; } } function persistBetaAnalytics(events: BetaAnalyticsEvent[]): void { if (!hasStorage()) return; try { const bounded = events.slice(-MAX_STORED_EVENTS); window.localStorage.setItem(BETA_ANALYTICS_STORAGE_KEY, JSON.stringify(bounded)); } catch { // Analytics is best-effort; never surface storage failures to the class UI. } } /** * Record one event. Consecutive duplicate step-reached events in the same * session (same step index) are de-duped so navigation spam is not stored. * Returns the stored event, or null if it was skipped/unavailable. */ export function trackBetaEvent(input: BetaAnalyticsInput): BetaAnalyticsEvent | null { try { const events = loadBetaAnalytics(); const sessionId = getBetaSessionId(); const event: BetaAnalyticsEvent = { ...input, id: randomId(), createdAt: new Date().toISOString(), sessionId, route: input.route ?? currentRoute(), }; if (event.name === "class_step_reached") { const last = events[events.length - 1]; if ( last && last.name === "class_step_reached" && last.sessionId === sessionId && last.stepIndex === event.stepIndex && last.missionTitle === event.missionTitle ) { return null; } } const next = [...events, event]; persistBetaAnalytics(next); return event; } catch { return null; } } export function clearBetaAnalytics(): void { if (!hasStorage()) return; try { window.localStorage.removeItem(BETA_ANALYTICS_STORAGE_KEY); } catch { // ignore } } export function getBetaAnalyticsSummary(events: BetaAnalyticsEvent[] = loadBetaAnalytics()): BetaAnalyticsSummary { const count = (name: BetaAnalyticsEventName) => events.filter((event) => event.name === name).length; const stepReach = new Map(); for (const event of events) { if (event.name !== "class_step_reached") continue; const label = event.stepLabel ?? `Step ${event.stepIndex ?? "?"}`; stepReach.set(label, (stepReach.get(label) ?? 0) + 1); } return { totalEvents: events.length, classStarts: count("class_started"), classCompletions: count("class_completed"), revisionSaves: count("revision_saved"), feedbackCount: count("feedback_submitted"), bugReportCount: count("bug_report_submitted"), confusingReportCount: count("confusing_answer_reported"), wrongReportCount: count("wrong_answer_reported"), betaAccessViewed: count("beta_access_viewed"), betaAccessGranted: count("beta_access_granted"), betaAccessDenied: count("beta_access_denied"), betaAccessCleared: count("beta_access_cleared"), videoPlanOpens: count("video_plan_opened"), malayalamSupportViews: count("malayalam_support_viewed"), stepReachCounts: Array.from(stepReach.entries()).map(([stepLabel, c]) => ({ stepLabel, count: c })), lastEventAt: events.length > 0 ? events[events.length - 1].createdAt : null, }; } /** * Step drop-off ordered by first-seen step index, with how many students fell * off between each step (distinct sessions that reached step N but not N+1). */ export function getStepDropoffSummary(events: BetaAnalyticsEvent[] = loadBetaAnalytics()): BetaStepDropoff[] { const stepEvents = events.filter((event) => event.name === "class_step_reached" && typeof event.stepIndex === "number"); const byIndex = new Map }>(); for (const event of stepEvents) { const index = event.stepIndex as number; const entry = byIndex.get(index) ?? { label: event.stepLabel ?? `Step ${index}`, sessions: new Set() }; entry.sessions.add(event.sessionId); byIndex.set(index, entry); } const ordered = Array.from(byIndex.entries()).sort((a, b) => a[0] - b[0]); let previousReached: number | null = null; return ordered.map(([stepIndex, entry]) => { const reached = entry.sessions.size; const dropoffFromPrevious = previousReached === null ? 0 : Math.max(0, previousReached - reached); previousReached = reached; return { stepLabel: entry.label, stepIndex, reached, dropoffFromPrevious }; }); }