File size: 15,079 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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
import {
  getStudyProfileRemote,
  saveStudyProfileRemote,
  type StudyProfileRemote,
} from "@/lib/api/study-profile";
import {
  getLearningState,
  type LearningStateSummary,
} from "@/lib/api/learning-state";
import { hasLiveSession } from "@/lib/api/mode";
import { getAuthToken, getStoredUser } from "@/lib/auth/auth-client";
import { readLocalTuitionSetup } from "@/components/tuition/tuitionSetupPersistence";

// =============================================================================
// Single source of truth for "who is this student and what are they studying".
//
// The app previously had three competing truths β€” the backend study profile,
// the localStorage onboarding payload, and the tuition setup β€” and pages
// disagreed about whether onboarding was complete (Home keyed on
// `study_focus.subject`, which onboarding never collects). Every consumer
// (Home focus card, StudyChat session context, Progress, Tuition entry) should
// read THIS module instead of the raw stores.
//
// Field-mapping note (legacy, do not "fix" silently): the onboarding flow
// stores dailyTime in the profile's `primary_need` column and preferredTime in
// `language_preference`. That mapping is quarantined here so consumers only
// ever see `dailyTime` / `preferredTime`.
// =============================================================================

export const ONBOARDING_COMPLETED_KEY = "docdoe-onboarding-completed";
export const ONBOARDING_PROFILE_KEY = "docdoe-onboarding-profile";
/**
 * Which account the device-local onboarding cache belongs to. Without this
 * tag, a shared computer leaks one student's identity into the next: the old
 * local profile renders in the header AND `repairRemoteProfile` pushes it up
 * into the newly signed-in account's backend profile.
 */
export const ONBOARDING_OWNER_KEY = "docdoe-onboarding-owner";

function currentUserId(): string | null {
  const stored = getStoredUser();
  if (stored?.id) return stored.id;
  const token = getAuthToken();
  if (!token) return null;
  try {
    const payload = JSON.parse(
      atob((token.split(".")[1] ?? "").replace(/-/g, "+").replace(/_/g, "/")),
    ) as { sub?: unknown };
    return typeof payload.sub === "string" ? payload.sub : null;
  } catch {
    return null;
  }
}

/**
 * Tag the device-local onboarding cache as belonging to the signed-in
 * account. Call after writing ONBOARDING_PROFILE_KEY on behalf of a user.
 */
export function claimLocalOnboardingOwnership(): void {
  if (typeof window === "undefined") return;
  const uid = currentUserId();
  if (uid) window.localStorage.setItem(ONBOARDING_OWNER_KEY, uid);
}

/**
 * True when the device-local onboarding cache is safe to use for the current
 * viewer. Logged out: any local data is the device user's own. Signed in: the
 * cache must be tagged with this exact account β€” an untagged or foreign cache
 * is another student's data and must be ignored (and never pushed remote).
 */
function localOnboardingUsable(): boolean {
  if (typeof window === "undefined") return false;
  const uid = currentUserId();
  if (!uid) return true;
  return window.localStorage.getItem(ONBOARDING_OWNER_KEY) === uid;
}

export type StudentContext = {
  /** Where the strongest signal came from. */
  source: "remote" | "local" | "none";
  /** True when the backend has a saved profile (survives device changes). */
  synced: boolean;
  onboardingComplete: boolean;
  classLevel: string | null;
  board: string | null;
  goal: string | null;
  examDate: string | null;
  dailyTime: string | null;
  preferredTime: string | null;
  subjects: string[];
  languagePreference: string | null;
  learningStyle: string | null;
  /** Active subject β€” from the tuition setup or the saved profile. */
  subject: string | null;
  /** Onboarding "what should DocDoe focus on" picks. Not evidence-based weak topics. */
  focusAreas: string[];
};

export const EMPTY_STUDENT_CONTEXT: StudentContext = {
  source: "none",
  synced: false,
  onboardingComplete: false,
  classLevel: null,
  board: null,
  goal: null,
  examDate: null,
  dailyTime: null,
  preferredTime: null,
  subjects: [],
  languagePreference: null,
  learningStyle: null,
  subject: null,
  focusAreas: [],
};

type LocalOnboarding = {
  goal?: string;
  class?: string;
  board?: string;
  examDate?: string;
  dailyTime?: string;
  preferredTime?: string;
  subjects?: string[];
  languagePreference?: string;
  learningStyle?: string;
  focus?: string[];
};

function readLocalOnboarding(): {
  completed: boolean;
  data: LocalOnboarding | null;
} {
  if (typeof window === "undefined") return { completed: false, data: null };
  const completed =
    window.localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true";
  try {
    const raw = window.localStorage.getItem(ONBOARDING_PROFILE_KEY);
    return {
      completed,
      data: raw ? (JSON.parse(raw) as LocalOnboarding) : null,
    };
  } catch {
    return { completed, data: null };
  }
}

