import { supabase } from '@/lib/supabase' import type { UserGamification, XuTransaction, UserLevel } from '@/types' // XP awarded per action type export const XP_REWARDS = { test: 20, writing: 15 } as const // Xu awarded at streak milestones const STREAK_XU: Record = { 7: 20, 30: 50, 100: 100 } function calcLevel(xp: number): UserLevel { if (xp >= 10000) return 'master' if (xp >= 5000) return 'gold' if (xp >= 2000) return 'silver' if (xp >= 500) return 'bronze' return 'beginner' } function today(): string { return new Date().toISOString().split('T')[0] } function yesterday(): string { const d = new Date() d.setDate(d.getDate() - 1) return d.toISOString().split('T')[0] } export function getWeekStart(): string { const d = new Date() const day = d.getDay() const diff = day === 0 ? -6 : 1 - day d.setDate(d.getDate() + diff) return d.toISOString().split('T')[0] } // Map Supabase snake_case row → camelCase type function mapGamification(row: Record): UserGamification { return { userId: row.user_id as string, xp: row.xp as number, level: row.level as UserLevel, streak: row.streak as number, longestStreak: row.longest_streak as number, lastActive: (row.last_active as string) ?? null, xu: row.xu as number, freezeCount: row.freeze_count as number, createdAt: row.created_at as string, } } // ─── Fetch ──────────────────────────────────────────────────────────────────── export async function fetchGamification(userId: string): Promise { const { data, error } = await supabase .from('user_gamification') .select('*') .eq('user_id', userId) .single() if (error) { if (error.code === 'PGRST116') return null throw error } return mapGamification(data as Record) } export async function fetchXuTransactions(userId: string, limit = 10): Promise { const { data, error } = await supabase .from('xu_transactions') .select('*') .eq('user_id', userId) .order('created_at', { ascending: false }) .limit(limit) if (error) throw error return (data ?? []).map((row: Record) => ({ id: row.id as string, userId: row.user_id as string, type: row.type as XuTransaction['type'], amount: row.amount as number, balance: row.balance as number, description: (row.description as string) ?? null, createdAt: row.created_at as string, })) } export interface LeaderboardRow { userId: string displayName: string xpEarned: number rank: number } export async function fetchLeaderboard(): Promise { const weekStart = getWeekStart() // Step 1: fetch leaderboard rankings const { data: lbRows, error: lbErr } = await supabase .from('weekly_leaderboard') .select('user_id, xp_earned') .eq('week_start', weekStart) .order('xp_earned', { ascending: false }) .limit(20) if (lbErr) throw lbErr if (!lbRows?.length) return [] // Step 2: fetch display names from user_gamification const userIds = lbRows.map((r) => r.user_id) const { data: gamRows } = await supabase .from('user_gamification') .select('user_id, display_name') .in('user_id', userIds) const nameMap = new Map((gamRows ?? []).map((r) => [r.user_id, r.display_name ?? 'Người dùng'])) return lbRows.map((row, i) => ({ userId: row.user_id, displayName: nameMap.get(row.user_id) ?? 'Người dùng', xpEarned: row.xp_earned, rank: i + 1, })) } // ─── Award Activity ─────────────────────────────────────────────────────────── export interface AwardResult { xpGained: number xuGained: number newStreak: number levelUp: boolean } export async function awardActivity( userId: string, xpAmount: number, displayName: string, ): Promise { const todayStr = today() const current = await fetchGamification(userId) const prevXp = current?.xp ?? 0 const prevXu = current?.xu ?? 50 const prevStreak = current?.streak ?? 0 const lastActive = current?.lastActive ?? null // Streak logic const isNewDay = lastActive !== todayStr const isConsecutive = lastActive === yesterday() let newStreak: number if (!lastActive) newStreak = 1 else if (!isNewDay) newStreak = prevStreak else if (isConsecutive) newStreak = prevStreak + 1 else newStreak = 1 // streak broken // XP + level const newXp = prevXp + xpAmount const newLevel = calcLevel(newXp) const levelUp = newLevel !== calcLevel(prevXp) // Xu (daily goal + streak milestones, once per day) let xuGained = 0 const txRows: Array<{ user_id: string; type: string; amount: number; balance: number; description: string }> = [] if (isNewDay) { xuGained += 10 txRows.push({ user_id: userId, type: 'earn_daily', amount: 10, balance: 0, description: 'Hoàn thành mục tiêu ngày' }) } if (isNewDay && STREAK_XU[newStreak]) { const bonus = STREAK_XU[newStreak] xuGained += bonus txRows.push({ user_id: userId, type: 'earn_streak', amount: bonus, balance: 0, description: `Streak ${newStreak} ngày` }) } const newXu = prevXu + xuGained // Back-fill running balance in transaction rows txRows.forEach((t, i) => { t.balance = prevXu + txRows.slice(0, i + 1).reduce((s, r) => s + r.amount, 0) }) // Upsert gamification state await supabase.from('user_gamification').upsert({ user_id: userId, xp: newXp, level: newLevel, streak: newStreak, longest_streak: Math.max(current?.longestStreak ?? 0, newStreak), last_active: todayStr, xu: newXu, freeze_count: current?.freezeCount ?? 0, display_name: displayName, }, { onConflict: 'user_id' }) if (txRows.length > 0) { await supabase.from('xu_transactions').insert(txRows) } // Accumulate XP on weekly leaderboard const weekStart = getWeekStart() const { data: existing } = await supabase .from('weekly_leaderboard') .select('xp_earned') .eq('user_id', userId) .eq('week_start', weekStart) .single() await supabase.from('weekly_leaderboard').upsert({ user_id: userId, week_start: weekStart, xp_earned: (existing?.xp_earned ?? 0) + xpAmount, }, { onConflict: 'user_id,week_start' }) return { xpGained: xpAmount, xuGained, newStreak, levelUp } }