import { IStorage } from "./storage"; import { User, InsertXpEvent } from "@shared/schema"; import { getLevelFromXP } from "@shared/gamification"; // Constants for XP Actions export const XP_RULES = { CREATE_TASK: 10, CREATE_SUBTASK: 5, UPDATE_TASK: 2, // Small amount for tweaking/updating COMPLETE_TASK: 50, COMPLETE_TASK_LATE: 20, // Reduced for overdue AI_ACTION: 5, // For using AI features LOGIN_STREAK: 100 }; export class GamificationService { private storage: IStorage; constructor(storage: IStorage) { this.storage = storage; } async awardXP(userId: string, source: string, amount?: number, details?: any): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> { const user = await this.storage.getUser(userId); if (!user) throw new Error("User not found"); const xpAmount = amount || this.getXPForSource(source); // FIX: The previous bug was likely in updateUserXP implementation in storage. // Let's check storage.ts. // DbStorage.updateUserXP does: .set({ xp: user.xp + xp }) where input is 'xp'. // So validation: // If I pass '10', DbStorage adds 10. // The MemStorage implementation was: user.xp += xp; // The issue: In the previous code: // const newTotalXP = (user.xp || 0) + xpAmount; // await this.storage.updateUserXP(userId, newTotalXP); // If user had 100 XP, xpAmount 50. newTotalXP = 150. // If storage.updateUserXP(150) adds 150 to 100, result is 250. // If storage.updateUserXP(150) sets it to 150, result is 150. // MEMORY storage ADDS. DB storage ADDS. // "set({ xp: user.xp + xp })" -> logic implies input is DELTA. // So passing 'newTotalXP' (150) as delta ADDS 150. Double counting! // CORRECTION: Pass ONLY the delta (xpAmount). await this.storage.updateUserXP(userId, xpAmount); // Fetch fresh user to get calculated new total const updatedUserRaw = await this.storage.getUser(userId); const currentXP = updatedUserRaw?.xp || 0; // Check for level up const oldLevel = getLevelFromXP(user.xp || 0); const newLevel = getLevelFromXP(currentXP); const levelUp = newLevel > oldLevel; // Log Event await this.storage.logXpEvent({ userId, amount: xpAmount, source, details // Log details }); return { user: updatedUserRaw!, levelUp, oldLevel, newLevel }; } private getXPForSource(source: string): number { switch (source) { case 'create_task': return XP_RULES.CREATE_TASK; case 'create_subtask': return XP_RULES.CREATE_SUBTASK; case 'update_task': return XP_RULES.UPDATE_TASK; case 'complete_task': return XP_RULES.COMPLETE_TASK; case 'complete_task_late': return XP_RULES.COMPLETE_TASK_LATE; case 'ai_action': return XP_RULES.AI_ACTION; case 'daily_streak': return XP_RULES.LOGIN_STREAK; default: return 0; } } async checkStreakBonuses(userId: string, currentStreak: number) { // Weekly Bonus (every 7 days) if (currentStreak > 0 && currentStreak % 7 === 0) { await this.awardXP(userId, 'weekly_streak_bonus', 300, { streak: currentStreak }); } // Monthly Bonus (every 30 days) if (currentStreak > 0 && currentStreak % 30 === 0) { await this.awardXP(userId, 'monthly_streak_bonus', 1000, { streak: currentStreak }); } } // Analytics Methods async getWeeklyAnalytics(userId: string) { // Return last 7 days details // In a real app we would use SQL aggregation. // For now, let's fetch events and aggregate in memory or rely on a new storage method if needed. // But better is to just fetch last 7 days events via storage.getXpEvents and process. const events = await this.storage.getXpEvents(userId); const now = new Date(); const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const last7Days = Array.from({ length: 7 }, (_, i) => { const d = new Date(); d.setDate(now.getDate() - (6 - i)); return d; }); // Map: Label -> XP const data = last7Days.map(date => { const dayEvents = events.filter(e => { if (!e.createdAt) return false; const d = new Date(e.createdAt); return d.getDate() === date.getDate() && d.getMonth() === date.getMonth(); }); const total = dayEvents.reduce((sum, e) => sum + e.amount, 0); return { labelKey: days[date.getDay()].toLowerCase(), // 'sun', 'mon', etc. for translation xp: total }; }); return data; // [ { labelKey: 'mon', xp: 50 }, ... ] } async getMonthlyAnalytics(userId: string) { // Return 4 weeks const events = await this.storage.getXpEvents(userId); // Group by ISO Week? Or just simplified chunks. // Let's do 4 previous weeks based on current date. // Helper to get week number const getWeek = (d: Date) => { const onejan = new Date(d.getFullYear(), 0, 1); const millis = d.getTime() - onejan.getTime(); return Math.ceil((((millis / 86400000) + onejan.getDay() + 1) / 7)); }; const currentWeek = getWeek(new Date()); const weeks = [currentWeek - 3, currentWeek - 2, currentWeek - 1, currentWeek]; const data = weeks.map(w => { const weekEvents = events.filter(e => { if (!e.createdAt) return false; const d = new Date(e.createdAt); return getWeek(d) === w && d.getFullYear() === new Date().getFullYear(); }); const total = weekEvents.reduce((sum, e) => sum + e.amount, 0); return { labelKey: w.toString(), xp: total }; }); return data; } async getYearlyAnalytics(userId: string) { const events = await this.storage.getXpEvents(userId); const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const data = months.map((m, index) => { const monthEvents = events.filter(e => { if (!e.createdAt) return false; const d = new Date(e.createdAt); return d.getMonth() === index && d.getFullYear() === new Date().getFullYear(); }); const total = monthEvents.reduce((sum, e) => sum + e.amount, 0); return { labelKey: m, xp: total }; }); return data; } }