Skip to content

Achievements & Gamification — Feature & Testing Guide

Overview

Objectuve uses a gamification system with badges, XP, levels, and ranks to reward consistent goal progress. Users earn badges by completing specific actions (creating goals, joining communities, maintaining streaks). XP accumulates to advance through levels with named ranks. Users can showcase up to 6 badges on their profile and dashboard. The Achievements page ("The Atelier") displays a curated, journey-themed interface with a hero greeting, XP progress, a showcase row, and a grid of badges organized by category. Real-time notifications alert users when badges are unlocked.

Related guides: Gamification Engine (XP awards, level thresholds, rank progression service) · Notifications (badge unlock and level-up notification delivery) · Celebration Sharing (sharing a badge as an image)


Badge Types

There are 20+ achievement badges organized by trigger condition. Rarity is source-of-truth from ionic_frontend/src/constants/badgeRarity.ts's RARITY_MAP — this table drifted against it on 13 of 21 rows before v4.47 Phase 6 corrected it (voice_heard and community_voice are in RARITY_MAP as common/rare respectively but have no badge entry documented here yet):

Badge KeyNameTriggerRarity
first_sign_inWelcomeFirst successful sign-inCommon
create_first_goalGoal SetterCreate first goalCommon
complete_first_goalFirst WinComplete first goalCommon
complete_fifth_goalGoal CrusherComplete 5 goalsEpic
complete_tenth_goalUnstoppableComplete 10 goalsLegendary
create_second_typeDiverse ThinkerUse 2+ goal typesRare
create_second_categoryExplorerUse 2+ goal categoriesRare
join_first_communityCommunity MemberJoin first communityCommon
founding_memberFounding MemberJoin a community within 30 days of its creation (Community::FOUNDING_WINDOW_DAYS); community-scoped (one per qualifying community)Epic
consistency_is_keyConsistentMaintain a sign-in streakRare
habit_formerHabit FormerBuild a habit streakRare
dedicatedDedicatedExtended sign-in streakEpic
supporterSupporterGive encouragementsRare
super_fanSuper FanGive many encouragementsLegendary
visionaryVisionaryCreate multiple goalsLegendary
achieverAchieverHigh completion rateLegendary
explorerExplorerTry all goal categoriesEpic
innovatorInnovatorUse all goal typesLegendary
plannerPlannerCreate milestonesRare
executorExecutorComplete milestonesEpic
early_birdEarly BirdCheck in earlyCommon

Rarity Levels

RaritySort OrderVisual Treatment
Common4thStandard badge style
Rare3rdHighlighted border
Epic2ndGlow effect
Legendary1stGold shimmer + special animation

XP System

Earning XP

XP is awarded through the GamificationService.award_xp(user, amount, reason) method:

ActionXP Awarded
Habit check-in25 XP
Goal event (progress update)Variable
Badge unlock100 XP per badge
Goal completionVariable

Level Progression

LevelXP ThresholdRank TitleVolume
10Open RoadI
2500BearingsI
31,000Full StrideI
42,000Second WindII
53,500Long HaulII
65,500RidgelineII
78,000FrontierIII
811,000WayfinderIII
915,000Far CountryIII
1020,000HorizonIII
11++5,000 per levelHorizonIII

Volume is a user-facing framing: Vol. I (ranks 1–3) = "Open Road–Full Stride", Vol. II (4–6) = "Second Wind–Ridgeline", Vol. III (7+) = "Frontier–Horizon".

This is the XP rank ladder — distinct from the streak rank ladder (ionic_frontend/src/composables/streakRanks.ts), which keeps its First Stone → Legacy masonry names. The two used to share nine of ten titles until the XP ladder was renamed off the shared vocabulary (OBJ-3024). See docs/architecture/ddd/02-ubiquitous-language.md § Rank (Streak) / Rank (XP).

When XP crosses a threshold, GamificationService.check_for_level_up triggers a CreateLevelUpNotificationJob.


The Atelier — Achievements Hall

