fix(routine): resolove routine blocker logic bug and white screen
continuous-integration/drone/push Build is passing
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:
+46
-2
@@ -151,8 +151,52 @@ export function setupAuth(app: Express) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/user", (req, res) => {
|
||||
app.get("/api/user", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
res.json(req.user);
|
||||
|
||||
// Check for Daily Streak
|
||||
const user = req.user as User;
|
||||
const now = new Date();
|
||||
const lastActive = user.lastActive ? new Date(user.lastActive) : new Date(0);
|
||||
|
||||
// Normalize to dates (ignore time)
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const lastDate = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// If last active was yesterday, increment streak
|
||||
// If last active was today, do nothing
|
||||
// If last active was before yesterday, reset streak (unless we decide to be lenient)
|
||||
|
||||
// We need GamificationService here
|
||||
const { GamificationService } = await import("./gamification");
|
||||
const gamificationService = new GamificationService(storage);
|
||||
|
||||
if (lastDate.getTime() < today.getTime()) {
|
||||
if (lastDate.getTime() === yesterday.getTime()) {
|
||||
// Perfect streak
|
||||
await gamificationService.awardXP(user.id, 'daily_streak');
|
||||
// Check bonuses
|
||||
const updatedUser = await storage.getUser(user.id);
|
||||
if (updatedUser) {
|
||||
await gamificationService.checkStreakBonuses(user.id, updatedUser.currentStreak);
|
||||
}
|
||||
} else if (lastDate.getTime() < yesterday.getTime()) {
|
||||
// Streak broken
|
||||
// Reset streak to 1 (today is day 1)
|
||||
await storage.updateUser(user.id, { currentStreak: 1 });
|
||||
// Still award daily XP for today? Yes.
|
||||
await gamificationService.awardXP(user.id, 'daily_streak');
|
||||
} else {
|
||||
// Should not happen if < today
|
||||
}
|
||||
// Update lastActive
|
||||
await storage.updateUser(user.id, { lastActive: now });
|
||||
}
|
||||
|
||||
// Re-fetch user to get latest XP and Streak
|
||||
const freshUser = await storage.getUser(user.id);
|
||||
res.json(freshUser);
|
||||
});
|
||||
}
|
||||
|
||||
+120
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-47
@@ -984,7 +984,7 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
// Award XP for creating a task
|
||||
if (req.user) {
|
||||
const source = req.body.parentTaskId ? 'create_subtask' : 'create_task';
|
||||
await gamificationService.awardXP((req.user as User).id, source);
|
||||
await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: task.id, taskTitle: task.title });
|
||||
}
|
||||
|
||||
await storage.createAuditLog({
|
||||
@@ -1017,10 +1017,10 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
if (updates.data.status === 'done' && previousTask.status !== 'done') {
|
||||
const isLate = previousTask.dueDate && new Date(previousTask.dueDate) < new Date();
|
||||
const source = isLate ? 'complete_task_late' : 'complete_task';
|
||||
await gamificationService.awardXP((req.user as User).id, source);
|
||||
await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: previousTask.id, taskTitle: previousTask.title });
|
||||
} else if (Object.keys(updates.data).length > 0) { // Only award if there are actual updates
|
||||
// Small points for any other update (title, description, etc)
|
||||
await gamificationService.awardXP((req.user as User).id, 'update_task');
|
||||
await gamificationService.awardXP((req.user as User).id, 'update_task', undefined, { taskId: previousTask.id, taskTitle: previousTask.title });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,56 +1131,33 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
|
||||
// Analytics API
|
||||
app.get("/api/analytics/weekly", async (req, res) => {
|
||||
// Return last 7 days. Key is 0-6 (Sun-Sat) or ISO date.
|
||||
// For simplicity, let's return day index relative to today or just standard day index (0=Sun)
|
||||
// To make it look "last 7 days" we can return relative indices
|
||||
const keys = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
// Better: Send localizable keys.
|
||||
// Day format: "day_1" (Mon) ... "day_7" (Sun) or just short codes the frontend can map
|
||||
|
||||
// We will send standard JS Day indices adjusted: 1 (Mon) - 7 (Sun) for "ISO Week" style or just 0-6
|
||||
// Let's send a `labelKey` that the frontend can translate.
|
||||
const data = [
|
||||
{ labelKey: 'mon', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'tue', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'wed', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'thu', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'fri', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'sat', xp: Math.floor(Math.random() * 500) },
|
||||
{ labelKey: 'sun', xp: Math.floor(Math.random() * 500) },
|
||||
];
|
||||
res.json(data);
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const data = await gamificationService.getWeeklyAnalytics((req.user as User).id);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to fetch analytics" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/analytics/yearly", async (req, res) => {
|
||||
const data = [
|
||||
{ labelKey: 'jan', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'feb', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'mar', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'apr', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'may', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'jun', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'jul', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'aug', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'sep', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'oct', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'nov', xp: Math.floor(Math.random() * 2000) },
|
||||
{ labelKey: 'dec', xp: Math.floor(Math.random() * 2000) },
|
||||
];
|
||||
res.json(data);
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const data = await gamificationService.getYearlyAnalytics((req.user as User).id);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to fetch analytics" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/analytics/monthly", async (req, res) => {
|
||||
// Return last 4-5 weeks with actual Calendar Week numbers
|
||||
// Mocking for now: Assume current week is ~50
|
||||
const currentWeek = 50;
|
||||
const data = [
|
||||
{ labelKey: (currentWeek - 3).toString(), xp: Math.floor(Math.random() * 800) },
|
||||
{ labelKey: (currentWeek - 2).toString(), xp: Math.floor(Math.random() * 800) },
|
||||
{ labelKey: (currentWeek - 1).toString(), xp: Math.floor(Math.random() * 800) },
|
||||
{ labelKey: currentWeek.toString(), xp: Math.floor(Math.random() * 800) },
|
||||
];
|
||||
res.json(data);
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const data = await gamificationService.getMonthlyAnalytics((req.user as User).id);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to fetch analytics" });
|
||||
}
|
||||
});
|
||||
|
||||
// Gamification Logic Wrapper
|
||||
@@ -1349,6 +1326,27 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/user/routine/:type/complete", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const type = req.params.type;
|
||||
if (type !== 'morning' && type !== 'evening') return res.status(400).json({ error: "Invalid routine type" });
|
||||
|
||||
try {
|
||||
const updates: any = {};
|
||||
const now = new Date();
|
||||
if (type === 'morning') {
|
||||
updates.lastMorningRoutine = now;
|
||||
} else {
|
||||
updates.lastEveningRoutine = now;
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateUser((req.user as User).id, updates);
|
||||
res.json(updatedUser);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to complete routine" });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/user/password", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user