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 Key | Name | Trigger | Rarity |
|---|---|---|---|
first_sign_in | Welcome | First successful sign-in | Common |
create_first_goal | Goal Setter | Create first goal | Common |
complete_first_goal | First Win | Complete first goal | Common |
complete_fifth_goal | Goal Crusher | Complete 5 goals | Epic |
complete_tenth_goal | Unstoppable | Complete 10 goals | Legendary |
create_second_type | Diverse Thinker | Use 2+ goal types | Rare |
create_second_category | Explorer | Use 2+ goal categories | Rare |
join_first_community | Community Member | Join first community | Common |
founding_member | Founding Member | Join a community within 30 days of its creation (Community::FOUNDING_WINDOW_DAYS); community-scoped (one per qualifying community) | Epic |
consistency_is_key | Consistent | Maintain a sign-in streak | Rare |
habit_former | Habit Former | Build a habit streak | Rare |
dedicated | Dedicated | Extended sign-in streak | Epic |
supporter | Supporter | Give encouragements | Rare |
super_fan | Super Fan | Give many encouragements | Legendary |
visionary | Visionary | Create multiple goals | Legendary |
achiever | Achiever | High completion rate | Legendary |
explorer | Explorer | Try all goal categories | Epic |
innovator | Innovator | Use all goal types | Legendary |
planner | Planner | Create milestones | Rare |
executor | Executor | Complete milestones | Epic |
early_bird | Early Bird | Check in early | Common |
Rarity Levels
| Rarity | Sort Order | Visual Treatment |
|---|---|---|
| Common | 4th | Standard badge style |
| Rare | 3rd | Highlighted border |
| Epic | 2nd | Glow effect |
| Legendary | 1st | Gold shimmer + special animation |
XP System
Earning XP
XP is awarded through the GamificationService.award_xp(user, amount, reason) method:
| Action | XP Awarded |
|---|---|
| Habit check-in | 25 XP |
| Goal event (progress update) | Variable |
| Badge unlock | 100 XP per badge |
| Goal completion | Variable |
Level Progression
| Level | XP Threshold | Rank Title | Volume |
|---|---|---|---|
| 1 | 0 | Open Road | I |
| 2 | 500 | Bearings | I |
| 3 | 1,000 | Full Stride | I |
| 4 | 2,000 | Second Wind | II |
| 5 | 3,500 | Long Haul | II |
| 6 | 5,500 | Ridgeline | II |
| 7 | 8,000 | Frontier | III |
| 8 | 11,000 | Wayfinder | III |
| 9 | 15,000 | Far Country | III |
| 10 | 20,000 | Horizon | III |
| 11+ | +5,000 per level | Horizon | III |
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):
- 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) - Showcase Row — 6 pinned badges with header "SHOWCASE · 6 PINNED"
- Next on the Shelf — Single card with upcoming unlock opportunity (closest-to-completion locked badge by progress %, ties broken by rarity desc then alphabetical)
- Recently Unlocked — Horizontal row showing last 5 unlocked badges from the last 30 days
- Filter Controls — Category tabs, sort dropdown
- Badge Grid — Filterable grid organized by category with section headers ("Milestones X / Y", "Goals Z / W", etc.)
Empty state (user has no unlocked badges):
- Hero Card — Same layout, greeting is
"Hello, <name>", empty XP bar, locked stats - 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 - 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
- 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'sisEmpty(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 holdsfirst_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_inis a real badge every user has.
Filters
| Filter | Options |
|---|---|
| Category | All, Milestones, Goals, Streaks, Community, Special |
| Sort | Rarity (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")
+XPreward 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
- Tap Showcase button in the Achievements Hall header (or navigate to showcase modal)
- The ShowcaseSelector modal opens
- Select/deselect badges from unlocked badges (max 6)
- Tap Save
- 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_ACHIEVEMENTSmutation - Backend interaction enforces cap:
Gamification::Achievements::UpdateShowcasedAchievements(line 15)
Badge Unlock Notifications
When a badge is earned:
- Backend creates a
UserActionrecord with the badge key - A notification is created
ACTION_UPDATE_SUBSCRIPTIONWebSocket pushes the event in real-time- Frontend shows a badge notification overlay
- 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
BadgeCollectionfor 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 XPreward (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:
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
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_inbadge unlocks - [ ] Create first goal → verify
create_first_goalbadge 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:
cd ionic_frontend
npm run smoke:playwright:stagingBackend Unit Tests
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.rbFrontend Unit Tests
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
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
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.
Navigation & UI Changes
- Bottom-nav label: "Badges" tab renamed to "Achieve" (
BottomTabBar.vueline 48) - Filter category: "Custom" renamed to "Special" (mapped in
BadgeCollection) - URL parameter handling: Legacy
?filter=customredirects to?filter=special(handled inAchievements.vue)
Rank/XP Computation
- Server-authoritative: Rank names, XP thresholds, and volume tiers are computed server-side via
Achievements::ComputeUserRankService - Source of truth:
AchievementStatsGraphQL 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
Badge creation is idempotent: The
UserAction.generatemethod checks for existing records before creating. A user cannot earn the same badge twice.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.
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.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.Badge stats are global: The
BADGE_STATS_QUERYreturns unlock percentages across all users. These are calculated on demand, not cached — could be slow with very large user counts.Rarity sorting: Rarity-sorted badges sort by rarity order: Legendary > Epic > Rare > Common. Unlocked badges appear first when mixed with locked.
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.
Recently Unlocked: Defaults to last 5 unlocked badges from the last 30 days. Sorted newest-first.
First sign-in badge: The
first_sign_inUserAction(and its badge) is created inside theSyncUserGraphQL mutation (Mutations::SyncUser#resolve), not by theRecordSignIninteraction —RecordSignInonly updatesUserSignIn/last-activity tracking and never touchesUserAction. 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 towardunlockedCountbut never towardearnedCount— see Empty-state predicate above.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)