Files
english/src/lib/gamification-service.ts
2026-04-12 23:12:29 +07:00

210 lines
6.4 KiB
TypeScript

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<number, number> = { 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<string, unknown>): 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<UserGamification | null> {
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<string, unknown>)
}
export async function fetchXuTransactions(userId: string, limit = 10): Promise<XuTransaction[]> {
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<string, unknown>) => ({
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<LeaderboardRow[]> {
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<AwardResult> {
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 }
}