Achievements Deep Dive
Note: this page describes an earlier achievements implementation (the
AchievementCard/AchievementsGrid/UserRankCardlayout,useAchievementNotificationstoast + confetti composable,ShowcaseSelectormodal). The achievements page has since been redesigned as "The Atelier" (hero card, showcase row, next-on-the-shelf card, recently-unlocked row). For the current layout, see Achievements & Gamification — Feature & Testing Guide. Sections below may not reflect the current UI; file/path references have been spot-corrected but the surrounding narrative has not been fully rewritten.
Overview
The achievement system lives at /achievements (the old /badges URL auto-redirects). It provides gamified progress tracking with badge unlocking, rarity tiers, confetti celebrations, filtering/sorting, and a showcase feature for displaying favorite badges.
Core Capabilities
- Progress indicators on locked badges with real-time current/target display (e.g., "3/5 goals completed"). Badges at 70%+ completion pulse with a golden glow.
- Categories and filters -- 6 categories (All, Milestones, Goals, Streaks, Community, Special), status filters (All, Unlocked, Locked, In Progress), and sorting (Rarity, Date, Progress, Name).
- Badge showcasing -- select up to 3 favorite badges to feature on the dashboard. Persisted in the
UserDetail.dataJSON field. - Celebration notifications -- confetti and toast notifications on unlock, with rarity-scaled effects and a queue to prevent spam.
- Automatic detection --
AchievementWatcherpolls for new unlocks and triggers notifications without manual intervention.
File Structure
Backend (Rails API)
rails_api/
├── app/
│ ├── services/achievements/
│ │ └── calculate_user_stats_service.rb # Calculates user achievement stats
│ ├── interactions/gamification/achievements/
│ │ └── update_showcased_achievements.rb # Updates showcased badges
│ ├── graphql/
│ │ ├── types/
│ │ │ └── user_stats_type.rb # GraphQL type for stats
│ │ └── mutations/
│ │ └── update_showcased_achievements.rb # GraphQL mutation
│ └── models/
│ └── user_detail.rb # Stores showcased achievements
└── spec/
├── services/achievements/
│ └── calculate_user_stats_service_spec.rb
├── interactions/gamification/achievements/
│ └── update_showcased_achievements_spec.rb
└── graphql/mutations/
└── update_showcased_achievements_spec.rbFrontend (Ionic/Vue)
ionic_frontend/src/
├── components/achievements/
│ ├── AchievementCard.vue # Individual badge card with progress
│ ├── AchievementsGrid.vue # Grid layout
│ ├── AchievementStats.vue # Stats display
│ ├── AchievementFilters.vue # Category/sort/status filters
│ ├── ShowcaseSelector.vue # Modal for selecting showcased badges
│ ├── AchievementWatcher.vue # Automatic notification detector
│ ├── AchievementCelebration.vue # Demo/test component
│ └── UserRankCard.vue # Level/rank display
├── components/dashboard/
│ └── ShowcasedAchievements.vue # Dashboard widget
├── composables/
│ └── useAchievementNotifications.ts # Confetti & toast composable
├── services/
│ └── achievementProgress.ts # Progress calculation service
├── components/ui/
│ └── AchievementToast.vue # Toast markup + scoped styles (no dedicated CSS file)
├── constants/
│ └── BadgeCollection.js # Badge definitions with categories
└── views/
└── Achievements.vue # Main achievements pageDependencies
canvas-confetti-- confetti animations@ionic/vue-- toast notifications@vue/apollo-composable-- GraphQL querieslucide-vue-next-- icons
Database Schema
No dedicated migrations. Showcased achievements are stored in the existing UserDetail.data JSON field:
# UserDetail.data JSON field structure
{
"showcasedAchievements": ["complete_first_goal", "visionary", "dedicated"]
}Badge Definitions & Rarity
Categories
| Category | Description | Badge Count |
|---|---|---|
| milestones | First-time achievements | 5 |
| goals | Goal-related achievements | 6 |
| streaks | Consistency achievements | 3 |
| community | Social achievements | 3 |
| special | Unique achievements | 3 |
Rarity Tiers
| Rarity | Count | Visual Effect | Confetti Colors | Particles | Duration | Special |
|---|---|---|---|---|---|---|
| Common | 5 | Blue | Blue | 50 | 2s | -- |
| Rare | 6 | Blue/Purple glow | Blue/Purple | 100 | 3s | -- |
| Epic | 4 | Purple glow | Purple | 150 | 4s | -- |
| Legendary | 5 | Gold glow + pulse | Gold | 200 | 5s | Stars |
Trackable Achievements (14 total)
- Goals: Complete 1st/5th/10th goal, Create 10 goals, Complete 20 goals
- Streaks: 3/7/30 day sign-in streaks
- Community: Give 5/20 encouragements
- Milestones: Create 5 milestones, Complete 10 milestones
- Diversity: Use 3 categories, Use 3 types
XP & Rank System
XP is calculated from unlocked badge count:
const xp = unlockedCount * 100
const levels = [0, 500, 1000, 2000, 3500, 5500, 8000, 11000, 15000, 20000]
const level = levels.findIndex(threshold => xp < threshold) || levels.lengthThe UserRankCard component displays the user's current level and rank.
Integration Guide
GraphQL Schema
Types:
type UserStats {
totalGoals: Int!
completedGoals: Int!
goalsCreatedCount: Int!
encouragementsGiven: Int!
milestonesCreated: Int!
milestonesCompleted: Int!
categoriesUsed: [String!]!
typesUsed: [String!]!
}
type User {
stats: UserStats
showcasedAchievements: [String!]
}Mutations:
mutation UpdateShowcasedAchievements(
$userId: ID!
$achievementKeys: [String!]!
) {
updateShowcasedAchievements(
userId: $userId
achievementKeys: $achievementKeys
) {
success: Boolean!
errors: [String!]
}
}Backend Service Objects
Achievements::CalculateUserStatsService-- calculates user statistics for progress trackingAchievements::UpdateShowcasedAchievements-- interaction for updating showcased badge selections
Component Props
AchievementCard:
props: {
name: string
imageUrl: string
rarity: 'common' | 'rare' | 'epic' | 'legendary'
unlocked: boolean
unlockedAt: string | null
progress: { current: number, target: number, percentage: number, label: string } | null
}ShowcasedAchievements:
props: {
showcasedAchievements: string[] // Array of badge keys
}ShowcaseSelector:
props: {
isOpen: boolean
badges: Badge[]
showcasedAchievements: string[]
userId: string
}
emits: ['close', 'updated']Composable API -- useAchievementNotifications
import { useAchievementNotifications } from '@/composables/useAchievementNotifications'
const {
showAchievementUnlocked, // Full notification (toast + confetti)
celebrateAchievement, // Just confetti (optional toast)
notificationQueue, // Current queue
isShowingNotification // Is one showing now?
} = useAchievementNotifications()
showAchievementUnlocked('badge_key')
celebrateAchievement('badge_key', skipToast = false)Service Functions -- calculateAchievementProgress
import { calculateAchievementProgress } from '@/services/achievementProgress'
const progress = calculateAchievementProgress(
badgeKey: string,
userStats: UserStats,
signInDates: string[]
)
// Returns: { current, target, percentage, label } | nullIntegration Examples
1. Add AchievementWatcher to App.vue for automatic notifications:
<template>
<ion-app>
<achievement-watcher />
<router-view />
</ion-app>
</template>
<script setup>
import AchievementWatcher from '@/components/achievements/AchievementWatcher.vue'
</script>2. Display showcased achievements on the dashboard:
<template>
<showcased-achievements
:showcased-achievements="user.showcasedAchievements"
/>
</template>
<script setup>
import ShowcasedAchievements from '@/components/dashboard/ShowcasedAchievements.vue'
import { computed } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import * as queries from '@/constants/graphql'
const { result } = useQuery(queries.USER_QUERY, { id: userId })
const user = computed(() => result.value?.user ?? {})
</script>3. Trigger a celebration manually after a user action:
import { useAchievementNotifications } from '@/composables/useAchievementNotifications'
const { celebrateAchievement } = useAchievementNotifications()
const completeGoal = async () => {
await api.completeGoal(goalId)
celebrateAchievement('complete_first_goal')
}Styling Customization
There is no dedicated achievement-toast stylesheet. Toast markup and styles live in the <style scoped> block of src/components/ui/AchievementToast.vue (rarity-based gradients, glow, and legendary pulse animation).
Confetti colors and effects can be customized in useAchievementNotifications.ts:
const rarityConfig = {
legendary: {
colors: ['#fbbf24', '#f59e0b', '#fcd34d', '#fde047'],
particleCount: 200,
spread: 120,
duration: 5000,
stars: true
}
}Toast duration is configured in the toastController.create call:
const toast = await toastController.create({
duration: 5000, // milliseconds
})Testing Guide
Running Backend Tests
cd rails_api
bin/rspec spec/services/achievements/ \
spec/interactions/gamification/achievements/ \
spec/graphql/mutations/update_showcased_achievements_spec.rbExpected: 11 examples, 0 failures.
GraphQL Manual Testing
Navigate to http://localhost:3000/graphiql and run these queries:
User Stats:
query {
user(id: "YOUR_USER_ID") {
stats {
totalGoals
completedGoals
goalsCreatedCount
encouragementsGiven
milestonesCreated
milestonesCompleted
categoriesUsed
typesUsed
}
}
}Showcased Achievements:
query {
user(id: "YOUR_USER_ID") {
showcasedAchievements
}
}Update Showcase:
mutation {
updateShowcasedAchievements(
userId: "YOUR_USER_ID"
achievementKeys: ["complete_first_goal", "visionary", "dedicated"]
) {
success
errors
}
}Frontend Manual Testing Checklist
Page Access:
- Navigate to
/achievements-- page loads successfully - Old
/badgesURL redirects to/achievements - Side menu shows "Achievements" (not "Badges")
Progress Indicators:
- Locked badges show progress bars (if trackable)
- Progress labels show current/target (e.g., "3/5 goals completed")
- Progress percentage is accurate
- Badges at 70%+ progress have pulsing golden animation
Categories and Filters:
- All 6 category tabs are visible and filter correctly
- Active category is highlighted
- Sort options work: Rarity, Date, Progress, Name
- Status filters work: All, Unlocked, Locked, In Progress
- Category tabs scroll horizontally on mobile
Badge Showcasing:
- "Showcase" button opens the modal
- Modal shows preview of 3 slots
- Can select up to 3 unlocked badges (no more)
- Can remove selected badges
- "Save Showcase" persists data across page refreshes
Notifications:
- Use
<achievement-celebration :show-demo="true" />to test confetti - Verify rarity-based confetti effects (common through legendary)
- Toast shows badge image, name, description, and "View" button
- Toast auto-dismisses after 5 seconds
- Queue system shows notifications one at a time with ~1 second delay
Automatic Detection:
- Complete an action that unlocks a badge
- Wait up to 10 seconds (polling interval)
- Notification appears automatically
Performance Testing
- Achievements page loads in <2 seconds
- Smooth 60fps animations, no layout shift
- No memory leaks after triggering 10+ notifications
- Confetti cleans up properly after animation
- Touch interactions work smoothly on mobile
Browser Compatibility
- Chrome 90+
- Firefox 88+
- Safari 14+
- Mobile Safari (iOS 14+)
- Chrome Mobile (Android)
Accessibility
- Keyboard navigation works for filters
- Screen reader announces achievement unlocks
- WCAG AA color contrast
- Focus indicators visible on interactive elements
- Semantic HTML throughout
Quick Reference
Common Patterns
Check if a badge is unlocked:
const isUnlocked = actions.find(a => a.action === badgeKey) !== undefinedFilter badges by category:
const filteredBadges = badges.filter(b => b.category === 'milestones')Performance Tips
- Use
v-ifnotv-showfor large grids - Lazy load
AchievementWatcher - Debounce filter changes
- Use virtual scrolling for 100+ badges
- Memoize expensive calculations
Troubleshooting
| Problem | Solution |
|---|---|
| Confetti not showing | Verify canvas-confetti is installed (npm list canvas-confetti). Check browser console for errors. |
| Progress not updating | Verify user has the required data (goals, encouragements, etc.). Check the GraphQL stats query response. |
| Showcase not saving | Check the mutation response in the Network tab. Verify UserDetail model exists with a data JSON field. |
| Toast notifications not styled | Verify AchievementToast.vue's scoped <style> block hasn't been stripped or overridden. |
| Filters not working | Check that all badges have a category field in BadgeCollection.js. |
| Notifications not auto-triggering | Ensure AchievementWatcher is mounted in App.vue. Default polling interval is 10 seconds. |
Migration Notes
- Old
/badgesURL auto-redirects to/achievements - No database migrations required
- Backward compatible with existing code
Last updated: 2026-07-13