File size: 9,441 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
/**
 * 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 };
  });
}