Layout

The Achievements page (/achievements) is redesigned as The Atelier: a curated, journey-themed surface.

Populated state (user has unlocked badges):

  1. Hero Card — Personalized greeting (Welcome back, <name>), current volume eyebrow ("The Atelier · Vol. II"), chapter/rank line with narrative ("Bearings · 800 XP from Full Stride"), XP progress bar, right-side stats panel (7-day sparkline, 3 stat boxes, streak counter)
  2. Showcase Row — 6 pinned badges with header "SHOWCASE · 6 PINNED"
  3. Next on the Shelf — Single card with upcoming unlock opportunity (closest-to-completion locked badge by progress %, ties broken by rarity desc then alphabetical)
  4. Recently Unlocked — Horizontal row showing last 5 unlocked badges from the last 30 days
  5. Filter Controls — Category tabs, sort dropdown
  6. Badge Grid — Filterable grid organized by category with section headers ("Milestones X / Y", "Goals Z / W", etc.)

Empty state (user has no unlocked badges):

  1. Hero Card — Same layout, greeting is "Hello, <name>", empty XP bar, locked stats
  2. Begin Here Card — Instead of "Next on the Shelf", shows a call-to-action card with "Begin Here" eyebrow, text "Create a goal to earn your first badge.", and a "Create a goal →" CTA to /goals/create
  3. The Inner Drawer — Instead of "Recently Unlocked", shows "THE INNER DRAWER · FIRST SIX OTHERS BEGIN" — a 6-badge preview row of the lowest-tier unlock paths in muted/locked styling
  4. Rest of page — Same filter and grid structure as populated state

Empty-state predicate: earnedCount, not unlockedCount

unlockedCount counts every badge a user holds, including first_sign_in ("Day One") — a real, designed, common-rarity badge every signed-in user has (Achievements::BadgeCatalog::BADGES, badge_catalog.rb:10), awarded unconditionally on first sign-in by Mutations::SyncUser#resolve before the user has taken any other action. Achievements::ComputeUserRankService#unlocked_count counts against all 21 BadgeCatalog::BADGE_KEYS (compute_user_rank_service.rb:33-38), so unlockedCount >= 1 for every user who can reach this page. It is not a valid empty-state signal, and it never changes meaning — it still drives the badge grid, SideMenu.vue's rank calculation, and share cards exactly as before.

earnedCount counts badges earned through action — every badge except first_sign_in, derived from UserAction::GAMEPLAY_EXEMPT_BADGE_ACTIONS (see gotchas.md § Three disagreeing badge-key lists). This is what gates the empty state above, and the three surfaces it controls:

  • AchievementsHero.vue's isEmpty (the hero greeting and empty-state copy)
  • Achievements.vue's Inner Drawer ("first six" preview row)
  • NextOnTheShelfCard's "Begin Here" call-to-action

A user whose only badge row is first_sign_in reports unlockedCount: 1, earnedCount: 0 and lands on the empty state — Day One still renders unlocked in the badge grid below, since the grid reads unlock status per key, not earnedCount.

Why this matters: before this fix (OBJ-3879), the empty-state predicate was unlockedCount === 0, which — because every user holds first_sign_in — was unreachable for any real user from the badge catalog's creation (3ff0e6260, 2026-04-29) until now. Three designed, shipped surfaces were dead code with no error, caught only because a QA smoke fixture happened to assert the empty state renders. Full incident write-up: gotchas.md § first_sign_in is a real badge every user has.

Filters

FilterOptions
CategoryAll, Milestones, Goals, Streaks, Community, Special
SortRarity (default), Date (newest first), Progress (highest %), Name (A-Z)

(Note: "Custom" category renamed to "Special")

Badge Display

Each badge card shows:

  • Medal icon (achieved or unachieved variant) with rarity ring color
  • Badge name
  • Rarity chip top-right ("COMMON", "RARE", "EPIC", "LEGENDARY")
  • +XP reward line (displays value when unlocked, hidden when locked)
  • Locked state: grayscale with lock icon overlay

