Download src/lib/beta/betaAnalytics.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 9.44 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/beta/betaAnalytics.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/beta/betaAnalytics.ts
-
curl -L -o betaAnalytics.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/beta/betaAnalytics.ts
9.44 kB
| /** | |
| * 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<BetaAnalyticsEventName, string> = { | |
| 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<string, string | number | boolean | null>; | |
| }; | |
| export type BetaAnalyticsInput = Omit<BetaAnalyticsEvent, "id" | "createdAt" | "sessionId" | "route"> & { | |
| 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<string, unknown>; | |
| 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<string, number>(); | |
| 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<number, { label: string; sessions: Set<string> }>(); | |
| for (const event of stepEvents) { | |
| const index = event.stepIndex as number; | |
| const entry = byIndex.get(index) ?? { label: event.stepLabel ?? `Step ${index}`, sessions: new Set<string>() }; | |
| 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 }; | |
| }); | |
| } | |