210 lines
6.4 KiB
TypeScript
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 }
|
|
}
|