Showcased Achievements

Users can select up to 6 badges to display prominently on their profile and dashboard.

Selecting Showcased Badges

  1. Tap Showcase button in the Achievements Hall header (or navigate to showcase modal)
  2. The ShowcaseSelector modal opens
  3. Select/deselect badges from unlocked badges (max 6)
  4. Tap Save
  5. Selected badges appear in the Showcase Row and are visible on public profiles

What Gets Stored

  • showcased_achievements: Array of badge key strings (up to 6) stored on the User model
  • Updated via UPDATE_SHOWCASED_ACHIEVEMENTS mutation
  • Backend interaction enforces cap: Gamification::Achievements::UpdateShowcasedAchievements (line 15)

Badge Unlock Notifications

When a badge is earned:

  1. Backend creates a UserAction record with the badge key
  2. A notification is created
  3. ACTION_UPDATE_SUBSCRIPTION WebSocket pushes the event in real-time
  4. Frontend shows a badge notification overlay
  5. Unacknowledged badges appear in the notifications panel

Acknowledging Badges

  • Tap a badge notification to acknowledge it
  • Uses ACKNOWLEDGE_ACTION_MUTATION
  • Acknowledged badges no longer show in the notification bell count

Sharing a badge

Any earned badge can be shared as an image via the device's own share sheet — from the Hall header (shares the most recently earned badge) or from a badge's own detail modal (shares that specific badge, added by OBJ-3404 so sharing isn't limited to whichever was earned most recently). Both reuse the same useCelebrationShare flow as goal, milestone, and streak sharing elsewhere in the app.

BadgeModal.vue's share control only appears for an earned badge (never a disabled control on a locked one), and only on a host that explicitly opts in via a canShare prop — Achievements.vue does; Dashboard.vue, PublicProfile.vue, and PublicGoal.vue don't, because the latter two render a badge that may not belong to the viewer, and gating on achieved alone would put a share control on a stranger's badge.

Full detail on the sharing kit itself — what gets shared, the Web Share API vs. @capacitor/share decision (CARD-03), and the render pipeline — lives in Celebration Sharing.


Badge Stats

The BADGE_STATS_QUERY returns global unlock percentages for each badge — showing what percentage of all users have unlocked each badge. This is used for the "X% of users unlocked" display on badge details.


UI Components

AchievementsHero (ionic_frontend/src/components/achievements/AchievementsHero.vue)

  • Personalized greeting (populated: "Welcome back, <name>"; empty: "Hello, <name>")
  • Volume eyebrow ("The Atelier · Vol. I / II / III")
  • Chapter/rank narrative line ("Full Stride · 800 XP from Second Wind")
  • XP progress bar with current/next threshold
  • Right-side stats panel: 7-day sparkline, 3 stat boxes (badges, rare count, total XP), streak counter
  • Mobile: collapses sparkline, stats become a 3-up grid

NextOnTheShelfCard (ionic_frontend/src/components/achievements/NextOnTheShelfCard.vue)

  • Populated variant: eyebrow "NEXT ON THE SHELF", single upcoming unlock card
  • Empty variant: eyebrow "BEGIN HERE", call-to-action text, CTA button → /goals/create
  • Badge medal, progress bar, progress label ("X / Y goals")

ShowcaseRow (ionic_frontend/src/components/achievements/ShowcaseRow.vue)

  • Header "SHOWCASE · 6 PINNED"
  • Horizontal scroll on mobile, flex row on desktop
  • Shows all 6 showcase badges with medal + rarity ring

AchievementFilters (ionic_frontend/src/components/achievements/AchievementFilters.vue)

  • Category pill tabs with counts (All (24), Milestones (5), Goals (6), Streaks (4), Community, Special)
  • Sort dropdown (Rarity / Date / Progress / Name)
  • Reads from BadgeCollection for counts and filtering

AchievementCard (ionic_frontend/src/components/achievements/AchievementCard.vue)

  • Medal icon with rarity ring (color varies by rarity)
  • Title
  • Rarity chip top-right ("COMMON", "RARE", "EPIC", "LEGENDARY")
  • +25 XP reward (shown when unlocked, hidden when locked)
  • Locked state: grayscale + lock icon overlay

AchievementsGrid (ionic_frontend/src/components/achievements/AchievementsGrid.vue)

  • Responsive grid (4-column desktop, 2-column mobile)
  • Section header per category ("Milestones X / Y", "View all" link)
  • Click handler for badge details modal

UserRankCard

  • User name display (firstName or "Friend")
  • Level number and rank title (deprecated in favor of AchievementsHero)
  • XP progress bar
  • Gradient background

BadgeModal

  • Large badge icon (achieved/unachieved)
  • Name, description, rarity indicator
  • Unlock condition explanation
  • Progress bar (if locked)
  • Unlock date (if achieved)
  • Global unlock percentage
  • Share control in the header, beside Close — earned badges only, and only on hosts that opt in (see Sharing a badge)

ShowcaseSelector

  • List of unlocked badges with select/deselect
  • Enforces max 6 selection
  • Save button

XpRanksInfoModal

  • Educational modal explaining the XP system and rank progression
  • Opened by tapping the ⓘ icon on the AchievementsHero card (right-aligned in the rank label row)
  • Two sections: "How you earn XP" (5 earning actions) and "The rank ladder" (10 named ranks with thresholds)
  • Current rank is highlighted (gold background + "← you" indicator)
  • Top-rank state (Horizon): shows a message when the user has reached the top rank ("Top of the ladder. The horizon keeps moving — that's the good part.")
  • Bottom-sheet modal on mobile, centered dialog on desktop (≥768px)
  • Dismiss via close button or backdrop tap

XP and Rank Learning

Users can tap the ⓘ icon on the Achievements page hero card to open the XP & Ranks Explanation Modal. This modal provides transparent documentation of:

  1. How XP is earned — All 5 positive XP-earning actions with amounts:

    • Habit check-in: +25 XP
    • Daily streak: +50 XP
    • Partner check-in day: +25 XP each
    • Accept a partnership: +50 XP each
    • Complete a goal: +500 XP
  2. The rank ladder — All 10 named ranks with their XP thresholds:

    • Open Road (0 XP) → Bearings → Full Stride → Second Wind → Long Haul → Ridgeline → Frontier → Wayfinder → Far Country → Horizon (20,000 XP)

The modal highlights the user's current rank in the ladder and shows a "← you" indicator. At max rank (Horizon), a message celebrates the achievement and encourages continued progress.


Testing the Feature

Manual Testing Checklist

1. View Achievements Hall

  • [ ] Navigate to Achievements page
  • [ ] Verify User Rank Card shows level, rank, and XP bar
  • [ ] Verify Achievement Stats shows correct counts
  • [ ] Verify badge grid loads with all badges

2. Badge Filtering

  • [ ] Filter by category (e.g., "Streaks") → verify only streak badges show
  • [ ] Filter by status "Unlocked" → verify only earned badges show
  • [ ] Filter by status "Locked" → verify only unearned badges show
  • [ ] Sort by Rarity → verify unlocked badges first, then by rarity order
  • [ ] Sort by Name → verify alphabetical order

3. Badge Details

  • [ ] Click a badge card → verify BadgeModal opens
  • [ ] Verify modal shows name, description, rarity, unlock condition
  • [ ] For unlocked badges: verify unlock date is shown
  • [ ] For locked badges: verify progress bar shows
  • [ ] Close modal

4. Earn a Badge

  • [ ] Sign in for the first time → verify first_sign_in badge unlocks
  • [ ] Create first goal → verify create_first_goal badge unlocks
  • [ ] Verify notification overlay appears
  • [ ] Verify badge appears in Achievements Hall as unlocked
  • [ ] Verify XP increases (100 XP per badge)

5. Level Progression

  • [ ] Accumulate XP through actions
  • [ ] Verify level increments when XP crosses threshold
  • [ ] Verify rank title updates (e.g., Open Road → Bearings at 500 XP)
  • [ ] Verify XP bar resets to show progress toward next level

6. Showcased Achievements

  • [ ] Tap Showcase button
  • [ ] Select 3 unlocked badges
  • [ ] Save selection
  • [ ] Navigate to Dashboard → verify showcased badges display
  • [ ] Return to Showcase → verify selections persist

7. Real-time Notifications

  • [ ] Complete an action that triggers a badge (e.g., join first community)
  • [ ] Verify badge notification appears in real-time
  • [ ] Verify notification bell count increments
  • [ ] Acknowledge the notification → verify count decrements

8. Badge Stats

  • [ ] Click a badge to view details
  • [ ] Verify "X% of users unlocked" percentage is displayed
  • [ ] Verify percentage is reasonable (common badges higher, legendary lower)

Smoke Tests

Automated smoke test:

  • ionic_frontend/tests/smoke-playwright/specs/gamification/08-achievements.spec.ts — Views the achievements page

Run locally:

bash
cd ionic_frontend
npm run smoke:playwright:staging

Backend Unit Tests

bash
cd rails_api

# Model specs
bundle exec rspec spec/models/user_action_spec.rb

# Service specs
bundle exec rspec spec/services/gamification_service_spec.rb

# Interaction specs
bundle exec rspec spec/interactions/gamification/acknowledge_action_spec.rb

# GraphQL specs
bundle exec rspec spec/requests/mutations/acknowledge_action_spec.rb
bundle exec rspec spec/requests/mutations/update_showcased_achievements_spec.rb

Frontend Unit Tests

bash
cd ionic_frontend

npm run test:unit -- --run -t "Achievements"
npm run test:unit -- --run -t "BadgeModal"
npm run test:unit -- --run -t "UserRankCard"
npm run test:unit -- --run -t "ShowcaseSelector"

GraphQL API Reference

Queries

graphql
query UserQuery($id: ID!) {
  user(id: $id) {
    firstName
    actions {
      id
      action          # Badge key (e.g., "first_sign_in")
      acknowledged     # Boolean
      createdAtTime    # Timestamp
    }
    achievementStats {
      # Server-authoritative rank/XP data (replaces frontend hardcoded ladder)
      unlockedCount           # Int (badges unlocked, including first_sign_in/"Day One")
      earnedCount             # Int (badges unlocked EXCLUDING first_sign_in — gates the empty state; see "Empty-state predicate" above)
      totalCount              # Int (total badges: 20)
      rarePlusCount           # Int (rare + epic + legendary)
      currentXp               # Int (total XP)
      currentRankName         # String (e.g., "Full Stride")
      nextRankName            # String | null (null at max level)
      xpToNextRank            # Int | null (remaining XP to next rank; null at max)
      currentVolume           # Int (1–3, derived from level)
      last7DaysXp             # [Int!]! (7 integers, oldest-first; 100 XP per badge)
    }
    nextOnTheShelfBadge {
      # Closest-to-completion locked badge (populated state)
      key                     # String (badge key)
      progressPercentage      # Int (0–99)
      progressLabel           # String (e.g., "3 / 5 goals")
    }
    recentlyUnlockedBadges(limit: 5, sinceDays: 30) {
      # Last N unlocked badges within the time window
      key                     # String
      unlockedAt              # ISO8601 timestamp
    }
    showcasedAchievements  # Array of badge key strings (up to 6)
    stats {
      totalGoals
      completedGoals
      goalsCreatedCount
      encouragementsGiven
      milestonesCreated
      milestonesCompleted
      categoriesUsed
      typesUsed
    }
  }
}

query BadgeStats {
  badgeStats {
    badgeKey        # e.g., "first_sign_in"
    percentage      # Float (0.0–100.0)
  }
}

Mutations

graphql
mutation AcknowledgeAction($actionId: ID!) {
  acknowledgeAction(actionId: $actionId) {
    action { id acknowledged }
  }
}

mutation UpdateShowcasedAchievements($userId: ID!, $achievementKeys: [String!]!) {
  updateShowcasedAchievements(userId: $userId, achievementKeys: $achievementKeys) {
    success
    errors
  }
}

Delivery mechanism

There is no actionUpdate subscription — new achievements are delivered by polling, not ActionCable. AchievementWatcher.vue runs UserQuery on a 10-second pollInterval and diffs the returned actions array for entries not yet seen. See gamification-engine.md for the full query shape and detail.


  • Bottom-nav label: "Badges" tab renamed to "Achieve" (BottomTabBar.vue line 48)
  • Filter category: "Custom" renamed to "Special" (mapped in BadgeCollection)
  • URL parameter handling: Legacy ?filter=custom redirects to ?filter=special (handled in Achievements.vue)

Rank/XP Computation

  • Server-authoritative: Rank names, XP thresholds, and volume tiers are computed server-side via Achievements::ComputeUserRankService
  • Source of truth: AchievementStats GraphQL field replaces frontend hardcoded rank ladder
  • Frontend fallback: Clients may maintain a fallback ladder for one release (for offline resilience); remove after verification that all clients have updated

Known Behaviors & Edge Cases

  1. Badge creation is idempotent: The UserAction.generate method checks for existing records before creating. A user cannot earn the same badge twice.

  2. XP per badge: Each badge unlock awards exactly 100 XP (1 badge = 100 XP). This is in addition to XP earned from the triggering action.

  3. Level-up notifications: Level-ups are dispatched via CreateLevelUpNotificationJob. If multiple level-ups occur at once (rare, but possible with bulk XP), each triggers a separate notification.

  4. Showcased achievements limit: Enforced to 6 badges. The mutation accepts an array of badge keys and replaces the entire showcase (not append). Backend cap: Gamification::Achievements::UpdateShowcasedAchievements.

  5. Badge stats are global: The BADGE_STATS_QUERY returns unlock percentages across all users. These are calculated on demand, not cached — could be slow with very large user counts.

  6. Rarity sorting: Rarity-sorted badges sort by rarity order: Legendary > Epic > Rare > Common. Unlocked badges appear first when mixed with locked.

  7. Next on the Shelf: Selects the closest-to-completion locked badge by progress percentage. Ties broken by rarity desc, then alphabetical. Returns null if all badges are unlocked.

  8. Recently Unlocked: Defaults to last 5 unlocked badges from the last 30 days. Sorted newest-first.

  9. First sign-in badge: The first_sign_in UserAction (and its badge) is created inside the SyncUser GraphQL mutation (Mutations::SyncUser#resolve), not by the RecordSignIn interaction — RecordSignIn only updates UserSignIn/last-activity tracking and never touches UserAction. This is the only badge that fires on auth-sync — all others require explicit user actions. Because every signed-in user holds it, it counts toward unlockedCount but never toward earnedCount — see Empty-state predicate above.

  10. Volume framing: Dynamically derived from user level (I = 1–3, II = 4–6, III = 7+). No data model change; purely a display-layer concept.

Coming Soon (Out of Scope v1)

  • Print card: Desktop print export deferred to a future follow-up issue. (Social share shipped — see Celebration Sharing Cards, currently at 0% rollout, and per-badge sharing from the badge detail modal, see Sharing a badge.)

Last updated: 2026-09-18 (OBJ-3879: empty state now gates on earnedCount, not unlockedCount — see Empty-state predicate)

Prior update: 2026-09-13 (OBJ-3404: badge detail modal gained a share control — see Sharing a badge)

Prior update: 2026-09-13 (OBJ-3684: corrected "Coming Soon" — share shipped behind celebration_sharing_enabled, print export still deferred)

Prior update: 2026-08-31 (OBJ-3024: XP rank ladder renamed off the streak vocabulary — Open Road → Horizon, thresholds unchanged)

Loading…