← Back to Notes

Gamification System

2025-01-15

Gamification System

Gamification system

Overview

Sifokus uses gamification to motivate users through XP, levels, streaks, achievements, and leaderboards.


Tables

user_stats

Stores per-user gamification data. One row per user.

ColumnTypeDescription
userIdtext (FK → users)Unique, one stats row per user
xpintegerAll-time XP earned
levelintegerCurrent level (derived from XP)
streakCountintegerCurrent consecutive active days
longestStreakintegerBest streak ever achieved
lastActiveDatetimestampLast day user earned XP
totalTasksCompletedintegerAll-time tasks completed
totalPomodorosintegerAll-time pomodoro sessions
totalProjectsCompletedintegerAll-time projects completed
updatedAttimestamp_msAuto-updated on change

achievements

Badge definitions with tiers. Admin-seeded data.

ColumnTypeDescription
nametextDisplay name
descriptiontextHow to earn it
icontextLucide icon name
categoryenumpomodoro, task, streak, project, xp
tierenumbronze, silver, gold, platinum
thresholdintegerTarget number to unlock
xpRewardintegerXP granted on unlock

Example Achievements

NameCategoryTierThresholdXP Reward
First Steptaskbronze110
Getting Things Donetasksilver5050
Task Mastertaskgold500200
Task Legendtaskplatinum50001000
Focus Starterpomodorobronze110
Deep Workpomodorosilver5050
Pomodoro Masterpomodorogold500200
Focus Legendpomodoroplatinum50001000
7 Day Streakstreakbronze750
30 Day Streakstreaksilver30200
100 Day Streakstreakgold100500
365 Day Streakstreakplatinum3652000
Project Kickoffprojectbronze125
Project Slayerprojectsilver5100
Project Legendprojectgold20500
Rising Starxpbronze1000
Power Userxpsilver10000
Elitexpgold100000
Legendaryxpplatinum1000000

user_achievements

Tracks which badges a user has earned. Composite PK: userId + achievementId.

ColumnTypeDescription
userIdtext (FK → users)Part of composite PK
achievementIdinteger (FK → achievements)Part of composite PK
countintegerTimes earned (supports repeatable)
firstEarnedAttimestampWhen first unlocked
lastEarnedAttimestampWhen last earned (for repeatable)

period_stats

Weekly and monthly leaderboard data. Composite PK: userId + periodType + periodStart.

ColumnTypeDescription
userIdtext (FK → users)Part of composite PK
periodTypeenumweekly, monthly
periodStarttimestampMonday (weekly) or 1st of month (monthly)
xpEarnedintegerXP earned in this period
tasksCompletedintegerTasks completed in this period
pomodorosCompletedintegerPomodoros completed in this period

XP System

XP Sources

ActionXP
Complete a task+10
Complete a pomodoro (work session)+25
Complete a project+100
Daily streak bonus+15

Level Calculation

Level = floor(totalXP / 500) + 1
LevelXP Required
10
2500
31000
52000
104500
209500

Streaks

Rules

  • User must earn at least 1 XP in a day to maintain streak
  • Checked on first action of each day by comparing lastActiveDate with today
  • If lastActiveDate is yesterday → streak continues
  • If lastActiveDate is today → no change
  • If lastActiveDate is older than yesterday → streak resets to 1
  • Update longestStreak if current streak exceeds it

Daily Streak Bonus

When streak continues: +15 XP bonus (on top of action XP)


Leaderboard

Visibility

  • All users appear on leaderboard (no opt-in required)
  • Free and paid users included

Categories

Top 10 users displayed for each period.

Weekly

SELECT u.id, u.name, u.image, p.xpEarned, p.tasksCompleted, p.pomodorosCompleted FROM period_stats p JOIN users u ON u.id = p.userId WHERE p.periodType = 'weekly' AND p.periodStart = <this_week_monday> ORDER BY p.xpEarned DESC LIMIT 10

Monthly

SELECT u.id, u.name, u.image, p.xpEarned, p.tasksCompleted, p.pomodorosCompleted FROM period_stats p JOIN users u ON u.id = p.userId WHERE p.periodType = 'monthly' AND p.periodStart = <this_month_1st> ORDER BY p.xpEarned DESC LIMIT 10

Period Start Calculation

  • Weekly: date('now', 'weekday 0', '-6 days') (Monday of current week)
  • Monthly: date('now', 'start of month') (1st of current month)

Query Patterns

Increment XP + Check Level

// After action, atomically increment XP db.update(userStats) .set({ xp: sql`${userStats.xp} + ${xpAmount}`, totalTasksCompleted: sql`${userStats.totalTasksCompleted} + 1`, }) .where(eq(userStats.userId, userId)) // Then check level const newLevel = Math.floor((currentXp + xpAmount) / 500) + 1 if (newLevel > currentLevel) { db.update(userStats).set({ level: newLevel }).where(...) }

Update Streak

const today = startOfDay(new Date()) const lastActive = userStats.lastActiveDate if (isSameDay(lastActive, today)) { // Already active today, no change } else if (isYesterday(lastActive)) { // Streak continues db.update(userStats).set({ streakCount: sql`${userStats.streakCount} + 1`, longestStreak: sql`MAX(${userStats.longestStreak}, ${userStats.streakCount} + 1)`, lastActiveDate: today, xp: sql`${userStats.xp} + 15`, // streak bonus }) } else { // Streak broken db.update(userStats).set({ streakCount: 1, lastActiveDate: today, }) }

Check Achievement Unlock

// 1. Single query: find all newly unlocked achievements not yet earned const newAchievements = await db .select({ id: achievements.id, xpReward: achievements.xpReward }) .from(achievements) .leftJoin( userAchievements, and( eq(userAchievements.userId, userId), eq(userAchievements.achievementId, achievements.id), ), ) .where( and( eq(achievements.category, 'task'), lte(achievements.threshold, userStats.totalTasksCompleted), isNull(userAchievements.userId), // not yet earned ), ) // 2. Bulk insert unlocked achievements if (newAchievements.length > 0) { await db.insert(userAchievements).values( newAchievements.map((a) => ({ userId, achievementId: a.id, })), ) // 3. Grant total XP reward in one update const totalReward = newAchievements.reduce((sum, a) => sum + a.xpReward, 0) await db .update(userStats) .set({ xp: sql`${userStats.xp} + ${totalReward}` }) .where(eq(userStats.userId, userId)) }

Update Period Stats

const weekStart = getMonday(new Date()) const monthStart = getFirstOfMonth(new Date()) // Upsert weekly await db .insert(periodStats) .values({ userId, periodType: 'weekly', periodStart: weekStart, xpEarned: xpAmount, tasksCompleted: 1, }) .onConflictDoUpdate({ target: [ periodStats.userId, periodStats.periodType, periodStats.periodStart, ], set: { xpEarned: sql`${periodStats.xpEarned} + ${xpAmount}`, tasksCompleted: sql`${periodStats.tasksCompleted} + 1`, }, }) // Same for monthly with periodType: 'monthly'

User Stats Initialization

When a new user signs up, create their stats row:

await db.insert(userStats).values({ userId: newUser.id, xp: 0, level: 1, streakCount: 0, longestStreak: 0, totalTasksCompleted: 0, totalPomodoros: 0, totalProjectsCompleted: 0, })