function localSubject(): string | null {
  const setup = readLocalTuitionSetup();
  return setup.activeSubject || setup.subjects[0] || null;
}

/**
 * Synchronous context from local stores only β€” safe for first paint.
 * `synced` is always false here; call loadStudentContext() for the real merge.
 */
export function getLocalStudentContext(): StudentContext {
  if (!localOnboardingUsable()) return EMPTY_STUDENT_CONTEXT;
  const { completed, data } = readLocalOnboarding();
  if (!completed && !data) return EMPTY_STUDENT_CONTEXT;
  return {
    source: "local",
    synced: false,
    onboardingComplete: completed,
    classLevel: data?.class || null,
    board: data?.board || null,
    goal: data?.goal || null,
    examDate: data?.examDate || null,
    dailyTime: data?.dailyTime || null,
    preferredTime: data?.preferredTime || null,
    subject: data?.subjects?.[0] || localSubject(),
    subjects: data?.subjects?.filter(Boolean) ?? [],
    languagePreference: data?.languagePreference || null,
    learningStyle: data?.learningStyle || null,
    focusAreas: data?.focus ?? [],
  };
}

function extraString(
  extra: Record<string, unknown>,
  key: string,
): string | null {
  return typeof extra[key] === "string" && extra[key]
    ? String(extra[key])
    : null;
}

function extraStrings(extra: Record<string, unknown>, key: string): string[] {
  return Array.isArray(extra[key])
    ? (extra[key] as unknown[]).filter(
        (value): value is string => typeof value === "string" && Boolean(value),
      )
    : [];
}

function contextFromRemote(
  profile: StudyProfileRemote,
  local: StudentContext,
): StudentContext {
  const extra = profile.extra ?? {};
  const subjects = extraStrings(extra, "subjects");
  const focusAreas = extraStrings(extra, "focus_areas");
  return {
    source: "remote",
    synced: true,
    // Backend truth wins; a locally-completed onboarding still counts.
    onboardingComplete:
      profile.onboarding_completed || local.onboardingComplete,
    classLevel: profile.grade ?? local.classLevel,
    board: profile.board ?? local.board,
    goal: profile.goal ?? local.goal,
    examDate: profile.time_left ?? local.examDate,
    dailyTime:
      extraString(extra, "daily_time") ??
      profile.primary_need ??
      local.dailyTime,
    preferredTime:
      extraString(extra, "preferred_time") ??
      local.preferredTime ??
      profile.language_preference,
    subject: profile.subject ?? subjects[0] ?? local.subject,
    subjects: subjects.length
      ? subjects
      : profile.subject
        ? [profile.subject]
        : local.subjects,
    languagePreference:
      profile.language_preference ??
      extraString(extra, "language_preference") ??
      local.languagePreference,
    learningStyle: extraString(extra, "learning_style") ?? local.learningStyle,
    focusAreas: focusAreas.length
      ? focusAreas
      : profile.weak_areas?.length
        ? profile.weak_areas
        : local.focusAreas,
  };
}

function dailyTimeFromMinutes(minutes: number): string | null {
  if (!Number.isFinite(minutes) || minutes <= 0) return null;
  return minutes === 120 ? "2 hours" : `${minutes} min`;
}

/**
 * Phase 4 learning state owns the active planning profile. The older study
 * profile still carries a few optional preferences, so it remains a fallback
 * rather than a competing completion signal.
 */
function contextFromLearningState(
  state: LearningStateSummary,
  fallback: StudentContext,
): StudentContext {
  const profile = state.profile;
  if (!profile) return fallback;
  const subjects = state.subjects.map((item) => item.name).filter(Boolean);
  const currentSubject =
    state.subjects.find((item) => item.id === profile.current_subject_id)?.name ??
    subjects[0] ??
    fallback.subject;
  return {
    ...fallback,
    source: "remote",
    synced: true,
    onboardingComplete:
      profile.onboarding_completed || fallback.onboardingComplete,
    classLevel: profile.class_level || fallback.classLevel,
    board: profile.board || fallback.board,
    goal: profile.goal || fallback.goal,
    examDate: profile.exam_date ?? fallback.examDate,
    dailyTime:
      dailyTimeFromMinutes(profile.daily_minutes) ?? fallback.dailyTime,
    preferredTime: profile.preferred_time ?? fallback.preferredTime,
    subjects: subjects.length ? subjects : fallback.subjects,
    subject: currentSubject,
  };
}

function cacheCompletedContext(context: StudentContext): void {
  if (typeof window === "undefined" || !context.onboardingComplete) return;
  window.localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
  claimLocalOnboardingOwnership();
  window.localStorage.setItem(
    ONBOARDING_PROFILE_KEY,
    JSON.stringify({
      goal: context.goal ?? "",
      class: context.classLevel ?? "",
      board: context.board ?? "",
      examDate: context.examDate ?? "",
      dailyTime: context.dailyTime ?? "",
      preferredTime: context.preferredTime ?? "",
      subjects: context.subjects,
      languagePreference: context.languagePreference ?? "",
      learningStyle: context.learningStyle ?? "",
      focus: context.focusAreas,
    } satisfies LocalOnboarding),
  );
}

