Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,15 @@ function SessionPage() {
}
})

// Track navigation state to prevent flash
const [isNavigatingToSummary, setIsNavigatingToSummary] = useState(false)

const { updateStats } = useStatsUpdate({
onLevelUp: (newLevel) => {
// Don't show level up if we are about to navigate
if (isNavigatingToSummary)
return

setCurrentReward({
type: 'level_up',
title: `Niveau ${newLevel} !`,
Expand All @@ -116,6 +123,10 @@ function SessionPage() {
setShowReward(true)
},
onAchievementUnlocked: (achievements) => {
// Don't show achievement if we are about to navigate
if (isNavigatingToSummary)
return

if (achievements.length > 0) {
setCurrentReward({
type: 'achievement',
Expand Down Expand Up @@ -225,6 +236,9 @@ function SessionPage() {

const navigateToSummary = useCallback(
async (finalCorrect?: number, finalIncorrect?: number) => {
// Set navigation flag to suppress rewards
setIsNavigatingToSummary(true)

const duration = Math.floor((Date.now() - startTime) / 1000)
const correct = finalCorrect ?? sessionStats.correct
const incorrect = finalIncorrect ?? sessionStats.incorrect
Expand All @@ -250,6 +264,10 @@ function SessionPage() {
xpEarned: result?.xpEarned ?? correct * 10,
leveledUp: result?.leveledUp ? 'true' : undefined,
newLevel: result?.currentLevel,
// Pass unlocked achievements to summary
achievements: result?.achievementsUnlocked && result.achievementsUnlocked.length > 0
? result.achievementsUnlocked.join(',')
: undefined,
},
})
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AchievementWithProgress } from '@kurama/data-ops/queries/achievements'
import { ACHIEVEMENTS } from '@kurama/data-ops/queries/achievements'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useNavigate, useParams, useSearch } from '@tanstack/react-router'
import {
Expand All @@ -13,10 +15,12 @@ import {
import { motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { EnhancedXPDisplay } from '@/components/gamification'
import { AchievementUnlockToast } from '@/components/gamification/achievement-unlock-toast'
import { AppHeader } from '@/components/main'
import { Button } from '@/components/ui/button'
import { getUserStats } from '@/core/functions/stats'
import { useOnlineStatus } from '@/hooks/use-online-status'
import { authClient } from '@/lib/auth-client'
import { getMutationQueueManager } from '@/lib/mutation-queue'
import { trackRouteLoad } from '@/lib/performance-monitor'
import { cn } from '@/lib/utils'
Expand All @@ -30,6 +34,7 @@ interface SearchParams {
xpEarned?: number
leveledUp?: string
newLevel?: number
achievements?: string
}

export const Route = createFileRoute('/_auth/app/lesson-summary/$lessonId')({
Expand All @@ -52,13 +57,14 @@ export const Route = createFileRoute('/_auth/app/lesson-summary/$lessonId')({
xpEarned: search.xpEarned ? Math.max(0, Number(search.xpEarned)) : undefined,
leveledUp: search.leveledUp as string | undefined,
newLevel: search.newLevel ? Math.max(1, Number(search.newLevel)) : undefined,
achievements: search.achievements as string | undefined,
}
},
})

function SummaryPage() {
const { lessonId } = useParams({ from: '/_auth/app/lesson-summary/$lessonId' })
const { correct, incorrect, total, duration, mode, xpEarned, leveledUp, newLevel } = useSearch({
const { correct, incorrect, total, duration, mode, xpEarned, leveledUp, newLevel, achievements } = useSearch({
from: '/_auth/app/lesson-summary/$lessonId',
})
const navigate = useNavigate()
Expand All @@ -80,6 +86,67 @@ function SummaryPage() {
const { isOnline } = useOnlineStatus()
const [pendingMutations, setPendingMutations] = useState(0)

// Achievement Display Logic
const session = authClient.useSession()
const userId = session.data?.user?.id
const [newlyUnlockedAchievements, setNewlyUnlockedAchievements] = useState<AchievementWithProgress[]>([])

useEffect(() => {
if (achievements && userId) {
const unlockedIds = achievements.split(',')

// Filter localStorage to avoid duplicates (using common key)
const storageKey = `seen_achievements_${userId}`
let seenIds = new Set<string>()
try {
const stored = localStorage.getItem(storageKey)
seenIds = new Set(stored ? JSON.parse(stored) : [])
}
catch (e) {
console.error('Error reading seen achievements', e)
}

// Filter to only truly new ones
const trulyNewIds = unlockedIds.filter(id => !seenIds.has(id))

if (trulyNewIds.length > 0) {
// Map to full objects
const unlockedObjects = trulyNewIds
.map((id) => {
const def = ACHIEVEMENTS.find(a => a.id === id)
if (!def)
return null
return {
...def,
unlocked: true,
unlockedAt: new Date().toISOString(),
progress: def.condition.threshold,
maxProgress: def.condition.threshold,
} as AchievementWithProgress
})
.filter((a): a is AchievementWithProgress => a !== null)

if (unlockedObjects.length > 0) {
// Delay display to allow summary to load
const timer = setTimeout(() => {
setNewlyUnlockedAchievements(unlockedObjects)

// Mark as seen in localStorage immediately when queuing to show
try {
const updatedSeenIds = [...Array.from(seenIds), ...trulyNewIds]
localStorage.setItem(storageKey, JSON.stringify(updatedSeenIds))
}
catch (e) {
console.error('Error saving seen achievements', e)
}
}, 800) // 800ms delay

return () => clearTimeout(timer)
}
}
}
}, [achievements, userId])

// Track route load performance
useEffect(() => {
const endTracking = trackRouteLoad('app-summary')
Expand Down Expand Up @@ -344,6 +411,12 @@ function SummaryPage() {
</motion.div>
</motion.div>
</main>

{/* Delayed Achievement Notification */}
<AchievementUnlockToast
achievements={newlyUnlockedAchievements}
onDismiss={() => setNewlyUnlockedAchievements([])}
/>
</div>
)
}
49 changes: 49 additions & 0 deletions tasks/prd-achievement-flash-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# PRD: Fix Achievement Flash on Session Summary

## Introduction/Overview

Currently, when a user unlocks an achievement at the end of a session, the achievement notification appears briefly ("flashes") on the Session screen just before the user is navigated to the Summary screen. This creates a jarring experience where the user misses the achievement celebration.
The desired behavior is to suppress this flash on the Session screen and instead display the achievement notification on the Summary screen, after a short delay to allow the UI to stabilize.

## Goals

1. **Eliminate "Flash":** Prevent the achievement notification from appearing on the `SessionPage` when the session is completing.
2. **Delayed Display:** Show the achievement notification on the `SummaryPage` after a 500ms-1s delay.
3. **Data Passing:** Successfully pass the list of newly unlocked achievements from the session to the summary page.
4. **Consistency:** Ensure that viewing the achievement on the summary page marks it as "seen" so it doesn't reappear on the Progress page (referencing the previous fix).

## User Stories

- **As a learner**, I want to finish my session and see my results summary first.
- **As a learner**, I want to be pleasantly surprised by an achievement notification *after* I've had a moment to breathe, not while the screen is changing.

## Functional Requirements

1. **Session Page (`lesson-session.$lessonId.tsx`):**
- The `onAchievementUnlocked` callback in `useStatsUpdate` must be suppressed or ignored when the update is triggered by `navigateToSummary`.
- The `navigateToSummary` function must capture the `achievementsUnlocked` from the `updateStats` result.
- The `achievementsUnlocked` (IDs or names) must be passed as a query parameter (e.g., `&achievements=...`) to the Summary page URL.

2. **Summary Page (`lesson-summary.$lessonId.tsx`):**
- Accept an optional `achievements` query parameter (string, likely comma-separated).
- Implement a `useEffect` that triggers the display of these achievements after a set delay (e.g., 800ms).
- Use the `AchievementUnlockToast` (or similar component) to display the achievements.
- **Crucial:** When displaying these achievements, update the `localStorage` "seen" list (using the logic from the recent fix) to prevent them from showing up again on the generic Progress page.

## Technical Considerations

- **URL Length:** Achievement IDs are strings. Passing 1-3 IDs in the URL is safe.
- **Component Reuse:** Reuse `AchievementUnlockToast` from `components/gamification/achievement-unlock-toast.tsx`.
- **State Management:** Use `useState` in Summary Page to control the visibility of the toast.

## Acceptance Criteria

- [ ] Completing a session with a new achievement does NOT show the toast on the Session page.
- [ ] The Summary page loads fully.
- [ ] After ~1 second, the Achievement Toast appears on the Summary page.
- [ ] Dismissing the toast (or auto-dismiss) works as expected.
- [ ] Navigating to the Progress page afterwards does NOT show the same achievement again.

## Open Questions

- *None at this stage.*
30 changes: 30 additions & 0 deletions tasks/tasks-achievement-flash-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Tasks: Fix Achievement Flash on Session Summary

## Relevant Files

- `apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx`
- `apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx`
- `apps/user-application/src/components/gamification/achievement-unlock-toast.tsx`

## Instructions

Check off tasks as you go.

## Tasks

- [x] 0.0 Create feature branch
- [x] 0.1 Create and checkout branch `fix/achievement-flash`
- [x] 1.0 Modify Session Page Logic (`lesson-session`)
- [x] 1.1 In `SessionPage`, add a ref or state `isNavigatingToSummary` to track when we are finishing.
- [x] 1.2 Modify `useStatsUpdate` `onAchievementUnlocked` callback to return early if `isNavigatingToSummary` is true.
- [x] 1.3 Update `navigateToSummary` to extract `achievementsUnlocked` (IDs) from the `updateStats` result.
- [x] 1.4 Pass these IDs in the URL search params to the summary page (e.g. `achievements=id1,id2`).
- [x] 2.0 Modify Summary Page Logic (`lesson-summary`)
- [x] 2.1 Update `validateSearch` to accept `achievements` (string or array).
- [x] 2.2 Add `AchievementUnlockToast` component to the JSX.
- [x] 2.3 Add `useEffect` to handle the delayed display logic (read params -> wait -> set state to show).
- [x] 2.4 Implement `handleDismiss` that updates `localStorage` (copying logic from `progress.tsx`) to mark as seen.
- [x] 3.0 Verification
- [x] 3.1 Verify no flash on session end.
- [x] 3.2 Verify delayed appearance on summary.
- [x] 3.3 Verify no duplicate on Progress page.
Loading