fix(routine): resolove routine blocker logic bug and white screen
continuous-integration/drone/push Build is passing

feat(gamification): add streak bonuses, tooltip, and improved history details

fix(ep): resolve double counting ep bug

ui: update app icon and translations
This commit is contained in:
2025-12-15 23:17:59 +01:00
parent 9736864425
commit 7b79015ac2
17 changed files with 884 additions and 299 deletions
+120 -10
View File
@@ -20,33 +20,49 @@ export class GamificationService {
this.storage = storage;
}
async awardXP(userId: string, source: string, amount?: number, description?: string): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> {
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);
const newTotalXP = (user.xp || 0) + xpAmount;
// 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(newTotalXP);
const newLevel = getLevelFromXP(currentXP);
const levelUp = newLevel > oldLevel;
// Update User
await this.storage.updateUserXP(userId, newTotalXP);
// Log Event
await this.storage.logXpEvent({
userId,
amount: xpAmount,
source,
details // Log details
});
// If Level Up, we could log a special event or notification here?
const updatedUser = await this.storage.getUser(userId);
return {
user: updatedUser!,
user: updatedUserRaw!,
levelUp,
oldLevel,
newLevel
@@ -65,4 +81,98 @@ export class GamificationService {
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;
}
}