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
122 changes: 72 additions & 50 deletions apps/user-application/src/routes/_auth/app/progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,34 @@ function ProgressPage() {
enabled: !!userId && !isSigningOut(),
})

// Handle newly unlocked achievements
// Handle newly unlocked achievements with localStorage filtering
useEffect(() => {
if (data?.newlyUnlocked && data.newlyUnlocked.length > 0) {
setNewlyUnlockedAchievements(data.newlyUnlocked)
if (data?.newlyUnlocked && data.newlyUnlocked.length > 0 && userId) {
const storageKey = `seen_achievements_${userId}`

try {
// Read previously seen achievements
const stored = localStorage.getItem(storageKey)
const seenIds = new Set(stored ? JSON.parse(stored) : [])

// Filter out achievements that have been seen
const trulyNew = data.newlyUnlocked.filter(a => !seenIds.has(a.id))

if (trulyNew.length > 0) {
setNewlyUnlockedAchievements(trulyNew)

// Update localStorage immediately to mark these as seen
const updatedSeenIds = [...Array.from(seenIds), ...trulyNew.map(a => a.id)]
localStorage.setItem(storageKey, JSON.stringify(updatedSeenIds))
}
}
catch (error) {
console.error('Error accessing localStorage for achievements:', error)
// Fallback: show them all if storage fails
setNewlyUnlockedAchievements(data.newlyUnlocked)
}
}
}, [data?.newlyUnlocked])
}, [data?.newlyUnlocked, userId])

const handleDismissAchievements = (achievementIds: string[]) => {
setNewlyUnlockedAchievements([])
Expand Down Expand Up @@ -230,18 +252,18 @@ function ProgressPage() {
<span className="text-sm text-muted-foreground">Cartes étudiées par jour</span>
{maxWeeklyValue === 0
? (
<span className="text-xs text-muted-foreground">Aucune activité</span>
)
<span className="text-xs text-muted-foreground">Aucune activité</span>
)
: (
<span className="text-xs text-muted-foreground">
Max:
{' '}
{maxWeeklyValue}
{' '}
carte
{maxWeeklyValue !== 1 ? 's' : ''}
</span>
)}
<span className="text-xs text-muted-foreground">
Max:
{' '}
{maxWeeklyValue}
{' '}
carte
{maxWeeklyValue !== 1 ? 's' : ''}
</span>
)}
</div>

