Visitar URL original
feat: add daily challenges system with XP progression and rewards by cwchanap · Pull Request #14 · cwchanap/cetus · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a934230
feat: add daily challenges system with XP progression and rewards
cwchanap Dec 24, 2025
87504f4
feat: improve challenge completion modal accessibility and UX
cwchanap Dec 24, 2025
9538164
fix: improve challenge progress handling and add refresh prompt on reset
cwchanap Dec 24, 2025
cc9b4ed
refactor: improve daily challenges error handling and data initializa…
cwchanap Dec 24, 2025
b572402
fix: correct XP display for multiple completed challenges
cwchanap Dec 24, 2025
d495b40
fix: correct level-up calculation to use updated XP instead of curren…
cwchanap Dec 24, 2025
3f9ca27
refactor: convert ChallengeComplete from TypeScript class to JSDoc-an…
cwchanap Dec 26, 2025
5d21cc7
fix: use UTC date for challenge generation to match progress keys
cwchanap Dec 27, 2025
a949e7e
fix: ensure challenge and streak columns exist before user stats oper…
cwchanap Dec 27, 2025
ef415a5
fix: improve challenge streak calculation to handle date transitions …
cwchanap Dec 28, 2025
5df6ac3
docs: update CLAUDE.md to reflect Bun as package manager
cwchanap Dec 28, 2025
10f1d5d
fix: improve challenge completion data handling and persist streak re…
cwchanap Dec 29, 2025
ff2b30f
fix: remove unnecessary type assertion in ChallengeComplete badge han…
cwchanap Dec 29, 2025
7239a8c
fix: prevent negative xpToNextLevel for max-level users
cwchanap Dec 30, 2025
7b08f7a
fix: handle database operation failures in challenge completion and l…
cwchanap Dec 30, 2025
22f18d3
refactor: improve daily challenges XP system with atomic streak updates
cwchanap Dec 31, 2025
e8c980d
fix: improve type safety and date handling in challenge system
cwchanap Jan 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: improve challenge streak calculation to handle date transitions …
…correctly

Previously, updateChallengeStreak accepted an increment boolean and fetched
the current streak internally, which could lead to race conditions. Now it
accepts the new streak value directly, calculated by the caller.

Also moved streak expiration logic from updateChallengeStreak to
getUserXPAndLevel, so the returned challengeStreak is always accurate
(returns 0 if last completion was before yesterday).

Updated checkAndUpdateStreak to
  • Loading branch information
