update real data
This commit is contained in:
209
src/lib/gamification-service.ts
Normal file
209
src/lib/gamification-service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user