Skip to content

Achievements Deep Dive

Note: this page describes an earlier achievements implementation (the AchievementCard / AchievementsGrid / UserRankCard layout, useAchievementNotifications toast + confetti composable, ShowcaseSelector modal). 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.data JSON field.
  • Celebration notifications -- confetti and toast notifications on unlock, with rarity-scaled effects and a queue to prevent spam.
  • Automatic detection -- AchievementWatcher polls 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.rb

Frontend (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 page

Dependencies

  • canvas-confetti -- confetti animations
  • @ionic/vue -- toast notifications
  • @vue/apollo-composable -- GraphQL queries
  • lucide-vue-next -- icons

Database Schema

No dedicated migrations. Showcased achievements are stored in the existing UserDetail.data JSON field:

ruby
# UserDetail.data JSON field structure
{
  "showcasedAchievements": ["complete_first_goal", "visionary", "dedicated"]
}

Badge Definitions & Rarity

Categories

CategoryDescriptionBadge Count
milestonesFirst-time achievements5
goalsGoal-related achievements6
streaksConsistency achievements3
communitySocial achievements3
specialUnique achievements3

Rarity Tiers

RarityCountVisual EffectConfetti ColorsParticlesDurationSpecial
Common5BlueBlue502s--
Rare6Blue/Purple glowBlue/Purple1003s--
Epic4Purple glowPurple1504s--
Legendary5Gold glow + pulseGold2005sStars

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:

typescript
const xp = unlockedCount * 100
const levels = [0, 500, 1000, 2000, 3500, 5500, 8000, 11000, 15000, 20000]
const level = levels.findIndex(threshold => xp < threshold) || levels.length

The UserRankCard component displays the user's current level and rank.


Integration Guide

GraphQL Schema

Types:

graphql
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:

graphql
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 tracking
  • Achievements::UpdateShowcasedAchievements -- interaction for updating showcased badge selections

Component Props

AchievementCard:

typescript
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:

typescript
props: {
  showcasedAchievements: string[] // Array of badge keys
}

ShowcaseSelector:

typescript
props: {
  isOpen: boolean
  badges: Badge[]
  showcasedAchievements: string[]
  userId: string
}

emits: ['close', 'updated']

Composable API -- useAchievementNotifications

typescript
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

typescript
import { calculateAchievementProgress } from '@/services/achievementProgress'

const progress = calculateAchievementProgress(
  badgeKey: string,
  userStats: UserStats,
  signInDates: string[]
)

// Returns: { current, target, percentage, label } | null

Integration Examples

1. Add AchievementWatcher to App.vue for automatic notifications:

vue
<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:

vue
<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:

typescript
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:

typescript
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:

typescript
const toast = await toastController.create({
  duration: 5000, // milliseconds
})

Testing Guide

Running Backend Tests

bash
cd rails_api
bin/rspec spec/services/achievements/ \
          spec/interactions/gamification/achievements/ \
          spec/graphql/mutations/update_showcased_achievements_spec.rb

Expected: 11 examples, 0 failures.

GraphQL Manual Testing

Navigate to http://localhost:3000/graphiql and run these queries:

User Stats:

graphql
query {
  user(id: "YOUR_USER_ID") {
    stats {
      totalGoals
      completedGoals
      goalsCreatedCount
      encouragementsGiven
      milestonesCreated
      milestonesCompleted
      categoriesUsed
      typesUsed
    }
  }
}

Showcased Achievements:

graphql
query {
  user(id: "YOUR_USER_ID") {
    showcasedAchievements
  }
}

Update Showcase:

graphql
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 /badges URL 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:

typescript
const isUnlocked = actions.find(a => a.action === badgeKey) !== undefined

Filter badges by category:

typescript
const filteredBadges = badges.filter(b => b.category === 'milestones')

Performance Tips

  • Use v-if not v-show for large grids
  • Lazy load AchievementWatcher
  • Debounce filter changes
  • Use virtual scrolling for 100+ badges
  • Memoize expensive calculations

Troubleshooting

ProblemSolution
Confetti not showingVerify canvas-confetti is installed (npm list canvas-confetti). Check browser console for errors.
Progress not updatingVerify user has the required data (goals, encouragements, etc.). Check the GraphQL stats query response.
Showcase not savingCheck the mutation response in the Network tab. Verify UserDetail model exists with a data JSON field.
Toast notifications not styledVerify AchievementToast.vue's scoped <style> block hasn't been stripped or overridden.
Filters not workingCheck that all badges have a category field in BadgeCollection.js.
Notifications not auto-triggeringEnsure AchievementWatcher is mounted in App.vue. Default polling interval is 10 seconds.

Migration Notes

  • Old /badges URL auto-redirects to /achievements
  • No database migrations required
  • Backward compatible with existing code

Last updated: 2026-07-13

Loading…