cwchanap committed Dec 28, 2025
commit ef415a54eac01f791f876d7414786744b2bfa0ff
27 changes: 22 additions & 5 deletions src/lib/server/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,11 +1341,30 @@ export async function getUserXPAndLevel(userId: string): Promise<{
.where('user_id', '=', userId)
.executeTakeFirst()

let challengeStreak = stats?.challenge_streak ?? 0
const lastChallengeDate = stats?.last_challenge_date ?? null

// If the last challenge was completed before yesterday, the current streak is effectively 0
if (lastChallengeDate) {
const today = new Date().toISOString().split('T')[0]
if (lastChallengeDate !== today) {
const todayDate = new Date(`${today}T00:00:00Z`)
const yesterdayDate = new Date(
todayDate.getTime() - 24 * 60 * 60 * 1000
)
const yesterday = yesterdayDate.toISOString().split('T')[0]

if (lastChallengeDate !== yesterday) {
challengeStreak = 0
}
}
}

return {
xp: stats?.xp ?? 0,
level: stats?.level ?? 1,
challengeStreak: stats?.challenge_streak ?? 0,
lastChallengeDate: stats?.last_challenge_date ?? null,
challengeStreak,
lastChallengeDate,
}
} catch (error) {
console.error('[getUserXPAndLevel] Error:', sanitizeError(error))
Expand Down Expand Up @@ -1470,13 +1489,11 @@ export async function getGamesPlayedCountToday(
*/
export async function updateChallengeStreak(
userId: string,
increment: boolean,
newStreak: number,
date: string
): Promise<number> {
try {
await ensureChallengeColumns()
const current = await getUserXPAndLevel(userId)
const newStreak = increment ? current.challengeStreak + 1 : 0

// Check if user_stats exists
const existing = await db
Expand Down
182 changes: 182 additions & 0 deletions src/lib/services/challengeService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GameID } from '../games'
import * as challenges from '../challenges'

// Mock challenges module
vi.mock('../challenges', async importOriginal => {
const actual = await importOriginal<typeof import('../challenges')>()
return {
...actual,
generateDailyChallenges: vi.fn().mockReturnValue([
{
id: 'play_2_games',
name: 'Warm Up',
description: 'Play 2 games today',
icon: '🎮',
type: 'play_games',
targetValue: 2,
xpReward: 30,
difficulty: 'easy',
},
{
id: 'score_tetris_100',
name: 'Tetris Starter',
description: 'Score 100+ points in Tetris',
icon: '🔲',
type: 'score_target',
gameId: 'tetris',
targetValue: 100,
xpReward: 30,
difficulty: 'easy',
},
]),
}
})

// Mock database queries
vi.mock('../server/db/queries', () => ({
Expand Down Expand Up @@ -87,5 +119,155 @@ describe('Challenge Service', () => {
)
expect(queries.getTotalScoreToday).toHaveBeenCalledWith('user-123')
})

it('should reset streak to 1 if last completion was not yesterday', async () => {
const today = new Date().toISOString().split('T')[0]
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
Comment thread
cwchanap marked this conversation as resolved.
Resolved

const mockChallenges = [
{ id: 'play_2_games', type: 'play_games', targetValue: 2 },
{
id: 'score_tetris_100',
type: 'score_target',
targetValue: 100,
},
{
id: 'total_score_200',
type: 'total_score',
targetValue: 200,
},
]
vi.mocked(challenges.generateDailyChallenges).mockReturnValue(
mockChallenges as any
)

vi.mocked(queries.getUserChallengeProgress).mockResolvedValue([
{
id: 1,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'play_2_games',
current_value: 2,
target_value: 2,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
{
id: 2,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'score_tetris_100',
current_value: 100,
target_value: 100,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
{
id: 3,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'total_score_200',
current_value: 200,
target_value: 200,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
])

vi.mocked(queries.getUserXPAndLevel).mockResolvedValue({
xp: 100,
level: 2,
challengeStreak: 5,
lastChallengeDate: twoDaysAgo,
})

await updateChallengeProgress('user-123', GameID.TETRIS, 500)

expect(queries.updateChallengeStreak).toHaveBeenCalledWith(
'user-123',
1,
today
)
})

it('should increment streak if last completion was yesterday', async () => {
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]

const mockChallenges = [
{ id: 'play_2_games', type: 'play_games', targetValue: 2 },
{
id: 'score_tetris_100',
type: 'score_target',
targetValue: 100,
},
{
id: 'total_score_200',
type: 'total_score',
targetValue: 200,
},
]
vi.mocked(challenges.generateDailyChallenges).mockReturnValue(
mockChallenges as any
)

vi.mocked(queries.getUserChallengeProgress).mockResolvedValue([
{
id: 1,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'play_2_games',
current_value: 2,
target_value: 2,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
{
id: 2,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'score_tetris_100',
current_value: 100,
target_value: 100,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
{
id: 3,
user_id: 'user-123',
challenge_date: today,
challenge_id: 'total_score_200',
current_value: 200,
target_value: 200,
completed_at: new Date(),
xp_awarded: 30,
created_at: new Date(),
},
])

vi.mocked(queries.getUserXPAndLevel).mockResolvedValue({
xp: 100,
level: 2,
challengeStreak: 5,
lastChallengeDate: yesterday,
})

await updateChallengeProgress('user-123', GameID.TETRIS, 500)

expect(queries.updateChallengeStreak).toHaveBeenCalledWith(
'user-123',
6,
today
)
})
})
})
17 changes: 15 additions & 2 deletions src/lib/services/challengeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,23 @@ async function checkAndUpdateStreak(userId: string): Promise<void> {
})

if (allCompleted) {
const { lastChallengeDate } = await getUserXPAndLevel(userId)
const { lastChallengeDate, challengeStreak } =
await getUserXPAndLevel(userId)
// Only update if this is the first time completing all today
if (lastChallengeDate !== today) {
await updateChallengeStreak(userId, true, today)
let newStreak = 1
if (lastChallengeDate) {
const lastDate = new Date(`${lastChallengeDate}T00:00:00Z`)
const yesterday = new Date(
todayDate.getTime() - 24 * 60 * 60 * 1000
)

// If last completion was yesterday, increment streak
if (lastDate.getTime() === yesterday.getTime()) {
newStreak = challengeStreak + 1
}
}
await updateChallengeStreak(userId, newStreak, today)
}
}
}
Comment thread
cwchanap marked this conversation as resolved.
Resolved