/**
 * The full merge: backend profile (when signed in) over local onboarding.
 *
 * Self-heals the split-truth bug: if onboarding was completed on this device
 * but the backend has no (or an incomplete) profile β€” e.g. the account
 * predates the write-through, or the original save failed β€” the local profile
 * is pushed up once, fire-and-forget, so every other device converges.
 */
// Short-lived cache so navigating between pages doesn't refetch the profile
// on every mount β€” the context changes rarely (onboarding, settings), and a
// snappy page-to-page feel matters more than 30s-stale sync metadata.
const CONTEXT_CACHE_MS = 30_000;
let contextCache: { at: number; userId: string | null; value: StudentContext } | null = null;

export async function loadStudentContext(): Promise<StudentContext> {
  const local = getLocalStudentContext();
  if (!hasLiveSession()) return local;

  const viewerId = currentUserId();
  if (
    contextCache &&
    contextCache.userId === viewerId &&
    Date.now() - contextCache.at < CONTEXT_CACHE_MS
  ) {
    return contextCache.value;
  }

  const merged = await loadStudentContextUncached(local);
  contextCache = { at: Date.now(), userId: viewerId, value: merged };
  return merged;
}

/** Drop the cache after anything that changes the profile (e.g. onboarding). */
export function invalidateStudentContext(): void {
  contextCache = null;
  remoteOnboardingCheck = null;
}

async function loadStudentContextUncached(
  local: StudentContext,
): Promise<StudentContext> {
  const [remote, learningState] = await Promise.all([
    getStudyProfileRemote().catch(() => null),
    getLearningState().catch(() => null),
  ]);
  const legacyContext = remote ? contextFromRemote(remote, local) : local;
  const merged = learningState
    ? contextFromLearningState(learningState, legacyContext)
    : legacyContext;

  if (remote || learningState?.profile) {
    cacheCompletedContext(merged);
    if (merged.onboardingComplete && !remote?.onboarding_completed) {
      void repairRemoteProfile(merged);
    }
    return merged;
  }

  // Signed in but no backend profile: repair from local when we can.
  if (local.onboardingComplete) {
    const saved = await repairRemoteProfile(local);
    return { ...local, synced: saved };
  }
  return local;
}

async function repairRemoteProfile(local: StudentContext): Promise<boolean> {
  return saveStudyProfileRemote({
    goal: local.goal,
    grade: local.classLevel,
    board: local.board,
    time_left: local.examDate,
    primary_need: local.dailyTime,
    language_preference: local.languagePreference,
    subject: local.subject,
    weak_areas: [],
    onboarding_completed: true,
    extra: {
      schema_version: 2,
      subjects: local.subjects,
      daily_time: local.dailyTime,
      preferred_time: local.preferredTime,
      language_preference: local.languagePreference,
      learning_style: local.learningStyle,
      focus_areas: local.focusAreas,
    },
  });
}

/** One-line summary for focus cards, e.g. "SSLC / 10th Β· Kerala State Board". */
export function studentContextHeadline(ctx: StudentContext): string | null {
  const parts = [ctx.classLevel, ctx.board].filter(Boolean);
  return parts.length ? parts.join(" Β· ") : null;
}

// ── Onboarding gate ──────────────────────────────────────────────────────────
// The route guard used to trust localStorage alone, so a signed-in student on
// a fresh device (backend profile complete, local flag absent) was forced back
// through onboarding. This checks the backend once per page load and backfills
// the local cache so the fast path works next time.

let remoteOnboardingCheck: {
  userId: string | null;
  promise: Promise<boolean>;
} | null = null;

export function isOnboardingCompleteEffective(): Promise<boolean> {
  if (
    typeof window !== "undefined" &&
    window.localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true" &&
    localOnboardingUsable()
  ) {
    return Promise.resolve(true);
  }
  if (!hasLiveSession()) return Promise.resolve(false);
  const viewerId = currentUserId();
  if (!remoteOnboardingCheck || remoteOnboardingCheck.userId !== viewerId) {
    const promise = Promise.all([
      getStudyProfileRemote().catch(() => null),
      getLearningState().catch(() => null),
    ]).then(([legacyProfile, learningState]) => {
      const local = getLocalStudentContext();
      const legacyContext = legacyProfile
        ? contextFromRemote(legacyProfile, local)
        : local;
      const merged = learningState
        ? contextFromLearningState(learningState, legacyContext)
        : legacyContext;
      cacheCompletedContext(merged);
      return merged.onboardingComplete;
    });
    remoteOnboardingCheck = { userId: viewerId, promise };
  }
  return remoteOnboardingCheck.promise;
}