import { getActivityDateKey } from './activityDates'; export const AUTH_TOKEN_KEY = 'ryp_auth_token'; const LOCAL_AUTH_TOKEN_PREFIX = 'local-demo:'; const LOCAL_AUTH_USERS_KEY = 'ryp_local_users'; export type AuthUser = { id: string; email: string; displayName: string; photoURL?: string; createdAt: string; currentStreak: number; longestStreak: number; coins: number; }; export type CoinTransaction = { id: string; userId: string; amount: number; source: string; category: string; createdAt: string; }; export type ProgressLeaderboardEntry = { id: string; displayName: string; solvedCount: number; currentStreak: number; weeklySolved: number; coins: number; score: number; rank: number; isCurrentUser: boolean; codingStreak?: number; languageStats?: Record; }; export type UserProgressStats = { solvedQuestionIds: string[]; dailyActivity: Record; dailyActivityBreakdown?: Record>; solvedCount: number; activeDays: number; weeklySolved: number; leaderboard: ProgressLeaderboardEntry[]; codingLeaderboard: ProgressLeaderboardEntry[]; codingSolvedCount: number; }; type StoredLocalUser = AuthUser & { password: string; lastActiveAt?: string; streakTimeZone?: string; solvedQuestionIds?: string[]; dailyActivity?: Record; dailyActivityBreakdown?: Record>; languageStats?: Record; purchasedItemIds?: number[]; }; function apiUrl(path: string): string { const base = ( import.meta.env.VITE_API_BASE as string | undefined ) ?.trim() .replace(/\/$/, '') ?? ''; const p = path.startsWith('/') ? path : `/${path}`; return `${base}${p}`; } function clientTimeZone(): string | null { try { const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone?.trim(); return timeZone || null; } catch { return null; } } function isLocalDevOrigin() { if (typeof window === 'undefined') { return false; } const host = window.location.hostname; return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; } function shouldUseLocalAuthFallback(error: unknown) { return ( isLocalDevOrigin() && error instanceof Error && error.message.startsWith('Cannot reach server') ); } function loadLocalUsers(): StoredLocalUser[] { if (typeof window === 'undefined') { return []; } const raw = window.localStorage.getItem(LOCAL_AUTH_USERS_KEY); if (!raw) { return []; } try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveLocalUsers(users: StoredLocalUser[]) { if (typeof window === 'undefined') { return; } window.localStorage.setItem(LOCAL_AUTH_USERS_KEY, JSON.stringify(users)); } function normalizeEmail(email: string) { return email.trim().toLowerCase(); } function createLocalUserId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function createLocalToken(userId: string) { return `${LOCAL_AUTH_TOKEN_PREFIX}${userId}`; } function isLocalToken(token: string) { return token.startsWith(LOCAL_AUTH_TOKEN_PREFIX); } function toAuthUser(user: StoredLocalUser): AuthUser { const normalizedUser = normalizeLocalUser(user); const { password: _password, lastActiveAt: _lastActiveAt, streakTimeZone: _streakTimeZone, solvedQuestionIds: _solvedQuestionIds, dailyActivity: _dailyActivity, purchasedItemIds: _purchasedItemIds, ...safeUser } = normalizedUser; return { ...safeUser, currentStreak: visibleLocalCurrentStreak(normalizedUser), }; } function findLocalUserByToken(token: string) { if (!isLocalToken(token)) { return null; } const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); return loadLocalUsers().find((user) => user.id === userId) ?? null; } function registerLocalUser( email: string, password: string, displayName: string, ): { token: string; user: AuthUser } { const users = loadLocalUsers(); const normalizedEmail = normalizeEmail(email); if (users.some((user) => normalizeEmail(user.email) === normalizedEmail)) { throw new Error('An account with this email already exists.'); } const newUser: StoredLocalUser = { id: createLocalUserId(), email: normalizedEmail, password, displayName: displayName.trim(), createdAt: new Date().toISOString(), lastActiveAt: new Date().toISOString(), currentStreak: 1, longestStreak: 1, coins: 0, streakTimeZone: clientTimeZone() ?? 'UTC', solvedQuestionIds: [], dailyActivity: {}, purchasedItemIds: [], }; users.push(newUser); saveLocalUsers(users); return { token: createLocalToken(newUser.id), user: toAuthUser(newUser), }; } function loginLocalUser(email: string, password: string): { token: string; user: AuthUser } { const normalizedEmail = normalizeEmail(email); const users = loadLocalUsers(); const userIndex = users.findIndex( (candidate) => normalizeEmail(candidate.email) === normalizedEmail && candidate.password === password, ); if (userIndex === -1) { throw new Error('Invalid email or password.'); } const user = normalizeLocalUser(users[userIndex]); touchLocalStreak(user, new Date()); users[userIndex] = user; saveLocalUsers(users); return { token: createLocalToken(user.id), user: toAuthUser(user), }; } function updateLocalUser( token: string, data: { displayName?: string; oldPassword?: string; newPassword?: string }, ): { user: AuthUser } { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((candidate) => candidate.id === userId); if (userIndex === -1) { throw new Error('Not authenticated'); } const user = users[userIndex]; if (data.newPassword) { if (!data.oldPassword || data.oldPassword !== user.password) { throw new Error('Current password is incorrect.'); } user.password = data.newPassword; } if (data.displayName?.trim()) { user.displayName = data.displayName.trim(); } users[userIndex] = user; saveLocalUsers(users); return { user: toAuthUser(user) }; } function updateLocalCoins(token: string, amount: number): { user: AuthUser } { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((candidate) => candidate.id === userId); if (userIndex === -1) { throw new Error('Not authenticated'); } const user = users[userIndex]; user.coins = Math.max(0, (user.coins || 0) + amount); users[userIndex] = user; saveLocalUsers(users); return { user: toAuthUser(user) }; } function normalizeLocalSolvedQuestionIds(ids: string[] | undefined) { const seen = new Set(); const normalized: string[] = []; for (const rawId of ids ?? []) { const id = rawId.trim(); if (!id || seen.has(id)) { continue; } seen.add(id); normalized.push(id); } return normalized; } function normalizeLocalDailyActivity(activity: Record | undefined) { const normalized: Record = {}; for (const [key, count] of Object.entries(activity ?? {})) { if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) { continue; } normalized[key.trim()] = Math.floor(count); } return normalized; } function normalizeLocalUser(user: StoredLocalUser): StoredLocalUser { const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); const currentStreak = Math.max(0, Math.floor(user.currentStreak || 0)); const longestStreak = Math.max(currentStreak, Math.floor(user.longestStreak || 0)); const coins = Math.max(0, Math.floor(user.coins || 0)); return { ...user, currentStreak, longestStreak, coins, streakTimeZone: user.streakTimeZone || clientTimeZone() || 'UTC', solvedQuestionIds, dailyActivity, purchasedItemIds: Array.isArray(user.purchasedItemIds) ? user.purchasedItemIds : [], }; } function differenceInLocalDays(later: Date, earlier: Date) { const laterDate = new Date(later); const earlierDate = new Date(earlier); laterDate.setHours(0, 0, 0, 0); earlierDate.setHours(0, 0, 0, 0); return Math.round((laterDate.getTime() - earlierDate.getTime()) / (1000 * 60 * 60 * 24)); } function visibleLocalCurrentStreak(user: StoredLocalUser) { if (!user.lastActiveAt) { return 0; } const lastActiveAt = new Date(user.lastActiveAt); if (Number.isNaN(lastActiveAt.getTime())) { return 0; } const dayDelta = differenceInLocalDays(new Date(), lastActiveAt); return dayDelta <= 1 ? Math.max(0, Math.floor(user.currentStreak || 0)) : 0; } function weeklySolvedFromLocalActivity(activity: Record) { const normalizedActivity = normalizeLocalDailyActivity(activity); let total = 0; for (let offset = 0; offset < 7; offset += 1) { const currentDate = new Date(); currentDate.setDate(currentDate.getDate() - offset); total += normalizedActivity[getActivityDateKey(currentDate)] || 0; } return total; } function trimLocalLeaderboard(entries: ProgressLeaderboardEntry[], currentUserId: string, limit = 10) { if (entries.length <= limit) { return entries; } const currentIndex = entries.findIndex((entry) => entry.id === currentUserId); if (currentIndex === -1 || currentIndex < limit) { return entries.slice(0, limit); } return [...entries.slice(0, limit - 1), entries[currentIndex]]; } function weeklyCodingSolvedFromLocalActivity(breakdown: Record> | undefined) { if (!breakdown) return 0; let total = 0; for (let offset = 0; offset < 7; offset += 1) { const currentDate = new Date(); currentDate.setDate(currentDate.getDate() - offset); const dayKey = getActivityDateKey(currentDate); const dayBreakdown = breakdown[dayKey]; if (dayBreakdown && typeof dayBreakdown.coding === 'number') { total += dayBreakdown.coding; } } return total; } function codingCurrentStreakFromLocalActivity(breakdown: Record> | undefined) { if (!breakdown || Object.keys(breakdown).length === 0) return 0; let streak = 0; const now = new Date(); const todayKey = getActivityDateKey(now); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const yesterdayKey = getActivityDateKey(yesterday); const todayCoding = breakdown[todayKey]?.['coding'] || 0; const yesterdayCoding = breakdown[yesterdayKey]?.['coding'] || 0; if (todayCoding === 0 && yesterdayCoding === 0) return 0; let currentDay = new Date(now); if (todayCoding === 0) { currentDay.setDate(currentDay.getDate() - 1); } while (true) { const dayKey = getActivityDateKey(currentDay); const codingCount = breakdown[dayKey]?.['coding'] || 0; if (codingCount > 0) { streak++; currentDay.setDate(currentDay.getDate() - 1); } else { break; } } return streak; } function buildLocalCodingLeaderboard(users: StoredLocalUser[], currentUserId: string) { const leaderboard = users .map((candidate) => { const user = normalizeLocalUser(candidate); let codingSolvedCount = 0; for (const id of user.solvedQuestionIds || []) { if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) { codingSolvedCount++; } } const weeklySolved = weeklyCodingSolvedFromLocalActivity(user.dailyActivityBreakdown); const codingStreak = codingCurrentStreakFromLocalActivity(user.dailyActivityBreakdown); const currentStreak = visibleLocalCurrentStreak(user); const coins = Math.max(0, user.coins || 0); return { id: user.id, displayName: user.displayName.trim() || 'User', solvedCount: codingSolvedCount, currentStreak, codingStreak, weeklySolved, coins, score: codingSolvedCount * 100, rank: 0, languageStats: user.languageStats, isCurrentUser: user.id === currentUserId, } satisfies ProgressLeaderboardEntry; }) .sort((left, right) => { if (right.score !== left.score) return right.score - left.score; if (right.solvedCount !== left.solvedCount) return right.solvedCount - left.solvedCount; if (right.weeklySolved !== left.weeklySolved) return right.weeklySolved - left.weeklySolved; if (right.coins !== left.coins) return right.coins - left.coins; return left.displayName.localeCompare(right.displayName); }) .map((entry, index) => ({ ...entry, rank: index + 1, })); return leaderboard; } function buildLocalLeaderboard(users: StoredLocalUser[], currentUserId: string) { const leaderboard = users .map((candidate) => { const user = normalizeLocalUser(candidate); const solvedCount = user.solvedQuestionIds?.length || 0; const weeklySolved = weeklySolvedFromLocalActivity(user.dailyActivity || {}); const currentStreak = visibleLocalCurrentStreak(user); const coins = Math.max(0, user.coins || 0); return { id: user.id, displayName: user.displayName.trim() || 'User', solvedCount, currentStreak, weeklySolved, coins, score: solvedCount * 100 + weeklySolved * 15 + currentStreak * 30 + coins, rank: 0, isCurrentUser: user.id === currentUserId, } satisfies ProgressLeaderboardEntry; }) .sort((left, right) => { if (right.score !== left.score) { return right.score - left.score; } if (right.solvedCount !== left.solvedCount) { return right.solvedCount - left.solvedCount; } if (right.weeklySolved !== left.weeklySolved) { return right.weeklySolved - left.weeklySolved; } if (right.coins !== left.coins) { return right.coins - left.coins; } return left.displayName.localeCompare(right.displayName); }) .map((entry, index) => ({ ...entry, rank: index + 1, })); // Return full list — the dashboard panel limits to 3 in the UI; the full leaderboard page shows all. return leaderboard; } function buildLocalProgressStats(userId: string, users: StoredLocalUser[]): UserProgressStats { const user = users.map(normalizeLocalUser).find((candidate) => candidate.id === userId); if (!user) { throw new Error('Not authenticated'); } const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); const rawUser = users.find((candidate) => candidate.id === userId); const dailyActivityBreakdown: Record> = rawUser?.dailyActivityBreakdown ?? {}; let codingSolvedCount = 0; for (const id of solvedQuestionIds) { if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) { codingSolvedCount++; } } return { solvedQuestionIds, dailyActivity, dailyActivityBreakdown, solvedCount: solvedQuestionIds.length, activeDays: Object.values(dailyActivity).filter((count) => count > 0).length, weeklySolved: weeklySolvedFromLocalActivity(dailyActivity), leaderboard: buildLocalLeaderboard(users, userId), codingLeaderboard: buildLocalCodingLeaderboard(users, userId), codingSolvedCount, }; } function updateLocalStreakForSolve(user: StoredLocalUser, now: Date) { const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null; const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime())); if (!lastActiveIsValid) { user.currentStreak = 1; user.longestStreak = Math.max(user.longestStreak || 0, 1); user.lastActiveAt = now.toISOString(); return; } const dayDelta = differenceInLocalDays(now, lastActiveAt!); if (dayDelta <= 0) { return; } if (dayDelta === 1) { user.currentStreak = Math.max(0, user.currentStreak || 0) + 1; } else { user.currentStreak = 1; } user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak); user.lastActiveAt = now.toISOString(); } /** * Touch the streak on every login event (password login, session restore, Google OAuth). * Mirrors the backend nextStreakUpdate logic: * - No lastActiveAt → streak becomes 1. * - Same calendar day → no change (already counted). * - Consecutive day → streak increments by 1. * - Missed day(s) → streak resets to 1. * Always keeps longestStreak up to date. */ function touchLocalStreak(user: StoredLocalUser, now: Date) { const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null; const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime())); if (!lastActiveIsValid) { user.currentStreak = 1; user.longestStreak = Math.max(user.longestStreak || 0, 1); user.lastActiveAt = now.toISOString(); return; } const dayDelta = differenceInLocalDays(now, lastActiveAt!); if (dayDelta <= 0) { // Already counted for today — nothing to do. return; } if (dayDelta === 1) { user.currentStreak = Math.max(0, user.currentStreak || 0) + 1; } else { // Missed one or more days — start a fresh streak. user.currentStreak = 1; } user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak); user.lastActiveAt = now.toISOString(); } function mergeLocalProgress(token: string, data: { solvedQuestionIds: string[]; dailyActivity: Record }) { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((candidate) => candidate.id === userId); if (userIndex === -1) { throw new Error('Not authenticated'); } const user = normalizeLocalUser(users[userIndex]); const mergedSolvedQuestionIds = normalizeLocalSolvedQuestionIds([ ...(user.solvedQuestionIds || []), ...normalizeLocalSolvedQuestionIds(data.solvedQuestionIds), ]); const mergedDailyActivity = { ...normalizeLocalDailyActivity(user.dailyActivity), ...normalizeLocalDailyActivity(data.dailyActivity), }; user.solvedQuestionIds = mergedSolvedQuestionIds; user.dailyActivity = mergedDailyActivity; users[userIndex] = user; saveLocalUsers(users); return { user: toAuthUser(user), stats: buildLocalProgressStats(user.id, users), }; } function recordLocalSolvedQuestion( token: string, questionId: string, difficulty: string, language?: string, ) { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((candidate) => candidate.id === userId); if (userIndex === -1) { throw new Error('Not authenticated'); } const reward = getSolveReward(difficulty); if (reward == null) { throw new Error('Question difficulty is required.'); } const user = normalizeLocalUser(users[userIndex]); const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); const alreadySolved = solvedQuestionIds.includes(questionId); if (!alreadySolved) { const now = new Date(); solvedQuestionIds.push(questionId); const todayKey = getActivityDateKey(now); dailyActivity[todayKey] = (dailyActivity[todayKey] || 0) + 1; // Detect section from question ID prefix (mirrors backend logic) let section = 'coding'; if (questionId.startsWith('sd-')) { section = 'systemDesign'; } else if (questionId.startsWith('cs-dbms-')) { section = 'dbms'; } else if (questionId.startsWith('cs-os-')) { section = 'os'; } else if (questionId.startsWith('cs-cn-')) { section = 'cn'; } else if (questionId.startsWith('cs-')) { section = 'csFundamentals'; } else if (questionId.startsWith('apt-')) { section = 'aptitude'; } const breakdown = user.dailyActivityBreakdown ?? {}; if (!breakdown[todayKey]) { breakdown[todayKey] = {}; } breakdown[todayKey][section] = (breakdown[todayKey][section] || 0) + 1; user.dailyActivityBreakdown = breakdown; user.solvedQuestionIds = solvedQuestionIds; user.dailyActivity = dailyActivity; if (section === 'coding' && language) { const stats = user.languageStats ?? {}; const lang = language.toLowerCase().trim(); stats[lang] = (stats[lang] || 0) + 1; user.languageStats = stats; } updateLocalStreakForSolve(user, now); user.coins = Math.max(0, user.coins || 0) + reward; } users[userIndex] = user; saveLocalUsers(users); return { user: toAuthUser(user), stats: buildLocalProgressStats(user.id, users), }; } function spendLocalCoins(token: string, amount: number): { user: AuthUser; stats: UserProgressStats } { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((candidate) => candidate.id === userId); if (userIndex === -1) { throw new Error('Not authenticated'); } const user = normalizeLocalUser(users[userIndex]); if (amount <= 0) { throw new Error('Amount must be greater than 0.'); } if (user.coins < amount) { throw new Error('Not enough coins.'); } user.coins -= amount; users[userIndex] = user; saveLocalUsers(users); return { user: toAuthUser(user), stats: buildLocalProgressStats(user.id, users), }; } const REACHABILITY_HINT = 'Start the API with "npm run api". For a single-server launch, run "npm start" and open http://localhost:3000. For Vite development, run "npm run api" and "npm run dev", then open http://localhost:5173. If the UI is on another port, add VITE_API_BASE=http://localhost:3000 to .env and restart.'; export async function apiFetch(path: string, init?: RequestInit): Promise { const url = apiUrl(path); const headers = new Headers(init?.headers); const timeZone = clientTimeZone(); if (timeZone && !headers.has('X-Timezone')) { headers.set('X-Timezone', timeZone); } try { const response = await fetch(url, { ...init, headers, }); return response; } catch (error) { console.error('API fetch error:', { url, error }); throw new Error(`Cannot reach server (${url}). ${REACHABILITY_HINT}`); } } async function parseJson(res: Response) { const text = await res.text(); try { return text ? JSON.parse(text) : {}; } catch (error) { console.error('JSON parse error:', { text, error }); return { error: 'Invalid server response' }; } } export async function registerUser( email: string, password: string, displayName: string, ): Promise<{ token: string; user: AuthUser }> { try { const res = await apiFetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, displayName }), }); const data = await parseJson(res); if (!res.ok) { if (isLocalDevOrigin() && res.status >= 500) { return registerLocalUser(email, password, displayName); } throw new Error(data.error || 'Registration failed'); } return data as { token: string; user: AuthUser }; } catch (error) { if (shouldUseLocalAuthFallback(error)) { return registerLocalUser(email, password, displayName); } console.error('Registration error:', error); throw error; } } export async function loginUser( email: string, password: string, ): Promise<{ token: string; user: AuthUser }> { try { const res = await apiFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await parseJson(res); if (!res.ok) { if (isLocalDevOrigin() && res.status >= 500) { return loginLocalUser(email, password); } throw new Error(data.error || 'Login failed'); } return data as { token: string; user: AuthUser }; } catch (error) { if (shouldUseLocalAuthFallback(error)) { return loginLocalUser(email, password); } console.error('Login error:', error); throw error; } } export async function fetchSessionUser(token: string): Promise { if (isLocalToken(token)) { const users = loadLocalUsers(); const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); const userIndex = users.findIndex((u) => u.id === userId); if (userIndex === -1) return null; const user = normalizeLocalUser(users[userIndex]); touchLocalStreak(user, new Date()); users[userIndex] = user; saveLocalUsers(users); return toAuthUser(user); } try { const res = await apiFetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) return null; const data = await parseJson(res); return (data as { user: AuthUser }).user ?? null; } catch (error) { console.error('Fetch session user error:', error); return null; } } export async function updateProfile( token: string, data: { displayName?: string; oldPassword?: string; newPassword?: string }, ): Promise<{ user: AuthUser }> { if (isLocalToken(token)) { const result = updateLocalUser(token, data); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); } return result; } const res = await apiFetch('/api/auth/update', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to update profile'); } return resData as { user: AuthUser }; } export async function addCoins( token: string, amount: number, ): Promise<{ user: AuthUser }> { if (isLocalToken(token)) { const result = updateLocalCoins(token, amount); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); } return result; } const res = await apiFetch('/api/user/add-coins', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ amount }), }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to add coins'); } if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: resData.user })); } return resData as { user: AuthUser }; } function getSolveReward(difficulty: string) { const normalizedDifficulty = difficulty.trim().toLowerCase(); if (normalizedDifficulty === 'easy') { return 10; } if (normalizedDifficulty === 'medium') { return 25; } if (normalizedDifficulty === 'hard') { return 50; } return null; } export async function fetchUserProgress( token: string, ): Promise { if (isLocalToken(token)) { const localUser = findLocalUserByToken(token); if (!localUser) { throw new Error('Not authenticated'); } return buildLocalProgressStats(localUser.id, loadLocalUsers()); } const res = await apiFetch('/api/user/stats', { headers: { Authorization: `Bearer ${token}`, }, }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to load progress'); } const typed = resData as { user?: AuthUser; stats: UserProgressStats }; if (typed.user && typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); } return typed.stats; } export async function importUserProgress( token: string, data: { solvedQuestionIds: string[]; dailyActivity: Record }, ): Promise<{ user: AuthUser; stats: UserProgressStats }> { if (isLocalToken(token)) { const result = mergeLocalProgress(token, data); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); } return result; } const res = await apiFetch('/api/user/import-progress', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to import progress'); } const typed = resData as { user: AuthUser; stats: UserProgressStats }; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); } return typed; } export async function recordSolvedQuestion( token: string, data: { questionId: string; difficulty: string; language?: string }, ): Promise<{ user: AuthUser; stats: UserProgressStats }> { if (isLocalToken(token)) { const result = recordLocalSolvedQuestion(token, data.questionId, data.difficulty, data.language); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); } return result; } const res = await apiFetch('/api/user/solve-question', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to record solved question'); } const typed = resData as { user: AuthUser; stats: UserProgressStats }; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); } return typed; } export async function spendCoins( token: string, amount: number, ): Promise<{ user: AuthUser; stats: UserProgressStats }> { if (isLocalToken(token)) { const result = spendLocalCoins(token, amount); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); } return result; } const res = await apiFetch('/api/user/spend-coins', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ amount }), }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to spend coins'); } const typed = resData as { user: AuthUser; stats: UserProgressStats }; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); } return typed; } export async function fetchCoinHistory(token: string): Promise { if (isLocalToken(token)) { const localUser = findLocalUserByToken(token); if (!localUser || !localUser.coins) { return []; } return [ { id: 'legacy-balance', userId: localUser.id, amount: localUser.coins, source: 'Initial Balance', category: 'bonus', createdAt: localUser.createdAt || new Date().toISOString(), } ]; } const res = await apiFetch('/api/user/coin-history', { headers: { Authorization: `Bearer ${token}`, }, }); const resData = await parseJson(res); if (!res.ok) { throw new Error(resData.error || 'Failed to fetch coin history'); } return resData as CoinTransaction[]; }