diff --git a/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx b/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx index 0b8e194..a5c6da5 100644 --- a/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx +++ b/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx @@ -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} !`, @@ -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', @@ -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 @@ -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, }, }) }, diff --git a/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx b/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx index 4a81761..e33b331 100644 --- a/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx +++ b/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx @@ -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 { @@ -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' @@ -30,6 +34,7 @@ interface SearchParams { xpEarned?: number leveledUp?: string newLevel?: number + achievements?: string } export const Route = createFileRoute('/_auth/app/lesson-summary/$lessonId')({ @@ -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() @@ -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([]) + + 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() + 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') @@ -344,6 +411,12 @@ function SummaryPage() { + + {/* Delayed Achievement Notification */} + setNewlyUnlockedAchievements([])} + /> ) } diff --git a/tasks/prd-achievement-flash-fix.md b/tasks/prd-achievement-flash-fix.md new file mode 100644 index 0000000..bda5a3e --- /dev/null +++ b/tasks/prd-achievement-flash-fix.md @@ -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.* diff --git a/tasks/tasks-achievement-flash-fix.md b/tasks/tasks-achievement-flash-fix.md new file mode 100644 index 0000000..adc8811 --- /dev/null +++ b/tasks/tasks-achievement-flash-fix.md @@ -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.