<div className="flex h-40 items-end justify-between gap-2 md:gap-4">
Expand All @@ -266,20 +288,20 @@ function ProgressPage() {
<div className="relative w-full h-32 flex items-end overflow-hidden">
{hasActivity
? (
<motion.div
initial={{ height: 0 }}
animate={{ height: `${Math.max(height, 8)}%` }} // Minimum 8% for visibility when there's activity
transition={{ duration: 1, ease: 'easeOut', delay: i * 0.1 }}
className={cn(
'w-full rounded-t-lg relative shadow-sm bg-gradient-success',
isToday && 'shadow-[0_0_10px_var(--success-from)]',
)}
/>
)
<motion.div
initial={{ height: 0 }}
animate={{ height: `${Math.max(height, 8)}%` }} // Minimum 8% for visibility when there's activity
transition={{ duration: 1, ease: 'easeOut', delay: i * 0.1 }}
className={cn(
'w-full rounded-t-lg relative shadow-sm bg-gradient-success',
isToday && 'shadow-[0_0_10px_var(--success-from)]',
)}
/>
)
: (
// Empty state - subtle indicator at bottom
<div className="w-full h-1 rounded-full bg-muted/30" />
)}
<div className="w-full h-1 rounded-full bg-muted/30" />
)}
</div>
<span className={cn(
'text-xs font-medium',
Expand Down Expand Up @@ -466,31 +488,31 @@ function ProgressPage() {
<div className="w-full mt-4 pt-4 border-t border-border">
{selectedAchievement.unlocked
? (
<div className="flex items-center justify-center gap-2 text-success font-medium">
<Check className="w-5 h-5" />
<span>Badge débloqué</span>
</div>
)
<div className="flex items-center justify-center gap-2 text-success font-medium">
<Check className="w-5 h-5" />
<span>Badge débloqué</span>
</div>
)
: (
<div className="space-y-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground uppercase font-bold tracking-wider">
<span>Progression</span>
<span>
{selectedAchievement.progress}
{' '}
/
{' '}
{selectedAchievement.maxProgress}
</span>
</div>
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${(selectedAchievement.progress / selectedAchievement.maxProgress) * 100}%` }}
/>
</div>
<div className="space-y-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground uppercase font-bold tracking-wider">
<span>Progression</span>
<span>
{selectedAchievement.progress}
{' '}
/
{' '}
{selectedAchievement.maxProgress}
</span>
</div>
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${(selectedAchievement.progress / selectedAchievement.maxProgress) * 100}%` }}
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
Expand Down
41 changes: 41 additions & 0 deletions tasks/prd-achievement-unlocked-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# PRD: Fix Always Achievement Unlocked Dialog

## Introduction/Overview

Currently, users see the "Achievement Unlocked" dialog every time they visit the progress page, even for achievements they have already seen. This creates a repetitive and annoying user experience. This PRD defines the fix to ensure only *genuinely new* achievements trigger the notification.

## Goals

1. Prevent the "Achievement Unlocked" dialog from appearing for achievements the user has already seen.
2. Ensure the dialog *does* appear when a new achievement is unlocked.
3. Persist the "seen" state across sessions using `localStorage`.

## User Stories

- **As a user**, I want to see a celebration when I unlock a new achievement so I feel rewarded.
- **As a user**, I DO NOT want to see the same celebration again when I refresh the page or return later, because it loses its meaning.

## Functional Requirements

1. **LocalStorage Persistence:** The application must store a list of `seen_achievement_ids` in the browser's `localStorage`.
2. **Filtering Logic:** On page load, the application must compare the `newlyUnlocked` achievements returned by the API against the stored `seen_achievement_ids`.
3. **Display Condition:** The dialog must only display achievements that are present in `newlyUnlocked` AND NOT present in `seen_achievement_ids`.
4. **State Update:** Once filtering is complete and new achievements are identified for display, their IDs must be added to `localStorage` to prevent future displays.

## Technical Considerations

- **Storage Key:** Use a unique key like `kurama_seen_achievements_${userId}` to support multiple users on the same device (if applicable) or just `kurama_seen_achievements`. Given the auth context, appending userId is safer.
- **Data Structure:** simple JSON array of strings: `['achievement-1', 'achievement-2']`.
- **Component:** `apps/user-application/src/routes/_auth/app/progress.tsx` is the primary target.

## Acceptance Criteria

- [ ] Visiting the progress page with *no* new achievements shows NO dialog.
- [ ] Visiting the progress page with a *new* achievement shows the dialog ONCE.
- [ ] Refreshing the page immediately after seeing the dialog does NOT show it again.
- [ ] The "seen" state persists if the user closes and reopens the browser.

## Open Questions

- Should we ever clear this list? (Likely not needed for now, as achievements are finite).
- What if the user clears their cache? (They will see the notification again once, which is acceptable).
20 changes: 20 additions & 0 deletions tasks/tasks-achievement-unlocked-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Tasks: Fix Always Achievement Unlocked Dialog

## Relevant Files

- `apps/user-application/src/routes/_auth/app/progress.tsx` - Main component to modify.

## Instructions for Completing Tasks

Checks off tasks as you go.

## Tasks

- [x] 0.0 Create feature branch
- [x] 0.1 Create and checkout branch `fix/achievement-dialog-loop`
- [x] 1.0 Implement LocalStorage Filtering in Progress Page
- [x] 1.1 In `ProgressPage` component, add logic to read seen achievements from `localStorage` on mount.
- [x] 1.2 Modify the `useEffect` handling `newlyUnlocked` to filter out IDs already in `localStorage`.
- [x] 1.3 Update the `localStorage` with the new IDs immediately when they are set to be displayed (or when dismissed, but immediate is safer to avoid loops).
- [x] 2.0 Testing & Verification
- [x] 2.1 specific manual verification steps (since this is client-side interaction).
Loading