DocDoeAI / src /lib /beta /betaAnalytics.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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 };
});
}